@homie-rocks/studio 0.35.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -2
  3. package/agents/agents.ts +8 -6
  4. package/app-starters/welcome/app.json +106 -0
  5. package/app-starters/welcome/index.html +4 -0
  6. package/app-starters/welcome/src/main.ts +107 -0
  7. package/apps/APPS.md +61 -0
  8. package/apps/apps.ts +28 -0
  9. package/bin/homie-studio.mjs +22 -10
  10. package/lab/lab.ts +15 -0
  11. package/lib/art-cli.mjs +2 -2
  12. package/lib/art-tools.mjs +6 -6
  13. package/lib/asset-check.mjs +3 -2
  14. package/lib/asset-import.mjs +3 -2
  15. package/lib/asset-manifest.mjs +8 -8
  16. package/lib/build.mjs +26 -18
  17. package/lib/characters.mjs +7 -7
  18. package/lib/check.mjs +49 -0
  19. package/lib/cloudflare.mjs +1 -1
  20. package/lib/codex.mjs +2 -2
  21. package/lib/collision.mjs +1 -1
  22. package/lib/decisions.mjs +7 -7
  23. package/lib/dev.mjs +42 -10
  24. package/lib/directory.mjs +1 -1
  25. package/lib/doctor.mjs +1 -1
  26. package/lib/handoff.mjs +1 -1
  27. package/lib/lab-check.mjs +18 -3
  28. package/lib/lab.mjs +29 -15
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +23 -3
  31. package/lib/parts-build.mjs +4 -3
  32. package/lib/parts-cli.mjs +3 -3
  33. package/lib/parts-store.mjs +8 -7
  34. package/lib/parts-tools.mjs +7 -7
  35. package/lib/parts.mjs +19 -14
  36. package/lib/perf.mjs +2 -1
  37. package/lib/player-image.mjs +2 -2
  38. package/lib/rules-build.mjs +97 -49
  39. package/lib/rules-check-worker.mjs +405 -0
  40. package/lib/rules-check.mjs +69 -0
  41. package/lib/rules-compiler.mjs +30 -0
  42. package/lib/rules-guard.mjs +26 -7
  43. package/lib/rules-types.mjs +167 -0
  44. package/lib/scaffold.mjs +21 -2
  45. package/lib/shop.mjs +1 -1
  46. package/lib/standalone-files.mjs +2 -2
  47. package/lib/standalone.mjs +12 -3
  48. package/lib/stats.mjs +2 -1
  49. package/lib/studio.mjs +32 -6
  50. package/lib/style-board.mjs +10 -9
  51. package/lib/template-history.json +85 -1
  52. package/lib/typecheck.mjs +52 -3
  53. package/links/links.d.ts +3 -0
  54. package/links/links.mjs +16 -0
  55. package/netplay/NETPLAY.md +123 -6
  56. package/netplay/netplay.ts +289 -53
  57. package/netplay/rules-output.mjs +85 -0
  58. package/package.json +16 -4
  59. package/parts/PARTS.md +10 -5
  60. package/rules/browser.ts +100 -0
  61. package/rules/core.ts +159 -58
  62. package/rules/guard.ts +11 -4
  63. package/rules/host.ts +90 -15
  64. package/rules/math.ts +16 -0
  65. package/rules/pack.ts +94 -17
  66. package/rules/rules.ts +21 -13
  67. package/rules/types.ts +63 -0
  68. package/rules/view.ts +36 -33
  69. package/shop/SHOP.md +13 -0
  70. package/site/SITE.md +1 -1
  71. package/standalone/shell/shell.js +8 -6
  72. package/worker/account-page.mjs +4 -3
  73. package/worker/app-format.mjs +42 -0
  74. package/worker/app-records.mjs +100 -0
  75. package/worker/app-shell.mjs +29 -0
  76. package/worker/brain.mjs +2 -1
  77. package/worker/embed.mjs +4 -2
  78. package/worker/hosted.mjs +1 -1
  79. package/worker/index.mjs +40 -20
  80. package/worker/limits.mjs +41 -0
  81. package/worker/pages.mjs +37 -29
  82. package/worker/parts.mjs +11 -9
  83. package/worker/room.mjs +136 -63
  84. package/worker/schema.mjs +6 -5
  85. package/worker/seats.mjs +2 -2
  86. package/worker/shop-page.mjs +4 -3
  87. package/worker/site.mjs +49 -40
  88. package/worker/standalone.mjs +2 -2
  89. package/worker/version.mjs +1 -1
