@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. package/worker/version.mjs +1 -1
@@ -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
+ }
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
1
2
  /**
2
3
  * `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
3
4
  * monorepo: a visible folder the person opens in Claude or Codex.
@@ -131,6 +132,16 @@ directory lists its games; homie.rocks does not host them.
131
132
  | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
132
133
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
133
134
 
135
+ ## Apps: one screen, roles and parts
136
+
137
+ An app lives in \`apps/<id>/app.json\` and \`src/\`. Make one with \`homie-studio app new <id>\` (or \`app_make\`). Use the Homie plugin's \`app\` skill for businesses, venues, causes and customer apps. No game demo, rounds, scores, bots or mandatory fun: build what the person needs with the fewest human steps.
138
+
139
+ An app is ONE screen that morphs with context: camera moves, panels unfold, its roles see and do different things, and parts plug into the scene. No page-to-page navigation or menus of links inside it. Use all the same engine packages and media skills as games (camera, geom, props, render, postfx, fx, audio, input, ui-world; style, art, models, animate, music, sound, video). It can be fully 3D and wild while remaining useful and legible.
140
+
141
+ Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
142
+
143
+ Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
144
+
134
145
  ## Commands (all through the pinned CLI in node_modules)
135
146
 
136
147
  Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
@@ -700,6 +711,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
700
711
  'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
701
712
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
702
713
  '.gitignore': GITIGNORE,
714
+ 'apps/README.md': 'One folder per app: app.json, index.html and src/. One transforming screen, its roles and its parts. Start with `npx --no-install homie-studio app new <id>`. The app guide explains lasting records, staff sign-in, LAN preview and standalone builds.\n',
703
715
  'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
704
716
  'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
705
717
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
@@ -728,6 +740,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
728
740
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
729
741
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
730
742
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
743
+ [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
731
744
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
732
745
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
733
746
  '.claude/skills/.gitkeep': '',
@@ -877,13 +890,19 @@ function ensureLoungeMigration(root) {
877
890
  return `site/migrations/${LOUNGE_MIGRATION_FILE}`;
878
891
  }
879
892
 
893
+ function ensureAppsMigration(root) {
894
+ const file = join(root, 'site', 'migrations', APPS_MIGRATION_FILE);
895
+ if (existsSync(file)) return null;
896
+ mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
897
+ return `site/migrations/${APPS_MIGRATION_FILE}`;
898
+ }
880
899
  export function ensureMigrations(root) {
881
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
900
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root)].filter(Boolean);
882
901
  }
883
902
 
884
903
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
885
904
  export function migrationWord(file) {
886
- return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
905
+ return /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
887
906
  }
888
907
 
889
908
  /**
package/lib/shop.mjs CHANGED
@@ -32,7 +32,7 @@ import { dirname, join } from 'node:path';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { askedFor, withKey } from './office.mjs';
34
34
  import { runner } from './cloudflare.mjs';
35
- import { listGames, readStudio, siteUrl } from './studio.mjs';
35
+ import { listExperiences as listGames, readStudio, siteUrl } from './studio.mjs';
36
36
  import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
37
37
  import {
38
38
  KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
@@ -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, kind = null }) {
109
+ const cfg = { ...(kind === 'app' ? { kind: 'app' } : {}), 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
 
@@ -1,3 +1,4 @@
1
+ import { APP_SHELL_JS } from '../worker/app-shell.mjs';
1
2
  /**
2
3
  * `homie-studio standalone plan|build|run|steam|ci <game>`: A STANDALONE COPY OF A GAME (standalone/STANDALONE.md).
3
4
  *
@@ -30,7 +31,7 @@ import { homedir, tmpdir } from 'node:os';
30
31
  import { dirname, join, relative } from 'node:path';
31
32
  import { build as buildSite, uiOf } from './build.mjs';
32
33
  import { compareVersions } from './changelog.mjs';
33
- import { GAME_ID, PACKAGE_ROOT, isWorkersDev, listGames, readStudio, siteUrl } from './studio.mjs';
34
+ import { GAME_ID, PACKAGE_ROOT, isWorkersDev, listExperiences as listGames, readStudio, siteUrl } from './studio.mjs';
34
35
  import { pinnedVersion } from './upgrade.mjs';
35
36
  import { STUDIO_VERSION } from './version.mjs';
36
37
  import { sharePlaces } from '../worker/pages.mjs';
@@ -388,6 +389,7 @@ export async function standalonePlan(root, id, opts = {}) {
388
389
 
389
390
  /* ------------------------------------------------------------------ the web folder */
