@homie-rocks/studio 0.40.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/README.md +32 -9
- package/app-starters/welcome/app.json +8 -1
- package/app-starters/welcome/map/main.json +1 -0
- package/app-starters/welcome/src/move.ts +3 -0
- package/app-starters/welcome/src/rules.ts +21 -0
- package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
- package/bin/homie-studio.mjs +11 -3
- package/functions/FUNCTIONS.md +99 -0
- package/functions/functions.ts +13 -0
- package/lib/build.mjs +32 -12
- package/lib/cloudflare.mjs +9 -9
- package/lib/codex.mjs +1 -1
- package/lib/dev.mjs +2 -1
- package/lib/functions-build.mjs +36 -0
- package/lib/functions-cli.mjs +23 -0
- package/lib/mcp-tools.mjs +10 -7
- package/lib/mcp.mjs +2 -1
- package/lib/port-check.mjs +7 -4
- package/lib/port.mjs +2 -2
- package/lib/purchase-links.mjs +8 -0
- package/lib/rules-build.mjs +6 -6
- package/lib/rules-guard.mjs +9 -3
- package/lib/scaffold.mjs +58 -15
- package/lib/shop.mjs +1 -1
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +148 -1
- package/lib/tools-build.mjs +2 -0
- package/lib/trailer.mjs +7 -3
- package/lib/typecheck.mjs +10 -8
- package/lib/upgrade.mjs +2 -0
- package/lib/worker-build.mjs +8 -20
- package/lib/worker-config.mjs +28 -0
- package/netplay/NETPLAY.md +8 -0
- package/package.json +7 -2
- package/rules/host.ts +2 -2
- package/rules/view.ts +1 -0
- package/shop/SHOP.md +4 -3
- package/starters/coin-dash/src/move.ts +2 -0
- package/starters/coin-dash/src/rules.ts +2 -0
- package/starters/coin-dash/src/view.ts +2 -0
- package/starters/ember-vale/src/move.ts +2 -0
- package/starters/ember-vale/src/rules.ts +2 -0
- package/starters/ember-vale/src/view.ts +2 -0
- package/starters/gem-rush/src/move.ts +2 -0
- package/starters/gem-rush/src/rules.ts +2 -0
- package/starters/gem-rush/src/view.ts +2 -0
- package/starters/gem-rush-3d/src/move.ts +2 -0
- package/starters/gem-rush-3d/src/rules.ts +2 -0
- package/starters/gem-rush-3d/src/view.ts +2 -0
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/starters/hero-rush-3d/src/rules.ts +2 -0
- package/starters/hero-rush-3d/src/view.ts +2 -0
- package/tools/TOOLS.md +42 -2
- package/tools/tools.ts +3 -0
- package/worker/customer-mcp.mjs +77 -0
- package/worker/customer-resources.mjs +53 -0
- package/worker/customer-shop.mjs +54 -0
- package/worker/extensions.mjs +1 -0
- package/worker/functions-schema.mjs +55 -0
- package/worker/functions.mjs +93 -0
- package/worker/index.mjs +37 -2
- package/worker/machine-payments.mjs +1 -1
- package/worker/mcp-store.mjs +3 -2
- package/worker/mcp-tools.mjs +16 -4
- package/worker/mcp.mjs +31 -15
- package/worker/office-page.mjs +1 -1
- package/worker/players.mjs +5 -2
- package/worker/purchase-delivery.mjs +1 -0
- package/worker/purchase-hosted-readiness.mjs +0 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-machine.mjs +1 -0
- package/worker/purchase-office.mjs +1 -1
- package/worker/purchase-owner.mjs +0 -2
- package/worker/purchase-reconciliation.mjs +1 -0
- package/worker/purchase-routes.mjs +11 -17
- package/worker/purchase-store.mjs +1 -0
- package/worker/selling/runtime.mjs +4 -1
- package/worker/service-run.mjs +25 -0
- package/worker/service-terms.mjs +2 -0
- package/worker/shop-rules.mjs +9 -8
- package/worker/shop.mjs +22 -9
- package/worker/tool-webhook.mjs +1 -0
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,62 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.43.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.43.0**
|
|
23
|
+
|
|
24
|
+
Ask for a game and its rules run on your server.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- The game skill teaches the whole rules-and-view workflow: shared truth, immediate movement, levels, companions, saving and the checks that prove the requested game plays. Your AI chooses the engineering defaults.
|
|
29
|
+
- A guided rewrite keeps an older game's look, controls and feel while moving its rules to the server. It preserves the studio's other games and existing tests; there is no automatic converter.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Apps that need a shared live screen now start with the same server rooms. A quiz, queue or turn-based app gets real actions and shared state without pretend movement or compulsory game rounds.
|
|
34
|
+
- Playtests explain stationary controls and intentional freezes. Performance reports measure both players when the server hosts. Covers and trailers use a live rules room and explain when an empty spectator room is paused.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Builds explain the exact script tag a game page needs, check rules types once, and keep guarded builds working through a temporary-directory alias.
|
|
39
|
+
- Builds refuse a game with neither rules plus view nor a browser-hosted netplay contract, instead of silently calling an unconnected page multiplayer. Existing netplay games keep their compatibility path.
|
|
40
|
+
|
|
41
|
+
### Upgrade notes
|
|
42
|
+
|
|
43
|
+
- Existing games keep working as they are. A rewrite happens only when requested. Building a game does not ask for publication or deploy it.
|
|
44
|
+
- Rooms still hold at most 32 seats. Larger rooms are a later milestone; Cloudflare capacity and billing remain unmeasured.
|
|
45
|
+
|
|
46
|
+
## [0.42.0] - 2026-10-09
|
|
47
|
+
|
|
48
|
+
**Plugin 0.42.0**
|
|
49
|
+
|
|
50
|
+
Customers and other businesses can use a studio's tools, buy its goods and services, and trigger its own functions.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- Customer catalogue, cart, checkout and agent purchases through the studio MCP server, using the existing payment standards and purchase lifecycle.
|
|
55
|
+
- Priced studio tools and configurable built-in audiences, with current permissions checked when listing and calling.
|
|
56
|
+
- Studio functions: plain-file event handlers, local firing, durable retries and office delivery status.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- MCP call rates and shop purchase/guest-buyer rates are opt-in.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- Claim refunds use the same tip, used-item and window policy as the web shop. Guest carts create usable accounts before payment.
|
|
65
|
+
- Builds fully regenerate Worker configuration, remove stale generated bindings and the old default purchase limiter, and keep explicit additions in `wrangler.custom.json`.
|
|
66
|
+
- New functions skip history unless explicitly replayed; indexed subscriptions and cursors avoid unused events and prune completed delivery pages.
|
|
67
|
+
- Paid tools renew execution leases, return saved results on retries and automatically refund failed handlers. Unpaid quotes expire and are pruned.
|
|
68
|
+
|
|
69
|
+
### Upgrade notes
|
|
70
|
+
|
|
71
|
+
- Run the toolkit upgrade and build, then commit the generated Worker configuration before deploying. Move hand-added Wrangler settings to root `wrangler.custom.json`; apply migrations 0018 and 0019 with the other studio migrations. Functions add a Cloudflare cron for retries only when declared.
|
|
72
|
+
|
|
20
73
|
## [0.40.0] - 2026-10-09
|
|
21
74
|
|
|
22
|
-
**Plugin 0.40.0**
|
|
75
|
+
**Plugin 0.40.0** · [#79](https://github.com/homie-rocks/homie/pull/79)
|
|
23
76
|
|
|
24
77
|
New 3D games keep their matches on the server and answer your controls immediately.
|
|
25
78
|
|
package/README.md
CHANGED
|
@@ -38,12 +38,31 @@ A new studio has no game: its home page says "First game coming soon" until the
|
|
|
38
38
|
`homie-studio demo` names a live game on Homie Arcade to try meanwhile. A starter is copied in only when the person
|
|
39
39
|
asks (`game new <id> --from gem-rush`, or `--from ember-vale` for a hero who lasts, with cloud saves).
|
|
40
40
|
|
|
41
|
+
New games are rules plus view: `src/rules.ts` declares state and server decisions,
|
|
42
|
+
`src/move.ts` supplies shared movement and local prediction, and `src/view.ts` draws
|
|
43
|
+
and sends input through `openRoom`. All five starters declare `room.host: server`.
|
|
44
|
+
Follow the game skill and `RULES.md`; `build` checks rules, movement and view types
|
|
45
|
+
and refuses a game with neither rules nor a browser-hosted netplay contract.
|
|
46
|
+
The same rules support browser hosting for offline, local and private play.
|
|
47
|
+
Existing browser-hosted games remain supported; rewriting one is an explicit task.
|
|
48
|
+
|
|
41
49
|
## In one chat: `homie-studio mcp`
|
|
42
50
|
|
|
43
51
|
```sh
|
|
44
52
|
npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" # the toolkit as a local MCP server (stdio)
|
|
45
53
|
```
|
|
46
54
|
|
|
55
|
+
Every studio also has its own customer-facing `/mcp`. Customers can browse, quote a cart,
|
|
56
|
+
and pay through the same MPP/x402 purchase engine used for paid parts, or open the studio's
|
|
57
|
+
Stripe checkout for human approval. Studio tools choose `public`, `signed-in`, an app staff
|
|
58
|
+
role, or `owner`; a tool can sell its service with `price`. See [Studio tools](tools/TOOLS.md).
|
|
59
|
+
|
|
60
|
+
[Studio functions](functions/FUNCTIONS.md) run your code when an order is paid or refunded,
|
|
61
|
+
a player joins, a room closes, an app record changes, a tool is called, or a schedule fires.
|
|
62
|
+
Add one with `homie-studio function new paid-order --event order.paid`; fire an event locally
|
|
63
|
+
with `homie-studio function fire order.paid --input '{"order":"example"}'`. Deliveries persist,
|
|
64
|
+
retry with the same event ID, and show failures in the office. Policies and rate limits are opt-in.
|
|
65
|
+
|
|
47
66
|
The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
|
|
48
67
|
the live demo, make / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
|
|
49
68
|
playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
|
|
@@ -132,7 +151,7 @@ Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<
|
|
|
132
151
|
Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
|
|
133
152
|
with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
|
|
134
153
|
player's camera and HUD from `net.viewSeat` (Gem Rush and Ember Vale do; a game that does not is watched as its
|
|
135
|
-
overview), and game.json `"watch": "overview"` or `false`
|
|
154
|
+
overview), and game.json `"watch": "overview"` or `false` changes the watch UI. Rules state is public to replicas; these settings do not secure hidden hands. Private and invite-only games
|
|
136
155
|
are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
|
|
137
156
|
|
|
138
157
|
## Upgrading a studio
|
|
@@ -187,12 +206,16 @@ game its studio chooses to share, each with its own licence ("Game parts", below
|
|
|
187
206
|
Every room of a game has the seats its netplay manifest names: game.json's `netplay`
|
|
188
207
|
block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
|
|
189
208
|
(`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
|
|
190
|
-
Bots
|
|
191
|
-
|
|
209
|
+
Bots and a late joiner taking a bot's body work the same at 32 as at 4.
|
|
210
|
+
Server rules keep running when a browser leaves; older browser-hosted games elect a new host. One address may hold every seat plus four sockets, so a party
|
|
192
211
|
on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
|
|
193
212
|
|
|
194
213
|
## A game's netplay manifest
|
|
195
214
|
|
|
215
|
+
For rules games the build derives the revision from the rules and view; do not bump
|
|
216
|
+
`netplay.version` or tune browser host election. `room` sets hosting and tick rate.
|
|
217
|
+
The version and stall settings below describe existing browser-hosted games.
|
|
218
|
+
|
|
196
219
|
game.json `"netplay"` says more than the room's size. All of it is optional, and `homie-studio build`
|
|
197
220
|
writes it to the catalogue for the site's rooms (`netplay/NETPLAY.md` sections 22 to 24):
|
|
198
221
|
|
|
@@ -353,7 +376,8 @@ npx homie-studio agents pass night-rush --label Claude
|
|
|
353
376
|
account) or invite (`office invite <id> --server <server>`).
|
|
354
377
|
- **AI is always marked AI**, by the room's relay, not the game: every agent is named "<label> · AI", and the
|
|
355
378
|
relay rewrites a host's roster and results so a game cannot hide one.
|
|
356
|
-
- **
|
|
379
|
+
- **Rules authoring:** use `world.level`, `world.guideLevel`, `world.guideSeats` and `world.kids`; declare `guide.view`, `guide.floor`, `think` and `world.goalDone` for companions, and `asks` with `world.ask` / `on.answer` for decisions. The following `net.skillOf`, `BotBrain`, `useAgents` and `net.decide` recipes apply to existing browser-hosted games.
|
|
380
|
+
- **The skill dial (browser-hosted games):** 1 Rookie to 5 Maxed (`{ reactionMs, aimNoise, aggression, positioning }`). The party votes it
|
|
357
381
|
on a card in the play page (the middle vote wins); `net.skillOf(slot)` is what a bot reads (`NETPLAY.md` section
|
|
358
382
|
17), and `BotBrain` reads it with a rebuild. In the starters, Gem Rush's Rookie bots collect far fewer gems than
|
|
359
383
|
its Maxed ones.
|
|
@@ -361,7 +385,7 @@ npx homie-studio agents pass night-rush --label Claude
|
|
|
361
385
|
(humans-only, a stricter door), closing it, removing a member, and the first time AI guides may talk are asked
|
|
362
386
|
for, and the owner confirms with one tap.
|
|
363
387
|
- **AI guides that talk** (0.17.0): a game's `agents.json` is its guides' vocabulary (goals, lines, the asks a
|
|
364
|
-
player taps), and `useAgents` from `@homie-rocks/studio/agents` its host side (Ember Vale is the reference). The
|
|
388
|
+
player taps), and `useAgents` from `@homie-rocks/studio/agents` its browser-host side (the pre-rules Ember Vale is the reference). The
|
|
365
389
|
brain is the server's: `agents brain night-rush first-light workers-ai` (the studio's own Workers AI; deploy binds
|
|
366
390
|
it; 8,000 neurons a day by default) or `owner-key` (the owner's own key, `agents brain key`, capped in dollars a
|
|
367
391
|
day); with none, the game's scripted floor. The local MCP's `agent_sit` puts the owner's own Claude in a guide's
|
|
@@ -379,7 +403,7 @@ npx homie-studio agents pass night-rush --label Claude
|
|
|
379
403
|
- **A game's own decisions** (0.24.4, NETPLAY.md section 20): `net.decide(state, questions, { floor })` on the host,
|
|
380
404
|
for a game whose game.json says `"decide": true`: tactics for its opponents, an NPC's reaction, a director's call,
|
|
381
405
|
a turn's move, answered by Clef in the room in about a quarter of a second, per beat (never per frame), within the
|
|
382
|
-
AI brains' day; the floor answers whenever it cannot. Ember Vale
|
|
406
|
+
AI brains' day; the floor answers whenever it cannot. Rules-based Ember Vale declares asks instead (opt-in).
|
|
383
407
|
- Everything is in the studio's own Worker and D1 (migration `0006_studio_servers.sql`). The site's side is in
|
|
384
408
|
[site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17, 18 and 20.
|
|
385
409
|
|
|
@@ -734,11 +758,10 @@ Web Workers, a live server) and what a game must do for its sound to be in the f
|
|
|
734
758
|
```sh
|
|
735
759
|
npx homie-studio perf crown-thief --url http://127.0.0.1:8787 [--device computer,phone] [--runs 3] [--profile]
|
|
736
760
|
npx homie-studio perf sizes crown-thief # every built file, raw and gzipped; with build --maps, the bundle's modules
|
|
737
|
-
npx homie-studio perf compare .perf/crown-thief/<before> .perf/crown-thief/<after> --goal phone.
|
|
761
|
+
npx homie-studio perf compare .perf/crown-thief/<before> .perf/crown-thief/<after> --goal phone.replica.frame.p95
|
|
738
762
|
```
|
|
739
763
|
|
|
740
|
-
One run is two headless Chromes on this computer's GPU in a fresh room of their own (`?room=perf-…`):
|
|
741
|
-
rules, the bots, the snapshots) and a replica, both a computer (1280x800 at 2x) or both an emulated phone (390x844 at
|
|
764
|
+
One run is two headless Chromes on this computer's GPU in a fresh room of their own (`?room=perf-…`): two replicas for server rules, or a host and replica for older browser-hosted games, both a computer (1280x800 at 2x) or both an emulated phone (390x844 at
|
|
742
765
|
3x, touch, Chrome's CPU throttle at 4x with `--cpu`, 4G; each run records the slow-down the throttle really gave, which
|
|
743
766
|
on a fast computer is less than its rate). They play the same seeded presses through a warm-up and a measured
|
|
744
767
|
window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "welcome",
|
|
3
3
|
"name": "Welcome together",
|
|
4
4
|
"blurb": "A little place to gather. Join the queue, keep your ticket, and see when it is your turn.",
|
|
5
|
-
"entry": "src/
|
|
5
|
+
"entry": "src/view.ts",
|
|
6
6
|
"roles": {
|
|
7
7
|
"customer": {
|
|
8
8
|
"signIn": false,
|
|
@@ -102,5 +102,12 @@
|
|
|
102
102
|
"phone": "Join once and keep your ticket.",
|
|
103
103
|
"computer": "Staff call the next person from their signed-in tablet."
|
|
104
104
|
}
|
|
105
|
+
},
|
|
106
|
+
"room": {
|
|
107
|
+
"host": "server"
|
|
108
|
+
},
|
|
109
|
+
"players": {
|
|
110
|
+
"min": 1,
|
|
111
|
+
"max": 32
|
|
105
112
|
}
|
|
106
113
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bounds":{"min":[-1,-1],"max":[1,1]},"boxes":[],"spots":{"start":[[0,0]]}}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Transient scene truth lives in the room; authorized queue records live in AppRecords.
|
|
2
|
+
// A phone has a logical stationary seat anchor. The wall watches without a seat.
|
|
3
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
4
|
+
import { move } from './move';
|
|
5
|
+
export default defineRules({
|
|
6
|
+
contract: 2, space: { dims: 2 }, move,
|
|
7
|
+
shared: { revision: f.u32() },
|
|
8
|
+
shapes: { events: { refresh: {} }, commands: { refresh: {} }, effects: { refresh: {} } },
|
|
9
|
+
entities: {
|
|
10
|
+
participant: {
|
|
11
|
+
player: true,
|
|
12
|
+
body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
|
|
13
|
+
commands: { refresh(world, self) { world.sendRoom('refresh', {}); } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
room: {
|
|
17
|
+
rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
|
|
18
|
+
join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
|
|
19
|
+
on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { damp1 } from '@homie-rocks/camera/spring.js';
|
|
3
3
|
import { chamferBox } from '@homie-rocks/geom/chamfer.js';
|
|
4
|
-
import {
|
|
4
|
+
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
5
|
+
import type rules from './rules';
|
|
5
6
|
import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
|
|
6
7
|
import { appLink, qrSvg, randomId } from '@homie-rocks/studio/links';
|
|
7
8
|
|
|
8
9
|
type Ticket = { label: string; status: string };
|
|
9
|
-
const
|
|
10
|
+
const room = openRoom<typeof rules>();
|
|
11
|
+
const net = room.net;
|
|
10
12
|
const role = net.params.role ?? 'customer';
|
|
11
13
|
document.body.className = role;
|
|
12
14
|
const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
|
|
@@ -42,7 +44,7 @@ async function refresh() {
|
|
|
42
44
|
}
|
|
43
45
|
async function action(fn: () => Promise<unknown>) {
|
|
44
46
|
pending = true; paint();
|
|
45
|
-
try { await fn();
|
|
47
|
+
try { await fn(); room.command('refresh', {}); await refresh(); }
|
|
46
48
|
catch (e) { $('#status').textContent = (e as Error).message; }
|
|
47
49
|
finally { pending = false; paint(); }
|
|
48
50
|
}
|
|
@@ -61,10 +63,9 @@ next.onclick = () => action(async () => {
|
|
|
61
63
|
if (first) await records.update(first, { ...first.data, status: 'called' });
|
|
62
64
|
});
|
|
63
65
|
// The room carries scene state and change notices; records always come from the authorized database.
|
|
64
|
-
|
|
65
|
-
net.on('snapshot', (s) => { light = s.d.light; });
|
|
66
|
+
room.on('refresh', () => { void refresh(); });
|
|
66
67
|
net.on('link', () => { void refresh(); });
|
|
67
|
-
setInterval(() => {
|
|
68
|
+
setInterval(() => { $('#connection').textContent = net.link === 'online' ? 'Here together · live' : 'Reconnecting…'; }, 500);
|
|
68
69
|
setInterval(() => void refresh(), 2000); // recover a missed notice; no dependency on a trusted browser host
|
|
69
70
|
void refresh(); paint();
|
|
70
71
|
|
|
@@ -100,6 +101,7 @@ function draw(now: number) {
|
|
|
100
101
|
const phone = innerWidth < 600;
|
|
101
102
|
camera.position.set(phone ? 7 : 6, phone ? 6 : 5, distance); camera.lookAt(phone ? 1 : -.4, .6, 0);
|
|
102
103
|
stage.position.x = phone ? 1.8 : 2.4;
|
|
104
|
+
light = net.now() / 1000;
|
|
103
105
|
stage.rotation.y = reduced.matches ? 0 : Math.sin(light * .12) * .09;
|
|
104
106
|
lanterns.forEach((g, i) => { const target = i < waiting ? 1 : .28; g.scale.y += (target - g.scale.y) * (1 - Math.exp(-dt * 5)); g.position.y = .18 + (reduced.matches ? 0 : Math.sin(now / 1000 + i) * .05); });
|
|
105
107
|
renderer.render(scene, camera);
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
* emulated (a --cpu times slower CPU, 4G) on this computer's GPU. The plugin's perf
|
|
58
58
|
* skill runs the whole measure, change, compare, keep-or-revert loop)
|
|
59
59
|
* homie-studio trailer <id> [--url <site>] [--seconds 40] [--length 20] [--title "…"] [--end "…"] [--skills <folder>]
|
|
60
|
-
* (a trailer in one command, by the plugin's video skill:
|
|
61
|
-
*
|
|
60
|
+
* (a trailer in one command, by the plugin's video skill: live footage for server rules,
|
|
61
|
+
* or a virtual clock and rebuilt sound for older browser games,
|
|
62
62
|
* the highlights picked, an end card, 16:9, 1:1 and 9:16 in videos/<id>-trailer/)
|
|
63
63
|
* homie-studio perf sizes <id> (what a player downloads: every built file, raw and gzipped, the biggest first; with
|
|
64
64
|
* build --maps, which modules make up the bundle; each big script read for whether it
|
|
@@ -155,6 +155,9 @@
|
|
|
155
155
|
* homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
|
|
156
156
|
* [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
|
|
157
157
|
* [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
|
|
158
|
+
* homie-studio function new <name> [--event order.paid]
|
|
159
|
+
* homie-studio function replay <name> --replay [--url <studio-site>]
|
|
160
|
+
* homie-studio function fire <event> [--input <JSON>] [--id <event-id>] [--url <local-site>]
|
|
158
161
|
* homie-studio tool new <name> [--app <id>]
|
|
159
162
|
* homie-studio tool call <name> --input <JSON> [--url <site>] [--public]
|
|
160
163
|
* homie-studio servers set <game> <server> [the same flags]
|
|
@@ -409,7 +412,7 @@ function print(result) {
|
|
|
409
412
|
lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
|
|
410
413
|
break;
|
|
411
414
|
case 'game new':
|
|
412
|
-
lines.push(`games/${result.id} is a new game from the ${result.from} starter.
|
|
415
|
+
lines.push(`games/${result.id} is a new game from the ${result.from} starter. Its server rules are src/rules.ts, shared movement src/move.ts, and browser view src/view.ts. Read the game skill and RULES.md, change the mechanic, then build and run: npx --no-install homie-studio dev`);
|
|
413
416
|
if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
|
|
414
417
|
for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
|
|
415
418
|
if (result.models) {
|
|
@@ -489,9 +492,12 @@ function print(result) {
|
|
|
489
492
|
lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
|
|
490
493
|
break;
|
|
491
494
|
}
|
|
495
|
+
case 'function new':
|
|
492
496
|
case 'tool new':
|
|
493
497
|
lines.push(result.file, result.next);
|
|
494
498
|
break;
|
|
499
|
+
case 'function replay':
|
|
500
|
+
case 'function fire':
|
|
495
501
|
case 'tool call':
|
|
496
502
|
case 'tool list':
|
|
497
503
|
lines.push(JSON.stringify(result.result, null, 2));
|
|
@@ -833,6 +839,7 @@ const OUTWARD_FLAGS = {
|
|
|
833
839
|
'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
|
|
834
840
|
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
835
841
|
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
842
|
+
'function replay': ['url','replay'], 'function new': ['event'], 'function fire': ['url','input','id'],
|
|
836
843
|
'tool new': ['app'], 'tool call': ['url', 'input', 'public'], 'tool list': ['url', 'public'],
|
|
837
844
|
// With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
|
|
838
845
|
'standalone run': ['for', 'device', 'site'],
|
|
@@ -894,6 +901,7 @@ async function main() {
|
|
|
894
901
|
// The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
|
|
895
902
|
if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
|
|
896
903
|
const root = requireStudio();
|
|
904
|
+
if (cmd === 'function') return (await import('../lib/functions-cli.mjs')).functionCommand(root, sub, positional, flags, { log });
|
|
897
905
|
if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
|
|
898
906
|
// A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
|
|
899
907
|
if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Studio functions: when this happens, run that
|
|
2
|
+
|
|
3
|
+
A function is your trusted code in your own Worker. Run:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
homie-studio function new prepare-order --event order.paid
|
|
7
|
+
homie-studio dev
|
|
8
|
+
homie-studio function fire order.paid --id local-order-1 --input '{"order":"example"}'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The fire command connects to the local studio through the existing browser OAuth
|
|
12
|
+
approval. It is an owner tool and refuses remote hosts. It records an event in the
|
|
13
|
+
same durable dispatcher as real events; firing the same ID again is a duplicate.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { defineFunction } from '@homie-rocks/studio/functions';
|
|
17
|
+
export default defineFunction<{ order: string }>({
|
|
18
|
+
name: 'prepare-order',
|
|
19
|
+
event: 'order.paid',
|
|
20
|
+
async handler(event, context) {
|
|
21
|
+
// For an external API, send event.id as its idempotency key.
|
|
22
|
+
await context.database.put(event.id, { order: event.data.order });
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Build discovers and type-checks `functions/*.ts`. Handlers have the studio tool
|
|
28
|
+
context and run with the studio's authority. They can change records, send a
|
|
29
|
+
room event through declared capabilities, call an external service, or implement
|
|
30
|
+
another business workflow. Treat event data as data, not instructions.
|
|
31
|
+
|
|
32
|
+
| Event | Data |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `order.paid` | `order`, `source`, `amount`, `currency`, `mode` |
|
|
35
|
+
| `payment.refunded` | `refund`, `payment`, `amount`, `currency` (each successful Stripe refund, including partial refunds) |
|
|
36
|
+
| `order.refunded` | `order`, `source`, `refund`, `mode` (full refund) |
|
|
37
|
+
| `player.joined` | `game`, `room`, `player` (nullable), `seat` |
|
|
38
|
+
| `room.closed` | `game`, `room`, closing/ending facts |
|
|
39
|
+
| `record.changed` | `app`, `collection`, `record`, `version`, `operation`, `data` |
|
|
40
|
+
| `tool.called` | `tool`, `person`, `client` (successful call, no arguments) |
|
|
41
|
+
|
|
42
|
+
For a scheduled function, add `schedule: '0 9 * * *'` and a chosen event name,
|
|
43
|
+
for example `event: 'morning.open'`. Schedules use Cloudflare cron syntax and UTC.
|
|
44
|
+
The event includes the function name, cron and scheduled time. Its ID remains the
|
|
45
|
+
same if Cloudflare delivers the same tick again. Only that scheduled function
|
|
46
|
+
receives its tick. Build reads custom crons from `wrangler.custom.json` and adds a minute tick
|
|
47
|
+
for pending delivery retries while functions exist. Commit build changes before
|
|
48
|
+
deploying. Cloudflare's own plan limits apply; Homie sets no attempt or event cap.
|
|
49
|
+
|
|
50
|
+
Order, refund, record and tool events use database triggers: the source change
|
|
51
|
+
and event either both commit or both roll back. Room events first enter the room's
|
|
52
|
+
durable SQLite outbox; its alarm retries transfer to D1. Delivery is **at least
|
|
53
|
+
once**, including after a worker dies between an external effect and its local
|
|
54
|
+
acknowledgement. `event.id` is the de-duplication key and `event.attempt` is the
|
|
55
|
+
attempt number. Completed deliveries do not run again. Failed/crashed deliveries
|
|
56
|
+
retry after a one-minute lease. Make effects idempotent; a database marker written
|
|
57
|
+
before an external request is not proof that the external request completed.
|
|
58
|
+
|
|
59
|
+
Open **Office → Functions** for event IDs, states, attempt counts and failures.
|
|
60
|
+
Use its Retry button, or an authenticated same-origin POST to `/_studio/office/functions` with
|
|
61
|
+
`{"retry":"event-id"}` retries a failed event immediately. Failure text is generic
|
|
62
|
+
so a thrown credential is never rendered in the office. Inspect the function's
|
|
63
|
+
code and the studio's own provider logs to diagnose its failure.
|
|
64
|
+
|
|
65
|
+
Existing signed inbound tool webhooks remain the external integration door. Their
|
|
66
|
+
successful calls produce `tool.called`; record changes they make also produce
|
|
67
|
+
`record.changed`. Use functions for durable downstream work instead of expecting
|
|
68
|
+
a webhook sender to orchestrate the studio's internal actions. New declarations start at deployment and skip past events. To replay retained
|
|
69
|
+
history, explicitly add `replay: true` to the declaration, then run
|
|
70
|
+
`homie-studio function replay <name> --replay --url https://your-studio.example`.
|
|
71
|
+
The owner OAuth connection is required. Merely adding `replay: true` does not run
|
|
72
|
+
history. Fully delivered events are pruned; replay cannot recover pruned events.
|
|
73
|
+
Only declared event types are recorded. Each function has an indexed sequence
|
|
74
|
+
cursor; dispatch and maintenance use pages of 100, with no event or retry cap.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
Worker configuration is generated at build and upgrade time from `studio.json`,
|
|
78
|
+
shop and tool declarations, game files, and function declarations. Commit the
|
|
79
|
+
build before deploy. Put custom bindings, variables, aliases, routes, crons or
|
|
80
|
+
observability settings in root `wrangler.custom.json` (plain JSON); objects merge
|
|
81
|
+
recursively and arrays replace generated arrays. Do not hand-edit the generated
|
|
82
|
+
`wrangler.jsonc`. Move existing manual additions into that file before upgrading.
|
|
83
|
+
`studio.json.cloudflare.ai` explicitly enables/disables AI; otherwise game
|
|
84
|
+
chat/decision declarations determine it. Set `cloudflare.routes` for routes and
|
|
85
|
+
`cloudflare.purchaseRateLimit` for an explicitly chosen Wrangler rate-limit
|
|
86
|
+
binding. An old generated purchase limiter is removed unless explicitly set.
|
|
87
|
+
|
|
88
|
+
Machine cart quotes bind an account before payment. Anonymous buyers receive
|
|
89
|
+
`account.player` and private `account.cookies`; retain the cookie and send its
|
|
90
|
+
name/value as the Cookie header to this studio to use the grants. These credentials
|
|
91
|
+
are returned only to the quoting caller, never embedded in the public offer.
|
|
92
|
+
Guest limits are checked at quote time using the incoming request address.
|
|
93
|
+
Quotes expire after 24 hours; paid orders retain their independent snapshots.
|
|
94
|
+
|
|
95
|
+
Paid handlers renew their execution lease while running. Retries share the first
|
|
96
|
+
result and never rerun an abandoned handler. A failed or abandoned run requests an
|
|
97
|
+
automatic Stripe refund; retries report completion or continue refund recovery.
|
|
98
|
+
Paid services require the studio's refundable Stripe connection; chain settlement
|
|
99
|
+
is not offered for services. Handler errors never expose their private contents.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ToolContext, StudioTool } from '../tools/tools';
|
|
2
|
+
export interface StudioEvent<Data = unknown> { readonly id: string; readonly type: string; readonly at: number; readonly data: Data; readonly attempt: number }
|
|
3
|
+
export interface StudioFunction<Data = unknown> {
|
|
4
|
+
name: string;
|
|
5
|
+
event: string;
|
|
6
|
+
/** Cloudflare cron expression; scheduled events are named by this function. */
|
|
7
|
+
schedule?: string;
|
|
8
|
+
/** Permit an explicit owner replay command to consume retained history. */
|
|
9
|
+
replay?: boolean;
|
|
10
|
+
events?: StudioTool['events'];
|
|
11
|
+
handler(event: StudioEvent<Data>, context: ToolContext): unknown | Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export function defineFunction<Data = unknown>(definition: StudioFunction<Data>): StudioFunction<Data> { return definition; }
|
package/lib/build.mjs
CHANGED
|
@@ -210,11 +210,12 @@ export function genreOf(g, log = () => {}) {
|
|
|
210
210
|
* bundle (netplay/netplay.ts NETPLAY_MARK). A build without it is revision 5 or older: it plays on every server, but
|
|
211
211
|
* reserved AI seats stay empty and its bots do not read the dial, and the office says so. Null when nothing says.
|
|
212
212
|
*/
|
|
213
|
-
export function netplayRevOf(out) {
|
|
213
|
+
export function netplayRevOf(out, { skipPort = false } = {}) {
|
|
214
214
|
const files = [];
|
|
215
215
|
const walk = (dir, depth) => {
|
|
216
216
|
if (depth > 3 || !existsSync(dir)) return;
|
|
217
217
|
for (const name of readdirSync(dir)) {
|
|
218
|
+
if (skipPort && name === 'homie-port.js') continue;
|
|
218
219
|
const f = join(dir, name);
|
|
219
220
|
let st;
|
|
220
221
|
try { st = statSync(f); } catch { continue; }
|
|
@@ -381,6 +382,8 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
381
382
|
rmSync(out, { recursive: true, force: true });
|
|
382
383
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
383
384
|
const mode = g.build?.mode ?? 'bundle';
|
|
385
|
+
const label = `${g.kind === 'app' ? 'apps' : 'games'}/${g.id}`;
|
|
386
|
+
const manifest = g.kind === 'app' ? 'app.json' : 'game.json';
|
|
384
387
|
let warnings = 0;
|
|
385
388
|
let metafile = null;
|
|
386
389
|
let bundled = null;
|
|
@@ -391,14 +394,22 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
391
394
|
// browser exactly as before, and cannot ask for the server.
|
|
392
395
|
const ruled = isRulesGame(g);
|
|
393
396
|
if (!ruled && g.room?.host === 'server') {
|
|
394
|
-
throw new Error(
|
|
397
|
+
throw new Error(`${label}/${manifest} asks for "room": { "host": "server" }, but ${mode === 'bundle' ? 'this game has no src/rules.ts: its rules are inside its own code and run in a player\'s browser. Ask for it to be rewritten as rules plus view' : 'a ported game is someone else\'s browser code, which the server cannot run. It stays hosted by a player\'s browser'}.`);
|
|
398
|
+
}
|
|
399
|
+
if (ruled && mode !== 'bundle') throw new Error(`${label} has a src/rules.ts and ${manifest} "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
|
|
400
|
+
if (ruled) {
|
|
401
|
+
const file = join(g.dir, 'index.html');
|
|
402
|
+
const html = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
403
|
+
const scripts = html.match(/<script\b[^>]*>/gi) ?? [];
|
|
404
|
+
if (!scripts.some((tag) => /\btype\s*=\s*["']module["']/i.test(tag) && /\bsrc\s*=\s*(["'])(?:\.\/)?assets\/main\.js(?:[?#][^"']*)?\1/i.test(tag))) {
|
|
405
|
+
throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/index.html: load the built view with <script type="module" src="./assets/main.js"></script>. The manifest entry names src/view.ts; HTML loads the bundle, not /src/view.ts. Keep the path relative so it works inside the game frame.`);
|
|
406
|
+
}
|
|
395
407
|
}
|
|
396
|
-
if (ruled && mode !== 'bundle') throw new Error(`games/${g.id} has a src/rules.ts and game.json "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
|
|
397
408
|
const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
|
|
398
409
|
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
|
|
399
410
|
const bundle = async (entryRel) => {
|
|
400
411
|
const entry = join(g.dir, entryRel);
|
|
401
|
-
if (!existsSync(entry)) throw new Error(
|
|
412
|
+
if (!existsSync(entry)) throw new Error(`${label}: entry ${entryRel} not found`);
|
|
402
413
|
const options = {
|
|
403
414
|
entryPoints: [rules ? 'homie:view' : entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? false,
|
|
404
415
|
outdir: join(out, 'assets'), splitting: true, entryNames: hashed ? 'main-[hash]' : 'main', chunkNames: 'chunk-[hash]',
|
|
@@ -411,14 +422,14 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
411
422
|
};
|
|
412
423
|
const failed = (error) => {
|
|
413
424
|
const first = error.errors?.[0];
|
|
414
|
-
throw new Error(
|
|
425
|
+
throw new Error(`${label} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
415
426
|
};
|
|
416
427
|
const result = await esbuild.build(options).catch(failed);
|
|
417
428
|
warnings += result.warnings.length;
|
|
418
429
|
metafile = result.metafile;
|
|
419
430
|
const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
|
|
420
431
|
const main = outputs.find(([, v]) => rules ? v.entryPoint === 'homie-view:homie:view' : Boolean(v.entryPoint))?.[0];
|
|
421
|
-
if (!main) throw new Error(
|
|
432
|
+
if (!main) throw new Error(`${label} did not build: esbuild wrote no bundle for ${entryRel}`);
|
|
422
433
|
bundled = `assets/${basename(main)}`;
|
|
423
434
|
chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
|
|
424
435
|
if (maps) {
|
|
@@ -442,25 +453,30 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
442
453
|
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
443
454
|
if (g.entry) await bundle(g.entry);
|
|
444
455
|
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
445
|
-
if (!/homie-port\.js/.test(html)) log(`warning:
|
|
456
|
+
if (!/homie-port\.js/.test(html)) log(`warning: ${label}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
446
457
|
if (hashed && bundled) writeFileSync(join(out, 'index.html'), pointAtBundle(html, bundled));
|
|
447
458
|
} else if (mode === 'command') {
|
|
448
459
|
const command = String(g.build.command ?? 'npm run build');
|
|
449
460
|
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
450
|
-
if (res.status !== 0) throw new Error(
|
|
461
|
+
if (res.status !== 0) throw new Error(`${label}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
|
|
451
462
|
const built = join(g.dir, String(g.build.out ?? 'dist'));
|
|
452
|
-
if (!existsSync(join(built, 'index.html'))) throw new Error(
|
|
463
|
+
if (!existsSync(join(built, 'index.html'))) throw new Error(`${label}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
|
|
453
464
|
cpSync(built, out, { recursive: true });
|
|
454
465
|
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
455
466
|
} else {
|
|
456
467
|
await bundle(g.entry ?? (rules ? 'src/view.ts' : 'src/main.ts'));
|
|
457
468
|
const html = join(g.dir, 'index.html');
|
|
458
|
-
if (!existsSync(html)) throw new Error(
|
|
469
|
+
if (!existsSync(html)) throw new Error(`${label}/index.html is missing`);
|
|
459
470
|
const text = readFileSync(html, 'utf8');
|
|
460
471
|
writeFileSync(join(out, 'index.html'), hashed ? pointAtBundle(text, bundled) : text);
|
|
461
472
|
}
|
|
462
473
|
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
463
|
-
if (!existsSync(join(out, 'index.html'))) throw new Error(
|
|
474
|
+
if (!existsSync(join(out, 'index.html'))) throw new Error(`${label}/index.html is missing`);
|
|
475
|
+
// Older games declare netplay v1 or carry the helper's revision marker. A plain
|
|
476
|
+
// browser bundle is not automatically a multiplayer game. Apps may be local-only.
|
|
477
|
+
if (!rules && g.kind !== 'app' && netplayOf(g, out).v !== 1 && netplayRevOf(out, { skipPort: true }) === null) {
|
|
478
|
+
throw new Error(`${label}/${manifest}: this game is neither rules plus view nor a browser-hosted netplay game. Start with game new, src/rules.ts, src/view.ts and "room": { "host": "server" }. Existing browser-hosted games must retain their netplay v1 manifest or netplay helper.`);
|
|
479
|
+
}
|
|
464
480
|
if (rules) {
|
|
465
481
|
// Only this game's executable view and rules data: buildInfo, the site and other games cannot reload its players.
|
|
466
482
|
const digest = createHash('sha256').update(rules.build);
|
|
@@ -492,6 +508,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
492
508
|
const esbuild = await studioEsbuild(root);
|
|
493
509
|
const studio = readStudio(root);
|
|
494
510
|
await (await import('./tools-build.mjs')).buildTools(root, esbuild);
|
|
511
|
+
await (await import('./functions-build.mjs')).buildFunctions(root, esbuild);
|
|
495
512
|
// The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
|
|
496
513
|
const shop = shopForBuild(root, { log });
|
|
497
514
|
const live = join(root, 'site', 'dist');
|
|
@@ -500,7 +517,10 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
500
517
|
for (const g of games) if (g.kind === 'app') { const bad = appProblems(g); if (bad.length) throw new Error(`apps/${g.id}/app.json: ${bad.join('; ')}`); }
|
|
501
518
|
// `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
|
|
502
519
|
// error stops the build before anything is built.
|
|
503
|
-
|
|
520
|
+
// Rules always use their generated capability types, including the view. The legacy
|
|
521
|
+
// compiler sees broad source types and can falsely reject valid rules callbacks.
|
|
522
|
+
const legacy = games.filter((g) => !isRulesGame(g));
|
|
523
|
+
const typed = types && legacy.length ? typecheck(root, legacy, { log }) : null;
|
|
504
524
|
// What the build before this one made, to say which games changed.
|
|
505
525
|
const before = readJson(join(live, '_site', 'build.json'))?.games ?? {};
|
|
506
526
|
// Everything is built in a folder of its own and put in place only when all of it is there (lib/stage.mjs): a
|