@homie-rocks/studio 0.14.4 → 0.16.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.
@@ -16,9 +16,22 @@
16
16
  * zone (gems inside it score double) is slow state on the keyed state channel,
17
17
  * so a joiner has it in its welcome and a promoted host inherits it.
18
18
  *
19
+ * WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never
20
+ * takes a seat. `net.viewSeat` says whose view to draw; the camera follows that
21
+ * player's body as their own browser does, the board marks their row, and with
22
+ * nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
23
+ * watcher on Auto cuts to whoever just bumped.
24
+ *
25
+ * SERVERS AND THE SKILL DIAL (contract revision 6): the bots play at the room's
26
+ * dial (`net.skillOf(slot)`: how late they see, how well they aim, whether they
27
+ * fight for the hot zone, how often they bump), which the party votes in the
28
+ * play page; a hybrid server's AI seats are kept as AI bodies (the Roster's
29
+ * reserve, marked " · AI"), an AI with a pass takes one, and a humans-only
30
+ * server's bots are off. The game says so: `caps: ['skill', 'agents']`.
31
+ *
19
32
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
20
33
  */
21
- import { createNetplay, Roster, q, lerp, capMove, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
34
+ import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
22
35
  // The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
23
36
  import { exposePort } from '@homie-rocks/studio/port';
24
37
 
@@ -38,10 +51,12 @@ const KNOCK_RANGE = 110;
38
51
  const KNOCK_SPEED = 950;
39
52
  const KNOCK_MS = 420;
40
53
  const ZONE_MS = 12_000;
41
- const PALETTE = ['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482'];
54
+ /** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
55
+ const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
42
56
  const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
43
57
  const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
44
- const label = (name: string, bot: boolean): string => (bot ? `${name} · bot` : name);
58
+ /** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
59
+ const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
45
60
 
46
61
  /* ------------------------------------------------------------ wire shapes */
47
62
  /** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */
@@ -66,10 +81,13 @@ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[];
66
81
  * Default `owner`: each browser moves its own body and the host bounds it.
67
82
  */
68
83
  const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
69
- const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, checkpoint: () => checkpoint() });
84
+ // caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
85
+ const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
86
+ /** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
87
+ const policy = () => net.policy;
70
88
 
71
89
  /* ------------------------------------------------------------ host state */
72
- let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
90
+ let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
73
91
  let bodies = new Map<number, Body>();
74
92
  let gems: Gem[] = [];
75
93
  let gemSeq = 0;
@@ -80,11 +98,15 @@ let hosting = false;
80
98
  let cheatResets = 0;
81
99
  let controlResets = 0;
82
100
  let predictionError = 0;
101
+ /** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
102
+ let pickups = new Map<number, number>();
83
103
 
84
104
  /* ------------------------------------------------ this browser's avatar */
85
105
  const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
86
106
  /** Offline (no shell) plays as a local seat 0, with keys or touch. */
87
107
  const mySeat = (): number | null => (net.offline ? 0 : net.seat);
108
+ /** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
109
+ const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
88
110
 
89
111
  /* ------------------------------------------------------- replica view */
90
112
  const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
@@ -143,6 +165,8 @@ function startRound(n: number): void {
143
165
  hostMoved(b);
144
166
  }
145
167
  gems = Array.from({ length: GEM_COUNT }, newGem);
168
+ pickups = new Map();
169
+ sight.clear();
146
170
  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
147
171
  setZone(newZone((zone?.n ?? 0) + 1));
148
172
  net.round(round);
@@ -154,7 +178,8 @@ function endRound(): void {
154
178
  if (!round) return;
155
179
  const now = net.now();
156
180
  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
157
- const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));
181
+ const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
182
+ const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));
158
183
  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
159
184
  net.round(round);
160
185
  }
@@ -164,7 +189,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
164
189
  hosting = true;
165
190
  if (e.promoted) restore(e);
166
191
  else {
167
- roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
192
+ roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
168
193
  bodies = new Map();
169
194
  const seat = mySeat();
170
195
  if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
@@ -172,7 +197,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
172
197
  startRound((e.round?.n ?? 0) + 1);
173
198
  }
174
199
  // Whoever is connected now is who plays: seats that left during the gap become bots.
175
- const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
200
+ const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));
176
201
  const { claimed } = roster.reconcile(peers);
177
202
  syncBodiesFromRoster();
178
203
  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
