@homie-rocks/studio 0.38.0 → 0.40.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 (60) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/bin/homie-studio.mjs +13 -1
  3. package/lib/build.mjs +4 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/port-check.mjs +33 -7
  8. package/lib/rules-build.mjs +8 -1
  9. package/lib/rules-check-worker.mjs +3 -3
  10. package/lib/rules-check.mjs +3 -1
  11. package/lib/scaffold.mjs +19 -6
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +82 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +74 -2
  18. package/package.json +17 -6
  19. package/rules/collision.ts +115 -0
  20. package/rules/core.ts +65 -25
  21. package/rules/host.ts +5 -1
  22. package/rules/math.ts +15 -5
  23. package/rules/rules.ts +33 -10
  24. package/rules/types.ts +2 -2
  25. package/rules/view.ts +126 -32
  26. package/starters/ember-vale/game.json +16 -4
  27. package/starters/ember-vale/map/main.json +66 -0
  28. package/starters/ember-vale/src/move.ts +31 -0
  29. package/starters/ember-vale/src/rules.ts +244 -0
  30. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  31. package/starters/ember-vale/tunables.json +100 -11
  32. package/starters/gem-rush/src/move.ts +9 -1
  33. package/starters/gem-rush/src/view.ts +2 -1
  34. package/starters/gem-rush-3d/game.json +23 -5
  35. package/starters/gem-rush-3d/map/main.json +153 -0
  36. package/starters/gem-rush-3d/src/move.ts +37 -0
  37. package/starters/gem-rush-3d/src/rules.ts +133 -0
  38. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  39. package/starters/gem-rush-3d/tunables.json +98 -11
  40. package/starters/hero-rush-3d/game.json +23 -5
  41. package/starters/hero-rush-3d/map/main.json +153 -0
  42. package/starters/hero-rush-3d/src/move.ts +49 -0
  43. package/starters/hero-rush-3d/src/rules.ts +200 -0
  44. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  45. package/starters/hero-rush-3d/tunables.json +260 -30
  46. package/tools/TOOLS.md +113 -0
  47. package/tools/tools.ts +28 -0
  48. package/worker/app-records.mjs +8 -0
  49. package/worker/index.mjs +26 -0
  50. package/worker/mcp-room.mjs +61 -0
  51. package/worker/mcp-store.mjs +45 -0
  52. package/worker/mcp-tools.mjs +105 -0
  53. package/worker/mcp.mjs +159 -0
  54. package/worker/office-page.mjs +1 -1
  55. package/worker/office.mjs +2 -1
  56. package/worker/purchase-mcp.mjs +3 -3
  57. package/worker/tool-events.mjs +14 -0
  58. package/worker/tool-identity.mjs +4 -0
  59. package/worker/tool-webhook.mjs +28 -0
  60. package/worker/version.mjs +1 -1
@@ -1,62 +1,18 @@
1
- /*
2
- * EMBER VALE — the persistent-character starter (@homie-rocks/studio/saves; the guide is saves/SAVES.md).
3
- *
4
- * Two kinds of state, kept apart on purpose:
5
- *
6
- * THE ROOM (createRoom, netplay): tonight's hunt. Bodies, slimes, the clock, who struck what. Rounds ("nights")
7
- * start the moment the first browser arrives (bots in the empty seats), arrivals take a bot's place, and a room
8
- * forgets everything 60 s after its last player leaves. That is fine: nothing here has to last.
9
- *
10
- * THE SAVE (createSaves): your hero. Name, level, experience, gold, hardcore or not. Loaded when you arrive,
11
- * saved when it changes, on this device at once and in the studio's cloud when online, so it follows you to
12
- * every device you sign in on. Lifetime stats (kills, gold, time played) only add up and outlive any hero.
13
- *
14
- * The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
15
- * hero and saves it. A hardcore hero who falls becomes a memorial in the Hall of the Fallen, and its save is wiped
16
- * in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
17
- *
18
- * NAMES: the room knows a person by their seat's name (their account's, else a two-word handle such as "Velvet
19
- * Comet"); the vale shows their HERO's name, over their head and in the night's ranking, for them and for everyone
20
- * else. Each hero's browser tells the host its hero's name (a `hero` event); the host keeps every seat's in the keyed
21
- * state `heroes`, which a new host and a late joiner have already. On a kids server nobody's typed name reaches
22
- * anyone else (NETPLAY.md section 17): there the others are shown by their handles, as the room has them.
23
- *
24
- * A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
25
- * follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
26
- * The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
27
- *
28
- * Servers and the skill dial (contract revision 6): the vale's bots hunt at the room's dial (`room.skillOf(body)`:
29
- * how soon they notice a slime, how close they stand, how often they strike), and a beginner server's AI guide seats
30
- * are kept as AI bodies, marked " · AI". createRoom does the seats; the bots below read the dial.
31
- *
32
- * AI GUIDES (contract revision 7, @homie-rocks/studio/agents): on a beginner server the guide seats get a brain. The
33
- * vale's own words for it are ../agents.json: the goals a guide may take (follow, quest, lead, guard, back), the lines
34
- * it may say, and the asks a new hero can tap ("Help me with King Slime", "Take me to camp", "No thanks"). The HANDS
35
- * below play every frame at the dial: they walk to the goal, hunt what the goal says, and tell agents.done() when it is
36
- * done. The BRAIN is the server's (the Table's Workers AI, the owner's key, or the owner's own Claude in the seat); with
37
- * none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
38
- * never from a model; "Quiet AI" hides them.
39
- *
40
- * THE GAME LAB (`homie-studio lab ember-vale`): the strike is tuned in the lab. Its numbers are tunables.json (read
41
- * with lab.tunables: the lab's sliders move them and write kept values back), and the game tells the lab what a strike
42
- * is doing: its phases, the struck slime's hit points, squash and push, poses for the onion skin and arcs, and views.
43
- * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
- * every lab call is a no-op.
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 DecidePicks, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
47
- import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
48
- import vocabulary from '../agents.json';
1
+ /** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
2
+ import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
49
3
  // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
50
4
  import { lab } from '@homie-rocks/studio/lab';
51
- import tuning from '../tunables.json';
5
+ import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
6
+ import type rules from './rules';
7
+ const room = openRoom<typeof rules>({ fallback: 'solo' });
8
+ const net = room.net;
52
9
 
53
- /* ------------------------------------------------------------------ rules */
54
10
  const W = 1600;
55
11
  const H = 1000;
56
12
  const R = 22;
57
13
  const SPEED = 300;
58
14
  /** The strike's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
59
- const T = lab.tunables(tuning);
15
+ const T = { ...room.tune, strikeRange: room.tune.strikeRange * 50, slimePush: room.tune.slimePush * 50, lunge: room.tune.lunge * 50 };
60
16
  const DOWN_MS = 4000;
61
17
  const DOWN = 1;
62
18
  const STRIKING = 2;
@@ -129,404 +85,55 @@ async function fell(cause: string): Promise<void> {
129
85
  void ui.hall(`${h.name} has fallen`, `Level ${h.level}, ${h.kills} slimes, ${h.gold} gold, slain by ${cause}. The Hall remembers.`, true);
130
86
  }
131
87
 
132
- /* ------------------------------------------------------------------ the room */
133
- let slimes: Slime[] = [];
134
- let slimeSeq = 0;
135
- let spawnAt = 0;
136
- let kingAt = 0;
137
88
  const me = { x: W / 2, y: H / 2, face: 0, has: false, strikes: 0, flashAt: 0 };
138
- /** Where a guide can lead the party (agents.json "lead"). */
139
89
  const PLACES: Record<string, { x: number; y: number }> = { camp: { x: W / 2, y: H / 2 }, king: { x: W / 2, y: 120 }, 'east-woods': { x: W - 200, y: H / 2 + 30 } };
