@homie-rocks/studio 0.16.0 → 0.17.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.
@@ -22,8 +22,18 @@
22
22
  * Servers and the skill dial (contract revision 6): the vale's bots hunt at the room's dial (`room.skillOf(body)`:
23
23
  * how soon they notice a slime, how close they stand, how often they strike), and a beginner server's AI guide seats
24
24
  * are kept as AI bodies, marked " · AI". createRoom does the seats; the bots below read the dial.
25
+ *
26
+ * AI GUIDES (contract revision 7, @homie-rocks/studio/agents): on a beginner server the guide seats get a brain. The
27
+ * vale's own words for it are ../agents.json: the goals a guide may take (follow, quest, lead, guard, back), the lines
28
+ * it may say, and the asks a new hero can tap ("Help me with King Slime", "Take me to camp", "No thanks"). The HANDS
29
+ * below play every frame at the dial: they walk to the goal, hunt what the goal says, and tell agents.done() when it is
30
+ * 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
31
+ * none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
32
+ * never from a model; "Quiet AI" hides them.
25
33
  */
26
- import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
34
+ import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent, type Skill } from '@homie-rocks/studio/port';
35
+ import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
36
+ import vocabulary from '../agents.json';
27
37
 
28
38
  /* ------------------------------------------------------------------ rules */
29
39
  const W = 1600;
@@ -107,6 +117,10 @@ let slimeSeq = 0;
107
117
  let spawnAt = 0;
108
118
  let kingAt = 0;
109
119
  const me = { x: W / 2, y: H / 2, face: 0, has: false, strikes: 0, flashAt: 0 };
120
+ /** Where a guide can lead the party (agents.json "lead"). */
121
+ 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 } };
122
+ /** When the last King Slime fell (host): a guide on the King's quest is done. */
123
+ let kingSlainAt = 0;
110
124
 
111
125
  const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
112
126
  game: 'ember-vale',
@@ -142,6 +156,62 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
142
156
  });
143
157
  const net = room.net;
144
158
  const myLevel = (): number => hero?.level ?? 1;
