@homie-rocks/studio 0.31.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/port/room.ts CHANGED
@@ -17,6 +17,12 @@
17
17
  * - Snapshots: one small row per body [slot, seat (-1 = bot), score, ...your
18
18
  * fields], plus your fast world, at 20 Hz; replicas interpolate them.
19
19
  *
20
+ * - Seat or solo (NETPLAY.md section 28): a page in the room with no body is
21
+ * never left with nothing said. The host seats whoever is waiting when a
22
+ * body frees up and at every round start; `fallback` decides what the page
23
+ * does meanwhile (wait, a private round with bots, or watch), within
24
+ * `seatWaitMs`, and `room.standing` says where the player stands.
25
+ *
20
26
  * - Servers and agent seats (NETPLAY.md section 17): a hybrid server's AI
21
27
  * seats are kept as AI bodies (the game's bots move them, marked AI), an AI
22
28
  * that sits takes one, the server's `bots: 'off'` adds no filler bots, and
@@ -48,6 +54,26 @@ export interface RoomCkpt<B, S> {
48
54
  */
49
55
  export interface TakeoverInfo { why: 'join' | 'restore' | 'migrate'; own: boolean; back: boolean }
50
56
 
57
+ /**
58
+ * SEAT OR SOLO (NETPLAY.md section 28): what a page does while the room has no body for it. `wait` (the default):
59
+ * nothing but say so. `solo`: play a private round with bots, and move into the room the moment it has a body for
60
+ * this player (at the latest its next round start). `spectate`: watch the room until then.
61
+ */
62
+ export type Fallback = 'wait' | 'solo' | 'spectate';
63
+ /**
64
+ * Where this player stands, in one word a game can act on. `joining`: not in the round yet (no welcome, or no body
65
+ * yet and still inside `seatWaitMs`). `playing`: a body in the room. `solo`: playing by itself with bots (`why`:
66
+ * `alone` or `offline` from the link, `no-body` or `full` from the fallback). `watching`: a screen or a watcher by
67
+ * choice (`screen`), or the fallback's `spectate`. `waiting`: the fallback's `wait`, past `seatWaitMs`. `closed`:
68
+ * the link stopped for good (`why` is `net.closedWhy`).
69
+ */
70
+ export interface Standing {
71
+ state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed';
72
+ why: string;
73
+ /** The sentence on screen for it (the line over the game), or null. */
74
+ line: string | null;
75
+ }
76
+
51
77
  export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
52
78
  /** The game id (the relay's logs). */
53
79
  game: string;
@@ -113,14 +139,30 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
113
139
  adopt?: (body: B) => void;
114
140
  /** A replica's own body as it sees it right now (owner movement): a promotion keeps it exactly there. */
115
141
  local?: () => B | null;
142
+ /**
143
+ * SEAT OR SOLO (section 28): what this page does when it is in the room and the room has no body for it for
144
+ * `seatWaitMs`: its host has not seated it (every body is somebody's), every seat is taken, or no snapshot has
145
+ * shown its body yet. `'wait'` (the default) keeps what a game did before, and says so on the line over the game.
146
+ * `'solo'`: a private round with bots, here, until the room has a body for this player (the host seats everybody
147
+ * who is waiting at every round start and whenever a body frees up). `'spectate'`: `viewBody()` follows the room.
148
+ */
149
+ fallback?: Fallback;
150
+ /** How long a page in the room waits for its body before the fallback applies. Default 4000 ms; 1000 to 30000. */
151
+ seatWaitMs?: number;
152
+ /** Where this player stands changed (also `room.standing`): draw your own words for it, or leave the line to say it. */
153
+ onStanding?: (s: Standing) => void;
116
154
  /** Extra netplay options (snapshotHz, inputHz, config for tests). */
117
155
  netplay?: Partial<NetplayOptions<RoomCkpt<B, S>>>;
118
156
  }
119
157
 
120
158
  export interface Room<B extends BodyBase, F = unknown, S = unknown> {
121
159
  readonly net: Netplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>;
122
- /** True while this browser runs the rules. */
160
+ /** True while this browser runs the rules: the room's, or (`solo`) a private round of its own. */
123
161
  readonly hosting: boolean;
162
+ /** This browser plays a private round with bots while the room has no body for it (`fallback: 'solo'`). */
163
+ readonly solo: boolean;
164
+ /** Where this player stands (section 28): playing, on its own, watching, waiting, joining or closed, and why. */
165
+ readonly standing: Standing;
124
166
  /** Host: every body by slot. */
125
167
  readonly bodies: Map<number, B>;
126
168
  /** The live round (host: authoritative; others: the relay's copy). */
@@ -199,6 +241,21 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
199
241
  let round: RoundInfo | null = null;
200
242
  let hosting = false;
201
243
  let tick = 0;
244
+ // Seat or solo (section 28).
245
+ const fallback: Fallback = opts.fallback === 'solo' || opts.fallback === 'spectate' ? opts.fallback : 'wait';
246
+ const seatWaitMs = Math.max(1000, Math.min(30_000, Number(opts.seatWaitMs) || 4000));
247
+ /** A private round with bots while the room has no body for this page: `hosting` is true, and nothing is sent. */
248
+ let solo = false;
249
+ let standing: Standing = { state: 'joining', why: 'connecting', line: null };
250
+ /** How long this page, in the room, has been without a body, counted only while it could listen (0: it has one). */
251
+ let noBodyMs = 0;
252
+ let lastCheckAt = 0;
253
+ /** The seat this browser held in its room (and in a private round), for the moment the helper gives the room up. */
254
+ let lastSeat: number | null = null;
255
+ let soloSeat = 0;
256
+ let saidLine: string | null = null;
257
+ /** The host reset this seat and the snapshot that said so had no body for it yet: adopt when one does. */
258
+ let adoptDue = false;
202
259
 
203
260
  const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
204
261
  game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
@@ -209,7 +266,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
209
266
  caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
210
267
  });