@@ -0,0 +1,85 @@
1
+ import { takeRulesToken } from '../worker/limits.mjs';
2
+
3
+ /**
4
+ * The hosting page's one way out for what its rules make. Reliable frames keep one order and each leaves as soon as
5
+ * its own kind has a token; only a snapshot may be superseded, and only a snapshot waits for the snapshot's token.
6
+ * Giving the role up is part of the same order: `handOver` follows the checkpoint through the queue and holds what
7
+ * is made after it until the relay answers, and `leave` sends the last checkpoint before the page goes.
8
+ */
9
+ export function rulesOutput({ rates, send, now, setTimer = setTimeout, clearTimer = clearTimeout }) {
10
+ const buckets = new Map();
11
+ const queue = [];
12
+ let snapshot = null, timer = null, stopped = false, finishing = false, held = false;
13
+ function budget(m, force = false) {
14
+ const rate = rates[m.t];
15
+ if (!rate) throw new Error(`Unknown rules output: ${m.t}`);
16
+ // The relay drops a surplus snapshot and nothing else, so snapshots spend its whole rate: an on-time tick's leaves
17
+ // with the tick. Reliable kinds keep a fifth of theirs clear of transport jitter and late timers.
18
+ const ms = m.t === 'snap' ? takeRulesToken(buckets, m.t, rate, now(), 2) : takeRulesToken(buckets, m.t, rate * 0.8, now(), rate);
19
+ // The relay's bucket holds twice this one: a forced last checkpoint is inside it.
20
+ if (ms && force) { buckets.get(m.t).tokens -= 1; return 0; }
21
+ return ms;
22
+ }
23
+ function drain() {
24
+ if (timer !== null) { clearTimer(timer); timer = null; }
25
+ if (stopped || held) return;
26
+ let wait = Infinity;
27
+ while (queue.length) {
28
+ const m = queue[0];
29
+ if (m.rules !== true) { queue.shift(); held = true; send(m); return; }
30
+ const ms = budget(m);
31
+ if (ms) { wait = ms; break; }
32
+ send(queue.shift());
33
+ }
34
+ if (snapshot) {
35
+ const ms = budget(snapshot);
36
+ if (ms) wait = Math.min(wait, ms);
37
+ else { const m = snapshot; snapshot = null; send(m); }
38
+ }
39
+ if (finishing && !queue.length) { stopped = true; return; }
40
+ if (wait !== Infinity) timer = setTimer(drain, Math.max(1, wait));
41
+ }
42
+ return {
43
+ push(frame) {
44
+ if (stopped || finishing) return;
45
+ const m = { ...frame, rules: true };
46
+ if (m.t === 'snap') snapshot = m; else queue.push(m);
47
+ drain();
48
+ },
49
+ finish(frame) {
50
+ if (stopped || finishing) return;
51
+ finishing = true; held = false; snapshot = null; queue.push({ ...frame, rules: true });
52
+ drain();
53
+ },
54
+ /** `yield`, in its place behind the checkpoint. What the rules make after it waits for the relay's answer. */
55
+ handOver(frame) {
56
+ if (stopped || finishing) return;
57
+ queue.push({ ...frame, t: 'yield' });
58
+ drain();
59
+ },
60
+ /** True from `handOver` until its `yield` is on the wire: the relay must hear nothing else that moves the role. */
61
+ get handing() { return !stopped && queue.some((m) => m.rules !== true); },
62
+ /** True while a yield is queued or unanswered. */
63
+ get waiting() { return !stopped && (held || queue.some((m) => m.rules !== true)); },
64
+ /** The relay kept this page as the host: what was held goes out, in order. */
65
+ release() { if (held) { held = false; drain(); } },
66
+ /**
67
+ * The page is going (it closes, or its rules host is closed). Everything its allowance permits is sent, in order;
68
+ * a frame that cannot be is covered by the checkpoint, which goes last and always goes.
69
+ */
70
+ leave() {
71
+ if (stopped) return;
72
+ if (timer !== null) { clearTimer(timer); timer = null; }
73
+ let last = null, blocked = held;
74
+ for (const m of queue) {
75
+ if (m.t === 'ckpt') { last = m; continue; }
76
+ if (m.rules !== true) { blocked = true; continue; }
77
+ if (!blocked && !budget(m)) send(m); else blocked = true;
78
+ }
79
+ if (last) { budget(last, true); send(last); }
80
+ held = false; queue.length = 0; snapshot = null;
81
+ },
82
+ /** The relay moved the role: it takes nothing more from this page as the host. */
83
+ stop() { stopped = true; if (timer !== null) clearTimer(timer); timer = null; queue.length = 0; snapshot = null; },
84
+ };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.35.1",
3
+ "version": "0.37.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -75,7 +75,15 @@
75
75
  "default": "./rules/host.ts"
76
76
  },
