@homie-rocks/studio 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
@@ -0,0 +1,357 @@
1
+ /*
2
+ * @homie-rocks/studio/powerups: pickups the host owns, and three of them that work.
3
+ * =============================================================================
4
+ *
5
+ * Power-ups are where a multiplayer game's bookkeeping goes wrong first. Who holds what, and for how much longer,
6
+ * is state every browser must agree on; it has timers in it; and the browser that owns it can leave. The usual
7
+ * first version keeps `shieldUntil = Date.now() + 8000` in the host's memory, and the first host migration either
8
+ * forgets every power-up in the room or hands the new host deadlines measured on somebody else's clock.
9
+ *
10
+ * So everything here is ONE PLAIN OBJECT (`PowerState`) and PURE FUNCTIONS over it: each takes a state and returns
11
+ * a new one, with the events that happened. Nothing reads a clock, a random number or the network.
12
+ *
13
+ * THE CONTRACT (`PowerupContract` below spells it as types)
14
+ * inputs none of its own. It reads bodies' positions; a game with a "use" button holds the item in its own
15
+ * state and calls `grant` when it is pressed.
16
+ * state `PowerState`: pickups lying on spawn spots, who holds what, and how long each has LEFT in
17
+ * milliseconds. Remaining time, never a deadline: a new host carries on from "3.2 s left" without
18
+ * knowing what time the old host thought it was. The spawn sequence's seed is in the state too, so the
19
+ * next pickup is the same one whoever is hosting.
20
+ * netplay host-authoritative. The host calls `stepPowerups`, `collectPowerups` and `resolveHit` in its tick
21
+ * and puts `packPowerups(state)` in its snapshot AND its checkpoint; replicas `unpackPowerups` and draw.
22
+ * It is the game's own snapshot data: the wire protocol is untouched. `powerFlags` is a bitmask per
23
+ * body for a game that would rather carry one small number on each body.
24
+ *
25
+ * THE THREE
26
+ * magnet a wider bite (`biteRadius`), and things inside its reach drift toward the mouth (`magnetPull`):
27
+ * the drift is what makes the wider bite read as fair on screen
28
+ * phase no collisions: `collides(state, a, b)` is false while either body holds it
29
+ * shield blocks ONE hit and bounces the attacker back along its own approach (`resolveHit`); the charge is
30
+ * spent, the timer still runs out on its own
31
+ *
32
+ * A game adds its own kind by adding a row to `rules.kinds` and reading `holds(state, slot, 'its-kind')`.
33
+ *
34
+ * DRAWING IS A THIN LAYER AT THE BOTTOM, and optional: `powerDraws` fills instance matrices (gems on spots, a ring
35
+ * round each magnet holder, a bubble round each shield holder) for three instanced meshes the game owns, and
36
+ * `hudPills` says what the HUD's pill shows. Neither imports a renderer, and both read only the state, so they
37
+ * work the same on the host and on a replica.
38
+ * =============================================================================
39
+ */
40
+ import type { V2 } from './bots';
41
+
42
+ /** One kind of power-up. */
43
+ export interface PowerDef {
44
+ /** How long it lasts once picked up, milliseconds. */
45
+ ms: number;
46
+ /** How often it spawns compared with the others (default 1). */
47
+ weight?: number;
48
+ /** Hits it absorbs before it is used up (a shield: 1). Absent: it only runs out on time. */
49
+ charges?: number;
50
+ /** What the HUD calls it. */
51
+ label?: string;
52
+ }
53
+
54
+ export interface PowerRules {
55
+ /** The kinds, by name. THEIR ORDER IS PART OF THE WIRE FORMAT (a kind travels as its index): add new ones last. */
56
+ kinds: Record<string, PowerDef>;
57
+ /** Milliseconds a spot stays empty after its pickup is taken. */
58
+ respawnMs: number;
59
+ /** Milliseconds before the first pickups appear (default: `respawnMs`). */
60
+ firstMs?: number;
61
+ /** Most pickups lying about at once. */
62
+ maxOnField: number;
63
+ /** How close a body's edge must be to a spot to take its pickup, in the game's units. */
64
+ pickupRadius: number;
65
+ magnet: { /** Bite radius is multiplied by this. */ bite: number; /** Things within `reach` bite radii drift in. */ reach: number; /** Units a second. */ pull: number };
66
+ shield: { /** The speed the attacker is thrown back at, units a second. */ bounce: number };
67
+ }
68
+
69
+ /** Tuned on a game where bodies are about one unit across and cross the arena in ten seconds. Change freely. */
70
+ export const POWER_RULES: PowerRules = Object.freeze({
71
+ kinds: Object.freeze({
72
+ magnet: Object.freeze({ ms: 9000, weight: 1, label: 'Magnet' }),
73
+ phase: Object.freeze({ ms: 6000, weight: 1, label: 'Phase' }),
74
+ shield: Object.freeze({ ms: 12000, weight: 1, charges: 1, label: 'Shield' }),
75
+ }),
76
+ respawnMs: 12000,
77
+ firstMs: 4000,
78
+ maxOnField: 3,
79
+ pickupRadius: 0.6,
80
+ magnet: Object.freeze({ bite: 1.8, reach: 3, pull: 6 }),
81
+ shield: Object.freeze({ bounce: 9 }),
82
+ }) as PowerRules;
83
+
84
+ /** A pickup lying on a spawn spot. */
85
+ export interface Pickup { id: number; spot: number; kind: string }
86
+ /** A power-up somebody holds: `left` is milliseconds remaining. */
87
+ export interface Held { slot: number; kind: string; left: number; charges: number }
88
+
89
+ export interface PowerState {
90
+ /** Milliseconds this state has been stepped. Only for effects that want a phase; nothing compares it to a clock. */
91
+ t: number;
92
+ /** The spawn sequence's generator. In the state so a new host draws the same next pickup. */
93
+ seed: number;
94
+ nextId: number;
95
+ pickups: Pickup[];
96
+ /** For each spawn spot: milliseconds until it may spawn again. */
97
+ cool: number[];
98
+ held: Held[];
99
+ }
100
+
101
+ export type PowerEvent =
102
+ | { type: 'spawn'; id: number; spot: number; kind: string }
103
+ | { type: 'pickup'; id: number; spot: number; kind: string; slot: number }
104
+ | { type: 'expire'; slot: number; kind: string }
105
+ | { type: 'block'; slot: number; by: number }
106
+ | { type: 'spent'; slot: number; kind: string };
107
+
108
+ /** The state as it travels in a snapshot or a checkpoint: small arrays, kinds as their index in `rules.kinds`. */
109
+ export interface PowerWire { t: number; s: number; n: number; p: [id: number, spot: number, kind: number][]; c: number[]; h: [slot: number, kind: number, left: number, charges: number][] }
110
+
111
+ /** The mechanic's contract, as types: what it reads, what it owns, what it sends, and who decides. */
112
+ export interface PowerupContract {
113
+ /** It has no input of its own: it reads where the bodies are. */
114
+ inputs: { bodies: readonly PowerBody[] };
115
+ state: PowerState;
116
+ /** In the host's snapshot and its checkpoint. Additive: a game without power-ups ignores the field. */
117
+ wire: PowerWire;
118
+ netplay: 'host-authoritative';
119
+ }
120
+
121
+ /** A body as the rules see it: its slot, where it is, and its radius. */
122
+ export interface PowerBody extends V2 { slot: number; r?: number }
123
+
124
+ export interface Stepped { state: PowerState; events: PowerEvent[] }
125
+
126
+ /** One draw from the state's generator: a number in [0, 1) and the next seed. */
127
+ function draw(seed: number): [number, number] {
128
+ const a = (seed + 0x6d2b79f5) >>> 0;
129
+ let t = a;
130
+ t = Math.imul(t ^ (t >>> 15), t | 1);
131
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
132
+ return [((t ^ (t >>> 14)) >>> 0) / 4294967296, a];
133
+ }
134
+
135
+ /** A fresh state for an arena with `spots` spawn spots. */
136
+ export function createPowerups(spots: number, seed: number, rules: PowerRules = POWER_RULES): PowerState {
137
+ const first = rules.firstMs ?? rules.respawnMs;
138
+ // Staggered, so the spots do not all fill on one frame and then all again one respawn later.
139
+ return { t: 0, seed: seed >>> 0, nextId: 1, pickups: [], cool: Array.from({ length: Math.max(0, spots) }, (_, i) => first + (i * rules.respawnMs) / Math.max(1, spots)), held: [] };
140
+ }
141
+
142
+ /** Time passes: timers run down, what has run out expires, and empty spots that are due get a pickup. */
143
+ export function stepPowerups(s: PowerState, dtMs: number, rules: PowerRules = POWER_RULES): Stepped {
144
+ const dt = Math.max(0, dtMs);
145
+ const events: PowerEvent[] = [];
146
+ const held: Held[] = [];
147
+ for (const h of s.held) {
148
+ const left = h.left - dt;
149
+ if (left > 0) held.push({ ...h, left }); else events.push({ type: 'expire', slot: h.slot, kind: h.kind });
150
+ }
151
+ const taken = new Set(s.pickups.map((p) => p.spot));
152
+ const cool = s.cool.map((c, i) => (taken.has(i) ? c : Math.max(0, c - dt)));
153
+ const pickups = s.pickups.slice();
154
+ let seed = s.seed; let nextId = s.nextId;
155
+ const names = Object.keys(rules.kinds);
156
+ const total = names.reduce((n, k) => n + Math.max(0, (rules.kinds[k] as PowerDef).weight ?? 1), 0);
157
+ for (let i = 0; i < cool.length && pickups.length < rules.maxOnField && total > 0; i += 1) {
158
+ if (taken.has(i) || (cool[i] as number) > 0) continue;
159
+ const [u, next] = draw(seed);
160
+ seed = next;
161
+ let pick = names[names.length - 1] as string; let acc = 0;
162
+ for (const k of names) { acc += Math.max(0, (rules.kinds[k] as PowerDef).weight ?? 1); if (u * total < acc) { pick = k; break; } }
163
+ pickups.push({ id: nextId, spot: i, kind: pick });
164
+ events.push({ type: 'spawn', id: nextId, spot: i, kind: pick });
165
+ nextId += 1; taken.add(i);
166
+ }
167
+ return { state: { t: s.t + dt, seed, nextId, pickups, cool, held }, events };
168
+ }
169
+
170
+ /** Give a slot a power-up (a pickup does; a game's own shop or a "use" button may). Holding it already refreshes it. */
171
+ export function grant(s: PowerState, slot: number, kind: string, rules: PowerRules = POWER_RULES): PowerState {
172
+ const def = rules.kinds[kind];
173
+ if (!def) return s;
174
+ return { ...s, held: [...s.held.filter((h) => !(h.slot === slot && h.kind === kind)), { slot, kind, left: def.ms, charges: def.charges ?? 0 }] };
175
+ }
176
+
177
+ /**
178
+ * Bodies take the pickups they touch. `spots` are the spawn spots' positions, in the order the state counts them.
179
+ * When two bodies reach one pickup on the same tick the lower slot has it: a rule, so every host agrees.
180
+ */
181
+ export function collectPowerups(s: PowerState, bodies: readonly PowerBody[], spots: readonly V2[], rules: PowerRules = POWER_RULES): Stepped {
182
+ if (!s.pickups.length) return { state: s, events: [] };
183
+ const order = [...bodies].sort((a, b) => a.slot - b.slot);
184
+ const events: PowerEvent[] = [];
185
+ let state = s;
186
+ const left: Pickup[] = [];
187
+ const cool = s.cool.slice();
188
+ for (const p of s.pickups) {
189
+ const at = spots[p.spot];
190
+ const who = at ? order.find((b) => Math.hypot(b.x - at.x, b.y - at.y) <= rules.pickupRadius + (b.r ?? 0)) : undefined;
191
+ if (!who) { left.push(p); continue; }
192
+ cool[p.spot] = rules.respawnMs;
193
+ state = grant(state, who.slot, p.kind, rules);
194
+ events.push({ type: 'pickup', id: p.id, spot: p.spot, kind: p.kind, slot: who.slot });
195
+ }
196
+ return events.length ? { state: { ...state, pickups: left, cool }, events } : { state: s, events };
197
+ }
198
+
199
+ /** Whether a slot holds a kind now. */
200
+ export const holds = (s: PowerState, slot: number, kind: string): boolean => s.held.some((h) => h.slot === slot && h.kind === kind);
201
+ /** Everything a slot holds. */
202
+ export const heldBy = (s: PowerState, slot: number): Held[] => s.held.filter((h) => h.slot === slot);
203
+ /** A body left the round (or died): what it held goes with it. */
204
+ export const dropSlot = (s: PowerState, slot: number): PowerState => (s.held.some((h) => h.slot === slot) ? { ...s, held: s.held.filter((h) => h.slot !== slot) } : s);
205
+
206
+ /** Magnet: how far a slot's bite reaches, given its ordinary radius. */
207
+ export const biteRadius = (s: PowerState, slot: number, base: number, rules: PowerRules = POWER_RULES): number => (holds(s, slot, 'magnet') ? base * rules.magnet.bite : base);
208
+
209
+ /**
210
+ * Magnet: where a loose thing is after `dtMs` of being drawn toward a holder's mouth. Unchanged when the slot holds
211
+ * no magnet or the thing is out of reach. The host may move the real thing with it; a replica may move only what it
212
+ * draws. Either way it never overshoots the mouth.
213
+ */
214
+ export function magnetPull(s: PowerState, slot: number, mouth: V2, item: V2, base: number, dtMs: number, rules: PowerRules = POWER_RULES): V2 {
215
+ if (!holds(s, slot, 'magnet')) return item;
216
+ const dx = mouth.x - item.x; const dy = mouth.y - item.y; const d = Math.hypot(dx, dy);
217
+ if (d < 1e-9 || d > base * rules.magnet.bite * rules.magnet.reach) return item;
218
+ const move = Math.min(d, (rules.magnet.pull * Math.max(0, dtMs)) / 1000);
219
+ return { x: item.x + (dx / d) * move, y: item.y + (dy / d) * move };
220
+ }
221
+
222
+ /** Phase: whether two bodies collide at all. False while either is phased. */
223
+ export const collides = (s: PowerState, a: number, b: number): boolean => !holds(s, a, 'phase') && !holds(s, b, 'phase');
224
+
225
+ export interface HitResult {
226
+ state: PowerState;
227
+ /** `hit`: it lands, the game applies its own damage. `blocked`: a shield took it. `phased`: it passed through. */
228
+ outcome: 'hit' | 'blocked' | 'phased';
229
+ /** For `blocked`: the velocity to give the attacker, back along the way it came. */
230
+ bounce: V2 | null;
231
+ events: PowerEvent[];
232
+ }
233
+
234
+ /**
235
+ * Shield and phase: what happens when `attacker` hits `victim`. `toward` is the direction from the attacker to the
236
+ * victim (any length). A shield spends one charge, and is gone when it has none left; the attacker is thrown back.
237
+ */
238
+ export function resolveHit(s: PowerState, attacker: number, victim: number, toward: V2, rules: PowerRules = POWER_RULES): HitResult {
239
+ if (!collides(s, attacker, victim)) return { state: s, outcome: 'phased', bounce: null, events: [] };
240
+ const shield = s.held.find((h) => h.slot === victim && h.kind === 'shield' && h.charges > 0);
241
+ if (!shield) return { state: s, outcome: 'hit', bounce: null, events: [] };
242
+ const charges = shield.charges - 1;
243
+ const events: PowerEvent[] = [{ type: 'block', slot: victim, by: attacker }];
244
+ if (charges <= 0) events.push({ type: 'spent', slot: victim, kind: 'shield' });
245
+ const held = charges > 0 ? s.held.map((h) => (h === shield ? { ...h, charges } : h)) : s.held.filter((h) => h !== shield);
246
+ const d = Math.hypot(toward.x, toward.y);
247
+ const bounce = d < 1e-9 ? { x: 0, y: 0 } : { x: 0 - (toward.x / d) * rules.shield.bounce, y: 0 - (toward.y / d) * rules.shield.bounce };
248
+ return { state: { ...s, held }, outcome: 'blocked', bounce, events };
249
+ }
250
+
251
+ /** A slot's power-ups as one small number: bit `i` is the `i`-th kind of `rules.kinds`. For a field on each body. */
252
+ export function powerFlags(s: PowerState, slot: number, rules: PowerRules = POWER_RULES): number {
253
+ const names = Object.keys(rules.kinds);
254
+ let f = 0;
255
+ for (const h of s.held) if (h.slot === slot) { const i = names.indexOf(h.kind); if (i >= 0 && i < 31) f |= 1 << i; }
256
+ return f;
257
+ }
258
+ /** Whether a `powerFlags` number says a kind is held. */
259
+ export const flagHolds = (flags: number, kind: string, rules: PowerRules = POWER_RULES): boolean => { const i = Object.keys(rules.kinds).indexOf(kind); return i >= 0 && (flags & (1 << i)) !== 0; };
260
+
261
+ /** The state for a snapshot or a checkpoint. Times are whole milliseconds. */
262
+ export function packPowerups(s: PowerState, rules: PowerRules = POWER_RULES): PowerWire {
263
+ const names = Object.keys(rules.kinds);
264
+ return {
265
+ t: Math.round(s.t), s: s.seed, n: s.nextId,
266
+ p: s.pickups.map((p) => [p.id, p.spot, names.indexOf(p.kind)]),
267
+ c: s.cool.map((c) => Math.round(c)),
268
+ h: s.held.map((h) => [h.slot, names.indexOf(h.kind), Math.round(h.left), h.charges]),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The reverse, on a replica every snapshot and on a new host once (from the last snapshot or the checkpoint).
274
+ * Anything malformed, or a kind this build does not know, is dropped: a stale tab reading a newer room keeps
275
+ * running with the power-ups it understands.
276
+ */
277
+ export function unpackPowerups(w: PowerWire | null | undefined, rules: PowerRules = POWER_RULES): PowerState {
278
+ const names = Object.keys(rules.kinds);
279
+ const num = (v: unknown, d = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : d);
280
+ const o = (w ?? {}) as Partial<PowerWire>;
281
+ const pickups: Pickup[] = [];
282
+ for (const p of Array.isArray(o.p) ? o.p : []) { const kind = names[num(p?.[2], -1)]; if (kind !== undefined && Number.isInteger(p[0]) && Number.isInteger(p[1])) pickups.push({ id: num(p[0]), spot: num(p[1]), kind }); }
283
+ const held: Held[] = [];
284
+ for (const h of Array.isArray(o.h) ? o.h : []) { const kind = names[num(h?.[1], -1)]; if (kind !== undefined && Number.isInteger(h[0]) && num(h[2]) > 0) held.push({ slot: num(h[0]), kind, left: num(h[2]), charges: num(h[3]) }); }
285
+ return { t: num(o.t), seed: num(o.s) >>> 0, nextId: Math.max(1, num(o.n, 1)), pickups, cool: (Array.isArray(o.c) ? o.c : []).map((c) => Math.max(0, num(c))), held };
286
+ }
287
+
288
+ /* ------------------------------------------------------------------ drawing (optional; no renderer imported) */
289
+
290
+ /** Where a thing is for drawing: the game's flat `x, y`, and how high above the ground (default 0). */
291
+ export interface DrawPoint extends V2 { h?: number }
292
+
293
+ export interface PowerDrawOptions {
294
+ /** `'xz'`: a 3D game, y up, the flat `y` goes to world z (the default). `'xy'`: a flat game, z toward the viewer. */
295
+ plane?: 'xz' | 'xy';
296
+ /** Gem size, and how high it floats over its spot. */
297
+ gem?: number; float?: number;
298
+ }
299
+
300
+ export interface PowerDraws {
301
+ /** 4x4 matrices, column-major, 16 numbers each: what an InstancedMesh's `instanceMatrix.array` holds. */
302
+ gems: Float32Array; rings: Float32Array; bubbles: Float32Array;
303
+ /** How many of each are filled this frame: set each mesh's `count` to it. */
304
+ gemCount: number; ringCount: number; bubbleCount: number;
305
+ /** For each gem, its kind's index in `rules.kinds` (a colour table), and for each ring and bubble, the slot. */
306
+ gemKinds: Uint8Array; ringSlots: Int16Array; bubbleSlots: Int16Array;
307
+ }
308
+
309
+ /** Buffers for up to `gems` pickups and `bodies` holders. Make once; `powerDraws` refills them. */
310
+ export function createPowerDraws(gems: number, bodies: number): PowerDraws {
311
+ return { gems: new Float32Array(gems * 16), rings: new Float32Array(bodies * 16), bubbles: new Float32Array(bodies * 16), gemCount: 0, ringCount: 0, bubbleCount: 0, gemKinds: new Uint8Array(gems), ringSlots: new Int16Array(bodies), bubbleSlots: new Int16Array(bodies) };
312
+ }
313
+
314
+ /** A matrix: a turn of `spin` about the up axis, a uniform scale, then a move to `(x, y, h)` on the chosen plane. */
315
+ function place(out: Float32Array, at: number, x: number, y: number, h: number, scale: number, spin: number, xz: boolean): void {
316
+ const c = Math.cos(spin) * scale; const s = Math.sin(spin) * scale;
317
+ const o = at * 16;
318
+ out.fill(0, o, o + 16);
319
+ if (xz) { out[o] = c; out[o + 2] = -s; out[o + 5] = scale; out[o + 8] = s; out[o + 10] = c; out[o + 12] = x; out[o + 13] = h; out[o + 14] = y; }
320
+ else { out[o] = c; out[o + 1] = s; out[o + 4] = -s; out[o + 5] = c; out[o + 10] = scale; out[o + 12] = x; out[o + 13] = y; out[o + 14] = h; }
321
+ out[o + 15] = 1;
322
+ }
323
+
324
+ /**
325
+ * Fill the three instance buffers from the state: a gem on every occupied spot (turning, bobbing), a ring at the
326
+ * feet of every magnet holder (as wide as its bite), a bubble round every shield holder (breathing a little).
327
+ * `now` is any millisecond clock the browser draws with: it only animates. Three draws, whatever the room size.
328
+ */
329
+ export function powerDraws(s: PowerState, spots: readonly DrawPoint[], bodies: readonly (PowerBody & { h?: number })[], now: number, out: PowerDraws, rules: PowerRules = POWER_RULES, opts: PowerDrawOptions = {}): PowerDraws {
330
+ const xz = (opts.plane ?? 'xz') === 'xz';
331
+ const names = Object.keys(rules.kinds);
332
+ const size = opts.gem ?? 0.45; const float = opts.float ?? 0.9;
333
+ let g = 0;
334
+ for (const p of s.pickups) {
335
+ const at = spots[p.spot];
336
+ if (!at || g >= out.gemKinds.length) continue;
337
+ place(out.gems, g, at.x, at.y, (at.h ?? 0) + float + Math.sin(now / 420 + p.id) * 0.12, size, now / 600 + p.id, xz);
338
+ out.gemKinds[g] = Math.max(0, names.indexOf(p.kind));
339
+ g += 1;
340
+ }
341
+ let r = 0; let b = 0;
342
+ for (const body of bodies) {
343
+ const base = body.r ?? 0.5;
344
+ if (holds(s, body.slot, 'magnet') && r < out.ringSlots.length) { place(out.rings, r, body.x, body.y, (body.h ?? 0) + 0.05, biteRadius(s, body.slot, base, rules), -now / 900, xz); out.ringSlots[r] = body.slot; r += 1; }
345
+ if (holds(s, body.slot, 'shield') && b < out.bubbleSlots.length) { place(out.bubbles, b, body.x, body.y, (body.h ?? 0) + base, base * (1.35 + Math.sin(now / 300 + body.slot) * 0.04), 0, xz); out.bubbleSlots[b] = body.slot; b += 1; }
346
+ }
347
+ out.gemCount = g; out.ringCount = r; out.bubbleCount = b;
348
+ return out;
349
+ }
350
+
351
+ /** One pill of the HUD: what is held, how much of its time is left (1 full, 0 gone), and its charges. */
352
+ export interface HudPill { kind: string; label: string; left: number; fraction: number; charges: number }
353
+
354
+ /** What a slot's HUD shows, soonest to run out first. */
355
+ export function hudPills(s: PowerState, slot: number, rules: PowerRules = POWER_RULES): HudPill[] {
356
+ return heldBy(s, slot).map((h) => { const def = rules.kinds[h.kind]; return { kind: h.kind, label: def?.label ?? h.kind, left: h.left, fraction: def ? Math.max(0, Math.min(1, h.left / def.ms)) : 0, charges: h.charges }; }).sort((a, b) => a.left - b.left);
357
+ }
package/port/probe.ts CHANGED
@@ -37,8 +37,13 @@ export interface PortProbeOptions {
37
37
  /** True while my body is not mine to steer (knocked back, stunned, dead, respawning): the owner tests skip
38
38
  * those frames, and the recipe's newcomer rule says it should rarely happen in a person's first seconds. */
39
39
  busy?: () => boolean;
40
- /** Anything else worth a look in a receipt (numbers or short strings). */
41
- extra?: Record<string, () => unknown>;
40
+ /**
41
+ * Anything else worth a look in a receipt (numbers or short strings), each a function read when asked. The names
42
+ * in `PortExtra` are read BY NAME by the instruments (the playtest skill, `homie-studio perf`, `shoot`): set the
43
+ * ones this game has, and its rows say what state each press and picture was taken in. Any other name is kept
44
+ * in the receipt as it is.
45
+ */
46
+ extra?: PortExtra & Record<string, () => unknown>;
42
47
  /** The keys that move you (KeyboardEvent.code). Default: the arrows (WASD for first-person). */
43
48
  keys?: { up: string; down: string; left: string; right: string };
44
49
  /** Where the check puts a thumb for the stick, as screen fractions (default [0.24, 0.74]). */
@@ -48,6 +53,35 @@ export interface PortProbeOptions {
48
53
  world?: string;
49
54
  }
50
55
 
56
+ /**
57
+ * THE STATE HOOKS THE INSTRUMENTS READ BY NAME (all optional). A playtest that cannot tell a dead body from a broken
58
+ * control, or a perf run that cannot see the renderer's own counters, says "unknown" or "not exposed" where one of
59
+ * these lines would have given it the answer.
60
+ *
61
+ * exposePort(net, { view: 'top', self, busy,
62
+ * extra: { alive: () => me.hp > 0, mode: () => hud.mode, loadout: () => me.weapon,
63
+ * drawCalls: () => renderer.info.render.calls, triangles: () => renderer.info.render.triangles } });
64
+ *
65
+ * The round (`info().round`: { n, phase: 'live' | 'over', endsAt, leftMs }) needs no hook: it is the room's own, from
66
+ * `net.round(...)`, which `createRoom` calls for every round it runs. So are `info().link` and `info().reconnects`
67
+ * (where this browser stands with its room, and how often that was interrupted).
68
+ */
69
+ export interface PortExtra {
70
+ /** The local body is alive. false: spectating until the next round (a press that moves nothing is then not a broken control). */
71
+ alive?: () => boolean;
72
+ /** The control mode on screen, in a word or two ("manual fire", "auto", "driving"): it changes what a press does. */
73
+ mode?: () => string;
74
+ /** What the player holds (a weapon, a tool), when it changes which controls show. */
75
+ loadout?: () => string;
76
+ /** A thumb is down on the game's own stick right now. */
77
+ touchHeld?: () => boolean;
78
+ /** The renderer's last frame, for measured scene cost (three.js: `renderer.info.render.calls` / `.triangles`). */
79
+ drawCalls?: () => number;
80
+ triangles?: () => number;
81
+ }
82
+ /** The names above, for a test and for anything that lists what a probe may say. */
83
+ export const PORT_EXTRA_NAMES = ['alive', 'mode', 'loadout', 'touchHeld', 'drawCalls', 'triangles'] as const;
84
+
51
85
  interface PortProbe {
52
86
  v: 1;
53
87
  view: View;
@@ -113,6 +147,8 @@ export function exposePort(net: Netplay<unknown, unknown, unknown> | null, opts:
113
147
  role: net?.role ?? null, seat: net?.seat ?? null, offline: net?.offline ?? null, owned: net?.owned ?? null,
114
148
  round: net?.roundInfo ? { n: net.roundInfo.n, phase: net.roundInfo.phase, endsAt: net.roundInfo.endsAt, leftMs: Math.round(net.roundInfo.endsAt - net.now()) } : null,
115
149
  slots: net?.slots?.map((s) => ({ slot: s.slot, seat: s.seat, bot: s.bot })) ?? null,
150
+ // Where this browser stands with its room (netplay revision 9): a reading taken while cut off is not play.
151
+ link: net?.link ?? null, reconnects: net?.reconnects ?? null,
116
152
  lastMove, moves: opts.moves ? opts.moves() : null, score,
117
153
  audio: audioReport(), sandbox: sandboxReport(), errors: errors.slice(-10), extra,
118
154
  };
package/port/room.ts CHANGED
@@ -26,15 +26,27 @@
26
26
  * brains, the drawing, and the camera. See the port skill's recipe.
27
27
  */
28
28
  import {
29
- capMove, createNetplay, lerp, lerpAngle, Roster,
30
- type Movement, type Netplay, type NetplayOptions, type Policy, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot,
29
+ capMove, createNetplay, lerp, lerpAngle, placesOf, Roster,
30
+ type Movement, type Netplay, type NetplayOptions, type Policy, type RoleChange, type RosterOptions, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot, type TiePolicy,
31
31
  } from '../netplay/netplay';
32
32
 
33
33
  /** A body. `agent` (revision 6): this body is an AI's (a seat kept for AI, or an agent's), always marked AI. */
34
34
  export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
35
35
 
36
36
  export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
37
- export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number; spawns?: [slot: number, index: number][] }
37
+ export interface RoomCkpt<B, S> {
38
+ round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number; spawns?: [slot: number, index: number][];
39
+ /** Which stay in its seat held each body ([seat, occ], from the relay's `peer.occ`): how a new host tells a player who came back from a new one in the same seat number. */
40
+ occ?: [seat: number, occ: number][];
41
+ }
42
+ /**
43
+ * Why a body changed hands (`onTakeover`'s second argument). `join`: a person arrived in a running round. `restore`:
44
+ * this browser became host of a room that had emptied and still had its checkpoint, and the body was claimed as the
45
+ * round came back. `migrate`: this browser was promoted in a live room and the body's player had arrived since the
46
+ * last checkpoint. `own`: it is this browser's own body. `back`: its player held it before (a reload, a reconnect),
47
+ * so a game that keeps a returning player's score reads this.
48
+ */
49
+ export interface TakeoverInfo { why: 'join' | 'restore' | 'migrate'; own: boolean; back: boolean }
38
50
 
39
51
  export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
40
52
  /** The game id (the relay's logs). */
@@ -67,8 +79,30 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
67
79
  /** Host: round n starts; every body was just respawned. Reset the world here. */
68
80
  onRoundStart?: (n: number) => void;
69
81
  onRoundEnd?: (results: RoundResult[]) => void;
70
- /** Host: a person took over `body` (it was a bot). Default: keep where it stands, score 0. */
71
- onTakeover?: (body: B) => void;
82
+ /**
83
+ * Host: a person took over `body` (it was a bot). Default: keep where it stands, score 0. Called for every such
84
+ * claim, however it came about (NETPLAY.md section 25): a join in a running round, and the claims made when a
85
+ * room that had emptied is revived from its checkpoint or a host is replaced, the new host's own body included.
86
+ * `info` says which. Not called for a player whose body never left them.
87
+ */
88
+ onTakeover?: (body: B, info: TakeoverInfo) => void;
89
+ /**
90
+ * ADMISSION (section 25): which bot's body a NEW arrival takes, for a game where not every body will do (an
91
+ * elimination round: a newcomer should not be handed a dead body while a living bot stands by). Called with the
92
+ * bots' bodies the arrival may take, in slot order; return the one to give, `undefined` for the default (the
93
+ * lowest slot), or `null` when none will do (a new body is spawned while the room has space; with none, the
94
+ * default applies). Decide from the bodies' own state, so a new host makes the same choice. It is asked for fresh
95
+ * joins and for the claims of a restored room or a new host alike; never for a player coming back to their own
96
+ * body, never with a seat kept for AI when a person arrives. The host resets the body and checkpoints as always.
97
+ */
98
+ admit?: (candidates: B[], who: { seat: number; name: string; agent: boolean }) => B | null | undefined;
99
+ /**
100
+ * How results place equal scores (section 26). 'order' (the default, what results always were): 1, 2, 3… in the
101
+ * order below, so two equal scores get different places. 'shared': equal scores share a place and the next is
102
+ * skipped (1, 1, 3). 'dense': they share one and none is skipped (1, 1, 2). The order of the rows is always:
103
+ * higher score first, then people before bots, then the lower slot.
104
+ */
105
+ ties?: TiePolicy;
72
106
  /** Host: the fast world beyond bodies (projectiles, pickups), sent with every snapshot. Keep it under ~1.5 KB. */
73
107
  fastWorld?: () => F;
74
108
  /** Host: everything else the rules need to continue (for checkpoints). */
@@ -125,7 +159,7 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
125
159
  fast(): F | undefined;
126
160
  /** Seconds left in this phase, and the phase. */
127
161
  clock(): { n: number; phase: 'live' | 'over' | 'none'; secondsLeft: number };
128
- /** Rank bodies by score (ties: people first). */
162
+ /** Rank bodies by score (equal scores: people before bots, then the lower slot; their places as `ties` says). */
129
163
  results(): RoundResult[];
130
164
  /** One-shot events to everyone (sounds, hits, effects). */
131
165
  send(kind: string, data?: unknown): void;
@@ -145,7 +179,20 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
145
179
 
146
180
  // The roster keeps the server's AI seats (section 17); the policy is the room's, read when it fills.
147
181
  const policy = (): Policy => net.policy;
148
- let roster = new Roster({ min, max, botName, policy });
182
+ // The game's admission choice (section 25), in bodies: the roster asks it for every new arrival's claim.
183
+ const rosterOpts = (): RosterOptions => ({
184
+ min, max, botName, policy,
185
+ ...(opts.admit ? {
186
+ admit: (cands, who) => {
187
+ const list = cands.map((c) => bodies.get(c.slot)).filter((b): b is B => Boolean(b));
188
+ // A round that has not spawned its bodies yet has nothing to choose between.
189
+ if (!list.length) return undefined;
190
+ const pick = (opts.admit as NonNullable<RoomOptions<B, F, S>['admit']>)(list, who);
191
+ return pick === null ? null : pick ? pick.slot : undefined;
192
+ },
193
+ } : {}),
194
+ });
195
+ let roster = new Roster(rosterOpts());
149
196
  let bodies = new Map<number, B>();
150
197
  /** The index each body was spawned with (slot → index), so a body that arrives mid-round never gets one in use. */
151
198
  let spawned = new Map<number, number>();
@@ -156,7 +203,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
156
203
  const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
157
204
  game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
158
205
  snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
159
- checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick, spawns: [...spawned].filter(([slot]) => bodies.has(slot)) }),
206
+ checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick, spawns: [...spawned].filter(([slot]) => bodies.has(slot)), occ: roster.occupants() }),
160
207
  ...(opts.netplay ?? {}),
