@homie-rocks/studio 0.31.0 → 0.32.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 (42) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +44 -6
  3. package/bin/homie-studio.mjs +101 -8
  4. package/lib/build.mjs +1 -1
  5. package/lib/cloudflare.mjs +80 -6
  6. package/lib/directory.mjs +61 -9
  7. package/lib/doctor.mjs +23 -10
  8. package/lib/mcp-tools.mjs +18 -11
  9. package/lib/mcp.mjs +2 -0
  10. package/lib/parts.mjs +27 -0
  11. package/lib/routes.mjs +110 -2
  12. package/lib/scaffold.mjs +3 -3
  13. package/lib/standalone-cli.mjs +106 -0
  14. package/lib/standalone-files.mjs +360 -0
  15. package/lib/standalone-icons.mjs +106 -0
  16. package/lib/standalone-lock.json +3404 -0
  17. package/lib/standalone-pins.json +18 -0
  18. package/lib/standalone-tools.mjs +63 -0
  19. package/lib/standalone.mjs +868 -0
  20. package/lib/template-history.json +80 -0
  21. package/netplay/NETPLAY.md +148 -10
  22. package/netplay/netplay.ts +234 -33
  23. package/package.json +2 -1
  24. package/parts/PARTS.md +8 -2
  25. package/port/index.ts +1 -1
  26. package/port/room.ts +228 -23
  27. package/standalone/STANDALONE.md +313 -0
  28. package/standalone/electron/files.cjs +50 -0
  29. package/standalone/electron/main.cjs +139 -0
  30. package/standalone/electron/pack.mjs +82 -0
  31. package/standalone/shell/index.html +36 -0
  32. package/standalone/shell/shell.css +46 -0
  33. package/standalone/shell/shell.js +346 -0
  34. package/starters/ember-vale/src/main.ts +4 -0
  35. package/starters/gem-rush/src/main.ts +29 -7
  36. package/starters/gem-rush-3d/src/main.ts +36 -7
  37. package/starters/hero-rush-3d/src/main.ts +36 -7
  38. package/worker/index.mjs +55 -8
  39. package/worker/pages.mjs +19 -10
  40. package/worker/parts.mjs +43 -2
  41. package/worker/standalone.mjs +34 -0
  42. package/worker/version.mjs +1 -1
@@ -1612,6 +1612,86 @@
1612
1612
  }
1613
1613
  }
1614
1614
  }