390
391
 
392
+ const readJson = (file) => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } };
391
393
  const copyTree = (from, to) => { rmSync(to, { recursive: true, force: true }); cpSync(from, to, { recursive: true, dereference: true }); };
392
394
 
393
395
  /**
@@ -401,8 +403,15 @@ export function webBundle(root, game, dir, { meta, target = 'web', site = '', st
401
403
  rmSync(web, { recursive: true, force: true });
402
404
  mkdirSync(web, { recursive: true });
403
405
  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 }));
406
+ if (game.kind === 'app') {
407
+ const shell = join(web, 'shell.js');
408
+ writeFileSync(shell, readFileSync(shell, 'utf8') + '\n' + APP_SHELL_JS);
409
+ }
410
+ const row = (readJson(join(root, 'site', 'dist', 'games.json'))?.games ?? []).find((g) => g.id === game.id);
411
+ const m = { ...(meta ?? standaloneMeta(studio ?? readStudio(root), game)), ...(row?.room ? { netplayVersion: row.netplay?.version ?? null } : {}) };
412
+ const rules = readJson(join(built, 'rules.json'));
413
+ 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.`);
414
+ 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, ...(game.kind === 'app' ? { kind: 'app', params: { role: game.surfaces.phone } } : {}) }));
406
415
  copyTree(built, join(web, 'game'));
407
416
  const index = join(web, 'game', 'index.html');
408
417
  writeFileSync(index, withNetScript(readFileSync(index, 'utf8')));
package/lib/stats.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
14
14
  * locally (`npm run dev`, a loopback --url) uses the local D1.
15
15
  */
16
+ import { networkInterfaces } from 'node:os';
16
17
  import { createHash, randomBytes } from 'node:crypto';
17
18
  import { runner } from './cloudflare.mjs';
18
19
  import { readStudio, siteUrl, writeStudio } from './studio.mjs';
@@ -21,7 +22,7 @@ const HOUR = 3600_000;
21
22
  const RANGES = new Set(['1d', '7d', '30d', '90d']);
22
23
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
23
24
 
24
- export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
25
+ export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]', ...Object.values(networkInterfaces()).flat().filter((n) => n?.family === 'IPv4').map((n) => n.address)].includes(new URL(url).hostname); } catch { return false; } };
25
26
 
26
27
  export function siteOf(root, url) {
27
28
  const studio = readStudio(root);
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
11
+ const RESERVED_IDS = new Set(['api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
@@ -82,14 +82,25 @@ export function siteUrl(root, studio = readStudio(root)) {
82
82
  return custom ?? readLocal(root).url ?? cf.url ?? null;
83
83
  }
84
84
 
85
- export function listGames(root) {
86
- const dir = join(root, 'games');
85
+ export function listGames(root) { return listKind(root, 'game'); }
86
+ export function listApps(root) { return listKind(root, 'app'); }
87
+ export function listExperiences(root) {
88
+ const rows = [...listGames(root), ...listApps(root)].sort((a, b) => a.id.localeCompare(b.id));
89
+ const ids = new Set();
90
+ for (const row of rows) { if (ids.has(row.id)) throw new Error(`app/game id collision: ${row.id}`); ids.add(row.id); }
91
+ return rows;
92
+ }
93
+ export const experienceDir = (root, id) => join(root, existsSync(join(root, 'apps', id, 'app.json')) ? 'apps' : 'games', id);
94
+ export const experienceFile = (root, id) => join(experienceDir(root, id), existsSync(join(root, 'apps', id, 'app.json')) ? 'app.json' : 'game.json');
95
+ function listKind(root, kind) {
96
+ const dir = join(root, `${kind}s`);
87
97
  if (!existsSync(dir)) return [];
88
98
  return readdirSync(dir, { withFileTypes: true })
89
- .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, 'game.json')))
99
+ .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, `${kind}.json`)))
90
100
  .map((d) => {
91
- const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
92
- return { ...meta, id: meta.id ?? d.name, dir: join(dir, d.name) };
101
+ const meta = JSON.parse(readFileSync(join(dir, d.name, `${kind}.json`), 'utf8'));
102
+ if (kind === 'app' && (!GAME_ID.test(d.name) || RESERVED_IDS.has(d.name) || (meta.id !== undefined && meta.id !== d.name))) throw new Error(`apps/${d.name}: id must match its folder and use a free site address`);
103
+ return { ...meta, ...(kind === 'app' ? { kind: 'app' } : {}), id: meta.id ?? d.name, dir: join(dir, d.name) };
93
104
  })