161
208
  // createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
162
209
  caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
@@ -232,8 +279,16 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
232
279
  }
233
280
 
234
281
  function rank(): RoundResult[] {
282
+ // The order of the rows is fixed: score, then people before bots, then the lower slot. What place two equal
283
+ // scores get is the game's to say (`ties`): by default the order itself, as results always were.
235
284
  const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
236
- return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(b.agent ? { agent: true as const } : {}) }));
285
+ const places = placesOf(ranked.map((b) => b.score), opts.ties ?? 'order');
286
+ return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: places[i] as number, ...(b.agent ? { agent: true as const } : {}) }));
287
+ }
288
+
289
+ /** A body changed hands: the game's word on it (default: the newcomer starts from 0). */
290
+ function takeover(b: B, info: TakeoverInfo): void {
291
+ if (opts.onTakeover) opts.onTakeover(b, info); else b.score = 0;
237
292
  }
238
293
 
239
294
  function endRound(): void {
@@ -248,14 +303,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
248
303
  function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
249
304
  const ck = e.ckpt?.d ?? null;
250
305
  if (ck) {
251
- roster = Roster.from(ck.roster, { min, max, botName, policy });
252
306
  bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
307
+ roster = Roster.from(ck.roster, rosterOpts(), ck.occ ?? null);
253
308
  // A checkpoint from before spawns were kept: the round start's order (slot order) is the best guess.
254
309
  spawned = new Map(ck.spawns ?? [...bodies.keys()].sort((a, c) => a - c).map((slot, i) => [slot, i]));
255
310
  round = ck.round;
256
311
  tick = ck.tick;
257
312
  } else {
258
- roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
313
+ roster = Roster.from(e.roster ?? [], rosterOpts());
259
314
  bodies = new Map();
260
315
  spawned = new Map();
261
316
  syncBodies();
@@ -287,28 +342,55 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
287
342
  }
288
343
 
289
344
  function becomeHost(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
345
+ // A browser that was in the room a moment ago (a replica being promoted) sees its own body somewhere real; one
346
+ // that just arrived (its first role) does not: what it shows locally is a place it has never been.
347
+ const arriving = e.prev === null;
290
348
  // My own body was mine a moment ago: keep it exactly where I see it.
291
- const before = !hosting && mySeat() !== null && opts.local ? opts.local() : null;
349
+ const before = !hosting && mySeat() !== null && opts.local && !(e.promoted && arriving) ? opts.local() : null;
292
350
  hosting = true;
293
351
  if (e.promoted) restore(e);
294
352
  else {
295
- roster = new Roster({ min, max, botName, policy });
353
+ roster = new Roster(rosterOpts());
296
354
  bodies = new Map();
297
355
  spawned = new Map();
298
356
  const seat = mySeat();
299
- if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
357
+ if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name, null, net.offline ? null : myOcc());
300
358
  syncBodies();
301
359
  startRound((e.round?.n ?? 0) + 1);
302
360
  }
303
- 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 }));
304
- const { claimed } = roster.reconcile(peers);
361
+ 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, occ: p.occ ?? null }));
362
+ // THE RESTORED ROOM (section 25). Everyone left, the relay kept the checkpoint, and this browser arrives before it
363
+ // is forgotten: it is the host of a round whose roster still names the players who left. The reconcile below hands
364
+ // their bodies back to bots and claims one for everybody who is here now, through the same claim (and the game's
365
+ // `admit`) as a fresh join. A seat NUMBER can be the same as a departed player's: the relay's `occ` says whose stay
366
+ // it is; with a relay that does not, this browser at least knows its own (its welcome did not give it back a seat
367
+ // its token named), and gives up the body it would otherwise have inherited without a word.
368
+ const me = mySeat();
369
+ if (e.promoted && arriving && !net.offline && me !== null && !net.resumed && roster.bySeat(me) && typeof myOcc() !== 'number') roster.vacate(me);
370
+ const { claimed, back } = roster.reconcile(peers);
305
371
  syncBodies();