159
+
160
+ /* ------------------------------------------------------------------ the guides' brain (agents.json) */
161
+ type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
162
+ const greeted = new Map<number, Set<number>>();
163
+ const warnedAt = new Map<number, number>();
164
+ const agents = useAgents(net, vocabulary as unknown as Vocabulary, {
165
+ // 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.
166
+ view: (slot) => {
167
+ const b = room.bodies.get(slot);
168
+ if (!b) return {};
169
+ const party: PartyRow[] = [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null)
170
+ .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) }))
171
+ .filter((p) => p.dist < 900).sort((x, y) => x.dist - y.dist).slice(0, 6);
172
+ const king = slimes.find((o) => o.size === 3);
173
+ const near = slimes.filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 350).length;
174
+ const c = room.clock();
175
+ return {
176
+ me: { hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN) },
177
+ zone: zoneOf(b.x, b.y),
178
+ // A brain thinks again when this changes, so it changes only for what matters: the King close, or a swarm.
179
+ danger: king && Math.hypot(king.x - b.x, king.y - b.y) < 450 ? 'the King Slime' : near >= 5 ? 'slimes' : null,
180
+ party, quests: openQuests(), slimes: { near, king: Boolean(king) }, round: { phase: c.phase, left: c.secondsLeft },
181
+ };
182
+ },
183
+ // The scripted floor: with no AI, over budget, and between an AI's decisions. Synchronous; it never waits.
184
+ decide: (v, { slot, goal }) => {
185
+ const a = (v['asks'] as { k: string; args: Record<string, unknown> }[] | undefined)?.[0];
186
+ if (a?.k === 'ask_help') return { goal: 'quest', args: { quest: a.args['quest'] }, say: 'quest_help', sayArgs: { quest: a.args['quest'] } };
187
+ if (a?.k === 'lead_me') return { goal: 'lead', args: { place: a.args['place'] }, say: 'follow_me', sayArgs: { place: a.args['place'] } };
188
+ if (a?.k === 'no_thanks') return { goal: 'back', say: 'bye' };
189
+ const party = (v['party'] as PartyRow[] | undefined) ?? [];
190
+ // What a hero asked for is carried through until it is done.
191
+ if (goal && goal.state === 'active' && ['quest', 'lead', 'back'].includes(goal.goal)) return null;
192
+ const seen = greeted.get(slot) ?? new Set<number>();
193
+ greeted.set(slot, seen);
194
+ const fresh = party.find((p) => !seen.has(p.seat) && p.dist < 420);
195
+ if (fresh) { seen.add(fresh.seat); return { goal: 'follow', args: { seat: fresh.seat }, say: 'hello', sayArgs: { player: fresh.seat } }; }
196
+ if (v['danger'] === 'the King Slime') {
197
+ const warn = room.net.now() - (warnedAt.get(slot) ?? 0) > 30_000;
198
+ if (warn) warnedAt.set(slot, room.net.now());
199
+ return { goal: 'guard', ...(warn ? { say: 'careful', sayArgs: { thing: 'the King Slime' } } : {}) };
200
+ }
201
+ const hurt = party.find((p) => !p.down && p.hp < 40);
202
+ if (hurt) return { goal: 'follow', args: { seat: hurt.seat } };
203
+ if (party[0]) return { goal: 'follow', args: { seat: party[0].seat } };
204
+ return { goal: 'lead', args: { place: 'camp' } };
205
+ },
206
+ });
207
+ /** A guide's line over its head for a few seconds: the game's own words (agents.json), never a model's. */
208
+ const bubbles = new Map<number, { text: string; until: number }>();
209
+ /** What the e2e probe reads: goal changes (with the ask that led to one), asks, lines. Never a name or an account. */
210
+ const guideLog: Record<string, unknown>[] = [];
211
+ const logGuide = (row: Record<string, unknown>): void => { guideLog.push({ at: Date.now(), ...row }); if (guideLog.length > 80) guideLog.shift(); };
212
+ agents.on('say', (e) => { bubbles.set(e.slot, { text: e.text, until: performance.now() + 3600 }); logGuide({ ev: 'say', slot: e.slot, line: e.line }); });
213
+ 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 }));
214
+ agents.on('ask', (e) => logGuide({ ev: 'ask', slot: e.slot, k: e.k, args: e.args, from: e.from }));
145
215
  function mine(): Body {
146
216
  const b = room.mine();
147
217
  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);
