@homie-rocks/studio 0.46.0 → 0.46.3
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 +105 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +115 -5
- package/guides/game/SKILL.md +5 -0
- package/lib/build-identity.mjs +43 -0
- 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-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +5 -5
- package/lib/template-history.json +84 -0
- package/netplay/netplay.ts +19 -6
- package/package.json +4 -2
- package/rules/core.ts +29 -4
- package/rules/host.ts +119 -21
- package/rules/interest.mjs +320 -51
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +29 -8
- package/rules/timing.mjs +20 -0
- package/rules/types.ts +5 -0
- package/rules/view.ts +229 -25
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +2 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +92 -10
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +113 -19
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
- 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. */
|
|
@@ -1679,7 +1682,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1679
1682
|
else if (m.t === 'state' && typeof m.k === 'string') applyState(m.k, m.d);
|
|
1680
1683
|
else if (m.t === 'round') { const incoming = m.round as RoundInfo; roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming; if (!m.retime) { emit('round', roundInfo); post?.({ what: 'round', round: roundInfo }); } }
|
|
1681
1684
|
else if (m.t === 'roster' && (offline || !connected)) { slots = m.slots as Slot[]; emit('roster', slots); post?.({ what: 'roster', slots }); }
|
|
1682
|
-
else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id)) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
|
|
1685
|
+
else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id || (m.to === -1 && seat === null))) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
|
|
1683
1686
|
}
|
|
1684
1687
|
let role: Role = 'replica';
|
|
1685
1688
|
let roleKnown = false;
|
|
@@ -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.46.
|
|
3
|
+
"version": "0.46.3",
|
|
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
|
@@ -69,6 +69,10 @@ export const SEND = 16;
|
|
|
69
69
|
export const THROWN = 512;
|
|
70
70
|
/** The most effects (`world.emit`) one tick takes. */
|
|
71
71
|
export const FX_MAX = 256;
|
|
72
|
+
/** The most entities one effect's audience names (`world.emit(effect, at, data, { to })`). */
|
|
73
|
+
export const FX_TO_MAX = 32;
|
|
74
|
+
/** Who may perceive an effect: within so many metres of where it happened (-1: no distance), the entities named, and where it happened. */
|
|
75
|
+
export type FxAudience = [within: number, to: string[], from: [number, number, number] | null];
|
|
72
76
|
/** The most area events (`world.sendArea`) one tick takes. */
|
|
73
77
|
export const AREAS_MAX = 64;
|
|
74
78
|
/** What the log names when a room ends because of what it holds, not because of a handler. */
|
|
@@ -85,7 +89,7 @@ export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec
|
|
|
85
89
|
export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
|
|
86
90
|
export type CoreOut =
|
|
87
91
|
| { t: 'round'; n: number; phase: 'live' | 'over'; endsAt: number; startedAt: number; results?: ResultRow[] }
|
|
88
|
-
| { t: 'fx'; tick: number; list: [number, unknown, unknown][] }
|
|
92
|
+
| { t: 'fx'; tick: number; list: [number, unknown, unknown, FxAudience?][] }
|
|
89
93
|
| { t: 'ask'; n: string; state: unknown; questions: Record<string, unknown> }
|
|
90
94
|
| { t: 'goalDone'; seat: number; ok: boolean }
|
|
91
95
|
| { t: 'seats' }
|
|
@@ -162,6 +166,8 @@ export interface Core {
|
|
|
162
166
|
/** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
|
|
163
167
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
164
168
|
bodies(): Body[];
|
|
169
|
+
/** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
|
|
170
|
+
listeners(): { seat: number; id: string; pos: Vec3 }[];
|
|
165
171
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
166
172
|
character(seat: number): import('./rules.ts').Character | null;
|
|
167
173
|
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
@@ -235,7 +241,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
235
241
|
let sharedROVer = -1;
|
|
236
242
|
const sharedChanged = (): void => { sharedDirty = true; sharedVer += 1; };
|
|
237
243
|
let out: CoreOut[] = [];
|
|
238
|
-
let fx: [number, unknown, unknown][] = [];
|
|
244
|
+
let fx: [number, unknown, unknown, FxAudience?][] = [];
|
|
239
245
|
/** What is left of the running tick's budget. A room's first `room.start` runs before any tick, with a whole one. */
|
|
240
246
|
let left = c.settings.budget.tick;
|
|
241
247
|
let trips = 0;
|
|
@@ -527,6 +533,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
527
533
|
const world = brand({} as Record<string, unknown>);
|
|
528
534
|
const getters: Record<string, () => unknown> = {
|
|
529
535
|
tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
|
|
536
|
+
// What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
|
|
537
|
+
unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
|
|
530
538
|
stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
|
|
531
539
|
};
|
|
532
540
|
for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
|
|
@@ -594,14 +602,30 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
594
602
|
const wait = w >= 1 ? Math.min(w, 0x7fffffff) : 1;
|
|
595
603
|
push({ due: tick + wait, to: cx.ent ? cx.ent.id : '', kind: cx.ent ? 'ev' : 'room', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
596
604
|
},
|
|
597
|
-
emit: (effect: unknown, at: unknown, data?: unknown): void => {
|
|
605
|
+
emit: (effect: unknown, at: unknown, data?: unknown, audience?: unknown): void => {
|
|
598
606
|
charge(10);
|
|
599
607
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
|
|
600
608
|
const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
|
|
601
609
|
if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
|
|
602
610
|
if (fx.length >= FX_MAX) { G.note?.('effect', '', said(effect)); return; }
|
|
603
611
|
const shape = c.effects[c.effectNames[i]];
|
|
604
|
-
|
|
612
|
+
const row: [number, unknown, unknown, FxAudience?] = [i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)];
|
|
613
|
+
// An audience (0.46.3): who may perceive this effect. Without one it is public, as before.
|
|
614
|
+
if (audience !== undefined && audience !== null) {
|
|
615
|
+
charge(8);
|
|
616
|
+
const to = own(audience, 'to'), within = own(audience, 'within');
|
|
617
|
+
const ids: string[] = [];
|
|
618
|
+
if (typeof to === 'string') ids.push(to.slice(0, 24));
|
|
619
|
+
else if (Array.isArray(to)) { if (to.length > FX_TO_MAX) throw new Error(`an effect's audience names at most ${FX_TO_MAX} entities`); for (let n = 0; n < to.length; n++) { const id = to[n]; if (typeof id === 'string') ids.push(id.slice(0, 24)); } }
|
|
620
|
+
else if (to !== undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
|
|
621
|
+
if (within !== undefined && (typeof within !== 'number' || !Number.isFinite(within) || within < 0)) throw new Error('an effect\'s audience `within` is a distance in metres');
|
|
622
|
+
if (typeof to === 'undefined' && within === undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
|
|
623
|
+
// The place it is heard from: where it was emitted, or where the entity it names stands now.
|
|
624
|
+
const e = typeof at === 'string' ? ents.get(at.slice(0, 24)) : undefined;
|
|
625
|
+
const from = typeof at === 'string' ? (e && !e.dead ? e.pos : null) : V(at);
|
|
626
|
+
row.push([typeof within === 'number' ? within : -1, ids, from ? [from.x, from.y, from.z] : null]);
|
|
627
|
+
}
|
|
628
|
+
fx.push(row);
|
|
605
629
|
},
|
|
606
630
|
spawn: (kind: unknown, at: unknown, fields?: unknown): string => {
|
|
607
631
|
charge(10);
|
|
@@ -1484,6 +1508,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1484
1508
|
shared: () => packFields(c.shared, shared, dims),
|
|
1485
1509
|
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; },
|
|
1486
1510
|
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 })),
|
|
1511
|
+
listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
|
|
1487
1512
|
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; },
|
|
1488
1513
|
drain: () => { const o = out; out = []; return o; },
|
|
1489
1514
|
save,
|
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
|
* =============================================================================
|
|
@@ -112,6 +113,8 @@ export interface Host {
|
|
|
112
113
|
readonly privateDelivery: boolean;
|
|
113
114
|
projectSnapshot(snap: any, seat: number | null): any;
|
|
114
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;
|
|
115
118
|
frame(m: Record<string, unknown>): void;
|
|
116
119
|
/** Start the tick loop (the first person has joined). */
|
|
117
120
|
start(): void;
|
|
@@ -129,9 +132,12 @@ export interface Host {
|
|
|
129
132
|
readonly core: Core;
|
|
130
133
|
}
|
|
131
134
|
|
|
132
|
-
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
|
|
135
|
+
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
|
|
133
136
|
interface SeatIn {
|
|
134
|
-
|
|
137
|
+
/** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
|
|
138
|
+
pulses?: [string,number,number?,number?][];
|
|
139
|
+
/** The wait of the entry now held, in ticks (see Entry.early). */
|
|
140
|
+
early?: number;
|
|
135
141
|
held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
|
|
136
142
|
/** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
|
|
137
143
|
newest: number; stamps: number[]; ack: number;
|
|
@@ -158,7 +164,7 @@ const SPEECH = /^(?:say|chat|emote)/i;
|
|
|
158
164
|
/**
|
|
159
165
|
* The overrun check's memory: the last few ticks that started in this isolate, whichever rooms' they were, each with
|
|
160
166
|
* when it started. Time inside a Worker stands still while code runs, so a tick cannot time itself; but the start of
|
|
161
|
-
* the next tick, in any room,
|
|
167
|
+
* the next tick, in any room, gives a scheduling interval (including other work). A late callback suspects the newest
|
|
162
168
|
* tick here that took more than a period: the one just before it, or, when that one was quick and was itself held up,
|
|
163
169
|
* the one before that.
|
|
164
170
|
*/
|
|
@@ -251,7 +257,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
251
257
|
for (const [seat, q] of saved.inputs) {
|
|
252
258
|
check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
|
|
253
259
|
check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
|
|
254
|
-
if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&p.length===2&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
|
|
260
|
+
if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
|
|
255
261
|
for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
|
|
256
262
|
}
|
|
257
263
|
}
|
|
@@ -270,7 +276,26 @@ export function createHost(o: HostOptions): Host {
|
|
|
270
276
|
}
|
|
271
277
|
},
|
|
272
278
|
} : {};
|
|
273
|
-
|
|
279
|
+
/*
|
|
280
|
+
* How far back a person's ray may look (0.46.3). A shot is judged where the shooter saw the target, so the rewind is
|
|
281
|
+
* the age of that view when the input was RECEIVED, plus the time the input then waited for its tick:
|
|
282
|
+
*
|
|
283
|
+
* the view's age the relay's own measurement of this connection's round trip (a low percentile over a window of
|
|
284
|
+
* acknowledgements carried by the input frames themselves, worker/room.mjs), plus 150 ms of
|
|
285
|
+
* presentation delay. A client cannot bring the measurement under its real latency. It can
|
|
286
|
+
* pretend to be slower, by holding back what it sends, and then its input is late too.
|
|
287
|
+
* the wait whole ticks between an entry's arrival and the tick it was stamped for (at least one, at most a
|
|
288
|
+
* quarter of a second): tied to when the server received the input, not to what the client says.
|
|
289
|
+
*
|
|
290
|
+
* `room.rewindSeconds` is the game's hard cap on the sum. Without it the cap is `room.historySeconds`, as before.
|
|
291
|
+
*/
|
|
292
|
+
const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
|
|
293
|
+
const rewindTicks = (seat: number): number => {
|
|
294
|
+
const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
|
|
295
|
+
const need = Math.ceil(((measuredRtt.get(seat) ?? 0) + 150) / period) + waited;
|
|
296
|
+
return rewindCap === null ? need : Math.min(rewindCap, need);
|
|
297
|
+
};
|
|
298
|
+
const core = createCore(c, { ...characterOptions, rewindTicks, 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 });
|
|
274
299
|
let epoch = core.epoch;
|
|
275
300
|
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);
|
|
276
301
|
let lastSaveAt = o.clock.now();
|
|
@@ -395,6 +420,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
395
420
|
/** Where in a tick the runtime is, for the log line of a fault. */
|
|
396
421
|
let phase = '';
|
|
397
422
|
let faultLoggedAt = -Infinity;
|
|
423
|
+
const timing = stageTimings();
|
|
398
424
|
const stats = { ticks: 0, late: 0, slips: 0, since: 0, ins: 0, dropped: 0, lateEntries: 0, loggedAt: 0, faults: 0, lastFault: '' };
|
|
399
425
|
/** Something was thrown in the runtime itself: it is counted, and said in the log at most once a second. */
|
|
400
426
|
function fault(where: string, error: unknown): void {
|
|
@@ -411,9 +437,21 @@ export function createHost(o: HostOptions): Host {
|
|
|
411
437
|
for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
|
|
412
438
|
for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
|
|
413
439
|
const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press' || fd.t === 'pulse') v[name] = fd.t === 'press' ? false : 0; return v; };
|
|
440
|
+
/*
|
|
441
|
+
* `f.held()` inputs that arrive late (0.46.3). A held value is how much of ONE tick a control was down, so the sum over
|
|
442
|
+
* a tap's ticks is the tap. When an entry misses its tick, that tick has already run on the value before it (a
|
|
443
|
+
* release that arrives late ran one more whole tick of movement), and applying the entry a tick later as well would
|
|
444
|
+
* count the tap twice over. So the difference between what the missed ticks ran on and what they were meant to run
|
|
445
|
+
* on is owed, and paid on the next ticks, inside the range the control reads in. The body then ends where the
|
|
446
|
+
* browser predicted it, however the frames fell. Kept beside the queues and not saved: a restart forgets a debt.
|
|
447
|
+
*/
|
|
448
|
+
const heldNames = new Map<KindTable, string[]>();
|
|
449
|
+
const heldOf = (kind: KindTable): string[] => { let names = heldNames.get(kind); if (!names) { names = kind.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name); heldNames.set(kind, names); } return names; };
|
|
450
|
+
const pulsesTaken = new Map<number, number>();
|
|
451
|
+
const heldBook = new Map<number, { ran: [number, Record<string, number>][]; debt: Record<string, number>; until: number }>();
|
|
414
452
|
function queueOf(seat: number, kind: KindTable): SeatIn {
|
|
415
453
|
let q = queues.get(seat);
|
|
416
|
-
if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
|
|
454
|
+
if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); pulsesTaken.delete(seat); }
|
|
417
455
|
return q;
|
|
418
456
|
}
|
|
419
457
|
const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
|
|
@@ -457,9 +495,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
457
495
|
}
|
|
458
496
|
const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
|
|
459
497
|
q.stamps.push(j);
|
|
460
|
-
if (j > K) { q.entries.push({ at: j, values, presses, claim }); continue; }
|
|
498
|
+
if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
|
|
461
499
|
// Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
|
|
462
500
|
stats.lateEntries += 1;
|
|
501
|
+
const book = heldBook.get(seat as number);
|
|
502
|
+
if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
|
|
463
503
|
const at = (K + 1) >>> 0;
|
|
464
504
|
const keep = at - j <= lateTicks ? presses : [];
|
|
465
505
|
const same = q.entries.find((x) => x.at === at);
|
|
@@ -550,9 +590,15 @@ export function createHost(o: HostOptions): Host {
|
|
|
550
590
|
if (agents && (kind === 'agent:do' || /^ask:/.test(kind) || (SPEECH.test(kind) && present.get(m.from as number) === false))) { if (agentInbox.length < 128) agentInbox.push(m); return; }
|
|
551
591
|
if(kind === 'pulse') {
|
|
552
592
|
const data=m.d,body=core.bodyOf(m.from as number);
|
|
553
|
-
if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length
|
|
593
|
+
if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length<3 || data.length>5 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
|
|
554
594
|
const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
|
|
555
|
-
|
|
595
|
+
// The browser names the tick it predicted the pulse on (0.46.3): the server acts on it in that tick, as it does for a
|
|
596
|
+
// stamped input, so what was predicted is what the tick's snapshot holds. A tick already run, or none: at once.
|
|
597
|
+
const at=Number.isInteger(data[3])&&(data[3] as number)>0?Math.min(data[3] as number,0xffffffff):0;
|
|
598
|
+
// Its number in this browser's own count (0.46.3): the control row says the newest one acted on, so a browser knows
|
|
599
|
+
// whether a pulse it predicted has happened yet, however late its message was.
|
|
600
|
+
const n=Number.isInteger(data[4])&&(data[4] as number)>0?Math.min(data[4] as number,0xffffffff):0;
|
|
601
|
+
if(value && q.pulses.length<16)q.pulses.push(n?[data[1],value,at,n]:at?[data[1],value,at]:[data[1],value]);return;
|
|
556
602
|
}
|
|
557
603
|
if (kind === 'cmd') { const d = m.d as unknown[]; if (Array.isArray(d) && typeof d[0] === 'string') core.command(m.from as number, d[0], d[1]); return; }
|
|
558
604
|
// Speech keeps its payload, except the identity fields only the host may attach.
|
|
@@ -612,10 +658,27 @@ export function createHost(o: HostOptions): Host {
|
|
|
612
658
|
} });
|
|
613
659
|
} else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
|
|
614
660
|
else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
|
|
615
|
-
else if (x.t === 'fx')
|
|
661
|
+
else if (x.t === 'fx') {
|
|
662
|
+
// Effects with an audience (0.46.3) go to who may perceive them: one frame a person whose body is named or in
|
|
663
|
+
// range, with the tick's public effects in the order they were emitted. A watcher (no seat) and a seat with no
|
|
664
|
+
// body perceive the room: every effect with a range, never one addressed only `to` somebody.
|
|
665
|
+
// A tick with no audience at all is one frame for everybody, as before.
|
|
666
|
+
if (!x.list.some((row) => row[3])) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
|
|
667
|
+
else {
|
|
668
|
+
const all = x.list.map((row) => [row[0], row[1], row[2]]);
|
|
669
|
+
const bodies = new Map(core.listeners().map((l) => [l.seat, l]));
|
|
670
|
+
const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
|
|
671
|
+
for (const seat of present.keys()) {
|
|
672
|
+
const l = bodies.get(seat);
|
|
673
|
+
const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0] - l.pos.x, a[2][1] - l.pos.y, a[2][2] - l.pos.z) <= a[0]); });
|
|
674
|
+
if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
|
|
675
|
+
}
|
|
676
|
+
if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
616
679
|
else if (x.t === 'seats') roster = true;
|
|
617
680
|
else if (x.t === 'shared') o.send({ t: 'state', k: 'shared', d: core.shared() });
|
|
618
|
-
else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
|
|
681
|
+
else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); heldBook.clear(); pulsesTaken.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
|
|
619
682
|
}
|
|
620
683
|
if (roster || !rosterText) sendRoster();
|
|
621
684
|
}
|
|
@@ -635,49 +698,75 @@ export function createHost(o: HostOptions): Host {
|
|
|
635
698
|
const presses: (string | [string, number])[] = [];
|
|
636
699
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
637
700
|
const e = q.entries.shift() as Entry;
|
|
638
|
-
q.held = e.values; q.claim = e.claim;
|
|
701
|
+
q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
|
|
639
702
|
if (e.at === t) for (const p of e.presses) presses.push(p);
|
|
640
703
|
}
|
|
641
704
|
// Silence: no frame for a second. Its input is neutral until the next entry.
|
|
642
705
|
if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
|
|
643
706
|
while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
|
|
644
707
|
const values = { ...q.held };
|
|
708
|
+
const heldFields = heldOf(body.kind);
|
|
709
|
+
if (heldFields.length) {
|
|
710
|
+
let book = heldBook.get(seat);
|
|
711
|
+
if (!book) { book = { ran: [], debt: {}, until: 0 }; heldBook.set(seat, book); }
|
|
712
|
+
const level: Record<string, number> = {};
|
|
713
|
+
for (const name of heldFields) level[name] = Number(values[name]) || 0;
|
|
714
|
+
book.ran.push([t, level]); if (book.ran.length > lateTicks + 1) book.ran.shift();
|
|
715
|
+
if (book.until >= t) for (const name of heldFields) { const owed = book.debt[name]; if (owed) { const range = Math.max(1, Math.abs(level[name])), want = level[name] + owed, paid = Math.max(-range, Math.min(range, want)); values[name] = paid; book.debt[name] = want - paid; } }
|
|
716
|
+
else book.debt = {};
|
|
717
|
+
}
|
|
645
718
|
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
646
|
-
|
|
719
|
+
// One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
|
|
720
|
+
const head=q.pulses?.[0];if(head&&!((head[2]??0)>t&&(head[2] as number)-t<=Math.ceil(tickHz/4))){q.pulses!.shift();values[head[0]]=head[1];if(head[3])pulsesTaken.set(seat,head[3]);}
|
|
647
721
|
inputs.set(seat, { values, claim: q.claim });
|
|
648
722
|
}
|
|
649
723
|
if (roundRebasePending) publishRebase(now);
|
|
650
724
|
phase = 'step';
|
|
725
|
+
const rulesAt = timing.now();
|
|
651
726
|
core.step(inputs, guideBeat);
|
|
727
|
+
timing.record('rules', rulesAt);
|
|
652
728
|
phase = 'frames';
|
|
729
|
+
const framesAt = timing.now();
|
|
653
730
|
const out = core.drain();
|
|
654
731
|
handle(out, now);
|
|
732
|
+
timing.record('frames', framesAt);
|
|
655
733
|
if (ended) return;
|
|
656
734
|
phase = 'snapshot';
|
|
735
|
+
const snapshotAt = timing.now();
|
|
657
736
|
const data = core.snapshot();
|
|
737
|
+
timing.record('snapshot', snapshotAt);
|
|
658
738
|
const nextPhase = (data[0] as number[])[1];
|
|
659
739
|
if (roundPhase === 1 && nextPhase === 0) forceSave = true;
|
|
660
740
|
roundPhase = nextPhase;
|
|
741
|
+
const checkpointAt = timing.now();
|
|
661
742
|
checkpoint(forceSave);
|
|
743
|
+
timing.record('checkpoint', checkpointAt);
|
|
744
|
+
const encodeAt = timing.now();
|
|
662
745
|
// The control table: one row a seat, [seat, r, ack, lead].
|
|
663
746
|
const rows: number[][] = [];
|
|
664
747
|
for (const b of core.bodies()) {
|
|
665
748
|
if (!hasHands(b.seat, b.driver)) continue;
|
|
666
749
|
const q = queues.get(b.seat);
|
|
667
|
-
|
|
750
|
+
// A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
|
|
751
|
+
const taken = pulsesTaken.get(b.seat);
|
|
752
|
+
rows.push(taken === undefined ? [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128] : [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128, taken]);
|
|
668
753
|
if (q) q.lead = 127;
|
|
669
754
|
}
|
|
670
755
|
// A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
|
|
671
756
|
// apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
|
|
672
757
|
lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
|
|
673
758
|
const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
|
|
674
|
-
|
|
759
|
+
const encoded = JSON.stringify(snap);
|
|
760
|
+
timing.record('encode', encodeAt);
|
|
761
|
+
const sendAt = timing.now();
|
|
762
|
+
o.send(snap, encoded);
|
|
763
|
+
timing.record('send', sendAt);
|
|
675
764
|
stats.ticks += 1;
|
|
676
765
|
stats.since += 1;
|
|
677
766
|
if (now - stats.loggedAt >= 10_000) {
|
|
678
767
|
// Tick figures, one line every ten seconds, never a line a tick.
|
|
679
768
|
const s = core.stats;
|
|
680
|
-
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 } : {}) });
|
|
769
|
+
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 } : {}) });
|
|
681
770
|
stats.loggedAt = now; stats.since = 0; stats.late = 0; stats.ins = 0; stats.lateEntries = 0;
|
|
682
771
|
}
|
|
683
772
|
}
|
|
@@ -687,6 +776,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
687
776
|
* of the clock the room ends, and the log names the handler (or the fault).
|
|
688
777
|
*/
|
|
689
778
|
function tick(now: number): void {
|
|
779
|
+
const tickAt = timing.now();
|
|
690
780
|
const cut = core.stats.ticksCut;
|
|
691
781
|
let faulted = false;
|
|
692
782
|
if (persistent) pendingOutput = [];
|
|
@@ -698,8 +788,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
698
788
|
pendingOutput = null;
|
|
699
789
|
faulted = true; fault(`tick ${core.tick} (${phase})`, error);
|
|
700
790
|
// Never continue from uncommitted memory or retire the last good save.
|
|
701
|
-
if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
|
|
791
|
+
if (persistent) { timing.record('tick', tickAt); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
|
|
702
792
|
}
|
|
793
|
+
timing.record('tick', tickAt);
|
|
703
794
|
if (ended) return;
|
|
704
795
|
if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
|
|
705
796
|
if (failingSince === null) failingSince = now;
|
|
@@ -753,8 +844,13 @@ export function createHost(o: HostOptions): Host {
|
|
|
753
844
|
if (overSince !== null) {
|
|
754
845
|
overTicks += 1;
|
|
755
846
|
if (2 * overBlamed < overTicks) overSince = null;
|
|
756
|
-
//
|
|
757
|
-
|
|
847
|
+
// Late callbacks can catch up at the requested rate. End only a
|
|
848
|
+
// blamed room that also loses sustained throughput, not a healthy
|
|
849
|
+
// 20 Hz room whose callbacks arrive in pairs during admission/I/O.
|
|
850
|
+
else if (now - overSince >= OVERRUN_MS) {
|
|
851
|
+
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; }
|
|
852
|
+
overSince = null;
|
|
853
|
+
}
|
|
758
854
|
}
|
|
759
855
|
if (o.enforceOverrun) recent.push({ who: self, at: now });
|
|
760
856
|
if (recent.length > 8) recent.shift();
|
|
@@ -837,7 +933,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
837
933
|
}
|
|
838
934
|
|
|
839
935
|
function save(): Uint8Array {
|
|
840
|
-
|
|
936
|
+
const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
|
|
937
|
+
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] });
|
|
841
938
|
}
|
|
842
939
|
function checkpoint(force = false): void {
|
|
843
940
|
if (persistent) {
|
|
@@ -888,6 +985,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
888
985
|
},
|
|
889
986
|
characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
|
|
890
987
|
get viewSettings() { return c.settings.view; },
|
|
988
|
+
get rates() { return c.settings.rates ?? null; },
|
|
891
989
|
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
892
990
|
get tick() { return core.tick; },
|
|
893
991
|
get epoch() { return epoch; },
|
|
@@ -903,7 +1001,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
903
1001
|
try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
|
|
904
1002
|
},
|
|
905
1003
|
save,
|
|
906
|
-
facts: () => ({ tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
|
|
1004
|
+
facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
|
|
907
1005
|
core,
|
|
908
1006
|
};
|
|
909
1007
|
}
|