@homie-rocks/studio 0.35.0 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/agents/agents.ts +8 -6
  3. package/bin/homie-studio.mjs +9 -6
  4. package/lab/lab.ts +15 -0
  5. package/lib/build.mjs +20 -15
  6. package/lib/dev.mjs +16 -1
  7. package/lib/lab-check.mjs +18 -3
  8. package/lib/lab.mjs +21 -7
  9. package/lib/mcp-tools.mjs +2 -1
  10. package/lib/rules-build.mjs +97 -49
  11. package/lib/rules-check-worker.mjs +405 -0
  12. package/lib/rules-check.mjs +69 -0
  13. package/lib/rules-compiler.mjs +30 -0
  14. package/lib/rules-guard.mjs +26 -7
  15. package/lib/rules-types.mjs +167 -0
  16. package/lib/shop-catalog.mjs +20 -1
  17. package/lib/shop-links.mjs +3 -1
  18. package/lib/shop.mjs +14 -8
  19. package/lib/standalone-files.mjs +2 -2
  20. package/lib/standalone.mjs +6 -2
  21. package/lib/stripe-connect.mjs +18 -7
  22. package/lib/template-history.json +83 -0
  23. package/lib/typecheck.mjs +52 -3
  24. package/netplay/NETPLAY.md +123 -6
  25. package/netplay/netplay.ts +289 -53
  26. package/netplay/rules-output.mjs +85 -0
  27. package/package.json +3 -2
  28. package/rules/browser.ts +100 -0
  29. package/rules/core.ts +159 -58
  30. package/rules/guard.ts +11 -4
  31. package/rules/host.ts +90 -15
  32. package/rules/math.ts +16 -0
  33. package/rules/pack.ts +94 -17
  34. package/rules/rules.ts +21 -13
  35. package/rules/types.ts +63 -0
  36. package/rules/view.ts +36 -33
  37. package/standalone/shell/shell.js +8 -6
  38. package/worker/brain.mjs +2 -1
  39. package/worker/hosted.mjs +1 -1
  40. package/worker/index.mjs +7 -11
  41. package/worker/limits.mjs +41 -0
  42. package/worker/pages.mjs +4 -2
  43. package/worker/room.mjs +136 -63
  44. package/worker/stripe.mjs +2 -2
  45. 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
+ }
@@ -22,7 +22,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
22
22
  import { readStudio } from './studio.mjs';
23
23
  import { readShop } from './shop.mjs';
24
24
  import { defaultTaxCode, money } from '../worker/shop-rules.mjs';
25
- import { productIdOf } from '../worker/stripe.mjs';
25
+ import { productIdOf, redactStripe } from '../worker/stripe.mjs';
26
26
 
27
27
  const MARK = 'shop-v1';
28
28
 
@@ -58,6 +58,22 @@ export function productsIn(value, out = [], depth = 0) {
58
58
  return out;
59
59
  }
60
60
 
61
+ // Saved MCP responses can wrap a Stripe refusal in text or structured content.
62
+ function catalogError(value, depth = 0) {
63
+ if (depth > 8 || value == null) return null;
64
+ if (typeof value === 'string') {
65
+ try { return catalogError(JSON.parse(value), depth + 1); } catch { return null; }
66
+ }
67
+ if (typeof value !== 'object') return null;
68
+ if (typeof value.error?.message === 'string') return value.error.message;
69
+ if (value.isError === true) return (value.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join(' ');
70
+ for (const child of Array.isArray(value) ? value : ['data', 'content', 'text', 'result', 'results', 'structuredContent'].map((k) => value[k])) {
71
+ const error = catalogError(child, depth + 1);
72
+ if (error) return error;
73
+ }
74
+ return null;
75
+ }
76
+
61
77
  const taxCodeOf = (p) => (typeof p?.tax_code === 'string' ? p.tax_code : p?.tax_code?.id ?? null);
62
78
 
63
79
  /** The plan: the read first, then (with what Stripe answered) the writes still needed. */
@@ -83,6 +99,8 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
83
99
  ],
84
100
  };
85
101
  }
102
+ const error = catalogError(have);
103
+ if (error) return { ok: false, command: 'shop catalog', why: `Stripe said: “${redactStripe(error).slice(0, 400)}” (GET /v1/products). Read the catalog again after resolving it.` };
86
104
  const products = productsIn(have);
87
105
  const ours = products.filter((p) => p.metadata?.homie_studio === String(slug) || wanted.some((w) => w.id === p.id));
