@homie-rocks/studio 0.24.2 → 0.24.4

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.
@@ -402,6 +402,8 @@ export interface NetStats {
402
402
  reconnects: number;
403
403
  promotions: number;
404
404
  round: number | null;
405
+ /** Section 20: the host's decisions: asked, answered by the studio's model ('ai'), the person's own Ollama ('local'), or the floor; median ms. */
406
+ decides?: { asked: number; ai: number; local: number; floor: number; msP50: number | null };
405
407
  }
406
408
 
407
409
  export interface Vec { x: number; y: number; z?: number }
@@ -497,6 +499,29 @@ export interface NetHandlers<S, A, C> {
497
499
  held: (h: ChatHeld) => void;
498
500
  }
499
501
 
502
+ /**
503
+ * A GAME'S OWN DECISIONS (section 20): typed questions about the game's state that the studio's decision model (Clef)
504
+ * answers with a probability for every option, never with text. A Choice picks one option id (2 to 26), a yes/no
505
+ * (`noul`) is true or false, a Score is a level (2 to 10, lowest first; the answer is the probability-weighted level).
506
+ */
507
+ export type DecideQuestion =
508
+ | { type: 'choice'; instructions: string; criteria: Record<string, string | null> }
509
+ | { type: 'noul'; instructions: string; criteria?: { true?: string; false?: string } }
510
+ | { type: 'score'; instructions: string; criteria: string[] };
511
+ export type DecidePicks = Record<string, string | boolean | number>;
512
+ /**
513
+ * What `decide` resolves to, always: `by` 'ai' (the studio's Workers AI) or 'local' (the person's own Ollama under dev),
514
+ * or 'floor' (the game's own answer: no AI, not opted in, over budget, paced, slow, not the host); `why` says which.
515
+ * `p`: the model's probability for each option (a choice), `{ yes }` (a yes/no), or each level (a score).
516
+ */
517
+ export interface Decided { by: 'ai' | 'local' | 'floor'; picks: DecidePicks; p?: Record<string, Record<string, number>>; ms: number; why?: string }
518
+ export interface DecideOptions {
519
+ /** The game's own answer, synchronous: used whenever the model's is not there in time. Missing picks fall back to it one by one. */
520
+ floor?: (state: unknown) => DecidePicks;
521
+ /** How long to wait for the model (default 2500 ms; at most 5000). */
522
+ ms?: number;
523
+ }
524
+
500
525
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
501
526
  readonly role: Role;
502
527
  readonly isHost: boolean;
@@ -640,6 +665,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
640
665
  /** Interpolation pair for rendering remote entities. */
641
666
  sample(delayMs?: number): Sample<S> | null;
642
667
  latest(): Snapshot<S> | null;
668
+ /**
669
+ * Host: ask the studio's decision model (Clef) a few typed questions about the game's state, for a game whose
670
+ * game.json says "decide": true. Always resolves: with the model's picks, or the floor's when it cannot answer in
671
+ * time (no AI, over the day's budget, at most one ask every 3 s a room, a replica, offline). Never per frame: per
672
+ * beat (every few seconds), and the game keeps playing while it waits.
673
+ */
674
+ decide(state: unknown, questions: Record<string, DecideQuestion>, opts?: DecideOptions): Promise<Decided>;
643
675
  stats(): NetStats;
644
676
  expose(probes: Probes): void;
645
677
  close(): void;
@@ -1069,6 +1101,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1069
1101
  /** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
1070
1102
  const inSent: number[] = [];
1071
1103
  let inSlowUntil = 0;
1104
+ /** Section 20: decisions waiting for the room's answer (by request id), and how long the room said it is off. */
1105
+ const deciding = new Map<string, { done: (d: Decided) => void; timer: ReturnType<typeof setTimeout>; at: number; floor: () => Decided }>();
1106
+ let decideSeq = 0;
1107
+ let decideOffUntil = 0;
1108
+ let decideNextAt = 0;
1109
+ const decideStats = { asked: 0, ai: 0, local: 0, floor: 0, ms: [] as number[] };
1072
1110
  let idleSkipped = 0;
