@homie-rocks/studio 0.35.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +4 -2
- package/agents/agents.ts +8 -6
- package/app-starters/welcome/app.json +106 -0
- package/app-starters/welcome/index.html +4 -0
- package/app-starters/welcome/src/main.ts +107 -0
- package/apps/APPS.md +61 -0
- package/apps/apps.ts +28 -0
- package/bin/homie-studio.mjs +22 -10
- package/lab/lab.ts +15 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +6 -6
- package/lib/asset-check.mjs +3 -2
- package/lib/asset-import.mjs +3 -2
- package/lib/asset-manifest.mjs +8 -8
- package/lib/build.mjs +26 -18
- package/lib/characters.mjs +7 -7
- package/lib/check.mjs +49 -0
- package/lib/cloudflare.mjs +1 -1
- package/lib/codex.mjs +2 -2
- package/lib/collision.mjs +1 -1
- package/lib/decisions.mjs +7 -7
- package/lib/dev.mjs +42 -10
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +18 -3
- package/lib/lab.mjs +29 -15
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +23 -3
- package/lib/parts-build.mjs +4 -3
- package/lib/parts-cli.mjs +3 -3
- package/lib/parts-store.mjs +8 -7
- package/lib/parts-tools.mjs +7 -7
- package/lib/parts.mjs +19 -14
- package/lib/perf.mjs +2 -1
- package/lib/player-image.mjs +2 -2
- package/lib/rules-build.mjs +97 -49
- package/lib/rules-check-worker.mjs +405 -0
- package/lib/rules-check.mjs +69 -0
- package/lib/rules-compiler.mjs +30 -0
- package/lib/rules-guard.mjs +26 -7
- package/lib/rules-types.mjs +167 -0
- package/lib/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +12 -3
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +85 -1
- package/lib/typecheck.mjs +52 -3
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/netplay/NETPLAY.md +123 -6
- package/netplay/netplay.ts +289 -53
- package/netplay/rules-output.mjs +85 -0
- package/package.json +16 -4
- package/parts/PARTS.md +10 -5
- package/rules/browser.ts +100 -0
- package/rules/core.ts +159 -58
- package/rules/guard.ts +11 -4
- package/rules/host.ts +90 -15
- package/rules/math.ts +16 -0
- package/rules/pack.ts +94 -17
- package/rules/rules.ts +21 -13
- package/rules/types.ts +63 -0
- package/rules/view.ts +36 -33
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- package/standalone/shell/shell.js +8 -6
- package/worker/account-page.mjs +4 -3
- package/worker/app-format.mjs +42 -0
- package/worker/app-records.mjs +100 -0
- package/worker/app-shell.mjs +29 -0
- package/worker/brain.mjs +2 -1
- package/worker/embed.mjs +4 -2
- package/worker/hosted.mjs +1 -1
- package/worker/index.mjs +40 -20
- package/worker/limits.mjs +41 -0
- package/worker/pages.mjs +37 -29
- package/worker/parts.mjs +11 -9
- package/worker/room.mjs +136 -63
- package/worker/schema.mjs +6 -5
- package/worker/seats.mjs +2 -2
- package/worker/shop-page.mjs +4 -3
- package/worker/site.mjs +49 -40
- package/worker/standalone.mjs +2 -2
- package/worker/version.mjs +1 -1
package/lib/art-tools.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { animPlan } from './characters.mjs';
|
|
|
36
36
|
import { CATALOGUE_IDS, decisionRows, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
|
|
37
37
|
import { libraryBase, loadIndex, searchLibrary, itemThumb } from './library.mjs';
|
|
38
38
|
import { boardView } from './style-board.mjs';
|
|
39
|
-
import { listGames } from './studio.mjs';
|
|
39
|
+
import { experienceDir, experienceFile, listExperiences as listGames } from './studio.mjs';
|
|
40
40
|
|
|
41
41
|
export const ART_UI = Object.freeze({
|
|
42
42
|
style: 'ui://homie-studio/style',
|
|
@@ -55,7 +55,7 @@ function picture(h, file, max = 160 * 1024) {
|
|
|
55
55
|
|
|
56
56
|
function gameOf(root, id) {
|
|
57
57
|
const games = listGames(root);
|
|
58
|
-
if (id && (games.some((g) => g.id === id) || existsSync(join(root,
|
|
58
|
+
if (id && (games.some((g) => g.id === id) || existsSync(join(experienceDir(root, String(id)), 'CODEX.md')))) return String(id);
|
|
59
59
|
if (!id && games.length === 1) return games[0].id;
|
|
60
60
|
throw new Error(`which game? ${games.map((g) => g.id).join(', ') || 'there is none yet (game_plan or game_make first)'}`);
|
|
61
61
|
}
|
|
@@ -64,11 +64,11 @@ function gameOf(root, id) {
|
|
|
64
64
|
function styleData(h, root, game, extra = {}) {
|
|
65
65
|
const doc = readDecisions(root, game);
|
|
66
66
|
let title = null;
|
|
67
|
-
try { title = JSON.parse(readFileSync(
|
|
68
|
-
if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(root,
|
|
67
|
+
try { title = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).name ?? null; } catch { /* planned: its codex names it */ }
|
|
68
|
+
if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(experienceDir(root, game), 'CODEX.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''))?.[1]?.trim() ?? null; } catch { title = null; } }
|
|
69
69
|
title = title ?? game;
|
|
70
70
|
const manifest = readManifest(root, game);
|
|
71
|
-
const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(root,
|
|
71
|
+
const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(experienceDir(root, game), d.swatch) : null), mood: d.mood?.path ? { image: picture(h, join(experienceDir(root, game), d.mood.path)), usd: d.mood.usd ?? null, label: 'a painted target, not what the game draws' } : null })) : [];
|
|
72
72
|
const rows = doc ? decisionRows(doc) : [];
|
|
73
73
|
return {
|
|
74
74
|
kind: 'style', game, title, path: doc?.path ?? null, line: doc ? oneLine(doc) : null, budget: doc?.budget?.usd ?? null, chosen: doc?.board?.chosen ?? null,
|
|
@@ -98,7 +98,7 @@ async function characterThumb(h, root, game, row) {
|
|
|
98
98
|
try { const lib = await loadIndex({ lib: libraryBase() }); const it = lib.index.items.find((x) => x.id === m.from.item); const t = it ? await itemThumb(lib.lib, it) : null; if (t) return `data:image/webp;base64,${Buffer.from(t).toString('base64')}`; } catch { /* no library here */ }
|
|
99
99
|
}
|
|
100
100
|
const concept = (m?.files ?? []).find((f) => f.role === 'concept');
|
|
101
|
-
return concept ? picture(h, join(root,
|
|
101
|
+
return concept ? picture(h, join(experienceDir(root, game), concept.path), 60 * 1024) : null;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** Which Game Lab take tunes a verb (lab.json): one named for it or whose note says it, else the default. */
|
package/lib/asset-check.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio assets check <id>`: static, free, on every asset and on the whole game. What it adds up is an
|
|
3
4
|
* INVENTORY ESTIMATE: the recorded files' own triangles, draw calls and pictures times their declared placements. It
|
|
@@ -74,7 +75,7 @@ const tierOf = (a) => (a.kind === 'clip' ? 'clip' : BUDGETS[a.tier] ? a.tier : a
|
|
|
74
75
|
|
|
75
76
|
/** A game's game.json, or {} (a planned game has none yet). */
|
|
76
77
|
export function gameJson(root, id) {
|
|
77
|
-
try { return JSON.parse(readFileSync(
|
|
78
|
+
try { return JSON.parse(readFileSync(experienceFile(root, id), 'utf8')) ?? {}; } catch { return {}; }
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/*
|
|
@@ -238,7 +239,7 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
|
|
|
238
239
|
const doc = readDecisions(root, id);
|
|
239
240
|
const game = gameJson(root, id);
|
|
240
241
|
const budgets = budgetsFor(doc, game);
|
|
241
|
-
const gdir =
|
|
242
|
+
const gdir = experienceDir(root, id);
|
|
242
243
|
let tools = null;
|
|
243
244
|
try { tools = await import('./optimise.mjs'); } catch { tools = null; }
|
|
244
245
|
const rows = [];
|
package/lib/asset-import.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* A MODEL FILE INTO A GAME: the person's own (imported), or one a provider generated on their account (the models
|
|
3
4
|
* skill's image-to-3D). `homie-studio assets add <id> --file <model> --license <kind>`.
|
|
@@ -54,7 +55,7 @@ export function screenImport(path) {
|
|
|
54
55
|
* the concept and mesh calls with their receipts and prices), slug (the art job's folder).
|
|
55
56
|
*/
|
|
56
57
|
export async function importModel(root, id, file, opts = {}) {
|
|
57
|
-
const gdir =
|
|
58
|
+
const gdir = experienceDir(root, id);
|
|
58
59
|
if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${id}"`);
|
|
59
60
|
const path = resolve(root, file);
|
|
60
61
|
const route = opts.route === 'generated' ? 'generated' : 'imported';
|
|
@@ -117,7 +118,7 @@ export async function importModel(root, id, file, opts = {}) {
|
|
|
117
118
|
* mesh fix it); one stale only on its budgets is not.
|
|
118
119
|
*/
|
|
119
120
|
export async function redoModel(root, id, assetId) {
|
|
120
|
-
const gdir =
|
|
121
|
+
const gdir = experienceDir(root, id);
|
|
121
122
|
const e = readManifest(root, id).assets.find((a) => a.id === assetId);
|
|
122
123
|
if (!e) throw new Error(`no asset "${assetId}" in games/${id}/assets/manifest.json`);
|
|
123
124
|
const raw = (e.files ?? []).find((f) => f.role === 'raw');
|
package/lib/asset-manifest.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { createHash } from 'node:crypto';
|
|
|
22
22
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { dirname, join, relative } from 'node:path';
|
|
24
24
|
import { pinByUse, readDecisions, revisionsFor } from './decisions.mjs';
|
|
25
|
-
import { GAME_ID } from './studio.mjs';
|
|
25
|
+
import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
|
|
26
26
|
|
|
27
27
|
export const MANIFEST_FILE = join('assets', 'manifest.json');
|
|
28
28
|
export const RIGHTS_FILE = join('assets', 'RIGHTS.md');
|
|
@@ -81,7 +81,7 @@ export function underFor(kind, route) {
|
|
|
81
81
|
return [...(UNDER[kind] ?? UNDER.prop), ...(route === 'library' ? ['cast.family'] : []), ...(route === 'generated' ? ['derived.prompt'] : [])];
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
const fileOf = (root, id) => join(root,
|
|
84
|
+
const fileOf = (root, id) => join(experienceDir(root, id), MANIFEST_FILE);
|
|
85
85
|
const now = () => new Date().toISOString();
|
|
86
86
|
const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : null);
|
|
87
87
|
|
|
@@ -167,7 +167,7 @@ export function removeAsset(root, id, assetId) {
|
|
|
167
167
|
writeManifest(root, id, manifest);
|
|
168
168
|
writeRights(root, id, manifest);
|
|
169
169
|
syncCredits(root, id, manifest);
|
|
170
|
-
const gdir = join(root,
|
|
170
|
+
const gdir = join(experienceDir(root, id));
|
|
171
171
|
const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
|
|
172
172
|
const deleted = []; const kept = [];
|
|
173
173
|
for (const f of gone.files ?? []) {
|
|
@@ -216,7 +216,7 @@ export function licenceProblems(root, id, manifest = readManifest(root, id)) {
|
|
|
216
216
|
|
|
217
217
|
/** Model files a game ships (.glb, .gltf, .fbx, .obj, .vrm under games/<id>/) that no manifest entry names. */
|
|
218
218
|
export function unrecordedModels(root, id, manifest = readManifest(root, id)) {
|
|
219
|
-
const dir = join(root,
|
|
219
|
+
const dir = join(experienceDir(root, id));
|
|
220
220
|
const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
|
|
221
221
|
const out = [];
|
|
222
222
|
const walk = (rel, depth) => {
|
|
@@ -245,7 +245,7 @@ const PICTURE = /\.(png|jpe?g|webp|avif|gif|hdr|exr|ktx2)$/i;
|
|
|
245
245
|
* The codex (private, the style board's own swatches) is not walked at all.
|
|
246
246
|
*/
|
|
247
247
|
export function unrecordedPictures(root, id, manifest = readManifest(root, id), { cover = null } = {}) {
|
|
248
|
-
const dir = join(root,
|
|
248
|
+
const dir = join(experienceDir(root, id));
|
|
249
249
|
const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
|
|
250
250
|
const coverPaths = new Set(cover ? [String(cover).replace(/^\.\//, ''), `public/${String(cover).replace(/^\.\//, '')}`] : []);
|
|
251
251
|
const shipped = []; const notShipped = [];
|
|
@@ -296,7 +296,7 @@ function routeWords(a) {
|
|
|
296
296
|
/** RIGHTS.md for one game, in plain words. */
|
|
297
297
|
export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
|
|
298
298
|
let game = {};
|
|
299
|
-
try { game = JSON.parse(readFileSync(
|
|
299
|
+
try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
|
|
300
300
|
const lines = [
|
|
301
301
|
`# Rights: ${game.name ?? id}`,
|
|
302
302
|
'',
|
|
@@ -333,7 +333,7 @@ function termsApply(t, a) {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
export function writeRights(root, id, manifest = readManifest(root, id)) {
|
|
336
|
-
const file = join(root,
|
|
336
|
+
const file = join(experienceDir(root, id), RIGHTS_FILE);
|
|
337
337
|
mkdirSync(dirname(file), { recursive: true });
|
|
338
338
|
const text = rightsMarkdown(root, id, manifest);
|
|
339
339
|
if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
|
|
@@ -347,7 +347,7 @@ export function writeRights(root, id, manifest = readManifest(root, id)) {
|
|
|
347
347
|
* and one per attribution-required file. Lines this function wrote carry `"asset": true`; every other part stays.
|
|
348
348
|
*/
|
|
349
349
|
export function syncCredits(root, id, manifest = readManifest(root, id)) {
|
|
350
|
-
const file = join(root,
|
|
350
|
+
const file = join(experienceDir(root, id), 'credits.json');
|
|
351
351
|
let credits = {};
|
|
352
352
|
try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
|
|
353
353
|
const before = Array.isArray(credits.parts) ? credits.parts : [];
|
package/lib/build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appProblems } from '../worker/app-format.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio build` — every game in games/ bundled into site/dist, plus the
|
|
3
4
|
* catalogue the Worker serves (games.json).
|
|
@@ -55,7 +56,7 @@ import { buildMedia } from './media.mjs';
|
|
|
55
56
|
import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
|
|
56
57
|
import { checkJsonLd } from './schema-check.mjs';
|
|
57
58
|
import { SCHEMA_REFUSED } from '../worker/schema.mjs';
|
|
58
|
-
import { PACKAGE_ROOT, isRulesGame, listGames, readStudio } from './studio.mjs';
|
|
59
|
+
import { PACKAGE_ROOT, isRulesGame, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
59
60
|
|
|
60
61
|
// The rules build brings a JavaScript parser with it. It is loaded when a build first needs it, never when this
|
|
61
62
|
// module is: Homie for Claude Desktop starts the toolkit with no node_modules beside it (scripts/desktop.mjs).
|
|
@@ -75,10 +76,10 @@ import { audienceOf } from '../worker/shop-rules.mjs';
|
|
|
75
76
|
import { loungeConfig, loungeProblems } from '../worker/lounge-store.mjs';
|
|
76
77
|
// GAME PARTS (parts/PARTS.md): the three call-outs below are all the build knows of them.
|
|
77
78
|
import { buildParts, partsPlugin } from './parts-build.mjs';
|
|
78
|
-
// RULES ON THE SERVER (NETPLAY.md section 29): a game
|
|
79
|
+
// RULES ON THE SERVER (NETPLAY.md section 29): a game declaring a room object and src/rules.ts builds as a view bundle plus a rules module.
|
|
79
80
|
|
|
80
81
|
/** Never copied into a static game's served folder. */
|
|
81
|
-
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
82
|
+
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'app.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
|
|
82
83
|
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
83
84
|
|
|
84
85
|
/** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
|
|
@@ -269,7 +270,7 @@ export function netplayOf(g, out = null) {
|
|
|
269
270
|
*/
|
|
270
271
|
export function seatsFor(g, net = netplayOf(g)) {
|
|
271
272
|
const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
|
|
272
|
-
const asked = named ?? 8;
|
|
273
|
+
const asked = named ?? (g.kind === 'app' ? 32 : 8);
|
|
273
274
|
const max = Math.min(SEAT_MAX, asked);
|
|
274
275
|
const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
|
|
275
276
|
return { min: Math.min(max, minNamed ?? 1), max, asked };
|
|
@@ -376,7 +377,7 @@ function pointAtBundle(html, bundle) {
|
|
|
376
377
|
* game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
|
|
377
378
|
* one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
|
|
378
379
|
*/
|
|
379
|
-
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false } = {}) {
|
|
380
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false } = {}) {
|
|
380
381
|
rmSync(out, { recursive: true, force: true });
|
|
381
382
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
382
383
|
const mode = g.build?.mode ?? 'bundle';
|
|
@@ -394,7 +395,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
394
395
|
}
|
|
395
396
|
if (ruled && mode !== 'bundle') throw new Error(`games/${g.id} has a src/rules.ts and game.json "build": { "mode": "${mode}" }. A game written as rules plus view is built by Homie itself: take the "build" setting out.`);
|
|
396
397
|
const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
|
|
397
|
-
const rules = ruled ? await prepareRules(esbuild, root, g, { log }) : null;
|
|
398
|
+
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
|
|
398
399
|
const bundle = async (entryRel) => {
|
|
399
400
|
const entry = join(g.dir, entryRel);
|
|
400
401
|
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
@@ -406,7 +407,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
406
407
|
// game.json assets.budgets, read by createModels() (assets/assets.ts): a constant in the bundle, never a fetch.
|
|
407
408
|
define: { __HOMIE_MODEL_BUDGETS__: JSON.stringify(budgets ?? {}) },
|
|
408
409
|
// GAME PARTS: `@parts/<host>/<id>` and `@parts/<id>` resolve to the part's entry, and the game is credited.
|
|
409
|
-
plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry)] : [])],
|
|
410
|
+
plugins: [partsPlugin(root, g.id), ...(rules ? [viewPlugin(g, rules, entry, { lab })] : [])],
|
|
410
411
|
};
|
|
411
412
|
const failed = (error) => {
|
|
412
413
|
const first = error.errors?.[0];
|
|
@@ -416,7 +417,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
416
417
|
warnings += result.warnings.length;
|
|
417
418
|
metafile = result.metafile;
|
|
418
419
|
const outputs = Object.entries(result.metafile.outputs).filter(([k]) => k.endsWith('.js'));
|
|
419
|
-
const main = outputs.find(([, v]) => v.entryPoint)?.[0];
|
|
420
|
+
const main = outputs.find(([, v]) => rules ? v.entryPoint === 'homie-view:homie:view' : Boolean(v.entryPoint))?.[0];
|
|
420
421
|
if (!main) throw new Error(`games/${g.id} did not build: esbuild wrote no bundle for ${entryRel}`);
|
|
421
422
|
bundled = `assets/${basename(main)}`;
|
|
422
423
|
chunks = outputs.filter(([k]) => k !== main).map(([k]) => `assets/${basename(k)}`).sort();
|
|
@@ -466,6 +467,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
466
467
|
for (const file of [bundled, ...chunks].filter(Boolean)) digest.update(readFileSync(join(out, file)));
|
|
467
468
|
digest.update(readFileSync(join(out, 'index.html')));
|
|
468
469
|
rules.build = digest.digest('hex').slice(0, 32);
|
|
470
|
+
writeFileSync(join(out, 'rules.json'), `${JSON.stringify({ v: 1, host: rules.settings.host, offline: rules.settings.offline, build: rules.build, files: [bundled, ...chunks] })}\n`);
|
|
469
471
|
if (g.netplay?.version !== undefined) log(` ${g.id}: rules games use their build hash as the revision; netplay.version is not read`);
|
|
470
472
|
}
|
|
471
473
|
return { mode, warnings, metafile, bundle: bundled, chunks, rules };
|
|
@@ -486,7 +488,7 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
486
488
|
return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
|
|
487
489
|
}
|
|
488
490
|
|
|
489
|
-
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
|
|
491
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
|
|
490
492
|
const esbuild = await studioEsbuild(root);
|
|
491
493
|
const studio = readStudio(root);
|
|
492
494
|
// The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
|
|
@@ -494,6 +496,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
494
496
|
const live = join(root, 'site', 'dist');
|
|
495
497
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
496
498
|
if (only && !games.length) throw new Error(`no game "${only}" in games/`);
|
|
499
|
+
for (const g of games) if (g.kind === 'app') { const bad = appProblems(g); if (bad.length) throw new Error(`apps/${g.id}/app.json: ${bad.join('; ')}`); }
|
|
497
500
|
// `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
|
|
498
501
|
// error stops the build before anything is built.
|
|
499
502
|
const typed = types ? typecheck(root, games, { log }) : null;
|
|
@@ -504,13 +507,13 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
504
507
|
// from the site as it is.
|
|
505
508
|
const dist = openStage(root, { from: only ? live : null });
|
|
506
509
|
try {
|
|
507
|
-
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps });
|
|
510
|
+
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck });
|
|
508
511
|
} finally {
|
|
509
512
|
rmSync(dist, { recursive: true, force: true });
|
|
510
513
|
}
|
|
511
514
|
}
|
|
512
515
|
|
|
513
|
-
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps }) {
|
|
516
|
+
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck }) {
|
|
514
517
|
const built = [];
|
|
515
518
|
const retired = [];
|
|
516
519
|
const cache = {};
|
|
@@ -519,7 +522,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
519
522
|
for (const g of games) {
|
|
520
523
|
const out = join(dist, 'games', g.id);
|
|
521
524
|
const started = Date.now();
|
|
522
|
-
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true });
|
|
525
|
+
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck });
|
|
523
526
|
if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
|
|
524
527
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
525
528
|
vocabFor(g, out);
|
|
@@ -542,11 +545,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
542
545
|
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
543
546
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
544
547
|
});
|
|
545
|
-
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} ${chunks.length === 1 ? 'chunk' : 'chunks'}
|
|
546
|
-
if (rules
|
|
548
|
+
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
549
|
+
if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this game's rules and tunables are sent to players' devices so it can be played offline`);
|
|
550
|
+
if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
|
|
551
|
+
if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
|
|
552
|
+
if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
|
|
547
553
|
}
|
|
548
554
|
// A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
|
|
549
|
-
if (browserHosted.length) log(`hosted by a player's browser, as before (no
|
|
555
|
+
if (browserHosted.length) log(`hosted by a player's browser, as before (no room object; nothing to do): ${browserHosted.join(', ')}`);
|
|
550
556
|
const all = listGames(root);
|
|
551
557
|
// No game is handed over whole any more (remix was retired; worker/license.mjs). A one-game build starts from the
|
|
552
558
|
// site as it is, which an older toolkit may have built: what that wrote for remixers (a game's whole source, and
|
|
@@ -571,8 +577,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
571
577
|
// studio.json site.order: the catalogue's order is every listing's order.
|
|
572
578
|
const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
|
|
573
579
|
// Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
|
|
574
|
-
const rulesOf = new Map(ruled.map((r) => [r.id, { host:
|
|
575
|
-
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.
|
|
580
|
+
const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds }]));
|
|
581
|
+
if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
|
|
576
582
|
// What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
|
|
577
583
|
const builds = {};
|
|
578
584
|
const rows = shown.map((g) => {
|
|
@@ -597,6 +603,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
597
603
|
const bundle = bundleOf(dir);
|
|
598
604
|
builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
|
|
599
605
|
return {
|
|
606
|
+
...(g.kind === 'app' ? { kind: 'app', roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
|
|
600
607
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
601
608
|
...(ui ? { ui } : {}),
|
|
602
609
|
// A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
|
|
@@ -714,7 +721,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
714
721
|
if (deploy) for (const b of built) log(` ${b.id}: ${b.changed}${b.changed === 'new' ? '' : ' since the last build here'}, build ${b.hash}`);
|
|
715
722
|
// The rules of the server-hosted games, where the studio's Worker imports them (site/src/rules/). Written with the
|
|
716
723
|
// site, never before: a game that did not build has left both as they were.
|
|
717
|
-
|
|
724
|
+
await beforePublish();
|
|
725
|
+
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
718
726
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
719
727
|
const swapped = swapIn(dist, live);
|
|
720
728
|
return {
|
package/lib/characters.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { readManifest, recordAsset, writeManifest } from './asset-manifest.mjs';
|
|
|
28
28
|
import { VERBS, VERB_IDS, bakeLibrary, findClip, gaitOf, rigOf, writeLibrary } from './clips.mjs';
|
|
29
29
|
import { readDecisions } from './decisions.mjs';
|
|
30
30
|
import { FAMILIES, normaliseRig, skeletonOf, socketBone } from './rig.mjs';
|
|
31
|
-
import { GAME_ID } from './studio.mjs';
|
|
31
|
+
import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
|
|
32
32
|
|
|
33
33
|
/** Where humanoid clips come from when a character has none of its own: KayKit's adventurers (CC0, 76 clips). */
|
|
34
34
|
export const CLIP_SOURCES = Object.freeze({
|
|
@@ -146,7 +146,7 @@ function appendClips(into, from) {
|
|
|
146
146
|
* `own`: its own clips (a clip source) or null. Returns { path, skeleton, verbs, clips, missing, bytes, entry }.
|
|
147
147
|
*/
|
|
148
148
|
async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, cache = new Map(), license = null } = {}) {
|
|
149
|
-
const gdir = join(root,
|
|
149
|
+
const gdir = join(experienceDir(root, game));
|
|
150
150
|
const skel = rig.skeleton;
|
|
151
151
|
const fallback = from ?? CLIP_SOURCES[skel.family] ?? null;
|
|
152
152
|
const existing = await existingLibrary(gdir, skel.id);
|
|
@@ -206,7 +206,7 @@ async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, c
|
|
|
206
206
|
*/
|
|
207
207
|
export async function addCharacter(root, game, opts = {}) {
|
|
208
208
|
if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
|
|
209
|
-
const gdir = join(root,
|
|
209
|
+
const gdir = join(experienceDir(root, game));
|
|
210
210
|
if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${game}"`);
|
|
211
211
|
const t = await tools();
|
|
212
212
|
const { optimiseModel, measureDoc } = await import('./optimise.mjs');
|
|
@@ -297,7 +297,7 @@ export async function bakeClips(root, game, asset, { verbs = [], from = null, li
|
|
|
297
297
|
if (!a?.rig) throw new Error(`${asset} is not a rigged character in ${game} (assets add brings one in)`);
|
|
298
298
|
const t = await tools();
|
|
299
299
|
const model = a.files.find((f) => f.role === 'model');
|
|
300
|
-
const doc = await t.io.readBinary(readFileSync(join(root,
|
|
300
|
+
const doc = await t.io.readBinary(readFileSync(join(experienceDir(root, game), model.path)));
|
|
301
301
|
doc.setLogger(t.logger);
|
|
302
302
|
const rig = rigOf(doc);
|
|
303
303
|
if (rig.skeleton.id !== a.rig.skeleton) rig.skeleton.id = a.rig.skeleton;
|
|
@@ -368,7 +368,7 @@ export async function measureGait(root, game, assetId) {
|
|
|
368
368
|
const a = m.assets.find((x) => x.id === assetId);
|
|
369
369
|
const model = (a?.files ?? []).find((f) => f.role === 'model');
|
|
370
370
|
if (!a?.rig || !model) return null;
|
|
371
|
-
const gdir = join(root,
|
|
371
|
+
const gdir = join(experienceDir(root, game));
|
|
372
372
|
const t = await tools();
|
|
373
373
|
const read = async (rel) => { const d = await t.io.readBinary(readFileSync(join(gdir, rel))); d.setLogger(t.logger); return d; };
|
|
374
374
|
const rig = rigOf(await read(model.path));
|
|
@@ -420,7 +420,7 @@ export function animPlan(root, game) {
|
|
|
420
420
|
return {
|
|
421
421
|
ok: true, command: 'anim plan', game, verbs: need, rows, unrigged,
|
|
422
422
|
decisions: Object.fromEntries(['anim.style', 'anim.clips', 'anim.source', 'anim.motion', 'anim.blend', 'anim.procedural', 'rig.skeleton', 'rig.source', 'rig.bones', 'game.feel'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state } : null])),
|
|
423
|
-
lab: existsSync(join(root,
|
|
423
|
+
lab: existsSync(join(experienceDir(root, game), 'lab.json')) ? JSON.parse(readFileSync(join(experienceDir(root, game), 'lab.json'), 'utf8')) : null,
|
|
424
424
|
};
|
|
425
425
|
}
|
|
426
426
|
|
|
@@ -434,7 +434,7 @@ export async function animPreview(root, game, { asset = null, verbs = null, size
|
|
|
434
434
|
const rows = plan.rows.filter((r) => !asset || r.id === asset);
|
|
435
435
|
if (!rows.length) return { ok: false, command: 'anim preview', game, why: asset ? `${asset} is not a rigged character in ${game}` : `${game} has no rigged characters yet (assets add <game> <character item>)` };
|
|
436
436
|
const m = readManifest(root, game);
|
|
437
|
-
const gdir = join(root,
|
|
437
|
+
const gdir = join(experienceDir(root, game));
|
|
438
438
|
let tokens = null;
|
|
439
439
|
try { tokens = JSON.parse(readFileSync(join(gdir, 'style.json'), 'utf8')); } catch { tokens = null; }
|
|
440
440
|
tokens = tokens?.palette ? tokens : { palette: { bg: '#e9eef2', ink: '#1d2b3a', accent: '#ff8a5b', accent2: '#5cbf8a', danger: '#e2553f', good: '#45b36b', gold: '#ffd25a' } };
|
package/lib/check.mjs
CHANGED
|
@@ -141,6 +141,9 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
141
141
|
// it either, that refusal (for a lookup: BLOCKED, this computer's network, local testing offered) is the report.
|
|
142
142
|
const cannotAsk = siteRefusal(await reachSite(base, { path: `/${game}/play`, timeout: 15_000 }), { command: 'check', play });
|
|
143
143
|
if (cannotAsk?.preflight === 'local') return cannotAsk;
|
|
144
|
+
let appMeta = null;
|
|
145
|
+
try { appMeta = (await (await fetch(`${base}/api/games`)).json()).games?.find((g) => g.id === game && g.kind === 'app'); } catch { /* browser preflight below still names failures */ }
|
|
146
|
+
if (appMeta) return checkApp({ url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
144
147
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
145
148
|
const profiles = [];
|
|
146
149
|
const browsers = [];
|
|
@@ -333,3 +336,49 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
333
336
|
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|
|
334
337
|
}
|
|
335
338
|
}
|
|
339
|
+
|
|
340
|
+
/** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
|
|
341
|
+
export async function checkApp({ url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
342
|
+
const proof = meta.check;
|
|
343
|
+
if (!proof || typeof proof.action !== 'string' || typeof proof.observe !== 'string') return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
344
|
+
if (shots) mkdirSync(shots, { recursive: true });
|
|
345
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
346
|
+
let browser;
|
|
347
|
+
const surfaces = [];
|
|
348
|
+
const room = `check-${Date.now().toString(36)}`;
|
|
349
|
+
try {
|
|
350
|
+
browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
351
|
+
for (const [name, width, height, path] of [['wall', 1280, 720, 'tv'], ['phone', 390, 844, 'open'], ['phone-two', 390, 844, 'open']]) {
|
|
352
|
+
const context = await browser.createBrowserContext();
|
|
353
|
+
const page = await context.newPage();
|
|
354
|
+
await page.setViewport({ width, height, isMobile: name !== 'wall', hasTouch: name !== 'wall' });
|
|
355
|
+
const params = new URLSearchParams(proof.params ?? {}); params.set('room', room);
|
|
356
|
+
await page.goto(`${url}/${game}/${path}?${params}`, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
|
357
|
+
await page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
|
|
358
|
+
const frame = page.frames().find((f) => /\/__game\//.test(f.url()));
|
|
359
|
+
if (!frame) throw new Error(`${name} never loaded the app`);
|
|
360
|
+
await frame.waitForSelector(proof.observe, { timeout: 30_000 });
|
|
361
|
+
surfaces.push({ name, page, frame });
|
|
362
|
+
log(`${name} connected to ${room}`);
|
|
363
|
+
}
|
|
364
|
+
const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
|
|
365
|
+
const actor = surfaces[1];
|
|
366
|
+
await actor.frame.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action);
|
|
367
|
+
const before = await textOf(actor.frame);
|
|
368
|
+
await actor.frame.click(proof.action);
|
|
369
|
+
await actor.frame.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before);
|
|
370
|
+
const after = await textOf(actor.frame);
|
|
371
|
+
for (const s of surfaces) await s.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
|
|
372
|
+
// The actor reloads with its ticket and room. The other screens remain live.
|
|
373
|
+
await actor.page.reload({ waitUntil: 'domcontentloaded' });
|
|
374
|
+
await actor.page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
|
|
375
|
+
actor.frame = actor.page.frames().find((f) => /\/__game\//.test(f.url()));
|
|
376
|
+
await actor.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
|
|
377
|
+
const rooms = await Promise.all(surfaces.map((s) => s.page.evaluate(() => window.__shell.room)));
|
|
378
|
+
if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
|
|
379
|
+
if (shots) for (const s of surfaces) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 65 });
|
|
380
|
+
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
381
|
+
return { ok: true, command: 'check', kind: 'app', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
|
|
382
|
+
} catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
383
|
+
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
384
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { whyFailed } from './net.mjs';
|
|
|
40
40
|
import { repoOf } from './repo.mjs';
|
|
41
41
|
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
42
42
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
43
|
-
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
43
|
+
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
44
44
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
45
45
|
|
|
46
46
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
package/lib/codex.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import { extname, join, relative, resolve, sep } from 'node:path';
|
|
|
41
41
|
import { latestFeedFor, summarize } from './feed-summary.mjs';
|
|
42
42
|
import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
|
|
43
43
|
import { readTheme } from './site.mjs';
|
|
44
|
-
import { GAME_ID, listGames, readStudio } from './studio.mjs';
|
|
44
|
+
import { experienceDir, experienceFile, GAME_ID, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
45
45
|
import { decisionRows, readDecisions } from './decisions.mjs';
|
|
46
46
|
|
|
47
47
|
export const CODEX_FILE = 'CODEX.md';
|
|
@@ -514,7 +514,7 @@ function codexGame(root, id, { create = false } = {}) {
|
|
|
514
514
|
const game = listGames(root).find((g) => g.id === id);
|
|
515
515
|
if (game) return game;
|
|
516
516
|
if (!GAME_ID.test(String(id ?? ''))) return null;
|
|
517
|
-
const dir = join(root,
|
|
517
|
+
const dir = join(experienceDir(root, id));
|
|
518
518
|
if (existsSync(join(dir, 'game.json'))) return null;
|
|
519
519
|
return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
|
|
520
520
|
}
|
package/lib/collision.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { createRequire } from 'node:module';
|
|
|
32
32
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
33
33
|
import { pathToFileURL } from 'node:url';
|
|
34
34
|
import { modelTools } from './optimise.mjs';
|
|
35
|
-
import { listGames } from './studio.mjs';
|
|
35
|
+
import { listExperiences as listGames } from './studio.mjs';
|
|
36
36
|
|
|
37
37
|
/** The body that must fit when a file names none: a person, in metres. */
|
|
38
38
|
export const DEFAULT_MOVER = Object.freeze({ radius: 0.35, height: 1.8, step: 0.3, maxDrop: 0.5, arrival: 1, speed: 4 });
|
package/lib/decisions.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { dirname, join } from 'node:path';
|
|
|
20
20
|
import { HEX, deltaHex, hexToRgb, luminance, mix, nudge } from './colour.mjs';
|
|
21
21
|
import { CAMERAS, FAMILIES, FONT_SETS, GENRES, LIGHTS, MOODS, PALETTES, PALETTE_KEYS, RENDERS, SHAPES } from './style-presets.mjs';
|
|
22
22
|
import { readTheme } from './site.mjs';
|
|
23
|
-
import { GAME_ID, listGames } from './studio.mjs';
|
|
23
|
+
import { experienceDir, experienceFile, GAME_ID, listExperiences as listGames } from './studio.mjs';
|
|
24
24
|
|
|
25
25
|
export const DECISIONS_FILE = join('codex', 'decisions.json');
|
|
26
26
|
export const STATES = Object.freeze(['auto', 'steered', 'pinned', 'locked']);
|
|
@@ -35,7 +35,7 @@ const clean = (s, n = 200) => String(s ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').
|
|
|
35
35
|
|
|
36
36
|
/** What the automatic picks read: the person's words, the codex (its concept, art text and cards), the game. */
|
|
37
37
|
export function contextOf(root, id, { prompt = '' } = {}) {
|
|
38
|
-
const dir = join(root,
|
|
38
|
+
const dir = join(experienceDir(root, id));
|
|
39
39
|
let codex = '';
|
|
40
40
|
try { codex = readFileSync(join(dir, 'CODEX.md'), 'utf8').replace(/<!--[\s\S]*?-->/g, ''); } catch { codex = ''; }
|
|
41
41
|
let game = {};
|
|
@@ -282,12 +282,12 @@ function castFrom(ctx) {
|
|
|
282
282
|
|
|
283
283
|
/* ------------------------------------------------------------------ the file */
|
|
284
284
|
|
|
285
|
-
const fileOf = (root, id) => join(root,
|
|
285
|
+
const fileOf = (root, id) => join(experienceDir(root, id), DECISIONS_FILE);
|
|
286
286
|
|
|
287
287
|
function gameDirOf(root, id) {
|
|
288
288
|
if (!GAME_ID.test(String(id ?? ''))) throw new Error(`"${id}" is not a game id`);
|
|
289
|
-
const dir = join(root,
|
|
290
|
-
if (!existsSync(
|
|
289
|
+
const dir = join(experienceDir(root, id));
|
|
290
|
+
if (!existsSync(experienceFile(root, id)) && !existsSync(join(dir, 'CODEX.md'))) throw new Error(`no game "${id}" (no games/${id}/game.json or CODEX.md)${listGames(root).length ? `; games: ${listGames(root).map((g) => g.id).join(', ')}` : ''}`);
|
|
291
291
|
return dir;
|
|
292
292
|
}
|
|
293
293
|
|
|
@@ -332,7 +332,7 @@ export function styleTokens(doc) {
|
|
|
332
332
|
function writeStyleTokens(root, id, doc) {
|
|
333
333
|
const t = styleTokens(doc);
|
|
334
334
|
if (!t) return;
|
|
335
|
-
const file = join(root,
|
|
335
|
+
const file = join(experienceDir(root, id), 'style.json');
|
|
336
336
|
const text = `${JSON.stringify(t, null, 2)}\n`;
|
|
337
337
|
if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
|
|
338
338
|
}
|
|
@@ -790,7 +790,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
|
|
|
790
790
|
|
|
791
791
|
/** One dated line under the codex's Latest, newest first ("- 2026-10-02: …"). */
|
|
792
792
|
export function addLatestLine(root, id, text) {
|
|
793
|
-
const file = join(root,
|
|
793
|
+
const file = join(experienceDir(root, id), 'CODEX.md');
|
|
794
794
|
if (!existsSync(file)) return false;
|
|
795
795
|
const src = readFileSync(file, 'utf8');
|
|
796
796
|
const line = `- ${now().slice(0, 10)}: ${clean(text, 300)}`;
|