211
268
 
212
- const mySeat = (): number | null => (net.offline ? 0 : net.seat);
269
+ // Offline, and in a private round of a full room (no seat at all), this browser is seat 0 of its own round.
270
+ const mySeat = (): number | null => (net.offline ? 0 : solo && net.seat === null ? 0 : net.seat);
213
271
  const bodyOfSeat = (seat: number | null): B | null => {
214
272
  if (seat === null) return null;
215
273
  for (const b of bodies.values()) if (!b.bot && b.seat === seat) return b;
@@ -246,7 +304,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
246
304
  function moved(b: B): void {
247
305
  if (b.bot || b.seat === null) return;
248
306
  if (b.seat === mySeat()) { opts.adopt?.(b); return; }
249
- net.reset(b.seat);
307
+ if (!solo) net.reset(b.seat);
250
308
  }
251
309
 
252
310
  const packRow = (b: B): number[] => [b.slot, b.seat ?? -1, b.score, ...opts.pack(b)];
@@ -257,9 +315,34 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
257
315
  return snap;
258
316
  }
259
317
 
260
- function startRound(n: number): void {
318
+ /**
319
+ * Everybody who holds a seat and has no body is seated now (section 28). A join is claimed once, when it happens;
320
+ * a claim that found no body free was never tried again, so that player watched for the rest of the visit, online
321
+ * and with nothing said. Tried again whenever a body frees up, and at every round start.
322
+ */
323
+ function seatEveryone(quiet = false): boolean {
324
+ if (!hosting || solo || net.offline) return false;
325
+ let any = false;
326
+ for (const p of net.peers.values()) {
327
+ if (p.seat === null || p.seat === mySeat() || roster.bySeat(p.seat)) continue;
328
+ const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
329
+ if (!c) continue;
330
+ any = true;
331
+ syncBodies();
332
+ const b = bodies.get(c.slot.slot);
333
+ if (!b) continue;
334
+ if (c.yielded || c.added) takeover(b, { why: 'join', own: false, back: c.back });
335
+ if (!quiet) moved(b);
336
+ }
337
+ return any;
338
+ }
339
+
340
+ function startRound(n: number, rollover = false): void {
261
341
  const now = net.now();
262
342
  roster.trim();
343
+ // The next round of a running room seats whoever is still waiting for a body, before the bodies are dealt: the
344
+ // trim above is what makes room for them (the seats a server no longer keeps for AI go at the round, not before).
345
+ if (rollover) seatEveryone(true);
263
346
  syncBodies();
264
347
  let i = 0;
265
348
  spawned = new Map();
@@ -273,6 +356,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
273
356
  round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
274
357
  opts.onRoundStart?.(n);
275
358
  for (const b of bodies.values()) moved(b);
359
+ // A private round (solo) is this browser's alone: the room's round, roster and snapshots are its host's to say.
360
+ if (solo) return;
276
361
  net.round(round);
277
362
  net.roster(roster.toJSON());
278
363
  net.snapshot(buildSnap(), tick, true);
@@ -297,7 +382,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
297
382
  const results = rank();
298
383
  round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + breakMs, results };
299
384
  opts.onRoundEnd?.(results);
300
- net.round(round);
385
+ if (!solo) net.round(round);
301
386
  }
