@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.
- package/CHANGELOG.md +57 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +50 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- 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).
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 = ()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
220
|
-
const answering = !open ||
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 [...
|
|
330
|
-
}
|
|
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
|
|
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
|
}
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
|
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') {
|