@homie-rocks/studio 0.46.3 → 0.46.6
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 +70 -0
- package/README.md +12 -1
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +23 -0
- package/guides/game/SKILL.md +2 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/crowd-cloud.mjs +75 -17
- package/lib/crowd-diagnostics.mjs +1 -0
- package/lib/crowd-load.mjs +15 -3
- package/lib/crowd-probe/README.md +58 -0
- package/lib/crowd-probe/client.mjs +3 -0
- package/lib/crowd-probe/reference-work.mjs +45 -0
- package/lib/crowd-probe/run-trial.sh +207 -0
- package/lib/crowd-probe/worker.mjs +37 -10
- package/lib/crowd-summary.mjs +74 -7
- package/lib/crowd-worker.mjs +12 -4
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +2 -0
- package/package.json +1 -1
- package/rules/host.ts +14 -3
- package/rules/rules.ts +9 -3
- package/rules/view.ts +66 -12
- package/worker/build-id.mjs +1 -1
- package/worker/gate.mjs +72 -25
- package/worker/index.mjs +44 -10
- package/worker/office.mjs +2 -2
- package/worker/pages.mjs +46 -1
- package/worker/room-store.mjs +71 -11
- package/worker/room.mjs +16 -3
- package/worker/version.mjs +1 -1
|
@@ -2817,6 +2817,90 @@
|
|
|
2817
2817
|
}
|
|
2818
2818
|
}
|
|
2819
2819
|
}
|
|
2820
|
+
},
|
|
2821
|
+
"0.46.5": {
|
|
2822
|
+
"files": {
|
|
2823
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2824
|
+
"README.md": "684b3cca8001673c",
|
|
2825
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2826
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2827
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2828
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2829
|
+
"posts/README.md": "37841aae476bd540",
|
|
2830
|
+
"site/README.md": "732894273a32dc3f",
|
|
2831
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2832
|
+
},
|
|
2833
|
+
"sections": {
|
|
2834
|
+
"AGENTS.md": {
|
|
2835
|
+
"(intro)": "394cbbe934e80722",
|
|
2836
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2837
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2838
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2839
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2840
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2841
|
+
"## Making games": "bb715272fd1a615b",
|
|
2842
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2843
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2844
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2845
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2846
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2847
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2848
|
+
"## Rules": "27881d11d3ff3803",
|
|
2849
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2850
|
+
"## Beta": "9d02638207a26e73"
|
|
2851
|
+
}
|
|
2852
|
+
},
|
|
2853
|
+
"named": {
|
|
2854
|
+
"Homie Arcade": {
|
|
2855
|
+
"sections": {
|
|
2856
|
+
"AGENTS.md": {
|
|
2857
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
},
|
|
2863
|
+
"0.46.6": {
|
|
2864
|
+
"files": {
|
|
2865
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2866
|
+
"README.md": "684b3cca8001673c",
|
|
2867
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2868
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2869
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2870
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2871
|
+
"posts/README.md": "37841aae476bd540",
|
|
2872
|
+
"site/README.md": "732894273a32dc3f",
|
|
2873
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2874
|
+
},
|
|
2875
|
+
"sections": {
|
|
2876
|
+
"AGENTS.md": {
|
|
2877
|
+
"(intro)": "394cbbe934e80722",
|
|
2878
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2879
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2880
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2881
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2882
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2883
|
+
"## Making games": "bb715272fd1a615b",
|
|
2884
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2885
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2886
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2887
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2888
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2889
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2890
|
+
"## Rules": "27881d11d3ff3803",
|
|
2891
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2892
|
+
"## Beta": "9d02638207a26e73"
|
|
2893
|
+
}
|
|
2894
|
+
},
|
|
2895
|
+
"named": {
|
|
2896
|
+
"Homie Arcade": {
|
|
2897
|
+
"sections": {
|
|
2898
|
+
"AGENTS.md": {
|
|
2899
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2820
2904
|
}
|
|
2821
2905
|
}
|
|
2822
2906
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1699,6 +1699,18 @@ reads the other's snapshot with the wrong code.
|
|
|
1699
1699
|
- **An old tab that knocks at the live build's room is refused**: `error stale` (final; the helper
|
|
1700
1700
|
shows "This game was updated. Tap to reload." and the play page reloads the frame, at most twice
|
|
1701
1701
|
a minute).
|
|
1702
|
+
- **A page older than the game it would host is brought up to date too (0.46.5).** The frame alone
|
|
1703
|
+
is reloaded only while the play page around it is still the page the Worker would write now. The
|
|
1704
|
+
page says what it was made for (`sh` on the frame's address: the toolkit's version and the parts
|
|
1705
|
+
of the manifest a page is built around, `shellKey` in worker/pages.mjs; not the game's code or
|
|
1706
|
+
words). When that differs, the Worker answers the frame with a stand-in that says so, and the
|
|
1707
|
+
page does **one full reload**, to the same room (it is in the address) and the same seat (its
|
|
1708
|
+
token is in the tab). A page from before this handshake has only one way to load a whole new
|
|
1709
|
+
page, the one for leaving a finished room; it is used only when the game cannot work inside the
|
|
1710
|
+
old page at all: the game keeps results for a player (`room.records`) and the old page holds no
|
|
1711
|
+
player ticket. Such a player lands in a fresh page and a room the lobby gives them. A result
|
|
1712
|
+
that still has no player to be written for is never dropped in silence: the log says
|
|
1713
|
+
`record-unsaved`, and the player's screen says the round was not saved and to reload.
|
|
1702
1714
|
- **A tab of the live build that knocks at a room still on the old build waits**: `error
|
|
1703
1715
|
room-stale` (not final). The room's players are told to reload; when the last of them has left,
|
|
1704
1716
|
the next knock is let in, and the room **starts fresh**: its checkpoint, snapshot, round, roster
|
package/netplay/netplay.ts
CHANGED
|
@@ -2547,6 +2547,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2547
2547
|
// An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
|
|
2548
2548
|
if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
|
|
2549
2549
|
if (code === 'host-fault') { rulesNotice(String(m.message)); return; }
|
|
2550
|
+
// A result the server could not keep for this player (0.46.5): said on their screen, not dropped in silence.
|
|
2551
|
+
if (code === 'unsaved') { rulesNotice(String(m.message)); return; }
|
|
2550
2552
|
if (code === 'vote') { warnOnce('vote', m['message']); return; }
|
|
2551
2553
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
2552
2554
|
// The room still runs another build of the game (section 23): not final. Its players were told to reload; this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.6",
|
|
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",
|
package/rules/host.ts
CHANGED
|
@@ -80,6 +80,8 @@ export interface HostOptions {
|
|
|
80
80
|
clock: HostClock;
|
|
81
81
|
/** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
|
|
82
82
|
store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
|
|
83
|
+
/** Whether a snapshot is also handed over as text (the default). A room that sends the whole world to nobody answers false. */
|
|
84
|
+
snapshotText?: () => boolean;
|
|
83
85
|
/** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
|
|
84
86
|
restoreEpoch?: number;
|
|
85
87
|
startDelayMs?: number;
|
|
@@ -289,6 +291,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
289
291
|
*
|
|
290
292
|
* `room.rewindSeconds` is the game's hard cap on the sum. Without it the cap is `room.historySeconds`, as before.
|
|
291
293
|
*/
|
|
294
|
+
const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
|
|
292
295
|
const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
|
|
293
296
|
const rewindTicks = (seat: number): number => {
|
|
294
297
|
const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
|
|
@@ -628,7 +631,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
628
631
|
/* ---------------------------------------------------------------- frames out */
|
|
629
632
|
|
|
630
633
|
const hasHands = (seat: number, driver: Driver): boolean => driver === 'person' || (driver === 'ai' && [...peers.values()].some((p) => p.seat === seat && p.agent?.hands === 'self'));
|
|
631
|
-
|
|
634
|
+
// The game's own names for its bots (room.bots.names, 0.46.5), else the toolkit's.
|
|
635
|
+
const botNames = c.botNames?.length ? c.botNames : BOT_NAMES;
|
|
636
|
+
const nameOf = (seat: number, driver: Driver): string => (driver === 'bot' || (driver === 'ai' && (core.bodies().find((b) => b.seat === seat)?.owner === 'reserved' || !names.has(seat))) ? botNames[seat % botNames.length] : names.get(seat) || `Player ${seat + 1}`);
|
|
632
637
|
function sendRoster(): void {
|
|
633
638
|
const guides = new Map(agentSlots().map((s) => [s.slot, s.agent]));
|
|
634
639
|
const slots = core.bodies().sort((a, b) => a.seat - b.seat).map((b) => ({ slot: b.seat, seat: b.driver === 'bot' ? null : b.seat, name: nameOf(b.seat, b.driver), bot: b.driver === 'bot', ...(guides.has(b.seat) ? { agent: guides.get(b.seat) } : {}) }));
|
|
@@ -749,14 +754,20 @@ export function createHost(o: HostOptions): Host {
|
|
|
749
754
|
const q = queues.get(b.seat);
|
|
750
755
|
// A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
|
|
751
756
|
const taken = pulsesTaken.get(b.seat);
|
|
752
|
-
|
|
757
|
+
const row = [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128];
|
|
758
|
+
// A sixth number only in a game that rewinds (room.historySeconds, 0.46.5): how many ticks back this seat's own
|
|
759
|
+
// ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
|
|
760
|
+
if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
|
|
761
|
+
else if (taken !== undefined) row.push(taken);
|
|
762
|
+
rows.push(row);
|
|
753
763
|
if (q) q.lead = 127;
|
|
754
764
|
}
|
|
755
765
|
// A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
|
|
756
766
|
// apart to whoever interpolates between them. The stamp always rises: a replica drops one that does not.
|
|
757
767
|
lastSt = Math.max(lastSt + 1, Math.round(Math.min(now, dueOf(core.tick))));
|
|
758
768
|
const snap = { t: 'snap', from: null, e: epoch, k: core.tick, st: lastSt, d: data, c: rows };
|
|
759
|
-
|
|
769
|
+
// The whole world as text, for whoever is sent it whole. A room that sends it to nobody says so and is spared it.
|
|
770
|
+
const encoded = o.snapshotText?.() === false ? undefined : JSON.stringify(snap);
|
|
760
771
|
timing.record('encode', encodeAt);
|
|
761
772
|
const sendAt = timing.now();
|
|
762
773
|
o.send(snap, encoded);
|
package/rules/rules.ts
CHANGED
|
@@ -167,7 +167,8 @@ export interface RulesDef {
|
|
|
167
167
|
shared?: Fields;
|
|
168
168
|
room?: {
|
|
169
169
|
rounds?: { seconds: number; breakSeconds: number };
|
|
170
|
-
|
|
170
|
+
/** `names` (0.46.5): what the game's bots are called, by seat; the toolkit's own list without it. */
|
|
171
|
+
bots?: { keep: number; names?: readonly string[] };
|
|
171
172
|
records?: RoundRecords;
|
|
172
173
|
historySeconds?: number;
|
|
173
174
|
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
@@ -396,6 +397,7 @@ export interface Compiled {
|
|
|
396
397
|
rewindSeconds?: number;
|
|
397
398
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
398
399
|
bots: number;
|
|
400
|
+
botNames?: readonly string[];
|
|
399
401
|
start: RoomHandler | null;
|
|
400
402
|
join: NonNullable<RulesDef['room']>['join'] | null;
|
|
401
403
|
roomOn: Record<string, RoomHandler>;
|
|
@@ -636,6 +638,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
636
638
|
}
|
|
637
639
|
const keep = room.bots === undefined ? 0 : typeof room.bots?.keep === 'number' ? Math.floor(room.bots.keep) : NaN;
|
|
638
640
|
if (!(keep >= 0 && keep <= SEATS_MAX)) throw new Error(`room.bots is { keep: n }, 0 to ${SEATS_MAX}`);
|
|
641
|
+
const botNames = (room.bots as { names?: unknown } | undefined)?.names;
|
|
642
|
+
if (botNames !== undefined && !(Array.isArray(botNames) && botNames.length >= 1 && botNames.length <= 64 && botNames.every((n) => typeof n === 'string' && n.trim().length >= 1 && n.length <= 24 && !/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/.test(n)))) throw new Error('room.bots.names is a list of 1 to 64 names, each 1 to 24 characters on one line');
|
|
639
643
|
if (room.start !== undefined && typeof room.start !== 'function') throw new Error('room.start is a function');
|
|
640
644
|
if (room.join !== undefined && typeof room.join !== 'function') throw new Error('room.join is a function');
|
|
641
645
|
const players = kinds.filter((k) => k.player);
|
|
@@ -661,7 +665,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
661
665
|
Object.freeze(navigation);
|
|
662
666
|
return {
|
|
663
667
|
navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
664
|
-
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
668
|
+
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, ...(botNames ? { botNames: Object.freeze([...(botNames as string[])]) } : {}), bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
665
669
|
tune, publicTune, map, settings, seats,
|
|
666
670
|
};
|
|
667
671
|
}
|
|
@@ -674,6 +678,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
674
678
|
*/
|
|
675
679
|
export interface Schema {
|
|
676
680
|
records?: true;
|
|
681
|
+
/** A game that rewinds (0.46.5): its window and its cap, for a browser predicting its own shots. */
|
|
682
|
+
rewind?: { historySeconds: number; capSeconds?: number };
|
|
677
683
|
contract: 2;
|
|
678
684
|
dims: 2 | 3;
|
|
679
685
|
seats: number;
|
|
@@ -698,7 +704,7 @@ function publicField(field: Field, hidden=false): Field {
|
|
|
698
704
|
}
|
|
699
705
|
export function schemaOf(c: Compiled): Schema {
|
|
700
706
|
return {
|
|
701
|
-
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}),
|
|
707
|
+
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}), ...(c.historySeconds ? { rewind: { historySeconds: c.historySeconds, ...(c.rewindSeconds !== undefined ? { capSeconds: c.rewindSeconds } : {}) } } : {}),
|
|
702
708
|
kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
|
|
703
709
|
effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
|
|
704
710
|
};
|
package/rules/view.ts
CHANGED
|
@@ -47,8 +47,8 @@ import { createNetplay } from '../netplay/netplay.ts';
|
|
|
47
47
|
import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
48
48
|
import { lab } from '../lab/lab.ts';
|
|
49
49
|
import { exposePort, type PortProbeOptions } from '../port/probe.ts';
|
|
50
|
-
import { BudgetError } from './guard.ts';
|
|
51
|
-
import { moveContext } from './math.ts';
|
|
50
|
+
import { BudgetError, brand } from './guard.ts';
|
|
51
|
+
import { math, moveContext } from './math.ts';
|
|
52
52
|
import { coerce, coerceFields, dir, initFields, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
53
53
|
import type { Unpacked } from './pack.ts';
|
|
54
54
|
import { compileMap } from './rules.ts';
|
|
@@ -113,6 +113,15 @@ export interface OpenRoomOptions {
|
|
|
113
113
|
/** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
|
|
114
114
|
export interface PredictContext extends RayQueries {
|
|
115
115
|
readonly tick: number; readonly dt: number;
|
|
116
|
+
/** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
|
|
117
|
+
readonly math: typeof math;
|
|
118
|
+
/**
|
|
119
|
+
* The tick the server will judge this player's ray against when the browser says it aimed at `aimTick` (0.46.5):
|
|
120
|
+
* `aimTick`, held to how far back this seat may look right now (the server says so in every snapshot) and to this
|
|
121
|
+
* tick. Pass it to `ctx.ray(..., { atTick })`: the other bodies are then posed as the server will pose them, from
|
|
122
|
+
* the snapshots this browser holds (between two of them, or carried on from the newest along its velocity).
|
|
123
|
+
*/
|
|
124
|
+
judgedTick(aimTick: number): number;
|
|
116
125
|
/** The tick the other bodies are drawn at: what the player is looking at. */
|
|
117
126
|
readonly viewTick: number;
|
|
118
127
|
/** This run draws ahead of the tick; the tick's own run follows. What it shows is shown once. */
|
|
@@ -128,6 +137,8 @@ export interface PredictContext extends RayQueries {
|
|
|
128
137
|
export type Predictor = (me: any, input: any, ctx: PredictContext) => void;
|
|
129
138
|
export interface Room<R = unknown> extends RayQueries {
|
|
130
139
|
readonly viewTick: number;
|
|
140
|
+
/** Where a body was at a tick, from the snapshots this browser holds (0.46.5); `known: false` past the newest one. */
|
|
141
|
+
poseAt(id: string, tick: number): { pos: Vec3; vel: Vec3; known: boolean } | null;
|
|
131
142
|
readonly status: RoomStatus;
|
|
132
143
|
readonly seat: number | null;
|
|
133
144
|
readonly me: Entity | null;
|
|
@@ -184,6 +195,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
184
195
|
|
|
185
196
|
let status: RoomStatus = 'connecting';
|
|
186
197
|
let latest: Frame | null = null;
|
|
198
|
+
/** The snapshots this browser holds, oldest first: two seconds of them (see poseAt). */
|
|
199
|
+
const held2: Frame[] = [];
|
|
187
200
|
let epoch = 0;
|
|
188
201
|
let round: RoomRound | null = null;
|
|
189
202
|
let results: RoundInfo['results'] | undefined;
|
|
@@ -380,11 +393,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
380
393
|
// run has asked for: never by the order of everything asked, which changes when another action joins the tick.
|
|
381
394
|
const asked = new Map<string, number>();
|
|
382
395
|
const once = (what: string): boolean => { if (!live) return false; let set = shown.get(t); if (!set) { set = new Set(); shown.set(t, set); } const n = asked.get(what) ?? 0; asked.set(what, n + 1); const key = what + '#' + n; if (set.has(key)) return false; set.add(key); return true; };
|
|
383
|
-
|
|
384
|
-
|
|
396
|
+
// Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
|
|
397
|
+
// code can be run on it instead of being restated in the view.
|
|
398
|
+
const ctx: PredictContext = brand({
|
|
399
|
+
tick: t, dt: 1 / tickHz, preview, math,
|
|
400
|
+
judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
|
|
385
401
|
get viewTick() { return viewTickNow(); },
|
|
386
402
|
ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
|
|
387
|
-
rays: (options) => raySnapshot(map, viewTargets(), dims, options, id),
|
|
403
|
+
rays: (options) => raySnapshot(map, viewTargets(atTickOf(options)), dims, options, id),
|
|
388
404
|
rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
|
|
389
405
|
ghost(kindOfGhost, at, data = {}, options = {}) {
|
|
390
406
|
const k = kindOf.get(kindOfGhost);
|
|
@@ -406,7 +422,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
406
422
|
if (waiting) waiting.confirms = true; else provisional.push({ name, tick: t, payload, match: options.match });
|
|
407
423
|
emit(name, Object.freeze({ ...payload, predicted: true }));
|
|
408
424
|
},
|
|
409
|
-
};
|
|
425
|
+
});
|
|
410
426
|
const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
|
|
411
427
|
for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
|
|
412
428
|
try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
|
|
@@ -551,6 +567,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
551
567
|
const before = latest;
|
|
552
568
|
if (before && before.e === f.e && f.k <= before.k) return;
|
|
553
569
|
latest = f;
|
|
570
|
+
if (before && before.e !== f.e) held2.length = 0;
|
|
571
|
+
held2.push(f); if (held2.length > 2 * tickHz) held2.shift();
|
|
554
572
|
if (!before || before.e !== f.e) collisionHistory = [];
|
|
555
573
|
const collision = f.collision;
|
|
556
574
|
if (collision) {
|
|
@@ -785,11 +803,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
785
803
|
// and stops dead at a release, with nothing to blend away afterwards.
|
|
786
804
|
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
787
805
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
788
|
-
|
|
806
|
+
// A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
|
|
807
|
+
// read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
|
|
808
|
+
// then and standing from then, not sinking until the tick's end.
|
|
809
|
+
let av = a, landed = false;
|
|
810
|
+
if (dims === 3 && !mine.grounded && next.grounded && mine.vel.z < 0) {
|
|
811
|
+
const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
|
|
812
|
+
av = Math.min(1, a / contact); landed = a >= contact;
|
|
813
|
+
}
|
|
814
|
+
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, av) }, dims);
|
|
789
815
|
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
790
816
|
velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
|
|
791
817
|
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
792
|
-
grounded = mine.grounded && (a === 0 || next.grounded);
|
|
818
|
+
grounded = landed || mine.grounded && (a === 0 || next.grounded);
|
|
793
819
|
preview = next;
|
|
794
820
|
if (predictors[kind.name]) {
|
|
795
821
|
// The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
|
|
@@ -912,16 +938,43 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
912
938
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
913
939
|
|
|
914
940
|
function viewTickNow(): number { const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; }
|
|
915
|
-
|
|
941
|
+
/*
|
|
942
|
+
* WHERE A BODY WAS, OR WILL BE JUDGED TO BE (0.46.5). The server judges a rewound ray against poses it kept by
|
|
943
|
+
* tick. A browser holds the snapshots it has received: a pose at a tick between two of them is the point between;
|
|
944
|
+
* past the newest it is that snapshot's pose carried on along its velocity (`known: false`: a guess); before the
|
|
945
|
+
* oldest it is the oldest. A placement between the two is not glided across.
|
|
946
|
+
*/
|
|
947
|
+
function poseAt(id: string, at: number): { pos: Vec3; vel: Vec3; known: boolean } | null {
|
|
948
|
+
let before: Frame | null = null, after: Frame | null = null;
|
|
949
|
+
for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
|
|
950
|
+
const a = before?.ents.get(id), b = after?.ents.get(id);
|
|
951
|
+
if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3({ x: lerp(a.pos.x, b.pos.x, m), y: lerp(a.pos.y, b.pos.y, m), z: lerp(a.pos.z, b.pos.z, m) }, dims), vel: b.vel, known: true }; }
|
|
952
|
+
if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
|
|
953
|
+
return b ? { pos: b.pos, vel: b.vel, known: false } : null;
|
|
954
|
+
}
|
|
955
|
+
const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
|
|
956
|
+
/** How many ticks back this seat's ray may look: the server's own figure from the newest snapshot, else the rule it follows with this browser's round trip. */
|
|
957
|
+
function rewindAllowance(): number {
|
|
958
|
+
const seat = net.seat, row = seat === null ? undefined : latest?.rows.find((x) => x[0] === seat);
|
|
959
|
+
const history = Math.ceil((schema.rewind?.historySeconds ?? 0) * tickHz);
|
|
960
|
+
if (typeof row?.[5] === 'number') return row[5];
|
|
961
|
+
const cap = schema.rewind?.capSeconds !== undefined ? Math.ceil(schema.rewind.capSeconds * tickHz) : history;
|
|
962
|
+
return Math.min(history, cap, Math.ceil((authorityRtt() + 150) / period) + 1);
|
|
963
|
+
}
|
|
964
|
+
const viewTargets = (atTick?: number) => {
|
|
916
965
|
pump();
|
|
917
966
|
const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
|
|
918
967
|
for(const k of schema.kinds) {
|
|
919
968
|
if(k.collider || !k.radius)continue;
|
|
920
|
-
each(k.name,e=>
|
|
969
|
+
each(k.name,e=>{
|
|
970
|
+
// At a tick: where the server will have the body, with the fields as they are now (the server filters on current fields too).
|
|
971
|
+
const at = atTick !== undefined && e.id !== mine?.id && !e.provisional ? poseAt(e.id, atTick)?.pos ?? e.pos : e.pos;
|
|
972
|
+
targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(at,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at});
|
|
973
|
+
});
|
|
921
974
|
}
|
|
922
975
|
return targets;
|
|
923
976
|
};
|
|
924
|
-
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
|
|
977
|
+
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
|
|
925
978
|
return {
|
|
926
979
|
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
927
980
|
get status() { return solo ? 'offline' : status; },
|
|
@@ -929,7 +982,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
929
982
|
get me() { pump(); return meNow(); },
|
|
930
983
|
each, get,
|
|
931
984
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
932
|
-
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(),dims,options,mine?.id),
|
|
985
|
+
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(atTickOf(options)),dims,options,mine?.id),
|
|
986
|
+
poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
|
|
933
987
|
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
934
988
|
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
935
989
|
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.46.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.46.6","sha256":"efd3ddbf72033f5d24b8107a41db2bc1a518f1d3f92963f5afd77c5c0e2f5059"});
|
package/worker/gate.mjs
CHANGED
|
@@ -36,9 +36,11 @@ function parseFrame(event) {
|
|
|
36
36
|
* as full as it can serve, and a room opens only the Gates its players fill.
|
|
37
37
|
* A studio chooses its room's size and nothing else. */
|
|
38
38
|
export const GATE_PLAYERS = 50;
|
|
39
|
-
|
|
39
|
+
/** `per`: players a Gate, for a trial that is finding the right number (`HOMIE_GATE_PLAYERS` on the Worker). A studio never sets it. */
|
|
40
|
+
export const gatePlayers = (env) => { const n = Number(env?.HOMIE_GATE_PLAYERS); return Number.isInteger(n) && n >= 4 && n <= 200 ? n : GATE_PLAYERS; };
|
|
41
|
+
export function roomLayout(seats, per = GATE_PLAYERS) {
|
|
40
42
|
// A room of up to 64 is its Table alone, as it has been since 0.45.0.
|
|
41
|
-
const gates = seats <= 64 ? 1 : Math.ceil(seats /
|
|
43
|
+
const gates = seats <= 64 ? 1 : Math.ceil(seats / per);
|
|
42
44
|
return { gates, concentrators: 0 };
|
|
43
45
|
}
|
|
44
46
|
/** The Gate a key lands on when the room cannot be asked: spread evenly, as 0.45.0 did. */
|
|
@@ -52,17 +54,17 @@ function hashedGate(key, gates) {
|
|
|
52
54
|
* Gate, not twenty with two or three players each, every one of them costing the room a channel. `held` is each
|
|
53
55
|
* Gate's players now and `promised` those sent to it who have not arrived yet.
|
|
54
56
|
*/
|
|
55
|
-
export function nextGate(gates, held, promised = new Map()) {
|
|
57
|
+
export function nextGate(gates, held, promised = new Map(), per = GATE_PLAYERS) {
|
|
56
58
|
let least = 0, fewest = Infinity;
|
|
57
59
|
for (let i = 0; i < gates; i++) {
|
|
58
60
|
const n = (held.get(i) ?? 0) + (promised.get(i) ?? 0);
|
|
59
|
-
if (n <
|
|
61
|
+
if (n < per) return i;
|
|
60
62
|
if (n < fewest) { fewest = n; least = i; }
|
|
61
63
|
}
|
|
62
64
|
return least;
|
|
63
65
|
}
|
|
64
66
|
export function gateFor(env, game, room, seats, key) {
|
|
65
|
-
const layout = roomLayout(seats);
|
|
67
|
+
const layout = roomLayout(seats, gatePlayers(env));
|
|
66
68
|
// One logical Gate is embedded in the Table for a small room. Same relay,
|
|
67
69
|
// admission and delivery protocol; no extra Durable Object or seat clamp.
|
|
68
70
|
const table = () => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
@@ -211,7 +213,9 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
211
213
|
const scheduleAdmission = () => {
|
|
212
214
|
if (!closed && admissions.length && attaching < 8 && out.buffered() < 512 * 1024 && admissionTimer === null) admissionTimer = setTimeout(pump, 1);
|
|
213
215
|
};
|
|
214
|
-
|
|
216
|
+
// What this Gate has sent the room: frames, the rows in them (a row is a player's message, mostly) and their size.
|
|
217
|
+
const heard = { frames: 0, rows: 0, bytes: 0 };
|
|
218
|
+
const transportFacts = () => ({ ...out.facts(), in: { ...heard }, admissions: admissions.length, attaching });
|
|
215
219
|
const shutdown = () => {
|
|
216
220
|
if (closed) return;
|
|
217
221
|
closed = true; out.close();
|
|
@@ -226,6 +230,7 @@ export function multiplexSession(socket, connect, { onMetrics = () => {}, delay
|
|
|
226
230
|
if (closed) return;
|
|
227
231
|
const rows = parseFrame(event);
|
|
228
232
|
if (!Array.isArray(rows)) throw new Error('invalid multiplex batch');
|
|
233
|
+
heard.frames += 1; heard.rows += rows.length; heard.bytes += event.data.length;
|
|
229
234
|
for (const [op, id, value, ip, agent] of expandBatch(rows)) {
|
|
230
235
|
if (op === 'metrics') { onMetrics(id, value); continue; }
|
|
231
236
|
if (op === 'open') {
|
|
@@ -291,26 +296,38 @@ export function acceptMultiplex(request, connect, options) {
|
|
|
291
296
|
* A socket to a slow player has no back-pressure a Worker can read, so a Gate
|
|
292
297
|
* that writes every tick to a link that carries fewer leaves that player
|
|
293
298
|
* seconds behind for good: every frame arrives, each one later than the last.
|
|
294
|
-
* A client names the newest tick it holds (`g`, on its inputs and pings).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
299
|
+
* A client names the newest tick it holds (`g`, on its inputs and pings).
|
|
300
|
+
*
|
|
301
|
+
* Being behind is not the reason to send less: a connection that stalls for a
|
|
302
|
+
* second and then delivers what it held catches up by itself, and sending it
|
|
303
|
+
* less only makes it worse (0.46.2 paced all 600 players of a healthy room
|
|
304
|
+
* when their one home link hiccuped). The reason is being behind and NOT
|
|
305
|
+
* catching up: more than a second behind on two reports half a second apart,
|
|
306
|
+
* the second not a quarter of a second nearer than the first. Then this
|
|
307
|
+
* player's rate halves, down to one a second, and halves again while that
|
|
308
|
+
* stays so. Within half a second of the room, the rate doubles back, a step a
|
|
309
|
+
* second. Each frame is a delta
|
|
310
|
+
* from the last one SENT, so a skipped tick costs nothing later, and the
|
|
311
|
+
* player's own body is in every frame. A client that names nothing is sent
|
|
312
|
+
* every tick, as before.
|
|
300
313
|
*/
|
|
301
314
|
export function deliveryPace(hz = 20) {
|
|
302
|
-
let interval = 1, sent = -Infinity,
|
|
315
|
+
let interval = 1, sent = -Infinity, changedAt = -Infinity, lag = null, behind = null;
|
|
303
316
|
const most = Math.max(1, Math.round(hz));
|
|
304
317
|
return {
|
|
305
318
|
/** The client holds tick `tick`; the room is at `world`. `now` in ms. */
|
|
306
319
|
report(tick, world, now) {
|
|
307
320
|
if (!Number.isFinite(tick) || !Number.isFinite(world)) return;
|
|
308
321
|
// A new room epoch restarts its ticks: a tick from the old one says nothing.
|
|
309
|
-
|
|
310
|
-
lag
|
|
311
|
-
if (lag
|
|
312
|
-
if (lag
|
|
313
|
-
|
|
322
|
+
lag = tick > world ? null : world - tick;
|
|
323
|
+
if (lag === null) { behind = null; return; }
|
|
324
|
+
if (lag <= hz / 2) { behind = null; if (interval > 1 && now - changedAt >= 1000) { interval = Math.max(1, interval / 2); changedAt = now; } return; }
|
|
325
|
+
if (lag <= hz) { behind = null; return; }
|
|
326
|
+
if (behind === null) { behind = { lag, at: now }; return; }
|
|
327
|
+
if (now - behind.at < 500) return;
|
|
328
|
+
// Catching up means a quarter of a second nearer in half a second; anything less is a link that cannot keep this rate.
|
|
329
|
+
if (lag > behind.lag - hz / 4 && now - changedAt >= 500 && interval < most) { interval = Math.min(most, interval * 2); changedAt = now; }
|
|
330
|
+
behind = { lag, at: now };
|
|
314
331
|
},
|
|
315
332
|
due(tick) { return tick < sent || tick - sent >= interval; },
|
|
316
333
|
sent(tick) { sent = tick; },
|
|
@@ -324,8 +341,19 @@ const COASTS = /^\{"t":"hello".{0,2000}?"coast":1[,}]/;
|
|
|
324
341
|
const percentile = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : null;
|
|
325
342
|
|
|
326
343
|
export class Gate {
|
|
327
|
-
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; }
|
|
328
|
-
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0 }; }
|
|
344
|
+
constructor(ctx, env) { this.ctx = ctx; this.env = env; this.clients = new Map(); this.admissions = new Map(); this.sequence = 0; this.link = null; this.opening = null; this.timing = stageTimings(); this.metricsAt = 0; this.window = this.freshWindow(); this.worldTick = null; this.behind = new Set(); this.idle = null; }
|
|
345
|
+
freshWindow() { return { worlds: 0, frames: 0, bytes: 0, skipped: 0, inputs: 0, worldAge: [], tickFrom: null, tickFromAt: 0, tickAt: 0, gapMax: 0, gaps: [0, 0, 0], late: 0, busy: 0 }; }
|
|
346
|
+
/** This Gate's own lateness: a timer every 20 ms, and how long past it each one came. A Gate that is out of time is late to its own timer. */
|
|
347
|
+
watchLoop() {
|
|
348
|
+
if (this.loopTimer) return;
|
|
349
|
+
let due = Date.now() + 20;
|
|
350
|
+
this.loopTimer = setInterval(() => {
|
|
351
|
+
const now = Date.now(), late = Math.max(0, now - due); due = now + 20;
|
|
352
|
+
this.window.late = Math.max(this.window.late, late); if (late > 20) this.window.busy += 1;
|
|
353
|
+
if (!this.link || !this.clients.size) { clearInterval(this.loopTimer); this.loopTimer = null; }
|
|
354
|
+
}, 20);
|
|
355
|
+
this.loopTimer?.unref?.();
|
|
356
|
+
}
|
|
329
357
|
/** `[sent, received, answered]`: this Gate's clock when its frame left, the room's when it read it and when it answered. */
|
|
330
358
|
roomClock([sent, received, answered]) {
|
|
331
359
|
const now = Date.now(), offset = ((received - sent) + (answered - now)) / 2, delay = (now - sent) - (answered - received);
|
|
@@ -380,6 +408,13 @@ export class Gate {
|
|
|
380
408
|
} catch (error) { lost({ error, reason: 'invalid room frame' }); try { socket.close(1011, 'room link failed'); } catch {} }
|
|
381
409
|
});
|
|
382
410
|
socket.addEventListener('close', lost); socket.addEventListener('error', lost);
|
|
411
|
+
// A Gate nobody is on gives its channel back after ten seconds: an open channel keeps the room and this Gate in
|
|
412
|
+
// memory (and billed) with nothing to carry. The next player to arrive opens another, as the first did.
|
|
413
|
+
this.rest = () => {
|
|
414
|
+
if (this.link !== link || this.clients.size) return;
|
|
415
|
+
this.link = null; this.worlds = null; link.close();
|
|
416
|
+
try { socket.close(1000, 'no players'); } catch { /* gone */ }
|
|
417
|
+
};
|
|
383
418
|
this.link = link; return link;
|
|
384
419
|
})();
|
|
385
420
|
try { return await this.opening; } finally { this.opening = null; }
|
|
@@ -388,7 +423,7 @@ export class Gate {
|
|
|
388
423
|
deliver(snap, settings, hz, drop) {
|
|
389
424
|
const w = this.window, now = Date.now(); w.worlds++;
|
|
390
425
|
if (w.tickFrom === null) { w.tickFrom = this.worldTick ?? snap.k; w.tickFromAt = this.worldAt ?? now; }
|
|
391
|
-
if (this.worldAt) w.gapMax = Math.max(w.gapMax,
|
|
426
|
+
if (this.worldAt) { const gap = now - this.worldAt; w.gapMax = Math.max(w.gapMax, gap); if (gap > 75) w.gaps[0] += 1; if (gap > 150) w.gaps[1] += 1; if (gap > 300) w.gaps[2] += 1; }
|
|
392
427
|
this.worldTick = snap.k; this.worldAt = now; w.tickAt = now;
|
|
393
428
|
// By this Gate's clock, corrected for what the room's clock is measured to differ by.
|
|
394
429
|
if (Number.isFinite(snap.st)) w.worldAge.push(Math.max(0, now - snap.st + (this.clock?.offsetMs ?? 0)));
|
|
@@ -431,7 +466,7 @@ export class Gate {
|
|
|
431
466
|
async connect(request, server) {
|
|
432
467
|
const link = await this.upstream(new URL(request.url));
|
|
433
468
|
const id = String(++this.sequence);
|
|
434
|
-
this.clients.set(id, server);
|
|
469
|
+
this.clients.set(id, server); clearTimeout(this.idle); this.idle = null; this.watchLoop();
|
|
435
470
|
let opened = false;
|
|
436
471
|
const deadline = setTimeout(() => {
|
|
437
472
|
this.admissions.delete(id);
|
|
@@ -458,12 +493,18 @@ export class Gate {
|
|
|
458
493
|
}
|
|
459
494
|
const now = Date.now();
|
|
460
495
|
const said = REPORTED_TICK.exec(event.data);
|
|
461
|
-
if (said && server.pace && this.worldTick !== null)
|
|
496
|
+
if (said && server.pace && this.worldTick !== null) {
|
|
497
|
+
server.pace.report(Number(said[1]), this.worldTick, now);
|
|
498
|
+
// Counted, never acted on: players more than a second behind at this moment. Many at once on one Gate is a
|
|
499
|
+
// connection they share or something upstream of them all; each is still paced by their own reports alone
|
|
500
|
+
// (a full shared link is cured by sending each of them less, and nothing else cures it).
|
|
501
|
+
if ((server.pace.facts().lag ?? 0) > 20) this.behind.add(id); else this.behind.delete(id);
|
|
502
|
+
}
|
|
462
503
|
this.window.inputs++;
|
|
463
504
|
// Only the public Gate stamps ingress; a player cannot supply it inside their JSON.
|
|
464
505
|
link.send(['data', id, event.data, Number.isFinite(event.receivedAt) ? event.receivedAt : now]);
|
|
465
506
|
});
|
|
466
|
-
const left = () => { clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); };
|
|
507
|
+
const left = () => { this.behind.delete(id); clearTimeout(deadline); this.admissions.delete(id); if (this.clients.delete(id) && opened) link.send(['close', id]); if (!this.clients.size && !this.idle) { this.idle = setTimeout(() => { this.idle = null; this.rest?.(); }, 10_000); this.idle?.unref?.(); } };
|
|
467
508
|
server.addEventListener('close', left); server.addEventListener('error', left);
|
|
468
509
|
}
|
|
469
510
|
/** What this Gate did in the last second, for whoever is measuring the room. */
|
|
@@ -480,15 +521,21 @@ export class Gate {
|
|
|
480
521
|
bytesPerSecond: Math.round(w.bytes / seconds), inputsPerSecond: Math.round(w.inputs / seconds), skipped: w.skipped,
|
|
481
522
|
// Room stamp to this Gate writing it to players, ms (both are Cloudflare's clock at the start of an event).
|
|
482
523
|
worldAgeMs: { p50: percentile(ages, 0.5), p95: percentile(ages, 0.95), max: ages.at(-1) ?? null },
|
|
524
|
+
// Of this second's worlds: how many were over 100, 200 and 400 ms old when this Gate wrote them, and how many came
|
|
525
|
+
// more than 75, 150 and 300 ms after the one before (a world is due every 50).
|
|
526
|
+
worldsOld: { n: ages.length, over100: ages.filter(a => a > 100).length, over200: ages.filter(a => a > 200).length, over400: ages.filter(a => a > 400).length },
|
|
527
|
+
worldGaps: { over75: w.gaps[0], over150: w.gaps[1], over300: w.gaps[2] },
|
|
483
528
|
// How far behind the room players say they are, in ticks, and how many are being sent fewer frames for it.
|
|
484
529
|
clientLagTicks: { reporting: lags.length, p50: percentile(lags, 0.5), p95: percentile(lags, 0.95), max: lags.at(-1) ?? null },
|
|
485
|
-
paced: paces.filter(p => p.interval > 1).length,
|
|
530
|
+
paced: paces.filter(p => p.interval > 1).length, behind: this.behind.size,
|
|
486
531
|
coasting: subscribed.filter(c => c.coasts).length,
|
|
487
532
|
// Ticks the room's worlds advanced a second, by THIS Gate's clock: the room's own clock cannot see itself run late.
|
|
488
533
|
roomTickHz: w.tickFrom !== null && w.tickAt > w.tickFromAt ? Math.round((this.worldTick - w.tickFrom) / (w.tickAt - w.tickFromAt) * 100000) / 100 : null,
|
|
489
534
|
worldGapMs: w.gapMax,
|
|
490
535
|
// The room's clock less this Gate's, and the channel's delay, from stamps exchanged on the channel (as NTP does).
|
|
491
536
|
roomClock: this.clock ?? null,
|
|
537
|
+
// The worst this Gate's own 20 ms timer came late in the second, and how many of the fifty came over 20 ms late.
|
|
538
|
+
loopLateMs: w.late, loopLateTimers: w.busy,
|
|
492
539
|
};
|
|
493
540
|
}
|
|
494
541
|
publishMetrics(url, link) {
|