302
387
 
303
388
  function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
@@ -345,8 +430,24 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
345
430
  // A browser that was in the room a moment ago (a replica being promoted) sees its own body somewhere real; one
346
431
  // that just arrived (its first role) does not: what it shows locally is a place it has never been.
347
432
  const arriving = e.prev === null;
433
+ // It was playing a private round: what it shows locally is that round's place, not one in the room.
434
+ const wasSolo = solo;
435
+ solo = false;
436
+ // THE ROOM DID NOT COME BACK (section 22): this browser was the room's host, cut off for `reconnectMaxMs`, and
437
+ // plays alone from here. It keeps the round it was running: its own body is seat 0 of a round of its own now,
438
+ // and everybody else's goes to a bot, exactly as if they had left.
439
+ // The same for a private round the page was already playing when its link went: that round goes on.
440
+ if (net.offline && hosting && round && (wasSolo || (e.why === 'reconnect-timeout' && e.prev === 'host'))) {
441
+ const was = wasSolo ? soloSeat : lastSeat;
442
+ const mineWas = was === null ? undefined : roster.slots.find((s) => !s.bot && !s.agent && s.seat === was);
443
+ for (const s of roster.humans()) if (s.seat !== null && s !== mineWas) roster.release(s.seat);
444
+ if (mineWas) { mineWas.seat = 0; mineWas.name = 'You'; } else roster.claim(0, 'You');
445
+ syncBodies();
446
+ net.roster(roster.toJSON());
447
+ return;
448
+ }
348
449
  // My own body was mine a moment ago: keep it exactly where I see it.
349
- const before = !hosting && mySeat() !== null && opts.local && !(e.promoted && arriving) ? opts.local() : null;
450
+ const before = !hosting && !wasSolo && mySeat() !== null && opts.local && !(e.promoted && arriving) ? opts.local() : null;
350
451
  hosting = true;
351
452
  if (e.promoted) restore(e);
352
453
  else {
@@ -381,7 +482,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
381
482
  if (mine && before) opts.unpack(opts.pack(before), mine);
382
483
  // A browser that arrived as the host of a restored round stands where its body stands (its own old one, or the
383
484
  // one it just took over): without this the game's local pose and the host's body disagreed until the next round.
384
- else if (mine && e.promoted && arriving) opts.adopt?.(mine);
485
+ // The same for one that came out of a private round to host the room's.
486
+ else if (mine && e.promoted && (arriving || wasSolo)) opts.adopt?.(mine);
385
487
  net.roster(roster.toJSON());
386
488
  }
387
489
 
@@ -393,10 +495,12 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
393
495
 
394
496
  net.on('role', (e) => {
395
497
  if (e.role === 'host') becomeHost(e);
396
- else hosting = false;
498
+ // A private round goes on while the room's own roles change around it.
499
+ else if (!solo) hosting = false;
500
+ check();
397
501
  });
398
502
  net.on('join', (p) => {
399
- if (!hosting || p.seat === null) return;
503
+ if (!hosting || solo || p.seat === null) return;
400
504
  // An AI takes a seat kept for AI (its hands say who moves the body); a person never does (section 17).
401
505
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
402
506
  if (!c) return; // full: they watch
@@ -412,28 +516,124 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
412
516
  net.snapshot(buildSnap(), tick, true);
413
517
  });
414
518
  net.on('leave', (p) => {
415
- if (!hosting || p.seat === null) return;
519
+ if (!hosting || solo || p.seat === null) return;
416
520
  roster.release(p.seat); // the body stays, a bot drives it now
417
521
  syncBodies();
522
+ // A body just went to a bot: somebody who was seated with none takes it now, not at the next round.
523
+ const seated = seatEveryone();
418
524
  net.roster(roster.toJSON());
525
+ if (seated) net.snapshot(buildSnap(), tick, true);
419
526
  });
