@homie-rocks/studio 0.35.1 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /** A compiler-only facade: declarations supply contextual types without changing a game's source or its bundle. */
2
+ import { join } from 'node:path';
3
+ import { PACKAGE_ROOT } from './studio.mjs';
4
+
5
+ const q = JSON.stringify;
6
+ const record = (entries) => `{ ${entries.map(([n, t]) => `${q(n)}: ${t}`).join('; ')} }`;
7
+ function field(f) {
8
+ if (f.t === 'bit' || f.t === 'press') return 'boolean';
9
+ if (f.t === 'ref' || f.t === 'text') return 'string';
10
+ if (f.t === 'vec3' || f.t === 'dir') return 'Vec3';
11
+ if (f.t === 'list') return `ReadonlyArray<${field(f.of)}>`;
12
+ if (f.t === 'map') return `Record<string, ${field(f.of)}>`;
13
+ if (f.t === 'struct') return fields(Object.entries(f.fields));
14
+ return 'number';
15
+ }
16
+ const fields = (list) => `{ ${list.map(([n, f]) => `get ${q(n)}(): ${field(f)}; set ${q(n)}(value: WriteState<${field(f)}>);`).join(' ')} }`;
17
+ const shapes = (table) => record(Object.entries(table).map(([n, fs]) => [n, fs.length ? fields(fs) : 'Record<string, never>']));
18
+ const value = (v) => v === null ? 'null' : Array.isArray(v) ? `Array<${[...new Set(v.map(value))].join(' | ') || 'never'}>` : typeof v === 'object' ? record(Object.entries(v).map(([n, x]) => [n, value(x)])) : typeof v;
19
+ const picks = (questions) => record(Object.entries(questions).map(([name, question]) => [name,
20
+ question.type === 'choice' ? (Array.isArray(question.criteria) ? question.criteria : Object.keys(question.criteria)).map(q).join(' | ')
21
+ : question.type === 'score' ? 'number' : 'boolean']));
22
+ // Vocabulary arguments are admitted by argsWhy; object offers expose their id.
23
+ function vocabularyTypes(vocab, view) {
24
+ if (!vocab) return { goals: 'Goal', asks: 'GuideRequest', floorAsks: 'GuideRequest', decision: 'GuideDecision' };
25
+ const argument = source => {
26
+ if (source === 'player') return 'number';
27
+ if (Array.isArray(source)) return source.map(q).join(' | ') || 'never';
28
+ let fd = view[source?.slice(5)];
29
+ if (fd?.t === 'list') fd = fd.of;
30
+ if (fd?.t === 'struct') fd = fd.fields.id;
31
+ return fd && ['text', 'ref', 'u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'fix', 'tick', 'ticks'].includes(fd.t) ? field(fd) : 'never';
32
+ };
33
+ const union = (table, discriminator, metadata) => Object.entries(table ?? {}).map(([name, def]) =>
34
+ `{ readonly ${discriminator}: ${q(name)}; readonly args: Readonly<${record(Object.entries(def.args ?? {}).map(([key, source]) => [key, argument(source)]))}>; ${metadata} }`).join(' | ') || 'never';
35
+ // A floor names one goal and one line, each with exactly its declared arguments, as argsWhy admits them.
36
+ const choice = (table, name, args) => [`{ ${name}?: null; ${args}?: Record<string, never> }`, ...Object.entries(table ?? {}).map(([n, def]) => {
37
+ const list = Object.entries(def.args ?? {}).map(([key, source]) => [key, argument(source)]);
38
+ return `{ ${name}: ${q(n)}; ${args}${list.length ? `: Readonly<${record(list)}>` : '?: Record<string, never>'} }`;
39
+ })].join(' | ');
40
+ return { goals: union(vocab.goals, 'goal', "readonly from: 'brain' | 'floor'; readonly at: number; readonly asked: boolean; readonly state: 'active' | 'done' | 'failed'"),
41
+ asks: union(vocab.asks, 'k', 'readonly from: number; readonly at: number'),
42
+ // The arguments of a request the server admitted carry a compile-time mark, so a floor may forward
43
+ // { goal: ask.k, args: ask.args } without narrowing while arguments it writes itself must fit their goal.
44
+ floorAsks: 'GameRequest extends infer A ? A extends { args: object } ? Omit<A, \'args\'> & { readonly args: A[\'args\'] & Admitted } : never : never',
45
+ decision: `(${choice(vocab.goals, 'goal', 'args')} | { goal: Extract<GameRequest['k'], GameGoal['goal']>; args: FloorRequest['args'] }) & (${choice(vocab.lines, 'say', 'sayArgs')})` };
46
+ }
47
+ export function rulesTypes(checked, tune) {
48
+ const { schema: s, declarations: d } = checked;
49
+ const vocabulary = vocabularyTypes(checked.vocab, d.view);
50
+ const kind = (k) => `Self<${fields(k.fields)}, ${fields(k.motion)}, ${fields(k.input)}, ${q(k.name)}, ${k.player}, GameGoal>`;
51
+ const all = record(s.kinds.map((k) => [k.name, kind(k)]));
52
+ const create = record(s.kinds.filter((k) => !k.player).map((k) => [k.name, k.fields.length ? fields(k.fields) : 'Record<string, never>']));
53
+ const asks = record(Object.entries(d.asks).map(([n, a]) => [n, fields(a.state)]));
54
+ const tunables = { ...Object.fromEntries(Object.entries(tune.public ?? {}).map(([n, v]) => [n, v?.value ?? v])), ...Object.fromEntries(Object.entries(tune).filter(([n]) => n !== 'public').map(([n, v]) => [n, v?.value ?? v])) };
55
+ const parameters = `${shapes(d.events)}, ${shapes(s.effects)}, ${fields(s.shared)}, ${all}, ${create}, ${asks}, ${value(tunables)}`;
56
+ const answer = scope => checked.answerScopes ? `Extract<Answers, { ask: ${[...new Set([...(checked.answerScopes.scopes[scope] ?? []), ...checked.answerScopes.common])].map(q).join(' | ') || 'never'} }>` : 'Answers';
57
+ const entities = record(s.kinds.map((k) => [k.name, `Entity<W, ${kind(k)}, ${fields(k.input)}, Events, Commands, ${fields(Object.entries(d.view))}, ${answer(k.name)}, GameGoal, FloorRequest, GameDecision>`]));
58
+ const joins = s.kinds.filter((k) => k.player).map((k) => `{ kind: ${q(k.name)}; at: Point; heading?: Point; fields?: Partial<WriteState<${fields(k.fields)}>>; motion?: Partial<WriteState<${fields(k.motion)}>> }`).join(' | ') || 'never';
59
+ const moves = `{ ${s.kinds.filter((k) => k.radius > 0).map((k) => `${q(k.name)}${k.player ? '' : '?'}: (body: MoveBody<${fields(k.motion)}>, input: ReadonlyState<${fields(k.input)}>, ctx: MoveContext<${value(checked.publicTune)}, ${fields(k.motion)}>) => undefined`).join('; ')} }`;
60
+ return `// Generated from this game's checked declarations. The build always uses strict compiler settings.
61
+ export * from ${q(join(PACKAGE_ROOT, 'rules/rules.ts'))};
62
+ import type { RulesDef, Vec3, Fields } from ${q(join(PACKAGE_ROOT, 'rules/rules.ts'))};
63
+ import type { World, Self, Entity, Point, MoveBody, MoveContext, ReadonlyState, WriteState, EventData, RoomEvents, Goal, GuideRequest, GuideDecision } from ${q(join(PACKAGE_ROOT, 'rules/types.ts'))};
64
+ export type GameGoal = ${vocabulary.goals};
65
+ export type GameRequest = ${vocabulary.asks};
66
+ declare const admitted: unique symbol;
67
+ type Admitted = { readonly [admitted]: true };
68
+ type FloorRequest = ${vocabulary.floorAsks};
69
+ type GameDecision = ${vocabulary.decision};
70
+ type Events = ${shapes(d.events)};
71
+ type Commands = ${shapes(s.commands)};
72
+ type Answers = ${Object.entries(d.asks).map(([n, a]) => `{ ask: ${q(n)}; by: 'local' | 'ai' | 'floor'; picks: ${picks(a.questions)}; why?: string }`).join(' | ') || 'never'};
73
+ type GameRoomEvents = Omit<RoomEvents<Events>, 'answer'> & { answer: ${answer('room')} };
74
+ export type GameWorld = W;
75
+ export type GameSelf<K extends keyof GameEntities> = GameEntities[K];
76
+ type GameEntities = ${all};
77
+ type W = World<${parameters}>;
78
+ type RW = World<${parameters}, true>;
79
+ type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
80
+ entities: ${entities}; move?: Moves;
81
+ room?: { rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number };
82
+ start?: (world: RW) => void;
83
+ join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => ${joins};
84
+ on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
85
+ };
86
+ asks?: ${record(Object.entries(d.asks).map(([n, a]) => [n, `{ state: Fields; questions: ${value(a.questions)}; floor: (state: ReadonlyState<${fields(a.state)}>) => ${picks(a.questions)} }`]))};
87
+ };
88
+ type Moves = ${moves};
89
+ export declare function defineRules(def: Definition): Definition;
90
+ export declare function defineMove(def: Moves): Moves;
91
+ `;
92
+ }
93
+
94
+ /** The view reads the same declared fields and names as the rules. */
95
+ export function rulesViewTypes(checked) {
96
+ const s = checked.schema;
97
+ const entities = record(s.kinds.map(k => [k.name, `ReadonlyState<Omit<GameSelf<${q(k.name)}>, 'input'>> & { readonly mine?: boolean }`]));
98
+ const effects = record(Object.entries(s.effects).map(([n, fs]) => [n, `ReadonlyState<${fields(fs)}> & { readonly at?: Vec3; readonly id?: string; readonly tick: number }`]));
99
+ const input = s.kinds.filter(k => k.player).map(k => `Partial<${fields(k.input)}>`).join(' | ') || 'Record<string, never>';
100
+ return `export * from ${q(join(PACKAGE_ROOT, 'rules/view.ts'))};
101
+ import type { Room as BaseRoom, OpenRoomOptions, RoomRound, RoomStatus } from ${q(join(PACKAGE_ROOT, 'rules/view.ts'))};
102
+ import type { ReadonlyState, WriteState } from ${q(join(PACKAGE_ROOT, 'rules/types.ts'))};
103
+ import type { Vec3 } from ${q(join(PACKAGE_ROOT, 'rules/rules.ts'))};
104
+ import type { GameSelf, GameGoal, GameRequest } from './rules';
105
+ type Entities = ${entities};
106
+ type Commands = ${shapes(s.commands)};
107
+ type Effects = ${effects} & { enter: Entity; leave: Entity; placed: Entity; round: RoomRound | null; status: RoomStatus; say: { readonly slot: number; readonly seat: number | null; readonly text: string; readonly line?: string; readonly kind?: string; readonly args: Readonly<Record<string, unknown>> }; goal: { readonly slot: number; readonly goal: GameGoal; readonly prev: GameGoal | null; readonly askAt: number | null }; ask: { readonly slot: number; readonly k: string; readonly ask?: string; readonly from: number; readonly at: number; readonly args: Readonly<Record<string, unknown>> } };
108
+ export type Entity = Entities[keyof Entities];
109
+ // A request's own arguments; a button's k and args, still a union, pass together.
110
+ type AskArgs<K> = GameRequest extends infer A ? A extends { k: infer N; args: infer X } ? K extends N ? X : never : never : never;
111
+ export type Room<R = never> = Omit<BaseRoom<R>, 'me' | 'each' | 'get' | 'on' | 'shared' | 'input' | 'command' | 'tune' | 'ask' | 'askButtons'> & {
112
+ // A name written out must be one of the vocabulary's, with its arguments. A name the view only has as a text (a list
113
+ // of buttons it built itself) passes as it is, with any arguments: the server checks those when they arrive.
114
+ ask<K extends string>(id: string, k: string extends K ? K : K extends GameRequest['k'] ? K : GameRequest['k'], ...args: string extends K ? [args?: Readonly<Record<string, unknown>>] : {} extends AskArgs<K> ? [args?: AskArgs<K>] : [args: AskArgs<K>]): void;
115
+ askButtons(id: string, offer?: Record<string, unknown>): Array<GameRequest extends infer A ? A extends { k: string; args: object } ? Pick<A, 'k' | 'args'> & { readonly text: string } : never : never>;
116
+ readonly me: Extract<Entity, { seat: number }> | null;
117
+ readonly shared: ReadonlyState<${fields(s.shared)}>;
118
+ readonly tune: ReadonlyState<${value(checked.publicTune)}>;
119
+ each<K extends keyof Entities>(kind: K, fn: (entity: Entities[K]) => void): void;
120
+ get(id: string): Entity | null;
121
+ on<K extends keyof Effects>(name: K, fn: (event: Effects[K]) => void): () => void;
122
+ input(sample: ${input}): void;
123
+ command<K extends keyof Commands>(name: K, ...data: {} extends Commands[K] ? [data?: Commands[K]] : [data: Commands[K]]): void;
124
+ };
125
+ export declare function openRoom<R = never>(options?: OpenRoomOptions): Room<R>;
126
+ `;
127
+ }
128
+
129
+ /** Answers return to their caller. A room cannot receive an entity's ask response.
130
+ * Literal asks in a known handler restrict that scope; shared helpers conservatively
131
+ * contribute their asks to every scope, and a dynamic name retains the full union.
132
+ */
133
+ export async function rulesAnswerScopes(entry) {
134
+ const [{ parse }, { default: traverseModule }, fs, path] = await Promise.all([
135
+ import('@babel/parser'), import('@babel/traverse'), import('node:fs'), import('node:path'),
136
+ ]);
137
+ const traverse = traverseModule.default ?? traverseModule;
138
+ const scopes = new Map(); const common = new Set(); const seen = new Set(); let dynamic = false;
139
+ const key = node => node?.key?.name ?? node?.key?.value;
140
+ function read(file) {
141
+ if (seen.has(file)) return; seen.add(file);
142
+ const ast = parse(fs.readFileSync(file, 'utf8'), { sourceType: 'module', plugins: ['typescript'] });
143
+ traverse(ast, {
144
+ ImportDeclaration(p) {
145
+ const name = p.node.source.value; if (!name.startsWith('.')) return;
146
+ const base = path.resolve(path.dirname(file), name);
147
+ const target = [base, `${base}.ts`, `${base}.js`, path.join(base, 'index.ts')].find(f => fs.existsSync(f) && fs.statSync(f).isFile());
148
+ if (target) read(target);
149
+ },
150
+ CallExpression(p) {
151
+ const callee = p.node.callee;
152
+ if (callee.type !== 'MemberExpression' || (callee.property.name ?? callee.property.value) !== 'ask') return;
153
+ const name = p.node.arguments[0];
154
+ if (name?.type !== 'StringLiteral') { dynamic = true; return; }
155
+ let scope;
156
+ for (let a = p.parentPath; a; a = a.parentPath) {
157
+ if (a.isObjectProperty() && key(a.node) === 'room') { scope = 'room'; break; }
158
+ if (a.isObjectProperty() && a.parentPath?.isObjectExpression() && key(a.parentPath.parentPath?.node) === 'entities') { scope = key(a.node); break; }
159
+ }
160
+ if (!scope) common.add(name.value);
161
+ else { const names = scopes.get(scope) ?? new Set(); names.add(name.value); scopes.set(scope, names); }
162
+ },
163
+ });
164
+ }
165
+ read(entry);
166
+ return dynamic ? null : { common: [...common], scopes: Object.fromEntries([...scopes].map(([k, names]) => [k, [...names]])) };
167
+ }
@@ -105,8 +105,8 @@ export function projectPackage(meta, { targets = TARGETS } = {}) {
105
105
  }
106
106
 
107
107
  /** web/config.js: everything the shell knows about the game it frames. `site` '' is a copy that only plays offline. */
108
- export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {} }) {
109
- const cfg = { v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, version: meta.version, target };
108
+ export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {}, room = null }) {
109
+ const cfg = { v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, ...(room ? { room } : {}), version: meta.version, target };
110
110
  return `window.__HOMIE_APP = ${JSON.stringify(cfg, null, 2).replace(/</g, '\\u003c')};\n`;
111
111
  }
