@homie-rocks/studio 0.32.1 → 0.33.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +24 -6
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +3 -3
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/studio.mjs +21 -1
  21. package/lib/template-history.json +50 -0
  22. package/lib/upgrade.mjs +19 -15
  23. package/lib/version.mjs +2 -7
  24. package/netplay/NETPLAY.md +249 -4
  25. package/netplay/netplay.ts +55 -13
  26. package/package.json +23 -1
  27. package/rules/core.ts +1250 -0
  28. package/rules/guard.ts +419 -0
  29. package/rules/host.ts +707 -0
  30. package/rules/math.ts +266 -0
  31. package/rules/pack.ts +292 -0
  32. package/rules/rules.ts +544 -0
  33. package/rules/view.ts +447 -0
  34. package/shop/SELLING.md +14 -16
  35. package/shop/SHOP.md +119 -30
  36. package/shop/shop.ts +6 -8
  37. package/site/SITE.md +119 -2
  38. package/starters/coin-dash/game.json +11 -0
  39. package/starters/coin-dash/index.html +17 -0
  40. package/starters/coin-dash/map/main.json +16 -0
  41. package/starters/coin-dash/src/move.ts +27 -0
  42. package/starters/coin-dash/src/rules.ts +86 -0
  43. package/starters/coin-dash/src/view.ts +106 -0
  44. package/starters/coin-dash/tunables.json +5 -0
  45. package/starters/ember-vale/index.html +11 -0
  46. package/worker/brain.mjs +8 -7
  47. package/worker/chat-page.mjs +1 -1
  48. package/worker/embed.mjs +135 -0
  49. package/worker/hosted.mjs +56 -0
  50. package/worker/index.mjs +413 -22
  51. package/worker/office-page.mjs +1 -0
  52. package/worker/office.mjs +24 -7
  53. package/worker/pages.mjs +189 -43
  54. package/worker/players.mjs +10 -6
  55. package/worker/referrals.mjs +192 -57
  56. package/worker/room-store.mjs +54 -0
  57. package/worker/room.mjs +294 -119
  58. package/worker/saves-shell.mjs +7 -1
  59. package/worker/schema.mjs +4 -3
  60. package/worker/shop-page.mjs +103 -37
  61. package/worker/shop-rules.mjs +156 -117
  62. package/worker/shop-store.mjs +70 -9
  63. package/worker/shop.mjs +275 -143
  64. package/worker/site.mjs +26 -5
  65. package/worker/stripe.mjs +9 -6
  66. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,9 +17,65 @@ 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.33.0] - 2026-10-08