420
527
  // The server's policy changed (a new server stamp, the owner): its AI seats come or go at once, filler at the round.
421
528
  net.on('policy', () => {
422
- if (!hosting) return;
529
+ if (!hosting || solo) return;
423
530
  roster.fill();
424
531
  syncBodies();
532
+ seatEveryone();
425
533
  net.roster(roster.toJSON());
426
534
  });
427
- net.on('control', (e) => {
428
- if (!e.reset || !opts.adopt) return;
429
- const snap = e.snap as Snapshot<RoomSnap<F>> | null;
430
- const row = snap?.d?.b?.find((r) => r[1] === net.seat);
431
- if (!row) return;
535
+ /** This seat's row in a snapshot of the room (the newest one, unless given): the body the room has for this page. */
536
+ function roomRow(snap?: Snapshot<RoomSnap<F>> | null): number[] | null {
537
+ if (net.seat === null) return null;
538
+ const s = snap === undefined ? net.latest() : snap;
539
+ return s?.d?.b?.find((r) => r[1] === net.seat) ?? null;
540
+ }
541
+ function adoptRow(row: number[]): void {
542
+ adoptDue = false;
543
+ if (!opts.adopt) return;
432
544
  const b = { slot: row[0], seat: row[1], score: row[2], name: net.name, bot: false } as unknown as B;
433
545
  opts.unpack(row.slice(3), b);
434
546
  opts.adopt(b);
547
+ }
548
+ net.on('control', (e) => {
549
+ if (!e.reset) return;
550
+ // In a private round the room's reset waits: this browser adopts its body when it moves in.
551
+ const row = solo ? null : roomRow(e.snap as Snapshot<RoomSnap<F>> | null);
552
+ // A reset whose snapshot has no body for this seat yet (the host knew the seat before it had a body for it) used
553
+ // to be spent on nothing: the body that came later was never adopted. It is owed until a snapshot shows one.
554
+ if (!row) { adoptDue = true; return; }
555
+ adoptRow(row);
435
556
  });
436
557
 
558
+ /* ------------------------------------------------------------ seat or solo (section 28) */
559
+ function enterSolo(): void {
560
+ solo = true;
561
+ hosting = true;
562
+ soloSeat = mySeat() as number;
563
+ roster = new Roster(rosterOpts());
564
+ bodies = new Map();
565
+ spawned = new Map();
566
+ roster.claim(mySeat() as number, net.name || 'You', null, null);
567
+ syncBodies();
568
+ // The room's own round number, so the HUD does not jump back to 1; the clock is this browser's.
569
+ startRound(Math.max(1, net.roundInfo?.n ?? 1));
570
+ }
571
+ /** The room has a body for this page (or its link changed): the private round ends and it stands in the room. */
572
+ function leaveSolo(): void {
573
+ solo = false;
574
+ hosting = false;
575
+ bodies = new Map();
576
+ spawned = new Map();
577
+ round = null;
578
+ const row = roomRow();
579
+ if (row) adoptRow(row);
580
+ }
581
+ const SAYS: Record<string, string> = {
582
+ joining: 'Joining the round…',
583
+ 'wait:no-body': 'Waiting for a place in this round…',
584
+ 'solo:no-body': 'Playing on your own until the next round',
585
+ 'solo:full': 'This room is full · playing on your own until a seat is free',
586
+ 'spectate:no-body': 'Watching until the next round',
587
+ };
588
+ function stand(state: Standing['state'], why: string, line: string | null): void {
589
+ // The line over the game is the room's only while it has something to say: a game's own `net.line` is left alone.
590
+ if (line !== saidLine) { saidLine = line; net.line(line); }
591
+ if (standing.state === state && standing.why === why && standing.line === line) return;
592
+ standing = { state, why, line };
593
+ try { opts.onStanding?.(standing); } catch (err) { console.warn('[room] onStanding()', err); }
594
+ }
595
+ /** Where this player stands, worked out again: on every role and link change, and four times a second. */
596
+ function check(): void {
597
+ const link = net.link;
598
+ const t = Date.now();
599
+ // Only time this page could listen counts (as for the welcome): after a frame that blocked for seconds, this
600
+ // timer runs before the snapshots queued behind it, and a body that is already on its way is not "missing".
601
+ const heard = lastCheckAt ? Math.max(0, Math.min(t - lastCheckAt, 300)) : 0;
602
+ lastCheckAt = t;
603
+ if (!net.offline && net.seat !== null) lastSeat = net.seat;
604
+ // Stopped for good. A page that plays a round of its own meanwhile (a private one, or the helper's own fallback
605
+ // for a room that refused it) is still playing: the helper's line says why it is not in the room.
606
+ if (link === 'closed') { noBodyMs = 0; stand(solo || (net.offline && hosting) ? 'solo' : 'closed', net.closedWhy ?? 'closed', solo ? saidLine : null); return; }
607
+ // Playing alone (no room, or it never answered, or it did not come back): the helper's own line says which.
608
+ if (net.offline) { noBodyMs = 0; stand('solo', link === 'offline' ? 'offline' : 'alone', null); return; }
609
+ if (link === 'connecting') { noBodyMs = 0; stand('joining', 'connecting', null); return; }
610
+ // A watcher, or a screen that never asked for a seat: watching is what it came for.
611
+ if (net.watching || (net.seat === null && !net.full && !solo)) { noBodyMs = 0; stand('watching', 'screen', null); return; }
612
+ const row = hosting && !solo ? null : roomRow();
613
+ const has = hosting && !solo ? Boolean(bodyOfSeat(mySeat())) : Boolean(row);
614
+ if (has && link === 'online') {
615
+ if (solo) leaveSolo();
616
+ else if (adoptDue && row) adoptRow(row);
617
+ noBodyMs = 0;
618
+ stand('playing', 'seated', null);
619
+ return;
620
+ }
621
+ const why = net.full ? 'full' : 'no-body';
622
+ if (solo) { stand('solo', why, SAYS[`solo:${why}`] as string); return; }
623
+ // Cut off for a moment: still its player (the helper says "Reconnecting…", for a bounded time).
624
+ if (link === 'reconnecting') { stand(has ? 'playing' : 'joining', 'reconnecting', null); return; }
625
+ noBodyMs += heard;
626
+ if (noBodyMs < seatWaitMs) { stand('joining', why, !net.full && noBodyMs >= 700 ? SAYS['joining'] as string : null); return; }
627
+ // BOUNDED: past seatWaitMs the page is told where it stands and, when the game asked, given something to do.
628
+ if (fallback === 'solo' && !hosting) { enterSolo(); stand('solo', why, SAYS[`solo:${why}`] as string); return; }
629
+ if (fallback === 'spectate') { stand('watching', why, net.full ? null : SAYS['spectate:no-body'] as string); return; }
630
+ stand('waiting', why, net.full ? null : SAYS['wait:no-body'] as string);
631
+ }
632
+ const checkTimer = setInterval(check, 250);
633
+ // A room nobody closed must not keep a test or a tool alive.
634
+ (checkTimer as unknown as { unref?: () => void }).unref?.();
635
+ net.on('link', (e) => { check(); if (e.state === 'closed') clearInterval(checkTimer); });
636
+
437
637
  function interpolated(): B[] {
438
638
  const smp = net.sample();
439
639
  if (!smp) return [];
@@ -458,14 +658,18 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
458
658
  const room: Room<B, F, S> = {
459
659
  net,
460
660
  get hosting() { return hosting; },
661
+ get solo() { return solo; },
662
+ get standing() { return standing; },
461
663
  get bodies() { return bodies; },
462
664
  get round() { return hosting ? round : (net.roundInfo ?? round); },
463
665
  mySeat,
464
- viewSeat: () => (net.offline ? 0 : net.viewSeat),
666
+ viewSeat: () => (net.offline || solo ? mySeat() : net.viewSeat),
465
667
  viewBody() {
466
- const s = net.offline ? 0 : net.viewSeat;
467
- if (s === null) return null;
468
- for (const b of room.view()) if (!b.bot && b.seat === s) return b;
668
+ const s = net.offline || solo ? mySeat() : net.viewSeat;
669
+ const all = room.view();
670
+ if (s !== null) for (const b of all) if (!b.bot && b.seat === s) return b;
671
+ // `fallback: 'spectate'`: no body of its own yet, so the camera follows the room (a person first, else anybody).
672
+ if (standing.state === 'watching' && standing.why !== 'screen') return all.find((b) => !b.bot) ?? all[0] ?? null;
469
673
  return null;
470
674
  },
471
675
  skillOf: (body) => net.skillOf(body.slot),
@@ -475,8 +679,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
475
679
  tick += 1;
476
680
  const now = net.now();
477
681
  if (round && round.phase === 'live' && now >= round.endsAt) endRound();
478
- else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
479
- if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
682
+ else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
683
+ if (!solo && net.snapshotDue()) net.snapshot(buildSnap(), tick);
480
684
  },
481
685
  avatar: (b) => (b.bot || b.seat === null ? null : (net.avatar(b.seat) as unknown[] | null)),
482
686
  bound(b, claim, maxSpeed, dt) {
@@ -498,7 +702,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
498
702
  return { n: r.n, phase: r.phase, secondsLeft: Math.max(0, Math.ceil((r.endsAt - net.now()) / 1000)) };
499
703
  },
500
704
  results: () => (hosting ? rank() : (room.round?.results ?? [])),
501
- send: (kind, data) => net.send(kind, data),
705
+ // A private round's effects are this browser's own: they are not sent into a room it is not playing in.
706
+ send: (kind, data) => { if (!solo) net.send(kind, data); },
502
707
  on: net.on.bind(net) as Room<B, F, S>['on'],
503
708
  };
504
709
  return room;
@@ -174,6 +174,10 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
174
174
  adopt: (b) => { me.x = b.x; me.y = b.y; me.has = true; },
175
175
  local: () => (me.has ? ({ ...mine(), x: me.x, y: me.y } as Body) : null),
176
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',
177
181
  });