112
112
 
@@ -388,6 +388,7 @@ export async function standalonePlan(root, id, opts = {}) {
388
388
 
389
389
  /* ------------------------------------------------------------------ the web folder */
390
390
 
391
+ const readJson = (file) => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } };
391
392
  const copyTree = (from, to) => { rmSync(to, { recursive: true, force: true }); cpSync(from, to, { recursive: true, dereference: true }); };
392
393
 
393
394
  /**
@@ -401,8 +402,11 @@ export function webBundle(root, game, dir, { meta, target = 'web', site = '', st
401
402
  rmSync(web, { recursive: true, force: true });
402
403
  mkdirSync(web, { recursive: true });
403
404
  for (const f of ['index.html', 'shell.js', 'shell.css']) copyFileSync(join(SHELL, f), join(web, f));
404
- const m = meta ?? standaloneMeta(studio ?? readStudio(root), game);
405
- writeFileSync(join(web, 'config.js'), configScript(m, { target, site, share: sharePlaces(game.screen?.share), colours: uiOf(game), movement: game.netplay?.movement ?? game.movement ?? null }));
405
+ const row = (readJson(join(root, 'site', 'dist', 'games.json'))?.games ?? []).find((g) => g.id === game.id);
406
+ const m = { ...(meta ?? standaloneMeta(studio ?? readStudio(root), game)), ...(row?.room ? { netplayVersion: row.netplay?.version ?? null } : {}) };
407
+ const rules = readJson(join(built, 'rules.json'));
408
+ if (rules) for (const file of rules.files ?? []) if (!existsSync(join(built, file))) throw new Error(`The rules build is incomplete: ${file} is missing. Build the game again before making its app.`);
409
+ writeFileSync(join(web, 'config.js'), configScript(m, { target, site, share: sharePlaces(game.screen?.share), colours: uiOf(game), movement: game.netplay?.movement ?? game.movement ?? null, room: row?.room ?? null }));
406
410
  copyTree(built, join(web, 'game'));
407
411
  const index = join(web, 'game', 'index.html');
408
412
  writeFileSync(index, withNetScript(readFileSync(index, 'utf8')));
@@ -1890,7 +1890,10 @@
1890
1890
  "## Art direction and models": "a48fff7bf0a5f2fc",
1891
1891
  "## Running live games (the back office)": "51ebf2ab756f6e4d",
1892
1892
  "## Servers and AI seats": "98d08705087a33ec",
1893
- "## Selling things (the shop)": "9e020d69dd6a954e",
1893
+ "## Selling things (the shop)": [
1894
+ "57c04f6526e16890",
1895
+ "9e020d69dd6a954e"
1896
+ ],
1894
1897
  "## Continuing a build from the Claude app": "0d68081e924ca419",
1895
1898
  "## Rules": "2e6b29afb85dcf7e",
1896
1899
  "## Telling Homie": "80b46fdd9e578db8",
@@ -1946,6 +1949,46 @@
1946
1949
  }
1947
1950
  }
1948
1951
  }
1952
+ },
1953
+ "0.36.0": {
1954
+ "files": {
1955
+ "CLAUDE.md": "ddf2b2a0376202f6",
1956
+ "README.md": "684b3cca8001673c",
1957
+ "HANDOFF.md": "29337e2ace95b2e7",
1958
+ "games/README.md": "5ad32fe80c61f117",
1959
+ "music/README.md": "a3dc042dadd39df8",
1960
+ "videos/README.md": "16babc3437d5545b",
1961
+ "posts/README.md": "37841aae476bd540",
1962
+ "site/README.md": "732894273a32dc3f",
1963
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1964
+ },
1965
+ "sections": {
1966
+ "AGENTS.md": {
1967
+ "(intro)": "214aa46eb3a895d5",
1968
+ "## Layout": "8baf49e4291e7e88",
1969
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1970
+ "## The site": "0cb8dd9a6f8a0096",
1971
+ "## Making games": "2da7fa74c8884834",
1972
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1973
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1974
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1975
+ "## Servers and AI seats": "98d08705087a33ec",
1976
+ "## Selling things (the shop)": "9e020d69dd6a954e",
1977
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1978
+ "## Rules": "2e6b29afb85dcf7e",
1979
+ "## Telling Homie": "80b46fdd9e578db8",
1980
+ "## Beta": "9d02638207a26e73"
1981
+ }
1982
+ },
1983
+ "named": {
1984
+ "Homie Arcade": {
1985
+ "sections": {
1986
+ "AGENTS.md": {
1987
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1988
+ }
1989
+ }
1990
+ }
1991
+ }
1949
1992
  }
1950
1993
  }
1951
1994
  }
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). @homie-rocks/studio does not depend on it: the check is something a studio opts into, and a studio that
10
- * never does pays nothing for it. Without it the command stops and says the one line that installs it.
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
- * Never run unasked: a plain `build` is exactly as fast as it was.
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
+ }
@@ -1,10 +1,17 @@
1
- # Homie netplay contract, v1 (revision 10)
1
+ # Homie netplay contract, v1 (revision 11)
2
2
 
3
- Status: **v1, revision 10** (2026-10-07, `@homie-rocks/studio` 0.33.0). The wire
4
- version is `v: 1`. Everything revisions 2 to 10 added is either an optional field, a new message type,
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 keep the existing version and checkpoint
2251
- behaviour. An old standalone app must update before joining a rules game's live room.
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.