21
+
22
+ **Plugin 0.34.0** · [#57](https://github.com/homie-rocks/homie/pull/57)
23
+
24
+ A game's rules can run on your own Cloudflare instead of in a player's browser. This is the first piece of it: the format, the server that runs it, and one example game. Your existing games are not touched.
25
+
26
+ ### Added
27
+
28
+ - Public games can offer X a picture with a play button: its phone apps open the game full screen inside X; its website opens the posted link in a new tab. X decides what it shows, and its written card policy is for audio/video, so this may change. Studio and game switches turn it off.
29
+ - The player joins a public room without sign-in or storage and works as a page of its own and framed; opened as its own page, a reload keeps the room, seat and guest name. Account and purchase links leave a frame, and no purchase starts in the player.
30
+ - The build measures the card's picture and offers the card for any picture X's reference says will render (JPG, PNG, WEBP or GIF, at least 68,600 pixels, under 5 MB), whatever its shape; a refused picture is a warning naming the file and the rule. `site.twitterSite` is optional.
31
+ - Ember Vale's new-hero card fits a short screen: a phone on its side, or a small frame.
32
+ - **A room survives restarts and updates.** Server-hosted games save the whole match, including seats, scores, AI companions and pending decisions. Players reconnect to the same room after a restart; with the default settings, at most one second of play is replayed from the last successful save. If storage is unavailable, play continues and the office reports that recovery may lose more.
33
+ - **Updates keep a running match when its saved state still fits.** Players and watchers load the new game and reclaim their seats. A change to the shape of the saved state starts a fresh match. A game that is slow to load after an update is given up to a minute. A load that fails is tried again a few times, the page says what it is doing while it waits, and it asks you to reload if it has to give up. Older browser-hosted games still update at the round break.
34
+ - **A game written as rules plus view.** The rules (`src/rules.ts`, with the movement code in `src/move.ts`) say what is true in the game: who took which coin, every score, when a round ends. The view (`src/view.ts`) draws what it is told and sends what the player presses. `@homie-rocks/studio/rules` is the format (`defineRules`, `defineMove`, `f` for the type of every piece of state) and `@homie-rocks/studio/rules/view` is what a view uses (`openRoom()`).
35
+ - **The server is the room's host.** The rules of such a game run in the room's own server object on your Cloudflare. No player's browser is in charge, so a changed browser cannot change a score, a pickup or a round, and closing any one tab does not interrupt the others. Seats, chat, votes, watchers and the owner's controls work as before.
36
+ - **Homie checks the rules before they go anywhere.** The build refuses rules that use anything outside a short list of safe operations and names the line (no clock, no network, no `Math.sin` that two machines may round differently, no state outside the declared fields). It then adds a guard, so a rule that never ends or grabs memory is stopped while the room carries on, and a room whose rules keep failing is closed and named in the log.
37
+ - **Every room feature works with the server in charge.** Seats and coming back into the same body, waiting for a seat, watching, chat, votes, your owner controls, servers and their policies, AI seats with a pass, the skill dial, and a game's own decisions (`world.ask`) all behave in a server-hosted room as they do in a browser-hosted one. On a hybrid or beginner server the AI companions keep their reserved seats and their own names, and a guide in such a room follows the goals and lines in your `agents.json`.
38
+ - **`coin-dash`, an example.** Copy it with `homie-studio game new <id> --from coin-dash` to see the two halves. It is an example, not yet a starter the chat offers: see the upgrade notes.
39
+ - **The deploy plan says it.** For each such game: that its rules run on Cloudflare, and roughly what an hour of play in one full room uses of the free plan's daily allowances.
40
+ - **The netplay contract is revision 10** (`netplay/NETPLAY.md`, section 29): the server as a host, input as steps stamped with the room's tick, and the life of such a room. Nothing a game written before sends or receives has changed.
41
+
42
+ ### Changed
43
+
44
+ - **Your shop, your prices.** Studios choose their own shop prices, tips, catalog size, item kinds, durations, refunds and referral terms. Homie no longer sets sales or spending ceilings. Optional spending caps and refund windows belong to the studio; Stripe currency requirements still apply, including currencies without cents.
45
+ - **Your shop policies are editable.** Shop policies are readable and editable presets: protective, adults-only and custom. New shops start protective, including the existing protections for younger players. Existing shops keep their written settings and protective policies unless they choose otherwise. Purchase flood protection now checks both the account and the address, with separate rates the studio can change.
46
+ - **Any name is yours to use in the directory.** The directory no longer keeps a list of Homie's own games' names. A studio, game, song, video or part is listed under whatever plain-text name you gave it, and `studio_request_grant` and `studio_grant_status` are gone from the Homie MCP server.
47
+ - **The toolkit comes from npm only.** homie.rocks no longer serves `homie-studio-<version>.tgz`. A studio pins the npm registry's exact version, as every studio made since 0.10.0 already does.
48
+ - A room of such a game pauses when the last person leaves and ends a minute later if nobody returns. An empty room uses nothing.
49
+ - `homie-studio perf` accepts a room with no browser host.
50
+ - A new studio's `site/src/worker.mjs` hands the Worker the rules of its server-hosted games (`site/src/rules/`, written by every build and committed with the studio), and its `wrangler.jsonc` turns off code made from text while the Worker starts.
51
+
52
+ ### Fixed
53
+
54
+ - A goal, a line, an ask or an argument named after something every JavaScript object has (`constructor`, `toString` and the like) is no longer taken for one of your vocabulary's. Before, an AI guide could be made to "say" such a line, which threw an error in every player's browser, in browser-hosted games too.
55
+ - Only the room's host can speak as a guide. A player or a watcher sending a line marked as the guide's is ignored.
56
+ - An AI pass opened twice keeps one seat: the newer connection takes it, and reconnecting is paced so a copied pass cannot be used to flood a room.
57
+
58
+ ### Upgrade notes
59
+
60
+ - **Check settings you left out of shop.json.** Written settings keep working; no limit is set unless the studio sets one. Omitted settings no longer receive the toolkit's old defaults. To keep those old choices, write `"capPerPlayerMonth": 5000` (monthly spending), `"refundDays": 14` (self-service refunds), `"min": 200, "max": 5000` on each tip, and `"windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500` inside `referrals`. Without them there is no monthly spending cap, no self-service refund window (buyers ask the studio), no tip range beyond provider currency units, no referral window or per-player referral cap, no hold and no minimum invoice. Older `shop init` wrote these amounts explicitly; hand-edited files may have omitted them. All amounts are minor units of your currency.
61
+ - **Write whole amounts.** Fractional minor-unit amounts now stop the build with the setting's name and an example, before publication. For example, replace `"capPerPlayerMonth": 2500.7` with the whole amount you intend, such as `2500`. Every amount setting follows this rule; none is silently rounded. Decimal numeric strings remain accepted; padded strings, hexadecimal strings, lists and booleans now stop the build. Fractional `days` and `refundDays` retain their fraction, rounded to milliseconds, instead of being floored to whole days.
62
+ - **Flood protection allows shared addresses.** `purchaseAttemptsPerMinute` is the account rate (6 by default); `purchaseAttemptsPerAddressPerMinute` is the separate address rate (600 by default, to allow households, schools and venues). Checkout, parent-link and parent-payment attempts have separate counts. Rotating addresses does not bypass the account rate. Referral statements retain a global rate of 30 per minute as well as 30 per address; both are settings in the site configuration; invalid values produce a named error. New guest buyers have a configurable address rate, `guestBuyersPerAddressPerHour`, defaulting to 600 an hour so venues and schools can share a connection; guest buyers and ordinary new players have separate counters.
63
+ - **The withdrawal acknowledgement stays.** Existing checkout and refund-page wording is restored. `policy.withdrawalAcknowledgement` is true in every preset; edit it only as a considered studio policy. Account and younger-player protections remain in the protective preset. `policy.childAge` and `policy.adultAge` default to 13 and 18 and can be edited.
64
+ - **Large shops and referral books use pages.** The shop, in-game sheet and office offer navigation. The office lists referrers with exact totals once each; sales page inside each referrer. `shop statements --send` and the office button send all statements in one action, saving immutable pages and progress in D1. Keep the command or tab open; restart after closing to resume safely. Failed pages retry three times with backoff; persistent failures name the referrer and the rest continue. One CLI office key is renewed during a run. Signed continuation pages contain lines without repeating totals; a new statement replaces the old pages for that period and currency. Mark paid selects one currency and does not change an edition already being sent. Read-only statement views allocate no editions. Sends use Workers-compatible manual redirects and refuse every redirect response.
65
+ - **The optional spending cap retains unresolved payments.** Sessionless rows age out after 32 minutes; sessions past expiry and bank payments still processing count until Stripe confirms payment, expiry or failure. Shop GETs never call Stripe. Only a cap-refused purchase waits for up to three parallel reads with two-second timeouts; successful purchases continue in the background. Atomic claims and per-row intervals (one minute, five for processing payments, growing with age to an hour) bound successful reads; failed reads retry after about one minute and log the order and reason. More than three lost rows can require another attempt. Paid rows use Stripe's charge time; pending, missing payment time or unreachable rows keep counting. Stripe's [resource_missing](https://docs.stripe.com/error-codes#resource-missing) means this key's account has no such session; it frees the reservation and shows the missing row in the office. The office also offers a release recorded with the confirming owner's account (or a fingerprint of the office sign-in session) and time, and asks Stripe to [expire an open Checkout Session](https://docs.stripe.com/api/checkout/sessions/expire). Neither mark prevents a verified payment from granting the item, counting toward spending again and being refundable, even if the freed space has been spent. Internal states and notes stay out of player account data. Late events are idempotent; test rows never count toward live spending. Free orders remain available after an owner lowers a cap. Partial refunds keep the full price counted; full refunds release it. No cap means no spending SUM. Deploy applies `0010_shop_reservations.sql` and `0011_shop_statements.sql`; missing schema reports not ready with the migration names.
66
+ - **Guest checkouts can answer the age question.** A valid answer creates a guest session when the studio permits guests. A refused Stripe checkout returns the guest cookie so retrying uses the same account. Idle guest cleanup keeps owners, including future entitlements, and continues removing other unused guests.
67
+ - Allowed odds and countdown fields that are not published now produce a warning telling the studio to put buyer-facing terms in `blurb`. Three-decimal amounts using the smallest minor unit warn, without an undocumented divisible-by-ten refusal; Stripe's error is shown if it refuses them, with the failure note in the office order row. Free orders can be taken back without contacting Stripe. Stripe settlement minimums remain warnings.
68
+ - A studio made before 0.10.0 pins an address on homie.rocks that no longer answers, so `npm install` in it fails until it is upgraded. Run `npx -y @homie-rocks/studio@latest upgrade --apply`, then `npm install`: the upgrade replaces the address with the registry's exact version. Nothing else about the studio changes.
69
+ - **Nothing to do for the games you have.** A game with no `src/rules.ts` builds and runs exactly as before, in a player's browser. The build says so in one line. Asking for the server on such a game (`"room": { "host": "server" }`) stops the build with the reason.
70
+ - **Do not put a game of your own on this yet.** A server-hosted room has no save in this version: a restart of the room, or any deploy of your site, starts its match again, where today's rooms carry on. That arrives in a later version, and `coin-dash` is offered as a starter only then.
71
+ - In this version a player's own character still moves as today: their browser says where it is and the server holds it to its top speed. A changed browser cannot move faster than that, but it can walk through a wall. Scores and pickups are decided on the server either way.
72
+ - Rules with 3D bodies, rules hosted by a player's browser, offline play of a rules game, and AI guides driven by the server are not in this version.
73
+ - An existing studio gets the new `site/src/worker.mjs` and the empty `site/src/rules/index.mjs` from `homie-studio upgrade --apply` (a Worker file you changed yourself is kept, and the upgrade shows the three lines to add). The next deploy writes the new `wrangler.jsonc`.
74
+ - This version has been run on a local Cloudflare runtime only. It has not been run or measured on Cloudflare itself.
75
+
20
76
  ## [0.32.1] - 2026-10-07
21
77
 
22
- **Plugin 0.33.1** · [#55](https://github.com/homie-rocks/homie/pull/55)
78
+ **Plugin 0.33.1** · [#55](https://github.com/homie-rocks/homie/pull/55) · [release-2026-10-07-studio-0.32.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-07-studio-0.32.1)
23
79
 
24
80
  A standalone game can be put on your own iPhone or iPad with one command, and the guide says what has now been run on a real phone and corrects what an older site means for a copy.
25
81
 
package/README.md CHANGED
@@ -207,6 +207,24 @@ the owner's session), so `localStorage` throws inside it: a setting or a persona
207
207
  `net.link` says whether the browser is in its room, reconnecting, or playing alone because the room
208
208
  never answered; `guardGestures()` stops a long press on a phone from selecting text in a touch game.
209
209
 
210
+ ## A game's rules on the server (an example, 0.33.0)
211
+
212
+ A game can be written as **rules plus view**: `src/rules.ts` and `src/move.ts` say what is true in the game and run
213
+ in the room's own server object on the studio's Cloudflare; `src/view.ts` draws what it is told and sends what the
214
+ player presses. No player's browser is the room's host, so a changed browser cannot change a score, a pickup or a
215
+ round. `homie-studio build` checks the rules first and refuses anything outside a short list of safe operations,
216
+ naming the line, then guards them so a rule that never ends is stopped while the room carries on
217
+ (`netplay/NETPLAY.md` section 29).
218
+
219
+ ```sh
220
+ npx --no-install homie-studio game new coin-dash --from coin-dash # the example
221
+ npx --no-install homie-studio dev # its rules run in the local room object
222
+ ```
223
+
224
+ `coin-dash` is an example, not yet a starter to build your own game on: a server-hosted room has no save in this
225
+ version, so a deploy of the site restarts its match. A game with no `src/rules.ts` is not touched by any of this and
226
+ runs in a player's browser exactly as before. So far this has run on a local Cloudflare runtime only.
227
+
210
228
  ## Player accounts and cloud saves
211
229
 
212
230
  A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
@@ -433,13 +451,14 @@ npx homie-studio lounge mod <player id> # a mode
433
451
 
434
452
  A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
435
453
  straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
436
- currency; not a Connect platform). Real-money prices only, nothing random, the kids rules built in (no shop on a kids
437
- server, spending off until a neutral age question says adult, nothing for under-13s, 13-17 through a parent's own
438
- checkout, a monthly cap), and the television only shows a code to buy on a phone.
454
+ currency; not a Connect platform). The studio chooses its prices, item kinds and optional spending and refund
455
+ settings, with no Homie ceiling. New shops start with the editable `protective` policy: accounts and an age
456
+ question, no purchases for under-13s, parent checkout for teens, no shop on kids servers, and no paid randomness.
457
+ The studio can inspect and change each rule in shop.json; [shop/SHOP.md](shop/SHOP.md) explains every preset.
439
458
 
440
459
  ```sh
441
460
  npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
442
- npx homie-studio shop check # the kit's rules; every build checks them too
461
+ npx homie-studio shop check # the studio settings and provider requirements; every build checks them too
443
462
  npx homie-studio shop catalog [--have <file>] # the items as Products in Stripe: the read, then the exact writes (Stripe's MCP)
444
463
  npx homie-studio shop connect # a page on THIS computer: the owner pastes one restricted key; it makes the webhook
445
464
  npx homie-studio shop # open (test or live) or what is missing
@@ -1200,8 +1219,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
1200
1219
  Versions are immutable: a published version never changes, so a studio that pinned one
1201
1220
  never changes by surprise. A change ships as a new `version` on the npm registry as
1202
1221
  `@homie-rocks/studio`, which is what `homie-studio new` pins (the exact version; package-lock.json keeps its
1203
- integrity). The registry is what Workers Builds and a Claude Code cloud session reach by default. Older studios
1204
- pinned a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`, which stays.
1222
+ integrity). The registry is what Workers Builds and a Claude Code cloud session reach by default.
1205
1223
 
1206
1224
  ## Beta
1207
1225
 
package/agents/agents.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  * vocabulary line with arguments that fit, on a server whose AI may talk).
25
25
  * =============================================================================