178
182
  const net = room.net;
179
183
  const myLevel = (): number => hero?.level ?? 1;
@@ -36,7 +36,7 @@
36
36
  *
37
37
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
38
38
  */
39
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
39
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
40
40
  // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
41
41
  // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
42
42
  import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -167,9 +167,12 @@ function newZone(n: number): Zone {
167
167
  }
168
168
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
169
169
 
170
- function startRound(n: number): void {
170
+ function startRound(n: number, rollover = false): void {
171
171
  const now = net.now();
172
172
  roster.trim();
173
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
174
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
175
+ if (rollover && !net.offline) seatWaiting();
173
176
  syncBodiesFromRoster();
174
177
  let i = 0;
175
178
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -282,15 +285,30 @@ net.on('role', (e) => {
282
285
  if (e.role === 'host') becomeHost(e);
283
286
  else { hosting = false; }
284
287
  });
285
- net.on('join', (p) => {
286
- if (!hosting || p.seat === null) return;
288
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
289
+ function giveBody(p: Peer): boolean {
290
+ if (p.seat === null) return false;
287
291
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
288
292
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
289
- if (!c) return; // full: they watch
293
+ if (!c) return false;
290
294
  syncBodiesFromRoster();
291
295
  const b = bodies.get(c.slot.slot);
292
296
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
293
297
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
298
+ return true;
299
+ }
300
+ /**
301
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
302
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
303
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
304
+ */
305
+ function seatWaiting(): boolean {
306
+ let any = false;
307
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
308
+ return any;
309
+ }
310
+ net.on('join', (p) => {
311
+ if (!hosting || !giveBody(p)) return;
294
312
  publishRoster();
295
313
  net.snapshot(buildSnap(), tick, true);
296
314
  });
@@ -298,7 +316,10 @@ net.on('leave', (p) => {
298
316
  if (!hosting || p.seat === null) return;
299
317
  roster.release(p.seat); // their body stays, driven by a bot
300
318
  syncBodiesFromRoster();
319
+ // A body just went to a bot: somebody seated with none takes it now.
320
+ const seated = seatWaiting();
301
321
  publishRoster();
322
+ if (seated) net.snapshot(buildSnap(), tick, true);
302
323
  });
303
324
  // The server's policy changed: its AI seats come (or go between rounds) at once.
304
325
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -584,7 +605,7 @@ function stepHost(dt: number): void {
584
605
  }
585
606
  }
586
607
  if (now >= round.endsAt) endRound();
587
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
608
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
588
609
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
589
610
  }
590
611
 
@@ -987,7 +1008,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
987
1008
  ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
988
1009
  });