140
- /** When the last King Slime fell (host): a guide on the King's quest is done. */
141
- let kingSlainAt = 0;
142
-
143
- const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
144
- game: 'ember-vale',
145
- maxPlayers: 8,
146
- minBodies: 3,
147
- roundSeconds: 90,
148
- breakSeconds: 8,
149
- botName: (slot) => BOT_NAMES[slot % BOT_NAMES.length] as string,
150
- // createRoom gives every body in the room its own index (a mid-round arrival the lowest free one), so no two
151
- // bodies ever spawn on one spot; SPREAD puts the first few far apart round the camp (0°, 180°, 90°, 270°…).
152
- spawn: (slot, i) => {
153
- const a = ((SPREAD[i % SPREAD.length] as number) / SPREAD.length) * Math.PI * 2;
154
- const ring = 1 + Math.floor(i / SPREAD.length) * 0.5;
155
- const lvl = slot.bot ? 1 + (slot.slot % 4) : 1;
156
- const x = W / 2 + Math.cos(a) * 160 * ring; const y = H / 2 + Math.sin(a) * 120 * ring;
157
- return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, score: 0, x, y, hp: maxHpOf(lvl), maxHp: maxHpOf(lvl), level: lvl, face: a, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: x, ty: y };
158
- },
159
- pack: (b) => [q(b.x, 0), q(b.y, 0), Math.max(0, Math.round(b.hp)), b.maxHp, b.level, q(b.face, 2), b.flags],
160
- unpack: (f, b) => { b.x = f[0] as number; b.y = f[1] as number; b.hp = f[2] as number; b.maxHp = f[3] as number; b.level = f[4] as number; b.face = f[5] as number; b.flags = f[6] as number; },
161
- angles: [5],
162
- discrete: [2, 3, 4, 6],
163
- onRoundStart: () => { slimes = []; spawnAt = 0; kingAt = room.net.now() + 45_000; },
164
- fastWorld: () => slimes.map((s) => [s.id, Math.round(s.x), Math.round(s.y), Math.max(0, Math.round(s.hp)), s.size]),
165
- saveWorld: () => ({ slimes, seq: slimeSeq }),
166
- loadWorld: (w, fast) => {
167
- slimes = w?.slimes?.map((s) => ({ ...s })) ?? [];
168
- slimeSeq = Math.max(w?.seq ?? 0, ...slimes.map((s) => s.id));
169
- for (const [id, x, y, hp, size] of fast ?? []) {
170
- const s = slimes.find((o) => o.id === id);
171
- if (s) { s.x = x; s.y = y; s.hp = hp; } else slimes.push({ id, x, y, hp, maxHp: hp, size, hitAt: 0, lastHit: -1 });
172
- }
173
- },
174
- adopt: (b) => { me.x = b.x; me.y = b.y; me.has = true; },
175
- local: () => (me.has ? ({ ...mine(), x: me.x, y: me.y } as Body) : null),
176
- onTakeover: (b) => { b.score = 0; b.flags = 0; b.hp = b.maxHp; },
177
- // SEAT OR SOLO (NETPLAY.md section 28). A hero who is in the room but has no body in tonight's hunt (every body is
178
- // somebody's, or every seat is taken) is not left watching: after four seconds they hunt on their own with bots,
179
- // the line over the game says so, and they step into the room's hunt the moment it has a body for them.
180
- fallback: 'solo',
181
- });
182
- const net = room.net;
183
90
  const myLevel = (): number => hero?.level ?? 1;
