@homie-rocks/studio 0.35.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
|
@@ -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,
|
package/lib/standalone-files.mjs
CHANGED
|
@@ -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
|
|
package/lib/standalone.mjs
CHANGED
|
@@ -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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
92
|
-
|
|
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
|
+
}
|
package/lib/style-board.mjs
CHANGED
|
@@ -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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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)":
|
|
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
|
}
|