306
- for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) moved(b); }
372
+ for (const s of claimed) {
373
+ const b = bodies.get(s.slot);
374
+ if (!b) continue;
375
+ const own = !b.bot && b.seat === mySeat();
376
+ // A claim made as a round comes back is a takeover like any other: the same callback, and it says which.
377
+ if (e.promoted) takeover(b, { why: arriving ? 'restore' : 'migrate', own, back: back.includes(s) });
378
+ if (!own) moved(b);
379
+ }
307
380
  const mine = bodyOfSeat(mySeat());
308
381
  if (mine && before) opts.unpack(opts.pack(before), mine);
382
+ // A browser that arrived as the host of a restored round stands where its body stands (its own old one, or the
383
+ // 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);
309
385
  net.roster(roster.toJSON());
310
386
  }
311
387
 
388
+ /** Which stay in its seat this browser's is (`peer.occ`), when the relay says. */
389
+ function myOcc(): number | null {
390
+ for (const p of net.peers.values()) if (p.id === net.id) return typeof p.occ === 'number' ? p.occ : null;
391
+ return null;
392
+ }
393
+
312
394
  net.on('role', (e) => {
313
395
  if (e.role === 'host') becomeHost(e);
314
396
  else hosting = false;
@@ -316,12 +398,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
316
398
  net.on('join', (p) => {
317
399
  if (!hosting || p.seat === null) return;
318
400
  // An AI takes a seat kept for AI (its hands say who moves the body); a person never does (section 17).
319
- const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
401
+ const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
320
402
  if (!c) return; // full: they watch
321
403
  syncBodies();
322
404
  const b = bodies.get(c.slot.slot);
323
405
  if (b) {
324
- if (opts.onTakeover) opts.onTakeover(b); else b.score = 0;
406
+ // A player whose body never left them (this host only missed that their socket changed) takes nothing over:
407
+ // resetting their score here cost a returning player their round.
408
+ if (c.yielded || c.added) takeover(b, { why: 'join', own: false, back: c.back });
325
409
  moved(b);
326
410
  }
327
411
  net.roster(roster.toJSON());