184
-
185
- /* ------------------------------------------------------------------ heroes' names */
186
- /** A hero's name as the vale shows it: one line of at most 20 characters, never ending in an AI or bot mark. */
187
- const heroName = (raw: unknown): string =>
188
- stripAi(String(raw ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')).slice(0, 20).trim();
189
- /** On a kids server nobody's typed name reaches anyone else: the room's handles stay. */
190
- const kids = (): boolean => net.policy.kids === true;
191
- /** Every seat's hero name, as the host keeps it (keyed state `heroes`: seat → name). */
192
- const heroNames = (): Record<string, string> => net.stateOf<Record<string, string>>('heroes') ?? {};
193
- /** Host: one seat's hero name (null or '': none). */
194
- function setHeroName(seat: number, name: string | null): void {
195
- const next = { ...heroNames() };
196
- if (name && !kids()) next[seat] = name; else delete next[seat];
197
- net.state('heroes', next);
91
+ function viewBodies(): Body[] {
92
+ const bodies: Body[] = [];
93
+ room.each('hero', e => {
94
+ const row = room.status === 'offline' ? undefined : (net.slots ?? []).find(s => s.slot === e.seat);
95
+ const name = room.roster.find(s => s.seat === e.seat)?.name ?? `Player ${e.seat + 1}`;
96
+ bodies.push({ slot: e.seat, seat: e.driver === 'bot' ? null : e.seat, name, bot: e.driver !== 'person', agent: row?.agent,
97
+ score: e.score, x: e.pos.x * 50, y: e.pos.y * 50, hp: e.hp, maxHp: e.maxHp, level: e.level, face: Math.atan2(e.heading.y, e.heading.x),
98
+ flags: e.hp === 0 ? DOWN : e.striking ? STRIKING : 0,
99
+ downUntil: e.motion.downUntil * 50, atkAt: e.motion.attackTick * 50, strikeAt: e.motion.attackTick * 50, tx: e.target.x * 50, ty: e.target.y * 50 });
100
+ });
101
+ return bodies;
198
102
  }
199
- /** The name over a body and in the ranking: a person's hero's (on your own screen, yours at once), else the room's. */
103
+ function slimeRows(): SlimeRow[] { const rows: SlimeRow[] = []; room.each('slime', e => rows.push([e.serial, e.pos.x * 50, e.pos.y * 50, e.hp, e.size])); return rows; }
104
+ function viewBody(): Body | null { return viewBodies().find(b => b.seat === net.viewSeat && !b.bot) ?? null; }
105
+ function guideId(slot: number): string | null { let id: string | null = null; room.each('hero', e => { if (e.seat === slot) id = e.id; }); return id; }
106
+ const heroName = (raw: unknown): string => stripAi(String(raw ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')).slice(0, 20).trim();
107
+ const kids = (): boolean => net.policy.kids === true;
108
+ function heroNames(): Record<string, string> { const names: Record<string, string> = {}; room.each('hero', e => { if (e.driver === 'person' && e.name) names[e.seat] = heroName(e.name); }); return names; }
200
109
  function nameOf(b: { seat: number | null; bot: boolean; name: string; agent?: unknown }): string {
201
110
  // A bot or an AI is always the room's name for it: an AI's ends in " · AI", and no hero name may hide that.
202
111
  if (b.bot || b.agent || b.seat === null || net.isAgent(b.seat)) return b.name;
203
- if (!lookOnly && hero && b.seat === room.mySeat()) return heroName(hero.name) || b.name;
112
+ if (!lookOnly && hero && b.seat === room.seat) return heroName(hero.name) || b.name;
204
113
  return (kids() ? '' : heroNames()[b.seat]) || b.name;
205
114
  }
206
- /**
207
- * Every frame: my hero's name goes to the host until the room has it (so a new host, a reload and a lost event all
208
- * heal by themselves); the host keeps only seats a person still holds.
209
- */
210
- let heroToldAt = 0;
211
- let heroesKeptAt = 0;
115
+ let heroToldAt = -1e9;
212
116
  function keepNames(now: number): void {
213
- const seat = room.mySeat();
214
- if (!lookOnly && seat !== null) {
215
- const want = hero && !kids() ? heroName(hero.name) : '';
216
- if ((heroNames()[seat] ?? '') !== want) {
217
- if (room.hosting) setHeroName(seat, want);
218
- else if (now - heroToldAt > 1500) { heroToldAt = now; net.send('hero', { name: want }); }
219
- }
220
- }
221
- if (!room.hosting || now - heroesKeptAt < 1000) return;
222
- heroesKeptAt = now;
223
- const held = new Set([...net.peers.values()].filter((p) => p.seat !== null && !p.agent && !p.watch).map((p) => String(p.seat)));
224
- if (seat !== null) held.add(String(seat));
225
- const names = heroNames();
226
- const keep = kids() ? {} : Object.fromEntries(Object.entries(names).filter(([k]) => held.has(k)));
227
- if (Object.keys(keep).length !== Object.keys(names).length) net.state('heroes', keep);
117
+ const own = room.me;
118
+ if (lookOnly || !own || now - heroToldAt < 1500) return;
119
+ const name = hero && !kids() ? heroName(hero.name) : '', level = Math.min(99, myLevel());
120
+ if (own.name !== name || own.level !== level) { heroToldAt = now; room.command('hero', { name, level }); }
228
121
  }
229
-
230
- /* ------------------------------------------------------------------ the guides' brain (agents.json) */
231
- type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
232
- const greeted = new Map<number, Set<number>>();
233
- const warnedAt = new Map<number, number>();
234
- const agents = useAgents(net, vocabulary as unknown as Vocabulary, {
235
- // What a guide sees (host, at most every 2 s, under 2 KB): the vale near it, never a name, an account or typed text.
236
- view: (slot) => {
237
- const b = room.bodies.get(slot);
238
- if (!b) return {};
239
- const party: PartyRow[] = [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null)
240
- .map((o) => ({ seat: o.seat as number, dist: Math.round(Math.hypot(o.x - b.x, o.y - b.y)), hp: Math.round((100 * o.hp) / Math.max(1, o.maxHp)), down: Boolean(o.flags & DOWN) }))
241
- .filter((p) => p.dist < 900).sort((x, y) => x.dist - y.dist).slice(0, 6);
242
- const king = slimes.find((o) => o.size === 3);
243
- const near = slimes.filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 350).length;
244
- const c = room.clock();
245
- return {
246
- me: { hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN) },
247
- zone: zoneOf(b.x, b.y),
248
- // A brain thinks again when this changes, so it changes only for what matters: the King close, or a swarm.
249
- danger: king && Math.hypot(king.x - b.x, king.y - b.y) < 450 ? 'the King Slime' : near >= 5 ? 'slimes' : null,
250
- party, quests: openQuests(), slimes: { near, king: Boolean(king) }, round: { phase: c.phase, left: c.secondsLeft },
251
- };
252
- },
253
- // The scripted floor: with no AI, over budget, and between an AI's decisions. Synchronous; it never waits.
254
- decide: (v, { slot, goal }) => {
255
- const a = (v['asks'] as { k: string; args: Record<string, unknown> }[] | undefined)?.[0];
256
- if (a?.k === 'ask_help') return { goal: 'quest', args: { quest: a.args['quest'] }, say: 'quest_help', sayArgs: { quest: a.args['quest'] } };
257
- if (a?.k === 'lead_me') return { goal: 'lead', args: { place: a.args['place'] }, say: 'follow_me', sayArgs: { place: a.args['place'] } };
258
- if (a?.k === 'no_thanks') return { goal: 'back', say: 'bye' };
259
- const party = (v['party'] as PartyRow[] | undefined) ?? [];
260
- // What a hero asked for is carried through until it is done.
261
- if (goal && goal.state === 'active' && ['quest', 'lead', 'back'].includes(goal.goal)) return null;
262
- const seen = greeted.get(slot) ?? new Set<number>();
263
- greeted.set(slot, seen);
264
- const fresh = party.find((p) => !seen.has(p.seat) && p.dist < 420);
265
- if (fresh) { seen.add(fresh.seat); return { goal: 'follow', args: { seat: fresh.seat }, say: 'hello', sayArgs: { player: fresh.seat } }; }
266
- if (v['danger'] === 'the King Slime') {
267
- const warn = room.net.now() - (warnedAt.get(slot) ?? 0) > 30_000;
268
- if (warn) warnedAt.set(slot, room.net.now());
269
- return { goal: 'guard', ...(warn ? { say: 'careful', sayArgs: { thing: 'the King Slime' } } : {}) };
270
- }
271
- const hurt = party.find((p) => !p.down && p.hp < 40);
272
- if (hurt) return { goal: 'follow', args: { seat: hurt.seat } };
273
- if (party[0]) return { goal: 'follow', args: { seat: party[0].seat } };
274
- return { goal: 'lead', args: { place: 'camp' } };
275
- },
276
- });
277
- /** A guide's line over its head for a few seconds: the game's own words (agents.json), never a model's. */
278
122
  const bubbles = new Map<number, { text: string; until: number }>();
279
- /** What the e2e probe reads: goal changes (with the ask that led to one), asks, lines. Never a name or an account. */
280
123
  const guideLog: Record<string, unknown>[] = [];
281
124
  const logGuide = (row: Record<string, unknown>): void => { guideLog.push({ at: Date.now(), ...row }); if (guideLog.length > 80) guideLog.shift(); };
282
- agents.on('say', (e) => { bubbles.set(e.slot, { text: e.text, until: performance.now() + 3600 }); logGuide({ ev: 'say', slot: e.slot, line: e.line }); });
283
- agents.on('goal', (e) => logGuide({ ev: 'goal', slot: e.slot, goal: e.goal.goal, args: e.goal.args, from: e.goal.from, askAt: e.askAt }));
284
- agents.on('ask', (e) => logGuide({ ev: 'ask', slot: e.slot, k: e.k, args: e.args, from: e.from }));
285
- function mine(): Body {
286
- const b = room.mine();
287
- return b ?? ({ slot: -1, seat: room.mySeat(), name: net.name, bot: false, score: 0, x: me.x, y: me.y, hp: maxHpOf(myLevel()), maxHp: maxHpOf(myLevel()), level: myLevel(), face: me.face, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: 0, ty: 0 } as Body);
288
- }
289
-
290
- /* The host tells the killer (and the fallen) by seat: only the hero's own browser changes its hero. */
291
- net.on('event', (e: NetEvent) => {
292
- const d = (e.d ?? {}) as { xp?: number; gold?: number; cause?: string; to?: number };
293
- if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
294
- if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
295
- if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
296
- // A hero's browser names its hero (its own seat only: the relay stamps who sent it).
297
- if (e.k === 'hero' && room.hosting && typeof e.from === 'number' && !net.isAgent(e.from)) setHeroName(e.from, heroName((e.d as { name?: unknown } | null)?.name));
298
- });
299
- function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
300
- if (b.bot || b.seat === null) return;
301
- if (b.seat === room.mySeat()) { if (k === 'loot') gain(Number(d.xp), Number(d.gold)); else void fell(String(d.cause)); return; }
302
- net.send(k, { ...d, to: b.seat }, b.seat);
303
- }
304
-
305
- function strike(b: Body, now: number): void {
306
- b.atkAt = now; b.strikeAt = now;
307
- const hits: Slime[] = [];
308
- for (const s of slimes) {
309
- if (s.hp <= 0 || Math.hypot(s.x - b.x, s.y - b.y) > T.strikeRange + s.size * 10) continue;
310
- s.hp -= damageOf(b.level);
311
- s.lastHit = b.slot;
312
- // The hit lands, then pushes: the slime holds for the hit-stop, then eases back along the strike.
313
- const d = Math.hypot(s.x - b.x, s.y - b.y) || 1;
314
- s.kx = s.x; s.ky = s.y; s.kdx = (s.x - b.x) / d; s.kdy = (s.y - b.y) / d; s.kat = now + T.hitStopMs;
315
- hits.push(s);
316
- if (s.hp <= 0) {
317
- const xp = s.size === 3 ? 60 : 6 * s.size;
318
- const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
319
- b.score += xp;
320
- if (s.size === 3) kingSlainAt = now;
321
- tell(b, 'loot', { xp, gold });
322
- // Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
323
- if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
324
- }
325
- }
326
- if (lab.on && b.seat !== null && b.seat === room.mySeat()) labStrike(b, now, hits);
327
- slimes = slimes.filter((s) => s.hp > 0);
328
- }
329
-
330
- function down(b: Body, now: number, cause: string): void {
331
- b.flags = DOWN; b.hp = 0; b.downUntil = now + DOWN_MS;
332
- tell(b, 'down', { cause });
333
- }
334
-
335
- function stepHost(dt: number): void {
336
- const now = net.now();
337
- const live = room.round?.phase === 'live';
338
- const myS = room.mySeat();
339
- for (const b of room.bodies.values()) {
340
- if (b.flags & DOWN) {
341
- if (now >= b.downUntil) { b.flags = 0; b.hp = b.maxHp; b.x = W / 2 + (Math.random() - 0.5) * 300; b.y = H / 2 + (Math.random() - 0.5) * 200; room.moved(b); }
342
- continue;
343
- }
344
- let wants = false;
345
- const guide = b.bot && b.agent?.role === 'guide' ? agents.goalOf(b.slot) : null;
346
- if (guide) wants = stepGuide(b, guide, room.skillOf(b), dt, now);
347
- else if (b.bot && lab.stage === 'dummy') wants = false; // the lab's training stage: bots stand by
348
- else if (b.bot) {
349
- // Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
350
- // room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
351
- // the front (positioning), aim a little off (aimNoise), and strike sooner the more aggressive they are.
352
- const s = room.skillOf(b);
353
- const near = nearestSlime(b.x, b.y);
354
- const reach = standoff(s, 460, 220);
355
- if (!b.seenAt || now - b.seenAt >= s.reactionMs) {
356
- b.seenAt = now;
357
- const t0 = near && Math.hypot(near.x - b.x, near.y - b.y) < reach ? near : null;
358
- b.ax = t0 ? t0.x + jitter(s, 40) : undefined; b.ay = t0 ? t0.y + jitter(s, 40) : undefined;
359
- }
360
- const t = b.ax !== undefined && b.ay !== undefined ? { x: b.ax, y: b.ay } : null;
361
- const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
362
- if (!t && Math.hypot(b.tx - b.x, b.ty - b.y) < 30) { b.tx = 200 + Math.random() * (W - 400); b.ty = 150 + Math.random() * (H - 300); }
363
- const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
364
- if (dist > 60) { b.x += (dx / dist) * SPEED * 0.75 * dt; b.y += (dy / dist) * SPEED * 0.75 * dt; b.face = Math.atan2(dy, dx); }
365
- wants = Boolean(t && dist < T.strikeRange && now - b.atkAt > 1400 - 900 * s.aggression);
366
- } else if (b.seat === myS) {
367
- b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
368
- wants = me.strikes > 0; me.strikes = 0;
369
- } else {
370
- const a = room.avatar(b);
371
- if (a) {
372
- room.bound(b, { x: Number(a[0]), y: Number(a[1]) }, SPEED, dt);
373
- b.face = Number(a[2]) || 0;
374
- // The hero's level comes from its own browser (it owns the save). A game with stakes would check it.
375
- b.level = clamp(Math.floor(Number(a[3]) || 1), 1, 99); b.maxHp = maxHpOf(b.level);
376
- }
377
- wants = Boolean(room.presses(b)['strike']);
378
- }
379
- b.hp = Math.min(b.hp, b.maxHp);
380
- b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
381
- if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
382
- }
383
- if (live) { stepDirector(now); stepSlimes(dt, now); }
384
- // Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
385
- net.state('quests', openQuests());
386
- room.update();
387
- }
388
-
389
- /* ------------------------------------------------------------------ the slimes' director (section 20, opt-in) */
390
- /**
391
- * Every 6 s of a live night the host asks the studio's decision model how the slimes should hunt (`net.decide`: Clef,
392
- * three typed questions, never text): a tactic, whether a wave comes now, how hard to push. It runs only when game.json
393
- * says "decide": true (the room answers "off" otherwise, and asks are spent from the studio's AI brains' day); without it,
394
- * over budget or slow, the floor answers: chase, no wave, steady, which is how the vale always played. On a kids server
395
- * the slimes never gang up on the most hurt hero and the vale never pushes past steady. The heroes' dial is untouched:
396
- * this is the slimes' mind, not the bots'.
397
- */
398
- type Tactic = 'chase' | 'surround' | 'weakest' | 'regroup';
399
- const TACTICS: Record<Tactic, string> = {
400
- chase: 'Rush the nearest hero',
401
- surround: 'Spread out and close in on a hero from every side',
402
- weakest: 'Gang up on the most hurt hero',
403
- regroup: 'Fall back to the King Slime and gather before the next push',
404
- };
405
- /** How often a slime comes, by pressure (0 a breather .. 4 fierce). 1100 ms at 2 is the vale as it always was. */
406
- const SPAWN_MS = [1900, 1450, 1100, 850, 700];
407
- const DIRECTOR_MS = 6000;
408
- const director = { tactic: 'chase' as Tactic, pressure: 2, waveLeft: 0, by: 'floor', at: 0, asking: false, log: [] as Record<string, unknown>[] };
409
- const directorFloor = (): DecidePicks => ({ tactic: 'chase', wave: false, pressure: 2 });
410
- function stepDirector(now: number): void {
411
- if (!room.hosting || director.asking || now < director.at) return;
412
- director.at = now + DIRECTOR_MS;
413
- director.asking = true;
414
- const kidsRoom = kids();
415
- const king = slimes.find((o) => o.size === 3);
416
- const heroes = [...room.bodies.values()].filter((b) => !b.bot && b.seat !== null);
417
- const state = {
418
- 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 })),
419
- slimes: { count: slimes.length, big: slimes.filter((o) => o.size === 2).length, king: king ? Math.round((100 * king.hp) / king.maxHp) : null },
420
- night: { secondsLeft: room.clock().secondsLeft }, now: { tactic: director.tactic, pressure: director.pressure },
421
- ...(kidsRoom ? { note: 'Young players: keep it gentle' } : {}),
422
- };
423
- const tactics = Object.fromEntries(Object.entries(TACTICS).filter(([k]) => !(kidsRoom && k === 'weakest')));
424
- const asked = performance.now();
425
- void net.decide(state, {
426
- 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 },
427
- 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' } },
428
- pressure: { type: 'score', instructions: 'How hard should the vale push the heroes right now?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
429
- }, { floor: directorFloor }).then((d) => {
430
- director.asking = false;
431
- const t = String(d.picks['tactic'] ?? 'chase') as Tactic;
432
- director.tactic = t in tactics ? t : 'chase';
433
- director.pressure = clamp(Math.round(Number(d.picks['pressure'] ?? 2)), 0, kidsRoom ? 2 : 4);
434
- if (d.picks['wave'] === true && director.waveLeft === 0) director.waveLeft = 3;
435
- director.by = d.by;
436
- 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 });
437
- if (director.log.length > 40) director.log.shift();
438
- // Every screen sees what the slimes are up to (only when a model decided; the floor is the vale as always).
439
- net.state('slimes', d.by === 'floor' ? null : { tactic: director.tactic, pressure: director.pressure });
440
- });
441
- }
442
-
443
- /* ------------------------------------------------------------------ the guides' hands */
444
- const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
445
- const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
446
- const huntFrom = new Map<number, number>();
447
- const arrivedAt = new Map<number, number>();
448
- /**
449
- * A guide's body, every host frame, doing its goal at the dial (`s`): where it stands (positioning), how soon it
450
- * notices (reactionMs), how far off it strikes (aimNoise), how eagerly (aggression). Returns whether it strikes now.
451
- */
452
- function stepGuide(b: Body, g: Goal, s: Skill, dt: number, now: number): boolean {
453
- let tx = b.x; let ty = b.y; let hunt: Slime | null = null;
454
- const nearTo = (x: number, y: number, r: number): Slime | null => { const n = nearestSlime(x, y); return n && Math.hypot(n.x - x, n.y - y) < r ? n : null; };
455
- switch (g.goal) {
456
- case 'follow': {
457
- const p = bodyOfSeat(g.args['seat']);
458
- if (!p) { agents.done(b.slot, false); break; }
459
- // A step behind the hero (closer at a higher dial), each guide to its own side, and the slime that comes for them.
460
- const back = standoff(s, 70, 150);
461
- const side = (b.slot % 2 ? 1 : -1) * (0.5 + (b.slot % 3) * 0.25);
462
- tx = p.x - Math.cos(p.face + side) * back; ty = p.y - Math.sin(p.face + side) * back;
463
- hunt = nearTo(p.x, p.y, 200);
464
- break;
465
- }
466
- case 'quest': {
467
- const quest = g.args['quest'];
468
- if (quest === 'king-slime') {
469
- if (kingSlainAt > g.at) { agents.done(b.slot, true); break; }
470
- hunt = slimes.find((o) => o.size === 3) ?? null;
471
- if (!hunt) { tx = PLACES['king']!.x; ty = PLACES['king']!.y + 70; }
472
- } else if (quest === 'big-slime') {
473
- hunt = slimes.filter((o) => o.size === 2).sort((x, y) => Math.hypot(x.x - b.x, x.y - b.y) - Math.hypot(y.x - b.x, y.y - b.y))[0] ?? null;
474
- if (!hunt) agents.done(b.slot, true);
475
- } else {
476
- // A slime hunt: the slimes near the party, three of them.
477
- if (!huntFrom.has(b.slot) || huntFrom.get(b.slot)! > b.score) huntFrom.set(b.slot, b.score);
478
- if (b.score - huntFrom.get(b.slot)! >= 18) { huntFrom.delete(b.slot); agents.done(b.slot, true); break; }
479
- const ps = people();
480
- const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
481
- const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
482
- hunt = nearTo(cx, cy, 520) ?? nearestSlime(b.x, b.y);
483
- }
484
- break;
485
- }
486
- case 'lead': {
487
- const at = PLACES[String(g.args['place'])] ?? PLACES['camp']!;
488
- tx = at.x; ty = at.y;
489
- // Done when it is there and a hero came along; given up when nobody came within 20 s of it arriving.
490
- if (Math.hypot(at.x - b.x, at.y - b.y) < 60) {
491
- if (!arrivedAt.has(b.slot)) arrivedAt.set(b.slot, now);
492
- if (people().some((o) => Math.hypot(o.x - at.x, o.y - at.y) < 280)) { arrivedAt.delete(b.slot); agents.done(b.slot, true); }
493
- else if (now - arrivedAt.get(b.slot)! > 20_000) { arrivedAt.delete(b.slot); agents.done(b.slot, false); }
494
- } else arrivedAt.delete(b.slot);
495
- hunt = nearTo(b.x, b.y, 110);
496
- break;
497
- }
498
- case 'guard': {
499
- const ps = people().filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 700);
500
- const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
501
- const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
502
- tx = cx + 50; ty = cy + 40;
503
- hunt = nearTo(cx, cy, standoff(s, 260, 160));
504
- break;
505
- }
506
- case 'back': {
507
- const at = PLACES['camp']!;
508
- tx = at.x; ty = at.y;
509
- if (Math.hypot(at.x - b.x, at.y - b.y) < 50) agents.done(b.slot, true);
510
- break;
511
- }
512
- default: break;
513
- }
514
- // Two guides with the same goal never stand in one spot: each keeps its own place around it.
515
- if (!hunt && g.goal !== 'follow') { const k = b.slot * 2.4; tx += Math.cos(k) * 46; ty += Math.sin(k) * 46; }
516
- // Reaction time: a guide re-aims only every reactionMs, a little off at a low dial.
517
- if (hunt) {
518
- if (!b.seenAt || now - b.seenAt >= s.reactionMs) { b.seenAt = now; b.ax = hunt.x + jitter(s, 40); b.ay = hunt.y + jitter(s, 40); }
519
- tx = b.ax ?? hunt.x; ty = b.ay ?? hunt.y;
520
- }
521
- const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
522
- if (dist > (hunt ? 50 : 24)) { const v = Math.min(SPEED * 0.85, dist / Math.max(dt, 1e-3)); b.x = clamp(b.x + (dx / dist) * v * dt, R, W - R); b.y = clamp(b.y + (dy / dist) * v * dt, R, H - R); b.face = Math.atan2(dy, dx); }
523
- return Boolean(hunt && Math.hypot(hunt.x - b.x, hunt.y - b.y) < T.strikeRange + hunt.size * 10 && now - b.atkAt > 1400 - 900 * s.aggression);
524
- }
525
-
526
- /** The quests open now: a slime hunt always, the King Slime when it is out or close to coming, a big slime when one is. */
527
- function openQuests(): string[] {
528
- const now = net.now();
529
- return ['slime-hunt', ...(slimes.some((o) => o.size === 3) || kingAt - now < 30_000 ? ['king-slime'] : []), ...(slimes.some((o) => o.size === 2) ? ['big-slime'] : [])];
125
+ room.on('say', e => { if (e.slot < 0) return; bubbles.set(e.slot, { text: e.text, until: performance.now() + 3600 }); logGuide({ ev: 'say', slot: e.slot, line: e.line }); });
126
+ room.on('goal', e => logGuide({ ev: 'goal', slot: e.slot, goal: e.goal.goal, args: e.goal.args, from: e.goal.from }));
127
+ room.on('ask', e => logGuide({ ev: 'ask', slot: e.slot, k: e.k, args: e.args, from: e.from }));
128
+ room.on('loot', e => { if (e.id === room.me?.id && !lookOnly) { gain(e.xp, e.gold); net.spotlight(room.seat); } });
129
+ room.on('down', e => { if (e.id === room.me?.id && !lookOnly) void fell(e.cause); });
130
+ const directorLog: Record<string, unknown>[] = [];
131
+ room.on('director', e => { directorLog.push({ at: Date.now(), tactic: e.tactic, pressure: e.pressure, by: e.by }); if (directorLog.length > 40) directorLog.shift(); });
132
+ function mine(): Body { return viewBodies().find(b => b.seat === room.seat && !b.bot) ?? { slot: -1, seat: room.seat, name: net.name, bot: false, score: 0, x: me.x, y: me.y, hp: maxHpOf(myLevel()), maxHp: maxHpOf(myLevel()), level: myLevel(), face: me.face, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: 0, ty: 0 }; }
133
+ function nearestSlime(x: number, y: number): Slime | null {
134
+ let best: Slime | null = null, distance = Infinity;
135
+ for (const [id, sx, sy, hp, size] of slimeRows()) { const d = Math.hypot(sx - x, sy - y); if (d < distance) { distance = d; best = { id, x: sx, y: sy, hp, size, maxHp: hp, hitAt: 0, lastHit: -1 }; } }
136
+ return best;
530
137
  }
531
138
  const zoneOf = (x: number, y: number): string => {
532
139
  let best = 'vale'; let bd = 260;
@@ -534,98 +141,19 @@ const zoneOf = (x: number, y: number): string => {
534
141
  return best;
535
142
  };
536
143
 
537
- function nearestSlime(x: number, y: number): Slime | null {
538
- let best: Slime | null = null; let bd = Infinity;
539
- for (const s of slimes) { const d = Math.hypot(s.x - x, s.y - y); if (d < bd) { bd = d; best = s; } }
540
- return best;
541
- }
542
-
543
- /** A struck slime: held through the hit-stop, then eased back along the strike. True while it is (it does not chase). */
544
- function pushed(s: Slime, now: number): boolean {
545
- if (s.kat === undefined || s.kx === undefined || s.ky === undefined) return false;
546
- if (now < s.kat) return true;
547
- const u = Math.min(1, (now - s.kat) / Math.max(1, T.pushMs));
548
- const e = 1 - (1 - u) ** 3;
549
- s.x = clamp(s.kx + (s.kdx ?? 0) * T.slimePush * e, 20, W - 20); s.y = clamp(s.ky + (s.kdy ?? 0) * T.slimePush * e, 20, H - 20);
550
- // The push ends exactly where it should, at any frame rate; then the slime chases again.
551
- if (u >= 1) { delete s.kat; return false; }
552
- return true;
553
- }
554
- function stepSlimes(dt: number, now: number): void {
555
- const struck = new Set(slimes.filter((s) => pushed(s, now)).map((s) => s.id));
556
- if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
557
- const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
558
- const people = bodies.filter((b) => !b.bot).length;
559
- // The director's pressure (0 a breather .. 4 fierce; 2 is how the vale always played) sets how fast and how many.
560
- const cap = Math.max(4, 6 + people * 2 + (director.pressure - 2) * 2) + (director.waveLeft > 0 ? 3 : 0);
561
- if ((now >= spawnAt || director.waveLeft > 0) && slimes.length < cap) {
562
- spawnAt = now + SPAWN_MS[director.pressure]!;
563
- if (director.waveLeft > 0) director.waveLeft -= 1;
564
- const edge = Math.floor(Math.random() * 4);
565
- const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
566
- const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
567
- const size = Math.random() < 0.25 ? 2 : 1;
568
- slimes.push({ id: ++slimeSeq, x, y, hp: 18 * size, maxHp: 18 * size, size, hitAt: 0, lastHit: -1 });
569
- }
570
- if (now >= kingAt && !slimes.some((s) => s.size === 3)) {
571
- kingAt = now + 60_000;
572
- slimes.push({ id: ++slimeSeq, x: W / 2, y: 40, hp: 160, maxHp: 160, size: 3, hitAt: 0, lastHit: -1 });
573
- room.send('king', {});
574
- }
575
- // Slimes hunt heroes: the nearest person, and a bot only when no person is up.
576
- const prey = bodies.some((b) => !b.bot) ? bodies.filter((b) => !b.bot) : bodies;
577
- for (const s of slimes) {
578
- if (struck.has(s.id)) continue;
579
- let target: Body | null = null; let bd = Infinity;
580
- for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
581
- // The director's tactic (section 20; chase is how the vale always played): the most hurt hero, a ring round the
582
- // nearest, or back to the King to gather (a hero close by is still bitten).
583
- if (director.tactic === 'weakest' && prey.length > 1) target = prey.reduce((a, b) => (b.hp / b.maxHp < a.hp / a.maxHp ? b : a));
584
- const speed = s.size === 3 ? 70 : 95 - s.size * 10;
585
- if (target) {
586
- let gx = target.x; let gy = target.y;
587
- const king = slimes.find((o) => o.size === 3);
588
- if (director.tactic === 'surround' && bd > 90) { const a = s.id * 2.39996; gx += Math.cos(a) * 80; gy += Math.sin(a) * 80; }
589
- 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; }
590
- const dx = gx - s.x; const dy = gy - s.y; const dist = Math.hypot(target.x - s.x, target.y - s.y) || 1;
591
- const step = Math.hypot(dx, dy) || 1;
592
- if (step > 4) { s.x += (dx / step) * speed * dt; s.y += (dy / step) * speed * dt; }
593
- if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
594
- s.hitAt = now;
595
- target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
596
- if (target.hp <= 0) down(target, now, s.size === 3 ? 'the King Slime' : s.size === 2 ? 'a big slime' : 'a slime');
597
- }
598
- } else { s.x += Math.sin(now / 900 + s.id) * 20 * dt; s.y += Math.cos(now / 1100 + s.id) * 20 * dt; }
599
- s.x = clamp(s.x, 20, W - 20); s.y = clamp(s.y, 20, H - 20);
600
- }
601
- }
602
-
603
- /* ------------------------------------------------------------------ input */
604
144
  const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