989
1010
  }
990
- if (!net.offline && !net.connected && net.role !== 'host') {
1011
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
1012
+ if (net.link === 'reconnecting' && net.role !== 'host') {
991
1013
  ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';
992
1014
  ctx.fillText('Reconnecting…', cw / 2, ch - pad);
993
1015
  }
@@ -23,6 +23,13 @@
23
23
  * byte). A model that is refused or not there is drawn as a stand-in in the same colours (see "models" below): a
24
24
  * round never waits on, or breaks for, a model.
25
25
  *
26
+ * HEADINGS, AND THERE ARE TWO: a body's `yaw` is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
27
+ * (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
28
+ * `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
29
+ * package takes a body's `yaw` as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
30
+ * `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
31
+ * `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
32
+ *
26
33
  * PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
27
34
  * renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
28
35
  * disc under each body, gem, tree and rock (a computer draws the sun's real shadows). Repeated things (gems, trees, flowers, the fence, the discs) are one
@@ -42,7 +49,7 @@ import {
42
49
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
43
50
  // The one model loader every studio game uses: checks each file, decodes meshopt and WebP, copies for placing.
44
51
  import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
45
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
52
+ import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
46
53
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
47
54
  // labels that never pile up (port/view.ts).
48
55
  import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -199,9 +206,12 @@ function newZone(n: number): Zone {
199
206
  }
200
207
  function setZone(z: Zone): void { zone = z; net.state('zone', z); }
201
208
 
202
- function startRound(n: number): void {
209
+ function startRound(n: number, rollover = false): void {
203
210
  const now = net.now();
204
211
  roster.trim();
212
+ // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
213
+ // here), so whoever was seated with no body is given one now, before the bodies are placed.
214
+ if (rollover && !net.offline) seatWaiting();
205
215
  syncBodiesFromRoster();
206
216
  let i = 0;
207
217
  for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
@@ -314,15 +324,30 @@ net.on('role', (e) => {
314
324
  if (e.role === 'host') becomeHost(e);
315
325
  else { hosting = false; }
316
326
  });
317
- net.on('join', (p) => {
318
- if (!hosting || p.seat === null) return;
327
+ /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
328
+ function giveBody(p: Peer): boolean {
329
+ if (p.seat === null) return false;
319
330
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
320
331
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
321
- if (!c) return; // full: they watch
332
+ if (!c) return false;
322
333
  syncBodiesFromRoster();
323
334
  const b = bodies.get(c.slot.slot);
324
335
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
325
336
  if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
337
+ return true;
338
+ }
339
+ /**
340
+ * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
341
+ * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
342
+ * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
343
+ */
344
+ function seatWaiting(): boolean {
345
+ let any = false;
346
+ for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
347
+ return any;
348
+ }
349
+ net.on('join', (p) => {
350
+ if (!hosting || !giveBody(p)) return;
326
351
  publishRoster();
327
352
  net.snapshot(buildSnap(), tick, true);
328
353
  });
@@ -330,7 +355,10 @@ net.on('leave', (p) => {
330
355
  if (!hosting || p.seat === null) return;
331
356
  roster.release(p.seat); // their body stays, driven by a bot
332
357
  syncBodiesFromRoster();
358
+ // A body just went to a bot: somebody seated with none takes it now.
359
+ const seated = seatWaiting();
333
360
  publishRoster();
361
+ if (seated) net.snapshot(buildSnap(), tick, true);
334
362
  });
335
363
  // The server's policy changed: its AI seats come (or go between rounds) at once.
336
364
  net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
@@ -617,7 +645,7 @@ function stepHost(dt: number): void {
617
645
  }
618
646
  }
619
647
  if (now >= round.endsAt) endRound();
620
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
648
+ } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
621
649
  if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
622
650
  }
623
651
 
@@ -2021,7 +2049,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2021
2049
  ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
2022
2050
  });
2023
2051
  }
2024
- if (!net.offline && !net.connected && net.role !== 'host') {
2052
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
2053
+ if (net.link === 'reconnecting' && net.role !== 'host') {
2025
2054
  ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
2026
2055
  const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
2027
2056
  chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);