1615
+ },
1616
+ "0.31.1": {
1617
+ "files": {
1618
+ "CLAUDE.md": "ddf2b2a0376202f6",
1619
+ "README.md": "684b3cca8001673c",
1620
+ "HANDOFF.md": "29337e2ace95b2e7",
1621
+ "games/README.md": "5ad32fe80c61f117",
1622
+ "music/README.md": "a3dc042dadd39df8",
1623
+ "videos/README.md": "16babc3437d5545b",
1624
+ "posts/README.md": "37841aae476bd540",
1625
+ "site/README.md": "732894273a32dc3f",
1626
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1627
+ },
1628
+ "sections": {
1629
+ "AGENTS.md": {
1630
+ "(intro)": "214aa46eb3a895d5",
1631
+ "## Layout": "8baf49e4291e7e88",
1632
+ "## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
1633
+ "## The site": "0cb8dd9a6f8a0096",
1634
+ "## Making games": "2da7fa74c8884834",
1635
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1636
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1637
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1638
+ "## Servers and AI seats": "98d08705087a33ec",
1639
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1640
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1641
+ "## Rules": "2e6b29afb85dcf7e",
1642
+ "## Telling Homie": "80b46fdd9e578db8",
1643
+ "## Beta": "9d02638207a26e73"
1644
+ }
1645
+ },
1646
+ "named": {
1647
+ "Homie Arcade": {
1648
+ "sections": {
1649
+ "AGENTS.md": {
1650
+ "## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
1651
+ }
1652
+ }
1653
+ }
1654
+ }
1655
+ },
1656
+ "0.32.0": {
1657
+ "files": {
1658
+ "CLAUDE.md": "ddf2b2a0376202f6",
1659
+ "README.md": "684b3cca8001673c",
1660
+ "HANDOFF.md": "29337e2ace95b2e7",
1661
+ "games/README.md": "5ad32fe80c61f117",
1662
+ "music/README.md": "a3dc042dadd39df8",
1663
+ "videos/README.md": "16babc3437d5545b",
1664
+ "posts/README.md": "37841aae476bd540",
1665
+ "site/README.md": "732894273a32dc3f",
1666
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1667
+ },
1668
+ "sections": {
1669
+ "AGENTS.md": {
1670
+ "(intro)": "214aa46eb3a895d5",
1671
+ "## Layout": "8baf49e4291e7e88",
1672
+ "## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
1673
+ "## The site": "0cb8dd9a6f8a0096",
1674
+ "## Making games": "2da7fa74c8884834",
1675
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1676
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1677
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1678
+ "## Servers and AI seats": "98d08705087a33ec",
1679
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1680
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1681
+ "## Rules": "2e6b29afb85dcf7e",
1682
+ "## Telling Homie": "80b46fdd9e578db8",
1683
+ "## Beta": "9d02638207a26e73"
1684
+ }
1685
+ },
1686
+ "named": {
1687
+ "Homie Arcade": {
1688
+ "sections": {
1689
+ "AGENTS.md": {
1690
+ "## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1615
1695
  }
1616
1696
  }
1617
1697
  }
@@ -23,6 +23,9 @@ revision-8 relay ignore every new field and frame):
23
23
  the `admit` callback, and createRoom's takeover and adopt callbacks for a room revived from its
24
24
  checkpoint and for a host that reconnects as host.
25
25
  - **Places** (section 26): a tie policy for results. **The relay's log** (section 27).
26
+ - **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `createRoom({ fallback })`,
27
+ `room.standing`, `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
28
+ readers for `net.prefs` (section 24).
26
29
  - New optional fields `hello.ver`, `hello.feat`, `welcome.ver`, `welcome.stale`, `welcome.stall`,
27
30
  `peer.occ`, `peer.ver`, `peer.feat`, `snap.hb`; a relay → client `stale` frame; the final refusal
28
31
  `stale` and the reported `room-stale`; `NETPLAY_REVISION` 9 and the build mark `homie-netplay-rev:9`.
@@ -1529,8 +1532,8 @@ A page stands in one of six places with its room, and the helper says which:
1529
1532
  |---|---|---|---|
1530
1533
  | `connecting` | No welcome yet. | false | false |
1531
1534
  | `online` | In the room. | false | true |
1532
- | `reconnecting` | It was in the room and its socket went. It knocks again with its token (250 ms, 500 ms, 1 s, 2 s, then every 4 s); its seat is held 60 s and a bot drives its body meanwhile. | false | false |
1533
- | `alone` | The room never answered in time, so this page plays by itself as an offline host: no seat, its own bots, nobody else. It still knocks, and joins when it is answered (a `role` with `why: 'reconnected'`). | **true** | false |
1535
+ | `reconnecting` | It was in the room and its socket went. It knocks again with its token (250 ms, 500 ms, 1 s, 2 s, then every 4 s); its seat is held 60 s and a bot drives its body meanwhile. **Bounded**: after `reconnectMaxMs` (default 20000, 5000 to 300000) it is `alone`. | false | false |
1536
+ | `alone` | The room never answered in time (`why: 'relay-timeout'`), or it was in the room and the room did not come back (`why: 'reconnect-timeout'`). This page plays by itself as an offline host: no seat, its own bots, nobody else. It still knocks, and joins when it is answered (a `role` with `why: 'reconnected'`). | **true** | false |
1534
1537
  | `offline` | There is no room at all: a plain file, a dev server with no shell. | true | false |
1535
1538
  | `closed` | Stopped for good; `net.closedWhy` says why (`replaced`, `room-full`, `kicked`, `stale`…). | as it was | false |
1536
1539
 
@@ -1544,12 +1547,40 @@ A page stands in one of six places with its room, and the helper says which:
1544
1547
  anything, which is how two browsers of one room came to report `hosting: true` with different
1545
1548
  rosters. A game that must not score, save or award anything while it is cut off checks
1546
1549
  `net.link === 'online'`.
1547
- - **The line over the game.** While the link is `reconnecting` (for more than 0.7 s) or `alone`,
1548
- the helper draws one line at the top of the game's own document: "Reconnecting…" or "Playing
1549
- offline · reconnecting…". It is an element `[data-homie-link="reconnecting" | "alone" |
1550
- "stale"]` whose default style has no specificity (`:where(…)`), so any rule the game writes for
1550
+ - **The line over the game.** One line at the top of the game's own document says where the
1551
+ page stands whenever that is not "playing in the room". It says what is true, so "Reconnecting…"
1552
+ is only ever shown to a page that was connected:
1553
+
1554
+ | When | `data-homie-link` | The line |
1555
+ |---|---|---|
1556
+ | `reconnecting` for more than 0.7 s | `reconnecting` | Reconnecting… |
1557
+ | `alone`, never answered | `alone` | Playing on your own · still looking for the room… |
1558
+ | `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 |
1559
+ | `alone`, the room did not come back | `alone` | Playing on your own · the room dropped, still trying… |
1560
+ | `online`, every seat taken (`net.full`) | `full` | This room is full · watching until a seat is free |
1561
+ | `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 |
1562
+ | `closed`: `room-full`, `too-many` | `closed` | This room is full. (with "· playing on your own" once it does) |
1563
+ | `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. |
1564
+ | `closed`: `stale`, or a newer build is live | `stale` | This game was updated. Tap to reload. |
1565
+
1566
+ Its default style has no specificity (`:where(…)`), so any rule the game writes for
1551
1567
  `[data-homie-link]` restyles it. `createNetplay({ linkOverlay: false })` turns it off: listen
1552
- to `link` and draw your own. The play page's chip says "reconnecting" too.
1568
+ to `link` and draw your own. The play page's chip says the same in a word ("reconnecting",
1569
+ "playing on your own: looking for the room", "room full"), and a room with no place left at all
1570
+ gets the page's notice with **Play in another room**. A game that draws its own "Reconnecting…"
1571
+ tests `net.link === 'reconnecting'`, not `!net.connected`: that is also true of a page still
1572
+ joining and of one that stopped for good.
1573
+ - **Every state ends somewhere a player can act, in bounded time.** `connecting` ends in `online`
1574
+ or, after `connectOpenMaxMs`, in `alone`. `reconnecting` ends in `online` or, after
1575
+ `reconnectMaxMs`, in `alone` (before this bound a replica whose room never came back showed a
1576
+ frozen round and "Reconnecting…" for good). Going `alone` from a room is a `role` event (`role:
1577
+ 'host'`, `why: 'reconnect-timeout'`), also for a page that was the room's host: its seat went
1578
+ with the room. `createRoom` keeps the round such a host was running (its own body and score,
1579
+ everybody else's body a bot's); a game on raw `createNetplay` starts a round of its own, as it
1580
+ does for `relay-timeout`. `closed` is said in words and the play page offers the way out.
1581
+ - **`net.full`** is true when this browser asked to play and every seat is taken: it is in the
1582
+ room with no seat (`seat === null`, role `screen`) and the relay seats it when one frees up (a
1583
+ `role` event with `why: 'seated'`). Section 28 says what a game does meanwhile.
1553
1584
  - **`net.reconnects`** is how many times the helper has scheduled another knock (the same number
1554
1585
  as `stats().reconnects`, which was always there; `net.reconnects` and
1555
1586
  `window.__homieNet.reconnects` are new). 0 means the connection was never interrupted, which is
