@homie-rocks/studio 0.35.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/agents/agents.ts +8 -6
- package/bin/homie-studio.mjs +9 -6
- package/lab/lab.ts +15 -0
- package/lib/build.mjs +20 -15
- package/lib/dev.mjs +16 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +21 -7
- package/lib/mcp-tools.mjs +2 -1
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +6 -2
- package/lib/template-history.json +44 -1
- package/lib/typecheck.mjs +52 -3
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +3 -2
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/standalone/shell/shell.js +8 -6
- package/worker/brain.mjs +2 -1
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +7 -11
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +4 -2
- package/worker/room.mjs +136 -63
- package/worker/version.mjs +1 -1
package/netplay/netplay.ts
CHANGED
|
@@ -88,9 +88,9 @@
|
|
|
88
88
|
|
|
89
89
|
export const NETPLAY_VERSION = 1;
|
|
90
90
|
/** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
|
|
91
|
-
export const NETPLAY_REVISION =
|
|
91
|
+
export const NETPLAY_REVISION = 11;
|
|
92
92
|
/** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
|
|
93
|
-
export const NETPLAY_MARK = 'homie-netplay-rev:
|
|
93
|
+
export const NETPLAY_MARK = 'homie-netplay-rev:11';
|
|
94
94
|
|
|
95
95
|
export type Role = 'host' | 'replica' | 'screen';
|
|
96
96
|
export type Device = 'phone' | 'desk' | 'tv';
|
|
@@ -571,6 +571,35 @@ export interface Probes {
|
|
|
571
571
|
[extra: string]: unknown;
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
/** The guarded rules module supplies this adapter lazily; the vendorable helper imports no runtime. */
|
|
575
|
+
export interface RulesHost {
|
|
576
|
+
frame(m: Record<string, unknown>): void;
|
|
577
|
+
sync(peers: Peer[]): void;
|
|
578
|
+
/** The round, the roster, the shared state and the capabilities again, as output: a relay that restarted has none of them. */
|
|
579
|
+
announce(): void;
|
|
580
|
+
pause(): void;
|
|
581
|
+
resume(): void;
|
|
582
|
+
start(): void;
|
|
583
|
+
stop(): void;
|
|
584
|
+
save(): unknown;
|
|
585
|
+
readonly tick: number;
|
|
586
|
+
facts(): Record<string, unknown>;
|
|
587
|
+
}
|
|
588
|
+
export type RulesHostFactory = (o: { now(): number; send(m: Record<string, unknown>): void; restore: unknown; peers?: Peer[]; held?: Peer[]; policy?: Policy; onEnd(why: string): void }) => RulesHost;
|
|
589
|
+
|
|
590
|
+
export interface RulesOutputSender {
|
|
591
|
+
push(frame: Record<string, unknown>): void;
|
|
592
|
+
finish(frame: Record<string, unknown>): void;
|
|
593
|
+
/** `yield`, in order behind the checkpoint; what the rules make after it is held until `release()` or `stop()`. */
|
|
594
|
+
handOver(frame: Record<string, unknown>): void;
|
|
595
|
+
readonly handing: boolean;
|
|
596
|
+
readonly waiting: boolean;
|
|
597
|
+
release(): void;
|
|
598
|
+
/** The page is going: what the allowance permits, then the last checkpoint, always. */
|
|
599
|
+
leave(): void;
|
|
600
|
+
stop(): void;
|
|
601
|
+
}
|
|
602
|
+
|
|
574
603
|
export interface NetplayOptions<C = unknown> {
|
|
575
604
|
/** Defaults to `window.HOMIE_NET`. `null` forces offline. */
|
|
576
605
|
config?: NetConfig | null;
|
|
@@ -582,11 +611,15 @@ export interface NetplayOptions<C = unknown> {
|
|
|
582
611
|
/** May this browser be elected host? Default true. */
|
|
583
612
|
canHost?: boolean;
|
|
584
613
|
/**
|
|
585
|
-
* Revision 10: this game is written as rules plus view
|
|
586
|
-
*
|
|
614
|
+
* Revision 10: this game is written as rules plus view (NETPLAY.md section 29). Its elected browser or server
|
|
615
|
+
* runs the same host runtime. The view sends input with `net.steps` and reads the control table as rows of
|
|
587
616
|
* `[seat, r, ack, lead]`. Set by the view library (`openRoom`), not by a game.
|
|
588
617
|
*/
|
|
589
618
|
rules?: boolean;
|
|
619
|
+
/** Shared relay limits supplied by the rules view; legacy vendor copies stay dependency-free. */
|
|
620
|
+
rulesLimits?: { bytes: Record<string, number>; rates: Record<string, number>; output: (opts: { rates: Record<string, number>; send: (frame: Record<string, unknown>) => void; now: () => number }) => RulesOutputSender };
|
|
621
|
+
/** Set by the rules view's build. Server games load this only once playable, or when starting offline. */
|
|
622
|
+
rulesHost?: { mode: 'server' | 'browser'; offline: boolean; load?: () => Promise<RulesHostFactory> };
|
|
590
623
|
/** Game id, for the relay's logs. */
|
|
591
624
|
game?: string;
|
|
592
625
|
/** Seats in a room (the manifest's players.max). The relay takes it from the first visitor of an empty room. */
|
|
@@ -747,6 +780,7 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
747
780
|
readonly seat: number | null;
|
|
748
781
|
readonly spectator: boolean;
|
|
749
782
|
readonly offline: boolean;
|
|
783
|
+
readonly rulesHosting: boolean;
|
|
750
784
|
readonly connected: boolean;
|
|
751
785
|
/** Why the client stopped for good (a refusal reconnecting would repeat: 'replaced', 'room-full', ...), else null. */
|
|
752
786
|
readonly closedWhy: string | null;
|
|
@@ -1293,7 +1327,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
|
|
|
1293
1327
|
* second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
|
|
1294
1328
|
* After one it comes back (its seat token keeps its body), sending at the slow rate below.
|
|
1295
1329
|
*/
|
|
1296
|
-
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported', 'stale']);
|
|
1330
|
+
const FINAL_ERRORS = new Set(['room-over', 'host-failed', 'replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off', 'agent-pass', 'agents-off', 'agents-unsupported', 'stale']);
|
|
1297
1331
|
/** An AI closed for being alone in a room (`agents-alone`, section 17) waits this long before it knocks again. */
|
|
1298
1332
|
const AGENTS_ALONE_WAIT_MS = 30_000;
|
|
1299
1333
|
/**
|
|
@@ -1469,8 +1503,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1469
1503
|
const checkpointMs = Math.max(250, opts.checkpointMs ?? 1000);
|
|
1470
1504
|
const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
|
|
1471
1505
|
const connectOpenMaxMs = Math.max(2000, Math.min(120_000, Number(opts.connectOpenMaxMs) || CONNECT_OPEN_MAX_MS));
|
|
1472
|
-
const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || RECONNECT_MAX_MS));
|
|
1473
|
-
const heartbeatMs = opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
|
|
1506
|
+
const reconnectMaxMs = Math.max(5000, Math.min(300_000, Number(opts.reconnectMaxMs) || (opts.rules && (opts.rulesHost?.offline || opts.rulesHost?.mode === 'browser') ? 5000 : RECONNECT_MAX_MS)));
|
|
1507
|
+
const heartbeatMs = opts.rules === true || opts.heartbeatMs === 0 ? 0 : Math.max(200, Math.min(2000, Number(opts.heartbeatMs) || HEARTBEAT_MS));
|
|
1474
1508
|
const staleMs = Math.max(2500, opts.staleMs ?? 6000);
|
|
1475
1509
|
// Revisions (section 23): the game's revision (the page's word, or the game's own) and what this build can do.
|
|
1476
1510
|
const version = cleanVersion(opts.version ?? cfg?.ver);
|
|
@@ -1480,7 +1514,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1480
1514
|
const watching = Boolean(opts.watch ?? cfg?.watch);
|
|
1481
1515
|
const want: Want = watching ? 'screen' : (opts.want ?? cfg?.want ?? 'play');
|
|
1482
1516
|
const rulesGame = opts.rules === true;
|
|
1483
|
-
|
|
1517
|
+
let canHost = rulesGame ? opts.rulesHost?.mode === 'browser' : opts.canHost ?? true;
|
|
1484
1518
|
const defaultOwn = (opts.movement ?? 'owner') === 'owner';
|
|
1485
1519
|
|
|
1486
1520
|
// Where parent notifications go: the shell page around a framed game.
|
|
@@ -1508,6 +1542,143 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1508
1542
|
};
|
|
1509
1543
|
|
|
1510
1544
|
// ---------------------------------------------------------------- state
|
|
1545
|
+
let rulesHost: RulesHost | null = null;
|
|
1546
|
+
let hostLoad: Promise<RulesHostFactory> | null = null;
|
|
1547
|
+
let hostGeneration = 0;
|
|
1548
|
+
let pendingRules: Record<string, unknown>[] = [];
|
|
1549
|
+
let disposed = false;
|
|
1550
|
+
let onlineRulesSave: unknown = null;
|
|
1551
|
+
const canOffline = !rulesGame || Boolean(opts.rulesHost?.offline || canHost);
|
|
1552
|
+
function loadHost(): Promise<RulesHostFactory> | null {
|
|
1553
|
+
if (!opts.rulesHost?.load) return null;
|
|
1554
|
+
return hostLoad ??= Promise.resolve().then(() => opts.rulesHost!.load!()).catch((error) => { hostLoad = null; throw error; });
|
|
1555
|
+
}
|
|
1556
|
+
let rulesSender: RulesOutputSender | null = null;
|
|
1557
|
+
let yieldTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1558
|
+
function stopRules(): void { if (yieldTimer !== null) { clearTimeout(yieldTimer); yieldTimer = null; } rulesSender?.stop(); rulesSender = null; hostGeneration += 1; rulesHost?.stop(); rulesHost = null; pendingRules = []; }
|
|
1559
|
+
/**
|
|
1560
|
+
* Checkpoint, then yield: one ordered act. A rules host's yield follows its checkpoint through the sender, and what
|
|
1561
|
+
* its rules make afterwards is held until the relay answers (a `role` to somebody else, or `host` with
|
|
1562
|
+
* `why: 'host-kept'`), so no page is shown a tick the next host will run again.
|
|
1563
|
+
*/
|
|
1564
|
+
function yieldHost(extra: Record<string, unknown> = {}): boolean {
|
|
1565
|
+
sendCheckpoint();
|
|
1566
|
+
if (!rulesHost || !rulesSender) return raw({ t: 'yield', ...extra });
|
|
1567
|
+
rulesSender.handOver(extra);
|
|
1568
|
+
// An answer that never comes (a relay that does not know the frame) must not hold the room's output for good.
|
|
1569
|
+
if (yieldTimer === null) yieldTimer = setTimeout(yieldAnswered, 2000);
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1572
|
+
function yieldAnswered(): void {
|
|
1573
|
+
if (yieldTimer !== null) { clearTimeout(yieldTimer); yieldTimer = null; }
|
|
1574
|
+
rulesPace = null;
|
|
1575
|
+
rulesSender?.release();
|
|
1576
|
+
// A second yield was queued behind what was held: it waits for its own answer.
|
|
1577
|
+
if (rulesSender?.waiting) yieldTimer = setTimeout(yieldAnswered, 2000);
|
|
1578
|
+
}
|
|
1579
|
+
function rulesFrame(m: Record<string, unknown>): boolean {
|
|
1580
|
+
if (!rulesGame || role !== 'host' || offline) return false;
|
|
1581
|
+
if (rulesHost) rulesHost.frame(m);
|
|
1582
|
+
else if (pendingRules.length < 256) pendingRules.push(m);
|
|
1583
|
+
return true;
|
|
1584
|
+
}
|
|
1585
|
+
function rulesPeers(): Peer[] {
|
|
1586
|
+
if (!offline) return [...peers.values()];
|
|
1587
|
+
return seat === null ? [] : [{ id: 'offline', seat, name: name || 'You', colour: seat % 12, device, want: 'play', role: 'host', occ: 1 }];
|
|
1588
|
+
}
|
|
1589
|
+
function rulesNotice(text: string): void {
|
|
1590
|
+
api.line(text);
|
|
1591
|
+
setTimeout(() => { if (!disposed && gameLine === text) api.line(null); }, 8000);
|
|
1592
|
+
}
|
|
1593
|
+
function unavailableRules(): void {
|
|
1594
|
+
canHost = false;
|
|
1595
|
+
stopRules(); setRole('replica', 'rules-unavailable');
|
|
1596
|
+
if (!offline && ws) { raw({ t: 'bye' }); lost(ws, 'rules-unavailable'); }
|
|
1597
|
+
api.line('This game needs a connection.');
|
|
1598
|
+
}
|
|
1599
|
+
function endRules(why: string, message = `This game's rules stopped: ${why}`): void {
|
|
1600
|
+
if (offline && cfg?.url) {
|
|
1601
|
+
stopRules(); setRole('replica', 'offline-rules-ended');
|
|
1602
|
+
api.line(`Offline rules stopped: ${why}. This game needs a connection.`);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const sender = rulesSender; rulesSender = null;
|
|
1606
|
+
if (sender) sender.finish({ t: 'rules-end', why }); else raw({ t: 'rules-end', why, rules: true });
|
|
1607
|
+
stopRules(); setRole('replica', 'rules-ended');
|
|
1608
|
+
closed = true; api.line(message); setLink('closed', 'rules-ended');
|
|
1609
|
+
}
|
|
1610
|
+
function startRules(restore: unknown): void {
|
|
1611
|
+
stopRules();
|
|
1612
|
+
const generation = hostGeneration;
|
|
1613
|
+
const loading = loadHost();
|
|
1614
|
+
if (!loading) { unavailableRules(); return; }
|
|
1615
|
+
void loading.then((make) => {
|
|
1616
|
+
if (disposed || generation !== hostGeneration || role !== 'host') return;
|
|
1617
|
+
try {
|
|
1618
|
+
rebase = true;
|
|
1619
|
+
rulesPace = null;
|
|
1620
|
+
if (!opts.rulesLimits) throw new Error('Rules output limits are missing');
|
|
1621
|
+
rulesSender = opts.rulesLimits.output({ rates: opts.rulesLimits.rates, send: raw, now: () => performance.now() });
|
|
1622
|
+
const options = { now: () => performance.now(), restore, peers: rulesPeers(), held: offline ? [] : heldPeers, policy: offline ? DEFAULT_POLICY : policy, send: rulesOut, onEnd: (why: string) => endRules(why) };
|
|
1623
|
+
try { rulesHost = make(options); }
|
|
1624
|
+
catch (error) {
|
|
1625
|
+
if (!restore) throw error;
|
|
1626
|
+
rulesHost = make({ ...options, restore: null });
|
|
1627
|
+
rulesNotice('The saved round could not be restored. A new round has started.');
|
|
1628
|
+
}
|
|
1629
|
+
rulesHost.frame({ t: 'policy', policy: offline ? DEFAULT_POLICY : policy });
|
|
1630
|
+
rulesHost.sync(rulesPeers());
|
|
1631
|
+
for (const m of pendingRules) rulesHost.frame(m);
|
|
1632
|
+
pendingRules = [];
|
|
1633
|
+
rulesHost.start();
|
|
1634
|
+
if (offline && cfg?.url && !closed) { if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = setTimeout(open, 0); }
|
|
1635
|
+
if (hidden()) rulesHost.pause();
|
|
1636
|
+
sendCheckpoint(); paintLink();
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
if (generation === hostGeneration && !disposed) { endRules('fault', 'The game rules could not start. Reload to try again.'); console.warn('[netplay] rules startup failed', error); }
|
|
1639
|
+
}
|
|
1640
|
+
}, (error) => {
|
|
1641
|
+
if (generation === hostGeneration && !disposed) { unavailableRules(); console.warn('[netplay] rules module failed to load', error); }
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
/** A local host sees its own output too. No offline frame is ever sent or saved to the relay. */
|
|
1645
|
+
let rulesPace: { at: number; tick: number } | null = null;
|
|
1646
|
+
let rulesWireStamp = -Infinity;
|
|
1647
|
+
let rulesSimStamp = 0;
|
|
1648
|
+
let rulesFrameAt = 0;
|
|
1649
|
+
let rulesWireOffset: number | null = null;
|
|
1650
|
+
let rulesRound: Record<string, unknown> | null = null;
|
|
1651
|
+
function rulesOut(m: Record<string, unknown>): void {
|
|
1652
|
+
const offset = now() - performance.now();
|
|
1653
|
+
let slowYield: Record<string, unknown> | null = null;
|
|
1654
|
+
if (m.t === 'snap') {
|
|
1655
|
+
const at = performance.now();
|
|
1656
|
+
if (!rulesPace) rulesPace = { at, tick: Number(m.k) };
|
|
1657
|
+
else if (at - rulesPace.at >= 5000) {
|
|
1658
|
+
const { at: paceAt, tick: paceTick } = rulesPace;
|
|
1659
|
+
const slow = (Number(m.k) - rulesPace.tick) * 1000 < (at - rulesPace.at) * (opts.snapshotHz ?? 20) / 2;
|
|
1660
|
+
rulesPace = { at, tick: Number(m.k) };
|
|
1661
|
+
if (slow && !offline && peers.size > 1 && !hidden()) slowYield = { slow: true, speed: (Number(m.k) - paceTick) * 1000 / ((at - paceAt) * (opts.snapshotHz ?? 20)) };
|
|
1662
|
+
}
|
|
1663
|
+
rulesWireStamp = Math.max(rulesWireStamp + Math.max(1, Number(m.st) - rulesSimStamp), Math.round(Number(m.st) + offset));
|
|
1664
|
+
rulesSimStamp = Number(m.st); rulesFrameAt = performance.now();
|
|
1665
|
+
m = { ...m, st: rulesWireStamp };
|
|
1666
|
+
if (rulesWireOffset !== null && Math.abs(offset - rulesWireOffset) > 50 && rulesRound) { const { results, ...round } = rulesRound.round as RoundInfo; rulesOut({ ...rulesRound, round, retime: true }); }
|
|
1667
|
+
rulesWireOffset = offset;
|
|
1668
|
+
}
|
|
1669
|
+
if (m.t === 'round') rulesRound = m;
|
|
1670
|
+
if (m.t === 'round') { const r = m.round as RoundInfo; m = { ...m, round: { ...r, startedAt: Math.round(r.startedAt + offset), endsAt: Math.round(r.endsAt + offset) } }; }
|
|
1671
|
+
|
|
1672
|
+
if (!offline && connected) rulesSender?.push(m);
|
|
1673
|
+
// A slow host yields after the snapshot of the tick its checkpoint saves, so nothing of that tick follows the yield.
|
|
1674
|
+
if (slowYield) yieldHost(slowYield);
|
|
1675
|
+
if (!offline && connected && ['ev', 'roster', 'round', 'state'].includes(String(m.t))) return;
|
|
1676
|
+
if (m.t === 'snap') { tick = Number(m.k); onSnap(m, 0, true); }
|
|
1677
|
+
else if (m.t === 'state' && typeof m.k === 'string') applyState(m.k, m.d);
|
|
1678
|
+
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 }); } }
|
|
1679
|
+
else if (m.t === 'roster' && (offline || !connected)) { slots = m.slots as Slot[]; emit('roster', slots); post?.({ what: 'roster', slots }); }
|
|
1680
|
+
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 });
|
|
1681
|
+
}
|
|
1511
1682
|
let role: Role = 'replica';
|
|
1512
1683
|
let roleKnown = false;
|
|
1513
1684
|
let seat: number | null = null;
|
|
@@ -1529,8 +1700,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1529
1700
|
let staleVer: string | null = null;
|
|
1530
1701
|
let promotions = 0;
|
|
1531
1702
|
let lastMsgAt = 0;
|
|
1703
|
+
let socketStartedAt = 0;
|
|
1532
1704
|
let waitingVisible = false;
|
|
1533
1705
|
const peers = new Map<string, Peer>();
|
|
1706
|
+
let heldPeers: Peer[] = [];
|
|
1534
1707
|
let roundInfo: RoundInfo | null = null;
|
|
1535
1708
|
let slots: Slot[] | null = null;
|
|
1536
1709
|
let lastCkpt: Checkpoint<C> | null = null;
|
|
@@ -1611,6 +1784,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1611
1784
|
arrived.by = by;
|
|
1612
1785
|
arrived.playableMs = Math.round(wall() - createdAt);
|
|
1613
1786
|
post?.({ what: 'playable', by });
|
|
1787
|
+
if (rulesGame && canOffline && !canHost) void loadHost()?.catch(() => {});
|
|
1614
1788
|
}
|
|
1615
1789
|
/**
|
|
1616
1790
|
* The game's own `net.playable()`. With `arrival: 'auto'` the helper may already have given the game the screen
|
|
@@ -1631,7 +1805,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1631
1805
|
/** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
|
|
1632
1806
|
function autoPlayable(): void {
|
|
1633
1807
|
if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
|
|
1634
|
-
if (role !== 'host' && !snapSeen) return;
|
|
1808
|
+
if ((rulesGame || role !== 'host') && !snapSeen) return;
|
|
1635
1809
|
autoArmed = true;
|
|
1636
1810
|
const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame;
|
|
1637
1811
|
const later = (fn: () => void): void => { if (typeof raf === 'function') raf(fn); else setTimeout(fn, 16); };
|
|
@@ -1744,7 +1918,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1744
1918
|
/** Why this page plays alone, and the last refusal that was not final (`room-stale`): the line says the true one. */
|
|
1745
1919
|
let aloneWhy = '';
|
|
1746
1920
|
let lastRefusal = '';
|
|
1747
|
-
type LineKind = 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
|
|
1921
|
+
type LineKind = 'offline' | 'reconnecting' | 'alone' | 'stale' | 'closed' | 'full' | 'seat';
|
|
1748
1922
|
function showOverlay(kind: LineKind, text: string, forMs = 0): void {
|
|
1749
1923
|
const el = overlayEl();
|
|
1750
1924
|
if (!el) return;
|
|
@@ -1756,6 +1930,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1756
1930
|
}
|
|
1757
1931
|
/** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
|
|
1758
1932
|
function aloneText(): string {
|
|
1933
|
+
if (!canOffline) return 'This game needs a connection.';
|
|
1934
|
+
if (rulesGame && !rulesHost) return 'Loading offline play…';
|
|
1759
1935
|
if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
|
|
1760
1936
|
if (lastRefusal === 'room-stale') return 'Playing on your own · this room opens when its players have the new version';
|
|
1761
1937
|
return 'Playing on your own · still looking for the room…';
|
|
@@ -1771,11 +1947,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1771
1947
|
function paintLink(): void {
|
|
1772
1948
|
if (!overlayOn) return;
|
|
1773
1949
|
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null; }
|
|
1774
|
-
const closedSays = link === 'closed' ? closedText() : null;
|
|
1950
|
+
const closedSays = link === 'closed' ? gameLine || closedText() : null;
|
|
1775
1951
|
if (link === 'reconnecting') {
|
|
1776
1952
|
// A blip shorter than a blink is never drawn.
|
|
1777
1953
|
overlayTimer = setTimeout(() => { overlayTimer = null; if (link === 'reconnecting') showOverlay('reconnecting', 'Reconnecting…'); }, LINK_OVERLAY_MS);
|
|
1778
|
-
} else if (link === '
|
|
1954
|
+
} else if (link === 'offline' && !canOffline) showOverlay('offline', 'This game needs a connection.');
|
|
1955
|
+
else if (link === 'alone') showOverlay('alone', gameLine || aloneText());
|
|
1779
1956
|
else if (link === 'closed' && closedWhy === 'stale') showOverlay('stale', inApp ? APP_STALE_LINE : 'This game was updated. Tap to reload.');
|
|
1780
1957
|
else if (closedSays) showOverlay('closed', closedSays);
|
|
1781
1958
|
// In the room: the game's own sentence about where this player stands, else that the room is full.
|
|
@@ -2147,6 +2324,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2147
2324
|
}
|
|
2148
2325
|
post?.({ what: 'role', role: next, prev, why, seat });
|
|
2149
2326
|
if (next === 'host') { capsSent = ''; sendCaps(); }
|
|
2327
|
+
if (rulesGame) { if (next === 'host') startRules(extra.ckpt?.d ?? null); else stopRules(); }
|
|
2150
2328
|
resolveReady(e);
|
|
2151
2329
|
emit('role', e);
|
|
2152
2330
|
resolveView('seat');
|
|
@@ -2171,8 +2349,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2171
2349
|
switch (m['t']) {
|
|
2172
2350
|
case 'welcome': return onWelcome(m);
|
|
2173
2351
|
case 'snap': return onSnap(m, text.length);
|
|
2174
|
-
case '
|
|
2352
|
+
case 'free': if (![...peers.values()].some((p) => p.seat === m.seat)) rulesFrame(m); return;
|
|
2353
|
+
case 'in': if (rulesFrame(m)) return; return onInput(m);
|
|
2175
2354
|
case 'ev': {
|
|
2355
|
+
if (m.hosted !== true && rulesFrame(m)) return;
|
|
2176
2356
|
const k = String(m['k'] ?? '');
|
|
2177
2357
|
const from = typeof m['from'] === 'number' ? m['from'] : null;
|
|
2178
2358
|
// Quiet AI (section 17): this browser does not hear an AI's speech, its own or relayed by the host (section 18:
|
|
@@ -2182,6 +2362,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2182
2362
|
return;
|
|
2183
2363
|
}
|
|
2184
2364
|
case 'policy': {
|
|
2365
|
+
rulesFrame(m);
|
|
2185
2366
|
if (m['policy'] && typeof m['policy'] === 'object') { policy = readPolicy(m['policy']); emit('policy', policy); post?.({ what: 'policy', policy }); }
|
|
2186
2367
|
return;
|
|
2187
2368
|
}
|
|
@@ -2229,11 +2410,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2229
2410
|
return;
|
|
2230
2411
|
}
|
|
2231
2412
|
case 'join': {
|
|
2413
|
+
rulesFrame(m);
|
|
2232
2414
|
const p = m['peer'] as Peer | undefined;
|
|
2233
2415
|
if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); if (watching) resolveView('auto'); }
|
|
2234
2416
|
return;
|
|
2235
2417
|
}
|
|
2236
2418
|
case 'leave': {
|
|
2419
|
+
rulesFrame(m);
|
|
2237
2420
|
const pid = String(m['id'] ?? '');
|
|
2238
2421
|
const p = peers.get(pid);
|
|
2239
2422
|
peers.delete(pid);
|
|
@@ -2244,7 +2427,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2244
2427
|
return;
|
|
2245
2428
|
}
|
|
2246
2429
|
case 'round': {
|
|
2247
|
-
|
|
2430
|
+
const incoming = m['round'] as RoundInfo;
|
|
2431
|
+
roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming;
|
|
2432
|
+
if (m.retime) return;
|
|
2248
2433
|
post?.({ what: 'round', round: roundInfo });
|
|
2249
2434
|
emit('round', roundInfo);
|
|
2250
2435
|
return;
|
|
@@ -2256,8 +2441,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2256
2441
|
return;
|
|
2257
2442
|
}
|
|
2258
2443
|
case 'host': {
|
|
2444
|
+
// The relay kept this page as the host after its yield: what the rules made meanwhile goes out.
|
|
2445
|
+
if (m['why'] === 'host-kept') yieldAnswered();
|
|
2259
2446
|
host = (m['host'] as HostRef | null) ?? null;
|
|
2260
|
-
rebase = true; // the next snapshot is on the new host's timeline
|
|
2447
|
+
if (m['why'] !== 'host-kept') rebase = true; // the next snapshot is on the new host's timeline
|
|
2261
2448
|
for (const p of peers.values()) p.role = host && p.id === host.id ? 'host' : (p.seat === null ? 'screen' : 'replica');
|
|
2262
2449
|
notifyStats(true);
|
|
2263
2450
|
return;
|
|
@@ -2275,6 +2462,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2275
2462
|
return;
|
|
2276
2463
|
}
|
|
2277
2464
|
case 'role': {
|
|
2465
|
+
heldPeers = Array.isArray(m.held) ? m.held as Peer[] : [];
|
|
2278
2466
|
const next = m['role'] as Role;
|
|
2279
2467
|
host = (m['host'] as HostRef | null) ?? host;
|
|
2280
2468
|
rebase = true;
|
|
@@ -2282,7 +2470,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2282
2470
|
if (m['round']) roundInfo = m['round'] as RoundInfo;
|
|
2283
2471
|
if (m['roster']) slots = m['roster'] as Slot[];
|
|
2284
2472
|
if (m['state']) replaceState(m['state']);
|
|
2285
|
-
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
|
|
2473
|
+
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? (next === 'host' && !m['snap'] && onlineRulesSave ? { k: tick, st: now(), d: onlineRulesSave as C } : null);
|
|
2474
|
+
onlineRulesSave = null;
|
|
2286
2475
|
let snap = (m['snap'] as Snapshot<S> | null) ?? null;
|
|
2287
2476
|
if (next === 'host' && !snap) snap = localSnap();
|
|
2288
2477
|
if (ckpt) lastCkpt = ckpt;
|
|
@@ -2303,6 +2492,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2303
2492
|
return;
|
|
2304
2493
|
}
|
|
2305
2494
|
case 'decided': {
|
|
2495
|
+
if (rulesFrame(m)) return;
|
|
2306
2496
|
const n = String(m['n'] ?? '');
|
|
2307
2497
|
const w = deciding.get(n);
|
|
2308
2498
|
if (!w) return;
|
|
@@ -2333,10 +2523,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2333
2523
|
setLink('closed', code);
|
|
2334
2524
|
return;
|
|
2335
2525
|
}
|
|
2526
|
+
if (rulesGame && role === 'host' && m.view !== true && ['too-large', 'state-full'].includes(code)) {
|
|
2527
|
+
const message = `This game's rules exceeded the browser room size cap: ${m.message}. Join again for a fresh room.`;
|
|
2528
|
+
console.warn('[netplay]', message); endRules('size', message); return;
|
|
2529
|
+
}
|
|
2336
2530
|
// Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
|
|
2337
2531
|
if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
|
|
2338
2532
|
// An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
|
|
2339
2533
|
if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
|
|
2534
|
+
if (code === 'host-fault') { rulesNotice(String(m.message)); return; }
|
|
2340
2535
|
if (code === 'vote') { warnOnce('vote', m['message']); return; }
|
|
2341
2536
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
2342
2537
|
// The room still runs another build of the game (section 23): not final. Its players were told to reload; this
|
|
@@ -2351,6 +2546,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2351
2546
|
post?.({ what: 'closed', why: code, room: cfg?.room ?? null, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
|
|
2352
2547
|
// Kept out of a room for running an older build (section 23): the page reloads the game; the game may too.
|
|
2353
2548
|
if (code === 'stale') noteStale(m['ver'], true, m['immediate'] === true);
|
|
2549
|
+
if (rulesGame) { stopRules(); setRole('replica', code); if (typeof m['message'] === 'string' && !(code === 'room-over' && gameLine?.includes('cap:'))) api.line(m['message']); if (inApp && code === 'stale' && canOffline) goOfflineHost('stale'); }
|
|
2354
2550
|
setLink('closed', code);
|
|
2355
2551
|
}
|
|
2356
2552
|
return;
|
|
@@ -2385,6 +2581,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2385
2581
|
name = typeof m['name'] === 'string' ? m['name'] : name;
|
|
2386
2582
|
colour = typeof m['colour'] === 'number' ? m['colour'] : colour;
|
|
2387
2583
|
host = (m['host'] as HostRef | null) ?? null;
|
|
2584
|
+
heldPeers = Array.isArray(m.held) ? m.held as Peer[] : [];
|
|
2388
2585
|
peers.clear();
|
|
2389
2586
|
if (Array.isArray(m['peers'])) for (const p of m['peers'] as Peer[]) peers.set(p.id, p);
|
|
2390
2587
|
if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
|
|
@@ -2397,7 +2594,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2397
2594
|
// Revision 6: the room's policy and an open vote (an older relay says neither: open, Fair).
|
|
2398
2595
|
if (m['policy'] && typeof m['policy'] === 'object') { policy = readPolicy(m['policy']); queueMicrotask(() => { emit('policy', policy); post?.({ what: 'policy', policy }); }); }
|
|
2399
2596
|
if (m['vote'] && typeof m['vote'] === 'object') voteState = readVote(m['vote'] as Record<string, unknown>);
|
|
2400
|
-
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
|
|
2597
|
+
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? (next === 'host' && !m['snap'] && onlineRulesSave ? { k: tick, st: now(), d: onlineRulesSave as C } : null);
|
|
2598
|
+
onlineRulesSave = null;
|
|
2401
2599
|
let snap = (m['snap'] as Snapshot<S> | null) ?? null;
|
|
2402
2600
|
const a = m['announce'] as Announcement | undefined;
|
|
2403
2601
|
if (a && typeof a === 'object' && typeof a.id === 'string' && typeof a.text === 'string' && (!announcement || announcement.id !== a.id)) { announcement = { ...a, from: 'studio' }; queueMicrotask(() => emit('announce', announcement as Announcement)); }
|
|
@@ -2410,6 +2608,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2410
2608
|
// The relay's last snapshot comes with the welcome: the room's state is in (the arrival, section 21).
|
|
2411
2609
|
if (snap && next !== 'host' && acceptSnap(snap, JSON.stringify(snap).length, false)) snapSeen = true;
|
|
2412
2610
|
if (next === 'host' && !snap && !continuing) snap = localSnap();
|
|
2611
|
+
if (wasOffline && rulesGame) rulesNotice('Connection restored. Joining the online round.');
|
|
2413
2612
|
offline = false;
|
|
2414
2613
|
connected = true;
|
|
2415
2614
|
downSince = 0;
|
|
@@ -2427,7 +2626,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2427
2626
|
setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
|
|
2428
2627
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
2429
2628
|
}
|
|
2430
|
-
if (continuing) { reannounce(); catchUp(had, hadSeat); }
|
|
2629
|
+
if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
|
|
2431
2630
|
if (link === 'online') paintLink(); else setLink('online', 'welcome');
|
|
2432
2631
|
// A build older than the live one, let in to its own room (section 23).
|
|
2433
2632
|
if (m['stale'] && typeof m['stale'] === 'object') noteStale((m['stale'] as { ver?: unknown }).ver, false);
|
|
@@ -2468,6 +2667,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2468
2667
|
|
|
2469
2668
|
/** A host that reconnected (the relay may have restarted): hand the relay the round, roster, state and a checkpoint. */
|
|
2470
2669
|
function reannounce(): void {
|
|
2670
|
+
// A rules host's are its runtime's output, by the one way out every other frame of the rules takes.
|
|
2671
|
+
if (rulesGame) { rulesHost?.announce(); sendCheckpoint(); return; }
|
|
2471
2672
|
if (roundInfo) raw({ t: 'round', round: roundInfo });
|
|
2472
2673
|
if (slots) raw({ t: 'roster', slots });
|
|
2473
2674
|
for (const [k, d] of stateMap) raw({ t: 'state', k, d });
|
|
@@ -2476,10 +2677,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2476
2677
|
}
|
|
2477
2678
|
|
|
2478
2679
|
/** Buffer a snapshot. `live`: it came off the wire just now (its age is a transit sample). */
|
|
2479
|
-
function acceptSnap(s: Snapshot<S>, bytes: number, live: boolean): boolean {
|
|
2680
|
+
function acceptSnap(s: Snapshot<S>, bytes: number, live: boolean, local = false): boolean {
|
|
2480
2681
|
const t = now();
|
|
2481
2682
|
// A snapshot stamped far in the future would hold the buffer for good (the relay clamps st too).
|
|
2482
|
-
if (!Number.isFinite(s.st) || s.st > t + 1000) { rejectedSnaps += 1; return false; }
|
|
2683
|
+
if (!Number.isFinite(s.st) || (!local && s.st > t + 1000)) { rejectedSnaps += 1; return false; }
|
|
2483
2684
|
if (rebase) {
|
|
2484
2685
|
// New host, new timeline: its clock may sit a little behind the old one's. Keep older frames to hold on.
|
|
2485
2686
|
while (buf.length && (buf[buf.length - 1] as Snapshot<S>).st >= s.st) buf.pop();
|
|
@@ -2546,13 +2747,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2546
2747
|
if (changed) emit('control', { seat, rs, own: ownB, ack, reset, snap: s });
|
|
2547
2748
|
}
|
|
2548
2749
|
|
|
2549
|
-
function onSnap(m: Record<string, unknown>, bytes: number): void {
|
|
2550
|
-
if (role === 'host') return; // a stale frame from a previous host
|
|
2750
|
+
function onSnap(m: Record<string, unknown>, bytes: number, local = false): void {
|
|
2751
|
+
if (role === 'host' && !local) return; // a stale frame from a previous host
|
|
2551
2752
|
const s: Snapshot<S> = { k: Number(m['k']) || 0, st: Number(m['st']), d: m['d'] as S, from: typeof m['from'] === 'number' ? m['from'] : null };
|
|
2552
2753
|
if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
|
|
2553
2754
|
if (m['hb'] === 1) s.hb = 1;
|
|
2554
2755
|
if (typeof m['e'] === 'number') s.e = m['e'];
|
|
2555
|
-
if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
|
|
2756
|
+
if (acceptSnap(s, bytes, true, local)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
|
|
2556
2757
|
}
|
|
2557
2758
|
|
|
2558
2759
|
function onInput(m: Record<string, unknown>): void {
|
|
@@ -2600,6 +2801,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2600
2801
|
|
|
2601
2802
|
// ---------------------------------------------------------------- wire out
|
|
2602
2803
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
2804
|
+
let paceTimer: ReturnType<typeof setInterval> | null = null;
|
|
2805
|
+
let readySpeed = 0;
|
|
2603
2806
|
let ckptTimer: ReturnType<typeof setInterval> | null = null;
|
|
2604
2807
|
let statsTimer: ReturnType<typeof setInterval> | null = null;
|
|
2605
2808
|
let connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -2647,6 +2850,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2647
2850
|
const pingHeld = beat - lastPingTick > 2000 + 1500;
|
|
2648
2851
|
lastPingTick = beat;
|
|
2649
2852
|
if (!connected) {
|
|
2853
|
+
if (rulesGame && offline && rulesHost && ws && wall() - socketStartedAt >= 2500) { lost(ws, 'connect-timeout'); return; }
|
|
2650
2854
|
// A socket that opened and was never welcomed (a room that is not answering) is as dead as a silent one: drop
|
|
2651
2855
|
// it and knock again, so a page that gave up and plays alone still finds its room when the room is back.
|
|
2652
2856
|
if (ws && ws.readyState === 1 && lastMsgAt && wall() - lastMsgAt > Math.max(staleMs, connectOpenMaxMs)) lost(ws, 'stale');
|
|
@@ -2665,16 +2869,18 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2665
2869
|
const c = Date.now();
|
|
2666
2870
|
pingsOut.set(c, c);
|
|
2667
2871
|
if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
|
|
2668
|
-
|
|
2872
|
+
// A hidden host's yield is behind its checkpoint in the sender: the relay hears of the hidden tab after both.
|
|
2873
|
+
raw({ t: 'ping', c, hid: hidden() && !rulesSender?.handing, ...(rulesGame ? { readySpeed } : {}) });
|
|
2669
2874
|
}
|
|
2670
2875
|
|
|
2671
2876
|
function sendCheckpoint(): void {
|
|
2672
|
-
if (role !== 'host' || !opts.checkpoint || offline || !connected) return;
|
|
2877
|
+
if (role !== 'host' || (!opts.checkpoint && !rulesHost) || offline || !connected) return;
|
|
2673
2878
|
let d: C;
|
|
2674
|
-
try { d = opts.checkpoint(); } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
|
|
2675
|
-
const c: Checkpoint<C> = { k: tick, st: now(), d, c: ctlWire() };
|
|
2879
|
+
try { d = (rulesHost ? rulesHost.save() : opts.checkpoint!()) as C; } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
|
|
2880
|
+
const c: Checkpoint<C> = { k: rulesHost?.tick ?? tick, st: now(), d, c: ctlWire() };
|
|
2676
2881
|
lastCkpt = c;
|
|
2677
|
-
|
|
2882
|
+
const frame = { t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c };
|
|
2883
|
+
if (rulesHost) rulesSender?.push(frame); else raw(frame);
|
|
2678
2884
|
}
|
|
2679
2885
|
|
|
2680
2886
|
/** How long until one more input frame fits the rolling-second cap (0: now). */
|
|
@@ -2746,18 +2952,21 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2746
2952
|
|
|
2747
2953
|
// ---------------------------------------------------------------- connect
|
|
2748
2954
|
function goOfflineHost(why: string): void {
|
|
2955
|
+
const kept = rulesHost?.save() ?? null;
|
|
2956
|
+
if (!offline && kept && peers.size <= 1) onlineRulesSave = kept;
|
|
2749
2957
|
offline = true;
|
|
2750
2958
|
connected = false;
|
|
2751
|
-
seat = null;
|
|
2959
|
+
seat = rulesGame && canOffline && want === 'play' ? (seat ?? 0) : null;
|
|
2752
2960
|
if (full) { full = false; post?.({ what: 'full', full: false }); }
|
|
2753
2961
|
aloneWhy = why;
|
|
2754
2962
|
// A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
|
|
2755
2963
|
// gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
|
|
2756
|
-
if (!roleKnown || role !== 'host' || why === 'reconnect-timeout') setRole('host', why);
|
|
2964
|
+
if (!roleKnown || role !== 'host' || (rulesGame && !rulesHost) || why === 'reconnect-timeout') setRole(canOffline ? 'host' : 'replica', why, rulesGame && kept ? { ckpt: { k: tick, st: now(), d: kept as C } } : {});
|
|
2757
2965
|
// No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
|
|
2758
2966
|
// page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
|
|
2759
2967
|
if (closed) paintLink();
|
|
2760
2968
|
else if (why !== 'offline') setLink('alone', why);
|
|
2969
|
+
else paintLink();
|
|
2761
2970
|
}
|
|
2762
2971
|
|
|
2763
2972
|
function lost(sock: WebSocketLike, why: string): void {
|
|
@@ -2786,11 +2995,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2786
2995
|
if (closed || !cfg || !WS || ws) return;
|
|
2787
2996
|
let sock: WebSocketLike;
|
|
2788
2997
|
try { sock = new WS(cfg.url); } catch { retry(); return; }
|
|
2789
|
-
ws = sock;
|
|
2998
|
+
ws = sock; socketStartedAt = wall();
|
|
2790
2999
|
sock.onopen = () => {
|
|
2791
3000
|
lastMsgAt = wall();
|
|
2792
3001
|
raw({
|
|
2793
|
-
t: 'hello', 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,
|
|
3002
|
+
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,
|
|
2794
3003
|
...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
|
|
2795
3004
|
// Revision 9 (an older relay ignores both): the game's revision and what this build can do.
|
|
2796
3005
|
...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
|
|
@@ -2801,6 +3010,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2801
3010
|
// Browsers follow an error with close; Node's WebSocket, on a refused connection, never does.
|
|
2802
3011
|
sock.onerror = () => lost(sock, 'error');
|
|
2803
3012
|
}
|
|
3013
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2804
3014
|
function retry(): void {
|
|
2805
3015
|
if (closed) return;
|
|
2806
3016
|
// A hidden tab does not play and its timers crawl: reconnect when it is looked at again.
|
|
@@ -2808,7 +3018,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2808
3018
|
const wait = Math.max(rulesGame ? (attempt < 3 ? Math.random() * Math.min(2000, 500 * 2 ** attempt) : (0.5 + Math.random() * 0.5) * Math.min(60_000, 2000 * 2 ** Math.min(attempt - 2, 5))) : LADDER[Math.min(attempt, LADDER.length - 1)] as number, aloneUntil - wall());
|
|
2809
3019
|
attempt += 1;
|
|
2810
3020
|
reconnects += 1;
|
|
2811
|
-
setTimeout(open, wait);
|
|
3021
|
+
retryTimer = setTimeout(open, rulesGame && offline && rulesHost ? Math.min(wait, 2500) : wait);
|
|
2812
3022
|
}
|
|
2813
3023
|
|
|
2814
3024
|
if (offline) {
|
|
@@ -2842,21 +3052,32 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2842
3052
|
if (heartbeatMs) hbTimer = setInterval(heartbeat, 250);
|
|
2843
3053
|
lastPingTick = wall();
|
|
2844
3054
|
pingTimer = setInterval(ping, 2000);
|
|
3055
|
+
if (rulesGame) {
|
|
3056
|
+
let at = wall(); const samples: number[] = [];
|
|
3057
|
+
paceTimer = setInterval(() => {
|
|
3058
|
+
const next = wall(); samples.push(Math.min(1, 100 / Math.max(100, next - at))); at = next;
|
|
3059
|
+
if (samples.length > 20) samples.shift();
|
|
3060
|
+
readySpeed = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
3061
|
+
}, 100);
|
|
3062
|
+
}
|
|
2845
3063
|
ckptTimer = setInterval(sendCheckpoint, checkpointMs);
|
|
2846
3064
|
statsTimer = setInterval(() => notifyStats(true), 500);
|
|
2847
|
-
try {
|
|
2848
|
-
g.document?.addEventListener('visibilitychange', () => {
|
|
2849
|
-
if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
|
|
2850
|
-
if (!connected) return;
|
|
2851
|
-
if (hidden()) {
|
|
2852
|
-
// A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
|
|
2853
|
-
if (role === 'host' && peers.size > 1) { sendCheckpoint(); raw({ t: 'yield' }); }
|
|
2854
|
-
ping();
|
|
2855
|
-
} else ping();
|
|
2856
|
-
});
|
|
2857
|
-
g.addEventListener?.('pagehide', () => { if (role === 'host') sendCheckpoint(); raw({ t: 'bye' }); });
|
|
2858
|
-
} catch { /* not a browser */ }
|
|
2859
3065
|
}
|
|
3066
|
+
try {
|
|
3067
|
+
g.document?.addEventListener('visibilitychange', () => {
|
|
3068
|
+
if (disposed) return;
|
|
3069
|
+
if (hidden()) rulesHost?.pause(); else rulesHost?.resume();
|
|
3070
|
+
if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
|
|
3071
|
+
if (!connected) return;
|
|
3072
|
+
if (hidden()) {
|
|
3073
|
+
// A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
|
|
3074
|
+
if (role === 'host' && peers.size > 1) yieldHost();
|
|
3075
|
+
ping();
|
|
3076
|
+
} else ping();
|
|
3077
|
+
});
|
|
3078
|
+
g.addEventListener?.('online', () => { if (!closed && !connected && rulesGame) { if (ws) lost(ws, 'online'); if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = null; attempt = 0; open(); } });
|
|
3079
|
+
g.addEventListener?.('pagehide', () => { if (role === 'host') { sendCheckpoint(); rulesSender?.leave(); } raw({ t: 'bye' }); });
|
|
3080
|
+
} catch { /* not a browser */ }
|
|
2860
3081
|
|
|
2861
3082
|
// ---------------------------------------------------------------- watching
|
|
2862
3083
|
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -2924,7 +3145,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2924
3145
|
|
|
2925
3146
|
/** Tell the relay what this game does with servers (only a host's word counts), once per change. */
|
|
2926
3147
|
function sendCaps(): void {
|
|
2927
|
-
if (role !== 'host' || offline || !connected || !caps.size) return;
|
|
3148
|
+
if (rulesGame || role !== 'host' || offline || !connected || !caps.size) return;
|
|
2928
3149
|
const list = [...caps].sort();
|
|
2929
3150
|
const sig = list.join(',');
|
|
2930
3151
|
if (sig === capsSent) return;
|
|
@@ -2982,6 +3203,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2982
3203
|
get seat() { return seat; },
|
|
2983
3204
|
get spectator() { return seat === null && !offline; },
|
|
2984
3205
|
get offline() { return offline; },
|
|
3206
|
+
get rulesHosting() { return Boolean(rulesHost); },
|
|
2985
3207
|
get connected() { return connected; },
|
|
2986
3208
|
get closedWhy() { return closedWhy; },
|
|
2987
3209
|
get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
|
|
@@ -2993,6 +3215,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2993
3215
|
const next = typeof text === 'string' && text.trim() ? text.trim().slice(0, 120) : null;
|
|
2994
3216
|
if (next === gameLine) return;
|
|
2995
3217
|
gameLine = next;
|
|
3218
|
+
post?.({ what: 'line', text: next });
|
|
2996
3219
|
paintLink();
|
|
2997
3220
|
},
|
|
2998
3221
|
start(): void { clockRunning = true; },
|
|
@@ -3110,6 +3333,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3110
3333
|
return wall() - lastSnapSentAt >= (peers.size > 1 ? snapshotMs : 1000) - 2;
|
|
3111
3334
|
},
|
|
3112
3335
|
snapshot(d, k, force = false) {
|
|
3336
|
+
if (rulesGame) return false;
|
|
3113
3337
|
if (role !== 'host' || offline || !connected) return false;
|
|
3114
3338
|
if (!force && wall() - lastSnapSentAt < (peers.size > 1 ? snapshotMs : 1000) - 2) return false;
|
|
3115
3339
|
tick = k ?? tick + 1;
|
|
@@ -3137,21 +3361,23 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3137
3361
|
checkpointNow: sendCheckpoint,
|
|
3138
3362
|
handOff() {
|
|
3139
3363
|
if (role !== 'host' || offline || !connected || peers.size < 2) return false;
|
|
3140
|
-
|
|
3141
|
-
return raw({ t: 'yield' });
|
|
3364
|
+
return yieldHost();
|
|
3142
3365
|
},
|
|
3143
3366
|
round(r) {
|
|
3367
|
+
if (rulesGame) return;
|
|
3144
3368
|
roundInfo = r;
|
|
3145
3369
|
post?.({ what: 'round', round: r });
|
|
3146
3370
|
if (role === 'host') raw({ t: 'round', round: r });
|
|
3147
3371
|
emit('round', r);
|
|
3148
3372
|
},
|
|
3149
3373
|
roster(s) {
|
|
3374
|
+
if (rulesGame) return;
|
|
3150
3375
|
slots = s.map((x) => ({ slot: x.slot, seat: x.seat, name: x.name, bot: x.bot, ...(x.agent ? { agent: { seat: x.agent.seat, role: x.agent.role, hands: x.agent.hands } } : {}) }));
|
|
3151
3376
|
post?.({ what: 'roster', slots });
|
|
3152
3377
|
if (role === 'host') raw({ t: 'roster', slots });
|
|
3153
3378
|
},
|
|
3154
3379
|
state(key, d) {
|
|
3380
|
+
if (rulesGame) return false;
|
|
3155
3381
|
if (role !== 'host' && !offline) return false;
|
|
3156
3382
|
const text = JSON.stringify(d ?? null);
|
|
3157
3383
|
if (stateSent.get(key) === text) return false;
|
|
@@ -3164,6 +3390,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3164
3390
|
stateOf<T = unknown>(key: string) { return stateMap.get(key) as T | undefined; },
|
|
3165
3391
|
stateKeys() { return [...stateMap.keys()]; },
|
|
3166
3392
|
input(a, held) {
|
|
3393
|
+
if (rulesGame) return;
|
|
3167
3394
|
pendingA = a;
|
|
3168
3395
|
const list = heldList(held);
|
|
3169
3396
|
let edge = false;
|
|
@@ -3174,20 +3401,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3174
3401
|
scheduleInput(edge);
|
|
3175
3402
|
},
|
|
3176
3403
|
steps(e, k, entries, r) {
|
|
3177
|
-
if (
|
|
3404
|
+
if (seat === null || !entries.length) return false;
|
|
3405
|
+
if (rulesHost && offline) { rulesHost.frame({ t: 'in', from: seat, e, k, s: entries, r }); return true; }
|
|
3406
|
+
if ((!rulesGame && role === 'host') || offline || !connected) return false;
|
|
3178
3407
|
if (!raw({ t: 'in', e, k, s: entries, r })) return false;
|
|
3179
3408
|
inputOut.hit(wall());
|
|
3180
3409
|
return true;
|
|
3181
3410
|
},
|
|
3182
3411
|
press(idp) {
|
|
3412
|
+
if (rulesGame) return;
|
|
3183
3413
|
if (role === 'host' || seat === null || offline || !connected) return;
|
|
3184
3414
|
pendingPresses[idp] = (pendingPresses[idp] ?? 0) + 1;
|
|
3185
3415
|
scheduleInput(true);
|
|
3186
3416
|
},
|
|
3187
3417
|
send(kind, data, to) {
|
|
3418
|
+
if (rulesHost && offline) { rulesHost.frame({ t: 'ev', from: seat, id, k: kind, d: data ?? null }); return; }
|
|
3188
3419
|
if (offline) return;
|
|
3189
|
-
const m: Record<string, unknown> = { t: 'ev', k: kind, d: data ?? null };
|
|
3190
|
-
if (role === 'host' && (typeof to === 'number' || typeof to === 'string')) m['to'] = to;
|
|
3420
|
+
const m: Record<string, unknown> = { t: 'ev', k: kind, d: data ?? null, ...(rulesGame ? { view: true } : {}) };
|
|
3421
|
+
if (!rulesGame && role === 'host' && (typeof to === 'number' || typeof to === 'string')) m['to'] = to;
|
|
3191
3422
|
raw(m);
|
|
3192
3423
|
},
|
|
3193
3424
|
inputOf(s) { return inputs.get(s) ?? null; },
|
|
@@ -3215,7 +3446,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3215
3446
|
},
|
|
3216
3447
|
sample(delayMs) {
|
|
3217
3448
|
if (!buf.length) return null;
|
|
3218
|
-
const renderT = now() - (delayMs ?? interpDelay());
|
|
3449
|
+
const renderT = (rulesHost ? rulesWireStamp + performance.now() - rulesFrameAt : now()) - (delayMs ?? interpDelay());
|
|
3219
3450
|
const newest = buf[buf.length - 1] as Snapshot<S>;
|
|
3220
3451
|
const oldest = buf[0] as Snapshot<S>;
|
|
3221
3452
|
const t = wall();
|
|
@@ -3253,7 +3484,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3253
3484
|
};
|
|
3254
3485
|
decideStats.asked += 1;
|
|
3255
3486
|
const now0 = wall();
|
|
3256
|
-
const why = role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
|
|
3487
|
+
const why = rulesGame || role !== 'host' ? 'not-host' : offline || !connected ? 'offline' : now0 < decideOffUntil ? 'off' : now0 < decideNextAt ? 'pace' : null;
|
|
3257
3488
|
if (why) { decideStats.floor += 1; return Promise.resolve({ ...floor(), why }); }
|
|
3258
3489
|
decideNextAt = now0 + 3000;
|
|
3259
3490
|
const n = `d${(decideSeq += 1).toString(36)}`;
|
|
@@ -3267,13 +3498,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3267
3498
|
stats: statsNow,
|
|
3268
3499
|
expose(p) { probes = { ...probes, ...p }; },
|
|
3269
3500
|
close() {
|
|
3501
|
+
sendCheckpoint(); rulesSender?.leave();
|
|
3502
|
+
disposed = true; stopRules();
|
|
3270
3503
|
closed = true;
|
|
3271
3504
|
if (pingTimer) clearInterval(pingTimer);
|
|
3505
|
+
if (paceTimer) clearInterval(paceTimer);
|
|
3272
3506
|
if (ckptTimer) clearInterval(ckptTimer);
|
|
3273
3507
|
if (statsTimer) clearInterval(statsTimer);
|
|
3274
3508
|
if (watchTimer) clearInterval(watchTimer);
|
|
3275
3509
|
if (hbTimer) clearInterval(hbTimer);
|
|
3276
3510
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
3511
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
3277
3512
|
if (spotTimer) clearTimeout(spotTimer);
|
|
3278
3513
|
if (overlayTimer) clearTimeout(overlayTimer);
|
|
3279
3514
|
if (overlayHide) clearTimeout(overlayHide);
|
|
@@ -3290,6 +3525,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3290
3525
|
get role() { return role; },
|
|
3291
3526
|
get seat() { return seat; },
|
|
3292
3527
|
get offline() { return offline; },
|
|
3528
|
+
get rulesHosting() { return Boolean(rulesHost); },
|
|
3293
3529
|
get connected() { return connected; },
|
|
3294
3530
|
get owned() { return api.owned; },
|
|
3295
3531
|
get host() { return host; },
|