@homie-rocks/studio 0.46.3 → 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 +45 -0
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +23 -0
- package/guides/game/SKILL.md +2 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +42 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +2 -0
- package/package.json +1 -1
- package/rules/host.ts +10 -2
- package/rules/rules.ts +9 -3
- package/rules/view.ts +66 -12
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +18 -2
- package/worker/pages.mjs +46 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,51 @@ 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
|
+
|
|
20
65
|
## [0.46.3] - 2026-10-11
|
|
21
66
|
|
|
22
67
|
**Plugin 0.46.3**
|
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
|
@@ -1078,6 +1078,23 @@ leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
|
|
|
1078
1078
|
player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
|
|
1079
1079
|
predictor runs often: keep it cheap, and keep per-entity state in `me`.
|
|
1080
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
|
+
|
|
1081
1098
|
A tick's prediction runs more than once (between ticks, again when the input
|
|
1082
1099
|
changes, then the tick itself) and shows each thing once. Things are told apart
|
|
1083
1100
|
by their name and `key`: give two effects of one name that mean different things
|
|
@@ -1087,6 +1104,12 @@ before using it in a `match` closure: `me` is reused. A valued pulse
|
|
|
1087
1104
|
(`f.pulse()`) carries the tick it was predicted on, and the server acts on it in
|
|
1088
1105
|
that tick, so a predicted swap is in the snapshot of the tick it was drawn for.
|
|
1089
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
|
+
|
|
1090
1113
|
### Discrete actions with a value
|
|
1091
1114
|
|
|
1092
1115
|
Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
|
package/guides/game/SKILL.md
CHANGED
|
@@ -304,6 +304,8 @@ npm run build # fix every error it name
|
|
|
304
304
|
npx --no-install homie-studio build --types # the same, with the TypeScript checked first: run it before a deploy
|
|
305
305
|
npm run dev # in the background: http://127.0.0.1:8787/<id>/play
|
|
306
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.
|
|
307
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)
|
|
308
310
|
npx --no-install homie-studio preview <id> # only that game's built files at an address (alone, offline): for a capture script
|
|
309
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
|
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, rewindSeconds } = 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}:{}), ...(rewindSeconds!==undefined?{rewindSeconds}:{}), 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; rewindSeconds?: 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 };
|
|
@@ -2817,6 +2817,48 @@
|
|
|
2817
2817
|
}
|
|
2818
2818
|
}
|
|
2819
2819
|
}
|
|
2820
|
+
},
|
|
2821
|
+
"0.46.5": {
|
|
2822
|
+
"files": {
|
|
2823
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2824
|
+
"README.md": "684b3cca8001673c",
|
|
2825
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2826
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2827
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2828
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2829
|
+
"posts/README.md": "37841aae476bd540",
|
|
2830
|
+
"site/README.md": "732894273a32dc3f",
|
|
2831
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2832
|
+
},
|
|
2833
|
+
"sections": {
|
|
2834
|
+
"AGENTS.md": {
|
|
2835
|
+
"(intro)": "394cbbe934e80722",
|
|
2836
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2837
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2838
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2839
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2840
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2841
|
+
"## Making games": "bb715272fd1a615b",
|
|
2842
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2843
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2844
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2845
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2846
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2847
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2848
|
+
"## Rules": "27881d11d3ff3803",
|
|
2849
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2850
|
+
"## Beta": "9d02638207a26e73"
|
|
2851
|
+
}
|
|
2852
|
+
},
|
|
2853
|
+
"named": {
|
|
2854
|
+
"Homie Arcade": {
|
|
2855
|
+
"sections": {
|
|
2856
|
+
"AGENTS.md": {
|
|
2857
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2820
2862
|
}
|
|
2821
2863
|
}
|
|
2822
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
|
package/netplay/netplay.ts
CHANGED
|
@@ -2547,6 +2547,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2547
2547
|
// An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
|
|
2548
2548
|
if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
|
|
2549
2549
|
if (code === 'host-fault') { rulesNotice(String(m.message)); return; }
|
|
2550
|
+
// A result the server could not keep for this player (0.46.5): said on their screen, not dropped in silence.
|
|
2551
|
+
if (code === 'unsaved') { rulesNotice(String(m.message)); return; }
|
|
2550
2552
|
if (code === 'vote') { warnOnce('vote', m['message']); return; }
|
|
2551
2553
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
2552
2554
|
// The room still runs another build of the game (section 23): not final. Its players were told to reload; this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.5",
|
|
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
|
@@ -289,6 +289,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
289
289
|
*
|
|
290
290
|
* `room.rewindSeconds` is the game's hard cap on the sum. Without it the cap is `room.historySeconds`, as before.
|
|
291
291
|
*/
|
|
292
|
+
const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
|
|
292
293
|
const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
|
|
293
294
|
const rewindTicks = (seat: number): number => {
|
|
294
295
|
const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
|
|
@@ -628,7 +629,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
628
629
|
/* ---------------------------------------------------------------- frames out */
|
|
629
630
|
|
|
630
631
|
const hasHands = (seat: number, driver: Driver): boolean => driver === 'person' || (driver === 'ai' && [...peers.values()].some((p) => p.seat === seat && p.agent?.hands === 'self'));
|
|
631
|
-
|
|
632
|
+
// The game's own names for its bots (room.bots.names, 0.46.5), else the toolkit's.
|
|
633
|
+
const botNames = c.botNames?.length ? c.botNames : BOT_NAMES;
|
|
634
|
+
const nameOf = (seat: number, driver: Driver): string => (driver === 'bot' || (driver === 'ai' && (core.bodies().find((b) => b.seat === seat)?.owner === 'reserved' || !names.has(seat))) ? botNames[seat % botNames.length] : names.get(seat) || `Player ${seat + 1}`);
|
|
632
635
|
function sendRoster(): void {
|
|
633
636
|
const guides = new Map(agentSlots().map((s) => [s.slot, s.agent]));
|
|
634
637
|
const slots = core.bodies().sort((a, b) => a.seat - b.seat).map((b) => ({ slot: b.seat, seat: b.driver === 'bot' ? null : b.seat, name: nameOf(b.seat, b.driver), bot: b.driver === 'bot', ...(guides.has(b.seat) ? { agent: guides.get(b.seat) } : {}) }));
|
|
@@ -749,7 +752,12 @@ export function createHost(o: HostOptions): Host {
|
|
|
749
752
|
const q = queues.get(b.seat);
|
|
750
753
|
// A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
|
|
751
754
|
const taken = pulsesTaken.get(b.seat);
|
|
752
|
-
|
|
755
|
+
const row = [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128];
|
|
756
|
+
// A sixth number only in a game that rewinds (room.historySeconds, 0.46.5): how many ticks back this seat's own
|
|
757
|
+
// ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
|
|
758
|
+
if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
|
|
759
|
+
else if (taken !== undefined) row.push(taken);
|
|
760
|
+
rows.push(row);
|
|
753
761
|
if (q) q.lead = 127;
|
|
754
762
|
}
|
|
755
763
|
// A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
|
package/rules/rules.ts
CHANGED
|
@@ -167,7 +167,8 @@ export interface RulesDef {
|
|
|
167
167
|
shared?: Fields;
|
|
168
168
|
room?: {
|
|
169
169
|
rounds?: { seconds: number; breakSeconds: number };
|
|
170
|
-
|
|
170
|
+
/** `names` (0.46.5): what the game's bots are called, by seat; the toolkit's own list without it. */
|
|
171
|
+
bots?: { keep: number; names?: readonly string[] };
|
|
171
172
|
records?: RoundRecords;
|
|
172
173
|
historySeconds?: number;
|
|
173
174
|
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
@@ -396,6 +397,7 @@ export interface Compiled {
|
|
|
396
397
|
rewindSeconds?: number;
|
|
397
398
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
398
399
|
bots: number;
|
|
400
|
+
botNames?: readonly string[];
|
|
399
401
|
start: RoomHandler | null;
|
|
400
402
|
join: NonNullable<RulesDef['room']>['join'] | null;
|
|
401
403
|
roomOn: Record<string, RoomHandler>;
|
|
@@ -636,6 +638,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
636
638
|
}
|
|
637
639
|
const keep = room.bots === undefined ? 0 : typeof room.bots?.keep === 'number' ? Math.floor(room.bots.keep) : NaN;
|
|
638
640
|
if (!(keep >= 0 && keep <= SEATS_MAX)) throw new Error(`room.bots is { keep: n }, 0 to ${SEATS_MAX}`);
|
|
641
|
+
const botNames = (room.bots as { names?: unknown } | undefined)?.names;
|
|
642
|
+
if (botNames !== undefined && !(Array.isArray(botNames) && botNames.length >= 1 && botNames.length <= 64 && botNames.every((n) => typeof n === 'string' && n.trim().length >= 1 && n.length <= 24 && !/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/.test(n)))) throw new Error('room.bots.names is a list of 1 to 64 names, each 1 to 24 characters on one line');
|
|
639
643
|
if (room.start !== undefined && typeof room.start !== 'function') throw new Error('room.start is a function');
|
|
640
644
|
if (room.join !== undefined && typeof room.join !== 'function') throw new Error('room.join is a function');
|
|
641
645
|
const players = kinds.filter((k) => k.player);
|
|
@@ -661,7 +665,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
661
665
|
Object.freeze(navigation);
|
|
662
666
|
return {
|
|
663
667
|
navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
664
|
-
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
668
|
+
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, ...(botNames ? { botNames: Object.freeze([...(botNames as string[])]) } : {}), bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
665
669
|
tune, publicTune, map, settings, seats,
|
|
666
670
|
};
|
|
667
671
|
}
|
|
@@ -674,6 +678,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
674
678
|
*/
|
|
675
679
|
export interface Schema {
|
|
676
680
|
records?: true;
|
|
681
|
+
/** A game that rewinds (0.46.5): its window and its cap, for a browser predicting its own shots. */
|
|
682
|
+
rewind?: { historySeconds: number; capSeconds?: number };
|
|
677
683
|
contract: 2;
|
|
678
684
|
dims: 2 | 3;
|
|
679
685
|
seats: number;
|
|
@@ -698,7 +704,7 @@ function publicField(field: Field, hidden=false): Field {
|
|
|
698
704
|
}
|
|
699
705
|
export function schemaOf(c: Compiled): Schema {
|
|
700
706
|
return {
|
|
701
|
-
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}),
|
|
707
|
+
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}), ...(c.historySeconds ? { rewind: { historySeconds: c.historySeconds, ...(c.rewindSeconds !== undefined ? { capSeconds: c.rewindSeconds } : {}) } } : {}),
|
|
702
708
|
kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
|
|
703
709
|
effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
|
|
704
710
|
};
|
package/rules/view.ts
CHANGED
|
@@ -47,8 +47,8 @@ import { createNetplay } from '../netplay/netplay.ts';
|
|
|
47
47
|
import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
48
48
|
import { lab } from '../lab/lab.ts';
|
|
49
49
|
import { exposePort, type PortProbeOptions } from '../port/probe.ts';
|
|
50
|
-
import { BudgetError } from './guard.ts';
|
|
51
|
-
import { moveContext } from './math.ts';
|
|
50
|
+
import { BudgetError, brand } from './guard.ts';
|
|
51
|
+
import { math, moveContext } from './math.ts';
|
|
52
52
|
import { coerce, coerceFields, dir, initFields, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
53
53
|
import type { Unpacked } from './pack.ts';
|
|
54
54
|
import { compileMap } from './rules.ts';
|
|
@@ -113,6 +113,15 @@ export interface OpenRoomOptions {
|
|
|
113
113
|
/** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
|
|
114
114
|
export interface PredictContext extends RayQueries {
|
|
115
115
|
readonly tick: number; readonly dt: number;
|
|
116
|
+
/** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
|
|
117
|
+
readonly math: typeof math;
|
|
118
|
+
/**
|
|
119
|
+
* The tick the server will judge this player's ray against when the browser says it aimed at `aimTick` (0.46.5):
|
|
120
|
+
* `aimTick`, held to how far back this seat may look right now (the server says so in every snapshot) and to this
|
|
121
|
+
* tick. Pass it to `ctx.ray(..., { atTick })`: the other bodies are then posed as the server will pose them, from
|
|
122
|
+
* the snapshots this browser holds (between two of them, or carried on from the newest along its velocity).
|
|
123
|
+
*/
|
|
124
|
+
judgedTick(aimTick: number): number;
|
|
116
125
|
/** The tick the other bodies are drawn at: what the player is looking at. */
|
|
117
126
|
readonly viewTick: number;
|
|
118
127
|
/** This run draws ahead of the tick; the tick's own run follows. What it shows is shown once. */
|
|
@@ -128,6 +137,8 @@ export interface PredictContext extends RayQueries {
|
|
|
128
137
|
export type Predictor = (me: any, input: any, ctx: PredictContext) => void;
|
|
129
138
|
export interface Room<R = unknown> extends RayQueries {
|
|
130
139
|
readonly viewTick: number;
|
|
140
|
+
/** Where a body was at a tick, from the snapshots this browser holds (0.46.5); `known: false` past the newest one. */
|
|
141
|
+
poseAt(id: string, tick: number): { pos: Vec3; vel: Vec3; known: boolean } | null;
|
|
131
142
|
readonly status: RoomStatus;
|
|
132
143
|
readonly seat: number | null;
|
|
133
144
|
readonly me: Entity | null;
|
|
@@ -184,6 +195,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
184
195
|
|
|
185
196
|
let status: RoomStatus = 'connecting';
|
|
186
197
|
let latest: Frame | null = null;
|
|
198
|
+
/** The snapshots this browser holds, oldest first: two seconds of them (see poseAt). */
|
|
199
|
+
const held2: Frame[] = [];
|
|
187
200
|
let epoch = 0;
|
|
188
201
|
let round: RoomRound | null = null;
|
|
189
202
|
let results: RoundInfo['results'] | undefined;
|
|
@@ -380,11 +393,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
380
393
|
// run has asked for: never by the order of everything asked, which changes when another action joins the tick.
|
|
381
394
|
const asked = new Map<string, number>();
|
|
382
395
|
const once = (what: string): boolean => { if (!live) return false; let set = shown.get(t); if (!set) { set = new Set(); shown.set(t, set); } const n = asked.get(what) ?? 0; asked.set(what, n + 1); const key = what + '#' + n; if (set.has(key)) return false; set.add(key); return true; };
|
|
383
|
-
|
|
384
|
-
|
|
396
|
+
// Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
|
|
397
|
+
// code can be run on it instead of being restated in the view.
|
|
398
|
+
const ctx: PredictContext = brand({
|
|
399
|
+
tick: t, dt: 1 / tickHz, preview, math,
|
|
400
|
+
judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
|
|
385
401
|
get viewTick() { return viewTickNow(); },
|
|
386
402
|
ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
|
|
387
|
-
rays: (options) => raySnapshot(map, viewTargets(), dims, options, id),
|
|
403
|
+
rays: (options) => raySnapshot(map, viewTargets(atTickOf(options)), dims, options, id),
|
|
388
404
|
rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
|
|
389
405
|
ghost(kindOfGhost, at, data = {}, options = {}) {
|
|
390
406
|
const k = kindOf.get(kindOfGhost);
|
|
@@ -406,7 +422,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
406
422
|
if (waiting) waiting.confirms = true; else provisional.push({ name, tick: t, payload, match: options.match });
|
|
407
423
|
emit(name, Object.freeze({ ...payload, predicted: true }));
|
|
408
424
|
},
|
|
409
|
-
};
|
|
425
|
+
});
|
|
410
426
|
const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
|
|
411
427
|
for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
|
|
412
428
|
try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
|
|
@@ -551,6 +567,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
551
567
|
const before = latest;
|
|
552
568
|
if (before && before.e === f.e && f.k <= before.k) return;
|
|
553
569
|
latest = f;
|
|
570
|
+
if (before && before.e !== f.e) held2.length = 0;
|
|
571
|
+
held2.push(f); if (held2.length > 2 * tickHz) held2.shift();
|
|
554
572
|
if (!before || before.e !== f.e) collisionHistory = [];
|
|
555
573
|
const collision = f.collision;
|
|
556
574
|
if (collision) {
|
|
@@ -785,11 +803,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
785
803
|
// and stops dead at a release, with nothing to blend away afterwards.
|
|
786
804
|
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
787
805
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
788
|
-
|
|
806
|
+
// A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
|
|
807
|
+
// read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
|
|
808
|
+
// then and standing from then, not sinking until the tick's end.
|
|
809
|
+
let av = a, landed = false;
|
|
810
|
+
if (dims === 3 && !mine.grounded && next.grounded && mine.vel.z < 0) {
|
|
811
|
+
const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
|
|
812
|
+
av = Math.min(1, a / contact); landed = a >= contact;
|
|
813
|
+
}
|
|
814
|
+
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, av) }, dims);
|
|
789
815
|
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
790
816
|
velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
|
|
791
817
|
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
792
|
-
grounded = mine.grounded && (a === 0 || next.grounded);
|
|
818
|
+
grounded = landed || mine.grounded && (a === 0 || next.grounded);
|
|
793
819
|
preview = next;
|
|
794
820
|
if (predictors[kind.name]) {
|
|
795
821
|
// The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
|
|
@@ -912,16 +938,43 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
912
938
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
913
939
|
|
|
914
940
|
function viewTickNow(): number { const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; }
|
|
915
|
-
|
|
941
|
+
/*
|
|
942
|
+
* WHERE A BODY WAS, OR WILL BE JUDGED TO BE (0.46.5). The server judges a rewound ray against poses it kept by
|
|
943
|
+
* tick. A browser holds the snapshots it has received: a pose at a tick between two of them is the point between;
|
|
944
|
+
* past the newest it is that snapshot's pose carried on along its velocity (`known: false`: a guess); before the
|
|
945
|
+
* oldest it is the oldest. A placement between the two is not glided across.
|
|
946
|
+
*/
|
|
947
|
+
function poseAt(id: string, at: number): { pos: Vec3; vel: Vec3; known: boolean } | null {
|
|
948
|
+
let before: Frame | null = null, after: Frame | null = null;
|
|
949
|
+
for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
|
|
950
|
+
const a = before?.ents.get(id), b = after?.ents.get(id);
|
|
951
|
+
if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3({ x: lerp(a.pos.x, b.pos.x, m), y: lerp(a.pos.y, b.pos.y, m), z: lerp(a.pos.z, b.pos.z, m) }, dims), vel: b.vel, known: true }; }
|
|
952
|
+
if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
|
|
953
|
+
return b ? { pos: b.pos, vel: b.vel, known: false } : null;
|
|
954
|
+
}
|
|
955
|
+
const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
|
|
956
|
+
/** How many ticks back this seat's ray may look: the server's own figure from the newest snapshot, else the rule it follows with this browser's round trip. */
|
|
957
|
+
function rewindAllowance(): number {
|
|
958
|
+
const seat = net.seat, row = seat === null ? undefined : latest?.rows.find((x) => x[0] === seat);
|
|
959
|
+
const history = Math.ceil((schema.rewind?.historySeconds ?? 0) * tickHz);
|
|
960
|
+
if (typeof row?.[5] === 'number') return row[5];
|
|
961
|
+
const cap = schema.rewind?.capSeconds !== undefined ? Math.ceil(schema.rewind.capSeconds * tickHz) : history;
|
|
962
|
+
return Math.min(history, cap, Math.ceil((authorityRtt() + 150) / period) + 1);
|
|
963
|
+
}
|
|
964
|
+
const viewTargets = (atTick?: number) => {
|
|
916
965
|
pump();
|
|
917
966
|
const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
|
|
918
967
|
for(const k of schema.kinds) {
|
|
919
968
|
if(k.collider || !k.radius)continue;
|
|
920
|
-
each(k.name,e=>
|
|
969
|
+
each(k.name,e=>{
|
|
970
|
+
// At a tick: where the server will have the body, with the fields as they are now (the server filters on current fields too).
|
|
971
|
+
const at = atTick !== undefined && e.id !== mine?.id && !e.provisional ? poseAt(e.id, atTick)?.pos ?? e.pos : e.pos;
|
|
972
|
+
targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(at,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at});
|
|
973
|
+
});
|
|
921
974
|
}
|
|
922
975
|
return targets;
|
|
923
976
|
};
|
|
924
|
-
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
|
|
977
|
+
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
|
|
925
978
|
return {
|
|
926
979
|
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
927
980
|
get status() { return solo ? 'offline' : status; },
|
|
@@ -929,7 +982,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
929
982
|
get me() { pump(); return meNow(); },
|
|
930
983
|
each, get,
|
|
931
984
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
932
|
-
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(),dims,options,mine?.id),
|
|
985
|
+
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(atTickOf(options)),dims,options,mine?.id),
|
|
986
|
+
poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
|
|
933
987
|
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
934
988
|
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
935
989
|
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.46.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.46.5","sha256":"6e01cf0c45cbe8649134cb3332feed0ba34c9ac39f3431d4c8374e0237345c0b"});
|
package/worker/index.mjs
CHANGED
|
@@ -92,7 +92,7 @@ import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room
|
|
|
92
92
|
import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
|
|
93
93
|
import { roomStore, restoreDelay } from './room-store.mjs';
|
|
94
94
|
import { appCors, isAppOrigin } from './standalone.mjs';
|
|
95
|
-
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
95
|
+
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
|
|
96
96
|
import {
|
|
97
97
|
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
98
98
|
setMembership,
|
|
@@ -1226,6 +1226,17 @@ async function route(request, env, ctx) {
|
|
|
1226
1226
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
1227
1227
|
if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
|
|
1228
1228
|
const who = await ticketSub(env, game, url.searchParams.get('t'));
|
|
1229
|
+
// A play page left open across a deploy (0.46.5). One that names what it was made for (`sh`) and is behind is
|
|
1230
|
+
// told so, and reloads itself whole, once, to the same room and seat. One from before that handshake is sent
|
|
1231
|
+
// to a fresh page only when this game cannot work inside it: the game keeps verified results for a player, and
|
|
1232
|
+
// the old page has no player ticket to give its frame (a result would have nobody to be written for).
|
|
1233
|
+
if (url.searchParams.get('pf') === '1' && url.searchParams.get('embed') !== '1' && url.searchParams.get('want') !== 'screen' && !url.searchParams.has('watch')) {
|
|
1234
|
+
const said = url.searchParams.get('sh'), now = shellKey(meta);
|
|
1235
|
+
const ticketless = Boolean(meta.room?.records && env.DB) && !holders(who).some((x) => x.startsWith('p-'));
|
|
1236
|
+
if (said !== null ? said !== now : ticketless) {
|
|
1237
|
+
return new Response(shellStandIn(url.searchParams.get('room') ?? '', said !== null ? now : null), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1229
1240
|
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1230
1241
|
// An agent's frame (hands `self`): the game plays as that AI.
|
|
1231
1242
|
const agentPart = holders(who).find((x) => x.startsWith('a-'));
|
|
@@ -1778,7 +1789,12 @@ export class Table {
|
|
|
1778
1789
|
sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
|
|
1779
1790
|
const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
|
|
1780
1791
|
const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
|
|
1781
|
-
if(!player)
|
|
1792
|
+
if(!player){
|
|
1793
|
+
// Never dropped in silence (0.46.5): the log says so, and the person is told on their own screen.
|
|
1794
|
+
try{console.error(JSON.stringify({at:new Date().toISOString(),ev:'record-unsaved',why:'no-player',game:this.game??null,room:this.code??null,key,id:value?.id??null}));}catch{}
|
|
1795
|
+
if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1782
1798
|
sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
|
|
1783
1799
|
sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
|
|
1784
1800
|
this.ctx.waitUntil(this.flushRoundRecords());
|
package/worker/pages.mjs
CHANGED
|
@@ -8,6 +8,36 @@ import { playerAncestors, PLAYER_SANDBOX, PLAYER_SIZE } from './embed.mjs';
|
|
|
8
8
|
import { esc, layout, gameSocialTags } from './site.mjs';
|
|
9
9
|
import { NET_PALETTE } from './room.mjs';
|
|
10
10
|
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
11
|
+
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a play page was made for (0.46.5): the toolkit that wrote it and the parts of the game's manifest it is built
|
|
15
|
+
* around (who may sit, what the room keeps, whether the player has saves and a ticket). The page hands it to the
|
|
16
|
+
* game's frame (`sh`), and the Worker that serves the frame knows what a page made now would say. A page left open
|
|
17
|
+
* across a deploy says something older: it is brought up to date with one full reload, to the same room and seat,
|
|
18
|
+
* instead of hosting a newer game inside a page that lacks what that game needs (a player ticket, for one).
|
|
19
|
+
* It does not change when only the game's code or art does: that is still a reload of the frame alone.
|
|
20
|
+
*/
|
|
21
|
+
export function shellKey(g) {
|
|
22
|
+
// Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
|
|
23
|
+
// edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
|
|
24
|
+
const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
|
|
25
|
+
const { version: _version, ...netplay } = g?.netplay ?? {};
|
|
26
|
+
const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
|
|
27
|
+
let h = 2166136261;
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
29
|
+
return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The frame's stand-in when its page is older than the game it would host. It tells the page, and loads no game.
|
|
33
|
+
* `key`: the page understands the handshake and reloads itself once. No key: a page from before the handshake, whose
|
|
34
|
+
* only way to load a whole new page is the one it has for leaving a finished room; it is used only when the game cannot
|
|
35
|
+
* work inside that page at all.
|
|
36
|
+
*/
|
|
37
|
+
export function shellStandIn(room, key) {
|
|
38
|
+
const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
|
|
39
|
+
return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
|
|
40
|
+
}
|
|
11
41
|
import { SKILLS } from './agents.mjs';
|
|
12
42
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
13
43
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -180,7 +210,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
180
210
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
181
211
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
182
212
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
183
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
213
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
184
214
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
185
215
|
const joinCorner = corner('join', 'bottom-right');
|
|
186
216
|
const first = places.desk;
|
|
@@ -497,6 +527,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
497
527
|
q.set('b', roomKey);
|
|
498
528
|
if (boot.embed) q.set('name', guestName);
|
|
499
529
|
if (boot.t) q.set('t', boot.t);
|
|
530
|
+
// What this page was made for (0.46.5): the Worker answers with a stand-in when a page made now would differ.
|
|
531
|
+
if (boot.sh) q.set('sh', shellSaid || boot.sh);
|
|
500
532
|
if (!boot.embed && params.get('name')) q.set('name', params.get('name'));
|
|
501
533
|
else if (!boot.embed) {
|
|
502
534
|
// A player with an account (or a named guest) on this studio plays under their own name in every room.
|
|
@@ -517,6 +549,18 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
517
549
|
}
|
|
518
550
|
state.params = {};
|
|
519
551
|
(boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
|
|
552
|
+
// The Worker says this page is older than the game (a deploy while it was open): one full reload, which keeps the
|
|
553
|
+
// room (it is in the address) and the seat (its token is in this tab's storage). Once for each newer page: if the
|
|
554
|
+
// reload came back the same, the frame is asked for as the Worker described it and play goes on.
|
|
555
|
+
var shellSaid = '';
|
|
556
|
+
function shellBehind(key) {
|
|
557
|
+
var tried = '';
|
|
558
|
+
try { tried = sessionStorage.getItem('homie-shell') || ''; } catch (e) {}
|
|
559
|
+
if (tried === key) { shellSaid = key; frame.src = frameUrl(); return; }
|
|
560
|
+
try { sessionStorage.setItem('homie-shell', key); } catch (e) { shellSaid = key; frame.src = frameUrl(); return; }
|
|
561
|
+
state.shellReload = key;
|
|
562
|
+
location.reload();
|
|
563
|
+
}
|
|
520
564
|
var updating = buildRetry(function () {
|
|
521
565
|
state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
|
|
522
566
|
frame.src = frameUrl();
|
|
@@ -532,6 +576,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
532
576
|
var m = ev.data;
|
|
533
577
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
534
578
|
A.message(m);
|
|
579
|
+
if (m.what === 'shell' && typeof m.key === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(m.key)) { shellBehind(m.key); return; }
|
|
535
580
|
if (m.what === 'wait') updating.stop();
|
|
536
581
|
if (m.what === 'build' && updating.acknowledge(m.ver) && toast) toast.hidden = true;
|
|
537
582
|
if (m.what === 'attached' || m.what === 'ready') updating.reported();
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.46.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.46.5';
|