@@ -1572,6 +1603,12 @@ With `connectClock: 'game'` the socket still opens at once and a welcome that ar
1572
1603
  used at once; only the giving-up waits for `net.start()` (or for 60 s, so a game that never says
1573
1604
  is not left without a role).
1574
1605
 
1606
+ **A page that was blocked is not a dead socket.** The helper drops a socket that delivered
1607
+ nothing for `staleMs` (6 s) and knocks again. A heavy boot blocks the page for longer than that,
1608
+ and when it wakes its ping timer runs before the frames queued behind it: a healthy socket was
1609
+ dropped and every long hitch became a reconnect. A ping that itself runs more than 3.5 s late now
1610
+ gives the socket 2.5 s to be heard from first; one that is really dead is still dropped.
1611
+
1575
1612
  **A host that hitches.** The relay hands the room on when its host has sent no snapshot for the
1576
1613
  stall time while others are present. That is right for a frozen tab and wrong for a game whose
1577
1614
  frames stopped for a second (a level loading, shaders compiling): a pair of slow 3D clients swapped
@@ -1625,6 +1662,16 @@ reads the other's snapshot with the wrong code.
1625
1662
  names no version is one pool, as before. A relay nobody tells which build is live (no Worker in
1626
1663
  front) uses the hello's `ver`: two builds still never share a room, and nobody is called stale.
1627
1664
 
