@homie-rocks/studio 0.48.4 → 0.50.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 +46 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +20 -16
- package/functions/FUNCTIONS.md +1 -1
- package/guides/app/SKILL.md +178 -0
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/shop/SKILL.md +153 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +5 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +18 -5
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +19 -17
- package/lib/mcp.mjs +1 -1
- package/lib/parts.mjs +6 -6
- package/lib/scaffold.mjs +45 -11
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +7 -6
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/functions-schema.mjs +15 -0
- package/worker/index.mjs +2 -1
- package/worker/parts.mjs +4 -4
- package/worker/shop-page.mjs +22 -16
- package/worker/shop-rules.mjs +4 -3
- package/worker/shop.mjs +6 -2
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
package/lib/build.mjs
CHANGED
|
@@ -487,7 +487,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
487
487
|
digest.update(readFileSync(join(out, 'index.html')));
|
|
488
488
|
rules.build = digest.digest('hex').slice(0, 32);
|
|
489
489
|
writeFileSync(join(out, 'rules.json'), `${JSON.stringify({ v: 1, host: rules.settings.host, offline: rules.settings.offline, build: rules.build, files: [bundled, ...chunks] })}\n`);
|
|
490
|
-
if (g.netplay?.version !== undefined) log(` ${g.id}: rules games use their build hash as the revision; netplay.version is not read`);
|
|
490
|
+
if (g.netplay?.version !== undefined) log(` ${g.id}: rules ${g.kind === 'app' ? 'apps' : 'games'} use their build hash as the revision; netplay.version is not read`);
|
|
491
491
|
}
|
|
492
492
|
return { mode, warnings, metafile, bundle: bundled, chunks, rules };
|
|
493
493
|
}
|
|
@@ -572,7 +572,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
572
572
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
573
573
|
});
|
|
574
574
|
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` : ''})`);
|
|
575
|
-
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`);
|
|
575
|
+
if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this ${g.kind === 'app' ? 'app' : 'game'}'s rules and tunables are sent to players' devices so it can be played offline`);
|
|
576
576
|
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)`);
|
|
577
577
|
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.`);
|
|
578
578
|
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)`);
|
|
@@ -688,7 +688,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
688
688
|
const lounge = loungeConfig(studio, rows, { audience: audienceOf(studio) });
|
|
689
689
|
const catalogue = {
|
|
690
690
|
studio: {
|
|
691
|
-
name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
|
|
691
|
+
name: studio.name, slug: studio.slug, version: STUDIO_VERSION, ...(studio.kind ? { kind: studio.kind } : {}),
|
|
692
692
|
...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
|
|
693
693
|
theme,
|
|
694
694
|
// studio.json "site": { "schema": { … } }: the owner's own schema.org properties on the studio (sameAs, …).
|
|
@@ -727,6 +727,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
727
727
|
// shop.json, checked (nothing secret is ever in it): the Worker sells from this and checks it again.
|
|
728
728
|
...(shop ? { shop } : {}),
|
|
729
729
|
};
|
|
730
|
+
for (const app of rows.filter(g => g.kind === 'app' && site.pages.includes(`/${g.id}/`))) log(`site/pages/${app.id}/ replaces the app landing; open /${app.id}/open and wall /${app.id}/tv`);
|
|
730
731
|
for (const game of rows) {
|
|
731
732
|
const image = await playerImage(catalogue, game, dist, log);
|
|
732
733
|
if (image) game.playerImage = image;
|
|
@@ -760,6 +761,6 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
760
761
|
...(retired.length ? { retired: `Remix was retired, and games now build on each other through parts (pieces a studio shares; ask for a part, or see parts/PARTS.md). These settings no longer do anything and can be deleted: ${retired.join('; ')}. No game's source is served whole; a game made from another keeps its credit.` } : {}),
|
|
761
762
|
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
|
|
762
763
|
posts: posts.map((p) => p.slug), postsSkipped, codexes, parts: partsBuilt, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
763
|
-
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length) })),
|
|
764
|
+
landings: rows.map((g) => ({ id: g.id, ...(g.kind === 'app' ? { kind: 'app' } : {}), hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length) })),
|
|
764
765
|
};
|
|
765
766
|
}
|
package/lib/check-site.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { once } from 'node:events';
|
|
|
16
16
|
import { createServer } from 'node:net';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { runningDev } from './dev.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { listExperiences, siteUrl } from './studio.mjs';
|
|
20
20
|
|
|
21
21
|
const CLI = fileURLToPath(new URL('../bin/homie-studio.mjs', import.meta.url));
|
|
22
22
|
|
|
@@ -64,16 +64,16 @@ export async function checkSite(root, { url = null, live = false, log = () => {}
|
|
|
64
64
|
export function changedGames(root, { run = spawnSync } = {}) {
|
|
65
65
|
const git = (...args) => { const r = run('git', args, { cwd: root, encoding: 'utf8' }); return r.status === 0 ? String(r.stdout) : null; };
|
|
66
66
|
if (git('rev-parse', '--is-inside-work-tree') === null) return [];
|
|
67
|
-
const files = new Set((git('status', '--porcelain', '--', 'games') ?? '').split('\n').map((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()).filter(Boolean));
|
|
67
|
+
const files = new Set((git('status', '--porcelain', '--', 'games', 'apps') ?? '').split('\n').map((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()).filter(Boolean));
|
|
68
68
|
const head = (git('symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD') ?? '').trim();
|
|
69
69
|
for (const base of [head, 'origin/main', 'main', 'origin/master', 'master'].filter(Boolean)) {
|
|
70
70
|
const fork = (git('merge-base', 'HEAD', base) ?? '').trim();
|
|
71
71
|
if (!fork) continue;
|
|
72
|
-
for (const f of (git('diff', '--name-only', `${fork}..HEAD`, '--', 'games') ?? '').split('\n')) if (f) files.add(f);
|
|
72
|
+
for (const f of (git('diff', '--name-only', `${fork}..HEAD`, '--', 'games', 'apps') ?? '').split('\n')) if (f) files.add(f);
|
|
73
73
|
break;
|
|
74
74
|
}
|
|
75
|
-
const touched = new Set([...files].map((f) => /^games\/([^/]+)\//.exec(f)?.[1]).filter(Boolean));
|
|
76
|
-
return
|
|
75
|
+
const touched = new Set([...files].map((f) => /^(?:games|apps)\/([^/]+)\//.exec(f)?.[1]).filter(Boolean));
|
|
76
|
+
return listExperiences(root).map((g) => g.id).filter((id) => touched.has(id));
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** The games a check covers: the one named, else what changed, else all of them. */
|
|
@@ -81,5 +81,5 @@ export function gamesToCheck(root, named, options) {
|
|
|
81
81
|
if (named) return { games: [named], why: 'named' };
|
|
82
82
|
const changed = changedGames(root, options);
|
|
83
83
|
if (changed.length) return { games: changed, why: 'changed in this checkout' };
|
|
84
|
-
return { games:
|
|
84
|
+
return { games: listExperiences(root).map((g) => g.id), why: 'nothing changed against the default branch: every game' };
|
|
85
85
|
}
|
package/lib/check.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
|
|
3
4
|
* when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
|
|
@@ -127,7 +128,7 @@ export function judgeRounds(browsers, missed = new Map()) {
|
|
|
127
128
|
return null;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
131
|
+
export async function check({ root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
131
132
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
132
133
|
const base = String(url).replace(/\/+$/, '');
|
|
133
134
|
const play = `${base}/${game}/play`;
|
|
@@ -148,7 +149,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
148
149
|
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
149
150
|
let puppeteer;
|
|
150
151
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
151
|
-
if (appMeta) return checkApp({ url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
152
|
+
if (appMeta) return checkApp({ root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
152
153
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
153
154
|
const profiles = [];
|
|
154
155
|
const browsers = [];
|
|
@@ -343,7 +344,9 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
343
344
|
}
|
|
344
345
|
|
|
345
346
|
/** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
|
|
346
|
-
export async function checkApp({ url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
347
|
+
export async function checkApp({ root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
348
|
+
let plan;
|
|
349
|
+
try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
347
350
|
const proof = meta.check;
|
|
348
351
|
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' };
|
|
349
352
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
@@ -381,9 +384,19 @@ export async function checkApp({ url, game, meta, shots = null, log = () => {},
|
|
|
381
384
|
await actor.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
|
|
382
385
|
const rooms = await Promise.all(surfaces.map((s) => s.page.evaluate(() => window.__shell.room)));
|
|
383
386
|
if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
|
|
384
|
-
|
|
387
|
+
for (const s of surfaces) {
|
|
388
|
+
await s.page.waitForFunction(() => {
|
|
389
|
+
const cover = document.querySelector('[data-arrive]');
|
|
390
|
+
return window.__shell?.arrival?.phase === 'done' && (!cover || (getComputedStyle(cover).opacity === '0' && getComputedStyle(cover).visibility === 'hidden'));
|
|
391
|
+
}, { timeout: READY_TIMEOUT_MS });
|
|
392
|
+
await s.frame.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
393
|
+
if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
|
|
394
|
+
}
|
|
385
395
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
386
|
-
|
|
396
|
+
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report }) : null;
|
|
397
|
+
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
398
|
+
const ok = !needs || needs.every(n => n.status === 'met');
|
|
399
|
+
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
|
|
387
400
|
} catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
388
401
|
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
389
402
|
}
|
package/lib/codex.mjs
CHANGED
|
@@ -68,6 +68,15 @@ export const SECTIONS = [
|
|
|
68
68
|
{ key: 'questions', title: 'Open questions', match: /^(open questions|questions|undecided|to decide)\b/i },
|
|
69
69
|
];
|
|
70
70
|
|
|
71
|
+
export const APP_SECTIONS = [
|
|
72
|
+
{ key: 'dream', title: 'Their words', match: /^their words|^dream/i },
|
|
73
|
+
{ key: 'scene', title: 'Scene', match: /^scene/i },
|
|
74
|
+
{ key: 'people', title: 'People and surfaces', match: /^people/i },
|
|
75
|
+
{ key: 'offers', title: 'Offers', match: /^offers/i },
|
|
76
|
+
{ key: 'needs', title: 'Needs', match: /^needs/i },
|
|
77
|
+
...SECTIONS.filter(s => ['art','sound','milestones','questions'].includes(s.key)),
|
|
78
|
+
];
|
|
79
|
+
|
|
71
80
|
/* ------------------------------------------------------------------ parsing */
|
|
72
81
|
|
|
73
82
|
/** A small YAML subset: `key: value`, quoted or bare, and one level of nested `key:` blocks or `{ a: b, c: d }`. */
|
|
@@ -130,7 +139,7 @@ export function parseCodex(source) {
|
|
|
130
139
|
while (seen.has(id)) id = `${id}-2`;
|
|
131
140
|
seen.add(id);
|
|
132
141
|
s.id = id;
|
|
133
|
-
s.key = SECTIONS.find((k) => k.match.test(s.title))?.key ?? (/^latest|^decisions|^changelog|^news/i.test(s.title) ? 'latest' : null);
|
|
142
|
+
s.key = [...SECTIONS, ...APP_SECTIONS].find((k) => k.match.test(s.title))?.key ?? (/^latest|^decisions|^changelog|^news/i.test(s.title) ? 'latest' : null);
|
|
134
143
|
}
|
|
135
144
|
return { meta, title, pitch: pitch.join('\n').trim(), sections };
|
|
136
145
|
}
|
|
@@ -139,7 +148,7 @@ export function parseCodex(source) {
|
|
|
139
148
|
export function codexGaps(parsed) {
|
|
140
149
|
const has = (key) => parsed.sections.some((s) => s.key === key && s.body.join('\n').replace(/<!--[\s\S]*?-->/g, '').trim());
|
|
141
150
|
const questions = parsed.sections.filter((s) => s.key === 'questions').flatMap((s) => s.body.filter((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)));
|
|
142
|
-
return { missing: SECTIONS.filter((s) => s.key !== 'questions' && !has(s.key)).map((s) => s.title), openQuestions: questions.length };
|
|
151
|
+
return { missing: (parsed.meta.kind === 'app' ? APP_SECTIONS : SECTIONS).filter((s) => s.key !== 'questions' && !has(s.key)).map((s) => s.title), openQuestions: questions.length };
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
/* ------------------------------------------------------------------ files from the studio */
|
|
@@ -303,6 +312,16 @@ ${stats.length ? `<dl class="stats">${stats.map(([k, v]) => `<dt>${esc(k)}</dt><
|
|
|
303
312
|
}
|
|
304
313
|
|
|
305
314
|
function sectionHtml(section, emb, ctx) {
|
|
315
|
+
if (section.key === 'needs') {
|
|
316
|
+
const source = section.body.join('\n');
|
|
317
|
+
const block = /^```needs\s*\n([\s\S]*?)^```/m.exec(source);
|
|
318
|
+
if (block) {
|
|
319
|
+
try {
|
|
320
|
+
const needs = JSON.parse(block[1]);
|
|
321
|
+
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate on the wall and phone' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
|
|
322
|
+
} catch { /* Keep malformed source visible for correction. */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
306
325
|
const intro = [];
|
|
307
326
|
const cards = [];
|
|
308
327
|
let current = null;
|
|
@@ -510,13 +529,13 @@ function lookOf(root, game, meta, emb) {
|
|
|
510
529
|
* sit in games/<id>/ before its game.json does: then the "game" is that planned folder. `create`: a codex is being
|
|
511
530
|
* started for an id with no folder yet.
|
|
512
531
|
*/
|
|
513
|
-
function codexGame(root, id, { create = false } = {}) {
|
|
532
|
+
function codexGame(root, id, { create = false, kind = null } = {}) {
|
|
514
533
|
const game = listGames(root).find((g) => g.id === id);
|
|
515
534
|
if (game) return game;
|
|
516
535
|
if (!GAME_ID.test(String(id ?? ''))) return null;
|
|
517
|
-
const dir = join(experienceDir(root, id)
|
|
536
|
+
const dir = kind === 'app' ? join(root, 'apps', id) : experienceDir(root, id);
|
|
518
537
|
if (existsSync(join(dir, 'game.json'))) return null;
|
|
519
|
-
return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
|
|
538
|
+
return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, kind: dir === join(root, 'apps', id) ? 'app' : 'game', name: null, planned: true } : null;
|
|
520
539
|
}
|
|
521
540
|
|
|
522
541
|
/**
|
|
@@ -529,9 +548,10 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
529
548
|
const game = codexGame(root, id);
|
|
530
549
|
if (!game) throw new Error(`no game "${id}" in games/, and no codex planning it`);
|
|
531
550
|
const file = join(game.dir, CODEX_FILE);
|
|
532
|
-
if (!existsSync(file)) throw new Error(
|
|
533
|
-
if (statSync(file).size > LIMITS.source) throw new Error(
|
|
551
|
+
if (!existsSync(file)) throw new Error(`${relative(root, file)} does not exist yet: npx --no-install homie-studio codex new ${id}`);
|
|
552
|
+
if (statSync(file).size > LIMITS.source) throw new Error(`${relative(root, file)} is over ${LIMITS.source / 1024} KB`);
|
|
534
553
|
const parsed = parseCodex(readFileSync(file, 'utf8'));
|
|
554
|
+
if (game.kind === 'app') parsed.meta.kind = 'app';
|
|
535
555
|
const { meta } = parsed;
|
|
536
556
|
let studio = {};
|
|
537
557
|
try { studio = readStudio(root); } catch { studio = {}; }
|
|
@@ -555,7 +575,17 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
555
575
|
tabs.splice(sections[0]?.key === 'latest' ? 1 : 0, 0, statusTab);
|
|
556
576
|
const cover = typeof meta.cover === 'string' ? emb.image(meta.cover) : null;
|
|
557
577
|
const pitch = parsed.pitch ? renderMarkdown(parsed.pitch).html : (game.blurb ? `<p>${esc(game.blurb)}</p>` : '');
|
|
558
|
-
const
|
|
578
|
+
const needReportFile = join(root, '.studio', 'checks', `${id}-needs.json`);
|
|
579
|
+
let needStatus = '';
|
|
580
|
+
if (game.kind === 'app' && existsSync(needReportFile)) {
|
|
581
|
+
try {
|
|
582
|
+
const r = JSON.parse(readFileSync(needReportFile, 'utf8'));
|
|
583
|
+
const stale = r.codexHash !== createHash('sha256').update(readFileSync(file, 'utf8')).digest('hex');
|
|
584
|
+
tabs.push('<a href="#needs-status">Needs status</a>');
|
|
585
|
+
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}. Screen assertions are evidence; review the captures against Their words.</p>${r.needs.map(n => `<article><h3>${esc(n.statement)}</h3><p><strong>${stale ? 'not yet rechecked' : esc(n.status)}</strong> — ${esc(n.why ?? '')}</p>${(n.captures ?? []).map(p => figure(emb, n.statement, relative(root, resolve(root, p)), p)).join('')}</article>`).join('')}</section>`;
|
|
586
|
+
} catch { /* The next check repairs an incomplete receipt. */ }
|
|
587
|
+
}
|
|
588
|
+
const body = needStatus + sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
|
|
559
589
|
const status = statusHtml(s, ctx);
|
|
560
590
|
const start = s?.state === 'running' ? 'build-status' : (sections[0]?.id ?? 'build-status');
|
|
561
591
|
const tagline = typeof meta.tagline === 'string' ? meta.tagline : '';
|
|
@@ -567,13 +597,13 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
567
597
|
<meta charset="utf-8">
|
|
568
598
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
569
599
|
<meta name="robots" content="noindex, nofollow">
|
|
570
|
-
<title>${esc(title)} · Game Codex</title>
|
|
600
|
+
<title>${esc(title)} · ${game.kind === 'app' ? 'App' : 'Game'} Codex</title>
|
|
571
601
|
${look.links.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
|
|
572
602
|
<style>${look.faces.join('')}${css(look.palette, look.fonts, ctx.pixel)}</style>
|
|
573
603
|
</head>
|
|
574
604
|
<body data-mode="${esc(mode)}" data-start="${esc(start)}"${ctx.pixel ? ' class="pixel"' : ''}>
|
|
575
605
|
<header class="hero">${cover ? `<div class="bgimg" style="background-image:url(${cover})"></div>` : ''}<div class="wrap">
|
|
576
|
-
<p class="kicker"
|
|
606
|
+
<p class="kicker">${game.kind === 'app' ? 'App' : 'Game'} Codex · ${esc(studio.name ?? '')}</p>
|
|
577
607
|
<h1>${esc(title)}</h1>
|
|
578
608
|
${tagline ? `<p class="tagline">${esc(tagline)}</p>` : ''}
|
|
579
609
|
${pitch ? `<div class="pitch">${pitch}</div>` : ''}
|
|
@@ -583,12 +613,12 @@ ${pitch ? `<div class="pitch">${pitch}</div>` : ''}
|
|
|
583
613
|
${body}
|
|
584
614
|
${status}
|
|
585
615
|
</main>
|
|
586
|
-
<footer
|
|
616
|
+
<footer>${game.kind === 'app' ? 'App' : 'Game'} Codex for ${esc(title)}: the plan your AI keeps true as decisions change. Source: ${esc(relative(root, file))}. Drawn ${esc(now.toISOString().slice(0, 16).replace('T', ' '))} UTC. Made with Homie.</footer>
|
|
587
617
|
<script>${CODEX_SCRIPT}</script>
|
|
588
618
|
</body>
|
|
589
619
|
</html>
|
|
590
620
|
`;
|
|
591
|
-
return { html, title, sections: sections.map((x) => ({ id: x.id, title: x.title, key: x.key })), missing: gaps.missing, openQuestions: gaps.openQuestions, warnings: emb.warnings, build: s ? { state: s.state, percent: s.percent } : null };
|
|
621
|
+
return { html, kind: game.kind ?? 'game', title, sections: sections.map((x) => ({ id: x.id, title: x.title, key: x.key })), missing: gaps.missing, openQuestions: gaps.openQuestions, warnings: emb.warnings, build: s ? { state: s.state, percent: s.percent } : null };
|
|
592
622
|
}
|
|
593
623
|
|
|
594
624
|
/** `homie-studio codex <id>`: the page on this computer (or the artifact copy). */
|
|
@@ -598,7 +628,7 @@ export function writeCodexPage(root, id, { mode = 'file' } = {}) {
|
|
|
598
628
|
mkdirSync(dir, { recursive: true });
|
|
599
629
|
const file = join(dir, mode === 'artifact' ? `${id}.artifact.html` : `${id}.html`);
|
|
600
630
|
writeFileSync(file, r.html);
|
|
601
|
-
return { ok: true, command: 'codex', id, mode, file: relative(root, file), path: file, bytes: Buffer.byteLength(r.html), title: r.title, sections: r.sections, missing: r.missing, openQuestions: r.openQuestions, warnings: r.warnings, build: r.build };
|
|
631
|
+
return { ok: true, command: 'codex', kind: r.kind, id, mode, file: relative(root, file), path: file, bytes: Buffer.byteLength(r.html), title: r.title, sections: r.sections, missing: r.missing, openQuestions: r.openQuestions, warnings: r.warnings, build: r.build };
|
|
602
632
|
}
|
|
603
633
|
|
|
604
634
|
/** Called whenever a progress feed changes: the local page redraws itself (only when it exists already). */
|
|
@@ -632,21 +662,21 @@ export function buildCodexPages(root, dist, ids, { log = () => {} } = {}) {
|
|
|
632
662
|
/* ------------------------------------------------------------------ a new codex */
|
|
633
663
|
|
|
634
664
|
/** `homie-studio codex new <id>`: CODEX.md with every section, its look taken from the game and the studio. */
|
|
635
|
-
export function newCodex(root, id, { name = null } = {}) {
|
|
665
|
+
export function newCodex(root, id, { name = null, kind = null } = {}) {
|
|
636
666
|
// A game is planned before it is made: with no game of this id, the codex starts its folder (games/<id>/CODEX.md),
|
|
637
667
|
// and `game new <id>` later makes the game around it.
|
|
638
|
-
const game = codexGame(root, id, { create: true });
|
|
668
|
+
const game = codexGame(root, id, { create: true, kind });
|
|
639
669
|
if (!game) return { ok: false, command: 'codex new', why: `"${id}" is not a game id (lowercase letters, digits and hyphens, up to 40)` };
|
|
640
670
|
if (game.planned) { game.name = name ? String(name).slice(0, 60) : id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '); mkdirSync(game.dir, { recursive: true }); }
|
|
641
671
|
const file = join(game.dir, CODEX_FILE);
|
|
642
|
-
if (existsSync(file)) return { ok: false, command: 'codex new', why:
|
|
672
|
+
if (existsSync(file)) return { ok: false, command: 'codex new', why: `${relative(root, file)} exists already: change it, never replace it` };
|
|
643
673
|
const theme = readTheme(root);
|
|
644
674
|
const land = game.landing?.theme ?? {};
|
|
645
675
|
const q = (v) => JSON.stringify(String(v));
|
|
646
676
|
const cover = ['hero/wide.jpg', 'cover.jpg', 'cover.png', 'public/cover.jpg', 'public/cover.png'].find((p) => existsSync(join(game.dir, p)));
|
|
647
677
|
const today = new Date().toISOString().slice(0, 10);
|
|
648
678
|
const players = game.players ? `${game.players.min ?? 1} to ${game.players.max ?? 8}` : '1 to 8';
|
|
649
|
-
|
|
679
|
+
let text = `---
|
|
650
680
|
tagline: ${q(game.blurb ?? '')}
|
|
651
681
|
${cover ? `cover: ${cover}\n` : ''}palette:
|
|
652
682
|
bg: ${q(theme.bg ?? '#0b0c12')}
|
|
@@ -721,9 +751,56 @@ ${game.planned ? `- [ ] The plan (this codex)
|
|
|
721
751
|
## Open questions
|
|
722
752
|
|
|
723
753
|
-
|
|
754
|
+
`;
|
|
755
|
+
if (game.kind === 'app') text = `---
|
|
756
|
+
kind: app
|
|
757
|
+
tagline: ""
|
|
758
|
+
try: "Open the wall and phone. Follow a real request through the scene."
|
|
759
|
+
---
|
|
760
|
+
# ${game.name ?? id}
|
|
761
|
+
|
|
762
|
+
## Their words
|
|
763
|
+
<!-- Quote the owner's request. Preserve their dream, not your implementation summary. -->
|
|
764
|
+
|
|
765
|
+
## Scene
|
|
766
|
+
<!-- Walking in: what is on the wall, what moves/fills/arrives/lights up, how it feels. -->
|
|
767
|
+
|
|
768
|
+
## People and surfaces
|
|
769
|
+
<!-- Each person, what they must do, and what wall, phone, staff and kiosk show them. -->
|
|
770
|
+
|
|
771
|
+
## Offers
|
|
772
|
+
<!-- What is sold or given, price and currency, once/monthly, and what payment fulfils. -->
|
|
773
|
+
|
|
774
|
+
## Needs
|
|
775
|
+
<!-- Each statement is observable on a real screen. Fill this executable needs block.
|
|
776
|
+
Each need: id, statement, steps (surface wall/phone/phone-two; click/fill/select and selector/value),
|
|
777
|
+
assertions (surface, selector, optional text). Include wall and phone assertions and real flow steps.
|
|
778
|
+
A missing flow is reported not met. Do not replace an unmet need with an easier one. -->
|
|
779
|
+
\`\`\`needs
|
|
780
|
+
[]
|
|
781
|
+
\`\`\`
|
|
782
|
+
|
|
783
|
+
## Art direction
|
|
784
|
+
<!-- Run style init with the owner's words. Composition, materials, typography and motion. -->
|
|
785
|
+
|
|
786
|
+
## Music and sound
|
|
787
|
+
<!-- Useful cues, silent wall by default, local synthesis or connected providers. -->
|
|
788
|
+
|
|
789
|
+
## Built from
|
|
790
|
+
<!-- Engine packages, parts_find results and provenance, original work and media routes. -->
|
|
791
|
+
|
|
792
|
+
## Milestones
|
|
793
|
+
- [ ] Their dream captured
|
|
794
|
+
- [ ] Their scene and workflows running
|
|
795
|
+
- [ ] Every need demonstrated on wall and phone and visually reviewed
|
|
796
|
+
|
|
797
|
+
## Latest
|
|
798
|
+
- ${today}: Planned from the person's request. Starter plumbing is not completion.
|
|
799
|
+
|
|
800
|
+
## Open questions
|
|
724
801
|
`;
|
|
725
802
|
writeFileSync(file, text);
|
|
726
|
-
return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the request and sensible defaults (interview only when asked), then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`after recording those defaults: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
|
|
803
|
+
return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the request and sensible defaults (interview only when asked), then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`after recording those defaults: npx --no-install homie-studio ${game.kind === 'app' ? 'app' : 'game'} new ${id} --from ${game.kind === 'app' ? 'welcome' : 'gem-rush'} --name "<Name>" makes the game around it (the codex stays)`] : [])] };
|
|
727
804
|
}
|
|
728
805
|
|
|
729
806
|
/** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
|
package/lib/decisions.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* games/<id>/style.json (public, tiny) carries the palette and fonts a game, its landing and its title cards draw with.
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { dirname, join } from 'node:path';
|
|
19
|
+
import { relative, 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';
|
|
@@ -39,7 +39,8 @@ export function contextOf(root, id, { prompt = '' } = {}) {
|
|
|
39
39
|
let codex = '';
|
|
40
40
|
try { codex = readFileSync(join(dir, 'CODEX.md'), 'utf8').replace(/<!--[\s\S]*?-->/g, ''); } catch { codex = ''; }
|
|
41
41
|
let game = {};
|
|
42
|
-
try { game = JSON.parse(readFileSync(
|
|
42
|
+
try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
|
|
43
|
+
const kind = existsSync(join(dir, 'app.json')) || /^kind: app$/m.test(codex) ? 'app' : 'game';
|
|
43
44
|
const fm = /^---\n([\s\S]*?)\n---/.exec(codex)?.[1] ?? '';
|
|
44
45
|
const pal = {};
|
|
45
46
|
for (const k of [...PALETTE_KEYS, 'panel']) { const m = new RegExp(`^\\s+${k}:\\s*"?(#[0-9a-fA-F]{3,6})"?`, 'm').exec(fm); if (m) pal[k] = m[1]; }
|
|
@@ -50,9 +51,9 @@ export function contextOf(root, id, { prompt = '' } = {}) {
|
|
|
50
51
|
for (const [key, re] of [['characters', '(?:characters|cast|heroes|creatures|enemies)'], ['items', '(?:items|props|pickups|collectibles)'], ['places', '(?:world|places|setting|zones?|levels?)']]) {
|
|
51
52
|
for (const m of section(re).matchAll(/^###\s+(.+?)\s*$/gm)) cards.push({ section: key, title: clean(m[1], 60) });
|
|
52
53
|
}
|
|
53
|
-
const words = [prompt, game.name, game.blurb, section('(?:concept|the game|pitch|overview)'), section('(?:art|look|style|visual)'), section('(?:world|setting)')].filter(Boolean).join(' \n ');
|
|
54
|
+
const words = [prompt, game.name, game.blurb, section('(?:concept|the game|pitch|overview)'), section('(?:art|look|style|visual)'), section('(?:world|setting)'), ...(kind === 'app' ? [section('(?:their words|dream)'), section('scene'), section('people')] : [])].filter(Boolean).join(' \n ');
|
|
54
55
|
const score = (re) => (words.match(new RegExp(re.source, 'gi')) ?? []).length;
|
|
55
|
-
const genre = Object.entries(GENRES).map(([g, v]) => [g, score(v.words)]).filter(([, n]) => n).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'party';
|
|
56
|
+
const genre = kind === 'app' ? 'app' : Object.entries(GENRES).map(([g, v]) => [g, score(v.words)]).filter(([, n]) => n).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'party';
|
|
56
57
|
const mood = Object.fromEntries(Object.entries(MOODS).map(([m, re]) => [m, score(re)]));
|
|
57
58
|
let theme = {};
|
|
58
59
|
try { theme = readTheme(root); } catch { theme = {}; }
|
|
@@ -76,7 +77,7 @@ export function contextOf(root, id, { prompt = '' } = {}) {
|
|
|
76
77
|
const hs = assets.filter((a) => a.kind === 'character' && Number(a.measured?.heightM) > 0).map((a) => Number(a.measured.heightM)).sort((a, b) => a - b);
|
|
77
78
|
if (hs.length) castHeightM = Math.round(hs[Math.floor(hs.length / 2)] * 100) / 100;
|
|
78
79
|
} catch { clipVerbs = []; }
|
|
79
|
-
return { id, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400), drawn, clipVerbs, castHeightM };
|
|
80
|
+
return { id, kind, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400), drawn, clipVerbs, castHeightM };
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** The best match in a preset table by word score: [key, score] or null. */
|
|
@@ -356,7 +357,7 @@ function change(rec, patch, { by = 'ai', words = null, reason = null } = {}) {
|
|
|
356
357
|
export function initDecisions(root, id, { prompt = '', path = null, budget = null } = {}) {
|
|
357
358
|
gameDirOf(root, id);
|
|
358
359
|
const ctx = contextOf(root, id, { prompt });
|
|
359
|
-
const doc = readDecisions(root, id) ?? { v: 1, scope:
|
|
360
|
+
const doc = readDecisions(root, id) ?? { v: 1, scope: ctx.kind, game: id, path: path ?? 'automatic', budget: null, prompt: '', decisions: {}, board: null, golden: [] };
|
|
360
361
|
if (path) doc.path = path === 'hands-on' ? 'hands-on' : 'automatic';
|
|
361
362
|
if (budget !== null && budget !== undefined) doc.budget = { usd: Math.max(0, Number(budget) || 0), provider: 'fal', approvedBy: 'the person, in chat', at: now() };
|
|
362
363
|
if (prompt) doc.prompt = clean(prompt, 600);
|
|
@@ -367,6 +368,7 @@ export function initDecisions(root, id, { prompt = '', path = null, budget = nul
|
|
|
367
368
|
const kept = [];
|
|
368
369
|
const made = [];
|
|
369
370
|
for (const [did] of CATALOGUE) {
|
|
371
|
+
if (ctx.kind === 'app' && !did.startsWith('style.')) continue;
|
|
370
372
|
const def = CAT.get(did);
|
|
371
373
|
const rec = doc.decisions[did];
|
|
372
374
|
// Kept: anything the person steered or locked, anything pinned, and what a starter's style.json already draws.
|
|
@@ -380,7 +382,7 @@ export function initDecisions(root, id, { prompt = '', path = null, budget = nul
|
|
|
380
382
|
made.push(did);
|
|
381
383
|
}
|
|
382
384
|
writeDecisions(root, id, doc);
|
|
383
|
-
return { ok: true, command: 'style init', id, picked: made, kept, genre: ctx.genre, summary: oneLine(doc), file:
|
|
385
|
+
return { ok: true, command: 'style init', id, picked: made, kept, kind: ctx.kind, genre: ctx.genre, summary: oneLine(doc), file: relative(root, fileOf(root, id)) };
|
|
384
386
|
}
|
|
385
387
|
|
|
386
388
|
/** The one line the automatic path says: "Look: flat low-poly, autumn grove palette, high three-quarter camera". */
|
package/lib/dev.mjs
CHANGED
|
@@ -310,7 +310,7 @@ const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'si
|
|
|
310
310
|
* options: port, remoteAi, localAi (false: --no-local-ai), timestamps (every line of Wrangler's gets a time), log.
|
|
311
311
|
*/
|
|
312
312
|
export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
|
|
313
|
-
|
|
313
|
+
let port = String(askedPort ?? 8787);
|
|
314
314
|
const stop = 'npx --no-install homie-studio dev --stop';
|
|
315
315
|
|
|
316
316
|
// What is really there, before anything is built or started (the header: stale state heals itself).
|
|
@@ -327,7 +327,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
327
327
|
const left = await clearLeftover(root, p);
|
|
328
328
|
if (left.stopped.length) log(`Stopped a leftover local runtime of this studio on port ${p} (process ${left.stopped.join(', ')}): its dev server had been stopped another way.`);
|
|
329
329
|
if (p === Number(port) && left.open) {
|
|
330
|
-
|
|
330
|
+
const busy = port;
|
|
331
|
+
do { port = String(Number(port) + 1); } while (Number(port) < 65535 && await portOpen(Number(port)));
|
|
332
|
+
log(`Port ${busy} is busy; using http://127.0.0.1:${port}/`);
|
|
331
333
|
}
|
|
332
334
|
}
|
|
333
335
|
|
package/lib/doctor.mjs
CHANGED
|
@@ -297,7 +297,7 @@ export async function setupStatus({
|
|
|
297
297
|
who: 'person',
|
|
298
298
|
say: app === 'codex'
|
|
299
299
|
? 'In Codex, open /hooks and trust Homie\'s three hooks: Codex runs no plugin\'s hooks until you do. Then run this again. Until then nothing is held, so your AI asks you before each of those itself.'
|
|
300
|
-
: 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok.
|
|
300
|
+
: 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok. Ask your AI to install the Homie plugin in Grok and open its trust prompt (the AI runs grok plugin install homie-rocks/homie#plugins/homie --trust), start a new session (/hooks lists Homie\'s three), then run this again. Until then nothing is held, so your AI asks you before each of those itself.',
|
|
301
301
|
},
|
|
302
302
|
});
|
|
303
303
|
}
|
package/lib/handoff.mjs
CHANGED
|
@@ -37,13 +37,18 @@ const line = (v, max) => String(v ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').repla
|
|
|
37
37
|
const lines = (v, max) => String(v ?? '').split(/\r?\n/).map((l) => line(l, max)).filter(Boolean).join('\n').slice(0, max);
|
|
38
38
|
|
|
39
39
|
/** The steps for a kind of build, in this toolkit's commands (the brief says what; these say how). */
|
|
40
|
-
export function stepsFor(h, { games = [], client = 'chat' } = {}) {
|
|
40
|
+
export function stepsFor(h, { games = [], client = 'chat', studioKind = 'game' } = {}) {
|
|
41
41
|
const id = ID.test(String(h.id ?? '')) ? h.id : null;
|
|
42
42
|
const has = id && games.includes(id);
|
|
43
43
|
const q = (s) => JSON.stringify(String(s));
|
|
44
|
-
const
|
|
44
|
+
const app = h.what === 'app' || ['business', 'cause', 'app'].includes(studioKind);
|
|
45
|
+
const work = app && ['setup', 'make'].includes(h.kind) ? [
|
|
46
|
+
'Follow the app and shared plan skills: capture their words, scene, people, offers and demonstrable needs in apps/<id>/CODEX.md. Starter plumbing is not the deliverable. Build their scene and prove each need on wall and phone; continue without an engineering interview.',
|
|
47
|
+
has ? `apps/${id} is here already: change it as the brief says.` : `npx --no-install homie-studio app new ${id ?? '<id>'} --from ${ID.test(String(h.from ?? '')) ? h.from : 'welcome'}`,
|
|
48
|
+
'Use apps/<id>/app.json for the organisation’s words, roles and records; site/pages/ for public pages; shop init --giving --currency <currency> for gifts.',
|
|
49
|
+
] : {
|
|
45
50
|
setup: [
|
|
46
|
-
`This studio was just set up from ${client === 'grok' ? 'Grok' : client === 'codex' ? 'Codex' : client === 'claude' ? 'the Claude app' : 'this chat'}.
|
|
51
|
+
`This studio was just set up from ${client === 'grok' ? 'Grok' : client === 'codex' ? 'Codex' : client === 'claude' ? 'the Claude app' : 'this chat'}. Proceed through the new-studio checklist, building and checks from the request without waiting unless the person asks (the plugin's studio-setup skill): for a business or cause, start with the app skill and app new; choose the starter that fits the requested work.`,
|
|
47
52
|
'See a working game: `npx --no-install homie-studio demo` names a live one on Homie Arcade; copy a starter in only if they ask.',
|
|
48
53
|
'One small change from one sentence of theirs (the studio\'s colours in site/theme.json, a tagline in studio.json, or a first post in posts/), then plan their game into its Game Codex (`npx --no-install homie-studio codex new <id> --name "<Name>"`) before any game is made.',
|
|
49
54
|
],
|
|
@@ -58,7 +63,7 @@ export function stepsFor(h, { games = [], client = 'chat' } = {}) {
|
|
|
58
63
|
}[h.kind] ?? ['Do what the brief asks.'];
|
|
59
64
|
return [
|
|
60
65
|
...work,
|
|
61
|
-
'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url
|
|
66
|
+
'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url <the printed dev address> (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
|
|
62
67
|
'npx --no-install homie-studio progress change "<what it does, one line>"; commit on a new branch, push, gh pr create; then npx --no-install homie-studio progress pr --url <the pull request>.',
|
|
63
68
|
'Never merge the pull request: the person publishes it from the chat\'s card, in GitHub.',
|
|
64
69
|
];
|
|
@@ -71,8 +76,8 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
|
|
|
71
76
|
if (SETUP.test(String(build ?? ''))) {
|
|
72
77
|
const r = await checkIn(root, build, { homie, client: who.id === 'chat' ? null : who.id });
|
|
73
78
|
if (!r.ok) return { ok: false, command: 'handoff', setup: build, why: r.why, ...(r.needs ? { needs: r.needs } : {}) };
|
|
74
|
-
const h = { kind: 'setup', what: 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
|
|
75
|
-
return { ok: true, command: 'handoff', build: null, ...h, client: who.id, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h, { client: who.id }) };
|
|
79
|
+
const h = { kind: 'setup', what: ['business', 'cause', 'app'].includes(studio.kind) ? 'app' : 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
|
|
80
|
+
return { ok: true, command: 'handoff', build: null, ...h, client: who.id, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h, { client: who.id, studioKind: studio.kind }) };
|
|
76
81
|
}
|
|
77
82
|
if (!BUILD.test(String(build ?? ''))) return { ok: false, command: 'handoff', why: `the id from the one line the chat gave: hb_ (a build) or hs_ (a setup) and 32 hex digits` };
|
|
78
83
|
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
@@ -88,7 +93,7 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
|
|
|
88
93
|
if (want && here && here !== want) return { ok: false, command: 'handoff', build, why: `this build is for ${body.repo}, but this session is in ${here}: open it in ${body.repo} (the card's Build it does), and change nothing here` };
|
|
89
94
|
const h = {
|
|
90
95
|
kind: ['make', 'port', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
|
|
91
|
-
what: ['game', 'song', 'video'].includes(body.what) ? body.what : 'game',
|
|
96
|
+
what: ['app', 'game', 'song', 'video'].includes(body.what) ? body.what : 'game',
|
|
92
97
|
title: line(body.title, 120), studio: line(body.studio, 60) || studio.name, brief: lines(body.brief, 1200),
|
|
93
98
|
id: ID.test(String(body.id ?? '')) ? body.id : null, name: line(body.name, 60) || null, from: ID.test(String(body.from ?? '')) ? body.from : null,
|
|
94
99
|
folder: line(body.folder, 200) || null,
|
|
@@ -106,7 +111,7 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
|
|
|
106
111
|
ok: true, command: 'handoff', build, ...h, setup,
|
|
107
112
|
attached: taken.ok ? { feed: taken.build, shared: taken.shared?.build ?? build } : null,
|
|
108
113
|
attachWhy: taken.ok ? null : taken.why,
|
|
109
|
-
steps: stepsFor(h, { games, client: who.id }),
|
|
114
|
+
steps: stepsFor(h, { games, client: who.id, studioKind: studio.kind }),
|
|
110
115
|
};
|
|
111
116
|
}
|
|
112
117
|
|