1073
1111
  /** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
1074
1112
  let closedWhy: string | null = null;
@@ -1460,6 +1498,29 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1460
1498
  resolveView('policy');
1461
1499
  return;
1462
1500
  }
1501
+ case 'decided': {
1502
+ const n = String(m['n'] ?? '');
1503
+ const w = deciding.get(n);
1504
+ if (!w) return;
1505
+ deciding.delete(n);
1506
+ clearTimeout(w.timer);
1507
+ const ms = Math.round(wall() - w.at);
1508
+ if (m['ok'] === true && m['picks'] && typeof m['picks'] === 'object') {
1509
+ const base = w.floor();
1510
+ const by = m['by'] === 'local' ? 'local' : 'ai';
1511
+ decideStats[by] += 1;
1512
+ decideStats.ms.push(ms); if (decideStats.ms.length > 40) decideStats.ms.shift();
1513
+ w.done({ by, picks: { ...base.picks, ...(m['picks'] as DecidePicks) }, p: (m['p'] as Decided['p']) ?? {}, ms });
1514
+ return;
1515
+ }
1516
+ const why = String(m['why'] ?? 'error');
1517
+ // Not opted in, no AI or out of budget: ask again only after a minute. Paced: at the room's next slot.
1518
+ if (why === 'off' || why === 'no-ai' || why === 'budget') decideOffUntil = wall() + 60_000;
1519
+ if (why === 'pace' && Number.isFinite(Number(m['retryMs']))) decideNextAt = wall() + Number(m['retryMs']);
1520
+ decideStats.floor += 1;
1521
+ w.done({ ...w.floor(), why, ms });
1522
+ return;
1523
+ }
1463
1524
  case 'error': {
1464
1525
  const code = String(m['code'] ?? '');
1465
1526
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
@@ -1748,6 +1809,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1748
1809
  interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
1749
1810
  owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
1750
1811
  peers: peers.size, reconnects, promotions, round: roundInfo ? roundInfo.n : null,
1812
+ decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
1751
1813
  };
1752
1814
  }
1753
1815
  function notifyStats(force: boolean): void {
@@ -2185,6 +2247,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2185
2247
  return { a: newest, b: newest, alpha: 1, renderT, starved: 0 };
2186
2248
  },
2187
2249
  latest() { return buf.length ? buf[buf.length - 1] as Snapshot<S> : null; },
2250
+ decide(state, questions, o = {}) {
2251
+ const t0 = wall();
2252
+ const floor = (): Decided => {
2253
+ let picks: DecidePicks = {};
2254
+ try { picks = o.floor ? o.floor(state) ?? {} : {}; } catch { picks = {}; }
2255
+ return { by: 'floor', picks, ms: Math.round(wall() - t0) };
2256
+ };
2257
+ decideStats.asked += 1;
2258
+ const now0 = wall();
2259
+ const why = role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
2260
+ if (why) { decideStats.floor += 1; return Promise.resolve({ ...floor(), why }); }
2261
+ decideNextAt = now0 + 3000;
2262
+ const n = `d${(decideSeq += 1).toString(36)}`;
2263
+ return new Promise<Decided>((done) => {
2264
+ const ms = Math.max(200, Math.min(5000, Number(o.ms) || 2500));
2265
+ const timer = setTimeout(() => { if (deciding.delete(n)) { decideStats.floor += 1; done({ ...floor(), why: 'slow' }); } }, ms);
2266
+ deciding.set(n, { done, timer, at: t0, floor });
2267
+ if (!raw({ t: 'decide', n, state, questions })) { deciding.delete(n); clearTimeout(timer); decideStats.floor += 1; done({ ...floor(), why: 'offline' }); }
2268
+ });
2269
+ },
2188
2270
  stats: statsNow,
2189
2271
  expose(p) { probes = { ...probes, ...p }; },
2190
2272
  close() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.24.2",
3
+ "version": "0.24.4",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/shop/SELLING.md CHANGED
@@ -74,6 +74,22 @@ referral share you published in `shop.json` (10% if you kept the suggestion; 0 i
74
74
  shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
75
75
  yourself.** Nothing moves through Homie.
76
76
 
77
+ ## What you do, and what your AI does
78
+
79
+ Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
80
+ makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
81
+ your Stripe. What stays yours, in Stripe's own pages:
82
+
83
+ - making the Stripe account, and later activating it (your business details, bank account and identity checks);
84
+ - one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
85
+ - one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
86
+ - approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
87
+ in your office.
88
+
89
+ If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
90
+ Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
91
+ restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
92
+
77
93
  ## Before the first real sale
78
94
 
79
95
  1. Make (or pick) your Stripe account, verify it, and add a bank account.
package/shop/SHOP.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # The shop: selling in a studio's games with the studio's own Stripe
2
2
 
3
- `@homie-rocks/studio` 0.24.0. A studio sells items for its games through **its own Stripe account**. The studio
3
+ `@homie-rocks/studio` 0.24.0, set up with Stripe's own agent tools since 0.24.3. A studio sells items for its games
4
+ through **its own Stripe account**. The studio
4
5
  is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
5
6
  no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
6
7
  note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
@@ -40,6 +41,7 @@ At the studio's root, reviewed in git. Nothing secret is ever in it.
40
41
  | `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
41
42
  | `items[].taxCode` | a Stripe product tax code. Managed Payments needs one: by default `txcd_10201000` (video games, downloaded, permanent) or `txcd_10201001` (limited time). |
42
43
  | `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
44
+ | `catalog` | where the items are Products in Stripe: `["test"]`, `["test", "live"]`. `homie-studio shop catalog` writes it once Stripe's catalog matches; the Worker's checkouts then name each item's Product (0.24.3). |
43
45
 
44
46
  `homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
45
47
  crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
@@ -107,23 +109,59 @@ homie.rocks: not involved at any step.
107
109
  - Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
108
110
  acknowledgement) and that an unused item can still be refunded.
109
111
 
110
- ## Setting it up (Claude does the commands; the owner only uses pages)
112
+ ## Setting it up (the AI does the commands; the owner only uses Stripe's pages and one page here)
113
+
114
+ The AI works with **Stripe's own agent tools** (0.24.3): Stripe's agent plugin (`npm install -g @stripe/cli@latest &&
115
+ stripe agent setup`: Stripe's MCP server at `https://mcp.stripe.com` and Stripe's skills, for Claude Code and Codex) or
116
+ Stripe's connector in the Claude app. The owner signs it in once on Stripe's consent page and gives it **a sandbox
117
+ first**. An agent that cannot do OAuth uses an **Agent key** (a restricted key Stripe tags "Agent") from its
118
+ environment; from **2026-10-31** Stripe's MCP refuses full secret keys and restricted keys without that tag. The shop's
119
+ own key, in the Worker, is a plain restricted key and is not affected.
120
+
121
+ What only the owner does: make the Stripe account and (for live) activate it: business details, bank, identity, and,
122
+ for Managed Payments, its terms and Stripe's eligibility review; approve Stripe's sign-in page; make **one restricted
123
+ key** in Stripe's Dashboard (Stripe has no API that makes API keys) and paste it on the connect page; approve any
124
+ write Stripe sends to them for confirmation (a refund through Stripe's MCP, for one).
111
125
 
112
126
  1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
113
127
  `npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
114
- 2. In Stripe (the owner): a restricted key with **Checkout Sessions: Write, Charges: Write (refunds),
115
- PaymentIntents: Read, Disputes: Read**, and a webhook endpoint at `https://<site>/api/shop/hook` for
116
- `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`,
117
- `checkout.session.expired`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`,
118
- `charge.dispute.closed`.
119
- 3. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes)
120
- with those steps, both fields and the seller choice (the studio, or Stripe Managed Payments, with what each
121
- costs and does). The key and the secret go straight to the Worker secrets `STRIPE_KEY` and
122
- `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input. Test keys only, unless `--live`.
123
- 4. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
124
- refund it from `/_studio/office/shop`.
125
-
126
- Never ask for a key in a chat, never put one in a file, an argument, a log or a commit.
128
+ 2. **The catalog**, through Stripe's MCP: `homie-studio shop catalog` names the one read to make
129
+ (`stripe_api_read GET /v1/products`, expanding each default price); `homie-studio shop catalog --have <the saved
130
+ answer>` lists exactly the `stripe_api_write` calls still needed: each item a Product with the id
131
+ `homie_<studio slug>_<item id>` (the same in a sandbox and live), its name, blurb, a tax code (video games,
132
+ downloaded: `txcd_10201000`, or `txcd_10201001` for an item that lasts days or ends on a date; eligible for
133
+ Managed Payments) and metadata naming the studio and the item, with a default Price of shop.json's amount (a tip:
134
+ the Product only). A changed price is a new Price made the default; an item gone from shop.json is archived, never
135
+ deleted; a product the studio did not make is never named. When Stripe matches, it says so and writes
136
+ `"catalog": ["test"]` into shop.json (commit, deploy). shop.json's checked price is always what is charged; the
137
+ checkout names the Product so Stripe's Dashboard, reports and MCP see sales by product, and a Product missing in
138
+ this mode falls back to the item described inline.
139
+ 3. **Tax**, read through Stripe's MCP: `GET /v1/tax/settings` (with the studio as the seller, Stripe Tax needs
140
+ `status: active`, the business address, or checkouts fail) and `GET /v1/tax/registrations` (none: Stripe Tax
141
+ collects nothing anywhere until the owner registers somewhere). Reading only; the owner and their accountant decide.
142
+ 4. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes). The
143
+ owner makes one restricted key with **Checkout Sessions: Write, Charges: Write (refunds), PaymentIntents: Read,
144
+ Disputes: Read, Webhook Endpoints: Write**, not an Agent key, and pastes it. With it the page reads once (a typo is
145
+ caught here), **makes the webhook** at `https://<site>/api/shop/hook` for `checkout.session.completed`,
146
+ `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`,
147
+ `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed` (API version
148
+ `2025-03-31.basil`, metadata `homie: shop-v1`), and puts the key and the webhook's signing secret, which Stripe hands
149
+ back once, straight into the Worker secrets `STRIPE_KEY` and `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input.
150
+ An older endpoint the kit made for the same address is turned off, never deleted. The page offers the seller
151
+ choice (the studio, or Stripe Managed Payments, with what each costs and does) and, in test mode with Managed
152
+ Payments, tries one test checkout with it (expired at once) and says whether Stripe took it. Afterwards the owner
153
+ may set the key's Webhook Endpoints back to None (recommended; the shop never needs it again). A key without
154
+ Webhook Endpoints: the page takes a webhook signing secret the owner made and revealed in Stripe instead. Test keys
155
+ only, unless `--live`.
156
+ 5. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
157
+ refund it from `/_studio/office/shop`. Through Stripe's MCP, `GET /v1/webhook_endpoints` shows the endpoint the
158
+ page made (its `we_…` id): if it is not there, the MCP is signed in to another account or sandbox than the key.
159
+ 6. **Live**, only when the owner says so: they activate the account, give Stripe's MCP access to the live account,
160
+ `shop catalog --mode live` makes the live catalog the same way, and `shop connect --live` takes a live key.
161
+
162
+ Never ask for a key in a chat, never put one in a file, an argument, a log or a commit. **Never make a webhook endpoint
163
+ or event destination through Stripe's MCP**: its answer carries the signing secret into the conversation (in Claude
164
+ Code the Homie mod refuses the call). Never use a Stripe CLI login or a key found on the computer.
127
165
 
128
166
  ## The office
129
167
 
@@ -133,6 +171,15 @@ to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral
133
171
  refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
134
172
  with one tap; so is marking a referrer paid.
135
173
 
174
+ A refund made anywhere else (in Stripe's Dashboard, or through Stripe's MCP after the owner approved Stripe's own
175
+ confirmation link) reaches the shop as `refund.created` / `charge.refunded`, and the item leaves the player's account
176
+ the same way. If the Worker's key is an Agent key, Stripe holds the Worker's refunds for a person's approval
177
+ (`approval_required`): the office answers 202 "held", nothing changes until someone approves it in Stripe (Settings,
178
+ Approvals), and the webhook finishes it. Reconnect with a plain restricted key to get the one tap back.
179
+
180
+ "How are sales?": `homie-studio shop` and `shop orders` (the studio's own books, no names), and through Stripe's MCP,
181
+ read-only, `stripe_analytics` or `stripe_api_read` on the balance, payouts and checkout sessions.
182
+
136
183
  ## Referrals
137
184
 
138
185
  - A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
@@ -150,5 +197,7 @@ with one tap; so is marking a referrer paid.
150
197
  ## Testing without money
151
198
 
152
199
  The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
153
- against Stripe's own API description) and sign webhooks with a test secret. `STRIPE_API_BASE` (a loopback address
200
+ against Stripe's own API description) and sign webhooks with a test secret. stripe-mock also takes every write
201
+ `shop catalog` lists, a checkout that names its Product, and the webhook the connect page makes. Stripe's MCP is
202
+ never called by the tests: `shop catalog --have` reads an answer in the shapes Stripe's API documents. `STRIPE_API_BASE` (a loopback address
154
203
  only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
@@ -7,6 +7,7 @@
7
7
  "entry": "src/main.ts",
8
8
  "netplay": { "v": 1, "public": true, "movement": "owner" },
9
9
  "saves": true,
10
+ "decide": false,
10
11
  "landing": {
11
12
  "pitch": "Your hero keeps every level. The night does not.",
12
13
  "howToPlay": [
@@ -43,7 +43,7 @@
43
43
  * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
44
  * every lab call is a no-op.
45
45
  */