605
145
  // A touch screen has STRIKE at the bottom right: the ask panel stands right above it (index.html, .touch).
606
146
  document.body.classList.toggle('touch', input.touch.enabled);
607
- /** A strike's lunge: my hero steps T.lunge along its facing over 90 ms, easing out (owner movement, under top speed). */
608
- const lunge = { at: -1e9, face: 0, done: 1 };
609
- function stepLunge(): void {
610
- if (lunge.done >= 1) return;
611
- const u = Math.min(1, (performance.now() - lunge.at) / 90);
612
- const e = 1 - (1 - u) ** 2;
613
- const d = (e - lunge.done) * T.lunge;
614
- lunge.done = e;
615
- me.x = clamp(me.x + Math.cos(lunge.face) * d, R, W - R); me.y = clamp(me.y + Math.sin(lunge.face) * d, R, H - R);
616
- }
617
- function stepMe(dt: number): void {
618
- const b = room.hosting ? room.mine() : null;
619
- const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
620
- const typing = !ui.idle();
621
- const m = typing || isDown ? { x: 0, y: 0 } : input.move();
622
- if (Math.hypot(m.x, m.y) > 0.05) { me.face = Math.atan2(m.y, m.x); me.x = clamp(me.x + m.x * SPEED * dt, R, W - R); me.y = clamp(me.y + m.y * SPEED * dt, R, H - R); me.has = true; }
623
- if (!typing && input.pressed('strike')) { if (room.hosting) me.strikes += 1; else room.press('strike'); me.flashAt = performance.now(); if (!isDown) { lunge.at = performance.now(); lunge.face = me.face; lunge.done = 0; } }
624
- if (!isDown) stepLunge();
625
- room.input([q(me.x, 0), q(me.y, 0), q(me.face, 2), myLevel()]);
626
- }
627
- function myViewBody(): Body | null { const s = room.mySeat(); return room.view().find((b) => b.seat === s && !b.bot) ?? null; }
628
-
147
+ function stepMe(): void {
148
+ const typing = !ui.idle(), own = room.me;
149
+ const m = typing || own?.hp === 0 ? { x: 0, y: 0 } : input.move();
150
+ const strike = !typing && input.pressed('strike');
151
+ room.input({ ax: Math.round(m.x * 127), ay: Math.round(m.y * 127), strike });
152
+ const body = room.me; me.has = Boolean(body);
153
+ if (body) { me.x = body.pos.x * 50; me.y = body.pos.y * 50; me.face = Math.atan2(body.heading.y, body.heading.x); }
154
+ if (strike) { me.flashAt = performance.now(); if (lab.on) labStrike(mine(), net.now(), []); }
155
+ }
156
+ function myViewBody(): Body | null { return viewBodies().find(b => b.seat === room.seat && !b.bot) ?? null; }
629
157
  /* ------------------------------------------------------------------ drawing */