1665
+ **A standalone copy** (a desktop or phone app, `standalone/STANDALONE.md`) is one build for good:
1666
+ it cannot load a newer one, only be updated. So it says its own revision to the Lobby
1667
+ (`/api/lobby?gv=`), which matches it with copies of that revision whatever is live now, and its
1668
+ `HOMIE_NET.app` makes the helper's lines truthful: "A new version is out. Update the app to play
1669
+ online.", and a tap reloads nothing. A copy that knocks at a room of the live revision is refused
1670
+ like any old tab, and plays on by itself. What a copy says of itself is its own word, so the Lobby
1671
+ keeps rooms for at most eight revisions besides the live one (a copy on a ninth plays offline for
1672
+ now), and a helper's `closed` word to its page names the room it is about. Nothing on the wire differs. `homie-studio deploy` says
1673
+ when a deploy makes another revision live than the copies a studio has built.
1674
+
1628
1675
  **What a revision cannot see.** Until a game's bundle has a content-hashed
1629
1676
  name, the edge may serve the previous `main.js` for up to a minute after a deploy while the page
1630
1677
  around it is already stamped with the new version. A tab loaded in that minute says it is the new
@@ -1680,10 +1727,38 @@ those through the helper.
1680
1727
 
1681
1728
  **`net.prefs`: a few settings that last.** Quality, volume, a personal best, the last name typed.
1682
1729
 
1730
+ **The trap, and the fix: a setting that is not there must never reach your game as `null`.**
1731
+ `null * x` is `0` and `Number(null)` is `0`, so a volume that was never set, or was kept as `null`,
1732
+ plays as silence with no error. Read a number, a switch or a word with the typed readers: they
1733
+ answer with your fallback for a key never set, a `null`, a wrong type, a `NaN`, and a read before
1734
+ the prefs arrived.
1735
+
1683
1736
  ```ts
1684
- const q = await net.prefs.get('quality', 'high'); // always resolves, with the fallback if nothing is kept
1685
- net.prefs.set('quality', 'low'); // resolves true when kept
1686
- await net.prefs.ready; net.prefs.peek('best', 0); // after ready: read without waiting
1737
+ await net.prefs.ready; // once, before the first read
1738
+ const volume = net.prefs.number('volume', 0.8, { min: 0, max: 1 }); // never null, never NaN, never out of range
1739
+ const muted = net.prefs.boolean('muted', false); // true or false, never 0 or 'false'
1740
+ const quality = net.prefs.string('quality', 'high', { oneOf: ['low', 'high'] });
1741
+ net.prefs.set('volume', 0.4); // resolves true when kept
1742
+ ```
1743
+
1744
+ - `number(key, fallback, { min, max, integer })`, `boolean(key, fallback)` and
1745
+ `string(key, fallback, { oneOf })` read what has arrived, without waiting. The fallback is
1746
+ yours and required: one of the wrong type (`number('volume')`, a `NaN`) throws a `TypeError`.
1747
+ - **Before `ready`** the page has not answered yet (on the play page the prefs come by message):
1748
+ a typed reader and `peek` answer with the fallback and the console says so once, naming
1749
+ `await net.prefs.ready`. `net.prefs.loaded` says whether they have arrived.
1750
+ - **A wrong type is said once** per key in the console, with what was kept (a string where a
1751
+ number was asked for, a word that is not one of `oneOf`). A key that is simply not set says nothing.
1752
+ - **`set` refuses what JSON cannot keep**: `NaN`, `Infinity`, a function, a loop, `undefined`
1753
+ inside an object. JSON would write `null`, and the next visit would read that as the setting.
1754
+ It resolves false, warns once for the key, and what was kept stands. `set(key, null)` still
1755
+ removes. A `null` that an earlier build kept this way is read as nothing kept, by `get` too.
1756
+ - `get(key, fallback)`, `all()` and `peek(key, fallback)` remain for JSON values (an object, a
1757
+ list): they are not checked against the fallback's type.
1758
+
1759
+ ```ts
1760
+ const q = await net.prefs.get('layout', { hud: 'left' }); // always resolves, with the fallback if nothing is kept
1761
+ await net.prefs.ready; net.prefs.peek('best', 0); // after ready: read without waiting
1687
1762
  ```
1688
1763
 
1689
1764
  - The play page keeps them in its own storage, one JSON object per game on this browser
@@ -1834,6 +1909,13 @@ real browsers:
1834
1909
  6. The host's network drops for a second while a third browser joins: after it reconnects, three
1835
1910
  people, three bodies, and nobody's score was reset.
1836
1911
  7. A deploy with tabs open: section 23.
