@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/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,22 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.37.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.38.0** · [#72](https://github.com/homie-rocks/homie/pull/72)
|
|
23
|
+
|
|
24
|
+
A studio holds apps beside its games, with public player cards and a shop you can find.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Apps are a studio kind: `apps/<id>/app.json`, `app new` / `app_make`, one morphing screen with roles and parts, and a 3D welcome queue starter. Apps share game build, rooms, screen, shop, parts and standalone paths; their buttons say Open and Scan to join.
|
|
29
|
+
- App records outlast rooms in the studio's D1, with validated fields, role permissions, account grants, private staff addresses and optimistic updates. Public links/QR and cryptographic IDs work on LAN HTTP; `dev --lan` prints phone-reachable addresses.
|
|
30
|
+
- Local rules rebuilds retry transient wall-clock timing rejections up to three times, keeping the last good build and the existing performance guard.
|
|
31
|
+
- App checks exercise a real action across a wall and two phones and prove reconnect without requiring rounds. The app skill and scaffold guidance cover roles, single-screen composition, engine/media reuse and customer store builds. Parts now include app components, music and video with explicit intended uses and the existing licence checks; sharing adds no selling flow.
|
|
32
|
+
|
|
33
|
+
- Public apps carry the same player card as games, opening their own screen and using their own picture and wording.
|
|
34
|
+
- An open shop has a visible Shop button above the play/open screen, with room reserved for it. Set `screen.shop` to `false` to hide it; `createShop().open()` or `.open(item)` opens the same shop from your own button. Closed shops and experiences with nothing for sale show no control.
|
|
35
|
+
|
|
20
36
|
## [0.36.0] - 2026-10-08
|
|
21
37
|
|
|
22
38
|
**Plugin 0.37.0** · [#68](https://github.com/homie-rocks/homie/pull/68) · [#73](https://github.com/homie-rocks/homie/pull/73)
|
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @homie-rocks/studio
|
|
2
2
|
|
|
3
|
-
A studio for games, music and video on its own Cloudflare. One repository (`AGENTS.md`,
|
|
4
|
-
`games/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
|
|
3
|
+
A studio for games, apps, music and video on its own Cloudflare. One repository (`AGENTS.md`,
|
|
4
|
+
`games/`, `apps/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
|
|
5
5
|
contract (every browser renders, strangers meet in public rooms, bots fill seats, rounds
|
|
6
6
|
restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
|
|
7
7
|
to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
|
|
8
8
|
|
|
9
|
+
Apps are one morphing screen, its roles and its parts: a customer phone, a staff tablet and a wall can share a 3D scene without rounds or scores. `homie-studio app new welcome` creates a working welcome queue in `apps/welcome/`; install its declared dependencies, build, then use `dev --lan` and the printed Wi-Fi address. `check welcome --url <origin>` proves a shared action and reconnect across three screens. Staff permissions, lasting D1 records, public QR/link helpers and customer standalone builds use the existing studio machinery. See [the app guide](apps/APPS.md).
|
|
10
|
+
|
|
9
11
|
Most people never run this by hand: the Homie plugin for Claude Code, Codex and Grok does,
|
|
10
12
|
and the person approves Cloudflare once in their browser.
|
|
11
13
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "welcome",
|
|
3
|
+
"name": "Welcome together",
|
|
4
|
+
"blurb": "A little place to gather. Join the queue, keep your ticket, and see when it is your turn.",
|
|
5
|
+
"entry": "src/main.ts",
|
|
6
|
+
"roles": {
|
|
7
|
+
"customer": {
|
|
8
|
+
"signIn": false,
|
|
9
|
+
"can": [
|
|
10
|
+
"read:queue",
|
|
11
|
+
"create:queue"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"wall": {
|
|
15
|
+
"signIn": false,
|
|
16
|
+
"can": [
|
|
17
|
+
"read:queue"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"staff": {
|
|
21
|
+
"signIn": true,
|
|
22
|
+
"can": [
|
|
23
|
+
"read:queue",
|
|
24
|
+
"update:queue",
|
|
25
|
+
"delete:queue"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"surfaces": {
|
|
30
|
+
"phone": "customer",
|
|
31
|
+
"wall": "wall",
|
|
32
|
+
"kiosk": "customer",
|
|
33
|
+
"tablet": "staff"
|
|
34
|
+
},
|
|
35
|
+
"words": {
|
|
36
|
+
"open": "Open",
|
|
37
|
+
"join": "Scan to join",
|
|
38
|
+
"one": "person",
|
|
39
|
+
"many": "people"
|
|
40
|
+
},
|
|
41
|
+
"records": {
|
|
42
|
+
"persist": true,
|
|
43
|
+
"collections": {
|
|
44
|
+
"queue": {
|
|
45
|
+
"fields": {
|
|
46
|
+
"label": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"required": true,
|
|
49
|
+
"maxLength": 24
|
|
50
|
+
},
|
|
51
|
+
"status": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"required": true,
|
|
54
|
+
"enum": [
|
|
55
|
+
"waiting",
|
|
56
|
+
"called"
|
|
57
|
+
],
|
|
58
|
+
"initial": "waiting"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"parts": [
|
|
65
|
+
"queue",
|
|
66
|
+
"ticket",
|
|
67
|
+
"welcome-scene"
|
|
68
|
+
],
|
|
69
|
+
"netplay": {
|
|
70
|
+
"v": 1,
|
|
71
|
+
"public": true,
|
|
72
|
+
"version": "1",
|
|
73
|
+
"params": [
|
|
74
|
+
"role",
|
|
75
|
+
"surface",
|
|
76
|
+
"ticket"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"playerCard": false,
|
|
80
|
+
"chat": false,
|
|
81
|
+
"agents": {
|
|
82
|
+
"vote": false
|
|
83
|
+
},
|
|
84
|
+
"watch": "overview",
|
|
85
|
+
"screen": {
|
|
86
|
+
"join": "bottom-right",
|
|
87
|
+
"share": "top-right"
|
|
88
|
+
},
|
|
89
|
+
"needs": {
|
|
90
|
+
"three": "0.185.1",
|
|
91
|
+
"@homie-rocks/camera": "0.2.1",
|
|
92
|
+
"@homie-rocks/geom": "0.1.1"
|
|
93
|
+
},
|
|
94
|
+
"check": {
|
|
95
|
+
"action": "[data-join]",
|
|
96
|
+
"observe": "[data-count]",
|
|
97
|
+
"surface": "phone"
|
|
98
|
+
},
|
|
99
|
+
"landing": {
|
|
100
|
+
"pitch": "A warm welcome. A place in line. One screen, together.",
|
|
101
|
+
"controls": {
|
|
102
|
+
"phone": "Join once and keep your ticket.",
|
|
103
|
+
"computer": "Staff call the next person from their signed-in tablet."
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Welcome together</title>
|
|
2
|
+
<style>
|
|
3
|
+
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#122f38;color:#fff4da;font-family:ui-rounded,system-ui,sans-serif}canvas{position:fixed;inset:0;width:100%;height:100%}main{position:fixed;inset:0;padding:clamp(22px,5vw,72px);pointer-events:none;display:flex;flex-direction:column;justify-content:space-between}header{max-width:580px}.eyebrow{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:#b7d8ce}h1{font-size:clamp(46px,7.5vw,108px);line-height:.94;letter-spacing:-.06em;margin:18px 0}p{line-height:1.5;max-width:31em;color:#cce0d4}.panel{pointer-events:auto;background:#fff4da;color:#163b40;width:min(390px,100%);border-radius:24px;padding:22px;box-shadow:0 18px 70px #061e3244;transform:translateY(0);transition:transform .7s,background .7s}.panel.called{background:#c6efb3;transform:translateY(-10px)}.number{font-size:clamp(34px,5vw,60px);font-weight:750;letter-spacing:-.05em}button{border:0;min-height:50px;border-radius:14px;padding:12px 20px;font:700 16px system-ui;background:#163b40;color:#fff4da;cursor:pointer;width:100%;touch-action:manipulation}button:disabled{opacity:.45;cursor:default}button:focus-visible{outline:3px solid #ffb655;outline-offset:4px}.panel p{color:#49655f;font-size:13px;margin:10px 0}.line{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.tag{font:600 12px system-ui;text-transform:uppercase;letter-spacing:.1em}#ticket-qr{width:80px;height:80px;float:right;margin:0 0 6px 14px}#ticket-qr:empty{display:none}#ticket-qr svg{width:100%;height:100%}.live{display:flex;gap:8px;align-items:center;font-size:12px}.dot{width:7px;height:7px;background:#c6efb3;border-radius:50%}#status{min-height:18px;font-size:12px}body.wall .panel{width:min(510px,45vw)}body.wall header{max-width:800px}body.staff .panel{width:420px}footer{position:absolute;right:35px;bottom:24px;font-size:10px;letter-spacing:.15em;color:#9fbbb4}@media(max-width:600px){main{padding:78px 22px 26px}h1{font-size:48px}header p{font-size:13px;max-width:245px}.panel{padding:18px}footer{display:none}}@media(max-height:520px){main{flex-direction:row;align-items:center;padding:60px 24px 20px;gap:30px}h1{font-size:40px}.panel{max-width:310px}header p{display:none}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
4
|
+
</style></head><body><canvas aria-hidden="true"></canvas><main><header><div class="eyebrow">A space for everyone</div><h1>Good things<br>start here.</h1><p id="intro">Take a little moment. We’ll keep your place while you settle in.</p><div class="live"><i class="dot"></i><span id="connection">Joining the room…</span></div></header><section class="panel" aria-label="Your place"><div id="ticket-qr"></div><div class="line"><span class="tag" id="panel-label">Welcome in</span><span><b data-count>0</b> waiting</span></div><div class="number" id="ticket">Make yourself at home.</div><p id="detail">No account needed. Just a place in line.</p><button data-join>Join the queue</button><button data-next hidden>Call the next person</button><div id="status" role="status" aria-live="polite"></div></section><footer>ONE PLACE · TOGETHER</footer></main><script type="module" src="./assets/main.js"></script></body></html>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { damp1 } from '@homie-rocks/camera/spring.js';
|
|
3
|
+
import { chamferBox } from '@homie-rocks/geom/chamfer.js';
|
|
4
|
+
import { createNetplay } from '@homie-rocks/studio/netplay';
|
|
5
|
+
import { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
|
|
6
|
+
import { appLink, qrSvg, randomId } from '@homie-rocks/studio/links';
|
|
7
|
+
|
|
8
|
+
type Ticket = { label: string; status: string };
|
|
9
|
+
const net = createNetplay<{ light: number }>({ game: 'welcome', maxPlayers: 32, snapshotHz: 2 });
|
|
10
|
+
const role = net.params.role ?? 'customer';
|
|
11
|
+
document.body.className = role;
|
|
12
|
+
const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
|
|
13
|
+
const join = $<HTMLButtonElement>('[data-join]');
|
|
14
|
+
const next = $<HTMLButtonElement>('[data-next]');
|
|
15
|
+
const records = createAppRecords<Ticket>({ collection: 'queue' });
|
|
16
|
+
let rows: AppRecord<Ticket>[] = [];
|
|
17
|
+
let mine = net.params.ticket ?? '';
|
|
18
|
+
let pending = false;
|
|
19
|
+
let loaded = false;
|
|
20
|
+
let light = 0;
|
|
21
|
+
let refreshing = false;
|
|
22
|
+
join.hidden = role !== 'customer'; next.hidden = role !== 'staff';
|
|
23
|
+
if (role === 'wall') { $('h1').innerHTML = 'You’re in<br>good company.'; $('#intro').textContent = 'A warm welcome, one person at a time. Scan to find your place.'; }
|
|
24
|
+
if (role === 'staff') { $('h1').innerHTML = 'Make someone’s<br>day brighter.'; $('#intro').textContent = 'Your welcome desk. Call the next person when you are ready.'; }
|
|
25
|
+
function paint() {
|
|
26
|
+
const waiting = rows.filter((r) => r.data.status === 'waiting');
|
|
27
|
+
const called = rows.filter((r) => r.data.status === 'called').at(-1);
|
|
28
|
+
const own = rows.find((r) => r.id === mine);
|
|
29
|
+
$('[data-count]').textContent = String(waiting.length);
|
|
30
|
+
$('#panel-label').textContent = role === 'customer' ? (own ? 'Your ticket' : 'Welcome in') : 'Now welcoming';
|
|
31
|
+
$('#ticket').textContent = role === 'customer' ? own ? `No. ${own.data.label}` : 'Make yourself at home.' : called ? `No. ${called.data.label}` : 'Ready when you are.';
|
|
32
|
+
$('#detail').textContent = own?.data.status === 'called' ? 'It’s your turn. Come on over!' : own ? `You have a place. ${Math.max(0, waiting.findIndex((r) => r.id === mine))} ahead of you.` : role === 'customer' ? 'No account needed. Just a place in line.' : waiting.length ? `${waiting.length} ${waiting.length === 1 ? 'person is' : 'people are'} waiting for a welcome.` : 'The next arrival will appear here.';
|
|
33
|
+
$('.panel').classList.toggle('called', own?.data.status === 'called' || (role !== 'customer' && !!called));
|
|
34
|
+
join.hidden = role !== 'customer' || !!own;
|
|
35
|
+
join.disabled = pending || !loaded; next.disabled = pending || !waiting.length;
|
|
36
|
+
}
|
|
37
|
+
async function refresh() {
|
|
38
|
+
if (refreshing) return; refreshing = true;
|
|
39
|
+
try { rows = await records.list(); loaded = true; $('#status').textContent = ''; paint(); }
|
|
40
|
+
catch (e) { $('#status').textContent = (e as Error).message; }
|
|
41
|
+
finally { refreshing = false; }
|
|
42
|
+
}
|
|
43
|
+
async function action(fn: () => Promise<unknown>) {
|
|
44
|
+
pending = true; paint();
|
|
45
|
+
try { await fn(); net.send('records-changed'); await refresh(); }
|
|
46
|
+
catch (e) { $('#status').textContent = (e as Error).message; }
|
|
47
|
+
finally { pending = false; paint(); }
|
|
48
|
+
}
|
|
49
|
+
join.onclick = () => action(async () => {
|
|
50
|
+
mine = randomId();
|
|
51
|
+
const label = mine.slice(0, 4).toUpperCase();
|
|
52
|
+
await records.create({ label, status: 'waiting' }, mine);
|
|
53
|
+
// Build a customer link from a clean address, never from a private staff URL.
|
|
54
|
+
const origin = new URL((window as any).HOMIE_NET.url.replace(/^ws/, 'http')).origin;
|
|
55
|
+
const url = appLink(`/${(window as any).HOMIE_NET.url.split('/')[3]}/open`, { ticket: mine, room: (window as any).HOMIE_NET.room }, origin);
|
|
56
|
+
$('#ticket-qr').innerHTML = qrSvg(url, { title: 'Keep your ticket' });
|
|
57
|
+
parent.postMessage({ type: 'homie-app-ticket', ticket: mine }, '*');
|
|
58
|
+
});
|
|
59
|
+
next.onclick = () => action(async () => {
|
|
60
|
+
const first = rows.find((r) => r.data.status === 'waiting');
|
|
61
|
+
if (first) await records.update(first, { ...first.data, status: 'called' });
|
|
62
|
+
});
|
|
63
|
+
// The room carries scene state and change notices; records always come from the authorized database.
|
|
64
|
+
net.on('event', (e) => { if (e.k === 'records-changed') { if (net.role === 'host') net.send('records-changed'); void refresh(); } });
|
|
65
|
+
net.on('snapshot', (s) => { light = s.d.light; });
|
|
66
|
+
net.on('link', () => { void refresh(); });
|
|
67
|
+
setInterval(() => { if (net.role === 'host') { light = net.now() / 1000; net.snapshot({ light }); } $('#connection').textContent = net.link === 'online' ? 'Here together · live' : 'Reconnecting…'; }, 500);
|
|
68
|
+
setInterval(() => void refresh(), 2000); // recover a missed notice; no dependency on a trusted browser host
|
|
69
|
+
void refresh(); paint();
|
|
70
|
+
|
|
71
|
+
// A single sculptural scene. Queue arrivals unfold the lanterns; the camera follows your context.
|
|
72
|
+
const renderer = new THREE.WebGLRenderer({ canvas: $('canvas'), antialias: true, alpha: false });
|
|
73
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)); renderer.setClearColor('#122f38');
|
|
74
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
75
|
+
const scene = new THREE.Scene(); scene.fog = new THREE.Fog('#122f38', 12, 30);
|
|
76
|
+
const camera = new THREE.PerspectiveCamera(37, 1, .1, 60);
|
|
77
|
+
scene.add(new THREE.HemisphereLight('#fff1ce', '#214f5b', 3));
|
|
78
|
+
const sun = new THREE.DirectionalLight('#ffd2a2', 5); sun.position.set(-3, 8, 6); scene.add(sun);
|
|
79
|
+
const mat = (color: string, metalness = 0) => new THREE.MeshStandardMaterial({ color, roughness: .45, metalness });
|
|
80
|
+
const cream = mat('#f4d8a4'); const green = mat('#9dbda0'); const gold = mat('#e8a953', .35);
|
|
81
|
+
const stage = new THREE.Group(); scene.add(stage); stage.position.set(1.5, -.9, 0);
|
|
82
|
+
const plinth = new THREE.Mesh(new THREE.CylinderGeometry(3.5, 3.7, .35, 64), mat('#254952')); plinth.position.y = -.4; stage.add(plinth);
|
|
83
|
+
const ring = new THREE.Mesh(new THREE.TorusGeometry(2.4, .055, 10, 90), gold); ring.rotation.x = Math.PI / 2; ring.position.y = -.15; stage.add(ring);
|
|
84
|
+
const arch = new THREE.Mesh(new THREE.TorusGeometry(1.65, .18, 16, 64, Math.PI), cream); arch.position.set(0, .15, -.8); stage.add(arch);
|
|
85
|
+
for (const x of [-1.65, 1.65]) { const leg = new THREE.Mesh(chamferBox(.36, 1.5, .36, .08), cream); leg.position.set(x, -.55, -.8); stage.add(leg); }
|
|
86
|
+
const lanterns: THREE.Group[] = [];
|
|
87
|
+
for (let i = 0; i < 12; i++) {
|
|
88
|
+
const g = new THREE.Group(); const body = new THREE.Mesh(chamferBox(.42, .7, .42, .08), i % 3 ? green : gold); g.add(body);
|
|
89
|
+
const cap = new THREE.Mesh(new THREE.SphereGeometry(.14, 16, 12), cream); cap.position.y = .5; g.add(cap);
|
|
90
|
+
const angle = i * Math.PI * 2 / 12; g.position.set(Math.sin(angle) * 2.35, 0, Math.cos(angle) * 2.35); stage.add(g); lanterns.push(g);
|
|
91
|
+
}
|
|
92
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
93
|
+
const vel = { v: 0 }; let distance = 10; let last = performance.now();
|
|
94
|
+
function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
|
|
95
|
+
addEventListener('resize', resize); resize();
|
|
96
|
+
function draw(now: number) {
|
|
97
|
+
requestAnimationFrame(draw); const dt = Math.min(.05, (now - last) / 1000); last = now;
|
|
98
|
+
const waiting = rows.filter((r) => r.data.status === 'waiting').length;
|
|
99
|
+
distance = damp1(distance, mine ? 8.7 : 10.8, vel, .8, dt);
|
|
100
|
+
const phone = innerWidth < 600;
|
|
101
|
+
camera.position.set(phone ? 7 : 6, phone ? 6 : 5, distance); camera.lookAt(phone ? 1 : -.4, .6, 0);
|
|
102
|
+
stage.position.x = phone ? 1.8 : 2.4;
|
|
103
|
+
stage.rotation.y = reduced.matches ? 0 : Math.sin(light * .12) * .09;
|
|
104
|
+
lanterns.forEach((g, i) => { const target = i < waiting ? 1 : .28; g.scale.y += (target - g.scale.y) * (1 - Math.exp(-dt * 5)); g.position.y = .18 + (reduced.matches ? 0 : Math.sin(now / 1000 + i) * .05); });
|
|
105
|
+
renderer.render(scene, camera);
|
|
106
|
+
}
|
|
107
|
+
requestAnimationFrame(draw);
|
package/apps/APPS.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# One screen, roles, parts
|
|
2
|
+
|
|
3
|
+
`homie-studio app new welcome` writes `apps/welcome/app.json` and the welcome scene. Games and apps share one ID namespace, bundle/room/TV/watch pipeline, parts, shop and standalone builds. Source apps build into the compatibility asset namespace `site/dist/games/<id>`; `games.json` uses `kind: "app"`. Public manifests expose `apps[]` with `open` and `wall` addresses separately from `games[]`.
|
|
4
|
+
|
|
5
|
+
Open `/<id>/open`, its landing `/<id>/`, or its wall `/<id>/tv`. `?surface=kiosk` and `?role=customer` choose a declared public presentation. The phone surface must be public for standalone customer builds. Every app remains one view that transforms; roles choose what is visible and permitted, not a menu of pages.
|
|
6
|
+
|
|
7
|
+
## Lasting records
|
|
8
|
+
|
|
9
|
+
The studio's existing D1 stores app records independently of room checkpoints. Deploy/dev apply `0014_studio_apps.sql`; old studios gain it through the normal migration helper. Previews without D1 return a clear unavailable response, never pretend to persist. Room saves still expire according to the room contract.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createAppRecords } from '@homie-rocks/studio/apps';
|
|
13
|
+
const queue = createAppRecords<{ label: string; status: string }>({ collection: 'queue' });
|
|
14
|
+
const rows = await queue.list();
|
|
15
|
+
await queue.create({ label: 'A17', status: 'waiting' });
|
|
16
|
+
await queue.update(rows[0], { ...rows[0].data, status: 'called' });
|
|
17
|
+
await queue.remove(rows[0]);
|
|
18
|
+
queue.dispose();
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The shell handles requests only from its own frame and uses its own role, link and account, never authority claimed by the message. The Worker checks every request. Roles declare `signIn: boolean` and `can: ["read:queue", "create:queue", "update:queue", "delete:queue"]`. Collections declare fields with `type` (string/number/boolean), optional `required`, `maxLength`, `enum`, and `initial` (the only value allowed on create). Unknown fields are refused. Records are at most 8 KB, collection reads at most 1,000 records, request bodies at most 12 KB. A full collection refuses creates. This is a small operational helper, not an unbounded ledger.
|
|
22
|
+
|
|
23
|
+
Generate a fresh ID for every new record; never reuse a deleted ID. Updates and deletes compare `version` atomically. Conflicts return 409; reload and resolve them. A successful write is committed before acknowledgement. Records are scoped by app and collection, not by room. Deleting a room does not delete records. With `records.persist: false`, use netplay keyed state for transient data instead. Database failures are visible and never replaced with fabricated local success.
|
|
24
|
+
|
|
25
|
+
Use existing netplay events for refresh notices and shared animation. A missed or malicious notice cannot change authoritative records: reload them on reconnect and at a bounded fallback interval. The starter polls every two seconds and accelerates on room notices. Browser hosts remain untrusted for business data. Public collection reads reveal the whole public collection; ticket IDs and UI filtering are not access control. Use separate protected collections for private data.
|
|
26
|
+
|
|
27
|
+
## Staff
|
|
28
|
+
|
|
29
|
+
`homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. The command reuses the office's short-lived owner key. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. Every record call rechecks the grant, so revocation applies to open tablets too.
|
|
30
|
+
|
|
31
|
+
The private address hides the staff entrance; it is not a bearer permission. It is never in public metadata. Passkeys require HTTPS or localhost. `dev --lan` exposes the local site to the same Wi-Fi, but does not weaken staff authentication. On local HTTP, provision test sessions explicitly in tests or use a secure local origin for real sign-in.
|
|
32
|
+
|
|
33
|
+
## Links and plain HTTP
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { appLink, qrSvg, randomId } from '@homie-rocks/studio/links';
|
|
37
|
+
const ticket = randomId(); // 128 cryptographic bits through getRandomValues
|
|
38
|
+
const url = appLink('/welcome/open', { ticket, room: 'main' }, 'https://studio.example');
|
|
39
|
+
const svg = qrSvg(url, { title: 'Your ticket' });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The QR encoder is the existing wall encoder, publicly exported with types. Links allow HTTP(S), preserve parameters and encode values. Do not copy `access` or any other private parameter into a public ticket. The helper works on LAN HTTP, where `crypto.randomUUID` may not exist.
|
|
43
|
+
|
|
44
|
+
## Parts, check and stores
|
|
45
|
+
|
|
46
|
+
Parts import exactly as games do (`@parts/<id>` or `@parts/<host>/<id>`), with the same provenance and credit checks. `check.action` and `check.observe` name real UI selectors: the checker clicks on a phone, waits for changed text on wall and both phones, then reloads the actor and checks again. `check.params` optionally supplies public URL parameters (for example `{ "ticket": "404" }`) to all three surfaces so the action can address an existing sample record. The checker always chooses its own room. No round hooks are required. This performs the declared action and writes real records: use a local/test studio or a workflow explicitly safe to exercise.
|
|
47
|
+
|
|
48
|
+
`homie-studio standalone plan/build <app-id>` reuses Electron/Capacitor and the same built bundle. Public customer record calls use the existing standalone origin allowlist; credentials are never shared across origins. Native staff sign-in, private collections, offline record writes, native billing and automatic store upload are not implemented. Existing game standalone behavior is unchanged. A store build still needs its normal local SDK, signing and store review.
|
|
49
|
+
|
|
50
|
+
## Shop button on the screen
|
|
51
|
+
|
|
52
|
+
An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { createShop } from '@homie-rocks/studio/shop';
|
|
56
|
+
const shop = createShop();
|
|
57
|
+
shopButton.onclick = () => shop.open();
|
|
58
|
+
itemButton.onclick = () => shop.open('supporter');
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
|
package/apps/apps.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Lasting records through the same sandbox bridge used by saves and the shop. */
|
|
2
|
+
import { randomId } from '../links/links.mjs';
|
|
3
|
+
export interface AppRecord<T> { id: string; data: T; version: number; updatedAt: number }
|
|
4
|
+
export function createAppRecords<T extends Record<string, unknown>>({ collection }: { collection: string }) {
|
|
5
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(collection)) throw new Error('invalid collection');
|
|
6
|
+
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
7
|
+
const receive = (e: MessageEvent) => {
|
|
8
|
+
if (e.source !== parent || e.data?.type !== 'homie-app-result') return;
|
|
9
|
+
const p = pending.get(e.data.id); if (!p) return;
|
|
10
|
+
pending.delete(e.data.id); clearTimeout(p.timer);
|
|
11
|
+
if (e.data.result?.ok) p.resolve(e.data.result); else p.reject(new Error(e.data.result?.error ?? 'app request failed'));
|
|
12
|
+
};
|
|
13
|
+
addEventListener('message', receive);
|
|
14
|
+
const ask = (method: string, record?: string, data?: T, version?: number): Promise<any> => new Promise((resolve, reject) => {
|
|
15
|
+
const id = randomId();
|
|
16
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('app shell did not answer')); }, 15000);
|
|
17
|
+
pending.set(id, { resolve, reject, timer });
|
|
18
|
+
parent.postMessage({ type: 'homie-app', id, collection, method, record, data, version }, '*');
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
async list(): Promise<AppRecord<T>[]> { return (await ask('GET')).records; },
|
|
22
|
+
async get(id: string): Promise<AppRecord<T> | null> { return (await ask('GET', id)).records[0] ?? null; },
|
|
23
|
+
create: (data: T, id = randomId()) => ask('POST', id, data),
|
|
24
|
+
update: (record: AppRecord<T>, data: T) => ask('PUT', record.id, data, record.version),
|
|
25
|
+
remove: (record: AppRecord<T>) => ask('DELETE', record.id, undefined, record.version),
|
|
26
|
+
dispose() { removeEventListener('message', receive); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('disposed')); } pending.clear(); },
|
|
27
|
+
};
|
|
28
|
+
}
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* this toolkit as a local MCP server (stdio): the Claude desktop app's Homie
|
|
14
14
|
* extension, Claude Code or any MCP client builds in the same chat that shows
|
|
15
15
|
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
16
|
+
* homie-studio app new <id> [--name "<App Name>"]
|
|
17
|
+
* homie-studio app role <id> <role> [--player <account>] [--revoke] [--url <site>]
|
|
16
18
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
17
19
|
* homie-studio games
|
|
18
20
|
* homie-studio build [<id>] [--maps] [--types] [--long-check] (--maps: also keep each bundle's source map and
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
* homie-studio preview <id> [--port 8788] (one built game's files at an address on this computer, nothing else: no
|
|
29
31
|
* Wrangler, no rooms, no database; for a capture, a screenshot or a perf script.
|
|
30
32
|
* The game plays offline with its bots. Stop it with Ctrl-C)
|
|
31
|
-
* homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] [--timestamps] (--stop: stop exactly this studio's dev server,
|
|
33
|
+
* homie-studio dev [--lan] [--port 8787] [--remote-ai] [--no-local-ai] [--timestamps] (--stop: stop exactly this studio's dev server,
|
|
32
34
|
* nothing else, and clear a stale record; rooms here are always local: production
|
|
33
35
|
* routes in wrangler.jsonc are left out; a game built while it runs is picked up;
|
|
34
36
|
* --timestamps: a time on every line of Wrangler's, not only the room and error ones;
|
|
@@ -336,7 +338,7 @@ import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeO
|
|
|
336
338
|
import { newStudio } from '../lib/scaffold.mjs';
|
|
337
339
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
338
340
|
import { whatsNewLines } from '../lib/changelog.mjs';
|
|
339
|
-
import { listGames, newGame, readStudio, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
341
|
+
import { listGames, newGame, newApp, readStudio, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
340
342
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
341
343
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
342
344
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
@@ -371,7 +373,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
|
|
|
371
373
|
|
|
372
374
|
const argv = process.argv.slice(2);
|
|
373
375
|
const flags = new Map();
|
|
374
|
-
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
376
|
+
const BOOL_FLAGS = ['lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
375
377
|
const positional = [];
|
|
376
378
|
for (let i = 0; i < argv.length; i++) {
|
|
377
379
|
const a = argv[i];
|
|
@@ -904,6 +906,13 @@ async function main() {
|
|
|
904
906
|
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
|
|
905
907
|
return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
|
|
906
908
|
}
|
|
909
|
+
if (cmd === 'app' && sub === 'role') {
|
|
910
|
+
const { withKey } = await import('../lib/office.mjs');
|
|
911
|
+
const id = positional[2]; const role = positional[3];
|
|
912
|
+
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '') || !/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error('app role <id> <role> [--player <account>] [--revoke] [--url <site>]');
|
|
913
|
+
return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke') }));
|
|
914
|
+
}
|
|
915
|
+
if (cmd === 'app' && sub === 'new') return newApp(root, positional[2], { name: flags.get('name') });
|
|
907
916
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
908
917
|
// Remix was retired (a game is never handed over whole): the command is refused in a sentence that says where to
|
|
909
918
|
// go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
|
|
@@ -931,7 +940,7 @@ async function main() {
|
|
|
931
940
|
}
|
|
932
941
|
// lib/dev.mjs: the dev server, its registration file, and what `--stop` will and will not signal.
|
|
933
942
|
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
934
|
-
if (cmd === 'dev') return dev(root, { port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
|
|
943
|
+
if (cmd === 'dev') return dev(root, { lan: flags.has('lan'), port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
|
|
935
944
|
if (cmd === 'check') {
|
|
936
945
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
937
946
|
const url = flags.get('url') ?? siteUrl(root);
|
package/lib/art-cli.mjs
CHANGED
|
@@ -49,14 +49,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
49
49
|
import { join, resolve } from 'node:path';
|
|
50
50
|
import { USAGES, licenceProblems, readManifest, removeAsset, rightsMarkdown, setUsage, syncCredits, usageOf, writeRights } from './asset-manifest.mjs';
|
|
51
51
|
import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
|
|
52
|
-
import { listGames } from './studio.mjs';
|
|
52
|
+
import { experienceDir, experienceFile, listExperiences as listGames } from './studio.mjs';
|
|
53
53
|
import { checkLines } from './asset-check.mjs';
|
|
54
54
|
import { animPlan as animPlanOf } from './characters.mjs';
|
|
55
55
|
|
|
56
56
|
/** The game a command means: the one named, else the only one. */
|
|
57
57
|
function gameOf(root, id, command) {
|
|
58
58
|
const games = listGames(root);
|
|
59
|
-
const planned = id && existsSync(join(root,
|
|
59
|
+
const planned = id && existsSync(join(experienceDir(root, id), 'CODEX.md'));
|
|
60
60
|
if (id && (games.some((g) => g.id === id) || planned)) return id;
|
|
61
61
|
if (!id && games.length === 1) return games[0].id;
|
|
62
62
|
throw new Error(`name the game: homie-studio ${command} <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}`);
|
package/lib/art-tools.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { animPlan } from './characters.mjs';
|
|
|
36
36
|
import { CATALOGUE_IDS, decisionRows, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
|
|
37
37
|
import { libraryBase, loadIndex, searchLibrary, itemThumb } from './library.mjs';
|
|
38
38
|
import { boardView } from './style-board.mjs';
|
|
39
|
-
import { listGames } from './studio.mjs';
|
|
39
|
+
import { experienceDir, experienceFile, listExperiences as listGames } from './studio.mjs';
|
|
40
40
|
|
|
41
41
|
export const ART_UI = Object.freeze({
|
|
42
42
|
style: 'ui://homie-studio/style',
|
|
@@ -55,7 +55,7 @@ function picture(h, file, max = 160 * 1024) {
|
|
|
55
55
|
|
|
56
56
|
function gameOf(root, id) {
|
|
57
57
|
const games = listGames(root);
|
|
58
|
-
if (id && (games.some((g) => g.id === id) || existsSync(join(root,
|
|
58
|
+
if (id && (games.some((g) => g.id === id) || existsSync(join(experienceDir(root, String(id)), 'CODEX.md')))) return String(id);
|
|
59
59
|
if (!id && games.length === 1) return games[0].id;
|
|
60
60
|
throw new Error(`which game? ${games.map((g) => g.id).join(', ') || 'there is none yet (game_plan or game_make first)'}`);
|
|
61
61
|
}
|
|
@@ -64,11 +64,11 @@ function gameOf(root, id) {
|
|
|
64
64
|
function styleData(h, root, game, extra = {}) {
|
|
65
65
|
const doc = readDecisions(root, game);
|
|
66
66
|
let title = null;
|
|
67
|
-
try { title = JSON.parse(readFileSync(
|
|
68
|
-
if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(root,
|
|
67
|
+
try { title = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).name ?? null; } catch { /* planned: its codex names it */ }
|
|
68
|
+
if (!title) { try { title = /^#\s+(.+)$/m.exec(readFileSync(join(experienceDir(root, game), 'CODEX.md'), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, ''))?.[1]?.trim() ?? null; } catch { title = null; } }
|
|
69
69
|
title = title ?? game;
|
|
70
70
|
const manifest = readManifest(root, game);
|
|
71
|
-
const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(root,
|
|
71
|
+
const dirs = doc ? boardView(root, game, doc).map((d) => ({ ...d, swatch: picture(h, d.swatch ? join(experienceDir(root, game), d.swatch) : null), mood: d.mood?.path ? { image: picture(h, join(experienceDir(root, game), d.mood.path)), usd: d.mood.usd ?? null, label: 'a painted target, not what the game draws' } : null })) : [];
|
|
72
72
|
const rows = doc ? decisionRows(doc) : [];
|
|
73
73
|
return {
|
|
74
74
|
kind: 'style', game, title, path: doc?.path ?? null, line: doc ? oneLine(doc) : null, budget: doc?.budget?.usd ?? null, chosen: doc?.board?.chosen ?? null,
|
|
@@ -98,7 +98,7 @@ async function characterThumb(h, root, game, row) {
|
|
|
98
98
|
try { const lib = await loadIndex({ lib: libraryBase() }); const it = lib.index.items.find((x) => x.id === m.from.item); const t = it ? await itemThumb(lib.lib, it) : null; if (t) return `data:image/webp;base64,${Buffer.from(t).toString('base64')}`; } catch { /* no library here */ }
|
|
99
99
|
}
|
|
100
100
|
const concept = (m?.files ?? []).find((f) => f.role === 'concept');
|
|
101
|
-
return concept ? picture(h, join(root,
|
|
101
|
+
return concept ? picture(h, join(experienceDir(root, game), concept.path), 60 * 1024) : null;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** Which Game Lab take tunes a verb (lab.json): one named for it or whose note says it, else the default. */
|
package/lib/asset-check.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio assets check <id>`: static, free, on every asset and on the whole game. What it adds up is an
|
|
3
4
|
* INVENTORY ESTIMATE: the recorded files' own triangles, draw calls and pictures times their declared placements. It
|
|
@@ -74,7 +75,7 @@ const tierOf = (a) => (a.kind === 'clip' ? 'clip' : BUDGETS[a.tier] ? a.tier : a
|
|
|
74
75
|
|
|
75
76
|
/** A game's game.json, or {} (a planned game has none yet). */
|
|
76
77
|
export function gameJson(root, id) {
|
|
77
|
-
try { return JSON.parse(readFileSync(
|
|
78
|
+
try { return JSON.parse(readFileSync(experienceFile(root, id), 'utf8')) ?? {}; } catch { return {}; }
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/*
|
|
@@ -238,7 +239,7 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
|
|
|
238
239
|
const doc = readDecisions(root, id);
|
|
239
240
|
const game = gameJson(root, id);
|
|
240
241
|
const budgets = budgetsFor(doc, game);
|
|
241
|
-
const gdir =
|
|
242
|
+
const gdir = experienceDir(root, id);
|
|
242
243
|
let tools = null;
|
|
243
244
|
try { tools = await import('./optimise.mjs'); } catch { tools = null; }
|
|
244
245
|
const rows = [];
|
package/lib/asset-import.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { experienceDir, experienceFile } from './studio.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* A MODEL FILE INTO A GAME: the person's own (imported), or one a provider generated on their account (the models
|
|
3
4
|
* skill's image-to-3D). `homie-studio assets add <id> --file <model> --license <kind>`.
|
|
@@ -54,7 +55,7 @@ export function screenImport(path) {
|
|
|
54
55
|
* the concept and mesh calls with their receipts and prices), slug (the art job's folder).
|
|
55
56
|
*/
|
|
56
57
|
export async function importModel(root, id, file, opts = {}) {
|
|
57
|
-
const gdir =
|
|
58
|
+
const gdir = experienceDir(root, id);
|
|
58
59
|
if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${id}"`);
|
|
59
60
|
const path = resolve(root, file);
|
|
60
61
|
const route = opts.route === 'generated' ? 'generated' : 'imported';
|
|
@@ -117,7 +118,7 @@ export async function importModel(root, id, file, opts = {}) {
|
|
|
117
118
|
* mesh fix it); one stale only on its budgets is not.
|
|
118
119
|
*/
|
|
119
120
|
export async function redoModel(root, id, assetId) {
|
|
120
|
-
const gdir =
|
|
121
|
+
const gdir = experienceDir(root, id);
|
|
121
122
|
const e = readManifest(root, id).assets.find((a) => a.id === assetId);
|
|
122
123
|
if (!e) throw new Error(`no asset "${assetId}" in games/${id}/assets/manifest.json`);
|
|
123
124
|
const raw = (e.files ?? []).find((f) => f.role === 'raw');
|
package/lib/asset-manifest.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { createHash } from 'node:crypto';
|
|
|
22
22
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { dirname, join, relative } from 'node:path';
|
|
24
24
|
import { pinByUse, readDecisions, revisionsFor } from './decisions.mjs';
|
|
25
|
-
import { GAME_ID } from './studio.mjs';
|
|
25
|
+
import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
|
|
26
26
|
|
|
27
27
|
export const MANIFEST_FILE = join('assets', 'manifest.json');
|
|
28
28
|
export const RIGHTS_FILE = join('assets', 'RIGHTS.md');
|
|
@@ -81,7 +81,7 @@ export function underFor(kind, route) {
|
|
|
81
81
|
return [...(UNDER[kind] ?? UNDER.prop), ...(route === 'library' ? ['cast.family'] : []), ...(route === 'generated' ? ['derived.prompt'] : [])];
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
const fileOf = (root, id) => join(root,
|
|
84
|
+
const fileOf = (root, id) => join(experienceDir(root, id), MANIFEST_FILE);
|
|
85
85
|
const now = () => new Date().toISOString();
|
|
86
86
|
const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : null);
|
|
87
87
|
|
|
@@ -167,7 +167,7 @@ export function removeAsset(root, id, assetId) {
|
|
|
167
167
|
writeManifest(root, id, manifest);
|
|
168
168
|
writeRights(root, id, manifest);
|
|
169
169
|
syncCredits(root, id, manifest);
|
|
170
|
-
const gdir = join(root,
|
|
170
|
+
const gdir = join(experienceDir(root, id));
|
|
171
171
|
const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
|
|
172
172
|
const deleted = []; const kept = [];
|
|
173
173
|
for (const f of gone.files ?? []) {
|
|
@@ -216,7 +216,7 @@ export function licenceProblems(root, id, manifest = readManifest(root, id)) {
|
|
|
216
216
|
|
|
217
217
|
/** Model files a game ships (.glb, .gltf, .fbx, .obj, .vrm under games/<id>/) that no manifest entry names. */
|
|
218
218
|
export function unrecordedModels(root, id, manifest = readManifest(root, id)) {
|
|
219
|
-
const dir = join(root,
|
|
219
|
+
const dir = join(experienceDir(root, id));
|
|
220
220
|
const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
|
|
221
221
|
const out = [];
|
|
222
222
|
const walk = (rel, depth) => {
|
|
@@ -245,7 +245,7 @@ const PICTURE = /\.(png|jpe?g|webp|avif|gif|hdr|exr|ktx2)$/i;
|
|
|
245
245
|
* The codex (private, the style board's own swatches) is not walked at all.
|
|
246
246
|
*/
|
|
247
247
|
export function unrecordedPictures(root, id, manifest = readManifest(root, id), { cover = null } = {}) {
|
|
248
|
-
const dir = join(root,
|
|
248
|
+
const dir = join(experienceDir(root, id));
|
|
249
249
|
const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
|
|
250
250
|
const coverPaths = new Set(cover ? [String(cover).replace(/^\.\//, ''), `public/${String(cover).replace(/^\.\//, '')}`] : []);
|
|
251
251
|
const shipped = []; const notShipped = [];
|
|
@@ -296,7 +296,7 @@ function routeWords(a) {
|
|
|
296
296
|
/** RIGHTS.md for one game, in plain words. */
|
|
297
297
|
export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
|
|
298
298
|
let game = {};
|
|
299
|
-
try { game = JSON.parse(readFileSync(
|
|
299
|
+
try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
|
|
300
300
|
const lines = [
|
|
301
301
|
`# Rights: ${game.name ?? id}`,
|
|
302
302
|
'',
|
|
@@ -333,7 +333,7 @@ function termsApply(t, a) {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
export function writeRights(root, id, manifest = readManifest(root, id)) {
|
|
336
|
-
const file = join(root,
|
|
336
|
+
const file = join(experienceDir(root, id), RIGHTS_FILE);
|
|
337
337
|
mkdirSync(dirname(file), { recursive: true });
|
|
338
338
|
const text = rightsMarkdown(root, id, manifest);
|
|
339
339
|
if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
|
|
@@ -347,7 +347,7 @@ export function writeRights(root, id, manifest = readManifest(root, id)) {
|
|
|
347
347
|
* and one per attribution-required file. Lines this function wrote carry `"asset": true`; every other part stays.
|
|
348
348
|
*/
|
|
349
349
|
export function syncCredits(root, id, manifest = readManifest(root, id)) {
|
|
350
|
-
const file = join(root,
|
|
350
|
+
const file = join(experienceDir(root, id), 'credits.json');
|
|
351
351
|
let credits = {};
|
|
352
352
|
try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
|
|
353
353
|
const before = Array.isArray(credits.parts) ? credits.parts : [];
|