77
77
  "./worker": "./worker/index.mjs",
78
- "./package.json": "./package.json"
78
+ "./package.json": "./package.json",
79
+ "./links": {
80
+ "types": "./links/links.d.ts",
81
+ "default": "./links/links.mjs"
82
+ },
83
+ "./apps": {
84
+ "types": "./apps/apps.ts",
85
+ "default": "./apps/apps.ts"
86
+ }
79
87
  },
80
88
  "files": [
81
89
  "bin/",
@@ -99,7 +107,10 @@
99
107
  "README.md",
100
108
  "CHANGELOG.md",
101
109
  "LICENSE",
102
- "NOTICE"
110
+ "NOTICE",
111
+ "links/",
112
+ "apps/",
113
+ "app-starters/"
103
114
  ],
104
115
  "engines": {
105
116
  "node": ">=22"
@@ -119,7 +130,8 @@
119
130
  "meshoptimizer": "1.3.0",
120
131
  "puppeteer-core": "25.8.0",
121
132
  "sharp": "0.35.5",
122
- "three": "0.185.1"
133
+ "three": "0.185.1",
134
+ "typescript": "5.9.3"
123
135
  },
124
136
  "scripts": {
125
137
  "test": "node --test test/*.test.mjs"
package/parts/PARTS.md CHANGED
@@ -1,6 +1,6 @@
1
- # Game parts
1
+ # Studio parts
2
2
 
3
- Games build on each other by sharing **parts**: a piece of a game that its studio chooses to share, so somebody
3
+ Studios build on each other by sharing **parts**: a reusable piece of a game, app, music or video that its studio chooses to share, so somebody
4
4
  making another game can use it. A creature from one game, a level from another, a bot brain from a third. That is
5
5
  the whole idea. Without parts, a creator's AI has to come up with everything on its own.
6
6
 
@@ -8,7 +8,7 @@ Mashing up is not a command: it is making a game with parts from several games.
8
8
 
9
9
  ## The rules
10
10
 
11
- 1. **A part is a piece of a game, never the whole game.** Remix, which handed over an entire game, is retired;
11
+ 1. **A part is a reusable piece, never a whole game or app.** Remix, which handed over an entire game, is retired;
12
12
  parts are what replaces it.
13
13
  2. **A part can take any form** that suits the piece: code, assets, data, a JSON description, a tuned config, or a
14
14
  mix. Form is not restricted.
@@ -89,10 +89,11 @@ named after it):
89
89
  | Field | What it is |
90
90
  | --- | --- |
91
91
  | `id`, `name`, `kind`, `version`, `summary` | `version` is three numbers; a shared version never changes, so a change is a new version. `summary` is one sentence. |
92
- | `kind` | `character`, `rig`, `clips`, `environment`, `effect`, `sound`, `ui`, `mechanic`, `shader`, `level-generator`, `bot-brain`, `audio-pack`, `set-piece`. There is no kind for a whole game. |
92
+ | `kind` | `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` also name parts. There is no kind for a whole game or app. |
93
93
  | `license`, `attribution` | Needed to share: an SPDX identifier, and who a game using the part credits. |
94
94
  | `share` | **Default `false`.** The part's own switch, and the only thing that decides whether it is shared. |
95
- | `from` | The game and studio the part came out of. Written when a part is lifted out of a game. |
95
+ | `uses` | Optional intended uses: `game`, `app`, `venue`, `cause`, `music`, `video`. Describe the purpose, independently of licence and any future pricing. |
96
+ | `from` | The source and studio: `game`, `app`, `music` or `video` with its id. Written when a part is lifted out of a game. |
96
97
  | `entry` | For a part with code: the module a game imports. A part of assets or data has none. |
