@homie-rocks/studio 0.44.1 → 0.45.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.
@@ -2428,6 +2428,58 @@
2428
2428
  }
2429
2429
  },
2430
2430
  "0.44.1": {
2431
+ "files": {
2432
+ "CLAUDE.md": "ddf2b2a0376202f6",
2433
+ "README.md": "684b3cca8001673c",
2434
+ "HANDOFF.md": "29337e2ace95b2e7",
2435
+ "games/README.md": "5ad32fe80c61f117",
2436
+ "music/README.md": "a3dc042dadd39df8",
2437
+ "videos/README.md": "16babc3437d5545b",
2438
+ "posts/README.md": "37841aae476bd540",
2439
+ "site/README.md": "732894273a32dc3f",
2440
+ "site/src/worker.mjs": "34444a6ca4b14437"
2441
+ },
2442
+ "sections": {
2443
+ "AGENTS.md": {
2444
+ "(intro)": "214aa46eb3a895d5",
2445
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2446
+ "## Layout": "f3a280b8c96cd068",
2447
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2448
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2449
+ "## The site": "0cb8dd9a6f8a0096",
2450
+ "## Making games": [
2451
+ "78950c6b4eb98ea6",
2452
+ "796cc9389236c713",
2453
+ "9e44559de535d842"
2454
+ ],
2455
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2456
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2457
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2458
+ "## Servers and AI seats": [
2459
+ "1ccfd549d2369a60",
2460
+ "d0028361fe567add"
2461
+ ],
2462
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2463
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2464
+ "## Rules": [
2465
+ "2e6b29afb85dcf7e",
2466
+ "c48971c1437414c2"
2467
+ ],
2468
+ "## Telling Homie": "80b46fdd9e578db8",
2469
+ "## Beta": "9d02638207a26e73"
2470
+ }
2471
+ },
2472
+ "named": {
2473
+ "Homie Arcade": {
2474
+ "sections": {
2475
+ "AGENTS.md": {
2476
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2477
+ }
2478
+ }
2479
+ }
2480
+ }
2481
+ },
2482
+ "0.44.3": {
2431
2483
  "files": {
2432
2484
  "CLAUDE.md": "ddf2b2a0376202f6",
2433
2485
  "README.md": "684b3cca8001673c",
@@ -2468,6 +2520,51 @@
2468
2520
  }
2469
2521
  }
2470
2522
  }
2523
+ },
2524
+ "0.45.0": {
2525
+ "files": {
2526
+ "CLAUDE.md": "ddf2b2a0376202f6",
2527
+ "README.md": "684b3cca8001673c",
2528
+ "HANDOFF.md": "29337e2ace95b2e7",
2529
+ "games/README.md": "5ad32fe80c61f117",
2530
+ "music/README.md": "a3dc042dadd39df8",
2531
+ "videos/README.md": "16babc3437d5545b",
2532
+ "posts/README.md": "37841aae476bd540",
2533
+ "site/README.md": "732894273a32dc3f",
2534
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2535
+ },
2536
+ "sections": {
2537
+ "AGENTS.md": {
2538
+ "(intro)": "214aa46eb3a895d5",
2539
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2540
+ "## Layout": "f3a280b8c96cd068",
2541
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2542
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2543
+ "## The site": "0cb8dd9a6f8a0096",
2544
+ "## Making games": [
2545
+ "78950c6b4eb98ea6",
2546
+ "bb715272fd1a615b"
2547
+ ],
2548
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2549
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2550
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2551
+ "## Servers and AI seats": "1ccfd549d2369a60",
2552
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2553
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2554
+ "## Rules": "c48971c1437414c2",
2555
+ "## Telling Homie": "80b46fdd9e578db8",
2556
+ "## Beta": "9d02638207a26e73"
2557
+ }
2558
+ },
2559
+ "named": {
2560
+ "Homie Arcade": {
2561
+ "sections": {
2562
+ "AGENTS.md": {
2563
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2564
+ }
2565
+ }
2566
+ }
2567
+ }
2471
2568
  }
2472
2569
  }
2473
2570
  }
package/lib/typecheck.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {listOwnParts, readOrigins, vendorDir} from './parts.mjs';
2
+ import {resolvePartImport} from './parts-build.mjs';
1
3
  import { runRulesCompiler } from './rules-compiler.mjs';
