@homie-rocks/studio 0.36.0 → 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 +16 -0
- package/README.md +4 -2
- 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 +13 -4
- 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 +6 -3
- 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 +26 -9
- package/lib/directory.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +1 -1
- package/lib/lab-check.mjs +1 -1
- package/lib/lab.mjs +8 -8
- package/lib/library.mjs +2 -2
- package/lib/mcp-tools.mjs +22 -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/scaffold.mjs +21 -2
- package/lib/shop.mjs +1 -1
- package/lib/standalone-files.mjs +2 -2
- package/lib/standalone.mjs +7 -2
- package/lib/stats.mjs +2 -1
- package/lib/studio.mjs +32 -6
- package/lib/style-board.mjs +10 -9
- package/lib/template-history.json +41 -0
- package/links/links.d.ts +3 -0
- package/links/links.mjs +16 -0
- package/package.json +14 -3
- package/parts/PARTS.md +10 -5
- package/shop/SHOP.md +13 -0
- package/site/SITE.md +1 -1
- 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/embed.mjs +4 -2
- package/worker/index.mjs +33 -9
- package/worker/pages.mjs +33 -27
- package/worker/parts.mjs +11 -9
- 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/parts-store.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
packedVersions, parseRef, partDir, readOrigins, readPart, requiredParts, sha256, shareProblems, syncPartCredits, vendorDir, verifyFiles, writeHashes,
|
|
24
24
|
writeOrigins, writePart,
|
|
25
25
|
} from './parts.mjs';
|
|
26
|
-
import { listGames, readStudio } from './studio.mjs';
|
|
26
|
+
import { experienceDir, experienceFile, listExperiences as listGames, readStudio } from './studio.mjs';
|
|
27
27
|
|
|
28
28
|
export const HUB = 'https://homie.rocks';
|
|
29
29
|
const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -139,8 +139,8 @@ export function ensurePackages(root, wants, { npm = runNpm, install = false, say
|
|
|
139
139
|
/** What a part's licence can care about in the game using it: whether the studio sells, and who the game credits. */
|
|
140
140
|
export function gameFacts(root, id) {
|
|
141
141
|
let g = {}; let credits = {};
|
|
142
|
-
try { g = JSON.parse(readFileSync(
|
|
143
|
-
try { credits = JSON.parse(readFileSync(join(root,
|
|
142
|
+
try { g = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { g = {}; }
|
|
143
|
+
try { credits = JSON.parse(readFileSync(join(experienceDir(root, id), 'credits.json'), 'utf8')); } catch { credits = {}; }
|
|
144
144
|
return { id, name: g.name ?? id, sells: existsSync(join(root, 'shop.json')), credits: Array.isArray(credits.parts) ? credits.parts : [] };
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -177,7 +177,7 @@ const diffFiles = (was, now) => {
|
|
|
177
177
|
export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {} } = {}) {
|
|
178
178
|
const command = 'parts add';
|
|
179
179
|
const fail = (why, extra = {}) => ({ ok: false, command, why, ...extra });
|
|
180
|
-
if (game && !existsSync(
|
|
180
|
+
if (game && !existsSync(experienceFile(root, game))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
|
|
181
181
|
let got;
|
|
182
182
|
try { got = await fetchPart(refText, { fetch }); } catch (error) { return fail(error.message); }
|
|
183
183
|
const { ref, part, files, version, base, entry } = got;
|
|
@@ -281,14 +281,15 @@ export function sharePart(root, id, on = true) {
|
|
|
281
281
|
const packed = packPart(root, id);
|
|
282
282
|
if (!packed.ok) { part.share = false; writePart(dir, part); return { ...packed, command }; }
|
|
283
283
|
const lic = licenseOfPart(part.license);
|
|
284
|
-
return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from
|
|
284
|
+
return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from ? { from: part.from } : {}), effect: 'It is live after the studio\'s next deploy, not before: nothing was uploaded now. Sharing a part does not list the studio in the directory.' };
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
/* ------------------------------------------------------------------ find */
|
|
288
288
|
|
|
289
289
|
const row = (p, extra) => ({
|
|
290
290
|
id: p.id, name: p.name, kind: p.kind, version: p.version, summary: line(p.summary), license: p.license || null, tags: Array.isArray(p.tags) ? p.tags.slice(0, 12) : [],
|
|
291
|
-
|
|
291
|
+
...(Array.isArray(p.uses) ? { uses: p.uses } : {}),
|
|
292
|
+
from: isObj(p.from) ? { ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
|
|
292
293
|
cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
|
|
293
294
|
});
|
|
294
295
|
function matches(p, { words, kind, tag, license, builds }) {
|
|
@@ -297,7 +298,7 @@ function matches(p, { words, kind, tag, license, builds }) {
|
|
|
297
298
|
if (license && String(p.license ?? '').toLowerCase() !== String(license).toLowerCase()) return false;
|
|
298
299
|
// What a part builds on: a package it names, or the skeleton it is made for.
|
|
299
300
|
if (builds && !(Object.keys(p.requires?.packages && typeof p.requires.packages === 'object' ? p.requires.packages : {}).includes(builds) || p.rig === builds)) return false;
|
|
300
|
-
const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.studio].filter(Boolean).join(' ').toLowerCase();
|
|
301
|
+
const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.app, p.from?.music, p.from?.video, ...(p.uses ?? []), p.from?.studio].filter(Boolean).join(' ').toLowerCase();
|
|
301
302
|
return words.every((w) => hay.includes(w));
|
|
302
303
|
}
|
|
303
304
|
|
package/lib/parts-tools.mjs
CHANGED
|
@@ -51,22 +51,22 @@ export function partsToolDefs(ctx, h) {
|
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
name: 'part_new', title: 'Make a
|
|
55
|
-
description: 'Make a part of this studio: a piece of
|
|
56
|
-
inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
|
|
57
|
-
annotations: { title: 'Make a
|
|
54
|
+
name: 'part_new', title: 'Make a shared part',
|
|
55
|
+
description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game\'s id) and files (paths inside that game: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
|
|
56
|
+
inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
|
|
57
|
+
annotations: { title: 'Make a shared part', ...RW },
|
|
58
58
|
run: async (a) => {
|
|
59
59
|
const root = ctx.root(a.studio);
|
|
60
|
-
const r = newPart(root, String(a.id ?? ''), { kind: a.kind ?? 'mechanic', name: a.name ?? null, from: a.from ? String(a.from) : null, paths: Array.isArray(a.files) ? a.files.map(String) : [] });
|
|
60
|
+
const r = newPart(root, String(a.id ?? ''), { kind: a.kind ?? 'mechanic', uses: a.uses, name: a.name ?? null, from: a.from ? String(a.from) : null, paths: Array.isArray(a.files) ? a.files.map(String) : [] });
|
|
61
61
|
if (!r.ok) return fail(`No part was made: ${r.why}`, { kind: 'part', ...r });
|
|
62
62
|
return ok([text(r), r.lifted ? `Build ${r.from} now and check it still plays the same.` : '', 'Fill in parts/' + r.id + '/part.json: a one-sentence summary, what it takes from a game ("contract"), and its costs if measured. It stays private until the person asks to share it (part_share).'].filter(Boolean).join('\n'), { kind: 'part', ...r });
|
|
63
63
|
},
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
|
-
name: 'part_share', title: 'Share a
|
|
66
|
+
name: 'part_share', title: 'Share a part',
|
|
67
67
|
description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
|
|
68
68
|
inputSchema: { type: 'object', properties: { id: str('The part\'s id'), share: { type: 'boolean', description: 'true: share it; false: stop sharing it; left out: only check what stands in the way' }, license: str('The licence the person picked, an SPDX identifier (set before sharing)'), attribution: str('Who a game using it should credit'), ...STUDIO_ARG }, required: ['id'] },
|
|
69
|
-
annotations: { title: 'Share a
|
|
69
|
+
annotations: { title: 'Share a part', ...RW },
|
|
70
70
|
run: async (a) => {
|
|
71
71
|
const root = ctx.root(a.studio);
|
|
72
72
|
const id = String(a.id ?? '');
|
package/lib/parts.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* GAME PARTS: THE FORMAT AND ITS CHECKER (parts/PARTS.md is the design; this file holds it to its word).
|
|
3
4
|
*
|
|
@@ -24,7 +25,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, r
|
|
|
24
25
|
import { dirname, join, relative } from 'node:path';
|
|
25
26
|
|
|
26
27
|
export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
27
|
-
export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece']);
|
|
28
|
+
export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template']);
|
|
28
29
|
export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
|
|
29
30
|
export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
|
|
30
31
|
export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
|
|
@@ -315,8 +316,9 @@ export function checkPart(part, { id = null } = {}) {
|
|
|
315
316
|
if (!isObj(part.cost)) bad('cost', 'it is not an object', '{ "triangles": 0, "drawCalls": 0, "textureMB": 0, "bytes": 0, "measuredOn": "…" }');
|
|
316
317
|
else for (const k of ['triangles', 'drawCalls', 'textureMB', 'bytes']) if (part.cost[k] !== undefined && !(Number.isFinite(part.cost[k]) && part.cost[k] >= 0)) bad(`cost.${k}`, 'it is not a number of 0 or more', 'leave out a cost nobody measured');
|
|
317
318
|
}
|
|
319
|
+
if (part.uses !== undefined && (!Array.isArray(part.uses) || part.uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u)))) bad('uses', 'uses must name game, app, venue, cause, music or video');
|
|
318
320
|
if (part.from !== undefined && part.from !== null) {
|
|
319
|
-
if (!isObj(part.from) || (part.from.game != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
|
|
321
|
+
if (!isObj(part.from) || ((part.from.game ?? part.from.app ?? part.from.music ?? part.from.video) != null && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(part.from.game ?? part.from.app ?? part.from.music ?? part.from.video)))) bad('from', 'it does not name the game the part came out of', '{ "game": "<the game\'s id>", "studio": "<the studio\'s name>" }');
|
|
320
322
|
}
|
|
321
323
|
if (part.provenance !== undefined) {
|
|
322
324
|
if (!isObj(part.provenance)) bad('provenance', 'it is not an object', '{ "source": "original" }');
|
|
@@ -353,14 +355,16 @@ export function requiredParts(part) {
|
|
|
353
355
|
*/
|
|
354
356
|
export function assetRecords(root) {
|
|
355
357
|
const out = new Map();
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
+
for (const folder of ['games', 'apps']) {
|
|
359
|
+
const games = join(root, folder);
|
|
360
|
+
if (!existsSync(games)) continue;
|
|
358
361
|
for (const e of readdirSync(games, { withFileTypes: true })) {
|
|
359
362
|
if (!e.isDirectory()) continue;
|
|
360
363
|
let m = null;
|
|
361
364
|
try { m = JSON.parse(readFileSync(join(games, e.name, 'assets', 'manifest.json'), 'utf8')); } catch { m = null; }
|
|
362
365
|
for (const a of Array.isArray(m?.assets) ? m.assets : []) for (const f of a.files ?? []) if (/^[a-f0-9]{64}$/.test(String(f.sha256 ?? ''))) out.set(f.sha256, { game: e.name, asset: a.id, kind: a.license?.kind ?? null, attribution: a.license?.attribution ?? null });
|
|
363
366
|
}
|
|
367
|
+
}
|
|
364
368
|
return out;
|
|
365
369
|
}
|
|
366
370
|
/** The asset-manifest licence kinds a file may be handed on under (lib/asset-manifest.mjs LICENSES, `redistribute`). */
|
|
@@ -520,7 +524,7 @@ function relSpec(fromFile, toFile) {
|
|
|
520
524
|
* would point nowhere, and a part that reaches back into one game is not a part another game can use.
|
|
521
525
|
*/
|
|
522
526
|
function liftPlan(root, game, paths) {
|
|
523
|
-
const gdir = join(root,
|
|
527
|
+
const gdir = join(experienceDir(root, game));
|
|
524
528
|
const files = [];
|
|
525
529
|
const walk = (rel) => {
|
|
526
530
|
const abs = join(gdir, rel);
|
|
@@ -528,15 +532,15 @@ function liftPlan(root, game, paths) {
|
|
|
528
532
|
if (statSync(abs).isDirectory()) { for (const e of readdirSync(abs, { withFileTypes: true })) if (!e.name.startsWith('.') && e.name !== 'node_modules') walk(`${rel}/${e.name}`); } else files.push(rel);
|
|
529
533
|
};
|
|
530
534
|
for (const raw of paths) {
|
|
531
|
-
const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^games\/[^/]+\//, '').replace(/\/+$/, '');
|
|
535
|
+
const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^(?:games|apps)\/[^/]+\//, '').replace(/\/+$/, '');
|
|
532
536
|
if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside games/${game}`);
|
|
533
537
|
walk(rel);
|
|
534
538
|
}
|
|
535
539
|
if (!files.length) throw new Error(`name the files of games/${game} that become the part (src/creature.ts, or a folder)`);
|
|
536
540
|
// A part is a piece of a game, never the whole game: its entry, its page and its game.json stay the game's.
|
|
537
541
|
let entry = 'src/main.ts';
|
|
538
|
-
try { entry = JSON.parse(readFileSync(
|
|
539
|
-
const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'index.html');
|
|
542
|
+
try { entry = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
|
|
543
|
+
const whole = files.filter((f) => f === entry || f === 'game.json' || f === 'app.json' || f === 'index.html');
|
|
540
544
|
if (whole.length) throw new Error(`${whole.join(', ')} ${whole.length === 1 ? 'is' : 'are'} the game itself. A part is a piece of a game, never the whole game: name the files of one piece (the creature, the level generator, the camera).`);
|
|
541
545
|
// The folder they share (without a leading src/, which every part has of its own).
|
|
542
546
|
const dirs = files.map((f) => f.split('/').slice(0, -1));
|
|
@@ -560,15 +564,16 @@ function liftPlan(root, game, paths) {
|
|
|
560
564
|
* `parts new`: a private part with every field a sharer will need, so the gaps are visible from the first minute.
|
|
561
565
|
* `from` (a game's id) with `paths` lifts those files out of the game (above); without, the part starts empty.
|
|
562
566
|
*/
|
|
563
|
-
export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [] } = {}) {
|
|
567
|
+
export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [], uses = [] } = {}) {
|
|
564
568
|
if (!PART_ID.test(String(id ?? ''))) return { ok: false, command: 'parts new', why: `"${id ?? ''}" is not a part id: lowercase letters, digits and hyphens (chase-camera)` };
|
|
565
569
|
if (!PART_KINDS.includes(kind)) return { ok: false, command: 'parts new', why: `"${kind}" is not a kind of part: ${PART_KINDS.join(', ')}` };
|
|
570
|
+
if (!Array.isArray(uses) || uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u))) return { ok: false, command: 'parts new', why: 'invalid intended uses' };
|
|
566
571
|
const dir = partDir(root, id);
|
|
567
572
|
if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
|
|
568
573
|
const title = clean(name, 80) || id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
569
574
|
let entry = null; let lifted = null; let fromGame = null;
|
|
570
575
|
if (from) {
|
|
571
|
-
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(
|
|
576
|
+
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(experienceFile(root, from))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
|
|
572
577
|
let plan;
|
|
573
578
|
try { plan = liftPlan(root, from, paths); } catch (error) { return { ok: false, command: 'parts new', why: error.message }; }
|
|
574
579
|
if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of games/${from}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
|
|
@@ -589,7 +594,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
|
|
|
589
594
|
entry = mods.find((p) => /^src\/index\.[a-z]+$/.test(p)) ?? mods[0] ?? null;
|
|
590
595
|
let studio = null;
|
|
591
596
|
try { studio = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name ?? null; } catch { studio = null; }
|
|
592
|
-
fromGame = { game: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
|
|
597
|
+
fromGame = { [experienceFile(root, from).endsWith('/app.json') ? 'app' : 'game']: from, ...(studio ? { studio: clean(studio, 80) } : {}) };
|
|
593
598
|
} else {
|
|
594
599
|
mkdirSync(dir, { recursive: true });
|
|
595
600
|
if (['mechanic', 'ui', 'effect', 'shader', 'level-generator', 'bot-brain'].includes(kind)) {
|
|
@@ -602,7 +607,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
|
|
|
602
607
|
mkdirSync(join(dir, 'preview'), { recursive: true });
|
|
603
608
|
if (!existsSync(join(dir, 'preview', 'index.html'))) writeFileSync(join(dir, 'preview', 'index.html'), PREVIEW_HTML(title));
|
|
604
609
|
writePart(dir, {
|
|
605
|
-
id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [],
|
|
610
|
+
id, name: title, kind, version: '0.1.0', summary: '', license: '', attribution: '', share: false, tags: [], ...(uses.length ? { uses } : {}),
|
|
606
611
|
...(fromGame ? { from: fromGame } : {}),
|
|
607
612
|
...(entry ? { entry } : {}),
|
|
608
613
|
files: [],
|
|
@@ -628,7 +633,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
|
|
|
628
633
|
|
|
629
634
|
/* ------------------------------------------------------------------ checking a set together */
|
|
630
635
|
|
|
631
|
-
const creditsFile = (root, game) => join(root,
|
|
636
|
+
const creditsFile = (root, game) => join(experienceDir(root, game), 'credits.json');
|
|
632
637
|
|
|
633
638
|
/**
|
|
634
639
|
* THE LICENCES OF THE PARTS A GAME USES, and where they cannot be combined: [{ level: 'conflict' | 'warn' | 'note',
|
|
@@ -687,7 +692,7 @@ export function syncPartCredits(root, game, origins) {
|
|
|
687
692
|
const keep = (Array.isArray(credits.parts) ? credits.parts : []).filter((p) => typeof p?.part !== 'string');
|
|
688
693
|
const mine = Object.entries(origins.parts ?? {}).filter(([, e]) => (e.games ?? []).includes(game)).map(([ref, e]) => creditLine(ref, e));
|
|
689
694
|
if (!mine.length && !existsSync(file)) return null;
|
|
690
|
-
if (!existsSync(join(root,
|
|
695
|
+
if (!existsSync(join(experienceDir(root, game)))) return null;
|
|
691
696
|
const text = `${JSON.stringify({ ...credits, parts: [...keep, ...mine] }, null, 2)}\n`;
|
|
692
697
|
if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
|
|
693
698
|
return `games/${game}/credits.json`;
|
package/lib/perf.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio perf <game> --url <site>` — how fast a game runs, measured the same way every time, so a change can be
|
|
3
4
|
* judged against the build before it (`perf compare`, lib/perf-stats.mjs).
|
|
@@ -681,7 +682,7 @@ export function perfSizes(root, game) {
|
|
|
681
682
|
const dir = join(root, 'site', 'dist', 'games', game);
|
|
682
683
|
if (!existsSync(dir)) return { ok: false, command: 'perf sizes', why: `no build of ${game}: run npm run build first` };
|
|
683
684
|
let cover = null;
|
|
684
|
-
try { cover = JSON.parse(readFileSync(
|
|
685
|
+
try { cover = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).cover ?? null; } catch { /* none */ }
|
|
685
686
|
const files = listFiles(dir).map((f) => {
|
|
686
687
|
const buf = readFileSync(join(dir, f));
|
|
687
688
|
const kind = kindOf(f);
|
package/lib/player-image.mjs
CHANGED
|
@@ -12,10 +12,10 @@ import { PLAYER_IMAGE, playerEnabled, playerImageRule } from '../worker/embed.mj
|
|
|
12
12
|
*/
|
|
13
13
|
export async function playerImage(cat, game, dist, log) {
|
|
14
14
|
if (!playerEnabled(cat, game)) return null;
|
|
15
|
-
const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.cover,
|
|
15
|
+
const sources = [...new Set([game.landing?.hero?.wideImage, game.landing?.hero?.tallImage, game.landing?.cover,
|
|
16
16
|
game.cover ? `/games/${game.id}/${game.cover}` : null, cat.studio.theme?.social].filter(Boolean))];
|
|
17
17
|
const warn = (file, rule) => log(`warning: ${file}: player card needs ${rule}; keeping the picture card unless another image qualifies.`);
|
|
18
|
-
if (!sources.length) warn(
|
|
18
|
+
if (!sources.length) warn(`${game.kind === 'app' ? 'apps' : 'games'}/${game.id}/${game.kind === 'app' ? 'app' : 'game'}.json`, 'a cover or a studio social picture in the built site (starters do not include one)');
|
|
19
19
|
const root = realpathSync(dist);
|
|
20
20
|
for (const src of sources) {
|
|
21
21
|
if (!src.startsWith('/') || src.startsWith('//')) { warn(src, 'a local image the build can measure'); continue; }
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
|
|
3
4
|
* monorepo: a visible folder the person opens in Claude or Codex.
|
|
@@ -131,6 +132,16 @@ directory lists its games; homie.rocks does not host them.
|
|
|
131
132
|
| \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
|
|
132
133
|
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
133
134
|
|
|
135
|
+
## Apps: one screen, roles and parts
|
|
136
|
+
|
|
137
|
+
An app lives in \`apps/<id>/app.json\` and \`src/\`. Make one with \`homie-studio app new <id>\` (or \`app_make\`). Use the Homie plugin's \`app\` skill for businesses, venues, causes and customer apps. No game demo, rounds, scores, bots or mandatory fun: build what the person needs with the fewest human steps.
|
|
138
|
+
|
|
139
|
+
An app is ONE screen that morphs with context: camera moves, panels unfold, its roles see and do different things, and parts plug into the scene. No page-to-page navigation or menus of links inside it. Use all the same engine packages and media skills as games (camera, geom, props, render, postfx, fx, audio, input, ui-world; style, art, models, animate, music, sound, video). It can be fully 3D and wild while remaining useful and legible.
|
|
140
|
+
|
|
141
|
+
Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
|
|
142
|
+
|
|
143
|
+
Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
|
|
144
|
+
|
|
134
145
|
## Commands (all through the pinned CLI in node_modules)
|
|
135
146
|
|
|
136
147
|
Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
|
|
@@ -700,6 +711,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
700
711
|
'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
|
|
701
712
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
702
713
|
'.gitignore': GITIGNORE,
|
|
714
|
+
'apps/README.md': 'One folder per app: app.json, index.html and src/. One transforming screen, its roles and its parts. Start with `npx --no-install homie-studio app new <id>`. The app guide explains lasting records, staff sign-in, LAN preview and standalone builds.\n',
|
|
703
715
|
'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
|
|
704
716
|
'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
|
|
705
717
|
'music/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
@@ -728,6 +740,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
728
740
|
[`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
|
|
729
741
|
[`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
|
|
730
742
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
743
|
+
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
731
744
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
732
745
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
733
746
|
'.claude/skills/.gitkeep': '',
|
|
@@ -877,13 +890,19 @@ function ensureLoungeMigration(root) {
|
|
|
877
890
|
return `site/migrations/${LOUNGE_MIGRATION_FILE}`;
|
|
878
891
|
}
|
|
879
892
|
|
|
893
|
+
function ensureAppsMigration(root) {
|
|
894
|
+
const file = join(root, 'site', 'migrations', APPS_MIGRATION_FILE);
|
|
895
|
+
if (existsSync(file)) return null;
|
|
896
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
|
|
897
|
+
return `site/migrations/${APPS_MIGRATION_FILE}`;
|
|
898
|
+
}
|
|
880
899
|
export function ensureMigrations(root) {
|
|
881
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
|
|
900
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root)].filter(Boolean);
|
|
882
901
|
}
|
|
883
902
|
|
|
884
903
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
885
904
|
export function migrationWord(file) {
|
|
886
|
-
return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
905
|
+
return /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
887
906
|
}
|
|
888
907
|
|
|
889
908
|
/**
|
package/lib/shop.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { dirname, join } from 'node:path';
|
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import { askedFor, withKey } from './office.mjs';
|
|
34
34
|
import { runner } from './cloudflare.mjs';
|
|
35
|
-
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
35
|
+
import { listExperiences as listGames, readStudio, siteUrl } from './studio.mjs';
|
|
36
36
|
import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
37
37
|
import {
|
|
38
38
|
KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
|
package/lib/standalone-files.mjs
CHANGED
|
@@ -105,8 +105,8 @@ export function projectPackage(meta, { targets = TARGETS } = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** web/config.js: everything the shell knows about the game it frames. `site` '' is a copy that only plays offline. */
|
|
108
|
-
export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {}, room = null }) {
|
|
109
|
-
const cfg = { v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, ...(room ? { room } : {}), version: meta.version, target };
|
|
108
|
+
export function configScript(meta, { target, site = '', share, colours = null, movement = null, params = {}, room = null, kind = null }) {
|
|
109
|
+
const cfg = { ...(kind === 'app' ? { kind: 'app' } : {}), v: 1, game: meta.id, name: meta.name, site: String(site ?? '').replace(/\/+$/, ''), ver: meta.netplayVersion ?? '', ...(movement ? { movement } : {}), params, share, colours, ...(room ? { room } : {}), version: meta.version, target };
|
|
110
110
|
return `window.__HOMIE_APP = ${JSON.stringify(cfg, null, 2).replace(/</g, '\\u003c')};\n`;
|
|
111
111
|
}
|
|
112
112
|
|
package/lib/standalone.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APP_SHELL_JS } from '../worker/app-shell.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio standalone plan|build|run|steam|ci <game>`: A STANDALONE COPY OF A GAME (standalone/STANDALONE.md).
|
|
3
4
|
*
|
|
@@ -30,7 +31,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
30
31
|
import { dirname, join, relative } from 'node:path';
|
|
31
32
|
import { build as buildSite, uiOf } from './build.mjs';
|
|
32
33
|
import { compareVersions } from './changelog.mjs';
|
|
33
|
-
import { GAME_ID, PACKAGE_ROOT, isWorkersDev, listGames, readStudio, siteUrl } from './studio.mjs';
|
|
34
|
+
import { GAME_ID, PACKAGE_ROOT, isWorkersDev, listExperiences as listGames, readStudio, siteUrl } from './studio.mjs';
|
|
34
35
|
import { pinnedVersion } from './upgrade.mjs';
|
|
35
36
|
import { STUDIO_VERSION } from './version.mjs';
|
|
36
37
|
import { sharePlaces } from '../worker/pages.mjs';
|
|
@@ -402,11 +403,15 @@ export function webBundle(root, game, dir, { meta, target = 'web', site = '', st
|
|
|
402
403
|
rmSync(web, { recursive: true, force: true });
|
|
403
404
|
mkdirSync(web, { recursive: true });
|
|
404
405
|
for (const f of ['index.html', 'shell.js', 'shell.css']) copyFileSync(join(SHELL, f), join(web, f));
|
|
406
|
+
if (game.kind === 'app') {
|
|
407
|
+
const shell = join(web, 'shell.js');
|
|
408
|
+
writeFileSync(shell, readFileSync(shell, 'utf8') + '\n' + APP_SHELL_JS);
|
|
409
|
+
}
|
|
405
410
|
const row = (readJson(join(root, 'site', 'dist', 'games.json'))?.games ?? []).find((g) => g.id === game.id);
|
|
406
411
|
const m = { ...(meta ?? standaloneMeta(studio ?? readStudio(root), game)), ...(row?.room ? { netplayVersion: row.netplay?.version ?? null } : {}) };
|
|
407
412
|
const rules = readJson(join(built, 'rules.json'));
|
|
408
413
|
if (rules) for (const file of rules.files ?? []) if (!existsSync(join(built, file))) throw new Error(`The rules build is incomplete: ${file} is missing. Build the game again before making its app.`);
|
|
409
|
-
writeFileSync(join(web, 'config.js'), configScript(m, { target, site, share: sharePlaces(game.screen?.share), colours: uiOf(game), movement: game.netplay?.movement ?? game.movement ?? null, room: row?.room ?? null }));
|
|
414
|
+
writeFileSync(join(web, 'config.js'), configScript(m, { target, site, share: sharePlaces(game.screen?.share), colours: uiOf(game), movement: game.netplay?.movement ?? game.movement ?? null, room: row?.room ?? null, ...(game.kind === 'app' ? { kind: 'app', params: { role: game.surfaces.phone } } : {}) }));
|
|
410
415
|
copyTree(built, join(web, 'game'));
|
|
411
416
|
const index = join(web, 'game', 'index.html');
|
|
412
417
|
writeFileSync(index, withNetScript(readFileSync(index, 'utf8')));
|
package/lib/stats.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
|
|
14
14
|
* locally (`npm run dev`, a loopback --url) uses the local D1.
|
|
15
15
|
*/
|
|
16
|
+
import { networkInterfaces } from 'node:os';
|
|
16
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
17
18
|
import { runner } from './cloudflare.mjs';
|
|
18
19
|
import { readStudio, siteUrl, writeStudio } from './studio.mjs';
|
|
@@ -21,7 +22,7 @@ const HOUR = 3600_000;
|
|
|
21
22
|
const RANGES = new Set(['1d', '7d', '30d', '90d']);
|
|
22
23
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
24
|
|
|
24
|
-
export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
25
|
+
export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]', ...Object.values(networkInterfaces()).flat().filter((n) => n?.family === 'IPv4').map((n) => n.address)].includes(new URL(url).hostname); } catch { return false; } };
|
|
25
26
|
|
|
26
27
|
export function siteOf(root, url) {
|
|
27
28
|
const studio = readStudio(root);
|
package/lib/studio.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
-
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
|
|
11
|
+
const RESERVED_IDS = new Set(['api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
|
|
12
12
|
|
|
13
13
|
export function findStudio(from = process.cwd()) {
|
|
14
14
|
let at = resolve(from);
|
|
@@ -82,14 +82,25 @@ export function siteUrl(root, studio = readStudio(root)) {
|
|
|
82
82
|
return custom ?? readLocal(root).url ?? cf.url ?? null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export function listGames(root) {
|
|
86
|
-
|
|
85
|
+
export function listGames(root) { return listKind(root, 'game'); }
|
|
86
|
+
export function listApps(root) { return listKind(root, 'app'); }
|
|
87
|
+
export function listExperiences(root) {
|
|
88
|
+
const rows = [...listGames(root), ...listApps(root)].sort((a, b) => a.id.localeCompare(b.id));
|
|
89
|
+
const ids = new Set();
|
|
90
|
+
for (const row of rows) { if (ids.has(row.id)) throw new Error(`app/game id collision: ${row.id}`); ids.add(row.id); }
|
|
91
|
+
return rows;
|
|
92
|
+
}
|
|
93
|
+
export const experienceDir = (root, id) => join(root, existsSync(join(root, 'apps', id, 'app.json')) ? 'apps' : 'games', id);
|
|
94
|
+
export const experienceFile = (root, id) => join(experienceDir(root, id), existsSync(join(root, 'apps', id, 'app.json')) ? 'app.json' : 'game.json');
|
|
95
|
+
function listKind(root, kind) {
|
|
96
|
+
const dir = join(root, `${kind}s`);
|
|
87
97
|
if (!existsSync(dir)) return [];
|
|
88
98
|
return readdirSync(dir, { withFileTypes: true })
|
|
89
|
-
.filter((d) => d.isDirectory() && existsSync(join(dir, d.name,
|
|
99
|
+
.filter((d) => d.isDirectory() && existsSync(join(dir, d.name, `${kind}.json`)))
|
|
90
100
|
.map((d) => {
|
|
91
|
-
const meta = JSON.parse(readFileSync(join(dir, d.name,
|
|
92
|
-
|
|
101
|
+
const meta = JSON.parse(readFileSync(join(dir, d.name, `${kind}.json`), 'utf8'));
|
|
102
|
+
if (kind === 'app' && (!GAME_ID.test(d.name) || RESERVED_IDS.has(d.name) || (meta.id !== undefined && meta.id !== d.name))) throw new Error(`apps/${d.name}: id must match its folder and use a free site address`);
|
|
103
|
+
return { ...meta, ...(kind === 'app' ? { kind: 'app' } : {}), id: meta.id ?? d.name, dir: join(dir, d.name) };
|
|
93
104
|
})
|
|
94
105
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
95
106
|
}
|
|
@@ -141,6 +152,7 @@ function renameTakeSaves(dir, from, id) {
|
|
|
141
152
|
*/
|
|
142
153
|
export async function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
143
154
|
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
|
|
155
|
+
if (listApps(root).some((a) => a.id === id)) throw new Error(`apps/${id} already owns this address`);
|
|
144
156
|
const src = join(PACKAGE_ROOT, 'starters', from);
|
|
145
157
|
if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
|
|
146
158
|
const dest = join(root, 'games', id);
|
|
@@ -287,3 +299,17 @@ export function addNeeds(root, needs) {
|
|
|
287
299
|
return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
|
|
288
300
|
}
|
|
289
301
|
|
|
302
|
+
|
|
303
|
+
export async function newApp(root, id, { name } = {}) {
|
|
304
|
+
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('invalid app id');
|
|
305
|
+
if (listExperiences(root).some((g) => g.id === id) || existsSync(join(root, 'apps', id))) throw new Error(`app/game ${id} already exists`);
|
|
306
|
+
const dest = join(root, 'apps', id);
|
|
307
|
+
cpSync(join(PACKAGE_ROOT, 'app-starters', 'welcome'), dest, { recursive: true });
|
|
308
|
+
const file = join(dest, 'app.json');
|
|
309
|
+
const meta = JSON.parse(readFileSync(file, 'utf8'));
|
|
310
|
+
meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
|
|
311
|
+
writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
|
|
312
|
+
const main = join(dest, 'src/main.ts');
|
|
313
|
+
writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
|
|
314
|
+
return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
|
|
315
|
+
}
|
package/lib/style-board.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* THE STYLE BOARD AND THE LINEUP: pictures the engine draws for free (lib/render3d.mjs).
|
|
3
4
|
*
|
|
@@ -77,10 +78,10 @@ export async function styleBoard(root, id, { prompt = '', log = () => {}, librar
|
|
|
77
78
|
if (!doc) { initDecisions(root, id, { prompt, path: 'hands-on' }); doc = readDecisions(root, id); }
|
|
78
79
|
const { directions } = directionsFor(root, id, { prompt: prompt || doc.prompt });
|
|
79
80
|
let game = {};
|
|
80
|
-
try { game = JSON.parse(readFileSync(
|
|
81
|
+
try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
|
|
81
82
|
const title = game.name ?? id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
82
83
|
const words = [prompt, doc.prompt, (doc.decisions['cast.list']?.value ?? []).map((c) => c.id).join(' ')].join(' ');
|
|
83
|
-
const dir = join(root,
|
|
84
|
+
const dir = join(experienceDir(root, id), BOARD_DIR);
|
|
84
85
|
mkdirSync(dir, { recursive: true });
|
|
85
86
|
const pieces = {};
|
|
86
87
|
if (library) for (const d of directions) if (!pieces[d.family]) pieces[d.family] = await libraryPieces(d.family, words, 4, log);
|
|
@@ -90,7 +91,7 @@ export async function styleBoard(root, id, { prompt = '', log = () => {}, librar
|
|
|
90
91
|
const url = await r.swatch(tokensOf(d.values), pieces[d.family] ?? [], { title });
|
|
91
92
|
const file = join(dir, `${d.id}-swatch.jpg`);
|
|
92
93
|
const bytes = await savePicture(url, file);
|
|
93
|
-
out.push({ ...d, swatch: relative(
|
|
94
|
+
out.push({ ...d, swatch: relative(experienceDir(root, id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
|
|
94
95
|
}
|
|
95
96
|
return r.renderer;
|
|
96
97
|
}, { log });
|
|
@@ -124,13 +125,13 @@ export async function recordMood(root, id, dirId, image, { usd = null, receipt =
|
|
|
124
125
|
const d = doc?.board?.directions?.find((x) => x.id === dirId);
|
|
125
126
|
if (!d) throw new Error(`no direction "${dirId}" on the style board (style board ${id} draws it first)`);
|
|
126
127
|
if (!existsSync(image)) throw new Error(`no picture at ${image}`);
|
|
127
|
-
const file = join(root,
|
|
128
|
+
const file = join(experienceDir(root, id), BOARD_DIR, `${dirId}-mood.jpg`);
|
|
128
129
|
const sharp = (await import('sharp')).default;
|
|
129
130
|
let q = 82;
|
|
130
131
|
let out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
131
132
|
while (out.byteLength > PICTURE_MAX && q > 50) { q -= 10; out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer(); }
|
|
132
133
|
writeFileSync(file, out);
|
|
133
|
-
d.mood = { path: relative(
|
|
134
|
+
d.mood = { path: relative(experienceDir(root, id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
|
|
134
135
|
writeDecisions(root, id, doc);
|
|
135
136
|
return { ok: true, command: 'style mood', id, direction: dirId, mood: d.mood };
|
|
136
137
|
}
|
|
@@ -143,14 +144,14 @@ export async function addGolden(root, id, image, { from = null } = {}) {
|
|
|
143
144
|
doc.golden = doc.golden ?? [];
|
|
144
145
|
if (doc.golden.length >= 6) throw new Error('six golden images already: remove one first (style golden <id> remove <n>)');
|
|
145
146
|
const n = doc.golden.reduce((m, g) => Math.max(m, Number(/(\d+)\.jpg$/.exec(g.path)?.[1] ?? 0)), 0) + 1;
|
|
146
|
-
const dir = join(root,
|
|
147
|
+
const dir = join(experienceDir(root, id), BOARD_DIR, 'golden');
|
|
147
148
|
mkdirSync(dir, { recursive: true });
|
|
148
149
|
const file = join(dir, `${n}.jpg`);
|
|
149
150
|
try {
|
|
150
151
|
const sharp = (await import('sharp')).default;
|
|
151
152
|
writeFileSync(file, await sharp(readFileSync(image)).resize(1024, 1024, { fit: 'inside' }).jpeg({ quality: 84, mozjpeg: true }).toBuffer());
|
|
152
153
|
} catch { if (/\.jpe?g$/i.test(extname(image))) copyFileSync(image, file); else throw new Error('sharp could not read that picture'); }
|
|
153
|
-
doc.golden.push({ path: relative(
|
|
154
|
+
doc.golden.push({ path: relative(experienceDir(root, id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
|
|
154
155
|
writeDecisions(root, id, doc);
|
|
155
156
|
addLatestLine(root, id, `Added golden image ${n} (from ${from ?? relative(root, image)}): every concept is made with it as a reference.`);
|
|
156
157
|
return { ok: true, command: 'style golden', id, golden: doc.golden };
|
|
@@ -183,13 +184,13 @@ export async function assetsLineup(root, id, { log = () => {}, scope = null } =
|
|
|
183
184
|
for (const a of manifest.assets) {
|
|
184
185
|
const f = (a.files ?? []).find((x) => x.role === 'model');
|
|
185
186
|
if (!f) continue;
|
|
186
|
-
const abs = join(root,
|
|
187
|
+
const abs = join(experienceDir(root, id), f.path);
|
|
187
188
|
if (!existsSync(abs)) continue;
|
|
188
189
|
// `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
|
|
189
190
|
// lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
|
|
190
191
|
const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
|
|
191
192
|
// A character stands in its idle pose from its skeleton's clip library (not its bind pose: arms out in a T).
|
|
192
|
-
const anims = a.rig?.anims && existsSync(join(root,
|
|
193
|
+
const anims = a.rig?.anims && existsSync(join(experienceDir(root, id), a.rig.anims)) ? readFileSync(join(experienceDir(root, id), a.rig.anims)).toString('base64') : null;
|
|
193
194
|
models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(anims ? { anims, pose: 'idle', poseAt: 0.3 } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
|
|
194
195
|
}
|
|
195
196
|
if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
|
|
@@ -1989,6 +1989,47 @@
|
|
|
1989
1989
|
}
|
|
1990
1990
|
}
|
|
1991
1991
|
}
|
|
1992
|
+
},
|
|
1993
|
+
"0.37.0": {
|
|
1994
|
+
"files": {
|
|
1995
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1996
|
+
"README.md": "684b3cca8001673c",
|
|
1997
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1998
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1999
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2000
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2001
|
+
"posts/README.md": "37841aae476bd540",
|
|
2002
|
+
"site/README.md": "732894273a32dc3f",
|
|
2003
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
2004
|
+
},
|
|
2005
|
+
"sections": {
|
|
2006
|
+
"AGENTS.md": {
|
|
2007
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2008
|
+
"## Layout": "8baf49e4291e7e88",
|
|
2009
|
+
"## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
|
|
2010
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
2011
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2012
|
+
"## Making games": "2da7fa74c8884834",
|
|
2013
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2014
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2015
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2016
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
2017
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2018
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2019
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2020
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2021
|
+
"## Beta": "9d02638207a26e73"
|
|
2022
|
+
}
|
|
2023
|
+
},
|
|
2024
|
+
"named": {
|
|
2025
|
+
"Homie Arcade": {
|
|
2026
|
+
"sections": {
|
|
2027
|
+
"AGENTS.md": {
|
|
2028
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
1992
2033
|
}
|
|
1993
2034
|
}
|
|
1994
2035
|
}
|
package/links/links.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export function randomId(): string;
|
|
2
|
+
export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
|
|
3
|
+
export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
|
package/links/links.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Public links, QR and HTTP-safe IDs; the encoder is shared with the wall shell. */
|
|
2
|
+
export { qrSvg } from '../worker/qr.mjs';
|
|
3
|
+
export function randomId() {
|
|
4
|
+
const bytes = new Uint8Array(16);
|
|
5
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
6
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
7
|
+
}
|
|
8
|
+
export function appLink(address, params = {}, base = globalThis.location?.href) {
|
|
9
|
+
const url = new URL(address, base);
|
|
10
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('links use an HTTP(S) address without credentials');
|
|
11
|
+
for (const [key, value] of Object.entries(params)) {
|
|
12
|
+
if (value === null || value === undefined) url.searchParams.delete(key);
|
|
13
|
+
else url.searchParams.set(key, String(value));
|
|
14
|
+
}
|
|
15
|
+
return url.href;
|
|
16
|
+
}
|