88
106
  const modes = [...new Set(ours.map((p) => (p.livemode ? 'live' : 'test')))];
@@ -149,6 +167,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
149
167
 
150
168
  export function catalogLines(r) {
151
169
  const lines = [];
170
+ if (!r.ok) return [r.why];
152
171
  if (r.step === 'read') {
153
172
  lines.push('The catalog shop.json asks for (one Product an item, the same ids in the sandbox and live):');
154
173
  for (const p of r.products) lines.push(` ${p.id} ${p.name} ${p.price} (tax code ${p.tax_code})`);
@@ -37,8 +37,10 @@ export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt
37
37
  let product = products.find((p) => p.id === id);
38
38
  if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
39
39
  else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
40
+ // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
+ // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
40
42
  const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
41
- ? { custom_unit_amount: { enabled: true, minimum: item.min, ...(item.max !== null ? { maximum: item.max } : {}) } }
43
+ ? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
42
44
  : { unit_amount: item.price }) };
43
45
  const revision = hash(priceFields);
44
46
  let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
package/lib/shop.mjs CHANGED
@@ -205,7 +205,7 @@ async function makeWebhook(env, site, { fetcher }) {
205
205
  const list = await listWebhookEndpoints(env, { fetcher });
206
206
  older = (list?.data ?? []).filter((e) => e?.url === url && e?.metadata?.homie === HOOK_MARK && e?.status !== 'disabled').map((e) => e.id).filter((id) => typeof id === 'string');
207
207
  } catch (error) {
208
- if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
208
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".' + ` Stripe said: ${error.message}`) };
209
209
  return { ok: false, page: said(502, `Stripe did not answer the webhook list (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
210
210
  }
211
211
  let made = null;
@@ -215,7 +215,7 @@ async function makeWebhook(env, site, { fetcher }) {
215
215
  description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
216
216
  }, { fetcher });
217
217
  } catch (error) {
218
- if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
218
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".' + ` Stripe said: ${error.message}`) };
219
219
  return { ok: false, page: said(502, `Stripe did not make the webhook (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
220
220
  }
221
221
  const secret = typeof made?.secret === 'string' ? made.secret : '';
@@ -236,7 +236,7 @@ async function probeManaged(env, shop, { fetcher }) {
236
236
  line_items: [{ quantity: 1, price_data: { currency: shop?.currency ?? 'usd', unit_amount: item?.price ?? 500, tax_behavior: 'exclusive', product_data: { name: 'Homie shop check (test, expired at once)', tax_code: defaultTaxCode(item ?? {}) } } }],
237
237
  managed_payments: { enabled: true }, metadata: { homie: 'shop-check' },
238
238
  }, { fetcher, idempotencyKey: `homie-check-${randomBytes(8).toString('hex')}` });
239
- if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch { /* it expires by itself */ } }
239
+ if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch (error) { return { ok: false, words: `Stripe took the test checkout but could not expire it (${error instanceof StripeError ? error.message : 'no answer'}). It will expire by itself.` }; } }
240
240
  return { ok: true, words: 'Stripe took a test checkout with Managed Payments: it is on for this account (test mode).' };
241
241
  } catch (error) {
242
242
  return { ok: false, words: `Stripe refused a test checkout with Managed Payments (${error instanceof StripeError ? error.message : 'no answer'}). Finish Managed Payments in Stripe (Settings → Managed Payments: accept the terms, pass the eligibility review), or pick "You are" the seller.` };
@@ -284,14 +284,16 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
284
284
  // One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
285
285
  // except to Stripe itself.
286
286
  if (verify) {
287
- try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch {
288
- say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.')); return;
287
+ try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch (error) {
288
+ const reason = error instanceof StripeError ? ` Stripe said: ${error.message}` : '';
289
+ log(`Stripe could not read checkouts.${reason}`);
290
+ say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.' + reason)); return;
289
291
  }
290
292
  }
291
293
  let webhook = { made: false };
292
294
  if (!hook) {
293
295
  const m = await makeWebhook(sk, site, { fetcher });
294
- if (!m.ok) { say(m.page); return; }
296
+ if (!m.ok) { log(m.page.text); say(m.page); return; }
295
297
  hook = m.secret;
296
298
  webhook = { made: true, id: m.id, older: m.older };
297
299
  }
@@ -303,10 +305,13 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
303
305
  // The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
304
306
  // not deleted: the owner can turn one back on in Stripe).
305
307
  let disabled = 0;
308
+ const warnings = [];
306
309
  if (okay && webhook.made) {
307
- for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch { /* left on; Stripe retries and says so */ } }
310
+ for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch (error) { warnings.push(`Stripe could not turn off webhook ${id}: ${error instanceof StripeError ? error.message : 'no answer'}`); } }
308
311
  }
