@homie-rocks/studio 0.30.2 → 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/CHANGELOG.md +108 -1
- package/README.md +349 -23
- package/assets/animate.ts +16 -6
- package/assets/assets.ts +14 -3
- package/assets/render-page.ts +10 -2
- package/assets/safety.mjs +7 -1
- package/bin/homie-studio.mjs +204 -124
- package/lib/art-cli.mjs +61 -18
- package/lib/art-tools.mjs +53 -10
- package/lib/asset-check.mjs +249 -46
- package/lib/asset-import.mjs +11 -5
- package/lib/asset-manifest.mjs +128 -51
- package/lib/build.mjs +239 -72
- package/lib/characters.mjs +119 -20
- package/lib/check.mjs +110 -7
- package/lib/chrome.mjs +11 -0
- package/lib/clips.mjs +54 -0
- package/lib/cloudflare.mjs +203 -10
- package/lib/codex.mjs +6 -0
- package/lib/collision.mjs +245 -0
- package/lib/decisions.mjs +2 -2
- package/lib/deploy-state.mjs +166 -0
- package/lib/dev.mjs +430 -0
- package/lib/directory.mjs +172 -9
- package/lib/doctor.mjs +12 -11
- package/lib/handoff.mjs +2 -6
- package/lib/library.mjs +1 -1
- package/lib/mcp-tools.mjs +42 -37
- package/lib/mcp.mjs +1 -1
- package/lib/net.mjs +81 -2
- package/lib/node-version.mjs +40 -0
- package/lib/office.mjs +6 -6
- package/lib/optimise.mjs +39 -4
- package/lib/parts-build.mjs +169 -0
- package/lib/parts-cli.mjs +104 -0
- package/lib/parts-store.mjs +342 -0
- package/lib/parts-tools.mjs +92 -0
- package/lib/parts.mjs +720 -0
- package/lib/perf.mjs +131 -31
- package/lib/port-check.mjs +40 -6
- package/lib/preview.mjs +77 -0
- package/lib/routes.mjs +240 -0
- package/lib/scaffold.mjs +83 -22
- package/lib/shoot.mjs +307 -0
- package/lib/site.mjs +1 -2
- package/lib/stage.mjs +118 -0
- package/lib/studio.mjs +0 -66
- package/lib/style-board.mjs +49 -6
- package/lib/template-history.json +101 -0
- package/lib/trailer.mjs +47 -0
- package/lib/typecheck.mjs +107 -0
- package/mcp/ui/lineup.js +3 -3
- package/mcp/ui/rights.js +1 -3
- package/netplay/NETPLAY.md +552 -16
- package/netplay/netplay.ts +950 -43
- package/package.json +10 -1
- package/parts/PARTS.md +169 -0
- package/port/index.ts +8 -2
- package/port/personas.ts +275 -0
- package/port/powerups.ts +357 -0
- package/port/probe.ts +38 -2
- package/port/room.ts +329 -40
- package/site/SITE.md +102 -20
- package/starters/ember-vale/index.html +1 -1
- package/starters/ember-vale/src/main.ts +7 -1
- package/starters/gem-rush/index.html +1 -1
- package/starters/gem-rush/src/main.ts +41 -12
- package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
- package/starters/gem-rush-3d/assets/manifest.json +0 -24
- package/starters/gem-rush-3d/index.html +1 -1
- package/starters/gem-rush-3d/src/main.ts +50 -11
- package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
- package/starters/hero-rush-3d/assets/manifest.json +0 -19
- package/starters/hero-rush-3d/index.html +1 -1
- package/starters/hero-rush-3d/src/main.ts +50 -11
- package/worker/arrival.mjs +4 -1
- package/worker/discover.mjs +11 -25
- package/worker/index.mjs +142 -62
- package/worker/license.mjs +45 -41
- package/worker/office-page.mjs +0 -4
- package/worker/office-schema.mjs +6 -0
- package/worker/office.mjs +19 -23
- package/worker/pages.mjs +156 -29
- package/worker/parts.mjs +275 -0
- package/worker/room.mjs +157 -14
- package/worker/schema.mjs +6 -8
- package/worker/seats.mjs +71 -0
- package/worker/site.mjs +51 -53
- package/worker/version.mjs +1 -1
- package/lib/remix-assets.mjs +0 -84
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
|
|
@@ -26,15 +32,47 @@
|
|
|
26
32
|
* brains, the drawing, and the camera. See the port skill's recipe.
|
|
27
33
|
*/
|
|
28
34
|
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,
|
|
35
|
+
capMove, createNetplay, lerp, lerpAngle, placesOf, Roster,
|
|
36
|
+
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
37
|
} from '../netplay/netplay';
|
|
32
38
|
|
|
33
39
|
/** A body. `agent` (revision 6): this body is an AI's (a seat kept for AI, or an agent's), always marked AI. */
|
|
34
40
|
export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
|
|
35
41
|
|
|
36
42
|
export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
|
|
37
|
-
export interface RoomCkpt<B, S> {
|
|
43
|
+
export interface RoomCkpt<B, S> {
|
|
44
|
+
round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number; spawns?: [slot: number, index: number][];
|
|
45
|
+
/** 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. */
|
|
46
|
+
occ?: [seat: number, occ: number][];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Why a body changed hands (`onTakeover`'s second argument). `join`: a person arrived in a running round. `restore`:
|
|
50
|
+
* this browser became host of a room that had emptied and still had its checkpoint, and the body was claimed as the
|
|
51
|
+
* round came back. `migrate`: this browser was promoted in a live room and the body's player had arrived since the
|
|
52
|
+
* last checkpoint. `own`: it is this browser's own body. `back`: its player held it before (a reload, a reconnect),
|
|
53
|
+
* so a game that keeps a returning player's score reads this.
|
|
54
|
+
*/
|
|
55
|
+
export interface TakeoverInfo { why: 'join' | 'restore' | 'migrate'; own: boolean; back: boolean }
|
|
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
|
+
}
|
|
38
76
|
|
|
39
77
|
export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
40
78
|
/** The game id (the relay's logs). */
|
|
@@ -67,8 +105,30 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
67
105
|
/** Host: round n starts; every body was just respawned. Reset the world here. */
|
|
68
106
|
onRoundStart?: (n: number) => void;
|
|
69
107
|
onRoundEnd?: (results: RoundResult[]) => void;
|
|
70
|
-
/**
|
|
71
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Host: a person took over `body` (it was a bot). Default: keep where it stands, score 0. Called for every such
|
|
110
|
+
* claim, however it came about (NETPLAY.md section 25): a join in a running round, and the claims made when a
|
|
111
|
+
* room that had emptied is revived from its checkpoint or a host is replaced, the new host's own body included.
|
|
112
|
+
* `info` says which. Not called for a player whose body never left them.
|
|
113
|
+
*/
|
|
114
|
+
onTakeover?: (body: B, info: TakeoverInfo) => void;
|
|
115
|
+
/**
|
|
116
|
+
* ADMISSION (section 25): which bot's body a NEW arrival takes, for a game where not every body will do (an
|
|
117
|
+
* elimination round: a newcomer should not be handed a dead body while a living bot stands by). Called with the
|
|
118
|
+
* bots' bodies the arrival may take, in slot order; return the one to give, `undefined` for the default (the
|
|
119
|
+
* lowest slot), or `null` when none will do (a new body is spawned while the room has space; with none, the
|
|
120
|
+
* default applies). Decide from the bodies' own state, so a new host makes the same choice. It is asked for fresh
|
|
121
|
+
* joins and for the claims of a restored room or a new host alike; never for a player coming back to their own
|
|
122
|
+
* body, never with a seat kept for AI when a person arrives. The host resets the body and checkpoints as always.
|
|
123
|
+
*/
|
|
124
|
+
admit?: (candidates: B[], who: { seat: number; name: string; agent: boolean }) => B | null | undefined;
|
|
125
|
+
/**
|
|
126
|
+
* How results place equal scores (section 26). 'order' (the default, what results always were): 1, 2, 3… in the
|
|
127
|
+
* order below, so two equal scores get different places. 'shared': equal scores share a place and the next is
|
|
128
|
+
* skipped (1, 1, 3). 'dense': they share one and none is skipped (1, 1, 2). The order of the rows is always:
|
|
129
|
+
* higher score first, then people before bots, then the lower slot.
|
|
130
|
+
*/
|
|
131
|
+
ties?: TiePolicy;
|
|
72
132
|
/** Host: the fast world beyond bodies (projectiles, pickups), sent with every snapshot. Keep it under ~1.5 KB. */
|
|
73
133
|
fastWorld?: () => F;
|
|
74
134
|
/** Host: everything else the rules need to continue (for checkpoints). */
|
|
@@ -79,14 +139,30 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
79
139
|
adopt?: (body: B) => void;
|
|
80
140
|
/** A replica's own body as it sees it right now (owner movement): a promotion keeps it exactly there. */
|
|
81
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;
|
|
82
154
|
/** Extra netplay options (snapshotHz, inputHz, config for tests). */
|
|
83
155
|
netplay?: Partial<NetplayOptions<RoomCkpt<B, S>>>;
|
|
84
156
|
}
|
|
85
157
|
|
|
86
158
|
export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
87
159
|
readonly net: Netplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>;
|
|
88
|
-
/** 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. */
|
|
89
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;
|
|
90
166
|
/** Host: every body by slot. */
|
|
91
167
|
readonly bodies: Map<number, B>;
|
|
92
168
|
/** The live round (host: authoritative; others: the relay's copy). */
|
|
@@ -125,7 +201,7 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
125
201
|
fast(): F | undefined;
|
|
126
202
|
/** Seconds left in this phase, and the phase. */
|
|
127
203
|
clock(): { n: number; phase: 'live' | 'over' | 'none'; secondsLeft: number };
|
|
128
|
-
/** Rank bodies by score (
|
|
204
|
+
/** Rank bodies by score (equal scores: people before bots, then the lower slot; their places as `ties` says). */
|
|
129
205
|
results(): RoundResult[];
|
|
130
206
|
/** One-shot events to everyone (sounds, hits, effects). */
|
|
131
207
|
send(kind: string, data?: unknown): void;
|
|
@@ -145,24 +221,53 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
145
221
|
|
|
146
222
|
// The roster keeps the server's AI seats (section 17); the policy is the room's, read when it fills.
|
|
147
223
|
const policy = (): Policy => net.policy;
|
|
148
|
-
|
|
224
|
+
// The game's admission choice (section 25), in bodies: the roster asks it for every new arrival's claim.
|
|
225
|
+
const rosterOpts = (): RosterOptions => ({
|
|
226
|
+
min, max, botName, policy,
|
|
227
|
+
...(opts.admit ? {
|
|
228
|
+
admit: (cands, who) => {
|
|
229
|
+
const list = cands.map((c) => bodies.get(c.slot)).filter((b): b is B => Boolean(b));
|
|
230
|
+
// A round that has not spawned its bodies yet has nothing to choose between.
|
|
231
|
+
if (!list.length) return undefined;
|
|
232
|
+
const pick = (opts.admit as NonNullable<RoomOptions<B, F, S>['admit']>)(list, who);
|
|
233
|
+
return pick === null ? null : pick ? pick.slot : undefined;
|
|
234
|
+
},
|
|
235
|
+
} : {}),
|
|
236
|
+
});
|
|
237
|
+
let roster = new Roster(rosterOpts());
|
|
149
238
|
let bodies = new Map<number, B>();
|
|
150
239
|
/** The index each body was spawned with (slot → index), so a body that arrives mid-round never gets one in use. */
|
|
151
240
|
let spawned = new Map<number, number>();
|
|
152
241
|
let round: RoundInfo | null = null;
|
|
153
242
|
let hosting = false;
|
|
154
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;
|
|
155
259
|
|
|
156
260
|
const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
|
|
157
261
|
game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
|
|
158
262
|
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)) }),
|
|
263
|
+
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
264
|
...(opts.netplay ?? {}),
|
|
161
265
|
// createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
|
|
162
266
|
caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
|
|
163
267
|
});
|
|
164
268
|
|
|
165
|
-
|
|
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);
|
|
166
271
|
const bodyOfSeat = (seat: number | null): B | null => {
|
|
167
272
|
if (seat === null) return null;
|
|
168
273
|
for (const b of bodies.values()) if (!b.bot && b.seat === seat) return b;
|
|
@@ -199,7 +304,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
199
304
|
function moved(b: B): void {
|
|
200
305
|
if (b.bot || b.seat === null) return;
|
|
201
306
|
if (b.seat === mySeat()) { opts.adopt?.(b); return; }
|
|
202
|
-
net.reset(b.seat);
|
|
307
|
+
if (!solo) net.reset(b.seat);
|
|
203
308
|
}
|
|
204
309
|
|
|
205
310
|
const packRow = (b: B): number[] => [b.slot, b.seat ?? -1, b.score, ...opts.pack(b)];
|
|
@@ -210,9 +315,34 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
210
315
|
return snap;
|
|
211
316
|
}
|
|
212
317
|
|
|
213
|
-
|
|
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 {
|
|
214
341
|
const now = net.now();
|
|
215
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);
|
|
216
346
|
syncBodies();
|
|
217
347
|
let i = 0;
|
|
218
348
|
spawned = new Map();
|
|
@@ -226,14 +356,24 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
226
356
|
round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
|
|
227
357
|
opts.onRoundStart?.(n);
|
|
228
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;
|
|
229
361
|
net.round(round);
|
|
230
362
|
net.roster(roster.toJSON());
|
|
231
363
|
net.snapshot(buildSnap(), tick, true);
|
|
232
364
|
}
|
|
233
365
|
|
|
234
366
|
function rank(): RoundResult[] {
|
|
367
|
+
// The order of the rows is fixed: score, then people before bots, then the lower slot. What place two equal
|
|
368
|
+
// scores get is the game's to say (`ties`): by default the order itself, as results always were.
|
|
235
369
|
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
236
|
-
|
|
370
|
+
const places = placesOf(ranked.map((b) => b.score), opts.ties ?? 'order');
|
|
371
|
+
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 } : {}) }));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A body changed hands: the game's word on it (default: the newcomer starts from 0). */
|
|
375
|
+
function takeover(b: B, info: TakeoverInfo): void {
|
|
376
|
+
if (opts.onTakeover) opts.onTakeover(b, info); else b.score = 0;
|
|
237
377
|
}
|
|
238
378
|
|
|
239
379
|
function endRound(): void {
|
|
@@ -242,20 +382,20 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
242
382
|
const results = rank();
|
|
243
383
|
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + breakMs, results };
|
|
244
384
|
opts.onRoundEnd?.(results);
|
|
245
|
-
net.round(round);
|
|
385
|
+
if (!solo) net.round(round);
|
|
246
386
|
}
|
|
247
387
|
|
|
248
388
|
function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
249
389
|
const ck = e.ckpt?.d ?? null;
|
|
250
390
|
if (ck) {
|
|
251
|
-
roster = Roster.from(ck.roster, { min, max, botName, policy });
|
|
252
391
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
392
|
+
roster = Roster.from(ck.roster, rosterOpts(), ck.occ ?? null);
|
|
253
393
|
// A checkpoint from before spawns were kept: the round start's order (slot order) is the best guess.
|
|
254
394
|
spawned = new Map(ck.spawns ?? [...bodies.keys()].sort((a, c) => a - c).map((slot, i) => [slot, i]));
|
|
255
395
|
round = ck.round;
|
|
256
396
|
tick = ck.tick;
|
|
257
397
|
} else {
|
|
258
|
-
roster = Roster.from(e.roster ?? [],
|
|
398
|
+
roster = Roster.from(e.roster ?? [], rosterOpts());
|
|
259
399
|
bodies = new Map();
|
|
260
400
|
spawned = new Map();
|
|
261
401
|
syncBodies();
|
|
@@ -287,69 +427,213 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
287
427
|
}
|
|
288
428
|
|
|
289
429
|
function becomeHost(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
430
|
+
// A browser that was in the room a moment ago (a replica being promoted) sees its own body somewhere real; one
|
|
431
|
+
// that just arrived (its first role) does not: what it shows locally is a place it has never been.
|
|
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
|
+
}
|
|
290
449
|
// 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;
|
|
450
|
+
const before = !hosting && !wasSolo && mySeat() !== null && opts.local && !(e.promoted && arriving) ? opts.local() : null;
|
|
292
451
|
hosting = true;
|
|
293
452
|
if (e.promoted) restore(e);
|
|
294
453
|
else {
|
|
295
|
-
roster = new Roster(
|
|
454
|
+
roster = new Roster(rosterOpts());
|
|
296
455
|
bodies = new Map();
|
|
297
456
|
spawned = new Map();
|
|
298
457
|
const seat = mySeat();
|
|
299
|
-
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
458
|
+
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name, null, net.offline ? null : myOcc());
|
|
300
459
|
syncBodies();
|
|
301
460
|
startRound((e.round?.n ?? 0) + 1);
|
|
302
461
|
}
|
|
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
|
-
|
|
462
|
+
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 }));
|
|
463
|
+
// THE RESTORED ROOM (section 25). Everyone left, the relay kept the checkpoint, and this browser arrives before it
|
|
464
|
+
// is forgotten: it is the host of a round whose roster still names the players who left. The reconcile below hands
|
|
465
|
+
// their bodies back to bots and claims one for everybody who is here now, through the same claim (and the game's
|
|
466
|
+
// `admit`) as a fresh join. A seat NUMBER can be the same as a departed player's: the relay's `occ` says whose stay
|
|
467
|
+
// it is; with a relay that does not, this browser at least knows its own (its welcome did not give it back a seat
|
|
468
|
+
// its token named), and gives up the body it would otherwise have inherited without a word.
|
|
469
|
+
const me = mySeat();
|
|
470
|
+
if (e.promoted && arriving && !net.offline && me !== null && !net.resumed && roster.bySeat(me) && typeof myOcc() !== 'number') roster.vacate(me);
|
|
471
|
+
const { claimed, back } = roster.reconcile(peers);
|
|
305
472
|
syncBodies();
|
|
306
|
-
for (const s of claimed) {
|
|
473
|
+
for (const s of claimed) {
|
|
474
|
+
const b = bodies.get(s.slot);
|
|
475
|
+
if (!b) continue;
|
|
476
|
+
const own = !b.bot && b.seat === mySeat();
|
|
477
|
+
// A claim made as a round comes back is a takeover like any other: the same callback, and it says which.
|
|
478
|
+
if (e.promoted) takeover(b, { why: arriving ? 'restore' : 'migrate', own, back: back.includes(s) });
|
|
479
|
+
if (!own) moved(b);
|
|
480
|
+
}
|
|
307
481
|
const mine = bodyOfSeat(mySeat());
|
|
308
482
|
if (mine && before) opts.unpack(opts.pack(before), mine);
|
|
483
|
+
// A browser that arrived as the host of a restored round stands where its body stands (its own old one, or the
|
|
484
|
+
// one it just took over): without this the game's local pose and the host's body disagreed until the next round.
|
|
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);
|
|
309
487
|
net.roster(roster.toJSON());
|
|
310
488
|
}
|
|
311
489
|
|
|
490
|
+
/** Which stay in its seat this browser's is (`peer.occ`), when the relay says. */
|
|
491
|
+
function myOcc(): number | null {
|
|
492
|
+
for (const p of net.peers.values()) if (p.id === net.id) return typeof p.occ === 'number' ? p.occ : null;
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
312
496
|
net.on('role', (e) => {
|
|
313
497
|
if (e.role === 'host') becomeHost(e);
|
|
314
|
-
|
|
498
|
+
// A private round goes on while the room's own roles change around it.
|
|
499
|
+
else if (!solo) hosting = false;
|
|
500
|
+
check();
|
|
315
501
|
});
|
|
316
502
|
net.on('join', (p) => {
|
|
317
|
-
if (!hosting || p.seat === null) return;
|
|
503
|
+
if (!hosting || solo || p.seat === null) return;
|
|
318
504
|
// 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);
|
|
505
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
320
506
|
if (!c) return; // full: they watch
|
|
321
507
|
syncBodies();
|
|
322
508
|
const b = bodies.get(c.slot.slot);
|
|
323
509
|
if (b) {
|
|
324
|
-
|
|
510
|
+
// A player whose body never left them (this host only missed that their socket changed) takes nothing over:
|
|
511
|
+
// resetting their score here cost a returning player their round.
|
|
512
|
+
if (c.yielded || c.added) takeover(b, { why: 'join', own: false, back: c.back });
|
|
325
513
|
moved(b);
|
|
326
514
|
}
|
|
327
515
|
net.roster(roster.toJSON());
|
|
328
516
|
net.snapshot(buildSnap(), tick, true);
|
|
329
517
|
});
|
|
330
518
|
net.on('leave', (p) => {
|
|
331
|
-
if (!hosting || p.seat === null) return;
|
|
519
|
+
if (!hosting || solo || p.seat === null) return;
|
|
332
520
|
roster.release(p.seat); // the body stays, a bot drives it now
|
|
333
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();
|
|
334
524
|
net.roster(roster.toJSON());
|
|
525
|
+
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
335
526
|
});
|
|
336
527
|
// The server's policy changed (a new server stamp, the owner): its AI seats come or go at once, filler at the round.
|
|
337
528
|
net.on('policy', () => {
|
|
338
|
-
if (!hosting) return;
|
|
529
|
+
if (!hosting || solo) return;
|
|
339
530
|
roster.fill();
|
|
340
531
|
syncBodies();
|
|
532
|
+
seatEveryone();
|
|
341
533
|
net.roster(roster.toJSON());
|
|
342
534
|
});
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
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;
|
|
348
544
|
const b = { slot: row[0], seat: row[1], score: row[2], name: net.name, bot: false } as unknown as B;
|
|
349
545
|
opts.unpack(row.slice(3), b);
|
|
350
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);
|
|
351
556
|
});
|
|
352
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
|
+
|
|
353
637
|
function interpolated(): B[] {
|
|
354
638
|
const smp = net.sample();
|
|
355
639
|
if (!smp) return [];
|
|
@@ -374,14 +658,18 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
374
658
|
const room: Room<B, F, S> = {
|
|
375
659
|
net,
|
|
376
660
|
get hosting() { return hosting; },
|
|
661
|
+
get solo() { return solo; },
|
|
662
|
+
get standing() { return standing; },
|
|
377
663
|
get bodies() { return bodies; },
|
|
378
664
|
get round() { return hosting ? round : (net.roundInfo ?? round); },
|
|
379
665
|
mySeat,
|
|
380
|
-
viewSeat: () => (net.offline ?
|
|
666
|
+
viewSeat: () => (net.offline || solo ? mySeat() : net.viewSeat),
|
|
381
667
|
viewBody() {
|
|
382
|
-
const s = net.offline ?
|
|
383
|
-
|
|
384
|
-
for (const b of
|
|
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;
|
|
385
673
|
return null;
|
|
386
674
|
},
|
|
387
675
|
skillOf: (body) => net.skillOf(body.slot),
|
|
@@ -391,8 +679,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
391
679
|
tick += 1;
|
|
392
680
|
const now = net.now();
|
|
393
681
|
if (round && round.phase === 'live' && now >= round.endsAt) endRound();
|
|
394
|
-
else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
395
|
-
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);
|
|
396
684
|
},
|
|
397
685
|
avatar: (b) => (b.bot || b.seat === null ? null : (net.avatar(b.seat) as unknown[] | null)),
|
|
398
686
|
bound(b, claim, maxSpeed, dt) {
|
|
@@ -414,7 +702,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
414
702
|
return { n: r.n, phase: r.phase, secondsLeft: Math.max(0, Math.ceil((r.endsAt - net.now()) / 1000)) };
|
|
415
703
|
},
|
|
416
704
|
results: () => (hosting ? rank() : (room.round?.results ?? [])),
|
|
417
|
-
|
|
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); },
|
|
418
707
|
on: net.on.bind(net) as Room<B, F, S>['on'],
|
|
419
708
|
};
|
|
420
709
|
return room;
|