@homie-rocks/studio 0.42.0 → 0.44.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 +43 -1
- package/README.md +35 -9
- package/app-starters/welcome/app.json +8 -1
- package/app-starters/welcome/map/main.json +1 -0
- package/app-starters/welcome/src/move.ts +3 -0
- package/app-starters/welcome/src/rules.ts +21 -0
- package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +31 -12
- package/lib/codex.mjs +1 -1
- package/lib/mcp-tools.mjs +10 -7
- package/lib/mcp.mjs +2 -1
- package/lib/port-check.mjs +7 -4
- package/lib/port.mjs +2 -2
- package/lib/rules-build.mjs +6 -6
- package/lib/rules-guard.mjs +9 -3
- package/lib/scaffold.mjs +42 -12
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +149 -5
- package/lib/trailer.mjs +7 -3
- package/lib/typecheck.mjs +10 -8
- package/netplay/NETPLAY.md +8 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/host.ts +4 -2
- package/rules/interest.mjs +79 -0
- package/rules/rules.ts +13 -5
- package/rules/view.ts +4 -1
- package/starters/coin-dash/src/move.ts +2 -0
- package/starters/coin-dash/src/rules.ts +2 -0
- package/starters/coin-dash/src/view.ts +2 -0
- package/starters/ember-vale/src/move.ts +2 -0
- package/starters/ember-vale/src/rules.ts +2 -0
- package/starters/ember-vale/src/view.ts +2 -0
- package/starters/gem-rush/src/move.ts +2 -0
- package/starters/gem-rush/src/rules.ts +2 -0
- package/starters/gem-rush/src/view.ts +2 -0
- package/starters/gem-rush-3d/src/move.ts +2 -0
- package/starters/gem-rush-3d/src/rules.ts +2 -0
- package/starters/gem-rush-3d/src/view.ts +2 -0
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/starters/hero-rush-3d/src/rules.ts +2 -0
- package/starters/hero-rush-3d/src/view.ts +2 -0
- package/worker/room.mjs +15 -5
- package/worker/version.mjs +1 -1
package/lib/rules-build.mjs
CHANGED
|
@@ -53,9 +53,9 @@ function readMap(g) {
|
|
|
53
53
|
const dir = join(g.dir, 'map');
|
|
54
54
|
const names = existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.json')).sort() : [];
|
|
55
55
|
const name = names.includes('main.json') ? 'main.json' : names[0];
|
|
56
|
-
if (!name) throw new Error(
|
|
56
|
+
if (!name) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/map/ needs a map: map/main.json with "bounds", and the "boxes", "circles" and named "spots" the rules use`);
|
|
57
57
|
const raw = readJson(join(dir, name));
|
|
58
|
-
if (!raw || typeof raw !== 'object') throw new Error(
|
|
58
|
+
if (!raw || typeof raw !== 'object') throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/map/${name} is not JSON`);
|
|
59
59
|
return { ...raw, name: name.replace(/\.json$/, '') };
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -142,7 +142,7 @@ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cp
|
|
|
142
142
|
*/
|
|
143
143
|
export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
|
|
144
144
|
const guarded = await guardRules(esbuild, root, g.dir);
|
|
145
|
-
if (!guarded.ok) throw new Error(
|
|
145
|
+
if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
|
|
146
146
|
const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
|
|
147
147
|
const map = readMap(g);
|
|
148
148
|
const seats = Math.max(1, Math.min(32, Math.floor(Number(g.players?.max)) || 8));
|
|
@@ -151,12 +151,12 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
151
151
|
// The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
|
|
152
152
|
await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
|
|
153
153
|
const checked = await runRulesWorker(bundle, data, { allowance: longCheck ? LONG_ALLOWANCE : ALLOWANCE });
|
|
154
|
-
for (const p of checked.problems) log(`warning: games/${g.id}
|
|
154
|
+
for (const p of checked.problems) log(`warning: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/${g.kind === 'app' ? 'app' : 'game'}.json: ${p}`);
|
|
155
155
|
for (const line of checked.information) log(`info: ${line}`);
|
|
156
156
|
const { settings, schema, publicTune, rounds, stateHash } = checked;
|
|
157
157
|
const trial = RULES_CAPACITY_TRIAL;
|
|
158
158
|
log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
|
|
159
|
-
if (settings.tickHz > trial.tickHz) log(`info: games/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
159
|
+
if (settings.tickHz > trial.tickHz) log(`info: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
160
160
|
const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
|
|
161
161
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
162
162
|
const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
@@ -168,7 +168,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
168
168
|
...(t !== 'snap' && rate > rateCap ? [`${t} ${rate}/s (cap ${rateCap}/s; over by ${rate - rateCap}/s)`] : [])];
|
|
169
169
|
});
|
|
170
170
|
if (exceeded.length) {
|
|
171
|
-
const message =
|
|
171
|
+
const message = `${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: browser rules smoke run measured snapshot ${stats.snapshotBytes} B (cap ${snapshotCap} B), checkpoint ${stats.checkpointBytes} B (cap ${checkpointCap} B). Exceeded: ${exceeded.join(', ')}. Reduce these frames or use room.host: server; offline play has no relay caps.`;
|
|
172
172
|
if (settings.host === 'browser') throw new Error(message);
|
|
173
173
|
log(`warning: ${message}`);
|
|
174
174
|
}
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import babelTraverse from '@babel/traverse';
|
|
|
42
42
|
import * as babelGenerator from '@babel/generator';
|
|
43
43
|
import * as t from '@babel/types';
|
|
44
44
|
import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
|
|
45
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
45
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
46
46
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
47
47
|
|
|
48
48
|
const traverse = babelTraverse.default ?? babelTraverse;
|
|
@@ -757,12 +757,18 @@ export async function guardFiles(esbuild, root, gameDir, entry) {
|
|
|
757
757
|
|
|
758
758
|
/** An esbuild plugin that serves a game's guarded files in place of its sources (`stub`: files a bundle must not hold at all). */
|
|
759
759
|
export function guardedPlugin(files, { stub = [] } = {}) {
|
|
760
|
+
// esbuild resolves filesystem aliases (including macOS /tmp -> /private/tmp).
|
|
761
|
+
// Match those paths to the exact sources already checked and rewritten.
|
|
762
|
+
const canonical = (file) => existsSync(file) ? realpathSync(file) : file;
|
|
763
|
+
const guarded = new Map([...files].map(([file, code]) => [canonical(file), code]));
|
|
764
|
+
const omitted = new Set(stub.map(canonical));
|
|
760
765
|
return {
|
|
761
766
|
name: 'homie-rules-guard',
|
|
762
767
|
setup(b) {
|
|
763
768
|
b.onLoad({ filter: /\.(?:ts|js|mjs)$/ }, (a) => {
|
|
764
|
-
|
|
765
|
-
|
|
769
|
+
const file = canonical(a.path);
|
|
770
|
+
if (omitted.has(file)) return { contents: 'export default {};\n', loader: 'js' };
|
|
771
|
+
const code = guarded.get(file);
|
|
766
772
|
return typeof code === 'string' ? { contents: code, loader: 'js' } : null;
|
|
767
773
|
});
|
|
768
774
|
},
|
package/lib/scaffold.mjs
CHANGED
|
@@ -126,7 +126,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
126
126
|
|
|
127
127
|
| Path | What it is |
|
|
128
128
|
| --- | --- |
|
|
129
|
-
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/
|
|
129
|
+
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/rules.ts\`, \`src/move.ts\`, \`src/view.ts\` and shared \`map/\` data; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
|
|
130
130
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
131
131
|
| \`games/<id>/assets/\` | Every model and texture a game ships, with where it came from and its licence (\`manifest.json\`), and \`RIGHTS.md\` in plain words; \`games/<id>/codex/decisions.json\` (private, like the codex) is its look as decisions, and \`style.json\` the palette, fonts, light and camera the game draws with. \`art/<slug>/\` holds art jobs (concepts, receipts; \`raw/\` is git-ignored). |
|
|
132
132
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
@@ -171,7 +171,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
171
171
|
game: copy a starter only when the person asks for one, or once their game is planned.
|
|
172
172
|
- \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
|
|
173
173
|
how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
|
|
174
|
-
owner tests (real touch, a late joiner,
|
|
174
|
+
owner tests (real touch, a late joiner, one browser leaving, two browsers finishing a round). The Homie
|
|
175
175
|
plugin's \`port\` skill does the whole job.
|
|
176
176
|
- \`npm run build\` — bundle every game into \`site/dist\`. It builds in a folder of its own and puts the result
|
|
177
177
|
in place only when all of it is there: a game that does not build fails the command (a non-zero exit) and
|
|
@@ -197,7 +197,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
197
197
|
- \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
|
|
198
198
|
land in the same room and finish a round. Run it before you say a game works.
|
|
199
199
|
- \`npx --no-install homie-studio perf <id> --url <site>\` — how fast a game runs, on a computer and an emulated
|
|
200
|
-
phone, with two browsers in a room (
|
|
200
|
+
phone, with two browsers in a room (two replicas when the server hosts): frame times, the game's JavaScript and the main thread
|
|
201
201
|
per frame, time to playable, what it downloads, the heap, netplay messages a second (files under \`.perf/\`). The
|
|
202
202
|
Homie plugin's \`perf\` skill runs the whole loop: one change at a time, kept only when it is better beyond the noise
|
|
203
203
|
and \`check\` still passes, and a report in \`perf/<id>/\`.
|
|
@@ -279,11 +279,37 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
279
279
|
|
|
280
280
|
## Making games
|
|
281
281
|
|
|
282
|
-
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
282
|
+
- **New games are rules plus view, hosted by the server.** Read the Homie game skill and its
|
|
283
|
+
RULES.md before writing code (MCP: studio_guide game, then studio_guide game file RULES.md).
|
|
284
|
+
Start with coin-dash, gem-rush, ember-vale, gem-rush-3d or hero-rush-3d; all five follow this contract.
|
|
285
|
+
In games/<id>/src/rules.ts, defineRules declares entities, fields, handlers, events, rounds and companions.
|
|
286
|
+
The view calls openRoom and sends input/commands; it draws, plays sound and handles controls. It never
|
|
287
|
+
decides a hit, score, spawn, turn or result. Keep game.json room.host set to server, up to 32 seats.
|
|
288
|
+
- **Movement feels immediate through prediction.** Put the shared defineMove callback in src/move.ts;
|
|
289
|
+
it runs authoritatively on the server and immediately for your own view, then reconciles. Other bodies
|
|
290
|
+
interpolate. Use the starter's sweep/slide and room.me/room.each poses; no second browser physics loop,
|
|
291
|
+
and no owner-movement workaround. Discrete actions wait for the server's decision.
|
|
292
|
+
- **Truth is declared and deterministic.** Entity handlers write their own fields and send next-tick events;
|
|
293
|
+
room handlers write shared fields. Use world.tick/dt/ticks/after/random and world.math (ctx.math in move),
|
|
294
|
+
never clocks, Math.random, hidden module state, async/network calls or browser APIs in rules.
|
|
295
|
+
Read RULES.md for the bounded query and execution budget, declared commands/effects/asks, and message fixes.
|
|
296
|
+
- **Level data is shared.** map/main.json describes collision and bounds in metres. A 3D rules body's z is
|
|
297
|
+
height at its feet; Three.js draws x,z,y. Floors, walls and platforms must agree in rules and view.
|
|
298
|
+
Public tunables describe movement. Bots and companions use the same inputs and declared goals, with a
|
|
299
|
+
scripted floor that plays without AI. Asks declare bounded state, questions and a deterministic floor.
|
|
300
|
+
- **Choose sensible defaults yourself.** Do not ask the person for a hosting mode, movement mode, tick rate
|
|
301
|
+
or serialization scheme. Preserve the requested mechanic. Turn-based games and live apps may use manual
|
|
302
|
+
rounds. Shared live app state uses these same rooms; durable records and authentication use the app APIs.
|
|
303
|
+
A build-only request ends with a working local result; publish only within the owner's requested scope.
|
|
304
|
+
- **Check changes honestly.** Build first, repair each source-located guard/type/play-check error using RULES.md,
|
|
305
|
+
then check with two browsers and play the requested mechanic with network delay. Info is not a failure.
|
|
306
|
+
Never remove rules, weaken checks, forge a probe or switch hosting to get past a message.
|
|
307
|
+
Rules hashes and room saves are automatic; character saves are player-owned, untrusted input. Room state
|
|
308
|
+
is replicated, not secret. Bigger rooms and private state are not delivered by milestone 1.
|
|
309
|
+
- **Existing games:** change rules truth in rules and presentation in view. Browser hosting is for offline,
|
|
310
|
+
local development and private friends games. Untouched older createNetplay/createRoom games still work;
|
|
311
|
+
when asked to rewrite one, read the game skill's REWRITE.md and preserve its art, view and feel. There is
|
|
312
|
+
no automatic converter. NETPLAY.md section 29 is the rules contract; earlier sections describe older APIs.
|
|
287
313
|
- Phones and computers: touch controls on phones only, keys on computers; keep the
|
|
288
314
|
centre of the screen clear during play.
|
|
289
315
|
- The play page's small room button (Invite, Big screen, the room code) sits top right, with the Chat pill
|
|
@@ -433,7 +459,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
433
459
|
\`--policy beginner --guides 2 [--kids]\`, \`--policy humans-only\`) makes one at once; \`servers set <id> <server>
|
|
434
460
|
--level-max 3 …\` changes one; \`servers close <id> <server>\`. A change that narrows who may come in (humans-only,
|
|
435
461
|
a stricter door) and closing one only ASK, with the owner's one-tap link, like \`office kick\`.
|
|
436
|
-
- **
|
|
462
|
+
- **Rules companions:** use world.level, world.guideLevel, world.guideSeats and world.kids; guide.view,
|
|
463
|
+
guide.floor, think and goalDone follow the rules contract. The old BotBrain/useAgents/decide recipes below
|
|
464
|
+
apply only to unchanged browser-hosted games. Read RULES.md for a new game.
|
|
465
|
+
- **The skill dial (older browser-hosted games):** every room has a level, 1 Rookie, 2 Steady, 3 Fair, 4 Strong, 5 Maxed, each \`{ reactionMs,
|
|
437
466
|
aimNoise, aggression, positioning }\`. The party sets it by voting on a card in the play page (the middle vote
|
|
438
467
|
wins, capped by the server's ceiling). Make a game's bots honour it: \`net.skillOf(slot)\` in their step (the
|
|
439
468
|
snippet is in \`NETPLAY.md\` section 17; \`BotBrain\` from the port kit reads it with a rebuild), and declare
|
|
@@ -1018,10 +1047,11 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
1018
1047
|
const q = JSON.stringify(realpathSync(dir));
|
|
1019
1048
|
return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
|
|
1020
1049
|
`cd ${q}${install ? '' : ' && npm install'}`,
|
|
1021
|
-
'
|
|
1022
|
-
'
|
|
1050
|
+
'read the game skill and RULES.md; choose sensible defaults and make the requested game as server rules plus view',
|
|
1051
|
+
'optional live example, without copying a game here: npx --no-install homie-studio demo',
|
|
1052
|
+
'only when the person asks for a game: start from a rules starter and implement the requested mechanic: npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
1023
1053
|
'npm run dev (the home page, "first game coming soon", at http://127.0.0.1:8787/; with a game: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
1024
|
-
'npm run deploy (then
|
|
1054
|
+
'when the owner asks to publish: npm run deploy (then studio_publish, or: npx --no-install homie-studio publish)',
|
|
1025
1055
|
], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
|
|
1026
1056
|
}
|
|
1027
1057
|
|
package/lib/studio.mjs
CHANGED
|
@@ -108,7 +108,7 @@ function listKind(root, kind) {
|
|
|
108
108
|
/**
|
|
109
109
|
* RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view says so in game.json with a `"room"`
|
|
110
110
|
* object (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build) and has a
|
|
111
|
-
* src/rules.ts.
|
|
111
|
+
* src/rules.ts. Other games need the browser-hosted netplay contract checked by the build. The file alone decides nothing: games made
|
|
112
112
|
* before this had their own src/rules.ts, with whatever they liked in it, and they build exactly as they did.
|
|
113
113
|
*/
|
|
114
114
|
export const isRulesGame = (g) => g?.room != null && typeof g.room === 'object' && existsSync(join(g.dir, 'src', 'rules.ts'));
|
|
@@ -129,7 +129,7 @@ export function starters() {
|
|
|
129
129
|
const dir = join(PACKAGE_ROOT, 'starters');
|
|
130
130
|
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
|
|
131
131
|
const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
|
|
132
|
-
// `example`:
|
|
132
|
+
// `example`: marks the smallest reference (coin-dash); all starters use server rules and can start a new game.
|
|
133
133
|
return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
|
|
134
134
|
});
|
|
135
135
|
}
|
|
@@ -309,7 +309,7 @@ export async function newApp(root, id, { name } = {}) {
|
|
|
309
309
|
const meta = JSON.parse(readFileSync(file, 'utf8'));
|
|
310
310
|
meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
|
|
311
311
|
writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
|
|
312
|
-
const main = join(dest, 'src/main.ts');
|
|
312
|
+
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
313
313
|
writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
|
|
314
314
|
return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
|
|
315
315
|
}
|
|
@@ -2199,6 +2199,68 @@
|
|
|
2199
2199
|
}
|
|
2200
2200
|
},
|
|
2201
2201
|
"0.40.0": {
|
|
2202
|
+
"files": {
|
|
2203
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2204
|
+
"README.md": "684b3cca8001673c",
|
|
2205
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2206
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2207
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2208
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2209
|
+
"posts/README.md": "37841aae476bd540",
|
|
2210
|
+
"site/README.md": "732894273a32dc3f",
|
|
2211
|
+
"site/src/worker.mjs": [
|
|
2212
|
+
"34444a6ca4b14437",
|
|
2213
|
+
"fc90f3609cad18ba"
|
|
2214
|
+
]
|
|
2215
|
+
},
|
|
2216
|
+
"sections": {
|
|
2217
|
+
"AGENTS.md": {
|
|
2218
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2219
|
+
"## Layout": [
|
|
2220
|
+
"c18362ed28975d81",
|
|
2221
|
+
"f3a280b8c96cd068"
|
|
2222
|
+
],
|
|
2223
|
+
"## Apps: one screen, roles and parts": [
|
|
2224
|
+
"4db0abe4cdfac09f",
|
|
2225
|
+
"be6f8fa45f93211a"
|
|
2226
|
+
],
|
|
2227
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
2228
|
+
"a7f290579585368c",
|
|
2229
|
+
"c920ac84d44c872a"
|
|
2230
|
+
],
|
|
2231
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2232
|
+
"## Making games": [
|
|
2233
|
+
"2da7fa74c8884834",
|
|
2234
|
+
"9e44559de535d842"
|
|
2235
|
+
],
|
|
2236
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2237
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2238
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2239
|
+
"## Servers and AI seats": [
|
|
2240
|
+
"98d08705087a33ec",
|
|
2241
|
+
"d0028361fe567add"
|
|
2242
|
+
],
|
|
2243
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2244
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2245
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2246
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2247
|
+
"## Beta": "9d02638207a26e73"
|
|
2248
|
+
}
|
|
2249
|
+
},
|
|
2250
|
+
"named": {
|
|
2251
|
+
"Homie Arcade": {
|
|
2252
|
+
"sections": {
|
|
2253
|
+
"AGENTS.md": {
|
|
2254
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
2255
|
+
"7cad8fd10f62fef1",
|
|
2256
|
+
"cae1df6235f51d17"
|
|
2257
|
+
]
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
},
|
|
2263
|
+
"0.41.0": {
|
|
2202
2264
|
"files": {
|
|
2203
2265
|
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2204
2266
|
"README.md": "684b3cca8001673c",
|
|
@@ -2213,15 +2275,15 @@
|
|
|
2213
2275
|
"sections": {
|
|
2214
2276
|
"AGENTS.md": {
|
|
2215
2277
|
"(intro)": "214aa46eb3a895d5",
|
|
2216
|
-
"## Layout": "
|
|
2278
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2217
2279
|
"## Apps: one screen, roles and parts": "be6f8fa45f93211a",
|
|
2218
|
-
"## Commands (all through the pinned CLI in node_modules)": "
|
|
2280
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2219
2281
|
"## The site": "0cb8dd9a6f8a0096",
|
|
2220
|
-
"## Making games": "
|
|
2282
|
+
"## Making games": "9e44559de535d842",
|
|
2221
2283
|
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2222
2284
|
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2223
2285
|
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2224
|
-
"## Servers and AI seats": "
|
|
2286
|
+
"## Servers and AI seats": "d0028361fe567add",
|
|
2225
2287
|
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2226
2288
|
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2227
2289
|
"## Rules": "2e6b29afb85dcf7e",
|
|
@@ -2233,7 +2295,7 @@
|
|
|
2233
2295
|
"Homie Arcade": {
|
|
2234
2296
|
"sections": {
|
|
2235
2297
|
"AGENTS.md": {
|
|
2236
|
-
"## Commands (all through the pinned CLI in node_modules)": "
|
|
2298
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2237
2299
|
}
|
|
2238
2300
|
}
|
|
2239
2301
|
}
|
|
@@ -2282,6 +2344,88 @@
|
|
|
2282
2344
|
}
|
|
2283
2345
|
}
|
|
2284
2346
|
}
|
|
2347
|
+
},
|
|
2348
|
+
"0.43.0": {
|
|
2349
|
+
"files": {
|
|
2350
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2351
|
+
"README.md": "684b3cca8001673c",
|
|
2352
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2353
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2354
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2355
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2356
|
+
"posts/README.md": "37841aae476bd540",
|
|
2357
|
+
"site/README.md": "732894273a32dc3f",
|
|
2358
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2359
|
+
},
|
|
2360
|
+
"sections": {
|
|
2361
|
+
"AGENTS.md": {
|
|
2362
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2363
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2364
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2365
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2366
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2367
|
+
"## Making games": "9e44559de535d842",
|
|
2368
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2369
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2370
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2371
|
+
"## Servers and AI seats": "d0028361fe567add",
|
|
2372
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2373
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2374
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2375
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2376
|
+
"## Beta": "9d02638207a26e73"
|
|
2377
|
+
}
|
|
2378
|
+
},
|
|
2379
|
+
"named": {
|
|
2380
|
+
"Homie Arcade": {
|
|
2381
|
+
"sections": {
|
|
2382
|
+
"AGENTS.md": {
|
|
2383
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
},
|
|
2389
|
+
"0.44.0": {
|
|
2390
|
+
"files": {
|
|
2391
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2392
|
+
"README.md": "684b3cca8001673c",
|
|
2393
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2394
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2395
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2396
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2397
|
+
"posts/README.md": "37841aae476bd540",
|
|
2398
|
+
"site/README.md": "732894273a32dc3f",
|
|
2399
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2400
|
+
},
|
|
2401
|
+
"sections": {
|
|
2402
|
+
"AGENTS.md": {
|
|
2403
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2404
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2405
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2406
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2407
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2408
|
+
"## Making games": "9e44559de535d842",
|
|
2409
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2410
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2411
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2412
|
+
"## Servers and AI seats": "d0028361fe567add",
|
|
2413
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2414
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2415
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2416
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2417
|
+
"## Beta": "9d02638207a26e73"
|
|
2418
|
+
}
|
|
2419
|
+
},
|
|
2420
|
+
"named": {
|
|
2421
|
+
"Homie Arcade": {
|
|
2422
|
+
"sections": {
|
|
2423
|
+
"AGENTS.md": {
|
|
2424
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2285
2429
|
}
|
|
2286
2430
|
}
|
|
2287
2431
|
}
|
package/lib/trailer.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `homie-studio trailer <id>`: a trailer of a game, in one command.
|
|
3
3
|
*
|
|
4
|
-
* The work is not here. It is the Homie plugin's video skill (`video.mjs trailer`):
|
|
5
|
-
*
|
|
4
|
+
* The work is not here. It is the Homie plugin's video skill (`video.mjs trailer`): live footage for server rules,
|
|
5
|
+
* or a virtual clock and rebuilt sound for older browser games, selected highlights, an end card,
|
|
6
6
|
* and 16:9, 1:1 and 9:16 deliveries. This finds that script and hands the command over, so a person at a terminal
|
|
7
7
|
* and an agent with the plugin run the same thing. The video goes to videos/<id>-trailer/ (or --slug <slug>).
|
|
8
8
|
*
|
|
@@ -36,10 +36,14 @@ export function trailerCommand(root, id, rest, { skills = null, slug = null } =
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function trailerLines(r) {
|
|
39
|
+
const live = r.capture?.mode === 'live';
|
|
40
|
+
const audio = r.capture?.audio;
|
|
41
|
+
const sound = audio?.rebuilt ? `${audio.placed} sounds rebuilt from the game's own files`
|
|
42
|
+
: audio ? 'game audio captured live' : live ? 'no audio captured' : 'the game logged no sound';
|
|
39
43
|
return [
|
|
40
44
|
...(r.problems ?? []).map((x) => `PROBLEM: ${x}`),
|
|
41
45
|
`${r.seconds} s trailer of ${r.game}: ${Object.values(r.outputs ?? {}).map((o) => o.file).join(', ')}`,
|
|
42
|
-
` filmed ${r.capture?.seconds} s frame by frame (${r.capture?.frames} frames, ${r.capture?.heldFrames} held); ${
|
|
46
|
+
` filmed ${r.capture?.seconds} s ${live ? 'live' : 'frame by frame'} (${r.capture?.frames} frames, ${r.capture?.heldFrames} held); ${sound}`,
|
|
43
47
|
` ${r.edit?.shots} shots of ${r.edit?.shotSeconds} s, picked ${r.edit?.picked}`,
|
|
44
48
|
...(r.warnings ?? []).map((w) => ` warning: ${w}`),
|
|
45
49
|
...(r.next ? [` next (the video skill's script): ${r.next}`] : []),
|
package/lib/typecheck.mjs
CHANGED
|
@@ -93,12 +93,12 @@ export function typecheck(root, games, { log = () => {} } = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
// An error with no file is the project itself (a tsconfig.json that does not read): the game's to fix.
|
|
95
95
|
const bare = /^error (TS\d+): (.*)$/.exec(line);
|
|
96
|
-
if (bare) mine.push(
|
|
96
|
+
if (bare) mine.push(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}${own ? '/tsconfig.json' : ''} ${bare[1]} ${bare[2]}`);
|
|
97
97
|
}
|
|
98
98
|
rows.push({ id: g.id, checked: true, with: own ? 'its tsconfig.json' : 'the build\'s own settings', errors: mine.length, elsewhere });
|
|
99
|
-
if (elsewhere) log(`types: games/${g.id}: ${elsewhere} ${elsewhere === 1 ? 'error' : 'errors'} outside the game's own files (a package it imports as source); not the game's, so not counted`);
|
|
99
|
+
if (elsewhere) log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: ${elsewhere} ${elsewhere === 1 ? 'error' : 'errors'} outside the game's own files (a package it imports as source); not the game's, so not counted`);
|
|
100
100
|
if (mine.length) found.push(...mine);
|
|
101
|
-
else log(`types: games/${g.id} has no type errors (TypeScript ${ts.version ?? '?'}, ${own ? 'its tsconfig.json' : 'the build\'s own settings'})`);
|
|
101
|
+
else log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} has no type errors (TypeScript ${ts.version ?? '?'}, ${own ? 'its tsconfig.json' : 'the build\'s own settings'})`);
|
|
102
102
|
}
|
|
103
103
|
if (found.length) {
|
|
104
104
|
const shown = found.slice(0, 20);
|
|
@@ -125,7 +125,7 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
|
|
|
125
125
|
'@homie-rocks/studio/*': [join(PACKAGE_ROOT, '*')],
|
|
126
126
|
} }, files: [join(g.dir, 'src/rules.ts'), join(g.dir, g.entry ?? 'src/view.ts'), join(dir, 'files.d.ts')] }, null, 2)}\n`);
|
|
127
127
|
const res = await runRulesCompiler(compiler, ['-p', project, '--pretty', 'false']);
|
|
128
|
-
if (res.error) throw new Error(
|
|
128
|
+
if (res.error) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: the rules type check could not finish: ${res.error.message}`);
|
|
129
129
|
const messages = [];
|
|
130
130
|
for (const line of `${res.stdout ?? ''}\n${res.stderr ?? ''}`.split(/\r?\n/)) {
|
|
131
131
|
const at = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
@@ -134,7 +134,9 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
|
|
|
134
134
|
// Imported game helpers count too. Only the toolkit's own implementation is outside the author's remit.
|
|
135
135
|
if (!file.startsWith(`${PACKAGE_ROOT}${sep}`) && !file.includes(`${sep}node_modules${sep}`) || file.startsWith(`${g.dir}${sep}`)) {
|
|
136
136
|
const sourceLine = readFileSync(file,'utf8').split(/\r?\n/)[Number(at[2])-1] ?? '';
|
|
137
|
-
const advice = /Property '
|
|
137
|
+
const advice = /Property 'bot'.*RosterRow/.test(at[5]) ? "Rules roster rows use driver === 'bot' or 'ai' (not row.bot); preserve the visible AI label."
|
|
138
|
+
: /'[^']*\.at' is possibly 'undefined'/.test(at[5]) ? 'Effects carry either at (position) or id (entity). Check e.at before reading coordinates, or resolve e.id with room.get.'
|
|
139
|
+
: /Property 'ask'.*type 'never'/.test(at[5]) ? 'This handler scope never calls world.ask, so no answer can arrive here. Handle the answer in the scope that asks.'
|
|
138
140
|
: /world\.round\.end/.test(sourceLine) && at[4] === 'TS2339' ? 'Only room handlers end a round. Send a declared event to a room handler that calls world.round.end().'
|
|
139
141
|
: /'check'.*does not exist/.test(at[5]) ? 'Remove the obsolete check key. Generated observations require no exemption.'
|
|
140
142
|
: /=> (?:GameDecision|GuideDecision)'/.test(at[5]) ? 'A floor returns goal and say names from agents.json with exactly the args and sayArgs each declares, or {} to decline. Forward a request as { goal: ask.k, args: ask.args }.'
|
|
@@ -150,7 +152,7 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
|
|
|
150
152
|
} else if (/^error TS/.test(line)) messages.push(line);
|
|
151
153
|
else if (/^\s+\S/.test(line) && messages.length) messages[messages.length - 1] += `\n${line.length > 360 ? line.slice(0, 220) + ' … ' + line.slice(-100) : line}`;
|
|
152
154
|
}
|
|
153
|
-
if (messages.length) throw new Error(
|
|
154
|
-
if (res.status !== 0 && !res.stdout) throw new Error(
|
|
155
|
-
log(`types: games/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
|
|
155
|
+
if (messages.length) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules have type errors:\n${messages.slice(0, 30).map((m) => ` ${m}`).join('\n')}`);
|
|
156
|
+
if (res.status !== 0 && !res.stdout) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: TypeScript stopped without diagnostics (${res.signal ?? res.status})`);
|
|
157
|
+
log(`types: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
|
|
156
158
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Homie netplay contract, v1 (revision 11)
|
|
2
2
|
|
|
3
|
+
**Authoring now:** New games use rules plus view and the server as host. Read the
|
|
4
|
+
Homie game skill's RULES.md and section 29 below. Sections 1–28 preserve the
|
|
5
|
+
browser-hosted helper contract for existing games and describe shared shell
|
|
6
|
+
features; their createNetplay/host-loop examples are not a recipe for new games.
|
|
7
|
+
Use the skill's REWRITE.md for an owner-requested rewrite. All five starters use
|
|
8
|
+
server rules. Room seats are capped at 32; larger rooms are future work.
|
|
9
|
+
|
|
10
|
+
|
|
3
11
|
Status: **v1, revision 11** (2026-10-08, `@homie-rocks/studio` 0.33.1). The wire
|
|
4
12
|
version is `v: 1`. Everything revisions 2 to 11 added is either an optional field, a new message type,
|
|
5
13
|
a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
|
package/netplay/netplay.ts
CHANGED
|
@@ -2628,6 +2628,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2628
2628
|
setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
|
|
2629
2629
|
if (next === 'host' && snap) tick = Math.max(tick, snap.k);
|
|
2630
2630
|
}
|
|
2631
|
+
// A reconnect to the same role emits no role event, but its welcome is a new
|
|
2632
|
+
// connection's keyframe. Rules views must adopt it before the first delta.
|
|
2633
|
+
if (rulesGame && snap && next !== 'host') emit('snapshot', snap);
|
|
2631
2634
|
if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
|
|
2632
2635
|
if (link === 'online') paintLink(); else setLink('online', 'welcome');
|
|
2633
2636
|
// A build older than the live one, let in to its own room (section 23).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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",
|
package/rules/host.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* host.ts — the host runtime: a rules module playing the host's part on the wire.
|
|
3
3
|
* =============================================================================
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* The server normally hosts the room: rules, clock and state. Browser hosting uses the same runtime
|
|
6
|
+
* for offline play, local development or private friends games. It takes a compiled rules module (rules.ts) and three things from its
|
|
7
7
|
* caller: a way to send a frame, a clock and a store. It has no transport in it and no Cloudflare in it, in the style of
|
|
8
8
|
* worker/room.mjs. Inside the `Table` (worker/index.mjs) the relay hands it parsed frames and fans out what it sends;
|
|
9
9
|
* in Node the tests and the build check drive it with a clock of their own.
|
|
@@ -99,6 +99,7 @@ export interface Host {
|
|
|
99
99
|
readonly running: boolean;
|
|
100
100
|
readonly paused: boolean;
|
|
101
101
|
readonly people: number;
|
|
102
|
+
readonly viewRadiusM: number | null;
|
|
102
103
|
frame(m: Record<string, unknown>): void;
|
|
103
104
|
/** Start the tick loop (the first person has joined). */
|
|
104
105
|
start(): void;
|
|
@@ -767,6 +768,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
767
768
|
}
|
|
768
769
|
|
|
769
770
|
return {
|
|
771
|
+
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
770
772
|
get tick() { return core.tick; },
|
|
771
773
|
get epoch() { return epoch; },
|
|
772
774
|
get running() { return running; },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Per-connection state delivery. Pure data, shared by the relay and the view.
|
|
2
|
+
* Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
|
|
3
|
+
* therefore loses no baseline. A new keyframe bounds recovery to one second.
|
|
4
|
+
* No history grows with time: one keyframe per connection, no per-frame queue.
|
|
5
|
+
*/
|
|
6
|
+
export function interestSnapshot(snap, seat, radiusM) {
|
|
7
|
+
const [round, entities] = snap.d;
|
|
8
|
+
const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
|
|
9
|
+
const visible = own && radiusM !== null ? entities.filter(e => {
|
|
10
|
+
const p = e[3], q = own[3];
|
|
11
|
+
return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
|
|
12
|
+
}) : seat === null ? entities : own ? entities : [];
|
|
13
|
+
return { ...snap, d: [round, visible], c: (snap.c ?? []).filter(row => row[0] === seat) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function snapshotEncoder(keyframeTicks) {
|
|
17
|
+
let base = null;
|
|
18
|
+
let rows = new Map();
|
|
19
|
+
return {
|
|
20
|
+
reset() { base = null; rows.clear(); },
|
|
21
|
+
encode(snap) {
|
|
22
|
+
if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
|
|
23
|
+
base = snap;
|
|
24
|
+
rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
|
|
25
|
+
return snap;
|
|
26
|
+
}
|
|
27
|
+
const present = new Set(), changed = [];
|
|
28
|
+
for (const entity of snap.d[1]) {
|
|
29
|
+
const id = entity[0]; present.add(id);
|
|
30
|
+
const before = rows.get(id);
|
|
31
|
+
if (!before) { changed.push(entity); continue; }
|
|
32
|
+
let mask = 0; const values = [];
|
|
33
|
+
for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
|
|
34
|
+
if (mask) changed.push([id, mask, values]);
|
|
35
|
+
}
|
|
36
|
+
return { ...snap, d: { base: base.k, round: snap.d[0], changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function snapshotDecoder() {
|
|
42
|
+
let base = null;
|
|
43
|
+
let rows = new Map();
|
|
44
|
+
return {
|
|
45
|
+
reset() { base = null; rows.clear(); },
|
|
46
|
+
decode(snap) {
|
|
47
|
+
if (!snap || typeof snap !== 'object') return null;
|
|
48
|
+
if (Array.isArray(snap.d)) {
|
|
49
|
+
if (!Array.isArray(snap.d[0]) || !Array.isArray(snap.d[1]) ||
|
|
50
|
+
!snap.d[1].every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string')) return null;
|
|
51
|
+
// A late duplicate keyframe must not replace the newer baseline.
|
|
52
|
+
if (!base || snap.e !== base.e || snap.k >= base.k) {
|
|
53
|
+
base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
|
|
54
|
+
}
|
|
55
|
+
return snap;
|
|
56
|
+
}
|
|
57
|
+
const d = snap.d;
|
|
58
|
+
if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
|
|
59
|
+
if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
|
|
60
|
+
const next = new Map(rows);
|
|
61
|
+
for (const id of d.removed) next.delete(id);
|
|
62
|
+
for (const change of d.changed) {
|
|
63
|
+
if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
|
|
64
|
+
if (change.length !== 3) {
|
|
65
|
+
if (change.length < 9) return null;
|
|
66
|
+
next.set(change[0], change); continue;
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
|
|
69
|
+
const old = rows.get(change[0]);
|
|
70
|
+
if (!old) return null;
|
|
71
|
+
const entity = old.slice(); let j = 0;
|
|
72
|
+
for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
|
|
73
|
+
if (j !== change[2].length) return null;
|
|
74
|
+
next.set(entity[0], entity);
|
|
75
|
+
}
|
|
76
|
+
return { ...snap, d: [d.round, [...next.values()]] };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|