97
98
  | `files` | Every file with its SHA-256 and size, **written by the tool, never by hand**. A file may carry `"rights"` (below). |
98
99
  | `preview` | `page`: a self-contained HTML page shown in a sandboxed frame. `image`: a still. `aspect`: the frame's shape as `"W:H"` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; default `"16:9"`). `phoneAspect`: the shape when the frame is narrower than 600 px. |
@@ -167,3 +168,7 @@ build, whether or not the add named the game.
167
168
  A package the studio's own `package.json` already names at a version npm says does not fit is explained and left
168
169
  alone.
169
170
  - The plumbing behind the tools is `homie-studio parts find | new | add | share | unshare | check`.
171
+
172
+ App parts use the same lift/import/credit paths as game parts. Music and video parts can start empty with the corresponding kind; add their selected files, licence and provenance before sharing. `uses` describes what they are for, not permission to use them: the licence controls that. No price or checkout behavior is introduced; future commercial metadata can be added separately without changing the meaning of `share`.
173
+
174
+ For example: `homie-studio parts new welcome-queue --kind waitlist --uses app,venue`, or `parts new gala-loop --kind loop --uses venue,cause`. A source app is selected by the existing `--from <id>` and `--game <id>` options.
@@ -0,0 +1,100 @@
1
+ /*
2
+ * The browser's adapter for the one host runtime. The build puts it with the guarded rules, either in a browser
3
+ * host's bundle or in a server game's optional offline module. netplay owns elections and the transport; this file
4
+ * supplies the clock and translates the runtime's saved bytes to the relay's existing JSON checkpoint.
5
+ */
6
+ import { createHost } from './host.ts';
7
+ import { compileMap, compileRules } from './rules.ts';
8
+ import type { RoomSettings, RulesDef } from './rules.ts';
9
+ import { fromBytes, toBytes } from './pack.ts';
10
+ import { lab } from '../lab/lab.ts';
11
+ import type { RulesHostFactory, Peer } from '../netplay/netplay.ts';
12
+
13
+ export interface BrowserGame {
14
+ id: string; build: string; tune: Record<string, unknown>; map: Parameters<typeof compileMap>[0];
15
+ settings: RoomSettings; seats: number; vocab?: import('../agents/agents.ts').Vocabulary;
16
+ }
17
+
18
+ /** The rules and their private tunables stay inside this factory: the view is handed only the loader. */
19
+ export function browserHost(def: RulesDef, game: BrowserGame): RulesHostFactory {
20
+ return (o) => {
21
+ const tune = lab.rulesTune(game.tune);
22
+ const compiled = compileRules(def, { ...game, tune, map: compileMap(game.map, String((game.map as { name?: string }).name ?? 'main')) });
23
+ const checkpoint = o.restore as { rules?: string; data?: any } | null;
24
+ if (o.restore && (checkpoint?.rules !== game.build || !checkpoint.data || typeof checkpoint.data !== 'object')) throw new Error('invalid checkpoint');
25
+ // Check depth iteratively before clone/encoding can recurse on untrusted checkpoint data.
26
+ if (checkpoint) {
27
+ const pending: [unknown, number][] = [[checkpoint.data, 0]];
28
+ let nodes = 0;
29
+ while (pending.length) {
30
+ const [value, depth] = pending.pop()!;
31
+ if (depth > 64 || ++nodes > 500000) throw new Error('invalid checkpoint depth or size');
32
+ if (value && typeof value === 'object') for (const child of Object.values(value)) pending.push([child, depth + 1]);
33
+ }
34
+ }
35
+ const saved = checkpoint ? structuredClone(checkpoint.data) : null;
36
+ if (saved && o.peers && Array.isArray(saved.core?.ents) && Array.isArray(saved.core?.seats)) {
37
+ // Identity is the relay's word. The previous host's save can preserve game state, never seat authority.
38
+ const peers = new Map([...o.peers, ...(o.held ?? [])].filter((p) => p.seat !== null).map((p) => [p.seat, p]));
39
+ const policy = o.policy;
40
+ const count = (n: unknown): number => typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(game.seats - 1, Math.floor(n))) : 0;
41
+ const reserved = policy?.kind === 'hybrid' || policy?.kind === 'beginner' ? Math.min(game.seats - 1, count(policy.aiSeats) + (policy.kind === 'beginner' ? count(policy.guides) : 0)) : 0;
42
+ const identity = (seat: number): [string, string] => {
43
+ const peer = peers.get(seat);
44
+ return peer ? [peer.agent ? 'ai' : 'person', `p${peer.occ ?? peer.id}`] : seat >= game.seats - reserved && seat < game.seats ? ['ai', 'reserved'] : ['bot', ''];
45
+ };
46
+ const sameHolder = (q: any): boolean => !Array.isArray(q) || saved.core.seats.some((s: any) => Array.isArray(s) && s[0] === q[0] && s[1] === identity(q[0])[0] && s[2] === identity(q[0])[1]);
47
+ if (Array.isArray(saved.queues)) saved.queues = saved.queues.filter(sameHolder);
48
+ if (Array.isArray(saved.inputs)) saved.inputs = saved.inputs.filter(sameHolder);
49
+ if (Array.isArray(saved.names)) saved.names = saved.names.filter((row: any) => {
50
+ if (!Array.isArray(row)) return true;
51
+ const previous = saved.core.seats.find((s: any) => Array.isArray(s) && s[0] === row[0]);
52
+ return !(previous && previous[2] !== 'reserved' && identity(row[0])[1] === 'reserved');
53
+ });
54
+ for (const w of [...saved.core.ents, ...(Array.isArray(saved.core.spawns) ? saved.core.spawns : [])]) {
55
+ if (!Array.isArray(w)) throw new Error('invalid checkpoint entity');
56
+ const next = identity(w[11]);
57
+ if (w[12] !== next[0] || w[13] !== next[1]) w[15] = null;
58
+ [w[12], w[13]] = next;
59
+ }
60
+ for (const w of saved.core.seats) { if (!Array.isArray(w)) throw new Error('invalid checkpoint seat'); [w[1], w[2]] = identity(w[0]); }
61
+ for (const item of Array.isArray(saved.core.queue) ? saved.core.queue : []) {
62
+ if (Array.isArray(item) && item[6] === 'roundOver' && Array.isArray(item[7]?.results))
63
+ for (const row of item[7].results) if (row && typeof row === 'object') row.driver = identity(row.seat)[0];
64
+ }
65
+ saved.core.ops = [];
66
+ }
67
+ // A new authority rebases every player's input clock. Timers, dice, entities and round still come from the save.
68
+
69
+ const host = createHost({
70
+ game: game.id, build: game.build, compiled, send: o.send, restore: saved ? toBytes(saved) : null,
71
+ stage: lab.stage ?? undefined, startPaused: false, restoreEpoch: (Math.floor(Math.random() * 4294967296) >>> 0) || 1,
72
+ clock: { now: o.now, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) },
73
+ onEnd: o.onEnd,
74
+ });
75
+ if (game.vocab) host.frame({ t: 'vocabulary', vocab: game.vocab });
76
+ const present = new Set<number>();
77
+ const sync = (peers: Peer[]): void => {
78
+ const next = new Set(peers.flatMap((p) => p.seat === null ? [] : [p.seat]));
79
+ for (const seat of present) if (!next.has(seat)) host.frame({ t: 'leave', seat });
80
+ for (const body of host.core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved' && !next.has(body.seat)) host.core.seatAway(body.seat, true);
81
+ for (const peer of peers) if (peer.seat !== null) host.frame({ t: 'join', peer });
82
+ present.clear(); for (const seat of next) present.add(seat);
83
+ };
84
+ return {
85
+ frame: (m) => {
86
+ if (m.t === 'join') { const p = m.peer as { seat?: number }; if (typeof p?.seat === 'number') present.add(p.seat); }
87
+ if (m.t === 'leave' || m.t === 'free') present.delete(Number(m.seat));
88
+ host.frame(m);
89
+ },
90
+ sync,
91
+ // A restored room may be behind what its last host told the relay: the shared state is said again with the round.
92
+ start: () => { host.start(); if (saved) o.send({ t: 'state', k: 'shared', d: host.core.shared() }); },
93
+ stop: () => host.stop(),
94
+ pause: () => host.pause(), resume: () => host.resume(), announce: () => host.announce(),
95
+ save: () => ({ rules: game.build, data: fromBytes(host.save()) }),
96
+ get tick() { return host.tick; },
97
+ facts: () => host.facts(),
98
+ };
99
+ };
100
+ }