@homie-rocks/studio 0.35.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/lib/typecheck.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runRulesCompiler } from './rules-compiler.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio build --types` — the games' TypeScript, checked before anything is built.
|
|
3
4
|
*
|
|
@@ -6,15 +7,15 @@
|
|
|
6
7
|
* of its own to run `npx tsc` with.
|
|
7
8
|
*
|
|
8
9
|
* - The compiler is the STUDIO'S OWN (`typescript` in its node_modules, which a new studio's package.json asks
|
|
9
|
-
* for).
|
|
10
|
-
*
|
|
10
|
+
* for). This optional check for legacy games keeps using that compiler. Rules games use the toolkit's own
|
|
11
|
+
* pinned compiler through typecheckRules below, on every build.
|
|
11
12
|
* - A game with a tsconfig.json of its own is checked with it. Any other game is checked the way esbuild reads
|
|
12
13
|
* it: its entry and whatever that imports, as ES2022 for a browser, strictly, with the pictures, sounds and
|
|
13
14
|
* models it imports as addresses (the build's own loaders).
|
|
14
15
|
* - Only errors in the game's own files stop the build. A game imports the toolkit's helpers as TypeScript
|
|
15
16
|
* source, and a creator can do nothing about a line in node_modules; those are counted and said, never fatal.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
+
* The legacy check runs only when asked with --types. The rules check is mandatory.
|
|
18
19
|
*/
|
|
19
20
|
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
@@ -105,3 +106,51 @@ export function typecheck(root, games, { log = () => {} } = {}) {
|
|
|
105
106
|
}
|
|
106
107
|
return { typescript: ts.version, games: rows };
|
|
107
108
|
}
|
|
109
|
+
|
|
110
|
+
/** Every rules build uses the toolkit's pinned compiler, its own strict settings and declaration-derived faces. */
|
|
111
|
+
export async function typecheckRules(root, g, checked, tune, { log = () => {} } = {}) {
|
|
112
|
+
const [{ PACKAGE_ROOT }, { rulesTypes, rulesViewTypes, rulesAnswerScopes }] = await Promise.all([import('./studio.mjs'), import('./rules-types.mjs')]);
|
|
113
|
+
const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc');
|
|
114
|
+
const dir = join(root, '.studio', 'types', g.id);
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
writeFileSync(join(dir, 'rules.d.ts'), rulesTypes({ ...checked, answerScopes: await rulesAnswerScopes(join(g.dir, 'src/rules.ts')) }, tune));
|
|
117
|
+
writeFileSync(join(dir, 'view.d.ts'), rulesViewTypes(checked));
|
|
118
|
+
writeFileSync(join(dir, 'files.d.ts'), AMBIENT);
|
|
119
|
+
const project = join(dir, 'tsconfig.json');
|
|
120
|
+
writeFileSync(project, `${JSON.stringify({ compilerOptions: { ...OPTIONS, checkJs: true, paths: {
|
|
121
|
+
...Object.fromEntries(Object.entries(JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).exports)
|
|
122
|
+
.filter(([, entry]) => entry?.types).map(([name, entry]) => [`@homie-rocks/studio/${name.slice(2)}`, [join(PACKAGE_ROOT, entry.types)]])),
|
|
123
|
+
'@homie-rocks/studio/rules': [join(dir, 'rules.d.ts')],
|
|
124
|
+
'@homie-rocks/studio/rules/view': [join(dir, 'view.d.ts')],
|
|
125
|
+
'@homie-rocks/studio/*': [join(PACKAGE_ROOT, '*')],
|
|
126
|
+
} }, files: [join(g.dir, 'src/rules.ts'), join(g.dir, g.entry ?? 'src/view.ts'), join(dir, 'files.d.ts')] }, null, 2)}\n`);
|
|
127
|
+
const res = await runRulesCompiler(compiler, ['-p', project, '--pretty', 'false']);
|
|
128
|
+
if (res.error) throw new Error(`games/${g.id}: the rules type check could not finish: ${res.error.message}`);
|
|
129
|
+
const messages = [];
|
|
130
|
+
for (const line of `${res.stdout ?? ''}\n${res.stderr ?? ''}`.split(/\r?\n/)) {
|
|
131
|
+
const at = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
132
|
+
if (at) {
|
|
133
|
+
const file = resolve(at[1]);
|
|
134
|
+
// Imported game helpers count too. Only the toolkit's own implementation is outside the author's remit.
|
|
135
|
+
if (!file.startsWith(`${PACKAGE_ROOT}${sep}`) && !file.includes(`${sep}node_modules${sep}`) || file.startsWith(`${g.dir}${sep}`)) {
|
|
136
|
+
const sourceLine = readFileSync(file,'utf8').split(/\r?\n/)[Number(at[2])-1] ?? '';
|
|
137
|
+
const advice = /Property 'ask'.*type 'never'/.test(at[5]) ? 'This handler scope never calls world.ask, so no answer can arrive here. Handle the answer in the scope that asks.'
|
|
138
|
+
: /world\.round\.end/.test(sourceLine) && at[4] === 'TS2339' ? 'Only room handlers end a round. Send a declared event to a room handler that calls world.round.end().'
|
|
139
|
+
: /'check'.*does not exist/.test(at[5]) ? 'Remove the obsolete check key. Generated observations require no exemption.'
|
|
140
|
+
: /=> (?:GameDecision|GuideDecision)'/.test(at[5]) ? 'A floor returns goal and say names from agents.json with exactly the args and sayArgs each declares, or {} to decline. Forward a request as { goal: ask.k, args: ask.args }.'
|
|
141
|
+
: /\.args\./.test(sourceLine) ? 'Use the argument names and types declared in agents.json and shapes.view; for a union, narrow its goal or request name before reading variant-specific arguments.'
|
|
142
|
+
: /unknown/.test(at[5]) ? 'Validate this external value with a type check before reading it.'
|
|
143
|
+
: /e\.picks/.test(sourceLine) ? 'Check e.ask before reading a question specific to one ask. Picks use the declared choice names, booleans and numbers.'
|
|
144
|
+
: at[4] === 'TS2339' && /self\./.test(sourceLine) ? 'Declare the field on this entity kind, or use an existing declared name.'
|
|
145
|
+
: at[4] === 'TS2322' && /join|kind:/.test(sourceLine) ? `room.join returns a player kind: ${checked.schema.kinds.filter(k => k.player).map(k => k.name).join(', ')}.`
|
|
146
|
+
: at[4] === 'TS2345' && /world\.despawn\s*\(/.test(sourceLine) ? 'world.despawn takes self, the non-player entity running this handler. Send an event to ask another entity to remove itself.'
|
|
147
|
+
: at[4] === 'TS2345' && /world\.send\s*\(/.test(sourceLine) && /Argument of type.*(?:readonly|pos:)/.test(at[5]) ? 'world.send takes an entity ID. Pass entity.id rather than the entity record.' : '';
|
|
148
|
+
messages.push(`${relative(root, file)}:${at[2]}:${at[3]} ${at[4]} ${at[5].length > 360 ? at[5].slice(0, 220) + ' … ' + at[5].slice(-100) : at[5]}${advice ? `\n Repair: ${advice}` : ''}`);
|
|
149
|
+
}
|
|
150
|
+
} else if (/^error TS/.test(line)) messages.push(line);
|
|
151
|
+
else if (/^\s+\S/.test(line) && messages.length) messages[messages.length - 1] += `\n${line.length > 360 ? line.slice(0, 220) + ' … ' + line.slice(-100) : line}`;
|
|
152
|
+
}
|
|
153
|
+
if (messages.length) throw new Error(`games/${g.id}: its rules have type errors:\n${messages.slice(0, 30).map((m) => ` ${m}`).join('\n')}`);
|
|
154
|
+
if (res.status !== 0 && !res.stdout) throw new Error(`games/${g.id}: TypeScript stopped without diagnostics (${res.signal ?? res.status})`);
|
|
155
|
+
log(`types: games/${g.id}: rules, move and view checked with the toolkit's strict compiler`);
|
|
156
|
+
}
|
package/links/links.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export function randomId(): string;
|
|
2
|
+
export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
|
|
3
|
+
export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
|
package/links/links.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Public links, QR and HTTP-safe IDs; the encoder is shared with the wall shell. */
|
|
2
|
+
export { qrSvg } from '../worker/qr.mjs';
|
|
3
|
+
export function randomId() {
|
|
4
|
+
const bytes = new Uint8Array(16);
|
|
5
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
6
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
7
|
+
}
|
|
8
|
+
export function appLink(address, params = {}, base = globalThis.location?.href) {
|
|
9
|
+
const url = new URL(address, base);
|
|
10
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('links use an HTTP(S) address without credentials');
|
|
11
|
+
for (const [key, value] of Object.entries(params)) {
|
|
12
|
+
if (value === null || value === undefined) url.searchParams.delete(key);
|
|
13
|
+
else url.searchParams.set(key, String(value));
|
|
14
|
+
}
|
|
15
|
+
return url.href;
|
|
16
|
+
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 11)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
version is `v: 1`. Everything revisions 2 to
|
|
3
|
+
Status: **v1, revision 11** (2026-10-08, `@homie-rocks/studio` 0.33.1). The wire
|
|
4
|
+
version is `v: 1`. Everything revisions 2 to 11 added is either an optional field, a new message type,
|
|
5
5
|
a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
|
|
6
6
|
not know. A change to the contract bumps `v` and keeps v1 working.
|
|
7
7
|
|
|
8
|
+
**Revision 11** adds browser rules hosting and offline play (29.7): `held` occupants on
|
|
9
|
+
welcome/promotion, explicit `rules: true` on runtime output, `hosted` relay events,
|
|
10
|
+
`view` player events, `retime` clock-only round frames, terminal `rules-end`, and
|
|
11
|
+
`host` with `why: 'host-kept'` to a rules host whose yield elected nobody.
|
|
12
|
+
The hosting page's unmarked frames have ordinary player allowances. Old-style games
|
|
13
|
+
keep revision 10's character counts and rates.
|
|
14
|
+
|
|
8
15
|
**What revision 10 added** (a game that knows none of it plays exactly as before, hosted by a player's
|
|
9
16
|
browser; nothing here changes a frame such a game sends or receives):
|
|
10
17
|
- **The server as host** (section 29): a game written as rules plus view (`src/rules.ts`, `src/move.ts`,
|
|
@@ -295,7 +302,7 @@ it.
|
|
|
295
302
|
| The host closes or says `bye` | Sends `leave`, then elects a replacement. |
|
|
296
303
|
| **Stall:** the host has sent no snapshot for **1.5 s** (the game's own `netplay.stallMs`, 1.5 to 10 s; section 22) while others are present | Demotes it (`role replica, why host-stalled`) and elects another. A host whose frames hitch sends a heartbeat meanwhile, for 4 s. |
|
|
297
304
|
| **Frozen host at a newcomer's hello:** the host is hidden, or has sent nothing for 2.5 s | Makes the newcomer host at once. The old host gets `role replica`. |
|
|
298
|
-
| **Hidden tab** | The helper sends a checkpoint, then `yield`. `ping {hid: true}` has the same effect. |
|
|
305
|
+
| **Hidden tab** | The helper sends a checkpoint, then `yield`. `ping {hid: true}` has the same effect. A rules host is answered when nobody could take the role (section 29.7). |
|
|
299
306
|
| **Silent socket:** nothing received for **10 s** | Closes it (a slept phone, a half-open link). The seat's body turns into a bot. The helper pings every 2 s. |
|
|
300
307
|
|
|
301
308
|
- **Ranking candidates:**
|
|
@@ -2247,8 +2254,8 @@ The state hash includes ordered declarations and handler names/presence, body/pl
|
|
|
2247
2254
|
field options, shared state, shapes, bots, asks, map, dimensions, tick rate, rounds and contract.
|
|
2248
2255
|
Handler implementations, tunables and view code do not change it. The build hash includes
|
|
2249
2256
|
built rules, view, tunables and map, excluding build time, commit and other games. It replaces
|
|
2250
|
-
`netplay.version` for rules games. Browser-hosted games
|
|
2251
|
-
|
|
2257
|
+
`netplay.version` for rules games in either hosting mode. Browser-hosted games use the
|
|
2258
|
+
checkpoint handover described below. An old standalone app must update before joining a rules game's live room.
|
|
2252
2259
|
|
|
2253
2260
|
A restored run increments `boots.count` at its first tick and clears it after ten seconds of
|
|
2254
2261
|
ticking. The second successive restored run waits a deterministic room-specific delay of up
|
|
@@ -2259,3 +2266,113 @@ After sixty seconds paused without a returning person, the room ends even if wat
|
|
|
2259
2266
|
Ending deletes `save`, `boots` and `net`, preserves `office` and `recorded`, and keeps an `ended`
|
|
2260
2267
|
marker so a late reconnect cannot reopen that room. The Lobby forgets it. The stored alarm
|
|
2261
2268
|
also cleans up a room whose object restarted with nobody returning.
|
|
2269
|
+
### 29.7 The same runtime in a browser and offline
|
|
2270
|
+
|
|
2271
|
+
`room.host: 'browser'` bundles the guarded host runtime and announces `rules: true` in
|
|
2272
|
+
`hello`. The relay elects a browser using the existing election and visibility yield.
|
|
2273
|
+
Only an elected host that announced rules receives step `in` frames (`e`, `k`, `s`, `r`);
|
|
2274
|
+
an older host receives the original input format. The relay supplies `from` from the socket's
|
|
2275
|
+
seat. It preserves `e` on a rules host's snapshots, and seat expiry sends `{ t: 'free', seat }`
|
|
2276
|
+
to a rules host. An old-style host is sent neither, and its `welcome` and `role` carry no
|
|
2277
|
+
`held`: nothing an old-style game sends or receives differs from revision 9. A page that
|
|
2278
|
+
announces rules with a revision below 11 is never elected (its output would be unmarked).
|
|
2279
|
+
Joins, AI identity and policy are the relay's facts, never fields copied from a player's input.
|
|
2280
|
+
|
|
2281
|
+
A rules checkpoint holds `{ rules: build, data: runtimeSave }`. Promotion validates it and
|
|
2282
|
+
restores one complete state through `createHost` and the single `createCore` save validator, with a fresh input epoch. `restoreEpoch` is the single replacement epoch option; `startPaused: false` lets browser promotion resume with the relay’s current occupants, while server recovery starts paused. A malformed or incompatible checkpoint
|
|
2283
|
+
starts a fresh round, says so, and immediately replaces the relay checkpoint. Abrupt loss
|
|
2284
|
+
can rewind to the last checkpoint; the replacement host republishes the round's wall-clock
|
|
2285
|
+
deadline from its restored tick. No newer display snapshot is mixed into the save.
|
|
2286
|
+
|
|
2287
|
+
An elected rules host may send `{ t: 'rules-end', rules: true, why }` when its runtime ends. The relay
|
|
2288
|
+
accepts this only from that host in a game the server catalogue identifies as rules, sends terminal `room-over` to everyone, closes sockets,
|
|
2289
|
+
and forgets the room and checkpoint. A later visitor starts fresh. Replicas cannot end it.
|
|
2290
|
+
`host-failed` and `room-over` are terminal helper errors; neither starts offline play or
|
|
2291
|
+
restores the failed state automatically.
|
|
2292
|
+
|
|
2293
|
+
Browser simulation uses `performance.now()`. The helper converts outgoing timestamps to
|
|
2294
|
+
the room's wall clock with rising snapshot stamps. Clock corrections republish the round deadline. A callback up to a tenth of a second late (two tick periods, if that is longer) is caught up, as on the server; a later one rebases the simulation clock without blaming rules, so a browser whose timers are that late runs slow and yields to a faster one.
|
|
2295
|
+
Hidden local hosts pause; visible hosts resume at the same tick. With another player present,
|
|
2296
|
+
the hidden host checkpoints and yields as before. Server isolate overrun enforcement is
|
|
2297
|
+
explicitly enabled only by the server adapter.
|
|
2298
|
+
|
|
2299
|
+
Server-hosted games with offline enabled fetch a separate rules entry after playable.
|
|
2300
|
+
Private games (`offline: false`) have no local rules import. Local play sends no snapshots,
|
|
2301
|
+
checkpoints, results or state to the relay. A dropped connection tries for five seconds
|
|
2302
|
+
before local play starts when rules are available; reconnecting announces the switch to
|
|
2303
|
+
the online round. Production bundles do not read Lab stage or tuning globals.
|
|
2304
|
+
|
|
2305
|
+
The browser relay and rules build share `worker/limits.mjs`. The rules view receives the
|
|
2306
|
+
same table without adding imports to vendored connection helpers. Rules sizes are UTF-8
|
|
2307
|
+
JSON bytes, including the runtime marker. Old-style games count characters, as before.
|
|
2308
|
+
For tick rate H and seat count S, the shared allowances are:
|
|
2309
|
+
|
|
2310
|
+
| Frame | Bytes through 16 seats | Bytes above 16 seats | Tokens/second |
|
|
2311
|
+
| --- | --- | --- | --- |
|
|
2312
|
+
| Snapshot | 16,384 | 16,384 | max(30, ceil(1.25 H)) |
|
|
2313
|
+
| Checkpoint | 65,536 | 131,072 | 4 |
|
|
2314
|
+
| Shared state | 8,192 | 8,192 | H + 64 |
|
|
2315
|
+
| Event, including effects | 4,096 | 4,096 | ceil((H + 10S + 32) / 0.8) |
|
|
2316
|
+
| Round | 8,192 | 16,384 | 8 |
|
|
2317
|
+
| Roster | 4,096 | 8,192 | 2S + 8 |
|
|
2318
|
+
| Decision | 6,144 | 6,144 | ceil((H + 5S + 8) / 0.8) |
|
|
2319
|
+
| Caps / rules-end | 512 | 512 | 4 |
|
|
2320
|
+
|
|
2321
|
+
The relay's token bucket holds two seconds of each allowance. For every kind but the
|
|
2322
|
+
snapshot the sender refills at 80% of that rate and holds one second of tokens, which
|
|
2323
|
+
leaves headroom for transport batching and late timers. A snapshot spends the relay's
|
|
2324
|
+
whole rate with two tokens in hand: a tick that runs on time sends its snapshot at once,
|
|
2325
|
+
so a browser host sends a snapshot a tick at any tick rate, as the server does, and the
|
|
2326
|
+
quarter above the tick rate absorbs the ticks a late timer runs together. Only snapshots
|
|
2327
|
+
coalesce. All other runtime output, including checkpoints and decisions, shares one
|
|
2328
|
+
ordered FIFO, and each frame leaves as soon as its own kind has a token: nothing reliable
|
|
2329
|
+
waits for a snapshot's.
|
|
2330
|
+
A snapshot over the relay allowance drops. Reliable output over it moves the rules to
|
|
2331
|
+
another player with `host-fault` explaining the change; that socket is not elected again
|
|
2332
|
+
(a page that reconnects is a newcomer, behind every other candidate). The
|
|
2333
|
+
room ends only if no eligible host remains. Player rate errors never end the room.
|
|
2334
|
+
|
|
2335
|
+
**Giving the role up is one ordered act.** A rules host that yields (its tab is hidden, it
|
|
2336
|
+
runs slow, the game calls `handOff()`) saves a checkpoint and puts `yield` behind it in the
|
|
2337
|
+
same FIFO, so the relay elects with the tick the room is on. From the yield on, the sender
|
|
2338
|
+
holds what the rules make until the relay answers: a `role` to somebody else discards it
|
|
2339
|
+
(the next host makes those ticks itself), and `{ t: 'host', host, why: 'host-kept' }`, sent
|
|
2340
|
+
to everyone in a rules room whose host's yield elected nobody, releases it in order. Two seconds without an answer
|
|
2341
|
+
release it too. `ping` does not report `hid: true` while a yield is still queued. A page
|
|
2342
|
+
that closes (`pagehide`, `close()`) sends what its allowances permit, in order, then its
|
|
2343
|
+
last checkpoint whatever the checkpoint allowance says (the relay's bucket holds twice the
|
|
2344
|
+
sender's), then `bye`; a frame it could not send is covered by that checkpoint.
|
|
2345
|
+
|
|
2346
|
+
**Everything the rules say goes one way.** The helper sends no `round`, `roster`, `state`,
|
|
2347
|
+
`caps`, `snap`, `ckpt` or `decide` of its own in a rules game. A host that reconnects and
|
|
2348
|
+
is still the host (the relay may have restarted with an old save or none) asks its runtime
|
|
2349
|
+
to announce: the round with its results, the roster, the shared state and the capabilities
|
|
2350
|
+
leave as marked output, then a checkpoint. A promoted host that restored a checkpoint says
|
|
2351
|
+
its shared state with its round, since the checkpoint may be older than what the relay kept.
|
|
2352
|
+
A modified host can already fabricate rules output; its marker adds no authority.
|
|
2353
|
+
|
|
2354
|
+
Effects share one frame per tick. Shared state also has a room total of 64 keys and
|
|
2355
|
+
65,536 bytes. Build smoke checks measure every runtime kind; browser size excesses
|
|
2356
|
+
fail with a measured explanation, and server builds warn. These samples are not a
|
|
2357
|
+
proof of every future round. Oversized runtime frames remain terminal with the
|
|
2358
|
+
measurement on every page. Clock-only round retimings carry no results and do not
|
|
2359
|
+
fire another round event. Offline players retry at most 2.5 seconds apart, and the
|
|
2360
|
+
browser's `online` event retries immediately.
|
|
2361
|
+
|
|
2362
|
+
The view's guide helper never owns guides. Online commands, asks, inputs and speech go
|
|
2363
|
+
through the relay even on the page running the runtime. Runtime events return through the
|
|
2364
|
+
relay with a `hosted` mark (which a player's event cannot supply), and rosters and results
|
|
2365
|
+
return with the same AI labels all other views receive. A rules host's relayed speech keeps
|
|
2366
|
+
a live person's seat or null; older hosts retain the original sender stamping. Offline
|
|
2367
|
+
play uses the same runtime boundary locally and has bots, without online guides or AI.
|
|
2368
|
+
|
|
2369
|
+
A slow host reports its achieved speed. A replacement needs a recent event-loop measurement
|
|
2370
|
+
at least fifty percent faster plus ten percentage points, and any measured hosting speed
|
|
2371
|
+
must also pass that threshold. Two equally slow hosts keep one authority. Hidden tabs and
|
|
2372
|
+
lost hosts still use the existing election path. Welcome and promotion frames include held
|
|
2373
|
+
seat identities, without tokens, so an absent person's body stays theirs after handover.
|
|
2374
|
+
|
|
2375
|
+
A missing offline module leaves the connection helper alive and says the game needs a
|
|
2376
|
+
connection. A failed offline runtime stops locally while reconnection continues. An online
|
|
2377
|
+
runtime failure ends the room. Snapshots never grow above 16,384 bytes, including above
|
|
2378
|
+
sixteen seats.
|