2
4
  /**
3
5
  * `homie-studio build --types` — the games' TypeScript, checked before anything is built.
@@ -7,7 +9,7 @@ import { runRulesCompiler } from './rules-compiler.mjs';
7
9
  * of its own to run `npx tsc` with.
8
10
  *
9
11
  * - The compiler is the STUDIO'S OWN (`typescript` in its node_modules, which a new studio's package.json asks
10
- * for). This optional check for legacy games keeps using that compiler. Rules games use the toolkit's own
12
+ * for). This optional check for legacy games and local apps keeps using that compiler. Rules games use the toolkit's own
11
13
  * pinned compiler through typecheckRules below, on every build.
12
14
  * - A game with a tsconfig.json of its own is checked with it. Any other game is checked the way esbuild reads
13
15
  * it: its entry and whatever that imports, as ES2022 for a browser, strictly, with the pictures, sounds and
@@ -15,7 +17,7 @@ import { runRulesCompiler } from './rules-compiler.mjs';
15
17
  * - Only errors in the game's own files stop the build. A game imports the toolkit's helpers as TypeScript
16
18
  * source, and a creator can do nothing about a line in node_modules; those are counted and said, never fatal.
17
19
  *
18
- * The legacy check runs only when asked with --types. The rules check is mandatory.
20
+ * The legacy/app check runs only when asked with --types. The rules check is mandatory.
19
21
  */
20
22
  import { spawnSync } from 'node:child_process';
21
23
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
@@ -116,8 +118,17 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
116
118
  writeFileSync(join(dir, 'rules.d.ts'), rulesTypes({ ...checked, answerScopes: await rulesAnswerScopes(join(g.dir, 'src/rules.ts')) }, tune));
117
119
  writeFileSync(join(dir, 'view.d.ts'), rulesViewTypes(checked));
118
120
  writeFileSync(join(dir, 'files.d.ts'), AMBIENT);
121
+ const partPaths = {};
122
+ const origins = readOrigins(root);
123
+ for (const ref of [...listOwnParts(root).map(p => p.id), ...Object.keys(origins.parts)]) {
124
+ const base = origins.parts[ref] ? vendorDir(root, ref) : join(root, 'parts', ref);
125
+ partPaths[`@parts/${ref}/*`] = [join(base, '*')];
126
+ const entry = resolvePartImport(root, `@parts/${ref}`, origins);
127
+ if (entry.path) partPaths[`@parts/${ref}`] = [entry.path];
128
+ }
119
129
  const project = join(dir, 'tsconfig.json');