26
26
  */
27
- import { aiName, stripAi, type Netplay, type NetEvent, type Policy, type Slot } from '../netplay/netplay';
27
+ import { aiName, stripAi, type Netplay, type NetEvent, type Policy, type Slot } from '../netplay/netplay.ts';
28
28
 
29
29
  /* ------------------------------------------------------------------ the vocabulary (agents.json) */
30
30
 
@@ -56,6 +56,13 @@ export interface GoalEvent { slot: number; goal: Goal; prev: Goal | null; askAt:
56
56
  export interface AskButton { k: string; args: Record<string, unknown>; text: string }
57
57
 
58
58
  export interface AgentsOptions {
59
+ /** Runtime integration: a room clock and an explicit beat replace the browser timer. */
60
+ now?: () => number;
61
+ manual?: boolean;
62
+ /** Keep an asked-for goal through scripted floor decisions until completion or sixty seconds. */
63
+ carryFloor?: boolean;
64
+ restore?: AgentsSaved;
65
+
59
66
  /** Host: what a guide in this slot sees. Game state only: never an account, an address or typed text. Under 2 KB. */
60
67
  view: (slot: number) => Record<string, unknown>;
61
68
  /** The scripted floor: runs with no AI, over budget, and between AI decisions. Synchronous; never waits. */
@@ -70,7 +77,14 @@ export interface AgentsOptions {
70
77
 
71
78
  export interface AgentsStats { views: number; dos: number; says: number; asks: number; drops: number; floors: number; viewBytes: number }
72
79
 
80
+ export interface AgentsSaved { goals: [number, Goal][]; asks: [number, Ask[]][]; avoid: [number, [number, number][]][]; sayAt: [number, number][]; viewAt: [number, number][]; floorAt: [number, number][]; askAt: [number, number][] }
73
81
  export interface Agents {
82
+ tick(): void;
83
+ save(): AgentsSaved;
84
+ forget(slot: number): void;
85
+ /** Drop one slot's goal so its floor may run at once. Its pacing, its asks and who asked to be left alone all stay. */
86
+ dropGoal(slot: number): void;
87
+
74
88
  readonly vocab: Vocabulary;
75
89
  /** Whether this server's AI may talk (its brain is workers-ai or owner-key, and speech is not off). */
76
90
  readonly talking: boolean;
@@ -95,6 +109,7 @@ export interface Agents {
95
109
  stop(): void;
96
110
  }
97
111
 
112
+ const own = <T>(table: Record<string, T> | undefined, key: unknown): T | undefined => table && typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
98
113
  const TALK = ['workers-ai', 'owner-key'];
99
114
  export const AGENT_RULES = Object.freeze({ viewMs: 2050, floorMs: 1000, quietMs: 8000, leaveMs: 10 * 60_000, askMs: 30_000, holdMs: 45_000, askWaitMs: 4000, carryMs: 60_000, viewBytes: 1900 });
100
115
  const VIEW_ARG = /^view\.([A-Za-z][A-Za-z0-9_]{0,23})$/;
@@ -127,7 +142,7 @@ export function argsWhy(types: Record<string, ArgType> | undefined, args: unknow
127
142
  return null;
128
143
  }
129
144
 
130
- export const labelOf = (vocab: Vocabulary, value: unknown): string => vocab.labels?.[String(value)] ?? String(value ?? '').replace(/[-_]+/g, ' ');
145
+ export const labelOf = (vocab: Vocabulary, value: unknown): string => own(vocab.labels, String(value)) ?? String(value ?? '').replace(/[-_]+/g, ' ');
131
146
 
132
147
  /* ------------------------------------------------------------------ useAgents */
133
148
 
@@ -145,7 +160,15 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
145
160
  const askAt = new Map<number, number>();
146
161
  const handlers = { say: new Set<(e: SayEvent) => void>(), goal: new Set<(e: GoalEvent) => void>(), ask: new Set<(e: Ask & { slot: number }) => void>() };
147
162
  const st: AgentsStats = { views: 0, dos: 0, says: 0, asks: 0, drops: 0, floors: 0, viewBytes: 0 };
148
- const wall = (): number => Date.now();
163
+ const wall = opts.now ?? (() => Date.now());
164
+ if (opts.restore) {
165
+ const entries = (v: unknown): [number, any][] => Array.isArray(v) ? v.slice(0, 256).filter((e) => Array.isArray(e) && e.length === 2 && Number.isSafeInteger(e[0]) && e[0] >= 0) : [];
166
+ const stamp = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= wall();
167
+ for (const [k, v] of entries(opts.restore.goals)) if (v && own(vocab.goals, v.goal) && stamp(v.at) && v.args && typeof v.args === 'object' && !Array.isArray(v.args)) goals.set(k, { ...v });
168
+ for (const [k, v] of entries(opts.restore.asks)) if (Array.isArray(v)) asks.set(k, v.slice(0, 3).filter((a) => a && own(vocab.asks, a.k) && stamp(a.at) && Number.isSafeInteger(a.from) && a.from >= 0 && a.args && typeof a.args === 'object' && !Array.isArray(a.args)));
169
+ for (const [k, v] of entries(opts.restore.avoid)) avoid.set(k, new Map(entries(v).filter(([, until]) => typeof until === 'number' && Number.isFinite(until) && until > wall() && until <= wall() + AGENT_RULES.leaveMs)));
170
+ for (const [dst, src] of [[sayAt, opts.restore.sayAt], [viewAt, opts.restore.viewAt], [floorAt, opts.restore.floorAt], [askAt, opts.restore.askAt]] as const) for (const [k, v] of entries(src)) if (stamp(v)) dst.set(k, v);
171
+ }
149
172
 
150
173
  const guides = (): Slot[] => (net.slots ?? []).filter((s) => s.agent && roles.includes(s.agent.role as 'guide' | 'party'));
151
174
  const slotOf = (slot: number): Slot | undefined => guides().find((s) => s.slot === slot);
@@ -171,7 +194,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
171
194
  };
172
195
 
173
196
  function render(id: string, args: Record<string, unknown>, slot: number | null, kind: 'lines' | 'asks' = 'lines'): string | null {
174
- const def = kind === 'asks' ? vocab.asks?.[id] : vocab.lines?.[id];
197
+ const def = kind === 'asks' ? own(vocab.asks, id) : own(vocab.lines, id);
175
198
  if (!def) return null;
176
199
  const s = slot === null ? undefined : (net.slots ?? []).find((x) => x.slot === slot);
177
200
  const me = s ? stripAi(s.name) : 'Guide';
@@ -180,7 +203,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
180
203
 
181
204
  /** A line the host lets a guide say: from the vocabulary, its arguments fit, not within 8 s of its last. */
182
205
  function say(slot: number, line: string, args: Record<string, unknown>): boolean {
183
- const def = vocab.lines?.[line];
206
+ const def = own(vocab.lines, line);
184
207
  const now = wall();
185
208
  if (!def || !talksOn(net.policy) || now - (sayAt.get(slot) ?? -Infinity) < AGENT_RULES.quietMs) { st.drops += 1; return false; }
186
209
  if (argsWhy(def.args, args, lastView.get(slot) ?? null, people()) || touchesAvoided(def.args, args, slot)) { st.drops += 1; return false; }
@@ -197,7 +220,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
197
220
 
198
221
  function setGoal(slot: number, d: Decision, from: 'brain' | 'floor', answering = true): boolean {
199
222
  if (!d.goal) return false;
200
- const def = vocab.goals[d.goal];
223
+ const def = own(vocab.goals, d.goal);
201
224
  const args = (d.args ?? {}) as Record<string, unknown>;
202
225
  if (!def || argsWhy(def.args, args, lastView.get(slot) ?? opts.view(slot), people()) || touchesAvoided(def.args, args, slot)) { st.drops += 1; return false; }
203
226
  const prev = goals.get(slot) ?? null;
@@ -216,13 +239,13 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
216
239
  // A person's ask is answered the way agents.json says (its `goal`): a brain's decision that does something else
217
240
  // leaves the ask open, and the floor answers it once the brain has had askWaitMs (a fixed rule, not the model's).
218
241
  const open = askAt.has(slot) ? (asks.get(slot) ?? []).at(-1) ?? null : null;
219
- const want = open ? vocab.asks?.[open.k]?.goal : undefined;
220
- const answering = !open || from === 'floor' || !want || want === d.goal;
242
+ const want = open ? own(vocab.asks, open.k)?.goal : undefined;
243
+ const answering = !open || !want || (want === d.goal && Object.entries(open.args).every(([k, v]) => !own(own(vocab.goals, want)?.args, k) || d.args?.[k] === v));
221
244
  // What a person asked for is carried through until it is done (or 60 s): a brain's other goal waits.
222
245
  const cur = goals.get(slot);
223
- if (!open && from === 'brain' && cur && cur.asked && cur.state === 'active' && wall() - cur.at < AGENT_RULES.carryMs && d.goal !== cur.goal) { st.drops += 1; if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>); return; }
246
+ if (!open && from === 'brain' && cur && cur.asked && cur.state === 'active' && wall() - cur.at < AGENT_RULES.carryMs && (d.goal !== cur.goal || JSON.stringify(d.args ?? {}) !== JSON.stringify(cur.args))) { st.drops += 1; if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>); return; }
224
247
  setGoal(slot, d, from, answering);
225
- if (open && answering) { askAt.delete(slot); asks.set(slot, []); const g = goals.get(slot); if (g && d.goal === g.goal) g.asked = true; }
248
+ if (open && answering && goals.get(slot)?.goal === d.goal && JSON.stringify(goals.get(slot)?.args) === JSON.stringify(d.args ?? {})) { askAt.delete(slot); asks.set(slot, []); const g = goals.get(slot); if (g && d.goal === g.goal) { g.asked = true; g.at = wall(); } }
226
249
  if (speak && d.say) say(slot, d.say, (d.sayArgs ?? {}) as Record<string, unknown>);
227
250
  }
228
251
 
@@ -242,21 +265,23 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
242
265
 
243
266
  /** The floor for one slot: the game's own decide, on its view. */
244
267
  function floor(slot: number, held: boolean): void {
245
- if (!opts.decide) return;
268
+ if (!opts.decide) { floorAt.set(slot, wall()); askAt.delete(slot); asks.set(slot, []); return; }
246
269
  const v = viewOf(slot);
247
270
  lastView.set(slot, v);
248
271
  let d: Decision | null = null;
249
272
  try { d = opts.decide(v, { slot, goal: goals.get(slot) ?? null, asks: asksFor(slot) }); } catch { d = null; }
250
273
  floorAt.set(slot, wall());
251
- if (!d) return;
274
+ // One floor attempt settles an ask, even if it declines or fails.
275
+ if (!d) { askAt.delete(slot); asks.set(slot, []); return; }
252
276
  st.floors += 1;
253
277
  // The floor speaks only for a guide no AI holds (a held guide's own brain speaks for it).
254
278
  apply(slot, d, 'floor', !held);
279
+ askAt.delete(slot); asks.set(slot, []);
255
280
  }
256
281
 
257
282
  /** A person's ask reached the host (theirs, or its own player's). */
258
283
  function onAsk(slot: number, k: string, args: Record<string, unknown>, from: number): void {
259
- const def = vocab.asks?.[k];
284
+ const def = own(vocab.asks, k);
260
285
  const s = slotOf(slot);
261
286
  if (!def || !s || argsWhy(def.args, args, lastView.get(slot) ?? viewOf(slot), null)) { st.drops += 1; return; }
262
287
  const now = wall();
@@ -267,7 +292,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
267
292
  emit('ask', { slot, k, args, from, at: now });
268
293
  const held = s.agent?.seat !== null && s.agent?.seat !== undefined;
269
294
  // No AI holds this guide: the floor answers at once. Held: its brain has askWaitMs to answer first.
270
- if (!held) floor(slot, false);
295
+ if (!held) { if (opts.manual) floorAt.delete(slot); else floor(slot, false); }
271
296
  }
272
297
 
273
298
  const offEvent = net.on('event', (e: NetEvent) => {
@@ -286,12 +311,13 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
286
311
  if (ask && e.from !== null && !net.isAgent(e.from) && Number.isInteger(d['slot'])) { onAsk(d['slot'] as number, ask, (d['args'] ?? {}) as Record<string, unknown>, e.from); return; }
287
312
  return;
288
313
  }
314
+ if (k === 'agent:offer' && e.from === net.host?.seat && Number.isInteger(d['slot']) && d['view'] && typeof d['view'] === 'object') { lastView.set(d['slot'] as number, d['view'] as Record<string, unknown>); return; }
289
315
  // Every other browser: a guide's line the host relayed (Quiet AI already dropped it in the helper). Its words are
290
316
  // this browser's own copy of the vocabulary; an argument is a seat, a value of the line's list, or an id.
291
317
  const line = /^say:([a-z][a-z0-9_]{0,31})$/.exec(k)?.[1];
292
- if (line && d['ai'] === true && Number.isInteger(d['slot'])) {
318
+ if (e.from === net.host?.seat && line && d['ai'] === true && Number.isInteger(d['slot'])) {
293
319
  const args = (d['args'] ?? {}) as Record<string, unknown>;
294
- const def = vocab.lines?.[line];
320
+ const def = own(vocab.lines, line);
295
321
  const ids = Object.fromEntries(Object.entries(def?.args ?? {}).map(([n, t]) => [n, t]).filter(([, t]) => typeof t === 'string' && VIEW_ARG.test(t)).map(([n, t]) => [VIEW_ARG.exec(t as string)![1], /^[a-z0-9][a-z0-9_-]{0,39}$/.test(String(args[n as string] ?? '')) ? [args[n as string]] : []]));
296
322
  if (!def || argsWhy(def.args, args, ids, null)) { st.drops += 1; return; }
297
323
  const text = render(line, args, d['slot'] as number);
@@ -300,7 +326,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
300
326
  });
301
327
 
302
328
  // The host's beat: views to the guides an AI holds (at most one every 2 s each), the floor for the rest.
303
- const timer = setInterval(() => {
329
+ const beat = (): void => {
304
330
  if (!net.isHost || net.offline) return;
305
331
  const now = wall();
306
332
  for (const s of guides()) {
@@ -322,21 +348,27 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
322
348
  const waiting = held && askAt.has(s.slot) && now - (askAt.get(s.slot) as number) < askWaitMs;
323
349
  // The floor decides for a guide with no AI's decision in force, at most once a second; an ask an AI left
324
350
  // unanswered for askWaitMs is answered by the floor (silently: a held guide's lines are its brain's).
325
- if ((!brainFresh && !waiting && now - (floorAt.get(s.slot) ?? -Infinity) >= AGENT_RULES.floorMs) || (held && askAt.has(s.slot) && !waiting)) floor(s.slot, held);
351
+ const carried = opts.carryFloor && g?.asked && g.state === 'active' && now - g.at < AGENT_RULES.carryMs && !askAt.has(s.slot);
352
+ if (!carried && ((!brainFresh && !waiting && now - (floorAt.get(s.slot) ?? -Infinity) >= AGENT_RULES.floorMs) || (held && askAt.has(s.slot) && !waiting))) floor(s.slot, held);
326
353
  }
327
354
  // Slots no longer an AI's: forget them.
328
355
  const live = new Set(guides().map((s) => s.slot));
329
- for (const k of [...goals.keys()]) if (!live.has(k)) goals.delete(k);
330
- }, 250);
356
+ for (const map of [goals, asks, avoid, sayAt, viewAt, floorAt, askAt, lastView]) for (const k of [...map.keys()]) if (!live.has(k)) map.delete(k);
357
+ };
358
+ const timer = opts.manual ? null : setInterval(beat, 250);
331
359
 
332
360
  const agents: Agents = {
361
+ tick: beat,
362
+ forget: (slot) => { for (const map of [goals, asks, avoid, sayAt, viewAt, floorAt, askAt, lastView]) map.delete(slot); },
363
+ dropGoal: (slot) => { goals.delete(slot); floorAt.delete(slot); },
364
+ save: () => ({ goals: [...goals], asks: [...asks], avoid: [...avoid].map(([k, v]) => [k, [...v]]), sayAt: [...sayAt], viewAt: [...viewAt], floorAt: [...floorAt], askAt: [...askAt] }),
333
365
  vocab,
334
366
  get talking() { return talksOn(net.policy); },
335
367
  guides,
336
368
  goalOf: (slot) => goals.get(slot) ?? null,
337
369
  asksFor,
338
370
  ask(slot, k, args = {}) {
339
- const def = vocab.asks?.[k];
371
+ const def = own(vocab.asks, k);
340
372
  const s = slotOf(slot);
341
373
  if (!def || !s) return false;
342
374
  const seat = s.agent?.seat ?? null;
@@ -353,6 +385,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
353
385
  return true;
354
386
  },
355
387
  askButtons(slot, offer = {}) {
388
+ if (!slotOf(slot)) return [];
356
389
  const out: AskButton[] = [];
357
390
  const view = { ...(lastView.get(slot) ?? {}), ...offer };
358
391
  for (const [k, def] of Object.entries(vocab.asks ?? {})) {
@@ -362,7 +395,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
362
395
  const name = names[0] as string;
363
396
  const type = (def.args as Record<string, ArgType>)[name];
364
397
  const m = typeof type === 'string' ? VIEW_ARG.exec(type) : null;
365
- const values = Array.isArray(type) ? [...type] : m ? (Array.isArray(view[m[1] as string]) ? (view[m[1] as string] as unknown[]) : []) : [];
398
+ const values = Array.isArray(type) ? [...type] : type === 'player' ? (net.seat === null ? [] : [net.seat]) : m ? (Array.isArray(view[m[1] as string]) ? (view[m[1] as string] as unknown[]) : []) : [];
366
399
  for (const v of values.slice(0, 4)) {
367
400
  const value = v && typeof v === 'object' ? (v as { id?: unknown }).id : v;
368
401
  const text = render(k, { [name]: value }, slot, 'asks');
@@ -378,7 +411,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
378
411
  g.at = wall();
379
412
  const s = slotOf(slot);
380
413
  // A guide no AI holds decides again at once; one an AI holds hears it in its next view.
381
- if (s && (s.agent?.seat ?? null) === null) floor(slot, false);
414
+ if (s && (s.agent?.seat ?? null) === null) { if (opts.manual) floorAt.delete(slot); else floor(slot, false); }
382
415
  },
383
416
  render,
384
417
  on(kind: 'say' | 'goal' | 'ask', fn: (e: never) => void) {
@@ -386,7 +419,7 @@ export function useAgents(net: Netplay<any, any, any>, vocab: Vocabulary, opts:
386
419
  return () => { (handlers[kind] as Set<unknown>).delete(fn); };
387
420
  },
388
421
  stats: () => ({ ...st }),
389
- stop() { clearInterval(timer); offEvent(); },
422
+ stop() { if (timer) clearInterval(timer); offEvent(); },
390
423
  } as Agents;
391
424
  return agents;
392
425
  }
@@ -169,7 +169,7 @@
169
169
  * spent from the guides' day; --model compares a model before setting it)
170
170
  * homie-studio shop is the studio's shop selling (its own Stripe), and if not, what is missing
171
171
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
172
- * homie-studio shop check shop.json against the kit's rules (real money, nothing random, the kids rules)
172
+ * homie-studio shop check shop.json against studio settings and provider requirements
173
173
  * homie-studio shop connect [--managed] [--live] a page on THIS computer where the owner pastes the studio's
174
174
  * restricted Stripe key; with it this makes the webhook (0.24.3), and the key
175
175
  * and the webhook's secret go straight to the Worker secrets, never a chat or a
@@ -810,7 +810,7 @@ const OUTWARD_FLAGS = {
810
810
  'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
811
811
  'chat remove': ['url', 'all'], 'chat budget': ['url'],
812
812
  'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
813
- 'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send'],
813
+ 'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send', 'cursor'],
814
814
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
815
815
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
816
816
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
@@ -1055,7 +1055,7 @@ async function main() {
1055
1055
  }
1056
1056
  if (sub === 'orders') return shopOrders(root, { url });
1057
1057
  if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
1058
- if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), send: flags.has('send') });
1058
+ if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), cursor: flags.get('cursor') ?? '', send: flags.has('send') });
1059
1059
  return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, catalog, orders, refund, statements)` };
1060
1060
  }
1061
1061
  if (cmd === 'agents') {