1912
+ 8. **A slow browser joins mid-round** (throttle one to "4x slowdown" and open the room's link
1913
+ while a round runs, then again during the results). Within a few seconds it is in one of the
1914
+ places section 28 names, and the screen says which: playing in the room on its own body,
1915
+ playing on its own with bots, or watching with a line that says until when. Never a body-less
1916
+ camera with nothing said, never "Reconnecting…" on a page that was never connected, and never
1917
+ a line left up after the link came back. `room.standing` (or `net.link`, `net.full` and your
1918
+ own roster on raw `createNetplay`) is what a test asserts.
1837
1919
 
1838
1920
  ## 26. Places and ties (revision 9)
1839
1921
 
@@ -1876,3 +1958,59 @@ and `failed`.
1876
1958
  tested with stand-in sockets. If `wrangler dev` still prints an error with no room on it after a
1877
1959
  host is closed, it comes from somewhere these boundaries do not cover; the `leave` and
1878
1960
  `socket-gone` lines beside it say what the room was doing at that second.
1961
+
1962
+ ## 28. Seat or solo: a page with no body (after 0.31.0)
1963
+
1964
+ A page can be in its room and have nothing to play: `net.link === 'online'`, and no body. It
1965
+ happened three ways, and none of them said anything:
1966
+
1967
+ | How | What the page had | What happens now |
1968
+ |---|---|---|
1969
+ | 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()`). |
1970
+ | 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. |
1971
+ | 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. |
1972
+
1973
+ **The recipe is an option, not prose.** `createRoom` decides what a body-less page does, after
1974
+ `seatWaitMs` (default 4000, 1000 to 30000) of time it could actually listen:
1975
+
1976
+ ```ts
1977
+ const room = createRoom({
1978
+ // …
1979
+ fallback: 'solo', // 'wait' (default) | 'solo' | 'spectate'
1980
+ seatWaitMs: 4000,
1981
+ onStanding: (s) => hud.say(s.line), // optional: the line over the game says it already
1982
+ });
1983
+ room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed', why, line }
1984
+ ```
1985
+
1986
+ | `fallback` | What the page does while the room has no body for it | The line |
1987
+ |---|---|---|
1988
+ | `'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… |
1989
+ | `'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) |
1990
+ | `'spectate'` | No body; `room.viewBody()` follows a person in the room until it has one. | Watching until the next round |
1991
+
1992
+ `room.standing` ties it to the link states of section 22, so a game (and a test) asks one thing:
1993
+
1994
+ | `standing.state` | `why` | `net.link` | Can the player play? |
1995
+ |---|---|---|---|
1996
+ | `joining` | `connecting`, `no-body`, `full`, `reconnecting` | `connecting`, or `online` inside `seatWaitMs` | not yet; a line after 0.7 s ("Joining the round…") |
1997
+ | `playing` | `seated`, `reconnecting` | `online` (or `reconnecting`, bounded) | yes, in the room |
1998
+ | `solo` | `alone`, `offline` (the link); `no-body`, `full` (the fallback) | `alone`, `offline`, or `online` | yes, on their own, with bots |
1999
+ | `watching` | `screen` (a watcher or a big screen, by choice); `no-body`, `full` (`spectate`) | `online` | no; the line says until when |
2000
+ | `waiting` | `no-body`, `full` (`wait`) | `online` | no; the line says so |
2001
+ | `closed` | `net.closedWhy` | `closed` | no; the line says why and the play page offers another room |
2002
+
2003
+ - **Solo is the game's own host code, run privately.** `onRoundStart` runs for the private round
2004
+ (with the room's round number, a clock of its own) and the game's rules move its bots, exactly
2005
+ as offline. A score made there stays there: the room's body starts as the host made it. Events
2006
+ from the real room still arrive on `room.on('event')` while solo; a game that plays sounds for
2007
+ them checks `room.solo`.
2008
+ - **The wait counts only time the page could listen**, like the wait for the welcome: a page
2009
+ blocked for six seconds by its own boot runs its timers before the snapshots queued behind
2010
+ them, and a body that is already on its way is not "missing".
2011
+ - **The default changes nothing a working game did.** A page that gets its body with its first
2012
+ snapshots never sees any of this; `'wait'` only adds the line and the host's second try.
2013
+ - **On raw `createNetplay`** there is no option: do what the starters do. Host: try the claim
2014
+ again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
2015
+ `net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
2016
+ player stands with `net.line('…')`.