@@ -170,6 +240,7 @@ function strike(b: Body, now: number): void {
170
240
  const xp = s.size === 3 ? 60 : 6 * s.size;
171
241
  const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
172
242
  b.score += xp;
243
+ if (s.size === 3) kingSlainAt = now;
173
244
  tell(b, 'loot', { xp, gold });
174
245
  // Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
175
246
  if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
@@ -193,7 +264,9 @@ function stepHost(dt: number): void {
193
264
  continue;
194
265
  }
195
266
  let wants = false;
196
- if (b.bot) {
267
+ const guide = b.bot && b.agent?.role === 'guide' ? agents.goalOf(b.slot) : null;
268
+ if (guide) wants = stepGuide(b, guide, room.skillOf(b), dt, now);
269
+ else if (b.bot) {
197
270
  // Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
198
271
  // room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
199
272
  // the front (positioning), aim a little off (aimNoise), and strike sooner the more aggressive they are.
@@ -229,9 +302,105 @@ function stepHost(dt: number): void {
229
302
  if (wants && live && now - b.atkAt > STRIKE_MS) strike(b, now);
230
303
  }
231
304
  if (live) stepSlimes(dt, now);
305
+ // Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
306
+ net.state('quests', openQuests());
232
307
  room.update();
233
308
  }
234
309
 
310
+ /* ------------------------------------------------------------------ the guides' hands */
311
+ const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
312
+ const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
313
+ const huntFrom = new Map<number, number>();
314
+ const arrivedAt = new Map<number, number>();
315
+ /**
316
+ * A guide's body, every host frame, doing its goal at the dial (`s`): where it stands (positioning), how soon it
317
+ * notices (reactionMs), how far off it strikes (aimNoise), how eagerly (aggression). Returns whether it strikes now.
318
+ */
319
+ function stepGuide(b: Body, g: Goal, s: Skill, dt: number, now: number): boolean {
320
+ let tx = b.x; let ty = b.y; let hunt: Slime | null = null;
321
+ 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; };
322
+ switch (g.goal) {
323
+ case 'follow': {
324
+ const p = bodyOfSeat(g.args['seat']);
325
+ if (!p) { agents.done(b.slot, false); break; }
326
+ // A step behind the hero (closer at a higher dial), each guide to its own side, and the slime that comes for them.
327
+ const back = standoff(s, 70, 150);
328
+ const side = (b.slot % 2 ? 1 : -1) * (0.5 + (b.slot % 3) * 0.25);
329
+ tx = p.x - Math.cos(p.face + side) * back; ty = p.y - Math.sin(p.face + side) * back;
330
+ hunt = nearTo(p.x, p.y, 200);
331
+ break;
332
+ }
333
+ case 'quest': {
334
+ const quest = g.args['quest'];
335
+ if (quest === 'king-slime') {
336
+ if (kingSlainAt > g.at) { agents.done(b.slot, true); break; }
337
+ hunt = slimes.find((o) => o.size === 3) ?? null;
338
+ if (!hunt) { tx = PLACES['king']!.x; ty = PLACES['king']!.y + 70; }
339
+ } else if (quest === 'big-slime') {
340
+ 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;
341
+ if (!hunt) agents.done(b.slot, true);
342
+ } else {
343
+ // A slime hunt: the slimes near the party, three of them.
344
+ if (!huntFrom.has(b.slot) || huntFrom.get(b.slot)! > b.score) huntFrom.set(b.slot, b.score);
345
+ if (b.score - huntFrom.get(b.slot)! >= 18) { huntFrom.delete(b.slot); agents.done(b.slot, true); break; }
346
+ const ps = people();
347
+ const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
348
+ const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
349
+ hunt = nearTo(cx, cy, 520) ?? nearestSlime(b.x, b.y);
350
+ }
351
+ break;
352
+ }
353
+ case 'lead': {
354
+ const at = PLACES[String(g.args['place'])] ?? PLACES['camp']!;
355
+ tx = at.x; ty = at.y;
356
+ // Done when it is there and a hero came along; given up when nobody came within 20 s of it arriving.
357
+ if (Math.hypot(at.x - b.x, at.y - b.y) < 60) {
358
+ if (!arrivedAt.has(b.slot)) arrivedAt.set(b.slot, now);
359
+ if (people().some((o) => Math.hypot(o.x - at.x, o.y - at.y) < 280)) { arrivedAt.delete(b.slot); agents.done(b.slot, true); }
360
+ else if (now - arrivedAt.get(b.slot)! > 20_000) { arrivedAt.delete(b.slot); agents.done(b.slot, false); }
361
+ } else arrivedAt.delete(b.slot);
362
+ hunt = nearTo(b.x, b.y, 110);
363
+ break;
364
+ }
365
+ case 'guard': {
366
+ const ps = people().filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 700);
367
+ const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
368
+ const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
369
+ tx = cx + 50; ty = cy + 40;
370
+ hunt = nearTo(cx, cy, standoff(s, 260, 160));
371
+ break;
372
+ }
373
+ case 'back': {
374
+ const at = PLACES['camp']!;
375
+ tx = at.x; ty = at.y;
376
+ if (Math.hypot(at.x - b.x, at.y - b.y) < 50) agents.done(b.slot, true);
377
+ break;
378
+ }
379
+ default: break;
380
+ }
381
+ // Two guides with the same goal never stand in one spot: each keeps its own place around it.
382
+ if (!hunt && g.goal !== 'follow') { const k = b.slot * 2.4; tx += Math.cos(k) * 46; ty += Math.sin(k) * 46; }
383
+ // Reaction time: a guide re-aims only every reactionMs, a little off at a low dial.
384
+ if (hunt) {
385
+ 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); }
386
+ tx = b.ax ?? hunt.x; ty = b.ay ?? hunt.y;
387
+ }
388
+ const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
389
+ 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); }
390
+ return Boolean(hunt && Math.hypot(hunt.x - b.x, hunt.y - b.y) < STRIKE_RANGE + hunt.size * 10 && now - b.atkAt > 1400 - 900 * s.aggression);
391
+ }
392
+
393
+ /** 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. */
394
+ function openQuests(): string[] {
395
+ const now = net.now();
396
+ return ['slime-hunt', ...(slimes.some((o) => o.size === 3) || kingAt - now < 30_000 ? ['king-slime'] : []), ...(slimes.some((o) => o.size === 2) ? ['big-slime'] : [])];
397
+ }
398
+ const zoneOf = (x: number, y: number): string => {
399
+ let best = 'vale'; let bd = 260;
400
+ for (const [id, p] of Object.entries(PLACES)) { const d = Math.hypot(p.x - x, p.y - y); if (d < bd) { bd = d; best = id; } }
401
+ return best;
402
+ };
403
+
235
404
  function nearestSlime(x: number, y: number): Slime | null {
236
405
  let best: Slime | null = null; let bd = Infinity;
237
406
  for (const s of slimes) { const d = Math.hypot(s.x - x, s.y - y); if (d < bd) { bd = d; best = s; } }
@@ -332,7 +501,8 @@ function draw(t: number): void {
332
501
  const isDown = Boolean(b.flags & DOWN);
333
502
  ctx.globalAlpha = isDown ? 0.35 : 1;
334
503
  if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
335
- ctx.fillStyle = self ? '#ffcf6e' : b.bot ? '#8aa0b8' : '#f3ead2';
504
+ const isGuide = (net.slots ?? []).some((x) => x.slot === b.slot && x.agent?.role === 'guide');
505
+ ctx.fillStyle = self ? '#ffcf6e' : isGuide ? '#7fd8c8' : b.bot ? '#8aa0b8' : '#f3ead2';
336
506
  ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
337
507
  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();
338
508
  ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(x - 24, y - R - 12, 48, 5);
@@ -340,12 +510,32 @@ function draw(t: number): void {
340
510
  ctx.globalAlpha = 1;
341
511
  ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
342
512
  // An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
343
- ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''} · ${b.level}`, x, y - R - 18);
513
+ ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`, x, y - R - 18);
514
+ const said = bubbles.get(b.slot);
515
+ if (said && said.until > performance.now()) bubble(x, y - R - 44, said.text);
344
516
  }