94
105
  .sort((a, b) => a.id.localeCompare(b.id));
95
106
  }
@@ -141,6 +152,7 @@ function renameTakeSaves(dir, from, id) {
141
152
  */
142
153
  export async function newGame(root, id, { from = 'gem-rush', name } = {}) {
143
154
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
155
+ if (listApps(root).some((a) => a.id === id)) throw new Error(`apps/${id} already owns this address`);
144
156
  const src = join(PACKAGE_ROOT, 'starters', from);
145
157
  if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
146
158
  const dest = join(root, 'games', id);
@@ -287,3 +299,17 @@ export function addNeeds(root, needs) {
287
299
  return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
288
300
  }
289
301
 
302
+
303
+ export async function newApp(root, id, { name } = {}) {
304
+ if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('invalid app id');
305
+ if (listExperiences(root).some((g) => g.id === id) || existsSync(join(root, 'apps', id))) throw new Error(`app/game ${id} already exists`);
306
+ const dest = join(root, 'apps', id);
307
+ cpSync(join(PACKAGE_ROOT, 'app-starters', 'welcome'), dest, { recursive: true });
308
+ const file = join(dest, 'app.json');
309
+ const meta = JSON.parse(readFileSync(file, 'utf8'));
310
+ meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
311
+ writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
312
+ const main = join(dest, 'src/main.ts');
313
+ writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
314
+ return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
+ }
@@ -1,3 +1,4 @@
1
+ import { experienceDir, experienceFile } from './studio.mjs';
1
2
  /**
2
3
  * THE STYLE BOARD AND THE LINEUP: pictures the engine draws for free (lib/render3d.mjs).
3
4
  *
@@ -77,10 +78,10 @@ export async function styleBoard(root, id, { prompt = '', log = () => {}, librar
77
78
  if (!doc) { initDecisions(root, id, { prompt, path: 'hands-on' }); doc = readDecisions(root, id); }
78
79
  const { directions } = directionsFor(root, id, { prompt: prompt || doc.prompt });
79
80
  let game = {};
80
- try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
81
+ try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
81
82
  const title = game.name ?? id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
82
83
  const words = [prompt, doc.prompt, (doc.decisions['cast.list']?.value ?? []).map((c) => c.id).join(' ')].join(' ');
83
- const dir = join(root, 'games', id, BOARD_DIR);
84
+ const dir = join(experienceDir(root, id), BOARD_DIR);
84
85
  mkdirSync(dir, { recursive: true });
85
86
  const pieces = {};
86
87
  if (library) for (const d of directions) if (!pieces[d.family]) pieces[d.family] = await libraryPieces(d.family, words, 4, log);
@@ -90,7 +91,7 @@ export async function styleBoard(root, id, { prompt = '', log = () => {}, librar
90
91
  const url = await r.swatch(tokensOf(d.values), pieces[d.family] ?? [], { title });
91
92
  const file = join(dir, `${d.id}-swatch.jpg`);
92
93
  const bytes = await savePicture(url, file);
93
- out.push({ ...d, swatch: relative(join(root, 'games', id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
94
+ out.push({ ...d, swatch: relative(experienceDir(root, id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
94
95
  }
95
96
  return r.renderer;
96
97
  }, { log });
@@ -124,13 +125,13 @@ export async function recordMood(root, id, dirId, image, { usd = null, receipt =
124
125
  const d = doc?.board?.directions?.find((x) => x.id === dirId);
125
126
  if (!d) throw new Error(`no direction "${dirId}" on the style board (style board ${id} draws it first)`);
126
127
  if (!existsSync(image)) throw new Error(`no picture at ${image}`);
127
- const file = join(root, 'games', id, BOARD_DIR, `${dirId}-mood.jpg`);
128
+ const file = join(experienceDir(root, id), BOARD_DIR, `${dirId}-mood.jpg`);
128
129
  const sharp = (await import('sharp')).default;
129
130
  let q = 82;
130
131
  let out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer();
131
132
  while (out.byteLength > PICTURE_MAX && q > 50) { q -= 10; out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer(); }
132
133
  writeFileSync(file, out);
133
- d.mood = { path: relative(join(root, 'games', id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
134
+ d.mood = { path: relative(experienceDir(root, id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
134
135
  writeDecisions(root, id, doc);
135
136
  return { ok: true, command: 'style mood', id, direction: dirId, mood: d.mood };
136
137
  }
@@ -143,14 +144,14 @@ export async function addGolden(root, id, image, { from = null } = {}) {
143
144
  doc.golden = doc.golden ?? [];
144
145
  if (doc.golden.length >= 6) throw new Error('six golden images already: remove one first (style golden <id> remove <n>)');
145
146
  const n = doc.golden.reduce((m, g) => Math.max(m, Number(/(\d+)\.jpg$/.exec(g.path)?.[1] ?? 0)), 0) + 1;
146
- const dir = join(root, 'games', id, BOARD_DIR, 'golden');
147
+ const dir = join(experienceDir(root, id), BOARD_DIR, 'golden');
147
148
  mkdirSync(dir, { recursive: true });
148
149
  const file = join(dir, `${n}.jpg`);
149
150
  try {
150
151
  const sharp = (await import('sharp')).default;
151
152
  writeFileSync(file, await sharp(readFileSync(image)).resize(1024, 1024, { fit: 'inside' }).jpeg({ quality: 84, mozjpeg: true }).toBuffer());
152
153
  } catch { if (/\.jpe?g$/i.test(extname(image))) copyFileSync(image, file); else throw new Error('sharp could not read that picture'); }
153
- doc.golden.push({ path: relative(join(root, 'games', id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
154
+ doc.golden.push({ path: relative(experienceDir(root, id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
154
155
  writeDecisions(root, id, doc);
155
156
  addLatestLine(root, id, `Added golden image ${n} (from ${from ?? relative(root, image)}): every concept is made with it as a reference.`);
156
157
  return { ok: true, command: 'style golden', id, golden: doc.golden };
@@ -183,13 +184,13 @@ export async function assetsLineup(root, id, { log = () => {}, scope = null } =
183
184
  for (const a of manifest.assets) {
184
185
  const f = (a.files ?? []).find((x) => x.role === 'model');
185
186
  if (!f) continue;
186
- const abs = join(root, 'games', id, f.path);
187
+ const abs = join(experienceDir(root, id), f.path);
187
188
  if (!existsSync(abs)) continue;
188
189
  // `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
189
190
  // lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
190
191
  const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
191
192
  // A character stands in its idle pose from its skeleton's clip library (not its bind pose: arms out in a T).
192
- const anims = a.rig?.anims && existsSync(join(root, 'games', id, a.rig.anims)) ? readFileSync(join(root, 'games', id, a.rig.anims)).toString('base64') : null;
193
+ const anims = a.rig?.anims && existsSync(join(experienceDir(root, id), a.rig.anims)) ? readFileSync(join(experienceDir(root, id), a.rig.anims)).toString('base64') : null;
193
194
  models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(anims ? { anims, pose: 'idle', poseAt: 0.3 } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
194
195
  }
195
196
  if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
@@ -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,87 @@
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
+ }
1992
+ },
1993
+ "0.37.0": {
1994
+ "files": {
1995
+ "CLAUDE.md": "ddf2b2a0376202f6",
1996
+ "README.md": "684b3cca8001673c",
1997
+ "HANDOFF.md": "29337e2ace95b2e7",
1998
+ "games/README.md": "5ad32fe80c61f117",
1999
+ "music/README.md": "a3dc042dadd39df8",
2000
+ "videos/README.md": "16babc3437d5545b",
2001
+ "posts/README.md": "37841aae476bd540",
2002
+ "site/README.md": "732894273a32dc3f",
2003
+ "site/src/worker.mjs": "fc90f3609cad18ba"
2004
+ },
2005
+ "sections": {
2006
+ "AGENTS.md": {
2007
+ "(intro)": "214aa46eb3a895d5",
2008
+ "## Layout": "8baf49e4291e7e88",
2009
+ "## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
2010
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2011
+ "## The site": "0cb8dd9a6f8a0096",
2012
+ "## Making games": "2da7fa74c8884834",
2013
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2014
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2015
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2016
+ "## Servers and AI seats": "98d08705087a33ec",
2017
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2018
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2019
+ "## Rules": "2e6b29afb85dcf7e",
2020
+ "## Telling Homie": "80b46fdd9e578db8",
2021
+ "## Beta": "9d02638207a26e73"
2022
+ }
2023
+ },
2024
+ "named": {
2025
+ "Homie Arcade": {
2026
+ "sections": {
2027
+ "AGENTS.md": {
2028
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2029
+ }
2030
+ }
2031
+ }
2032
+ }
1949
2033
  }
1950
2034
  }
1951
2035
  }