46
- import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
46
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type DecidePicks, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
47
47
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
48
48
  import vocabulary from '../agents.json';
49
49
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
@@ -376,12 +376,66 @@ function stepHost(dt: number): void {
376
376
  b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
377
377
  if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
378
378
  }
379
- if (live) stepSlimes(dt, now);
379
+ if (live) { stepDirector(now); stepSlimes(dt, now); }
380
380
  // Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
381
381
  net.state('quests', openQuests());
382
382
  room.update();
383
383
  }
384
384
 
385
+ /* ------------------------------------------------------------------ the slimes' director (section 20, opt-in) */
386
+ /**
387
+ * Every 6 s of a live night the host asks the studio's decision model how the slimes should hunt (`net.decide`: Clef,
388
+ * three typed questions, never text): a tactic, whether a wave comes now, how hard to push. It runs only when game.json
389
+ * says "decide": true (the room answers "off" otherwise, and asks are spent from the studio's AI brains' day); without it,
390
+ * over budget or slow, the floor answers: chase, no wave, steady, which is how the vale always played. On a kids server
391
+ * the slimes never gang up on the most hurt hero and the vale never pushes past steady. The heroes' dial is untouched:
392
+ * this is the slimes' mind, not the bots'.
393
+ */
394
+ type Tactic = 'chase' | 'surround' | 'weakest' | 'regroup';
395
+ const TACTICS: Record<Tactic, string> = {
396
+ chase: 'Rush the nearest hero',
397
+ surround: 'Spread out and close in on a hero from every side',
398
+ weakest: 'Gang up on the most hurt hero',
399
+ regroup: 'Fall back to the King Slime and gather before the next push',
400
+ };
401
+ /** How often a slime comes, by pressure (0 a breather .. 4 fierce). 1100 ms at 2 is the vale as it always was. */
402
+ const SPAWN_MS = [1900, 1450, 1100, 850, 700];
403
+ const DIRECTOR_MS = 6000;
404
+ const director = { tactic: 'chase' as Tactic, pressure: 2, waveLeft: 0, by: 'floor', at: 0, asking: false, log: [] as Record<string, unknown>[] };
405
+ const directorFloor = (): DecidePicks => ({ tactic: 'chase', wave: false, pressure: 2 });
406
+ function stepDirector(now: number): void {
407
+ if (!room.hosting || director.asking || now < director.at) return;
408
+ director.at = now + DIRECTOR_MS;
409
+ director.asking = true;
410
+ const kidsRoom = kids();
411
+ const king = slimes.find((o) => o.size === 3);
412
+ const heroes = [...room.bodies.values()].filter((b) => !b.bot && b.seat !== null);
413
+ const state = {
414
+ heroes: heroes.map((b) => ({ hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN), level: b.level, nearKing: king ? Math.round(Math.hypot(king.x - b.x, king.y - b.y)) : null })),
415
+ slimes: { count: slimes.length, big: slimes.filter((o) => o.size === 2).length, king: king ? Math.round((100 * king.hp) / king.maxHp) : null },
416
+ night: { secondsLeft: room.clock().secondsLeft }, now: { tactic: director.tactic, pressure: director.pressure },
417
+ ...(kidsRoom ? { note: 'Young players: keep it gentle' } : {}),
418
+ };
419
+ const tactics = Object.fromEntries(Object.entries(TACTICS).filter(([k]) => !(kidsRoom && k === 'weakest')));
420
+ const asked = performance.now();
421
+ void net.decide(state, {
422
+ tactic: { type: 'choice', instructions: 'How should the slimes hunt the heroes for the next few seconds? A fun fight, not a massacre: hurt heroes deserve a breather.', criteria: tactics },
423
+ wave: { type: 'noul', instructions: 'Should a wave of extra slimes come now?', criteria: { true: 'The heroes are healthy and it has gone quiet', false: 'The heroes are hurt, down or few' } },
424
+ pressure: { type: 'score', instructions: 'How hard should the vale push the heroes right now?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
425
+ }, { floor: directorFloor }).then((d) => {
426
+ director.asking = false;
427
+ const t = String(d.picks['tactic'] ?? 'chase') as Tactic;
428
+ director.tactic = t in tactics ? t : 'chase';
429
+ director.pressure = clamp(Math.round(Number(d.picks['pressure'] ?? 2)), 0, kidsRoom ? 2 : 4);
430
+ if (d.picks['wave'] === true && director.waveLeft === 0) director.waveLeft = 3;
431
+ director.by = d.by;
432
+ director.log.push({ at: Date.now(), by: d.by, why: d.why ?? null, ms: Math.round(performance.now() - asked), tactic: director.tactic, pressure: director.pressure, wave: d.picks['wave'] === true });
433
+ if (director.log.length > 40) director.log.shift();
434
+ // Every screen sees what the slimes are up to (only when a model decided; the floor is the vale as always).
435
+ net.state('slimes', d.by === 'floor' ? null : { tactic: director.tactic, pressure: director.pressure });
436
+ });
437
+ }
438
+
385
439
  /* ------------------------------------------------------------------ the guides' hands */
