@homie-rocks/studio 0.45.3 → 0.46.2
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 +102 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +65 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build-identity.mjs +43 -0
- package/lib/build.mjs +1 -1
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +14 -8
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +18 -5
- package/package.json +4 -2
- package/rules/core.ts +12 -6
- package/rules/host.ts +132 -17
- package/rules/interest.mjs +320 -51
- package/rules/rules.ts +32 -9
- package/rules/timing.mjs +20 -0
- package/worker/build-id.mjs +2 -0
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +241 -25
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +85 -16
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
- package/worker/world-wire.mjs +110 -0
package/netplay/netplay.ts
CHANGED
|
@@ -529,6 +529,7 @@ export interface NetStats {
|
|
|
529
529
|
offline: boolean;
|
|
530
530
|
rtt: number | null;
|
|
531
531
|
offset: number;
|
|
532
|
+
telemetrySource: 'server' | 'browser' | 'offline' | 'disconnected';
|
|
532
533
|
snapHzIn: number;
|
|
533
534
|
snapHzOut: number;
|
|
534
535
|
inputHzIn: number;
|
|
@@ -550,6 +551,8 @@ export interface NetStats {
|
|
|
550
551
|
stateKeys: number;
|
|
551
552
|
peers: number;
|
|
552
553
|
reconnects: number;
|
|
554
|
+
/** Most recent loss, retained after recovery so a brief reconnect is diagnosable. */
|
|
555
|
+
lastDisconnect?: { at: number; why: string; code: number | null; reason: string | null; silentMs: number };
|
|
553
556
|
/** Revision 9: times a link that was up went down (a reconnect that worked on the first knock counts once). */
|
|
554
557
|
drops?: number;
|
|
555
558
|
/** Revision 9: where this browser stands with its room. */
|
|
@@ -1695,6 +1698,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1695
1698
|
let ws: WebSocketLike | null = null;
|
|
1696
1699
|
let attempt = 0;
|
|
1697
1700
|
let reconnects = 0;
|
|
1701
|
+
let lastDisconnect: NetStats['lastDisconnect'];
|
|
1698
1702
|
let drops = 0;
|
|
1699
1703
|
let heartbeats = 0;
|
|
1700
1704
|
/** My welcome gave me back the seat my token named (the same player), and a newer build is live (section 23). */
|
|
@@ -1777,6 +1781,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1777
1781
|
let playableSent = false;
|
|
1778
1782
|
let autoArmed = false;
|
|
1779
1783
|
let snapSeen = false;
|
|
1784
|
+
/** The newest tick this socket has been sent (rules games): said on inputs and pings as `g`, so a Gate that
|
|
1785
|
+
* finds this player far behind the room sends it fewer, fresher frames instead of every one late. */
|
|
1786
|
+
let gotTick: number | null = null;
|
|
1780
1787
|
let loadingAt = 0;
|
|
1781
1788
|
const createdAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
1782
1789
|
const arrived: ArrivalInfo = { mode: arrival, by: null, playableMs: null, explicitMs: null, lateMs: null };
|
|
@@ -2619,6 +2626,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2619
2626
|
if (wasOffline && rulesGame) rulesNotice('Connection restored. Joining the online round.');
|
|
2620
2627
|
offline = false;
|
|
2621
2628
|
connected = true;
|
|
2629
|
+
gotTick = null;
|
|
2622
2630
|
downSince = 0;
|
|
2623
2631
|
attempt = 0;
|
|
2624
2632
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
@@ -2764,6 +2772,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2764
2772
|
if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
|
|
2765
2773
|
if (m['hb'] === 1) s.hb = 1;
|
|
2766
2774
|
if (typeof m['e'] === 'number') s.e = m['e'];
|
|
2775
|
+
if (!local && rulesGame) gotTick = s.k;
|
|
2767
2776
|
if (acceptSnap(s, bytes, true, local)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
|
|
2768
2777
|
}
|
|
2769
2778
|
|
|
@@ -2881,7 +2890,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2881
2890
|
pingsOut.set(c, c);
|
|
2882
2891
|
if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
|
|
2883
2892
|
// A hidden host's yield is behind its checkpoint in the sender: the relay hears of the hidden tab after both.
|
|
2884
|
-
raw({ t: 'ping', c, hid: hidden() && !rulesSender?.handing, ...(rulesGame ? { readySpeed } : {}) });
|
|
2893
|
+
raw({ t: 'ping', c, hid: hidden() && !rulesSender?.handing, ...(rulesGame ? { readySpeed } : {}), ...(gotTick !== null ? { g: gotTick } : {}) });
|
|
2885
2894
|
}
|
|
2886
2895
|
|
|
2887
2896
|
function sendCheckpoint(): void {
|
|
@@ -2939,12 +2948,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2939
2948
|
function statsNow(): NetStats {
|
|
2940
2949
|
const t = wall();
|
|
2941
2950
|
return {
|
|
2951
|
+
telemetrySource: offline ? 'offline' : !connected ? 'disconnected' : opts.rulesHost?.mode === 'server' ? 'server' : 'browser',
|
|
2942
2952
|
role, seat, host, connected, offline, rtt, offset: Math.round(offset),
|
|
2943
2953
|
snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t), idleInputsSkipped: idleSkipped,
|
|
2944
2954
|
lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
|
|
2945
2955
|
interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
|
|
2946
2956
|
owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
|
|
2947
|
-
peers: peers.size, reconnects, drops, link, heartbeats, promotions, round: roundInfo ? roundInfo.n : null,
|
|
2957
|
+
peers: peers.size, reconnects, lastDisconnect, drops, link, heartbeats, promotions, round: roundInfo ? roundInfo.n : null,
|
|
2948
2958
|
decides: { asked: decideStats.asked, ai: decideStats.ai, local: decideStats.local, floor: decideStats.floor, msP50: [...decideStats.ms].sort((x, y) => x - y)[Math.floor(decideStats.ms.length / 2)] ?? null },
|
|
2949
2959
|
};
|
|
2950
2960
|
}
|
|
@@ -2980,8 +2990,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2980
2990
|
else paintLink();
|
|
2981
2991
|
}
|
|
2982
2992
|
|
|
2983
|
-
function lost(sock: WebSocketLike, why: string): void {
|
|
2993
|
+
function lost(sock: WebSocketLike, why: string, event?: unknown): void {
|
|
2984
2994
|
if (ws !== sock) return;
|
|
2995
|
+
const detail = event && typeof event === 'object' ? event as { code?: unknown; reason?: unknown } : null;
|
|
2996
|
+
lastDisconnect = { at: Date.now(), why, code: typeof detail?.code === 'number' ? detail.code : null, reason: typeof detail?.reason === 'string' ? detail.reason.slice(0, 160) : null, silentMs: Math.max(0, wall() - lastMsgAt) };
|
|
2985
2997
|
ws = null;
|
|
2986
2998
|
sock.onclose = null; sock.onmessage = null; sock.onerror = null; sock.onopen = null;
|
|
2987
2999
|
if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
|
|
@@ -3011,13 +3023,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3011
3023
|
lastMsgAt = wall();
|
|
3012
3024
|
raw({
|
|
3013
3025
|
t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
|
|
3026
|
+
...(rulesGame ? { coast: 1 } : {}),
|
|
3014
3027
|
...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
|
|
3015
3028
|
// Revision 9 (an older relay ignores both): the game's revision and what this build can do.
|
|
3016
3029
|
...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
|
|
3017
3030
|
});
|
|
3018
3031
|
};
|
|
3019
3032
|
sock.onmessage = (ev) => onMessage(ev.data);
|
|
3020
|
-
sock.onclose = () => lost(sock, 'closed');
|
|
3033
|
+
sock.onclose = (event) => lost(sock, 'closed', event);
|
|
3021
3034
|
// Browsers follow an error with close; Node's WebSocket, on a refused connection, never does.
|
|
3022
3035
|
sock.onerror = () => lost(sock, 'error');
|
|
3023
3036
|
}
|
|
@@ -3415,7 +3428,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3415
3428
|
if (seat === null || !entries.length) return false;
|
|
3416
3429
|
if (rulesHost && offline) { rulesHost.frame({ t: 'in', from: seat, e, k, s: entries, r }); return true; }
|
|
3417
3430
|
if ((!rulesGame && role === 'host') || offline || !connected) return false;
|
|
3418
|
-
if (!raw({ t: 'in', e, k, s: entries, r })) return false;
|
|
3431
|
+
if (!raw({ t: 'in', e, k, s: entries, r, ...(gotTick !== null ? { g: gotTick } : {}) })) return false;
|
|
3419
3432
|
inputOut.hit(wall());
|
|
3420
3433
|
return true;
|
|
3421
3434
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.2",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"files": [
|
|
98
|
+
"docs/",
|
|
98
99
|
"bin/",
|
|
99
100
|
"lib/",
|
|
100
101
|
"netplay/",
|
|
@@ -158,10 +159,11 @@
|
|
|
158
159
|
"three": "0.185.1",
|
|
159
160
|
"typescript": "5.9.3",
|
|
160
161
|
"viem": "2.57.4",
|
|
162
|
+
"ws": "8.22.0",
|
|
161
163
|
"zod": "4.3.6"
|
|
162
164
|
},
|
|
163
165
|
"scripts": {
|
|
164
166
|
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
|
|
165
|
-
"prepack": "node ../../scripts/authoring-guides.mjs --check"
|
|
167
|
+
"prepack": "node lib/build-identity.mjs --check && node ../../scripts/authoring-guides.mjs --check"
|
|
166
168
|
}
|
|
167
169
|
}
|
package/rules/core.ts
CHANGED
|
@@ -79,7 +79,7 @@ export const IGNORE_MAX = 16;
|
|
|
79
79
|
export const ANSWER_MAX = 256;
|
|
80
80
|
|
|
81
81
|
export type Driver = 'person' | 'bot' | 'ai';
|
|
82
|
-
export interface SeatInfo { seat: number; driver: Driver; owner: string }
|
|
82
|
+
export interface SeatInfo { seat: number; driver: Driver; owner: string; character?: import('./rules.ts').Character | null }
|
|
83
83
|
/** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
|
|
84
84
|
export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
|
|
85
85
|
export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
|
|
@@ -163,6 +163,7 @@ export interface Core {
|
|
|
163
163
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
164
164
|
bodies(): Body[];
|
|
165
165
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
166
|
+
character(seat: number): import('./rules.ts').Character | null;
|
|
166
167
|
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
167
168
|
/** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
|
|
168
169
|
drain(): CoreOut[];
|
|
@@ -183,7 +184,7 @@ export interface Core {
|
|
|
183
184
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
184
185
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
185
186
|
*/
|
|
186
|
-
export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
187
|
+
export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
187
188
|
const dims = c.dims;
|
|
188
189
|
const tickHz = c.settings.tickHz;
|
|
189
190
|
const dt = 1 / tickHz;
|
|
@@ -838,7 +839,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
838
839
|
return rows;
|
|
839
840
|
}
|
|
840
841
|
function joinBody(info: SeatInfo): Ent | null {
|
|
841
|
-
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
|
|
842
|
+
const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner, ...(info.character !== undefined ? { character: deepFreeze(info.character) } : {}) });
|
|
842
843
|
let made: Ent | null = null;
|
|
843
844
|
run('room', 'join', null, 'join', () => {
|
|
844
845
|
// What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
|
|
@@ -864,6 +865,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
864
865
|
} else {
|
|
865
866
|
if (e) {
|
|
866
867
|
if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
|
|
868
|
+
opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
|
|
867
869
|
e.dead = true; ents.delete(e.id);
|
|
868
870
|
}
|
|
869
871
|
seats.delete(s.seat);
|
|
@@ -975,7 +977,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
975
977
|
if (tick < restartAt) return;
|
|
976
978
|
startMatch();
|
|
977
979
|
// Seats stay through a finished match: every holder's body is made again, as if it had just joined.
|
|
978
|
-
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
980
|
+
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner, ...(opts.characterForSeat ? { character: opts.characterForSeat(s.seat) } : {}) } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
979
981
|
return;
|
|
980
982
|
}
|
|
981
983
|
if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
|
|
@@ -992,7 +994,10 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
992
994
|
function finishMatch(): void {
|
|
993
995
|
finishing = false; restartAsked = false;
|
|
994
996
|
out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
|
|
995
|
-
for (const e of ents.values())
|
|
997
|
+
for (const e of ents.values()) {
|
|
998
|
+
if (e.kind.player) opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
|
|
999
|
+
e.dead = true;
|
|
1000
|
+
}
|
|
996
1001
|
ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
|
|
997
1002
|
for (const s of seats.values()) s.id = null;
|
|
998
1003
|
shared = initFields(c.shared, dims); sharedChanged();
|
|
@@ -1456,7 +1461,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
1456
1461
|
if (!floorCall && value) guideViews.set(seat, value);
|
|
1457
1462
|
return value;
|
|
1458
1463
|
},
|
|
1459
|
-
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
|
|
1464
|
+
seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128), ...(info.character !== undefined ? { character: info.character } : {}) } }); },
|
|
1460
1465
|
seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
|
|
1461
1466
|
seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
|
|
1462
1467
|
setPolicy: (p) => { policy = normalizeCorePolicy(p); },
|
|
@@ -1477,6 +1482,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
|
|
|
1477
1482
|
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1478
1483
|
},
|
|
1479
1484
|
shared: () => packFields(c.shared, shared, dims),
|
|
1485
|
+
character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
|
|
1480
1486
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
|
1481
1487
|
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
|
|
1482
1488
|
drain: () => { const o = out; out = []; return o; },
|
package/rules/host.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stageTimings } from './timing.mjs';
|
|
1
2
|
/*
|
|
2
3
|
* host.ts — the host runtime: a rules module playing the host's part on the wire.
|
|
3
4
|
* =============================================================================
|
|
@@ -56,9 +57,13 @@ export interface HostClock {
|
|
|
56
57
|
setTimer(fn: () => void, ms: number): unknown;
|
|
57
58
|
clearTimer(handle: unknown): void;
|
|
58
59
|
}
|
|
60
|
+
export interface CharacterLease {
|
|
61
|
+
account: string; lease: number; seat: number; character: import('./rules.ts').Character | null; returning?: boolean;
|
|
62
|
+
}
|
|
59
63
|
export interface HostOptions {
|
|
60
64
|
game: string;
|
|
61
65
|
record?(owner:string,key:string,value:Record<string,unknown>):void;
|
|
66
|
+
characterFor?(peer: Record<string, unknown>): CharacterLease | null;
|
|
62
67
|
/** The build check compares a movement step before any tick handler can change it. */
|
|
63
68
|
moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
|
|
64
69
|
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
@@ -74,7 +79,7 @@ export interface HostOptions {
|
|
|
74
79
|
send(frame: Record<string, unknown>, text?: string): void;
|
|
75
80
|
clock: HostClock;
|
|
76
81
|
/** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
|
|
77
|
-
store?: { save(bytes: Uint8Array): void };
|
|
82
|
+
store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
|
|
78
83
|
/** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
|
|
79
84
|
restoreEpoch?: number;
|
|
80
85
|
startDelayMs?: number;
|
|
@@ -95,6 +100,10 @@ export interface HostOptions {
|
|
|
95
100
|
onEnd?(why: string, facts: Record<string, unknown>): void;
|
|
96
101
|
}
|
|
97
102
|
export interface Host {
|
|
103
|
+
readonly characters: ReadonlyMap<string, CharacterLease>;
|
|
104
|
+
characterReturned(account: string, lease: number): void;
|
|
105
|
+
releaseCharacter(account: string): void;
|
|
106
|
+
reserveCharacter(lease: CharacterLease): void;
|
|
98
107
|
readonly tick: number;
|
|
99
108
|
readonly epoch: number;
|
|
100
109
|
readonly running: boolean;
|
|
@@ -104,6 +113,8 @@ export interface Host {
|
|
|
104
113
|
readonly privateDelivery: boolean;
|
|
105
114
|
projectSnapshot(snap: any, seat: number | null): any;
|
|
106
115
|
readonly viewSettings: Compiled['settings']['view'];
|
|
116
|
+
/** The studio's own pace on player messages (`room.rates`), or null: Homie sets none. */
|
|
117
|
+
readonly rates: Record<string, number> | null;
|
|
107
118
|
frame(m: Record<string, unknown>): void;
|
|
108
119
|
/** Start the tick loop (the first person has joined). */
|
|
109
120
|
start(): void;
|
|
@@ -111,7 +122,7 @@ export interface Host {
|
|
|
111
122
|
resume(): void;
|
|
112
123
|
/** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
|
|
113
124
|
announce(): void;
|
|
114
|
-
/** Stop for good: the room has ended. */
|
|
125
|
+
/** Stop for good: the room has ended. A lasting world whose storage is healthy checkpoints first, and that write can throw. */
|
|
115
126
|
stop(): void;
|
|
116
127
|
/** Run the next tick now, whatever the clock says (tests, and the build check's runs). Like the timer's own tick, it never throws. */
|
|
117
128
|
tickNow(): void;
|
|
@@ -150,7 +161,7 @@ const SPEECH = /^(?:say|chat|emote)/i;
|
|
|
150
161
|
/**
|
|
151
162
|
* The overrun check's memory: the last few ticks that started in this isolate, whichever rooms' they were, each with
|
|
152
163
|
* when it started. Time inside a Worker stands still while code runs, so a tick cannot time itself; but the start of
|
|
153
|
-
* the next tick, in any room,
|
|
164
|
+
* the next tick, in any room, gives a scheduling interval (including other work). A late callback suspects the newest
|
|
154
165
|
* tick here that took more than a period: the one just before it, or, when that one was quick and was itself held up,
|
|
155
166
|
* the one before that.
|
|
156
167
|
*/
|
|
@@ -164,19 +175,33 @@ function blameLate(now: number, period: number): void {
|
|
|
164
175
|
|
|
165
176
|
export function createHost(o: HostOptions): Host {
|
|
166
177
|
const c = o.compiled;
|
|
178
|
+
const persistent = c.settings.lifetime === 'persistent';
|
|
179
|
+
// Keep even effects and agent requests behind the same commit as their tick.
|
|
180
|
+
// Match rooms retain their existing save frequency and output behaviour.
|
|
181
|
+
const publish = o.send;
|
|
182
|
+
let pendingOutput: [Record<string, unknown>, string | undefined][] | null = null;
|
|
183
|
+
if (persistent) o = { ...o, send: (frame, text) => {
|
|
184
|
+
if (pendingOutput) pendingOutput.push([frame, text]); else publish(frame, text);
|
|
185
|
+
} };
|
|
186
|
+
|
|
167
187
|
const period = 1000 / c.settings.tickHz;
|
|
168
188
|
const tickHz = c.settings.tickHz;
|
|
169
189
|
const lateTicks = Math.ceil(tickHz / 4);
|
|
170
190
|
// A log that throws takes nothing down with it.
|
|
171
191
|
const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
|
|
172
192
|
const random = o.random ?? Math.random;
|
|
173
|
-
const saved = o.restore ? fromBytes(o.restore) as { v: number; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
193
|
+
const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
174
194
|
if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
|
|
175
195
|
if (saved) {
|
|
176
196
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
|
|
177
197
|
const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
178
|
-
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs'].includes(k)));
|
|
198
|
+
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
|
|
179
199
|
check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
|
|
200
|
+
if (saved.characters !== undefined) {
|
|
201
|
+
check(c.settings.characters && Array.isArray(saved.characters));
|
|
202
|
+
check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
|
|
203
|
+
for (const held of saved.characters) check(typeof held.account === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(held.account) && uint(held.lease) && held.lease > 0 && Number.isInteger(held.seat) && held.seat >= -1 && held.seat < c.seats && (held.returning === undefined || typeof held.returning === 'boolean'));
|
|
204
|
+
}
|
|
180
205
|
if (saved.agents !== undefined) {
|
|
181
206
|
const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
|
|
182
207
|
check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
|
|
@@ -236,12 +261,27 @@ export function createHost(o: HostOptions): Host {
|
|
|
236
261
|
}
|
|
237
262
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
238
263
|
const measuredRtt = new Map<number, number>();
|
|
239
|
-
const
|
|
264
|
+
const characters = new Map<string, CharacterLease>((saved?.characters ?? []).map(c => [c.account, c.returning ? { ...c, seat: -1 } : c]));
|
|
265
|
+
const characterOptions = c.settings.characters ? {
|
|
266
|
+
characterForSeat: (seat: number) => [...characters.values()].find(c => c.seat === seat && !c.returning)?.character,
|
|
267
|
+
characterSaved: (seat: number, character: import('./rules.ts').Character) => {
|
|
268
|
+
const held = [...characters.values()].find(c => c.seat === seat);
|
|
269
|
+
if (held) {
|
|
270
|
+
held.character = character;
|
|
271
|
+
// Once removed, a delayed return must never follow a new occupant of this seat.
|
|
272
|
+
if (held.returning) held.seat = -1;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
} : {};
|
|
276
|
+
const core = createCore(c, { ...characterOptions, rewindTicks: seat => Math.ceil(((measuredRtt.get(seat) ?? 0) + 150 + period) / period), label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
240
277
|
let epoch = core.epoch;
|
|
241
278
|
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
242
279
|
let lastSaveAt = o.clock.now();
|
|
243
280
|
let saveTick = core.tick;
|
|
244
281
|
let forceSave = false;
|
|
282
|
+
let storeBroken = false;
|
|
283
|
+
/** Which state fields the game declared durable, by kind and for the shared state: what a lasting world journals at once. */
|
|
284
|
+
const durableFields = { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) };
|
|
245
285
|
let saveFailures = 0;
|
|
246
286
|
let retrySaveAt = 0;
|
|
247
287
|
let roundPhase = saved?.core.round[1] ?? 1;
|
|
@@ -358,6 +398,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
358
398
|
/** Where in a tick the runtime is, for the log line of a fault. */
|
|
359
399
|
let phase = '';
|
|
360
400
|
let faultLoggedAt = -Infinity;
|
|
401
|
+
const timing = stageTimings();
|
|
361
402
|
const stats = { ticks: 0, late: 0, slips: 0, since: 0, ins: 0, dropped: 0, lateEntries: 0, loggedAt: 0, faults: 0, lastFault: '' };
|
|
362
403
|
/** Something was thrown in the runtime itself: it is counted, and said in the log at most once a second. */
|
|
363
404
|
function fault(where: string, error: unknown): void {
|
|
@@ -459,7 +500,16 @@ export function createHost(o: HostOptions): Host {
|
|
|
459
500
|
// The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
|
|
460
501
|
const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
|
|
461
502
|
const previous = core.bodies().find((s) => s.seat === seat);
|
|
462
|
-
|
|
503
|
+
const grant = driver === 'person' ? o.characterFor?.(p as Record<string, unknown>) : null;
|
|
504
|
+
if (c.settings.characters && driver === 'person' && !grant && o.characterFor) throw new Error('Character lease required');
|
|
505
|
+
let character = grant?.character;
|
|
506
|
+
if (grant) {
|
|
507
|
+
const held = characters.get(grant.account);
|
|
508
|
+
if (held && (held.lease !== grant.lease || held.returning || held.seat >= 0 && held.seat !== seat)) throw new Error('Character return is still pending');
|
|
509
|
+
character = held ? core.character(held.seat) ?? held.character : grant.character;
|
|
510
|
+
characters.set(grant.account, { ...grant, character: character ?? null, seat });
|
|
511
|
+
}
|
|
512
|
+
core.seatJoin({ seat, driver, owner, ...(grant ? { character } : {}) });
|
|
463
513
|
core.seatAway(seat, false);
|
|
464
514
|
if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
|
|
465
515
|
rosterText = '';
|
|
@@ -471,7 +521,13 @@ export function createHost(o: HostOptions): Host {
|
|
|
471
521
|
for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
|
|
472
522
|
present.delete(m.seat as number);
|
|
473
523
|
queues.delete(m.seat as number);
|
|
474
|
-
|
|
524
|
+
const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
|
|
525
|
+
if (held) {
|
|
526
|
+
held.character = core.character(held.seat) ?? held.character;
|
|
527
|
+
held.returning = true;
|
|
528
|
+
core.seatLeave(held.seat);
|
|
529
|
+
tick(o.clock.now());
|
|
530
|
+
} else core.seatAway(m.seat as number, true);
|
|
475
531
|
if (running && people() === 0) pause();
|
|
476
532
|
return;
|
|
477
533
|
}
|
|
@@ -481,7 +537,10 @@ export function createHost(o: HostOptions): Host {
|
|
|
481
537
|
for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
|
|
482
538
|
present.delete(m.seat as number);
|
|
483
539
|
queues.delete(m.seat as number);
|
|
540
|
+
const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
|
|
541
|
+
if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
|
|
484
542
|
core.seatLeave(m.seat as number);
|
|
543
|
+
if (held) tick(o.clock.now());
|
|
485
544
|
rosterText = '';
|
|
486
545
|
return;
|
|
487
546
|
}
|
|
@@ -593,17 +652,26 @@ export function createHost(o: HostOptions): Host {
|
|
|
593
652
|
}
|
|
594
653
|
if (roundRebasePending) publishRebase(now);
|
|
595
654
|
phase = 'step';
|
|
655
|
+
const rulesAt = timing.now();
|
|
596
656
|
core.step(inputs, guideBeat);
|
|
657
|
+
timing.record('rules', rulesAt);
|
|
597
658
|
phase = 'frames';
|
|
659
|
+
const framesAt = timing.now();
|
|
598
660
|
const out = core.drain();
|
|
599
661
|
handle(out, now);
|
|
662
|
+
timing.record('frames', framesAt);
|
|
600
663
|
if (ended) return;
|
|
601
664
|
phase = 'snapshot';
|
|
665
|
+
const snapshotAt = timing.now();
|
|
602
666
|
const data = core.snapshot();
|
|
667
|
+
timing.record('snapshot', snapshotAt);
|
|
603
668
|
const nextPhase = (data[0] as number[])[1];
|
|
604
669
|
if (roundPhase === 1 && nextPhase === 0) forceSave = true;
|
|
605
670
|
roundPhase = nextPhase;
|
|
671
|
+
const checkpointAt = timing.now();
|
|
606
672
|
checkpoint(forceSave);
|
|
673
|
+
timing.record('checkpoint', checkpointAt);
|
|
674
|
+
const encodeAt = timing.now();
|
|
607
675
|
// The control table: one row a seat, [seat, r, ack, lead].
|
|
608
676
|
const rows: number[][] = [];
|
|
609
677
|
for (const b of core.bodies()) {
|
|
@@ -616,13 +684,17 @@ export function createHost(o: HostOptions): Host {
|
|
|
616
684
|
// apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
|
|
617
685
|
lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
|
|
618
686
|
const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
|
|
619
|
-
|
|
687
|
+
const encoded = JSON.stringify(snap);
|
|
688
|
+
timing.record('encode', encodeAt);
|
|
689
|
+
const sendAt = timing.now();
|
|
690
|
+
o.send(snap, encoded);
|
|
691
|
+
timing.record('send', sendAt);
|
|
620
692
|
stats.ticks += 1;
|
|
621
693
|
stats.since += 1;
|
|
622
694
|
if (now - stats.loggedAt >= 10_000) {
|
|
623
695
|
// Tick figures, one line every ten seconds, never a line a tick.
|
|
624
696
|
const s = core.stats;
|
|
625
|
-
log({ ev: 'ticks', game: o.game, ...(o.build ? { build: o.build } : {}), tick: core.tick, ticks: stats.since, late: stats.late, slips: stats.slips, ins: stats.ins, lateEntries: stats.lateEntries, dropped: stats.dropped, errors: s.errors, budgetStops: s.budgetStops, cut: s.ticksCut, maxUnits: s.maxUnits, worst: s.worst, maxTickUnits: s.maxTickUnits, ...(s.lost ? { lost: s.lost } : {}), ...(stats.faults ? { faults: stats.faults, lastFault: stats.lastFault } : {}), ...(s.lastError ? { lastError: s.lastError } : {}) });
|
|
697
|
+
log({ ev: 'ticks', timing: timing.facts(), game: o.game, ...(o.build ? { build: o.build } : {}), tick: core.tick, ticks: stats.since, late: stats.late, slips: stats.slips, ins: stats.ins, lateEntries: stats.lateEntries, dropped: stats.dropped, errors: s.errors, budgetStops: s.budgetStops, cut: s.ticksCut, maxUnits: s.maxUnits, worst: s.worst, maxTickUnits: s.maxTickUnits, ...(s.lost ? { lost: s.lost } : {}), ...(stats.faults ? { faults: stats.faults, lastFault: stats.lastFault } : {}), ...(s.lastError ? { lastError: s.lastError } : {}) });
|
|
626
698
|
stats.loggedAt = now; stats.since = 0; stats.late = 0; stats.ins = 0; stats.lateEntries = 0;
|
|
627
699
|
}
|
|
628
700
|
}
|
|
@@ -632,9 +704,21 @@ export function createHost(o: HostOptions): Host {
|
|
|
632
704
|
* of the clock the room ends, and the log names the handler (or the fault).
|
|
633
705
|
*/
|
|
634
706
|
function tick(now: number): void {
|
|
707
|
+
const tickAt = timing.now();
|
|
635
708
|
const cut = core.stats.ticksCut;
|
|
636
709
|
let faulted = false;
|
|
637
|
-
|
|
710
|
+
if (persistent) pendingOutput = [];
|
|
711
|
+
try {
|
|
712
|
+
tickOnce();
|
|
713
|
+
const committed = pendingOutput; pendingOutput = null;
|
|
714
|
+
if (committed && !ended) for (const [frame, text] of committed) publish(frame, text);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
pendingOutput = null;
|
|
717
|
+
faulted = true; fault(`tick ${core.tick} (${phase})`, error);
|
|
718
|
+
// Never continue from uncommitted memory or retire the last good save.
|
|
719
|
+
if (persistent) { timing.record('tick', tickAt); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
|
|
720
|
+
}
|
|
721
|
+
timing.record('tick', tickAt);
|
|
638
722
|
if (ended) return;
|
|
639
723
|
if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
|
|
640
724
|
if (failingSince === null) failingSince = now;
|
|
@@ -688,8 +772,13 @@ export function createHost(o: HostOptions): Host {
|
|
|
688
772
|
if (overSince !== null) {
|
|
689
773
|
overTicks += 1;
|
|
690
774
|
if (2 * overBlamed < overTicks) overSince = null;
|
|
691
|
-
//
|
|
692
|
-
|
|
775
|
+
// Late callbacks can catch up at the requested rate. End only a
|
|
776
|
+
// blamed room that also loses sustained throughput, not a healthy
|
|
777
|
+
// 20 Hz room whose callbacks arrive in pairs during admission/I/O.
|
|
778
|
+
else if (now - overSince >= OVERRUN_MS) {
|
|
779
|
+
if (overTicks * period < (now - overSince) * 0.9) { end('overrun', { worst: core.stats.worst, maxUnits: core.stats.maxUnits, seconds: Math.round((now - overSince) / 100) / 10 }); return; }
|
|
780
|
+
overSince = null;
|
|
781
|
+
}
|
|
693
782
|
}
|
|
694
783
|
if (o.enforceOverrun) recent.push({ who: self, at: now });
|
|
695
784
|
if (recent.length > 8) recent.shift();
|
|
@@ -730,7 +819,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
730
819
|
// A pending timer would keep the object in memory and billed: the pause clears it.
|
|
731
820
|
disarm();
|
|
732
821
|
log({ ev: 'host-pause', game: o.game, tick: core.tick });
|
|
733
|
-
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); }
|
|
822
|
+
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
|
|
734
823
|
}
|
|
735
824
|
function resume(): void {
|
|
736
825
|
if (running || ended || !paused) return;
|
|
@@ -772,9 +861,21 @@ export function createHost(o: HostOptions): Host {
|
|
|
772
861
|
}
|
|
773
862
|
|
|
774
863
|
function save(): Uint8Array {
|
|
775
|
-
|
|
864
|
+
const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
|
|
865
|
+
return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
776
866
|
}
|
|
777
867
|
function checkpoint(force = false): void {
|
|
868
|
+
if (persistent) {
|
|
869
|
+
// An empty match break or an unjoined reservation has no body-removal callback.
|
|
870
|
+
for (const held of characters.values()) if (held.returning) held.seat = -1;
|
|
871
|
+
if (!o.store) return; // Local checks/offline simulation have no durable account.
|
|
872
|
+
// A write that threw leaves memory ahead of storage: nothing later may checkpoint it over the committed world.
|
|
873
|
+
storeBroken = true;
|
|
874
|
+
o.store.save(save(), { force, seconds: c.settings.durability.movementSeconds, tickHz, fields: durableFields });
|
|
875
|
+
storeBroken = false;
|
|
876
|
+
saveTick = core.tick; lastSaveAt = o.clock.now(); forceSave = false;
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
778
879
|
const seconds = c.settings.durability.movementSeconds;
|
|
779
880
|
// Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
|
|
780
881
|
if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
|
|
@@ -800,21 +901,35 @@ export function createHost(o: HostOptions): Host {
|
|
|
800
901
|
return [...row.slice(0,7),fields,...row.slice(8)];
|
|
801
902
|
}),...snap.d.slice(2)]};
|
|
802
903
|
},
|
|
904
|
+
get characters() { return characters; },
|
|
905
|
+
reserveCharacter(lease) { if (!characters.has(lease.account)) characters.set(lease.account, { ...lease }); },
|
|
906
|
+
releaseCharacter(account) {
|
|
907
|
+
const held = characters.get(account);
|
|
908
|
+
if (!held || held.returning || ended) return;
|
|
909
|
+
held.character = core.character(held.seat) ?? held.character; held.returning = true;
|
|
910
|
+
present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
|
|
911
|
+
if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
|
|
912
|
+
if (running && people() === 0) pause();
|
|
913
|
+
},
|
|
914
|
+
characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
|
|
803
915
|
get viewSettings() { return c.settings.view; },
|
|
916
|
+
get rates() { return c.settings.rates ?? null; },
|
|
804
917
|
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
805
918
|
get tick() { return core.tick; },
|
|
806
919
|
get epoch() { return epoch; },
|
|
807
920
|
get running() { return running; },
|
|
808
921
|
get paused() { return paused; },
|
|
809
922
|
get people() { return people(); },
|
|
810
|
-
frame, start, pause, resume,
|
|
923
|
+
frame, start, pause, resume, announce,
|
|
924
|
+
// An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
|
|
925
|
+
stop() { try { if (persistent && !ended && !storeBroken) checkpoint(true); } finally { stop(); } },
|
|
811
926
|
tickNow: () => {
|
|
812
927
|
if (ended) return;
|
|
813
928
|
let now = lastStart;
|
|
814
929
|
try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
|
|
815
930
|
},
|
|
816
931
|
save,
|
|
817
|
-
facts: () => ({ tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
|
|
932
|
+
facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
|
|
818
933
|
core,
|
|
819
934
|
};
|
|
820
935
|
}
|