@@ -189,14 +214,14 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
189
214
  function restore(e: RoleChange<Snap, Ckpt>): void {
190
215
  const ck = e.ckpt?.d ?? null;
191
216
  if (ck) {
192
- roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName });
217
+ roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
193
218
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
194
219
  gems = ck.gems.map((g) => ({ ...g }));
195
220
  gemSeq = ck.gemSeq;
196
221
  round = ck.round;
197
222
  tick = ck.tick;
198
223
  } else {
199
- roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName });
224
+ roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
200
225
  bodies = new Map();
201
226
  syncBodiesFromRoster();
202
227
  }
@@ -241,7 +266,8 @@ net.on('role', (e) => {
241
266
  });
242
267
  net.on('join', (p) => {
243
268
  if (!hosting || p.seat === null) return;
244
- const c = roster.claim(p.seat, p.name);
269
+ // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
270
+ const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
245
271
  if (!c) return; // full: they watch
246
272
  syncBodiesFromRoster();
247
273
  const b = bodies.get(c.slot.slot);
@@ -256,6 +282,8 @@ net.on('leave', (p) => {
256
282
  syncBodiesFromRoster();
257
283
  publishRoster();
258
284
  });
285
+ // The server's policy changed: its AI seats come (or go between rounds) at once.
286
+ net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
259
287
  net.on('event', (e) => {
260
288
  if (e.k === 'wave') addWave(e.d as { slot: number });
261
289
  if (e.k === 'knock') addWave(e.d as { slot: number }, true);
@@ -342,6 +370,9 @@ function knock(b: Body, fx: number, fy: number): void {
342
370
  }
343
371
  function addWave(d: { slot: number }, isKnock = false): void {
344
372
  const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
373
+ // The action, for a watcher on Auto: whoever waved (a person, never a bot).
374
+ const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
375
+ if (!isKnock) net.spotlight(waver);
345
376
  const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
346
377
  const at = mineSlot === d.slot && me.has ? me : pos;
347
378
  if (!at) return;
@@ -408,28 +439,69 @@ function stepKnocked(b: Body, dt: number): void {
408
439
  b.vx = b.kvx; b.vy = b.kvy;
409
440
  }
410
441
 
442
+ /**
443
+ * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
444
+ * last noticed is where it heads, and a miss costs it that long again), aims up to 200 px off at aimNoise 1, leaves
445
+ * the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
446
+ * `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few: measured with the
447
+ * `pickups` probe over 60 s rounds, a Maxed bot picks up a little over twice the gems of a Rookie.
448
+ */
449
+ const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
450
+
411
451
  function stepBots(dt: number): void {
412
452
  const taken = new Set<number>();
413
453
  const now = net.now();
414
454
  for (const b of bodies.values()) {
415
455
  if (!b.bot) continue;
416
456
  if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
417
- let best: Gem | null = null; let bd = Infinity;
418
- for (const g of gems) {
419
- if (taken.has(g.id)) continue;
420
- const d = Math.hypot(g.x - b.x, g.y - b.y);
421
- if (d < bd) { bd = d; best = g; }
457
+ const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
458
+ let eye = sight.get(b.slot);
459
+ if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
460
+ const g = pickGem(b, taken, s); // POSITIONING
461
+ const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1
462
+ eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
463
+ sight.set(b.slot, eye);
464
+ if (g) taken.add(g.id);
422
465
  }
423
- if (best) { taken.add(best.id); b.tx = best.x; b.ty = best.y; }
424
- const dx = b.tx - b.x; const dy = b.ty - b.y; const len = Math.hypot(dx, dy) || 1;
466
+ b.tx = eye.tx; b.ty = eye.ty;
467
+ const rival = nearestBody(b, KNOCK_RANGE); // AGGRESSION: waves at a rival in reach
468
+ if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
469
+ const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
470
+ const len = dist || 1;
471
+ // Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
472
+ if (dist < 6 && !eye.arrived) { eye.arrived = true; eye.at = now; }
473
+ const speed = dist < 6 ? 0 : BOT_SPEED;
425
474
  const k = Math.min(1, dt * 5);
426
- b.vx += ((dx / len) * BOT_SPEED - b.vx) * k;
427
- b.vy += ((dy / len) * BOT_SPEED - b.vy) * k;
475
+ b.vx += ((dx / len) * speed - b.vx) * k;
476
+ b.vy += ((dy / len) * speed - b.vy) * k;
428
477
  b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));
429
478
  b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
430
479
  }
431
480
  }
432
481
 
482
+ /** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
483
+ function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
484
+ let best: Gem | null = null; let bd = Infinity;
485
+ for (const g of gems) {
486
+ if (taken.has(g.id)) continue;
487
+ const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
488
+ const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
489
+ if (d < bd) { bd = d; best = g; }
490
+ }
491
+ return best;
492
+ }
493
+
494
+ /** The nearest other body within `range` (a bot's rival), or null. */
495
+ function nearestBody(b: Body, range: number): Body | null {
496
+ let best: Body | null = null; let bd = range;
497
+ for (const o of bodies.values()) {
498
+ if (o === b) continue;
499
+ const d = Math.hypot(o.x - b.x, o.y - b.y);
500
+ if (d < bd) { bd = d; best = o; }
501
+ }
502
+ return best;
503
+ }
504
+
433
505
  function stepHost(dt: number): void {
434
506
  tick += 1;
435
507
  const seat = mySeat();
@@ -471,6 +543,7 @@ function stepHost(dt: number): void {
471
543
  const g = gems[i] as Gem;
472
544
  if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
473
545
  b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
546
+ pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
474
547
  gems[i] = newGem();
475
548
  }
476
549
  }
@@ -527,7 +600,14 @@ function resize(): void {
527
600
  addEventListener('resize', resize);
528
601
  resize();
529
602
 
530
- const cam = { x: W / 2, y: H / 2 };
603
+ const cam = { x: W / 2, y: H / 2, scale: 0 };
604
+ /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
605
+ function seatPos(seat: number): { x: number; y: number } | null {
606
+ if (seat === mySeat() && me.has && !net.watching) return me;
607
+ if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
608
+ const d = [...drawn.values()].find((x) => x.seat === seat);
609
+ return d ? { x: d.x, y: d.y } : null;
610
+ }
531
611
  const gemGlow = (() => {
532
612
  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
533
613
  g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');
@@ -536,9 +616,15 @@ const gemGlow = (() => {
536
616
  function draw(t: number): void {
537
617
  const cw = innerWidth; const ch = innerHeight;
538
618
  const phone = Math.min(cw, ch) <= 540;
539
- const spectating = mySeat() === null;
540
- const scale = spectating ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
541
- const target = spectating || !me.has ? { x: W / 2, y: H / 2 } : me;
619
+ // The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
620
+ const view = viewSeat();
621
+ const followed = net.watching && view !== null ? seatPos(view) : null;
622
+ const overview = net.watching ? !followed : mySeat() === null;
623
+ const wantScale = overview ? Math.min(cw / (W + 80), ch / (H + 80)) : Math.min(cw, ch) / (phone ? 560 : 820);
624
+ // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
625
+ cam.scale = cam.scale ? cam.scale + (wantScale - cam.scale) * 0.12 : wantScale;
626
+ const scale = cam.scale;
627
+ const target = net.watching ? (followed ?? { x: W / 2, y: H / 2 }) : overview || !me.has ? { x: W / 2, y: H / 2 } : me;
542
628
  cam.x += (target.x - cam.x) * 0.2; cam.y += (target.y - cam.y) * 0.2;
543
629
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
544
630
  const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
@@ -588,18 +674,20 @@ function draw(t: number): void {
588
674
 
589
675
  // avatars
590
676
  const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
677
+ // `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
591
678
  const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
679
+ const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
592
680
  if (hosting) {
593
- for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: b.seat !== null && b.seat === mySeat() });
681
+ for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
594
682
  } else {
595
683
  for (const d of drawn.values()) {
596
- const mine = d.seat >= 0 && d.seat === net.seat;
684
+ const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
597
685
  const s = names.get(d.slot);
598
- list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: mine && me.has ? me.x : d.x, y: mine && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine });
686
+ list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
599
687
  }
600
688
  }
601
689
  for (const a of list) {
602
- const colour = PALETTE[a.slot % PALETTE.length] as string;
690
+ const colour = colourOf(a.slot, a.bot ? null : a.seat);
603
691
  ctx.globalAlpha = a.bot ? 0.62 : 1;
604
692
  ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();
605
693
  ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
@@ -607,7 +695,7 @@ function draw(t: number): void {
607
695
  ctx.globalAlpha = 1;
608
696
  ctx.font = `600 ${Math.round(15 / Math.max(0.6, scale))}px ui-sans-serif, system-ui, sans-serif`;
609
697
  ctx.textAlign = 'center'; ctx.fillStyle = a.mine ? '#ffffff' : 'rgba(232,236,245,0.8)';
610
- ctx.fillText(a.mine ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
698
+ ctx.fillText(a.mine && !net.watching ? 'You' : label(a.name, a.bot), a.x, a.y - R_AV - 12);
611
699
  }
612
700
  ctx.restore();
613
701
 
@@ -640,11 +728,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
640
728
  ctx.textAlign = 'right';
641
729
  rows.forEach((a, i) => {
642
730
  ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
643
- ctx.fillText(`${a.mine ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
731
+ ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, top + 18 + i * (phone ? 20 : 22));
644
732
  });
645
733
  // role badge
646
734
  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
647
- ctx.fillText(net.offline ? 'OFFLINE HOST' : net.role.toUpperCase(), pad, ch - pad);
735
+ ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);
648
736
  if (r && r.phase === 'over' && r.results) {
649
737
  const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
650
738
  const x = (cw - w) / 2; const y = (ch - h) / 2;
@@ -653,10 +741,11 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
653
741
  ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
654
742
  ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
655
743
  ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
744
+ const view = viewSeat();
656
745
  r.results.slice(0, 6).forEach((row, i) => {
657
- const mine = row.seat !== null && row.seat === mySeat();
746
+ const mine = !row.bot && row.seat !== null && row.seat === view;
658
747
  ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
659
- ctx.fillText(`${row.place}. ${mine ? 'You' : label(row.name, row.bot)} — ${row.score}`, cw / 2, y + 56 + i * 26);
748
+ ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
660
749
  });
661
750
  }
662
751
  if (!net.offline && !net.connected && net.role !== 'host') {
@@ -691,6 +780,10 @@ net.expose({
691
780
  },
692
781
  frames: () => frames,
693
782
  scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
783
+ /** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
784
+ pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),
785
+ /** Host: when each bot last looked (its reaction clock). */
786
+ sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
694
787
  waves: () => wavesSeen,
695
788
  knocks: () => knocksSeen,
696
789
  zone: () => zone,
@@ -699,6 +792,8 @@ net.expose({
699
792
  movement: () => MOVEMENT,
700
793
  controlResets: () => controlResets,
701
794
  predictionError: () => predictionError,
795
+ /** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
796
+ camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
702
797
  /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
703
798
  arrivals: () => ({ firstStateAt, firstSnapAt }),
704
799
  /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
@@ -160,6 +160,11 @@ export function accountPage(cat, { origin = '', next = '/account/', owner = null
160
160
  <label for="acct-email">Email</label><input id="acct-email" data-email type="email" autocomplete="email">
161
161
  <div class="row"><button class="ghost" type="button" data-email-save>Send a confirmation</button><button class="ghost" type="button" data-email-remove hidden>Remove it</button></div>
162
162
  </div>` : ''}
163
+ <div class="card" data-servers-card hidden>
164
+ <h2>Your servers</h2>
165
+ <p>The servers you belong to in this studio's games. Your home server is where Play takes you.</p>
166
+ <ul class="keys-list" data-servers></ul>
167
+ </div>
163
168
  <div class="card">
164
169
  <h2>Your data</h2>
165
170
  <p>Download everything this studio keeps about you (your saves in every game, your stats, your memorials), or delete it all.</p>
@@ -230,6 +235,27 @@ export const ACCOUNT_JS = `${PASSKEY_JS}
230
235
  $('[data-email-remove]').hidden = !me.email;
231
236
  }
232
237
  keys();
238
+ servers();
239
+ }
240
+
241
+ // Your servers (0.16.0): each one, ★ for home, and Leave (a player's own choice; the door may let them back in).
242
+ function servers() {
243
+ var list = Array.isArray(me.servers) ? me.servers : [];
244
+ var card = $('[data-servers-card]'); var ul = $('[data-servers]');
245
+ if (!card || !ul) return;
246
+ card.hidden = !list.length; ul.textContent = '';
247
+ list.forEach(function (m) {
248
+ var li = document.createElement('li');
249
+ var a = document.createElement('a'); a.href = '/' + m.game + '/s/' + m.server + '/'; a.textContent = (m.home ? '★ ' : '') + m.server + ' · ' + m.game + (m.role !== 'member' ? ' · ' + m.role : '');
250
+ li.appendChild(a);
251
+ var b = document.createElement('button'); b.type = 'button'; b.className = 'ghost'; b.textContent = 'Leave';
252
+ b.addEventListener('click', function () {
253
+ fetch('/' + m.game + '/s/' + m.server + '/home', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ join: false }) })
254
+ .then(function (r) { return r.json(); }).then(function (x) { if (!x.ok) throw new Error(x.message || 'That did not work.'); me.servers = list.filter(function (y) { return y !== m; }); servers(); say('You left ' + m.server + '.', 'good'); }).catch(fail);
255
+ });
256
+ if (m.role === 'member') li.appendChild(b);
257
+ ul.appendChild(li);
258
+ });
233
259
  }
234
260
 
235
261
  function keys() {
@@ -0,0 +1,238 @@
1
+ /**
2
+ * AGENT SEATS (@homie-rocks/studio 0.16.0, NETPLAY.md section 17): an AI that sits in a room's seat the way a phone
3
+ * does, always marked AI, and the skill dial every game's bots can read.
4
+ *
5
+ * SKILLS / skillPreset the dial: five levels, one shape everywhere ({ level, name, reactionMs, aimNoise,
6
+ * aggression, positioning }); netplay/netplay.ts has the same table (a test keeps them equal)
7
+ * AI_MARK / aiName every agent's name ends in exactly " · AI"; a person's name never ends in an AI or bot mark
8
+ * passes an agent pass is one AI's way into a seat: `hap_<id>_<secret>`, shown once; D1 keeps only
9
+ * the SHA-256 of the whole pass (agent_passes). A ticket names it as the holder `a-<id>`.
10
+ * sitRoute POST /<game>/api/agent with `Authorization: Bearer hap_…`: a room with people in it, a
11
+ * ticket, the socket and (hands `self`) the game's frame to load
12
+ * fillSpot the fill-a-spot hook: nothing issues service passes yet (a money decision), so it is null
13
+ *
14
+ * Nothing here imports the rest of the Worker, so the relay (room.mjs) can use the dial and the names as they are.
15
+ */
16
+
17
+ /** The skill dial (DESIGN D8; NETPLAY.md section 17). Level 3, Fair, is what the port kit's bots always were. */
18
+ export const SKILLS = Object.freeze([
19
+ Object.freeze({ level: 1, name: 'Rookie', reactionMs: 650, aimNoise: 0.55, aggression: 0.1, positioning: 0.1, card: 'stays at the back, misses a lot' }),
20
+ Object.freeze({ level: 2, name: 'Steady', reactionMs: 420, aimNoise: 0.3, aggression: 0.3, positioning: 0.35, card: 'helps, never steals the show' }),
21
+ Object.freeze({ level: 3, name: 'Fair', reactionMs: 250, aimNoise: 0.15, aggression: 0.5, positioning: 0.5, card: 'plays like a regular' }),
22
+ Object.freeze({ level: 4, name: 'Strong', reactionMs: 170, aimNoise: 0.07, aggression: 0.7, positioning: 0.75, card: 'keeps up with good players' }),
23
+ Object.freeze({ level: 5, name: 'Maxed', reactionMs: 110, aimNoise: 0.02, aggression: 0.9, positioning: 0.95, card: 'front-line tank, rarely misses' }),
24
+ ]);
25
+ /** Kids servers: the dial's ceiling and the most aggression any AI may show. */
26
+ export const KIDS = Object.freeze({ levelMax: 3, aggression: 0.3 });
27
+
28
+ /** A level as its preset (a copy): 1..5, Fair when it is not a number; a kids room caps it at 3 and aggression at 0.3. */
29
+ export function skillPreset(n, { kids = false } = {}) {
30
+ const top = kids ? KIDS.levelMax : 5;
31
+ const level = Math.max(1, Math.min(top, Math.round(Number(n)) || 3));
32
+ const s = { ...SKILLS[level - 1] };
33
+ if (kids) s.aggression = Math.min(s.aggression, KIDS.aggression);
34
+ return s;
35
+ }
36
+
37
+ /* ------------------------------------------------------------------ names */
38
+
39
+ /** The exact mark every agent's name ends with (DESIGN D7): it survives an old game that only draws `name`. */
40
+ export const AI_MARK = ' · AI';
41
+ /**
42
+ * An AI or bot mark at the end of a name, as its own word after a space or a separator: "Ada · AI", "Bo (AI)",
43
+ * "Cy [bot]", "Di - A.I.". A name that is only "Ai" (a person's name) is not one.
44
+ */
45
+ const AI_TAIL = /[\s·•∙⋅・|:_\-–—(\[{]+(?:a\.?\s?i\.?|bots?)[\s)\]}.!·•∙⋅・|:_\-–—]*$/iu;
46
+ const oneLine = (v, max) => String(v ?? '').normalize('NFKC').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
47
+
48
+ /** Whether a name ends in an AI or bot mark. */
49
+ export const isAiName = (name) => AI_TAIL.test(String(name ?? '').trim());
50
+
51
+ /** A name with every AI or bot mark taken off its end: what a person's typed name becomes (they cannot claim one). */
52
+ export function stripAi(name) {
53
+ let s = oneLine(name, 64);
54
+ for (let i = 0; i < 6 && AI_TAIL.test(s); i += 1) s = s.replace(AI_TAIL, '').trim();
55
+ return s.replace(/[\s·•∙⋅・|:_\-–—(\[{]+$/u, '').trim();
56
+ }
57
+
58
+ /** An agent's name as every room shows it: its label (at most 16 characters, no mark of its own) and " · AI". */
59
+ export function aiName(label) {
60
+ const base = [...stripAi(label)].slice(0, 16).join('').trim();
61
+ return `${base || 'Agent'}${AI_MARK}`;
62
+ }
63
+
64
+ /* ------------------------------------------------------------------ the wire's facts (Worker → Table) */
65
+
66
+ /** JSON as base64url (no padding), for the Worker's own `pol` and `ag` values on the Table's address. */
67
+ export function encodeFacts(value) {
68
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
69
+ let s = '';
70
+ for (const b of bytes) s += String.fromCharCode(b);
71
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
72
+ }
73
+ /** The reverse, or null for anything that is not one (at most 4 KB). */
74
+ export function decodeFacts(text) {
75
+ const t = String(text ?? '');
76
+ if (!t || t.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(t)) return null;
77
+ try {
78
+ const s = atob(t.replace(/-/g, '+').replace(/_/g, '/'));
79
+ const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0));
80
+ return JSON.parse(new TextDecoder().decode(bytes));
81
+ } catch { return null; }
82
+ }
83
+
84
+ /* ------------------------------------------------------------------ passes */
85
+
86
+ export const PASS_KINDS = Object.freeze(['owner', 'guest', 'service']);
87
+ export const PASS_ROLES = Object.freeze(['party', 'guide', 'player']);
88
+ export const PASS_HANDS = Object.freeze(['self', 'host']);
89
+ export const PASS_ID = /^[a-f0-9]{10}$/;
90
+ const PASS_TOKEN = /^hap_([a-f0-9]{10})_([A-Za-z0-9_-]{40})$/;
91
+ const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
92
+ const SERVER_ID = /^[a-z0-9][a-z0-9-]{1,19}$/;
93
+ const DAY = 86_400_000;
94
+ /** An agent's ticket lasts as long as a player's (12 hours); a pass lasts what the owner said, or until revoked. */
95
+ export const AGENT_TICKET_MS = 12 * 3600_000;
96
+
97
+ function randomText(bytes) {
98
+ const raw = new Uint8Array(bytes);
99
+ crypto.getRandomValues(raw);
100
+ let s = '';
101
+ for (const b of raw) s += String.fromCharCode(b);
102
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
103
+ }
104
+ function randomHex(bytes) {
105
+ const raw = new Uint8Array(bytes);
106
+ crypto.getRandomValues(raw);
107
+ return [...raw].map((b) => b.toString(16).padStart(2, '0')).join('');
108
+ }
109
+ async function sha256(text) {
110
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
111
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
112
+ }
113
+
114
+ /** A pass as the office, the CLI and the MCP see it: never its secret. */
115
+ export function passView(r, now = Date.now()) {
116
+ if (!r) return null;
117
+ const expiresAt = r.expires_at === null || r.expires_at === undefined ? null : Number(r.expires_at);
118
+ const revoked = Number(r.revoked) === 1;
119
+ return {
120
+ id: r.id, label: r.label, name: aiName(r.label), kind: r.kind, role: r.role, hands: r.hands,
121
+ game: r.game ?? null, server: r.server ?? null, issuer: r.issuer ?? 'owner', expiresAt, revoked, createdAt: Number(r.created_at),
122
+ live: !revoked && (expiresAt === null || expiresAt > now),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A new pass (the owner's office): `{ ok, pass, secret }`, the secret shown this once. A label is what the relay
128
+ * shows ("Claude" plays as "Claude · AI"). A fill-a-spot service has no issuer yet (DESIGN section 12: money).
129
+ */
130
+ export async function passCreate(env, { label, kind = 'owner', role = 'party', hands = 'self', game = null, server = null, days = null, issuer = 'owner' } = {}) {
131
+ if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for agent passes.' };
132
+ const name = [...stripAi(label)].slice(0, 16).join('').trim();
133
+ if (!name || !/[\p{L}\p{N}]/u.test(name)) return { ok: false, error: 'label', message: 'label is the name the AI plays under (1 to 16 letters or digits; " · AI" is added)' };
134
+ if (kind === 'service') return { ok: false, error: 'service', message: 'Passes for a fill-a-spot service come later; nothing issues them yet.' };
135
+ if (!PASS_KINDS.includes(kind)) return { ok: false, error: 'kind', message: 'kind is owner or guest' };
136
+ if (!PASS_ROLES.includes(role)) return { ok: false, error: 'role', message: 'role is party, guide or player' };
137
+ if (!PASS_HANDS.includes(hands)) return { ok: false, error: 'hands', message: 'hands is self (the AI runs the game itself) or host (the host\'s bot code moves it)' };
138
+ if (game !== null && !GAME_ID.test(String(game))) return { ok: false, error: 'game', message: 'game is one of this studio\'s game ids, or leave it out for any' };
139
+ if (server !== null && !SERVER_ID.test(String(server))) return { ok: false, error: 'server', message: 'server is a server id, or leave it out for any that lets AI in' };
140
+ const id = randomHex(5);
141
+ const secret = `hap_${id}_${randomText(30)}`;
142
+ const now = Date.now();
143
+ const d = days === null || days === undefined ? null : Math.max(1, Math.min(365, Math.floor(Number(days)) || 7));
144
+ const row = { id, label: name, kind, role, hands, game, server, issuer: String(issuer ?? 'owner').slice(0, 40), expires_at: d ? now + d * DAY : null, revoked: 0, created_at: now };
145
+ await env.DB.prepare('INSERT INTO agent_passes (id, hash, label, kind, role, hands, game, server, issuer, expires_at, revoked, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, ?11)')
146
+ .bind(id, await sha256(secret), name, kind, role, hands, game, server, row.issuer, row.expires_at, now).run();
147
+ return { ok: true, pass: passView(row, now), secret };
148
+ }
149
+
150
+ /** The pass a `hap_…` secret is, when it is this studio's and still good; else null. */
151
+ export async function passOf(env, token) {
152
+ const m = PASS_TOKEN.exec(String(token ?? '').trim());
153
+ if (!m || !env?.DB) return null;
154
+ let r = null;
155
+ try { r = await env.DB.prepare('SELECT * FROM agent_passes WHERE hash = ?1').bind(await sha256(m[0])).first(); } catch { return null; }
156
+ if (!r || r.id !== m[1]) return null;
157
+ const v = passView(r);
158
+ return v.live ? v : null;
159
+ }
160
+
161
+ /** A pass by its id (a ticket's `a-<id>`), live or not: null when there is none. */
162
+ export async function passById(env, id) {
163
+ if (!PASS_ID.test(String(id ?? '')) || !env?.DB) return null;
164
+ try { return passView(await env.DB.prepare('SELECT * FROM agent_passes WHERE id = ?1').bind(id).first()); } catch { return null; }
165
+ }
166
+
167
+ export async function passRevoke(env, id) {
168
+ if (!PASS_ID.test(String(id ?? ''))) return { ok: false, error: 'id', message: 'id is the pass\'s id (10 letters and digits)' };
169
+ const r = await env.DB.prepare('UPDATE agent_passes SET revoked = 1 WHERE id = ?1 RETURNING id').bind(id).first();
170
+ return r ? { ok: true, revoked: id } : { ok: false, error: 'no-pass', message: 'No such pass.' };
171
+ }
172
+
173
+ /** Every pass (newest first), for one game or all: { id, label, name, kind, role, hands, game, server, live, … }. */
174
+ export async function passList(env, { game = null } = {}) {
175
+ try {
176
+ const { results } = game
177
+ ? await env.DB.prepare('SELECT * FROM agent_passes WHERE game = ?1 OR game IS NULL ORDER BY created_at DESC LIMIT 200').bind(game).all()
178
+ : await env.DB.prepare('SELECT * FROM agent_passes ORDER BY created_at DESC LIMIT 200').all();
179
+ return (results ?? []).map((r) => passView(r));
180
+ } catch { return []; }
181
+ }
182
+
183
+ /** What a room is told about an agent (`peer.agent`): its pass, role, hands, and who it is for. */
184
+ export const agentFacts = (pass) => ({ pass: pass.id, role: pass.role, hands: pass.hands, by: pass.kind === 'owner' ? 'studio' : pass.kind });
185
+
186
+ /**
187
+ * Why this pass may not sit here, or null when it may: the server's policy (humans-only lets no AI in), the pass's
188
+ * own game and server, and a game that is not public (only the owner's own passes open it).
189
+ */
190
+ export function passRefusal(pass, { game, server, policy, launch = 'public' }) {
191
+ if (!pass || !pass.live) return { status: 403, error: 'agent-pass', message: 'That agent pass is revoked, ended, or not this studio\'s.' };
192
+ if (pass.game && pass.game !== game) return { status: 403, error: 'agent-scope', message: `This pass is for ${pass.game}, not ${game}.` };
193
+ if (pass.server && pass.server !== server) return { status: 403, error: 'agent-scope', message: `This pass is for the server ${pass.server}.` };
194
+ if (policy?.kind === 'humans-only') return { status: 403, error: 'agents-off', message: 'This server is for humans only' };
195
+ if (launch !== 'public' && pass.kind !== 'owner') return { status: 403, error: 'not-open', message: 'This game is not open to you.' };
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * POST /<game>/api/agent (Bearer pass): a seat for an AI. Strangers are never started a room by an AI (DESIGN D9):
201
+ * the Lobby offers only a room of the server's pool that has people in it. Hands `self`: load `frame` (the game,
202
+ * which plays as the AI); hands `host`: open `ws` and say hello with `agent: { hands: 'host' }` (the host's bot code
203
+ * moves the body; NETPLAY.md section 17).
204
+ *
205
+ * `deps`: { meta, launch, serverOf(id) -> server | null, policyOf(server) -> Policy, join(server, policy) -> room | null,
206
+ * ticketFor(sub) -> ticket }
207
+ */
208
+ export async function sitRoute(request, env, url, deps) {
209
+ const json = (body, status = 200) => new Response(`${JSON.stringify(body)}\n`, { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private' } });
210
+ if (request.method !== 'POST') return json({ ok: false, error: 'method', message: 'POST, with Authorization: Bearer <agent pass>' }, 405);
211
+ const bearer = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? '';
212
+ const pass = await passOf(env, bearer);
213
+ if (!pass) return json({ ok: false, error: 'agent-pass', message: 'An AI sits with an agent pass from the studio\'s owner (homie-studio agents pass).' }, 401);
214
+ let body = {};
215
+ try { const t = await request.text(); body = t ? JSON.parse(t) : {}; } catch { return json({ ok: false, error: 'json' }, 400); }
216
+ const wanted = typeof body?.server === 'string' && SERVER_ID.test(body.server) ? body.server : pass.server ?? 'public';
217
+ const server = deps.serverOf(wanted);
218
+ if (!server || server.state !== 'open') return json({ ok: false, error: 'no-server', message: `No open server called ${wanted} here.` }, 404);
219
+ const policy = deps.policyOf(server);
220
+ const why = passRefusal(pass, { game: deps.meta.id, server: server.id, policy, launch: deps.launch });
221
+ if (why) return json({ ok: false, ...why }, why.status);
222
+ const room = await deps.join(server, policy);
223
+ if (!room) return json({ ok: false, error: 'no-room', message: 'Nobody is playing on this server right now: an AI never starts a room. Try again when people are here.' }, 409);
224
+ const ticket = await deps.ticketFor(`a-${pass.id}`);
225
+ if (!ticket) return json({ ok: false, error: 'no-ticket', message: 'This studio cannot mint tickets yet (its D1).' }, 503);
226
+ const ws = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}/${deps.meta.id}/__net?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}`;
227
+ return json({
228
+ ok: true, game: deps.meta.id, server: server.id, room, ticket, name: aiName(pass.label), role: pass.role, hands: pass.hands, policy: { kind: policy.kind, level: policy.level, levelMax: policy.levelMax },
229
+ ws, ...(pass.hands === 'self' ? { frame: `${url.origin}/${deps.meta.id}/__game/?room=${encodeURIComponent(room)}&t=${encodeURIComponent(ticket)}` } : { hello: { t: 'hello', v: 1, rev: 6, want: 'play', canHost: false, agent: { hands: 'host', role: pass.role } } }),
230
+ expiresAt: Date.now() + AGENT_TICKET_MS,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * FILL A SPOT (DESIGN section 12): a service that seats an AI in an empty spot for a price is the owner's money
236
+ * decision, so nothing issues `service` passes and this hook answers null. A later version decides who, what and how.
237
+ */
238
+ export function fillSpot() { return null; }