386
440
  const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
387
441
  const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
@@ -498,8 +552,11 @@ function stepSlimes(dt: number, now: number): void {
498
552
  if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
499
553
  const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
500
554
  const people = bodies.filter((b) => !b.bot).length;
501
- if (now >= spawnAt && slimes.length < 6 + people * 2) {
502
- spawnAt = now + 1100;
555
+ // The director's pressure (0 a breather .. 4 fierce; 2 is how the vale always played) sets how fast and how many.
556
+ const cap = Math.max(4, 6 + people * 2 + (director.pressure - 2) * 2) + (director.waveLeft > 0 ? 3 : 0);
557
+ if ((now >= spawnAt || director.waveLeft > 0) && slimes.length < cap) {
558
+ spawnAt = now + SPAWN_MS[director.pressure]!;
559
+ if (director.waveLeft > 0) director.waveLeft -= 1;
503
560
  const edge = Math.floor(Math.random() * 4);
504
561
  const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
505
562
  const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
@@ -517,10 +574,18 @@ function stepSlimes(dt: number, now: number): void {
517
574
  if (struck.has(s.id)) continue;
518
575
  let target: Body | null = null; let bd = Infinity;
519
576
  for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
577
+ // The director's tactic (section 20; chase is how the vale always played): the most hurt hero, a ring round the
578
+ // nearest, or back to the King to gather (a hero close by is still bitten).
579
+ if (director.tactic === 'weakest' && prey.length > 1) target = prey.reduce((a, b) => (b.hp / b.maxHp < a.hp / a.maxHp ? b : a));
520
580
  const speed = s.size === 3 ? 70 : 95 - s.size * 10;
521
581
  if (target) {
522
- const dx = target.x - s.x; const dy = target.y - s.y; const dist = Math.hypot(dx, dy) || 1;
523
- s.x += (dx / dist) * speed * dt; s.y += (dy / dist) * speed * dt;
582
+ let gx = target.x; let gy = target.y;
583
+ const king = slimes.find((o) => o.size === 3);
584
+ if (director.tactic === 'surround' && bd > 90) { const a = s.id * 2.39996; gx += Math.cos(a) * 80; gy += Math.sin(a) * 80; }
585
+ if (director.tactic === 'regroup' && king && s !== king && bd > 140) { gx = king.x + Math.cos(s.id) * 70; gy = king.y + Math.sin(s.id) * 70; }
586
+ const dx = gx - s.x; const dy = gy - s.y; const dist = Math.hypot(target.x - s.x, target.y - s.y) || 1;
587
+ const step = Math.hypot(dx, dy) || 1;
588
+ if (step > 4) { s.x += (dx / step) * speed * dt; s.y += (dy / step) * speed * dt; }
524
589
  if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
525
590
  s.hitAt = now;
526
591
  target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
@@ -919,6 +984,13 @@ function hud(t: number): void {
919
984
  // Top centre; on a narrow screen where that would touch the panel, just under it.
920
985
  if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
921
986
  else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
987
+ // What the slimes are up to, when the studio's decision model chose it (section 20; nothing shows on the floor).
988
+ const mind = c.phase === 'live' ? net.stateOf<{ tactic: string; pressure: number }>('slimes') : null;
989
+ if (mind) {
990
+ const words: Record<string, string> = { chase: 'rushing you', surround: 'closing in from every side', weakest: 'hunting the hurt', regroup: 'gathering round the King' };
991
+ ctx.font = SMALL; ctx.fillStyle = '#e7a86b'; ctx.textAlign = 'center';
992
+ ctx.fillText(`Slimes: ${words[mind.tactic] ?? mind.tactic}`, cw / 2, x0 + pw + 10 > cw / 2 - 80 ? y0 + 62 + 40 : 50);
993
+ }
922
994
  ctx.textAlign = 'center';
923
995
  if (c.phase === 'over') {
924
996
  const rows = room.results().slice(0, 5);
@@ -1144,6 +1216,8 @@ net.expose({
1144
1216
  me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
1145
1217
  log: guideLog.slice(),
1146
1218
  }),
1219
+ // The slimes' director (section 20): its tactic now, and each decision (who answered, how long it took).
1220
+ director: () => ({ tactic: director.tactic, pressure: director.pressure, by: director.by, log: director.log.slice(), net: net.stats().decides ?? null }),
1147
1221
  // Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
1148
1222
  camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
1149
1223
  labels: () => shownLabels.map((l) => ({ text: l.text, self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),