@homie-rocks/studio 0.35.0 → 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 +37 -1
- 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/shop-catalog.mjs +20 -1
- package/lib/shop-links.mjs +3 -1
- package/lib/shop.mjs +14 -8
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +6 -2
- package/lib/stripe-connect.mjs +18 -7
- package/lib/template-history.json +83 -0
- 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/stripe.mjs +2 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { takeRulesToken } from '../worker/limits.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The hosting page's one way out for what its rules make. Reliable frames keep one order and each leaves as soon as
|
|
5
|
+
* its own kind has a token; only a snapshot may be superseded, and only a snapshot waits for the snapshot's token.
|
|
6
|
+
* Giving the role up is part of the same order: `handOver` follows the checkpoint through the queue and holds what
|
|
7
|
+
* is made after it until the relay answers, and `leave` sends the last checkpoint before the page goes.
|
|
8
|
+
*/
|
|
9
|
+
export function rulesOutput({ rates, send, now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
10
|
+
const buckets = new Map();
|
|
11
|
+
const queue = [];
|
|
12
|
+
let snapshot = null, timer = null, stopped = false, finishing = false, held = false;
|
|
13
|
+
function budget(m, force = false) {
|
|
14
|
+
const rate = rates[m.t];
|
|
15
|
+
if (!rate) throw new Error(`Unknown rules output: ${m.t}`);
|
|
16
|
+
// The relay drops a surplus snapshot and nothing else, so snapshots spend its whole rate: an on-time tick's leaves
|
|
17
|
+
// with the tick. Reliable kinds keep a fifth of theirs clear of transport jitter and late timers.
|
|
18
|
+
const ms = m.t === 'snap' ? takeRulesToken(buckets, m.t, rate, now(), 2) : takeRulesToken(buckets, m.t, rate * 0.8, now(), rate);
|
|
19
|
+
// The relay's bucket holds twice this one: a forced last checkpoint is inside it.
|
|
20
|
+
if (ms && force) { buckets.get(m.t).tokens -= 1; return 0; }
|
|
21
|
+
return ms;
|
|
22
|
+
}
|
|
23
|
+
function drain() {
|
|
24
|
+
if (timer !== null) { clearTimer(timer); timer = null; }
|
|
25
|
+
if (stopped || held) return;
|
|
26
|
+
let wait = Infinity;
|
|
27
|
+
while (queue.length) {
|
|
28
|
+
const m = queue[0];
|
|
29
|
+
if (m.rules !== true) { queue.shift(); held = true; send(m); return; }
|
|
30
|
+
const ms = budget(m);
|
|
31
|
+
if (ms) { wait = ms; break; }
|
|
32
|
+
send(queue.shift());
|
|
33
|
+
}
|
|
34
|
+
if (snapshot) {
|
|
35
|
+
const ms = budget(snapshot);
|
|
36
|
+
if (ms) wait = Math.min(wait, ms);
|
|
37
|
+
else { const m = snapshot; snapshot = null; send(m); }
|
|
38
|
+
}
|
|
39
|
+
if (finishing && !queue.length) { stopped = true; return; }
|
|
40
|
+
if (wait !== Infinity) timer = setTimer(drain, Math.max(1, wait));
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
push(frame) {
|
|
44
|
+
if (stopped || finishing) return;
|
|
45
|
+
const m = { ...frame, rules: true };
|
|
46
|
+
if (m.t === 'snap') snapshot = m; else queue.push(m);
|
|
47
|
+
drain();
|
|
48
|
+
},
|
|
49
|
+
finish(frame) {
|
|
50
|
+
if (stopped || finishing) return;
|
|
51
|
+
finishing = true; held = false; snapshot = null; queue.push({ ...frame, rules: true });
|
|
52
|
+
drain();
|
|
53
|
+
},
|
|
54
|
+
/** `yield`, in its place behind the checkpoint. What the rules make after it waits for the relay's answer. */
|
|
55
|
+
handOver(frame) {
|
|
56
|
+
if (stopped || finishing) return;
|
|
57
|
+
queue.push({ ...frame, t: 'yield' });
|
|
58
|
+
drain();
|
|
59
|
+
},
|
|
60
|
+
/** True from `handOver` until its `yield` is on the wire: the relay must hear nothing else that moves the role. */
|
|
61
|
+
get handing() { return !stopped && queue.some((m) => m.rules !== true); },
|
|
62
|
+
/** True while a yield is queued or unanswered. */
|
|
63
|
+
get waiting() { return !stopped && (held || queue.some((m) => m.rules !== true)); },
|
|
64
|
+
/** The relay kept this page as the host: what was held goes out, in order. */
|
|
65
|
+
release() { if (held) { held = false; drain(); } },
|
|
66
|
+
/**
|
|
67
|
+
* The page is going (it closes, or its rules host is closed). Everything its allowance permits is sent, in order;
|
|
68
|
+
* a frame that cannot be is covered by the checkpoint, which goes last and always goes.
|
|
69
|
+
*/
|
|
70
|
+
leave() {
|
|
71
|
+
if (stopped) return;
|
|
72
|
+
if (timer !== null) { clearTimer(timer); timer = null; }
|
|
73
|
+
let last = null, blocked = held;
|
|
74
|
+
for (const m of queue) {
|
|
75
|
+
if (m.t === 'ckpt') { last = m; continue; }
|
|
76
|
+
if (m.rules !== true) { blocked = true; continue; }
|
|
77
|
+
if (!blocked && !budget(m)) send(m); else blocked = true;
|
|
78
|
+
}
|
|
79
|
+
if (last) { budget(last, true); send(last); }
|
|
80
|
+
held = false; queue.length = 0; snapshot = null;
|
|
81
|
+
},
|
|
82
|
+
/** The relay moved the role: it takes nothing more from this page as the host. */
|
|
83
|
+
stop() { stopped = true; if (timer !== null) clearTimer(timer); timer = null; queue.length = 0; snapshot = null; },
|
|
84
|
+
};
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
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",
|
|
@@ -119,7 +119,8 @@
|
|
|
119
119
|
"meshoptimizer": "1.3.0",
|
|
120
120
|
"puppeteer-core": "25.8.0",
|
|
121
121
|
"sharp": "0.35.5",
|
|
122
|
-
"three": "0.185.1"
|
|
122
|
+
"three": "0.185.1",
|
|
123
|
+
"typescript": "5.9.3"
|
|
123
124
|
},
|
|
124
125
|
"scripts": {
|
|
125
126
|
"test": "node --test test/*.test.mjs"
|
package/rules/browser.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The browser's adapter for the one host runtime. The build puts it with the guarded rules, either in a browser
|
|
3
|
+
* host's bundle or in a server game's optional offline module. netplay owns elections and the transport; this file
|
|
4
|
+
* supplies the clock and translates the runtime's saved bytes to the relay's existing JSON checkpoint.
|
|
5
|
+
*/
|
|
6
|
+
import { createHost } from './host.ts';
|
|
7
|
+
import { compileMap, compileRules } from './rules.ts';
|
|
8
|
+
import type { RoomSettings, RulesDef } from './rules.ts';
|
|
9
|
+
import { fromBytes, toBytes } from './pack.ts';
|
|
10
|
+
import { lab } from '../lab/lab.ts';
|
|
11
|
+
import type { RulesHostFactory, Peer } from '../netplay/netplay.ts';
|
|
12
|
+
|
|
13
|
+
export interface BrowserGame {
|
|
14
|
+
id: string; build: string; tune: Record<string, unknown>; map: Parameters<typeof compileMap>[0];
|
|
15
|
+
settings: RoomSettings; seats: number; vocab?: import('../agents/agents.ts').Vocabulary;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The rules and their private tunables stay inside this factory: the view is handed only the loader. */
|
|
19
|
+
export function browserHost(def: RulesDef, game: BrowserGame): RulesHostFactory {
|
|
20
|
+
return (o) => {
|
|
21
|
+
const tune = lab.rulesTune(game.tune);
|
|
22
|
+
const compiled = compileRules(def, { ...game, tune, map: compileMap(game.map, String((game.map as { name?: string }).name ?? 'main')) });
|
|
23
|
+
const checkpoint = o.restore as { rules?: string; data?: any } | null;
|
|
24
|
+
if (o.restore && (checkpoint?.rules !== game.build || !checkpoint.data || typeof checkpoint.data !== 'object')) throw new Error('invalid checkpoint');
|
|
25
|
+
// Check depth iteratively before clone/encoding can recurse on untrusted checkpoint data.
|
|
26
|
+
if (checkpoint) {
|
|
27
|
+
const pending: [unknown, number][] = [[checkpoint.data, 0]];
|
|
28
|
+
let nodes = 0;
|
|
29
|
+
while (pending.length) {
|
|
30
|
+
const [value, depth] = pending.pop()!;
|
|
31
|
+
if (depth > 64 || ++nodes > 500000) throw new Error('invalid checkpoint depth or size');
|
|
32
|
+
if (value && typeof value === 'object') for (const child of Object.values(value)) pending.push([child, depth + 1]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const saved = checkpoint ? structuredClone(checkpoint.data) : null;
|
|
36
|
+
if (saved && o.peers && Array.isArray(saved.core?.ents) && Array.isArray(saved.core?.seats)) {
|
|
37
|
+
// Identity is the relay's word. The previous host's save can preserve game state, never seat authority.
|
|
38
|
+
const peers = new Map([...o.peers, ...(o.held ?? [])].filter((p) => p.seat !== null).map((p) => [p.seat, p]));
|
|
39
|
+
const policy = o.policy;
|
|
40
|
+
const count = (n: unknown): number => typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(game.seats - 1, Math.floor(n))) : 0;
|
|
41
|
+
const reserved = policy?.kind === 'hybrid' || policy?.kind === 'beginner' ? Math.min(game.seats - 1, count(policy.aiSeats) + (policy.kind === 'beginner' ? count(policy.guides) : 0)) : 0;
|
|
42
|
+
const identity = (seat: number): [string, string] => {
|
|
43
|
+
const peer = peers.get(seat);
|
|
44
|
+
return peer ? [peer.agent ? 'ai' : 'person', `p${peer.occ ?? peer.id}`] : seat >= game.seats - reserved && seat < game.seats ? ['ai', 'reserved'] : ['bot', ''];
|
|
45
|
+
};
|
|
46
|
+
const sameHolder = (q: any): boolean => !Array.isArray(q) || saved.core.seats.some((s: any) => Array.isArray(s) && s[0] === q[0] && s[1] === identity(q[0])[0] && s[2] === identity(q[0])[1]);
|
|
47
|
+
if (Array.isArray(saved.queues)) saved.queues = saved.queues.filter(sameHolder);
|
|
48
|
+
if (Array.isArray(saved.inputs)) saved.inputs = saved.inputs.filter(sameHolder);
|
|
49
|
+
if (Array.isArray(saved.names)) saved.names = saved.names.filter((row: any) => {
|
|
50
|
+
if (!Array.isArray(row)) return true;
|
|
51
|
+
const previous = saved.core.seats.find((s: any) => Array.isArray(s) && s[0] === row[0]);
|
|
52
|
+
return !(previous && previous[2] !== 'reserved' && identity(row[0])[1] === 'reserved');
|
|
53
|
+
});
|
|
54
|
+
for (const w of [...saved.core.ents, ...(Array.isArray(saved.core.spawns) ? saved.core.spawns : [])]) {
|
|
55
|
+
if (!Array.isArray(w)) throw new Error('invalid checkpoint entity');
|
|
56
|
+
const next = identity(w[11]);
|
|
57
|
+
if (w[12] !== next[0] || w[13] !== next[1]) w[15] = null;
|
|
58
|
+
[w[12], w[13]] = next;
|
|
59
|
+
}
|
|
60
|
+
for (const w of saved.core.seats) { if (!Array.isArray(w)) throw new Error('invalid checkpoint seat'); [w[1], w[2]] = identity(w[0]); }
|
|
61
|
+
for (const item of Array.isArray(saved.core.queue) ? saved.core.queue : []) {
|
|
62
|
+
if (Array.isArray(item) && item[6] === 'roundOver' && Array.isArray(item[7]?.results))
|
|
63
|
+
for (const row of item[7].results) if (row && typeof row === 'object') row.driver = identity(row.seat)[0];
|
|
64
|
+
}
|
|
65
|
+
saved.core.ops = [];
|
|
66
|
+
}
|
|
67
|
+
// A new authority rebases every player's input clock. Timers, dice, entities and round still come from the save.
|
|
68
|
+
|
|
69
|
+
const host = createHost({
|
|
70
|
+
game: game.id, build: game.build, compiled, send: o.send, restore: saved ? toBytes(saved) : null,
|
|
71
|
+
stage: lab.stage ?? undefined, startPaused: false, restoreEpoch: (Math.floor(Math.random() * 4294967296) >>> 0) || 1,
|
|
72
|
+
clock: { now: o.now, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) },
|
|
73
|
+
onEnd: o.onEnd,
|
|
74
|
+
});
|
|
75
|
+
if (game.vocab) host.frame({ t: 'vocabulary', vocab: game.vocab });
|
|
76
|
+
const present = new Set<number>();
|
|
77
|
+
const sync = (peers: Peer[]): void => {
|
|
78
|
+
const next = new Set(peers.flatMap((p) => p.seat === null ? [] : [p.seat]));
|
|
79
|
+
for (const seat of present) if (!next.has(seat)) host.frame({ t: 'leave', seat });
|
|
80
|
+
for (const body of host.core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved' && !next.has(body.seat)) host.core.seatAway(body.seat, true);
|
|
81
|
+
for (const peer of peers) if (peer.seat !== null) host.frame({ t: 'join', peer });
|
|
82
|
+
present.clear(); for (const seat of next) present.add(seat);
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
frame: (m) => {
|
|
86
|
+
if (m.t === 'join') { const p = m.peer as { seat?: number }; if (typeof p?.seat === 'number') present.add(p.seat); }
|
|
87
|
+
if (m.t === 'leave' || m.t === 'free') present.delete(Number(m.seat));
|
|
88
|
+
host.frame(m);
|
|
89
|
+
},
|
|
90
|
+
sync,
|
|
91
|
+
// A restored room may be behind what its last host told the relay: the shared state is said again with the round.
|
|
92
|
+
start: () => { host.start(); if (saved) o.send({ t: 'state', k: 'shared', d: host.core.shared() }); },
|
|
93
|
+
stop: () => host.stop(),
|
|
94
|
+
pause: () => host.pause(), resume: () => host.resume(), announce: () => host.announce(),
|
|
95
|
+
save: () => ({ rules: game.build, data: fromBytes(host.save()) }),
|
|
96
|
+
get tick() { return host.tick; },
|
|
97
|
+
facts: () => host.facts(),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}
|