@homie-rocks/studio 0.46.2 → 0.46.5
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 +106 -0
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +127 -3
- package/guides/game/SKILL.md +7 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/rules-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +3 -1
- package/package.json +1 -1
- package/rules/core.ts +29 -4
- package/rules/host.ts +94 -14
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +24 -7
- package/rules/types.ts +5 -0
- package/rules/view.ts +291 -33
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +20 -2
- package/worker/pages.mjs +46 -1
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +28 -3
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,112 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.46.5] - 2026-10-11
|
|
21
|
+
|
|
22
|
+
**Plugin 0.46.5**
|
|
23
|
+
|
|
24
|
+
A page left open across a deploy is brought up to date with its game, and a check never opens the live site
|
|
25
|
+
unless it is asked to.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `room.bots: { keep, names: [...] }`: a rules game names its own bots, by seat. The roster, the results and
|
|
30
|
+
`world.label` all use it. Opt-in: without `names` the toolkit's own list is used, as before.
|
|
31
|
+
|
|
32
|
+
- A predictor can judge its own shot as the server will: `ctx.judgedTick(aimTick)` (the tick the server will
|
|
33
|
+
pose the others at for this seat; the snapshot's control row now carries the seat's own rewind allowance in a
|
|
34
|
+
game that declares `room.historySeconds`), `ctx.ray/rays(..., { atTick })` and `room.poseAt(id, tick)` (the pose
|
|
35
|
+
at a tick from the snapshots the browser holds), and `ctx.math` (the rules' own `math`).
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
Each of these changes what happened before; none needs a change to a game.
|
|
40
|
+
|
|
41
|
+
- A play page open during a deploy kept hosting the newer game inside the older page: only the frame was loaded
|
|
42
|
+
again. When the newer game needed something the old page did not have (a player ticket, for a game that had
|
|
43
|
+
just started keeping results with `room.records`), the player saw an empty history and finished rounds were not
|
|
44
|
+
recorded until they reloaded the page themselves. The page now says what it was made for, the Worker answers
|
|
45
|
+
the frame with a stand-in when a page made now would differ, and the page does one full reload to the same room
|
|
46
|
+
and seat. A page from before this handshake is sent to a fresh page, by the one way it has, only when the game
|
|
47
|
+
cannot keep its results inside it. A deploy that changes only a game's code or art still reloads the frame alone.
|
|
48
|
+
- A finished round that had no player to be written for was dropped without a word. The log now says
|
|
49
|
+
`record-unsaved`, and the player is told on their screen that the round was not saved and to reload.
|
|
50
|
+
- The player's own body was drawn sinking for the whole tick it landed in, and standing only at the tick's end
|
|
51
|
+
(up to 33 ms late at 20 ticks a second). It is drawn down to the ground by the moment it met it.
|
|
52
|
+
- `homie-studio check` with no `--url` opened the studio's **live** site, and with no game the first game by name:
|
|
53
|
+
`npm run check` could seat two check browsers in a live public room of a game nobody was working on. With no
|
|
54
|
+
`--url` a check now runs against this studio's own build: its running dev server, or one it starts on a free
|
|
55
|
+
port and stops afterwards. With no game it covers the games this checkout changed (against the default branch,
|
|
56
|
+
committed or not), else every game. The live site is checked only when asked: `--live`, or its address in
|
|
57
|
+
`--url`. `port check` and `perf` follow the same rule (the running dev server, `--url`, or `--live`).
|
|
58
|
+
|
|
59
|
+
### Upgrade notes
|
|
60
|
+
|
|
61
|
+
- Nothing to do. A script that relied on `homie-studio check` reaching the live site says `--live` now.
|
|
62
|
+
- The first deploy of this version reloads open play pages whole, once (their pages are older than it).
|
|
63
|
+
- Version 0.46.5 is provisional. No deploy or merge is part of this PR.
|
|
64
|
+
|
|
65
|
+
## [0.46.3] - 2026-10-11
|
|
66
|
+
|
|
67
|
+
**Plugin 0.46.3**
|
|
68
|
+
|
|
69
|
+
A guest who signs in keeps both histories; a rival's sounds reach the players who can hear them; a shooter's own
|
|
70
|
+
actions answer at once; a tap moves as far as it was held; a client cannot stretch its own lag compensation.
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
Every item here is opt-in: a game that declares none of them plays, costs and saves exactly as on 0.46.2.
|
|
75
|
+
|
|
76
|
+
- `mergeSaves({ '<game>': { saves: { '<key or prefix*>': rule }, stats: { … } } })` from
|
|
77
|
+
`@homie-rocks/studio/worker`: how a guest's saves combine with an account's when the guest signs in to an account
|
|
78
|
+
that already has the same key. Rules: `'account'`, `'guest'`, `'newer'`, `'larger'`, `'smaller'`, `'sum'`,
|
|
79
|
+
`'union'` (a list joined by id), or the game's own function, run on the server. It happens once, in the sign-in's
|
|
80
|
+
own transaction, guarded against a room writing a result at the same moment. The sign-in response says what
|
|
81
|
+
was merged, moved and kept (`adopted`), and a key no rule could combine reaches the game's `onConflict` with the
|
|
82
|
+
guest's value (saves/SAVES.md, "When a guest signs in to their account").
|
|
83
|
+
- Effects with an audience: `world.emit(effect, at, data, { within: metres, to: id | [ids] })` sends an effect only
|
|
84
|
+
to the players whose own body is in range or who are named; a watcher receives the ones with a range. For a sound a rival
|
|
85
|
+
could hear or a number only the shooter may see, in place of drawing it from state the rival should not have.
|
|
86
|
+
An effect with no audience is public and is sent as before.
|
|
87
|
+
- `f.held()`, an input type: the browser sends the average of the control over each tick, so a key held for part
|
|
88
|
+
of a tick moves the body for that part. Short taps no longer travel a whole tick or two by where the tick's
|
|
89
|
+
edges fell. An entry that reaches the server after its tick is settled on the next ticks, not counted twice.
|
|
90
|
+
- `openRoom({ predict: { <kind>(me, input, ctx) { … } } })`: the player's own actions predicted like their own
|
|
91
|
+
movement (run each predicted tick and between ticks, put right by every snapshot and re-run). `ctx.ghost` shows a
|
|
92
|
+
provisional entity and `ctx.effect` a provisional effect that the server's own then confirms (`confirms: true`)
|
|
93
|
+
or the room retracts (`room.on('retract', …)`). A valued pulse (`f.pulse()`) now names the tick it was pressed
|
|
94
|
+
on and the server acts on it in that tick; the control row says the newest pulse acted on, so a prediction
|
|
95
|
+
holds when the pulse's own message is late (as it can be through a Gate).
|
|
96
|
+
- `world.unitsLeft`: the budget units the running handler may still spend, so a handler that plans can stop early.
|
|
97
|
+
- `room.rewindSeconds` in rules: the game's hard cap on how far a person's ray is rewound for latency (from 0 to
|
|
98
|
+
`historySeconds`). Without it the cap is `historySeconds`, as before; `0.25` is a sensible value for a shooter.
|
|
99
|
+
|
|
100
|
+
### Fixed
|
|
101
|
+
|
|
102
|
+
Each of these changes what happened before; none needs a change to a game.
|
|
103
|
+
|
|
104
|
+
- A guest with verified results who signed in to an account that also had some kept only the account's: the
|
|
105
|
+
guest's `server:` record was dropped, and nothing could combine the two afterwards because a browser cannot
|
|
106
|
+
write a `server:` key. Two server records are now joined by id when the guest signs in, with no rule declared.
|
|
107
|
+
- A result a room wrote for the guest a moment after they signed in was written under a player that no longer
|
|
108
|
+
existed. It now follows the guest to the account (`appendServerRecord`, at the same one read and one write).
|
|
109
|
+
- Lag compensation could be stretched by a client: the round trip was timed on a separate answer the client could
|
|
110
|
+
hold back, and only the smallest value ever seen was kept. It is now timed on the input frames themselves (each
|
|
111
|
+
names the newest snapshot its browser had), as a low percentile over the last ten seconds, and the allowance
|
|
112
|
+
counts the whole ticks an input waited after it arrived instead of a fixed one. A client still cannot look
|
|
113
|
+
faster than it is; one that pretends to be slower is bounded by `room.rewindSeconds`.
|
|
114
|
+
|
|
115
|
+
### Upgrade notes
|
|
116
|
+
|
|
117
|
+
- Nothing to do for a studio that declares none of the above. To use `mergeSaves`, add it to
|
|
118
|
+
`site/src/worker.mjs` beside `hostRules`.
|
|
119
|
+
- A page from before this version that is still open against the new Worker is timed the old way until it
|
|
120
|
+
reloads. Its rewind is bounded by the same cap.
|
|
121
|
+
- All of the above works the same through Gates (a room of more than 64 seats) as in a small room; each has a
|
|
122
|
+
seeded test both ways. Through a Gate a held input that misses its tick can be drawn a few centimetres short for
|
|
123
|
+
a tick or two before it settles.
|
|
124
|
+
- Version 0.46.3 is provisional. No deploy or merge is part of this PR.
|
|
125
|
+
|
|
20
126
|
## [0.46.2] - 2026-10-11
|
|
21
127
|
|
|
22
128
|
**Plugin 0.46.2** · Big rooms: the room runs the rules, its Gates do the rest.
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -785,6 +785,8 @@ function print(result) {
|
|
|
785
785
|
`Written: ${result.file}`);
|
|
786
786
|
break;
|
|
787
787
|
case 'check':
|
|
788
|
+
if (result.results) { for (const r of result.results) lines.push(`${r.game}: PASS at ${r.site}: room ${r.room}, round ${r.round?.n} (${r.round?.humans} humans, ${r.round?.bots} bots), connection ${r.uninterrupted === true ? 'uninterrupted' : r.uninterrupted === false ? 'INTERRUPTED' : 'unknown'}`); break; }
|
|
789
|
+
if (result.site) lines.push(`Checked ${result.local ? 'this studio\'s own build' : 'the site'} at ${result.site}.`);
|
|
788
790
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
789
791
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
|
|
790
792
|
...(result.frames ?? []).map((f) => ` ${f.browser} drew ${f.fps ?? '?'} fps${f.renderer ? ` on ${f.renderer}` : ''}`),
|
|
@@ -933,8 +935,9 @@ async function main() {
|
|
|
933
935
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
934
936
|
if (cmd === 'port' && sub === 'check') {
|
|
935
937
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
936
|
-
|
|
937
|
-
|
|
938
|
+
// Never the live site unless asked (--live, or its address): this studio's running dev server, else say so.
|
|
939
|
+
const url = flags.get('url') ?? (flags.has('live') ? siteUrl(root) : (await import('../lib/dev.mjs')).runningDev(root)?.url);
|
|
940
|
+
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address), start npm run dev, or say --live for the live site' };
|
|
938
941
|
return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
|
|
939
942
|
}
|
|
940
943
|
if (cmd === 'app' && sub === 'role') {
|
|
@@ -955,8 +958,8 @@ async function main() {
|
|
|
955
958
|
if (cmd === 'lab') return labCommand(root, sub);
|
|
956
959
|
if (cmd === 'perf') {
|
|
957
960
|
const game = sub ?? listGames(root)[0]?.id;
|
|
958
|
-
const url = flags.get('url') ?? siteUrl(root);
|
|
959
|
-
if (!url) return { ok: false, command: 'perf', why: 'give --url (the local dev address or the live site
|
|
961
|
+
const url = flags.get('url') ?? (flags.has('live') ? siteUrl(root) : (await import('../lib/dev.mjs')).runningDev(root)?.url);
|
|
962
|
+
if (!url) return { ok: false, command: 'perf', why: 'give --url (the local dev address), start npm run dev, or say --live for the live site' };
|
|
960
963
|
const devices = String(flags.get('device') ?? 'computer,phone').split(',').map((d) => d.trim()).filter(Boolean);
|
|
961
964
|
const num = (k, d, lo, hi) => Math.max(lo, Math.min(hi, Number(flags.get(k) ?? d) || d));
|
|
962
965
|
return perfRun({ root, url, game, devices, runs: num('runs', 1, 1, 50), seconds: num('seconds', 15, 3, 300), warm: num('warm', 3, 0, 60), profile: flags.has('profile'), out: flags.get('out') ? resolve(String(flags.get('out'))) : null, maxLoad: num('max-load', 0.8, 0.05, 50), cpu: num('cpu', 4, 1, 20), pair: flags.get('pair') ? String(flags.get('pair')) : null, log });
|
|
@@ -973,14 +976,30 @@ async function main() {
|
|
|
973
976
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
974
977
|
if (cmd === 'dev') return dev(root, { lan: flags.has('lan'), port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
|
|
975
978
|
if (cmd === 'check') {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
+
// lib/check-site.mjs: with no --url a check runs against this studio's own build, never the live site (--live asks
|
|
980
|
+
// for that); with no game it covers the games this checkout changed.
|
|
981
|
+
const { checkSite, gamesToCheck } = await import('../lib/check-site.mjs');
|
|
982
|
+
const { games, why } = gamesToCheck(root, positional[1]);
|
|
983
|
+
if (!games.length) return { ok: false, command: 'check', why: 'this studio has no game to check' };
|
|
979
984
|
// A big binary committed under games/ fails the check before any browser starts: it belongs in R2, never in git.
|
|
980
985
|
const { bigCommittedFiles } = await import('../lib/asset-check.mjs');
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
986
|
+
for (const game of games) {
|
|
987
|
+
const big = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${game}/`));
|
|
988
|
+
if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached <file>) and check again.` };
|
|
989
|
+
}
|
|
990
|
+
const site = await checkSite(root, { url: flags.get('url') ?? null, live: flags.has('live'), log });
|
|
991
|
+
if (!site.ok) return { ok: false, command: 'check', why: site.why };
|
|
992
|
+
try {
|
|
993
|
+
if (!positional[1]) log(`check: ${games.join(', ')} (${why}) at ${site.url}`);
|
|
994
|
+
const results = [];
|
|
995
|
+
for (const game of games) {
|
|
996
|
+
const shots = flags.get('shots') ? resolve(flags.get('shots'), ...(games.length > 1 ? [game] : [])) : null;
|
|
997
|
+
const r = await tracked(root, 'checks', (report) => check({ url: site.url, game, shots, log, report }), 'check');
|
|
998
|
+
results.push({ ...r, game, site: site.url, local: site.local });
|
|
999
|
+
if (!r.ok) break;
|
|
1000
|
+
}
|
|
1001
|
+
return results.length === 1 ? results[0] : { ok: results.every((r) => r.ok), command: 'check', site: site.url, local: site.local, why: results.find((r) => !r.ok)?.why, results };
|
|
1002
|
+
} finally { await site.stop(); }
|
|
984
1003
|
}
|
|
985
1004
|
if (cmd === 'shoot') {
|
|
986
1005
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
package/guides/game/RULES.md
CHANGED
|
@@ -207,6 +207,7 @@ State belongs only in `fields`, `motion`, or `shared`; never a changing module v
|
|
|
207
207
|
| `f.fix()` | Multiples of 1/4096, ±2147483648; `fuel: f.fix({ init: 1 })` |
|
|
208
208
|
| `f.bit()` | Boolean; `alive: f.bit({ init: true })` |
|
|
209
209
|
| `f.press()` | Input-only one-shot boolean button; `fire: f.press()` |
|
|
210
|
+
| `f.held()` | Input-only number averaged over the tick (0.46.3); `ax: f.held()` |
|
|
210
211
|
| `f.tick()` | Absolute tick, 0–4294967295; `until: f.tick()` |
|
|
211
212
|
| `f.ticks()` | Tick duration, same range; `cooldown: f.ticks()` |
|
|
212
213
|
| `f.vec3()` | Float32 vector `{x,y,z}`; `target: f.vec3()` |
|
|
@@ -220,7 +221,7 @@ State belongs only in `fields`, `motion`, or `shared`; never a changing module v
|
|
|
220
221
|
Vector writes may omit z (it becomes zero); reads contain all three coordinates.
|
|
221
222
|
Integer writes round and clamp, not wrap. Guard against underflow explicitly:
|
|
222
223
|
`self.hp = Math.max(0, self.hp - damage)`. Inputs may contain only integer types,
|
|
223
|
-
fix, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
|
|
224
|
+
fix, held, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
|
|
224
225
|
A scalar is one cell; a vector four; a text is `1 + ceil(max/64)`;
|
|
225
226
|
a list is `1 + max * itemCells`; a map is `1 + max * (1 + itemCells)`;
|
|
226
227
|
a struct is one plus the cells of its fields. Map keys are at most 32 characters.
|
|
@@ -344,6 +345,7 @@ These costs bound work, not elapsed wall time.
|
|
|
344
345
|
| `sendArea(area,event,data?)` | Entity/room; 20 + 2N + 6C; 64 areas/tick, reach 64m | `world.sendArea({sphere:{at:self.pos,r:2}}, 'hit', {damage:1})` |
|
|
345
346
|
| `after(ticks,event,data?)` | Entity/room; 16 + 6C; wait clamped 1–2147483647; targets caller | `world.after(20, 'ready', {})` |
|
|
346
347
|
| `emit(effect,at,data?)` | Declared effect; 10 + 6C; at is position or ID; 256 effects a tick, and the next one throws | `world.emit('spark', self.pos, {})` |
|
|
348
|
+
| `emit(effect,at,data,audience)` | The same, plus 8, for who may perceive it (0.46.3): `{within: metres}`, `{to: id}` or `{to: [ids]}` (32 at most), or both | `world.emit('grunt', self.id, {}, {within: 45})` |
|
|
347
349
|
| `spawn(kind,at,fields?)` | Entity/room; 10 + copy cost; 2048 entities, non-player kinds only | `world.spawn('coin', self.pos, { value: 2 })` |
|
|
348
350
|
| `despawn(self)` | Own non-player entity; constant guard cost | `world.despawn(self)` |
|
|
349
351
|
| `place(self,at,options?)` | Own entity; 82; options vel/heading; clamps to bounds, changes movement revision | `world.place(self, world.map.spots('start')[0])` |
|
|
@@ -369,7 +371,8 @@ Queries return readonly views; send events to make recipients change themselves.
|
|
|
369
371
|
Math vectors are readonly too: replace `d.x += n` with
|
|
370
372
|
`d = { ...d, x: d.x + n }`, or use `world.math.add`/`scale`.
|
|
371
373
|
|
|
372
|
-
World properties: `tick`, `dt`, `
|
|
374
|
+
World properties: `tick`, `dt`, `unitsLeft` (0.46.3: budget units the running
|
|
375
|
+
handler may still spend, so a planner can stop early), `tune`, `shared`, `map`, `math`, `stage`, `level`,
|
|
373
376
|
`levelMax`, `round: {n,phase,endsAt}`. Phase is `live` or `over`.
|
|
374
377
|
`rounds.seconds: 0` means no automatic deadline: your room must call round.end.
|
|
375
378
|
A positive duration ends automatically. Round results arrive two ticks after the
|
|
@@ -941,7 +944,22 @@ Declare `room.historySeconds: 1` (maximum two seconds). The server retains and
|
|
|
941
944
|
saves non-collider body positions and placement revisions on its virtual clock.
|
|
942
945
|
The view's `room.viewTick` is the tick used to interpolate remote bodies. Send it
|
|
943
946
|
with an action, then use `world.ray(..., {atTick, ...filters})`. Fractional ticks
|
|
944
|
-
interpolate saved poses.
|
|
947
|
+
interpolate saved poses. A player's request is clamped to: the relay's own
|
|
948
|
+
measurement of that connection's round trip, plus 150 ms of presentation delay,
|
|
949
|
+
plus the whole ticks the input waited between arriving and running (at least one,
|
|
950
|
+
at most a quarter of a second). Without a measurement only that margin is allowed.
|
|
951
|
+
|
|
952
|
+
The measurement (0.46.3) is a low percentile of the last ten seconds of round
|
|
953
|
+
trips timed on the input frames themselves: each frame names the newest snapshot
|
|
954
|
+
its browser had, and the relay knows when that snapshot left. A client cannot
|
|
955
|
+
make it shorter than its real latency, and holding back a separate answer no
|
|
956
|
+
longer raises it. A modified client can still pretend to be further away than it
|
|
957
|
+
is, as it could by really being so; what that buys is bounded by the game's cap.
|
|
958
|
+
Set `room.rewindSeconds` (from 0 to `historySeconds`): the most any shot is
|
|
959
|
+
rewound, whatever the connection reports. `0.25` suits a shooter: a round trip
|
|
960
|
+
up to about 100 ms is compensated in full and a slower player leads the target a
|
|
961
|
+
little. Without it the cap is `historySeconds`, as before. Client-supplied
|
|
962
|
+
latency figures are ignored.
|
|
945
963
|
A placement or new round prevents rewinding through a teleport or reset.
|
|
946
964
|
|
|
947
965
|
Rewind changes actor poses only. Eligibility filters use current fields, so a
|
|
@@ -986,6 +1004,112 @@ skips the intermission. Use it for an authorized retry; validate eligibility in
|
|
|
986
1004
|
your room handler. Pending restart intent survives save/restore.
|
|
987
1005
|
|
|
988
1006
|
|
|
1007
|
+
### Held input: a tap moves for the time it was down (0.46.3)
|
|
1008
|
+
|
|
1009
|
+
An ordinary input field is read once a tick, at the tick's edge. A key held for
|
|
1010
|
+
60 ms of a 50 ms tick therefore moves the body for one tick or for two, by where
|
|
1011
|
+
the edges happened to fall: short taps travel too far and vary. Declare a
|
|
1012
|
+
movement axis `ax: f.held()` instead. The browser then sends the average of what
|
|
1013
|
+
the control read over each tick (a key down for a third of the tick reads a
|
|
1014
|
+
third of its value; an analogue stick reads its mean), prediction uses the same
|
|
1015
|
+
number, and `move` multiplies speed by it as before, so the body moves for the
|
|
1016
|
+
part of the tick the key was down. `room.input({ax: 1})` is called as usual,
|
|
1017
|
+
every frame or on change; a boolean counts as 1. `think` returns a plain number.
|
|
1018
|
+
Use it for movement axes, not for buttons: a button is `f.press()` or `f.pulse()`.
|
|
1019
|
+
Keep its values in -1 to 1. An entry that reaches the server after its tick is
|
|
1020
|
+
not counted twice: the tick it missed ran on the value before it, and the server
|
|
1021
|
+
settles the difference on the next ticks, so the body ends where the browser
|
|
1022
|
+
predicted.
|
|
1023
|
+
|
|
1024
|
+
### Effects with an audience: what a player may perceive (0.46.3)
|
|
1025
|
+
|
|
1026
|
+
Everything in a snapshot's public fields and every plain `world.emit` reaches
|
|
1027
|
+
every player. A sound or a flash must not be drawn from state a rival should not
|
|
1028
|
+
have (another player's exact health), and need not be sent to someone who could
|
|
1029
|
+
not perceive it. Emit the event instead, and say who perceives it:
|
|
1030
|
+
|
|
1031
|
+
```ts
|
|
1032
|
+
world.emit('grunt', self.id, {}, { within: 45 }); // earshot of where self stands
|
|
1033
|
+
world.emit('hitConfirm', target.id, { damage }, { to: shooter.id }); // the shooter alone
|
|
1034
|
+
world.emit('alarm', self.pos, {}, { within: 30, to: allies }); // either is enough
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
`within` is metres from the effect's place (the named entity's position at that
|
|
1038
|
+
moment) to the listener's own body; `to` names entities, and the people driving
|
|
1039
|
+
them receive it. A watcher, and a seat with no body yet, perceive the room: they
|
|
1040
|
+
receive every effect with a `within`, and never one addressed only `to` somebody.
|
|
1041
|
+
An effect with no audience is public, exactly as before. Delivery costs one
|
|
1042
|
+
frame a listener on ticks that have such effects and nothing on ticks that do
|
|
1043
|
+
not. Send what is perceivable (a hit landed, roughly how hard); send exact
|
|
1044
|
+
numbers only `to` the player entitled to them.
|
|
1045
|
+
|
|
1046
|
+
### Predicting the player's own actions (0.46.3)
|
|
1047
|
+
|
|
1048
|
+
The own position is predicted by `move`. Other own actions (a weapon swap, a
|
|
1049
|
+
build, a reload, a hit marker) otherwise wait for the server's round trip. Give
|
|
1050
|
+
the view a predictor for the kind:
|
|
1051
|
+
|
|
1052
|
+
```ts
|
|
1053
|
+
const room = openRoom<typeof rules>({ predict: { runner(me, input, ctx) {
|
|
1054
|
+
if (input.select) swap(me, input.select, ctx.tick * ctx.dt); // the rules' own pure helper
|
|
1055
|
+
if (input.build && me.builds > 0) { me.builds--; ctx.ghost('cover', spot(me), { size }); }
|
|
1056
|
+
if (input.fire && aimHits(ctx, me)) ctx.effect('hit', me.id, {});
|
|
1057
|
+
} } });
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
The room runs it for the own body once a predicted tick, after `move`, with that
|
|
1061
|
+
tick's input, and on a copy between ticks, so a press answers on the next drawn
|
|
1062
|
+
frame. `me` holds the body's declared fields (and readonly `id`, `pos`, `vel`,
|
|
1063
|
+
`heading`, `grounded`, `motion`); what it writes is held to the declared types
|
|
1064
|
+
and read back through `room.me`. Every snapshot puts the server's values back
|
|
1065
|
+
and runs the still-pending inputs again, exactly as it does for the position, so
|
|
1066
|
+
a wrong guess corrects itself. Share pure helpers with the rules (no `world`):
|
|
1067
|
+
the server's handler remains the authority and the only place a result counts.
|
|
1068
|
+
|
|
1069
|
+
`ctx.ghost(kind, at, fields?, {match?, seconds?})` draws a provisional entity:
|
|
1070
|
+
`room.each` yields it with `provisional: true` until an entity of that kind that
|
|
1071
|
+
`match(entity)` accepts (default: within a metre) arrives, and then until that
|
|
1072
|
+
one is drawn, so there is never a gap or a double. `ctx.effect(name, at, data?,
|
|
1073
|
+
{match?})` fires the effect's listeners now with `predicted: true`; the server's
|
|
1074
|
+
own effect of that name then arrives with `confirms: true` (use its exact data,
|
|
1075
|
+
do not replay the sound). If the server ran the tick without confirming, the
|
|
1076
|
+
room emits `retract` (`{ghost, kind}` or `{effect, ...data}`) and the ghost
|
|
1077
|
+
leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
|
|
1078
|
+
player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
|
|
1079
|
+
predictor runs often: keep it cheap, and keep per-entity state in `me`.
|
|
1080
|
+
|
|
1081
|
+
To guess a shot the server will judge with rewind, ask for what the server
|
|
1082
|
+
will use (0.46.5). `ctx.judgedTick(aimTick)` is the tick it will pose the others
|
|
1083
|
+
at: the `aimTick` the input carries, held to how far back this seat may look
|
|
1084
|
+
right now (the server says so in every snapshot) and to this tick. Pass it on:
|
|
1085
|
+
`ctx.ray(from, dir, max, { atTick: ctx.judgedTick(input.aimTick), ...filters })`
|
|
1086
|
+
poses the other bodies as the server will, from the snapshots this browser
|
|
1087
|
+
holds (between two of them, or carried on from the newest along its velocity).
|
|
1088
|
+
Inside the allowance that is the drawn target; past the game's cap it is a newer
|
|
1089
|
+
pose, and the predictor then shows no hit where the server will see none.
|
|
1090
|
+
`room.poseAt(id, tick)` gives `{pos, vel, known}` for the game's own tests
|
|
1091
|
+
(`known: false` is a guess past the newest snapshot). `ctx.math` is the rules'
|
|
1092
|
+
own `math`, and the context is accepted by guarded rules code as a world is for
|
|
1093
|
+
what the two share (`tick`, `dt`, `math`, `ray`, `rays`, `rayAll`, and a scene's
|
|
1094
|
+
`ray`, `rayAll`, `sweep`, `support`), so an aiming helper written for the rules
|
|
1095
|
+
can be called with `ctx` instead of being restated in the view. It has no
|
|
1096
|
+
`near`, `inBox`, `send` or `emit`: a helper that needs those is the server's.
|
|
1097
|
+
|
|
1098
|
+
A tick's prediction runs more than once (between ticks, again when the input
|
|
1099
|
+
changes, then the tick itself) and shows each thing once. Things are told apart
|
|
1100
|
+
by their name and `key`: give two effects of one name that mean different things
|
|
1101
|
+
(`ctx.effect('audio', me.id, {name: 'jump'}, {key: 'jump'})` beside a `reload`)
|
|
1102
|
+
a `key` each, and the same for two ghosts of a kind. Read `me.id` into a constant
|
|
1103
|
+
before using it in a `match` closure: `me` is reused. A valued pulse
|
|
1104
|
+
(`f.pulse()`) carries the tick it was predicted on, and the server acts on it in
|
|
1105
|
+
that tick, so a predicted swap is in the snapshot of the tick it was drawn for.
|
|
1106
|
+
|
|
1107
|
+
### Naming the bots (0.46.5)
|
|
1108
|
+
|
|
1109
|
+
`room.bots: { keep: 4, names: ['Vex', 'Moth', 'Kilo'] }` gives the game's bots its own names, by seat (the list
|
|
1110
|
+
wraps). The roster, the round's results and `world.label(id)` all say the same name, and a restore keeps it.
|
|
1111
|
+
Without `names` the toolkit's own list is used. A name is 1 to 24 characters on one line.
|
|
1112
|
+
|
|
989
1113
|
### Discrete actions with a value
|
|
990
1114
|
|
|
991
1115
|
Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
|
package/guides/game/SKILL.md
CHANGED
|
@@ -243,6 +243,11 @@ world or inventory, use Lasting worlds above. Ordinary match rooms expire after
|
|
|
243
243
|
handle. Draw the character's name over its body and in the ranking (a declared command requests it; rules validate and keep it in the player entity), except on a kids server, where the others stay handles. Ember Vale does it.
|
|
244
244
|
- Show who is playing (`saves.player.name`, guest or signed in) and a small "Keep my progress" button that calls
|
|
245
245
|
`saves.signIn()`; the play page shows its passkey sheet. Pressing Play never needs an account.
|
|
246
|
+
- A guest who signs in to an account that already has the same key: say how the two combine, once, in
|
|
247
|
+
`site/src/worker.mjs`: `mergeSaves({ '<id>': { saves: { coins: 'sum', 'best-level': 'larger', settings: 'newer' },
|
|
248
|
+
stats: { kills: 'sum' } } })` from `@homie-rocks/studio/worker` (or a function for an inventory). Server records
|
|
249
|
+
are joined without a rule; any other key with no rule reaches the game's `onConflict` (SAVES.md, "When a guest
|
|
250
|
+
signs in to their account").
|
|
246
251
|
- Prove it: build, `npm run dev`, open `http://localhost:8787/<id>/play` (passkeys need `localhost`, not
|
|
247
252
|
`127.0.0.1`), play, reload: the progress is still there. The whole guide is
|
|
248
253
|
`node_modules/@homie-rocks/studio/saves/SAVES.md` (the limits, offline and conflicts, what is stored, privacy).
|
|
@@ -299,6 +304,8 @@ npm run build # fix every error it name
|
|
|
299
304
|
npx --no-install homie-studio build --types # the same, with the TypeScript checked first: run it before a deploy
|
|
300
305
|
npm run dev # in the background: http://127.0.0.1:8787/<id>/play
|
|
301
306
|
npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 --shots ./.checks
|
|
307
|
+
# With no --url the check runs against this studio's own build (its running dev server, or one it starts and
|
|
308
|
+
# stops); with no <id>, on the games this checkout changed. The live site only with --live or its address.
|
|
302
309
|
npx --no-install homie-studio shoot <id> --preview # pictures of the built game on a stepped clock, no site needed (a 3D game where there is no GPU)
|
|
303
310
|
npx --no-install homie-studio preview <id> # only that game's built files at an address (alone, offline): for a capture script
|
|
304
311
|
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A CHECK RUNS, AND ON WHICH GAMES, WHEN NOBODY SAYS (0.46.5).
|
|
3
|
+
*
|
|
4
|
+
* A check puts real browsers in a real room. With no `--url` it used to open the studio's LIVE site, and with no game
|
|
5
|
+
* the first game by name: `npm run check` in a studio working on one game sat two check browsers in a live public
|
|
6
|
+
* room of another. A check is a gate on what is in this folder, so:
|
|
7
|
+
*
|
|
8
|
+
* where `--url <site>` as given; else this studio's own dev server: the one already running (lib/dev.mjs keeps
|
|
9
|
+
* its record), or one started for the check on a free port and stopped when the check ends. The live site
|
|
10
|
+
* only when asked by name: `--live`, or its address in `--url`.
|
|
11
|
+
* which the game named; else every game this checkout changed (files under games/<id>/ that differ from the
|
|
12
|
+
* default branch, committed or not); else, when nothing changed or this is not a git checkout, every game.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
+
import { once } from 'node:events';
|
|
16
|
+
import { createServer } from 'node:net';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { runningDev } from './dev.mjs';
|
|
19
|
+
import { listGames, siteUrl } from './studio.mjs';
|
|
20
|
+
|
|
21
|
+
const CLI = fileURLToPath(new URL('../bin/homie-studio.mjs', import.meta.url));
|
|
22
|
+
|
|
23
|
+
async function freePort() {
|
|
24
|
+
const s = createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening');
|
|
25
|
+
const port = s.address().port; await new Promise((r) => s.close(r)); return port;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The site a check opens: { ok, url, local, live, started, stop() }. `stop` ends only a server this call started.
|
|
30
|
+
* `spawnFn` and `fetchFn` are for tests.
|
|
31
|
+
*/
|
|
32
|
+
export async function checkSite(root, { url = null, live = false, log = () => {}, spawnFn = spawn, fetchFn = globalThis.fetch, waitMs = 240_000 } = {}) {
|
|
33
|
+
const none = async () => {};
|
|
34
|
+
if (url) return { ok: true, url: String(url), local: /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::|\/|$)/.test(String(url)), live: false, started: false, stop: none };
|
|
35
|
+
if (live) {
|
|
36
|
+
const site = siteUrl(root);
|
|
37
|
+
return site ? { ok: true, url: site, local: false, live: true, started: false, stop: none } : { ok: false, why: 'this studio has no live site yet (deploy it first), so there is nothing for --live to check' };
|
|
38
|
+
}
|
|
39
|
+
const running = runningDev(root);
|
|
40
|
+
if (running) return { ok: true, url: running.url, local: true, live: false, started: false, stop: none };
|
|
41
|
+
const port = await freePort(), origin = `http://127.0.0.1:${port}`;
|
|
42
|
+
log(`check: no --url, so this studio's own build is checked: starting its dev server on ${origin} (stopped when the check ends; --live checks the live site)`);
|
|
43
|
+
const child = spawnFn(process.execPath, [CLI, 'dev', '--port', String(port), '--no-local-ai'], { cwd: root, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
let tail = '';
|
|
45
|
+
for (const stream of [child.stdout, child.stderr]) stream?.on('data', (chunk) => { tail = (tail + chunk).slice(-4000); });
|
|
46
|
+
const stop = async () => {
|
|
47
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
48
|
+
const ended = once(child, 'exit');
|
|
49
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } }
|
|
50
|
+
const force = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, 8000);
|
|
51
|
+
try { await ended; } finally { clearTimeout(force); }
|
|
52
|
+
};
|
|
53
|
+
const deadline = Date.now() + waitMs;
|
|
54
|
+
while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) {
|
|
55
|
+
const up = await fetchFn(`${origin}/games.json`, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false);
|
|
56
|
+
if (up) return { ok: true, url: origin, local: true, live: false, started: true, stop };
|
|
57
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
58
|
+
}
|
|
59
|
+
await stop();
|
|
60
|
+
return { ok: false, why: `this studio's dev server did not start for the check${tail ? `:\n${tail.trim().split('\n').slice(-12).join('\n')}` : ''}\nStart it yourself (npm run dev) and give its address with --url, or fix the build.` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The ids of the games this checkout changed against its default branch (committed or not), in the studio's order; [] when it cannot be told. */
|
|
64
|
+
export function changedGames(root, { run = spawnSync } = {}) {
|
|
65
|
+
const git = (...args) => { const r = run('git', args, { cwd: root, encoding: 'utf8' }); return r.status === 0 ? String(r.stdout) : null; };
|
|
66
|
+
if (git('rev-parse', '--is-inside-work-tree') === null) return [];
|
|
67
|
+
const files = new Set((git('status', '--porcelain', '--', 'games') ?? '').split('\n').map((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()).filter(Boolean));
|
|
68
|
+
const head = (git('symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD') ?? '').trim();
|
|
69
|
+
for (const base of [head, 'origin/main', 'main', 'origin/master', 'master'].filter(Boolean)) {
|
|
70
|
+
const fork = (git('merge-base', 'HEAD', base) ?? '').trim();
|
|
71
|
+
if (!fork) continue;
|
|
72
|
+
for (const f of (git('diff', '--name-only', `${fork}..HEAD`, '--', 'games') ?? '').split('\n')) if (f) files.add(f);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const touched = new Set([...files].map((f) => /^games\/([^/]+)\//.exec(f)?.[1]).filter(Boolean));
|
|
76
|
+
return listGames(root).map((g) => g.id).filter((id) => touched.has(id));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The games a check covers: the one named, else what changed, else all of them. */
|
|
80
|
+
export function gamesToCheck(root, named, options) {
|
|
81
|
+
if (named) return { games: [named], why: 'named' };
|
|
82
|
+
const changed = changedGames(root, options);
|
|
83
|
+
if (changed.length) return { games: changed, why: 'changed in this checkout' };
|
|
84
|
+
return { games: listGames(root).map((g) => g.id), why: 'nothing changed against the default branch: every game' };
|
|
85
|
+
}
|
package/lib/check.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `homie-studio check <game> --url <site
|
|
2
|
+
* `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
|
|
3
|
+
* when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
|
|
3
4
|
* browsers (separate Chrome processes and profiles, one a computer and one a
|
|
4
5
|
* phone) press Play, land in the SAME public room, play, and both see the round
|
|
5
6
|
* finish. Exit 0 only when all of that happened. Uses this Mac's Chrome through
|
|
@@ -233,7 +233,7 @@ function play(c, companions, allow) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
/* ---- what the people press. Each plays one of three ways, changing every 20 s; seat 1 never sends anything. */
|
|
236
|
-
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
236
|
+
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' || fd.t === 'held' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
237
237
|
const steering = new Map();
|
|
238
238
|
let moveTick = 0;
|
|
239
239
|
let moveSelf;
|
package/lib/rules-check.mjs
CHANGED
|
@@ -28,10 +28,10 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
|
|
|
28
28
|
|
|
29
29
|
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
30
|
export function stateHash(c) {
|
|
31
|
-
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds } = c;
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds, rewindSeconds, botNames } = c;
|
|
32
32
|
// Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
|
|
33
33
|
const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
|
|
34
|
-
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
34
|
+
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), ...(rewindSeconds!==undefined?{rewindSeconds}:{}), ...(botNames?{botNames}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
package/lib/rules-types.mjs
CHANGED
|
@@ -78,7 +78,7 @@ type W = World<${parameters}>;
|
|
|
78
78
|
type RW = World<${parameters}, true>;
|
|
79
79
|
type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
|
|
80
80
|
entities: ${entities}; move?: Moves;
|
|
81
|
-
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number;
|
|
81
|
+
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number; names?: readonly string[] }; historySeconds?: number; rewindSeconds?: number;
|
|
82
82
|
start?: (world: RW) => void;
|
|
83
83
|
join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: (${joins}) | null }) => ${joins};
|
|
84
84
|
on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
|
|
@@ -2775,6 +2775,90 @@
|
|
|
2775
2775
|
}
|
|
2776
2776
|
}
|
|
2777
2777
|
}
|
|
2778
|
+
},
|
|
2779
|
+
"0.46.3": {
|
|
2780
|
+
"files": {
|
|
2781
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2782
|
+
"README.md": "684b3cca8001673c",
|
|
2783
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2784
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2785
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2786
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2787
|
+
"posts/README.md": "37841aae476bd540",
|
|
2788
|
+
"site/README.md": "732894273a32dc3f",
|
|
2789
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2790
|
+
},
|
|
2791
|
+
"sections": {
|
|
2792
|
+
"AGENTS.md": {
|
|
2793
|
+
"(intro)": "394cbbe934e80722",
|
|
2794
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2795
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2796
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2797
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2798
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2799
|
+
"## Making games": "bb715272fd1a615b",
|
|
2800
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2801
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2802
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2803
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2804
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2805
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2806
|
+
"## Rules": "27881d11d3ff3803",
|
|
2807
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2808
|
+
"## Beta": "9d02638207a26e73"
|
|
2809
|
+
}
|
|
2810
|
+
},
|
|
2811
|
+
"named": {
|
|
2812
|
+
"Homie Arcade": {
|
|
2813
|
+
"sections": {
|
|
2814
|
+
"AGENTS.md": {
|
|
2815
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
},
|
|
2821
|
+
"0.46.5": {
|
|
2822
|
+
"files": {
|
|
2823
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2824
|
+
"README.md": "684b3cca8001673c",
|
|
2825
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2826
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2827
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2828
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2829
|
+
"posts/README.md": "37841aae476bd540",
|
|
2830
|
+
"site/README.md": "732894273a32dc3f",
|
|
2831
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2832
|
+
},
|
|
2833
|
+
"sections": {
|
|
2834
|
+
"AGENTS.md": {
|
|
2835
|
+
"(intro)": "394cbbe934e80722",
|
|
2836
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2837
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2838
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2839
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2840
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2841
|
+
"## Making games": "bb715272fd1a615b",
|
|
2842
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2843
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2844
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2845
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2846
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2847
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2848
|
+
"## Rules": "27881d11d3ff3803",
|
|
2849
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2850
|
+
"## Beta": "9d02638207a26e73"
|
|
2851
|
+
}
|
|
2852
|
+
},
|
|
2853
|
+
"named": {
|
|
2854
|
+
"Homie Arcade": {
|
|
2855
|
+
"sections": {
|
|
2856
|
+
"AGENTS.md": {
|
|
2857
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2778
2862
|
}
|
|
2779
2863
|
}
|
|
2780
2864
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1699,6 +1699,18 @@ reads the other's snapshot with the wrong code.
|
|
|
1699
1699
|
- **An old tab that knocks at the live build's room is refused**: `error stale` (final; the helper
|
|
1700
1700
|
shows "This game was updated. Tap to reload." and the play page reloads the frame, at most twice
|
|
1701
1701
|
a minute).
|
|
1702
|
+
- **A page older than the game it would host is brought up to date too (0.46.5).** The frame alone
|
|
1703
|
+
is reloaded only while the play page around it is still the page the Worker would write now. The
|
|
1704
|
+
page says what it was made for (`sh` on the frame's address: the toolkit's version and the parts
|
|
1705
|
+
of the manifest a page is built around, `shellKey` in worker/pages.mjs; not the game's code or
|
|
1706
|
+
words). When that differs, the Worker answers the frame with a stand-in that says so, and the
|
|
1707
|
+
page does **one full reload**, to the same room (it is in the address) and the same seat (its
|
|
1708
|
+
token is in the tab). A page from before this handshake has only one way to load a whole new
|
|
1709
|
+
page, the one for leaving a finished room; it is used only when the game cannot work inside the
|
|
1710
|
+
old page at all: the game keeps results for a player (`room.records`) and the old page holds no
|
|
1711
|
+
player ticket. Such a player lands in a fresh page and a room the lobby gives them. A result
|
|
1712
|
+
that still has no player to be written for is never dropped in silence: the log says
|
|
1713
|
+
`record-unsaved`, and the player's screen says the round was not saved and to reload.
|
|
1702
1714
|
- **A tab of the live build that knocks at a room still on the old build waits**: `error
|
|
1703
1715
|
room-stale` (not final). The room's players are told to reload; when the last of them has left,
|
|
1704
1716
|
the next knock is let in, and the room **starts fresh**: its checkpoint, snapshot, round, roster
|