120
130
  writeFileSync(project, `${JSON.stringify({ compilerOptions: { ...OPTIONS, checkJs: true, paths: {
131
+ ...partPaths,
121
132
  ...Object.fromEntries(Object.entries(JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).exports)
122
133
  .filter(([, entry]) => entry?.types).map(([name, entry]) => [`@homie-rocks/studio/${name.slice(2)}`, [join(PACKAGE_ROOT, entry.types)]])),
123
134
  '@homie-rocks/studio/rules': [join(dir, 'rules.d.ts')],
@@ -131,6 +142,9 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
131
142
  const at = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
132
143
  if (at) {
133
144
  const file = resolve(at[1]);
145
+ const missingPart = /Cannot find module '(@parts\/[^']+)'/.exec(at[5]);
146
+ if (missingPart) { const result = resolvePartImport(root, missingPart[1]); if (result.error) at[5] = result.error; }
147
+
134
148
  // Imported game helpers count too. Only the toolkit's own implementation is outside the author's remit.
135
149
  if (!file.startsWith(`${PACKAGE_ROOT}${sep}`) && !file.includes(`${sep}node_modules${sep}`) || file.startsWith(`${g.dir}${sep}`)) {
136
150
  const sourceLine = readFileSync(file,'utf8').split(/\r?\n/)[Number(at[2])-1] ?? '';
@@ -2,9 +2,9 @@ import {createHash} from 'node:crypto';
2
2
  import {refreshWorkerConfig} from './worker-config.mjs';
3
3
  import {pathToFileURL} from 'node:url';
4
4
  import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
5
- import {join,relative,basename,sep} from 'node:path';
5
+ import {join,relative,basename,sep,resolve} from 'node:path';
6
6
  import {readConfig} from './routes.mjs';
7
- import {workerDir} from './studio.mjs';
7
+ import {PACKAGE_ROOT,workerDir} from './studio.mjs';
8
8
  import {paidReleases} from './parts-upload.mjs';
9
9
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
10
10
  export async function buildWorker(root,esbuild) {
@@ -15,7 +15,10 @@ export async function buildWorker(root,esbuild) {
15
15
  const toolModule=join(root,'site/src/tools/index.mjs');
16
16
  const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
17
17
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
18
- const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
18
+ // Older studio entries export only Table and Lobby. Supply the new toolkit-owned
19
+ // classes in the generated bundle without rewriting the studio's source/customizations.
20
+ const contents=`export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/gate.mjs'))};\n`;
21
+ const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
19
22
  // A watcher may already have read the previous entry while this build is
20
23
  // publishing. Keep its entire graph available. Publish immutable modules first,
21
24
  // then switch the single entry atomically; never remove a running graph.
@@ -38,7 +41,9 @@ export async function buildWorker(root,esbuild) {
38
41
  }
39
42
  // Wrangler's module root is the immutable generation, so old generations are
40
43
  // retained locally for in-flight reloads but are never added to a deployment.
41
- refreshWorkerConfig(root,{main:relative(workerDir(root),join(dir,'worker.js'))});
44
+ refreshWorkerConfig(root);
45
+ const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
46
+ writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
42
47
  // Dev sometimes watches a copy with routes/remote AI removed. Publish its
43
48
  // entry too, keeping its local bindings and its module root at one generation.
44
49
  const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
@@ -54,3 +59,12 @@ export async function buildWorker(root,esbuild) {
54
59
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
55
60
  return sizes;
56
61
  }
62
+
63
+ /** Local immutable entry; the committed config always names the stable shim. */
64
+ export function workerEntry(root) {
65
+ const main=readConfig(root)?.main;
66
+ // Explicit custom entry points remain the owner's choice.
67
+ if(typeof main!=='string' || resolve(workerDir(root),main)!==resolve(root,'site/src/runtime/worker.js'))return null;
68
+ const file=join(root,'site/src/runtime/current.json');
69
+ return existsSync(file)?JSON.parse(readFileSync(file,'utf8')).main:null;
70
+ }
@@ -1,3 +1,10 @@
1
+ > 0.45.0: this document describes the internal transport used by rules rooms.
2
+ > Games have one authoring path: rules plus view with `openRoom`. Browser/offline
3
+ > hosting uses that same contract. Existing netplay builds and port host helpers
4
+ > remain supported with their original 32-seat limit. Historical protocol revisions below explain wire fields, not an
5
+ > alternative way to author new games. `players.max` chooses the room size without
6
+ > a 32-seat ceiling for rules games. Revision 12 adds roster patches and ordered chained deltas.
7
+
1
8
  # Homie netplay contract, v1 (revision 11)
2
9
 
3
10
  **Authoring now:** New games use rules plus view and the server as host. Read the
@@ -55,11 +62,9 @@ revision-8 relay ignore every new field and frame):
55
62
  because the frame has no storage), `net.params` (the address's switches), `net.shell` (where the
56
63
  page's own controls sit over the game), `guardGestures()` for a touch game.
57
64
  - **Whose body** (section 25): `peer.occ` (which stay in a seat), `Roster.claim(…, occ)`,
58
- the `admit` callback, and createRoom's takeover and adopt callbacks for a room revived from its
59
- checkpoint and for a host that reconnects as host.
65
+ and restoring a room's holders from its checkpoint.
60
66
  - **Places** (section 26): a tie policy for results. **The relay's log** (section 27).
61
- - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `createRoom({ fallback })`,
62
- `room.standing`, `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
67
+ - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
63
68
  readers for `net.prefs` (section 24).
64
69
  - New optional fields `hello.ver`, `hello.feat`, `welcome.ver`, `welcome.stale`, `welcome.stall`,
65
70
  `peer.occ`, `peer.ver`, `peer.feat`, `snap.hb`; a relay → client `stale` frame; the final refusal
@@ -255,7 +260,7 @@ code.
255
260
  - **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
256
261
  `netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
257
262
  (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
258
- at most 32** (`SEAT_MAX`). `homie-studio build` writes the number into the catalogue and
263
+ chosen by the studio** (`players.max`). `homie-studio build` writes the number into the catalogue and
259
264
  the site's Table takes it from there; the first visitor of an empty room may lower it
260
265
  (`hello.max`), never raise it.
261
266
  - **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
@@ -1099,12 +1104,8 @@ function stepBots(dt: number): void {
1099
1104
  | `aiName(label)`, `stripAi(name)`, `AI_MARK`, `SKILLS`, `skillPreset(n, kids?)` | Names and the dial. |
1100
1105
  | `Roster({ …, policy })` | Keeps `aiSeats + guides` slots marked `agent` (a person never takes one); `claim(seat, name, agent?)` (hands `host`: the slot stays a bot, `agent.seat` set; hands `self`: claimed like a person's); `release(seat)` gives an agent's slot back as a seat kept for AI; `bots: 'off'` adds no filler. |
1101
1106
 
1102
- `createRoom` (the port kit) does all of it: its roster keeps the AI seats, its join passes
1103
- `p.agent`, it declares `caps: ['agents']`, and `room.skillOf(body)` is the dial. `BotBrain`
1104
- takes `skill: () => room.skillOf(body)` and maps it: `reactionMs`, an aim error of
1105
- `aimNoise × 0.8` rad, and a commitment of `2500 × (1.3 − 0.6 × aggression)` ms; `port/skill.ts`
1106
- adds `jitter(s, maxPx)`, `engages(s, dt)` and `standoff(s, near, far)`. A ported game built on
1107
- `BotBrain` gets the dial with a rebuild.
1107
+ Rules rooms provide the agent roster and skill dial. Declare deterministic bot inputs
1108
+ in `think`; presentation and controls remain in the view.
1108
1109
 
1109
1110
  **Old games and old relays.**
1110
1111
  - A game built with a helper before revision 6 plays on every server: doors, the human seat
@@ -1572,7 +1573,7 @@ and its page, so the wire did not change for it.
1572
1573
  that was, null when it was not late); on the play page `window.__shell.arrival` has `mode`,
1573
1574
  `by`, `explicitMs` (the page's clock) and `lateMs`, and `window.__shell.ready` the message
1574
1575
  itself. The fix is one option: `createNetplay({ arrival: 'game' })`, or in a port
1575
- `createRoom({ netplay: { arrival: 'game' } })`.
1576
+ the rules view's transport options.
1576
1577
 
1577
1578
  ## 22. The link, said out loud (revision 9)
1578
1579
 
@@ -1608,7 +1609,7 @@ A page stands in one of six places with its room, and the helper says which:
1608
1609
  | `alone`, a room still on the old build (`room-stale`) | `alone` | Playing on your own · this room opens when its players have the new version |
1609
1610
  | `alone`, the room did not come back | `alone` | Playing on your own · the room dropped, still trying… |
1610
1611
  | `online`, every seat taken (`net.full`) | `full` | This room is full · watching until a seat is free |
1611
- | `online`, the game's own sentence (`net.line(text)`; `createRoom`'s `fallback` uses it) | `seat` | for example: Playing on your own until the next round |
1612
+ | `online`, the game's own sentence (`net.line(text)`) | `seat` | for example: Playing on your own until the next round |
1612
1613
  | `closed`: `room-full`, `too-many` | `closed` | This room is full. (with "· playing on your own" once it does) |
1613
1614
  | `closed`: `replaced`, `kicked`, `room-closed`, `version` | `closed` | This game is open in another tab. / This room is closed. / This game needs a reload to play online. |
1614
1615
  | `closed`: `stale`, or a newer build is live | `stale` | This game was updated. Tap to reload. |
@@ -1625,9 +1626,7 @@ A page stands in one of six places with its room, and the helper says which:
1625
1626
  `reconnectMaxMs`, in `alone` (before this bound a replica whose room never came back showed a
1626
1627
  frozen round and "Reconnecting…" for good). Going `alone` from a room is a `role` event (`role:
1627
1628
  'host'`, `why: 'reconnect-timeout'`), also for a page that was the room's host: its seat went
1628
- with the room. `createRoom` keeps the round such a host was running (its own body and score,
1629
- everybody else's body a bot's); a game on raw `createNetplay` starts a round of its own, as it
1630
- does for `relay-timeout`. `closed` is said in words and the play page offers the way out.
1629
+ with the room. Rules views start their offline rules runtime when going alone. `closed` is said in words and the play page offers the way out.
1631
1630
  - **`net.full`** is true when this browser asked to play and every seat is taken: it is in the
1632
1631
  room with no seat (`seat === null`, role `screen`) and the relay seats it when one frees up (a
1633
1632
  `role` event with `why: 'seated'`). Section 28 says what a game does meanwhile.
@@ -1875,114 +1874,11 @@ const off = guardGestures({ touch: 'canvas, [data-action]' }); // once, early;
1875
1874
  listeners and the stylesheet are tested, and emulated touch does not raise the callout, so try
1876
1875
  a real long press on a real iPhone and Android phone before calling it done.
1877
1876
 
1878
- ## 25. Whose body: seats that change hands (revision 9)
1879
-
1880
- A seat number is not a person. The relay reuses a number when its player has been gone a minute
1881
- (or sooner, in a full room), a host can be cut off while people come and go, and a round can
1882
- outlive everyone in it. Three cases went wrong in real games; all three now run the same code as
1883
- an ordinary join.
1884
-
1885
- **`peer.occ`: which stay in the seat.** The relay numbers every stay: a new number whenever a seat
1886
- is given to a new token, the same number across that browser's reloads and reconnects, kept
1887
- through a deploy. `Roster.claim(seat, name, agent, occ)` and `Roster.reconcile(peers)` (pass
1888
- `occ`) use it: the same stay keeps its body; a seat that changed hands gives the old body back to
1889
- a bot and admits the newcomer like any arrival. `roster.occupants()` belongs in the checkpoint
1890
- beside `roster.toJSON()`, and `Roster.from(slots, opts, occupants)` reads it back. With a relay
1891
- that says no `occ`, a seat is taken to be the same player's, as before, and the helper still knows
1892
- its own: `net.resumed` is true when its welcome gave back the seat its token named.
1893
-
1894
- **The takeover callbacks cover every way a body changes hands** (`createRoom`):
1895
-
1896
- | How | `onTakeover(body, info)` | `adopt(body)` |
1897
- |---|---|---|
1898
- | A person joins a running round | `{ why: 'join', own: false }` | on the joiner, when the host resets its body |
1899
- | A visitor revives a room from its checkpoint | `{ why: 'restore', own: true }` for its own body, and for anyone claimed with it | on the new host, with the body it took |
1900
- | A replica is promoted and someone arrived since the last checkpoint | `{ why: 'migrate' }` | on that player, by reset |
1901
- | A host reconnects as host and someone arrived while it was away | `{ why: 'join' }` (the missed `join` is replayed) | on that player, by reset |
1902
- | The same player comes back to the body they held | not called when the slot never left them; called with `back: true` when they left and returned (a reload) | as before |
1903
-
1904
- `info.back` is true when the player held that body before. The default (no `onTakeover`) is still
1905
- "score 0, where it stands".
1906
-
1907
- **The restored room.** Everyone leaves; the relay keeps the round for 60 s; a new visitor arrives
1908
- before that. It is welcomed as host with the checkpoint (`why: 'resumed'`), and the round goes on
1909
- with the clock where it was. Before revision 9 the newcomer inherited a departed player's body
1910
- without `onTakeover` (their score, with eleven seconds left) and without `adopt` (its local pose
1911
- stayed at a default, so it could not move until the next round). Now `createRoom` hands the
1912
- departed players' bodies back to bots, claims one for the newcomer through `admit`, calls
1913
- `onTakeover` and then `adopt`. A player who comes back to their own room (their token) keeps
1914
- their body and score, and is adopted onto it. A game on raw `createNetplay` does the same with
1915
- `roster.reconcile(peers)` in its `role` handler: treat every slot in `claimed` as a takeover.
1916
-
1917
- **The host that reconnects as host.** A host whose socket drops and comes back while nobody else
1918
- could host gets a welcome, not a `role` (its role did not change), so its `role` handler never
1919
- ran, and the relay never told it who came or went meanwhile. A player who arrived in that window
1920
- had no body for the rest of the visit, and one who left kept a frozen one. The helper now says
1921
- the difference as the events it would have been: `leave` (with `why: 'gone'`) and `join`, by seat,
1922
- with a seat whose `occ` changed as both. Nothing to write: the handlers a game already has do it.
1923
- The same change stopped `createRoom` calling `onTakeover` (and zeroing the score) for a player
1924
- whose body never left them.
1925
-
1926
- **`admit`: which body an arrival takes.** By default a newcomer takes the lowest bot's body. In an
1927
- elimination round that can be a dead one while living bots stand by.
1928
-
1929
- ```ts
1930
- createRoom({
1931
- admit: (candidates, who) => candidates.find((b) => b.alive) ?? null, // null: none will do
1932
- });
1933
- new Roster({ min, max, admit: (slots, who) => pickSlotId(slots) }); // raw: slots in, a slot id out
1934
- ```
1935
-
1936
- - Asked for every NEW arrival: a fresh join, a restored room's claims, a promoted host's
1937
- reconcile, a missed join replayed. Return the body (the slot id, on `Roster`), `undefined` for the
1938
- default, or `null` for "none of these": a new body is spawned while the room has space, and when
1939
- it has none the default applies (a seated person always gets a body).
1940
- - Never asked for a player returning to the body they held: an eliminated player who reloads gets
1941
- their own dead body back, not a way into the round. `occ` is how the roster knows.
1942
- - Never offered a seat kept for AI when a person arrives; an agent is offered the kept seats first
1943
- (the reservation of section 17 holds).
1944
- - Decide from the bodies' own state so the next host makes the same choice. The host still resets
1945
- the body (`net.reset`), announces the roster and checkpoints, as for any claim.
1946
-
1947
- **The late-join checklist.** A game is not done with joining until each of these is true in two
1948
- real browsers:
1949
-
1950
- 1. A joiner mid-round takes a bot's body, the right one (`admit`), is reset onto it and can move.
1951
- 2. The host leaves; the other browser continues the same round; a joiner after that still works.
1952
- 3. A joiner who reloads comes back to the same body (and the same score, if the game keeps it:
1953
- `info.back`).
1954
- 4. **Everyone leaves, and a new visitor enters before the room is forgotten (60 s).** The round is
1955
- the same one; the visitor has a body it did not inherit silently (`onTakeover` ran, `adopt` ran),
1956
- can move at once, and is ranked as a newcomer.
1957
- 5. The same, but the visitor is one of the players who left (same tab, reloaded): their own body
1958
- and score.
1959
- 6. The host's network drops for a second while a third browser joins: after it reconnects, three
1960
- people, three bodies, and nobody's score was reset.
1961
- 7. A deploy with tabs open: section 23.
1962
- 8. **A slow browser joins mid-round** (throttle one to "4x slowdown" and open the room's link
1963
- while a round runs, then again during the results). Within a few seconds it is in one of the
1964
- places section 28 names, and the screen says which: playing in the room on its own body,
1965
- playing on its own with bots, or watching with a line that says until when. Never a body-less
1966
- camera with nothing said, never "Reconnecting…" on a page that was never connected, and never
1967
- a line left up after the link came back. `room.standing` (or `net.link`, `net.full` and your
1968
- own roster on raw `createNetplay`) is what a test asserts.
1969
-
1970
- ## 26. Places and ties (revision 9)
1971
-
1972
- `createRoom` ranks a round's results by score. The order of the rows was always: higher score
1973
- first, then **people before bots**, then **the lower slot**. Two bodies on 61 points were shown
1974
- first and second with nothing saying why. The order is the tiebreaker and is unchanged; what
1975
- place a tie gets is now the game's to say:
1976
-
1977
- | `createRoom({ ties })` | 61, 61, 40, 12 | |
1978
- |---|---|---|
1979
- | `'order'` (default) | 1, 2, 3, 4 | What results always were: the order above is the place. Say so to players if you keep it ("ties go to the player, then the earlier seat"). |
1980
- | `'shared'` | 1, 1, 3, 4 | Standard competition places: equal scores share one and the next is skipped. |
1981
- | `'dense'` | 1, 1, 2, 3 | Equal scores share one and none is skipped. |
1877
+ ## 25–26. Player bodies and places
1982
1878
 
1983
- `placesOf(scores, ties)` is the same rule for a game that ranks its own rows (pass the scores in
1984
- the order you show them). A game with a real tiebreaker (time, kills, who got there first) sorts
1985
- by it before ranking and keeps `'order'`.
1879
+ Rules own player bodies, arrival, departure and round results. Use the rules
1880
+ contract and `openRoom` view. The port-owned `createRoom` host scaffold and its
1881
+ callbacks remain available for existing games in 0.45.0. New games use rules plus view.
1986
1882
 
1987
1883
  ## 27. The relay's log (revision 9)
1988
1884
 
@@ -2009,61 +1905,10 @@ and `failed`.
2009
1905
  host is closed, it comes from somewhere these boundaries do not cover; the `leave` and
2010
1906
  `socket-gone` lines beside it say what the room was doing at that second.
2011
1907
 
2012
- ## 28. Seat or solo: a page with no body (after 0.31.0)
1908
+ ## 28. A page waiting for its body
2013
1909
 
2014
- A page can be in its room and have nothing to play: `net.link === 'online'`, and no body. It
2015
- happened three ways, and none of them said anything:
2016
-
2017
- | How | What the page had | What happens now |
2018
- |---|---|---|
2019
- | The relay seated the player and the host had no body free (every body was somebody's: a server stopped keeping seats for AI mid-round, or the game has fewer bodies than the room has seats). The claim was tried once, at the join. | a seat, role `replica`, no body, for the rest of the visit | The host seats everybody who is waiting **whenever a body frees up and at every round start** (`createRoom`; the starters on raw `createNetplay` do the same in `seatWaiting()`). |
2020
- | Every seat is taken. The relay lets the page in with no seat. | `seat === null`, role `screen`, nothing said | `net.full` is true and the line says so; the relay seats it when a seat frees up, as before. |
2021
- | The page was reset by its host before a snapshot showed its body. | a body it never adopted (it could not move until the next round) | `createRoom` owes the adoption until a snapshot shows the body. |
2022
-
2023
- **The recipe is an option, not prose.** `createRoom` decides what a body-less page does, after
2024
- `seatWaitMs` (default 4000, 1000 to 30000) of time it could actually listen:
2025
-
2026
- ```ts
2027
- const room = createRoom({
2028
- // …
2029
- fallback: 'solo', // 'wait' (default) | 'solo' | 'spectate'
2030
- seatWaitMs: 4000,
2031
- onStanding: (s) => hud.say(s.line), // optional: the line over the game says it already
2032
- });
2033
- room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed', why, line }
2034
- ```
2035
-
2036
- | `fallback` | What the page does while the room has no body for it | The line |
2037
- |---|---|---|
2038
- | `'wait'` (default) | What a game did before: it waits. Now it says so, and the host seats it as above. | Waiting for a place in this round… |
2039
- | `'solo'` | A private round with bots, here: `room.hosting` is true, `room.mine()` is its body, `room.solo` is true. Nothing of it is sent (no round, roster, snapshot or `room.send`). The moment the room has a body for it, the private round ends and `adopt` is called with the room's body. | Playing on your own until the next round (in a full room: This room is full · playing on your own until a seat is free) |
2040
- | `'spectate'` | No body; `room.viewBody()` follows a person in the room until it has one. | Watching until the next round |
2041
-
2042
- `room.standing` ties it to the link states of section 22, so a game (and a test) asks one thing:
2043
-
2044
- | `standing.state` | `why` | `net.link` | Can the player play? |
2045
- |---|---|---|---|
2046
- | `joining` | `connecting`, `no-body`, `full`, `reconnecting` | `connecting`, or `online` inside `seatWaitMs` | not yet; a line after 0.7 s ("Joining the round…") |
2047
- | `playing` | `seated`, `reconnecting` | `online` (or `reconnecting`, bounded) | yes, in the room |
2048
- | `solo` | `alone`, `offline` (the link); `no-body`, `full` (the fallback) | `alone`, `offline`, or `online` | yes, on their own, with bots |
2049
- | `watching` | `screen` (a watcher or a big screen, by choice); `no-body`, `full` (`spectate`) | `online` | no; the line says until when |
2050
- | `waiting` | `no-body`, `full` (`wait`) | `online` | no; the line says so |
2051
- | `closed` | `net.closedWhy` | `closed` | no; the line says why and the play page offers another room |
2052
-
2053
- - **Solo is the game's own host code, run privately.** `onRoundStart` runs for the private round
2054
- (with the room's round number, a clock of its own) and the game's rules move its bots, exactly
2055
- as offline. A score made there stays there: the room's body starts as the host made it. Events
2056
- from the real room still arrive on `room.on('event')` while solo; a game that plays sounds for
2057
- them checks `room.solo`.
2058
- - **The wait counts only time the page could listen**, like the wait for the welcome: a page
2059
- blocked for six seconds by its own boot runs its timers before the snapshots queued behind
2060
- them, and a body that is already on its way is not "missing".
2061
- - **The default changes nothing a working game did.** A page that gets its body with its first
2062
- snapshots never sees any of this; `'wait'` only adds the line and the host's second try.
2063
- - **On raw `createNetplay`** there is no option: do what the starters do. Host: try the claim
2064
- again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
2065
- `net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
2066
- player stands with `net.line('…')`.
1910
+ The rules view owns connecting, playing, offline and closed states. The same
1911
+ rules run for offline/browser play; a separate port host is not supported.
2067
1912
 
2068
1913
  ## 29. The server as host (revision 10)
2069
1914
 
@@ -88,9 +88,9 @@
88
88
 
89
89
  export const NETPLAY_VERSION = 1;
90
90
  /** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
91
- export const NETPLAY_REVISION = 11;
91
+ export const NETPLAY_REVISION = 12;
92
92
  /** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
93
- export const NETPLAY_MARK = 'homie-netplay-rev:11';
93
+ export const NETPLAY_MARK = 'homie-netplay-rev:12';
94
94
 
95
95
  export type Role = 'host' | 'replica' | 'screen';
96
96
  export type Device = 'phone' | 'desk' | 'tv';
@@ -802,13 +802,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
802
802
  /**
803
803
  * This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
804
804
  * `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
805
- * says so; `createRoom({ fallback })` decides what the page does meanwhile.
805
+ * says so; the rules view decides what the page shows meanwhile.
806
806
  */
807
807
  readonly full: boolean;
808
808
  /**
809
809
  * The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
810
810
  * on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
811
- * down says its own line first. `createRoom` uses it for its `fallback`.
811
+ * down says its own line first.
812
812
  */
813
813
  line(text: string | null): void;
814
814
  /** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
@@ -1046,7 +1046,7 @@ export interface RosterOptions {
1046
1046
  max: number;
1047
1047
  botName?: (slot: number) => string;
1048
1048
  /**
1049
- * Revision 6: the room's policy (createRoom passes `() => net.policy`). A hybrid or beginner server keeps
1049
+ * Revision 6: the room's policy (read from `net.policy`). A hybrid or beginner server keeps
1050
1050
  * `aiSeats + guides` slots for AI (marked `agent`, never given to a person); `bots: 'off'` adds no other filler.
1051
1051
  */
1052
1052
  policy?: () => Policy | null;
@@ -1371,7 +1371,7 @@ export function cleanFeatures(list: unknown): string[] {
1371
1371
 
1372
1372
  /**
1373
1373
  * PLACES (NETPLAY.md section 26): rows ranked by score, with a tie policy said out loud. `rows` must already be in the
1374
- * order to show them (the tiebreak is the caller's: createRoom puts people before bots, then the lower slot).
1374
+ * order to show them (the tiebreak is the caller's).
1375
1375
  * `'order'` (the default, what results always were): places 1, 2, 3… in that order, so two equal scores get different
1376
1376
  * places. `'shared'`: standard competition places, equal scores share one and the next is skipped (1, 1, 3).
1377
1377
  * `'dense'`: equal scores share one and none is skipped (1, 1, 2).
@@ -1799,7 +1799,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1799
1799
  const late = playableSent && arrived.by === 'auto' && arrived.playableMs !== null ? arrived.explicitMs - arrived.playableMs : null;
1800
1800
  if (late !== null && late >= 250) {
1801
1801
  arrived.lateMs = late;
1802
- console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' }) (createRoom: netplay: { arrival: 'game' }) so the card waits for net.playable().`);
1802
+ console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' }) so the card waits for net.playable().`);
1803
1803
  }
1804
1804
  post?.({ what: 'ready', by: 'game', mode: arrival, ms: arrived.explicitMs, ...(arrived.lateMs !== null ? { lateMs: arrived.lateMs } : {}) });
1805
1805
  sayPlayable('game');
@@ -2437,7 +2437,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2437
2437
  return;
2438
2438
  }
2439
2439
  case 'roster': {
2440
- slots = (m['slots'] as Slot[]) ?? null;
2440
+ if (m['patch'] === true) {
2441
+ const next = new Map((slots ?? []).map(row => [row.slot, row]));
2442
+ for (const slot of (m['removed'] as number[] ?? [])) next.delete(slot);
2443
+ for (const row of (m['slots'] as Slot[] ?? [])) next.set(row.slot, row);
2444
+ slots = [...next.values()].sort((a, b) => a.slot - b.slot);
2445
+ } else slots = (m['slots'] as Slot[]) ?? null;
2441
2446
  post?.({ what: 'roster', slots });
2442
2447
  if (slots) emit('roster', slots);
2443
2448
  return;
@@ -2965,7 +2970,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2965
2970
  if (full) { full = false; post?.({ what: 'full', full: false }); }
2966
2971
  aloneWhy = why;
2967
2972
  // A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
2968
- // gone with the room, and the game seats itself again for a round of its own (createRoom keeps the round it had).
2973
+ // gone with the room, and the game seats itself again for a round of its own.
2969
2974
  if (!roleKnown || role !== 'host' || (rulesGame && !rulesHost) || why === 'reconnect-timeout') setRole(canOffline ? 'host' : 'replica', why, rulesGame && kept ? { ckpt: { k: tick, st: now(), d: kept as C } } : {});
2970
2975
  // No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
2971
2976
  // page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.44.1",
3
+ "version": "0.45.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",
@@ -1,3 +1,5 @@
1
+ import { castTerrain } from './terrain.ts';
2
+ import { nearbyMap } from './map-index.ts';
1
3
  /** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
2
4
  * Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
3
5
  */
@@ -8,6 +10,7 @@ export interface BodyShape { shape: string; radius: number; height: number }
8
10
  export interface Solid { min: Vec3; max: Vec3; r: number }
9
11
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
10
12
  export interface Map3 {
13
+ staticMap?: Map3;
11
14
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
12
15
  heightTiles?: readonly MapHeightTile[];
13
16
  spheres?: readonly MapCircle[];
@@ -74,15 +77,17 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
74
77
  const x = p.x - tile.at.x, y = p.y - tile.at.y;
75
78
  let best: Hit3 | null = null;
76
79
  for (const half of [0, 1]) {
80
+ const alternate = tile.diagonal === '10-01';
77
81
  const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
78
- const gy = half === 0 ? (h11 - h10) / sy : (h01 - h00) / sy;
79
- const gap = p.z - tile.at.z - h00 - gx * x - gy * y;
82
+ const gy = alternate ? (half === 0 ? h01 - h00 : h11 - h10) / sy : (half === 0 ? h11 - h10 : h01 - h00) / sy;
83
+ const base = alternate && half === 1 ? h10 + h01 - h11 : h00;
84
+ const gap = p.z - tile.at.z - base - gx * x - gy * y;
80
85
  const closing = gx * d.x + gy * d.y - d.z;
81
86
  if (gap < -EPS || closing <= EPS) continue;
82
87
  const t = Math.max(0, gap / closing);
83
88
  if (t > 1 || best && t >= best.t) continue;
84
89
  const u = (x + d.x * t) / sx, v = (y + d.y * t) / sy;
85
- if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (half === 0 ? v > u + EPS : u > v + EPS)) continue;
90
+ if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (alternate ? (half === 0 ? u + v > 1 + EPS : u + v < 1 - EPS) : (half === 0 ? v > u + EPS : u > v + EPS))) continue;
86
91
  const length = Math.sqrt(gx * gx + gy * gy + 1);
87
92
  best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
88
93
  }
@@ -90,6 +95,7 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
90
95
  }
91
96
 
92
97
  export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
98
+ map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
93
99
  charge(24);
94
100
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
95
101
  let best: Hit3 | null = null;
@@ -101,7 +107,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
101
107
  const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
102
108
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
103
109
  }
104
- for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
110
+ for (const tile of map.heightTiles ?? []) take(tile.base === undefined ? castHeightTile(tile, p, d) : castTerrain(tile, a, d));
105
111
  for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
106
112
  // Legacy map circles are vertical columns in a 3D map.
107
113
  for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);