630
158
  const canvas = document.getElementById('c') as HTMLCanvasElement;
631
159
  const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
@@ -662,7 +190,7 @@ let kickAt = -1e9;
662
190
  function striker(x: number, y: number, bodies: Body[]): Body | null {
663
191
  let best: Body | null = null; let bd = Infinity;
664
192
  for (const b of bodies) {
665
- const mineNow = !b.bot && b.seat === room.mySeat() && performance.now() - me.flashAt < T.flashMs;
193
+ const mineNow = !b.bot && b.seat === room.seat && performance.now() - me.flashAt < T.flashMs;
666
194
  if (!(b.flags & STRIKING) && !mineNow) continue;
667
195
  const d = Math.hypot(b.x - x, b.y - y);
668
196
  if (d < bd && d < T.strikeRange + 60) { bd = d; best = b; }
@@ -670,18 +198,19 @@ function striker(x: number, y: number, bodies: Body[]): Body | null {
670
198
  return best;
671
199
  }
672
200
  function watchSlimes(t: number, bodies: Body[]): void {
673
- const rows = room.fast() ?? [];
201
+ const rows = slimeRows() ?? [];
674
202
  const here = new Set<number>();
675
203
  for (const [id, x, y, hp, size] of rows) {
676
204
  here.add(id);
677
205
  const s = seen.get(id);
678
206
  if (!s) { seen.set(id, { x, y, hp, size, hitAt: -1e9, dx: 0, dy: 0 }); continue; }
679
207
  if (hp < s.hp) {
208
+ if (lab.on && subject.id === id) subject.hit = true;
680
209
  const by = striker(x, y, bodies);
681
210
  const d = by ? Math.hypot(x - by.x, y - by.y) || 1 : 1;
682
211
  s.hitAt = t; s.dx = by ? (x - by.x) / d : 0; s.dy = by ? (y - by.y) / d : -1;
683
212
  numbers.push({ x: x + ((numbers.length % 3) - 1) * 16, y: y - (12 + size * 10), text: `-${Math.round(s.hp - hp)}`, at: t });
684
- if (by && !by.bot && by.seat === room.mySeat()) kickAt = t;
213
+ if (by && !by.bot && by.seat === room.seat) kickAt = t;
685
214
  }
686
215
  s.x = x; s.y = y; s.hp = hp; s.size = size;
687
216
  }
@@ -690,9 +219,10 @@ function watchSlimes(t: number, bodies: Body[]): void {
690
219
  seen.delete(id);
691
220
  const by = striker(s.x, s.y, bodies);
692
221
  if (!by) continue; // gone without a strike: the night ended
222
+ if (lab.on && subject.id === id) subject.hit = true;
693
223
  pops.push({ x: s.x, y: s.y, size: s.size, at: t });
694
224
  numbers.push({ x: s.x, y: s.y - (12 + s.size * 10), text: `-${Math.round(s.hp)}`, at: t });
695
- if (!by.bot && by.seat === room.mySeat()) kickAt = t;
225
+ if (!by.bot && by.seat === room.seat) kickAt = t;
696
226
  for (let i = 0; i < 10; i += 1) { const a = lab.random() * Math.PI * 2; const v = 120 + lab.random() * 260; drops.push({ x: s.x, y: s.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 80, at: t, life: 260 + lab.random() * 200, colour: SLIME[s.size - 1] ?? '#7ad35a' }); }
697
227
  }
698
228
  if (drops.length > 200) drops.splice(0, drops.length - 200);
@@ -791,7 +321,7 @@ net.on('unchat', (e) => { for (const id of e.ids) chatBubbles.remove(id); });
791
321
  let shownChat: BubbleOut[] = [];
792
322
  let shownLabels: LabelOut[] = [];
793
323
  function aim(): Fit {
794
- const v = lookOnly ? room.viewBody() : null;
324
+ const v = lookOnly ? viewBody() : null;
795
325
  const focus = !lookOnly && me.has ? { x: me.x, y: me.y } : v ? { x: v.x, y: v.y } : null;
796
326
  const phone = Math.min(vw, vh) <= 540;
797
327
  // The Game Lab's views (outside the lab: the game's own camera, always).
@@ -820,8 +350,8 @@ function draw(t: number): void {
820
350
  ctx.fillStyle = i % 5 ? 'rgba(122,211,90,.10)' : 'rgba(255,170,80,.35)';
821
351
  ctx.beginPath(); ctx.arc(x, H - y, i % 5 ? 26 : 2.5, 0, Math.PI * 2); ctx.fill();
822
352
  }
823
- const fast = room.fast() ?? [];
824
- const everyone = room.view();
353
+ const fast = slimeRows() ?? [];
354
+ const everyone = viewBodies();
825
355
  watchSlimes(t, everyone);
826
356
  for (const [id, x, y, hp, size] of fast) {
827
357
  const r = 12 + size * 10;
@@ -839,7 +369,7 @@ function draw(t: number): void {
839
369
  ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
840
370
  }
841
371
  // The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
842
- const viewS = room.viewSeat();
372
+ const viewS = net.viewSeat;
843
373
  const sx = (x: number): number => vw / 2 + (x - (cam as Fit).x) * s;
844
374
  const sy = (y: number): number => vh / 2 + (y - (cam as Fit).y) * s;
845
375
  // Names are drawn after the vale, on the screen, at a size that reads on any screen; they never pile up.
@@ -849,10 +379,10 @@ function draw(t: number): void {
849
379
  const said: { x: number; y: number; head: number; text: string }[] = [];
850
380
  const heads = new Map<number, { x: number; y: number; seat: number; self: boolean }>();
851
381
  const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
852
- const meAt = (() => { const v = room.view().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
853
- for (const b of room.view()) {
382
+ const meAt = (() => { const v = viewBodies().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
383
+ for (const b of viewBodies()) {
854
384
  const self = !b.bot && b.seat === viewS;
855
- const local = self && !room.hosting && !net.watching;
385
+ const local = self && !net.watching;
856
386
  const x = local ? me.x : b.x; const y = local ? me.y : b.y;
857
387
  const isDown = Boolean(b.flags & DOWN);
858
388
  ctx.globalAlpha = isDown ? 0.35 : 1;
@@ -862,7 +392,7 @@ function draw(t: number): void {
862
392
  if (striking && (!sw || t - sw.at > T.strikeMs * 0.9)) swings.set(b.slot, { at: self && performance.now() - me.flashAt < T.flashMs ? t - (performance.now() - me.flashAt) : t, face: self ? me.face : b.face });
863
393
  const swing = swings.get(b.slot);
864
394
  if (swing) drawSwing(x, y, swing.face, t - swing.at);
865
- const isGuide = (net.slots ?? []).some((x) => x.slot === b.slot && x.agent?.role === 'guide');
395
+ const isGuide = b.agent?.role === 'guide';
866
396
  ctx.fillStyle = self ? '#ffcf6e' : isGuide ? '#7fd8c8' : b.bot ? '#8aa0b8' : '#f3ead2';
867
397
  ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
868
398
  ctx.fillStyle = '#0b0f0a'; ctx.beginPath(); ctx.arc(x + Math.cos(b.face) * 10, y + Math.sin(b.face) * 10, 5, 0, Math.PI * 2); ctx.fill();
@@ -964,10 +494,10 @@ function hud(t: number): void {
964
494
  const TITLE = '700 16px ui-sans-serif, system-ui, sans-serif';
965
495
  const SMALL = '600 13px ui-sans-serif, system-ui, sans-serif';
966
496
  // A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
967
- const v = lookOnly ? room.viewBody() : null;
968
- const heroes = room.view().filter((b) => !b.bot).length;
497
+ const v = lookOnly ? viewBody() : null;
498
+ const heroes = viewBodies().filter((b) => !b.bot).length;
969
499
  const title = lookOnly ? (v ? `${nameOf(v)} · Lv ${v.level}` : 'Watching the vale') : hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…';
970
- const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
500
+ const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(slimeRows() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
971
501
  const bar = v ? { colour: '#7ad35a', of: clamp(v.hp, 0, v.maxHp) / Math.max(1, v.maxHp) } : hero && !lookOnly ? { colour: '#ffcf6e', of: hero.xp / xpFor(hero.level) } : null;
972
502
  // The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
973
503
  ctx.font = TITLE; const titleW = ctx.measureText(title).width;
@@ -981,15 +511,16 @@ function hud(t: number): void {
981
511
  ctx.fillStyle = bar.colour; ctx.fillRect(x0 + 10, y0 + 32, 140 * clamp(bar.of, 0, 1), 6);
982
512
  }
983
513
  if (sub) { ctx.font = SMALL; ctx.fillStyle = '#e7d9b4'; ctx.fillText(sub, x0 + 10, y0 + (bar ? 54 : 46)); }
984
- const c = room.clock();
514
+ const c = (room.round ?? { phase: 'live', secondsLeft: 90, n: 1 });
985
515
  ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
986
516
  const cw = canvas.width / k;
987
- const clock = c.phase === 'over' ? `Dawn · night ${c.n + 1} in ${c.secondsLeft}s` : `Night ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}`;
517
+ const secondsLeft = Math.max(0, Math.ceil(c.secondsLeft));
518
+ const clock = c.phase === 'over' ? `Dawn · night ${c.n + 1} in ${secondsLeft}s` : `Night ${c.n} · ${Math.floor(secondsLeft / 60)}:${String(secondsLeft % 60).padStart(2, '0')}`;
988
519
  // Top centre; on a narrow screen where that would touch the panel, just under it.
989
520
  if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
990
521
  else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
991
522
  // What the slimes are up to, when the studio's decision model chose it (section 20; nothing shows on the floor).
992
- const mind = c.phase === 'live' ? net.stateOf<{ tactic: string; pressure: number }>('slimes') : null;
523
+ const mind = c.phase === 'live' ? (room.shared.directorBy === 'floor' ? null : { tactic: room.shared.tactic, pressure: room.shared.pressure }) : null;
993
524
  if (mind) {
994
525
  const words: Record<string, string> = { chase: 'rushing you', surround: 'closing in from every side', weakest: 'hunting the hurt', regroup: 'gathering round the King' };
995
526
  ctx.font = SMALL; ctx.fillStyle = '#e7a86b'; ctx.textAlign = 'center';
@@ -997,7 +528,7 @@ function hud(t: number): void {
997
528
  }
998
529
  ctx.textAlign = 'center';
999
530
  if (c.phase === 'over') {
1000
- const rows = room.results().slice(0, 5);
531
+ const rows = (room.round?.results ?? []).slice(0, 5);
1001
532
  // Under the page's own pills on a narrow screen (they are buttons, above the canvas), else under the clock.
1002
533
  const top = vw <= 540 ? 132 * (dpr / k) : 44;
1003
534
  ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
@@ -1073,13 +604,12 @@ let asksSig = '';
1073
604
  const askEls = new Map<string, HTMLButtonElement>();
1074
605
  function paintAsks(): void {
1075
606
  const panel = $('asks');
1076
- const mySeat = room.mySeat();
607
+ const mySeat = room.seat;
1077
608
  const here = me.has && mySeat !== null && !lookOnly && ui.idle();
1078
609
  let best: { slot: number; name: string; d: number } | null = null;
1079
610
  if (here) {
1080
- for (const b of room.view()) {
1081
- const sl = (net.slots ?? []).find((x) => x.slot === b.slot);
1082
- if (!sl?.agent || sl.agent.role !== 'guide') continue;
611
+ for (const b of viewBodies()) {
612
+ if (b.agent?.role !== 'guide') continue;
1083
613
  // The guide the panel is for keeps it while it is in reach, unless another is much nearer: two guides at the
1084
614
  // party's side would otherwise swap it four times a second, and a thumb's tap would land on a button just redrawn.
1085
615
  const d = Math.hypot(b.x - me.x, b.y - me.y);
@@ -1088,10 +618,11 @@ function paintAsks(): void {
1088
618
  }
1089
619
  }
1090
620
  if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
1091
- const quests = (net.stateOf<string[]>('quests') ?? (room.hosting ? openQuests() : ['slime-hunt'])).slice(0, 3);
621
+ const quests = room.shared.quests.slice(0, 3);
1092
622
  // Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone (either way up).
1093
623
  const zone = zoneOf(me.x, me.y);
1094
- const all = agents.askButtons(best.slot, { quests });
624
+ const id = guideId(best.slot);
625
+ const all = id ? room.askButtons(id, { quests }) : [];
1095
626
  const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
1096
627
  const order = [...all.filter((b) => b.k === 'ask_help'), ...lead, ...all.filter((b) => b.k === 'no_thanks')];
1097
628
  const buttons = order.slice(0, Math.min(innerWidth, innerHeight) < 540 ? 3 : 5);
@@ -1116,7 +647,8 @@ function paintAsks(): void {
1116
647
  btn.dataset['ask'] = b.k;
1117
648
  btn.addEventListener('click', () => {
1118
649
  if (asksFor < 0) return;
1119
- if (agents.ask(asksFor, b.k, b.args)) { logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
650
+ const id = guideId(asksFor);
651
+ if (id) { room.ask(id, b.k, b.args); logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
1120
652
  });
1121
653
  askEls.set(key, btn);
1122
654
  el = btn;
@@ -1143,7 +675,7 @@ function labStrike(b: Body, now: number, hits: Slime[]): void {
1143
675
  }
1144
676
  /** The subject slime as drawn (the fast world's row), or null once it is gone. */
1145
677
  function subjectSlime(): { x: number; y: number; hp: number; size: number } | null {
1146
- const row = (room.fast() ?? []).find((r) => r[0] === subject.id);
678
+ const row = (slimeRows() ?? []).find((r) => r[0] === subject.id);
1147
679
  return row ? { x: row[1], y: row[2], hp: row[3], size: row[4] } : null;
1148
680
  }
1149
681
  function labReport(): void {
@@ -1162,15 +694,6 @@ function labReport(): void {
1162
694
  else lab.phase(null);
1163
695
  if (sl) lab.pose('slime', { x: sl.x, y: sl.y, r: 12 + sl.size * 10 });
1164
696
  }
1165
- /** The lab's training stage: the hero at camp facing right, a big slime a step away, the bots off to the side. */
1166
- let staged = false;
1167
- function stageDummy(): void {
1168
- staged = true;
1169
- me.x = W / 2 - 70; me.y = H / 2; me.face = 0; me.has = true;
1170
- let i = 0;
1171
- for (const b of room.bodies.values()) if (b.bot) { b.x = 200 + (i % 2) * 1200; b.y = 160 + Math.floor(i / 2) * 680; b.tx = b.x; b.ty = b.y; i += 1; room.moved(b); }
1172
- slimes = [{ id: ++slimeSeq, x: W / 2 + 30, y: H / 2, hp: 36, maxHp: 36, size: 2, hitAt: 0, lastHit: -1 }];
1173
- }
1174
697
  const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.2 }, whole: { whole: true } });
1175
698
  lab.overlay('onion', (c, k) => {
1176
699
  const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
@@ -1198,10 +721,8 @@ lab.overlay('reach', (c, k) => {
1198
721
  let frames = 0;
1199
722
  function frame(t: number): void {
1200
723
  const dt = lab.time.dt(t, 0.05);
1201
- if (lab.stage === 'dummy' && !staged && room.hosting && room.round?.phase === 'live') stageDummy();
1202
- stepMe(dt);
724
+ stepMe();
1203
725
  keepNames(t);
1204
- if (room.hosting) stepHost(dt);
1205
726
  draw(t);
1206
727
  if (lab.on) labReport();
1207
728
  frames += 1;
@@ -1212,16 +733,11 @@ function frame(t: number): void {
1212
733
  net.expose({
1213
734
  // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1214
735
  chatBubbles: () => shownChat.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
1215
- scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
736
+ scores: () => viewBodies().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
1216
737
  // The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
1217
- guides: () => ({
1218
- hosting: room.hosting, talking: agents.talking, stats: agents.stats(),
1219
- now: agents.guides().map((g) => { const v = room.view().find((b) => b.slot === g.slot); return { slot: g.slot, seat: g.agent?.seat ?? null, name: g.name, goal: agents.goalOf(g.slot), x: v ? Math.round(v.x) : null, y: v ? Math.round(v.y) : null }; }),
1220
- me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
1221
- log: guideLog.slice(),
1222
- }),
738
+ guides: () => ({ now: viewBodies().filter(b => b.agent).map(b => { const e = room.get(guideId(b.slot) ?? ''); return { slot: b.slot, x: b.x, y: b.y, goal: e?.kind === 'hero' ? e.goal : null }; }), log: guideLog.slice() }),
1223
739
  // The slimes' director (section 20): its tactic now, and each decision (who answered, how long it took).
1224
- director: () => ({ tactic: director.tactic, pressure: director.pressure, by: director.by, log: director.log.slice(), net: net.stats().decides ?? null }),
740
+ director: () => ({ tactic: room.shared.tactic, pressure: room.shared.pressure, by: room.shared.directorBy, log: directorLog.slice() }),
1225
741
  // Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
1226
742
  camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
1227
743
  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) })),
@@ -1229,14 +745,13 @@ net.expose({
1229
745
 
1230
746
  exposePort(net, {
1231
747
  view: 'top',
1232
- self: () => (me.has && room.mySeat() !== null ? { x: me.x, y: me.y } : null),
748
+ self: () => (me.has && room.seat !== null ? { x: me.x, y: me.y } : null),
1233
749
  size: R,
1234
- score: () => { const s = room.mySeat(); return room.view().find((b) => b.seat === s && !b.bot)?.score ?? null; },
750
+ score: () => { const s = room.seat; return viewBodies().find((b) => b.seat === s && !b.bot)?.score ?? null; },
1235
751
  busy: () => !ui.idle(),
1236
752
  // `alive` is read by name by the playtest (port/probe.ts PortExtra): a downed hero is waiting to stand up again, and a
1237
753
  // press that moves nothing then is not a broken control.
1238
754
  extra: { alive: () => !((myViewBody()?.flags ?? 0) & DOWN), level: () => hero?.level ?? 0, frames: () => frames, saves: () => saves.status().mode },
1239
755
  });
1240
756
 
1241
- void net.ready.then(() => { const b = room.mine(); if (b) { me.x = b.x; me.y = b.y; me.has = true; } });
1242
757
  requestAnimationFrame(frame);