309
312
  const check = okay && !live && chosen === 'stripe-managed' ? await probeManaged(sk, shopNow.shop, { fetcher }) : null;
313
+ if (check && !check.ok) warnings.push(check.words);
314
+ for (const warning of warnings) log(warning);
310
315
  let tillNote = '';
311
316
  if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
312
317
  try {
@@ -317,13 +322,14 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
317
322
  } catch { tillNote = ''; }
318
323
  }
319
324
  const narrow = webhook.made ? ' Recommended, 20 seconds: in Stripe, open this key (… → Edit key) and set Webhook Endpoints back to None. The shop never needs it again.' : '';
320
- say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check ? ` ${check.words}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
325
+ say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check?.ok ? ` ${check.words}` : ''}${warnings.length ? ` ${warnings.join(' ')}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
321
326
  server.close();
322
327
  done(okay
323
328
  ? {
324
329
  ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
325
330
  webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
326
331
  ...(check ? { managedPayments: check } : {}),
332
+ ...(warnings.length ? { warnings } : {}),
327
333
  message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${webhook.made ? ` Stripe made the webhook ${webhook.id} for ${hookUrlOf(site)}${disabled ? ` and ${disabled} older one(s) were turned off` : ''}.` : ''}${tillNote}${narrow} homie-studio shop says whether anything else is missing.`,
328
334
  }
329
335
  : { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
@@ -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')));
@@ -11,7 +11,7 @@ import { readStudio, siteUrl } from './studio.mjs';
11
11
  import { hookUrlOf, readShop, shopConnectManual } from './shop.mjs';
12
12
  import { syncPaymentLinks } from './shop-links.mjs';
13
13
  import { LINKS_STRIPE_VERSION, fullerReason } from '../worker/shop-links.mjs';
14
- import { formEncode } from '../worker/stripe.mjs';
14
+ import { formEncode, redactStripe } from '../worker/stripe.mjs';
15
15
 
16
16
  const hash = (s) => createHash('sha256').update(s).digest('hex');
17
17
  const failure = (needs, why, extra = {}) => ({ ok: false, command: 'shop connect', needs, why, ...extra });
@@ -28,6 +28,14 @@ export function stripeConnectionInfo(root, { storage = receiptHome(), now = Date
28
28
  }
29
29
 
30
30
  const json = (s) => { try { return JSON.parse(s); } catch { return null; } };
31
+ // Stripe CLI also prints login failures on stderr (stripe/stripe-cli pkg/cmd/root.go).
32
+ // Never fall back to stdout: successful login output can contain credentials.
33
+ const stripeReason = (result, call) => {
34
+ const message = (json(result?.stdout)?.error ?? json(result?.stderr)?.error)?.message
35
+ ?? (result?.code !== 0 ? result?.stderr?.trim() : null);
36
+ return typeof message === 'string' ? ` Stripe said: “${redactStripe(message).slice(0, 400)}” (${call}).` : '';
37
+ };
38
+
31
39
  // This output stays private, including CLI config, completion, errors and API responses.
32
40
  export function stripeProcess(args, { cwd, env = process.env } = {}) {
33
41
  const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
@@ -94,15 +102,15 @@ export async function shopConnect(root, options = {}) {
94
102
  const login = json(start.stdout);
95
103
  const complete = completionArgs(login?.next_step);
96
104
  if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
97
- return failure('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.');
105
+ return failure('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.' + stripeReason(start, 'login'));
98
106
  }
99
107
  log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
100
108
  open(login.browser_url);
101
109
  const finished = await cli(complete);
102
- if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.');
110
+ if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.' + stripeReason(finished, 'login completion'));
103
111
 
104
112
  probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
105
- if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return failure('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.');
113
+ if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return failure('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.' + stripeReason(probe, 'GET /v1/products'));
106
114
  }
107
115
  const site = siteUrl(root);
108
116
  if (!site) return failure('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
@@ -114,7 +122,9 @@ export async function shopConnect(root, options = {}) {
114
122
  for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
115
123
  const r = await cli(args);
116
124
  const body = json(r.stdout);
117
- if (r.code !== 0 || !body || body.error) throw new Error('Stripe API operation failed');
125
+ if (r.code !== 0 || !body || body.error) {
126
+ throw Object.assign(new Error('Stripe API operation failed'), { stripeReason: stripeReason(r, `${method} ${path.replace(/\/[^/]*_[A-Za-z0-9]+$/, '/…')}`) });
127
+ }
118
128
  return body;
119
129
  };
120
130
  const reason = fullerReason(checked.shop);
@@ -129,7 +139,8 @@ export async function shopConnect(root, options = {}) {
129
139
  renameSync(`${receiptFile}.tmp`, receiptFile);
130
140
  } });
131
141
  return { ...result, profile };
132
- } catch {
133
- return failure('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.');
142
+ } catch (e) {
143
+ const said = e?.stripeReason ?? '';
144
+ return failure('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.' + said);
134
145
  } finally { lock.release(); }
135
146
  }
@@ -1868,6 +1868,89 @@
1868
1868
  }
1869
1869
  },
1870
1870
  "0.35.0": {
1871
+ "files": {
1872
+ "CLAUDE.md": "ddf2b2a0376202f6",
1873
+ "README.md": "684b3cca8001673c",
1874
+ "HANDOFF.md": "29337e2ace95b2e7",
1875
+ "games/README.md": "5ad32fe80c61f117",
1876
+ "music/README.md": "a3dc042dadd39df8",
1877
+ "videos/README.md": "16babc3437d5545b",
1878
+ "posts/README.md": "37841aae476bd540",
1879
+ "site/README.md": "732894273a32dc3f",
1880
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1881
+ },
1882
+ "sections": {
1883
+ "AGENTS.md": {
1884
+ "(intro)": "214aa46eb3a895d5",
1885
+ "## Layout": "8baf49e4291e7e88",
1886
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1887
+ "## The site": "0cb8dd9a6f8a0096",
1888
+ "## Making games": "2da7fa74c8884834",
1889
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1890
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1891
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1892
+ "## Servers and AI seats": "98d08705087a33ec",
1893
+ "## Selling things (the shop)": [
1894
+ "57c04f6526e16890",
1895
+ "9e020d69dd6a954e"
1896
+ ],
1897
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1898
+ "## Rules": "2e6b29afb85dcf7e",
1899
+ "## Telling Homie": "80b46fdd9e578db8",
1900
+ "## Beta": "9d02638207a26e73"
1901
+ }
1902
+ },
1903
+ "named": {
1904
+ "Homie Arcade": {
1905
+ "sections": {
1906
+ "AGENTS.md": {
1907
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1908
+ }
1909
+ }
1910
+ }
1911
+ }
1912
+ },
1913
+ "0.35.1": {
1914
+ "files": {
1915
+ "CLAUDE.md": "ddf2b2a0376202f6",
1916
+ "README.md": "684b3cca8001673c",
1917
+ "HANDOFF.md": "29337e2ace95b2e7",
1918
+ "games/README.md": "5ad32fe80c61f117",
1919
+ "music/README.md": "a3dc042dadd39df8",
1920
+ "videos/README.md": "16babc3437d5545b",
1921
+ "posts/README.md": "37841aae476bd540",
1922
+ "site/README.md": "732894273a32dc3f",
1923
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1924
+ },
1925
+ "sections": {
1926
+ "AGENTS.md": {
1927
+ "(intro)": "214aa46eb3a895d5",
1928
+ "## Layout": "8baf49e4291e7e88",
1929
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1930
+ "## The site": "0cb8dd9a6f8a0096",
1931
+ "## Making games": "2da7fa74c8884834",
1932
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1933
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1934
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1935
+ "## Servers and AI seats": "98d08705087a33ec",
1936
+ "## Selling things (the shop)": "9e020d69dd6a954e",
1937
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1938
+ "## Rules": "2e6b29afb85dcf7e",
1939
+ "## Telling Homie": "80b46fdd9e578db8",
1940
+ "## Beta": "9d02638207a26e73"
1941
+ }
1942
+ },
1943
+ "named": {
1944
+ "Homie Arcade": {
1945
+ "sections": {
1946
+ "AGENTS.md": {
1947
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1948
+ }
1949
+ }
1950
+ }
1951
+ }
1952
+ },
1953
+ "0.36.0": {
1871
1954
  "files": {
1872
1955
  "CLAUDE.md": "ddf2b2a0376202f6",
1873
1956
  "README.md": "684b3cca8001673c",
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
+ }