345
517
  ctx.restore();
346
518
  hud(t);
347
519
  }
348
520
 
521
+ /** A speech bubble: up to three short lines, above a guide. */
522
+ function bubble(x: number, y: number, text: string): void {
523
+ ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
524
+ const words = text.split(' ');
525
+ const lines: string[] = [];
526
+ let line = '';
527
+ for (const w of words) { if ((line + ' ' + w).trim().length > 26 && line) { lines.push(line); line = w; } else line = (line + ' ' + w).trim(); }
528
+ if (line) lines.push(line);
529
+ const shown = lines.slice(0, 3);
530
+ const wid = Math.max(...shown.map((l) => ctx.measureText(l).width)) + 22;
531
+ const hgt = shown.length * 20 + 12;
532
+ ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.strokeStyle = 'rgba(127,216,200,.75)'; ctx.lineWidth = 2;
533
+ ctx.beginPath(); ctx.roundRect(x - wid / 2, y - hgt, wid, hgt, 10); ctx.fill(); ctx.stroke();
534
+ ctx.beginPath(); ctx.moveTo(x - 7, y); ctx.lineTo(x + 7, y); ctx.lineTo(x, y + 9); ctx.closePath(); ctx.fillStyle = 'rgba(14,30,27,.92)'; ctx.fill();
535
+ ctx.fillStyle = '#e9fff9'; ctx.textAlign = 'center';
536
+ shown.forEach((l, i) => ctx.fillText(l, x, y - hgt + 22 + i * 20));
537
+ }
538
+
349
539
  function hud(t: number): void {
350
540
  const k = Math.max(0.8, Math.min(1.4, canvas.height / 900));
351
541
  ctx.save(); ctx.scale(k, k);
@@ -446,6 +636,54 @@ saves.on('player', () => ui.paint());
446
636
  // Time in the vale, a lifetime stat: added every 30 s while the page is open and visible.
447
637
  setInterval(() => { if (hero && document.visibilityState === 'visible') void saves.stats.add({ seconds: 30 }); }, 30_000);
448
638
 
639
+ /* ------------------------------------------------------------------ asking a guide (buttons, never typing) */
640
+ /** The guide nearest my hero (within 340 px), and the asks I can make of it: drawn as buttons, four times a second. */
641
+ let asksFor = -1;
642
+ let asksSig = '';
643
+ function paintAsks(): void {
644
+ const panel = $('asks');
645
+ const mySeat = room.mySeat();
646
+ const here = me.has && mySeat !== null && !lookOnly && ui.idle();
647
+ let best: { slot: number; name: string; d: number } | null = null;
648
+ if (here) {
649
+ for (const b of room.view()) {
650
+ const sl = (net.slots ?? []).find((x) => x.slot === b.slot);
651
+ if (!sl?.agent || sl.agent.role !== 'guide') continue;
652
+ const d = Math.hypot(b.x - me.x, b.y - me.y);
653
+ if (d < 340 && (!best || d < best.d)) best = { slot: b.slot, name: b.name, d };
654
+ }
655
+ }
656
+ if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
657
+ const quests = (net.stateOf<string[]>('quests') ?? (room.hosting ? openQuests() : ['slime-hunt'])).slice(0, 3);
658
+ // Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone.
659
+ const zone = zoneOf(me.x, me.y);
660
+ const all = agents.askButtons(best.slot, { quests });
661
+ const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
662
+ const order = [...all.filter((b) => b.k === 'ask_help'), ...lead, ...all.filter((b) => b.k === 'no_thanks')];
663
+ const buttons = order.slice(0, innerWidth < 540 ? 3 : 5);
664
+ const sig = `${best.slot}|${best.name}|${buttons.map((b) => b.text).join('|')}`;
665
+ panel.hidden = false;
666
+ if (sig === asksSig) return;
667
+ asksSig = sig;
668
+ asksFor = best.slot;
669
+ panel.dataset['slot'] = String(best.slot);
670
+ $('asks-who').textContent = `${best.name} · guide`;
671
+ const list = $('asks-list');
672
+ list.textContent = '';
673
+ for (const b of buttons) {
674
+ const btn = document.createElement('button');
675
+ btn.type = 'button';
676
+ btn.textContent = b.text;
677
+ btn.dataset['ask'] = b.k;
678
+ btn.addEventListener('click', () => {
679
+ if (asksFor < 0) return;
680
+ 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); }
681
+ });
682
+ list.append(btn);
683
+ }
684
+ }
685
+ setInterval(paintAsks, 250);
686
+
449
687
  /* ------------------------------------------------------------------ loop */
450
688
  let last = performance.now();
451
689
  let frames = 0;
@@ -460,7 +698,16 @@ function frame(t: number): void {
460
698
  }
461
699
 
462
700
  // Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
463
- net.expose({ scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) });
701
+ net.expose({
702
+ scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
703
+ // The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
704
+ guides: () => ({
705
+ hosting: room.hosting, talking: agents.talking, stats: agents.stats(),
706
+ 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 }; }),
707
+ me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
708
+ log: guideLog.slice(),
709
+ }),
710
+ });
464
711
 
465
712
  exposePort(net, {
466
713
  view: 'top',
@@ -726,9 +726,12 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
726
726
  const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
727
727
  ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
728
728
  ctx.textAlign = 'right';
729
+ // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
730
+ // default): the scores start under that band, so the buttons never cover a score.
731
+ const board = top + 18 + 44;
729
732
  rows.forEach((a, i) => {
730
733
  ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
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));
734
+ ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
732
735
  });
733
736
  // role badge
734
737
  ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';