@homie-rocks/studio 0.45.3 → 0.46.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 CHANGED
@@ -17,6 +17,64 @@ 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.46.0] - 2026-10-10
21
+
22
+ **Plugin 0.46.0**
23
+
24
+ Keep a world and its account characters across restarts.
25
+
26
+ ### Added
27
+
28
+ - Opt-in lasting single-area worlds: `room.lifetime: "persistent"` retains a room
29
+ across empty periods, restarts and compatible builds. State fields declared
30
+ `{ durable: true }` are written the moment they change, as one small journal
31
+ record committed before that tick is published, exactly once across a crash.
32
+ The rest of the room is checkpointed every `room.durability.movementSeconds`
33
+ (30 by default for a lasting world) and on pause or an orderly stop; a restore
34
+ is the last checkpoint plus the journal, so value is never lost or repeated
35
+ though positions may rewind a moment. A room where nothing changes writes
36
+ nothing. Storage failures stop play and retain committed state.
37
+ - Optional `room.characters: true` keeps one character per game on the existing
38
+ signed-in player account. Rules read `player.character` on join and write its
39
+ inventory through declared entity fields. A durable lease and return outbox
40
+ prevent two rooms holding the character at once. Explicit `world.finish()`
41
+ resets retain the latest account character instead of replaying its old inventory.
42
+ No manual provisioning.
43
+
44
+ ### Changed
45
+
46
+ - Only opted-in worlds use a stable public room instead of allocating fresh match
47
+ copies. Full worlds wait for seats; named room links select other worlds.
48
+ - Lasting worlds default to `offline: false` so practice cannot look like a
49
+ durable session. An explicit `offline: true` still allows unsaved local practice.
50
+ - Only character games require studio account sign-in before playing and return
51
+ the character on disconnect/recall instead of leaving it as a bot.
52
+ - `room.durability.movementSeconds` defaults to 30 when `lifetime` is
53
+ `"persistent"`; it is still 1 for every other room. In a lasting world an
54
+ undeclared field, a position or an undeclared non-player entity can come back
55
+ up to that many seconds old after a crash: declare what must not.
56
+ - Only lasting worlds retain incompatible saves instead of
57
+ resetting and stop on write failure. Their save envelopes do
58
+ not use the match path's 16 MB cap. Existing simulation and Cloudflare constraints
59
+ still apply. Ordinary rules games and browser-hosted games keep their APIs,
60
+ save costs and lifecycle.
61
+
62
+ ### Fixed
63
+
64
+ - Refund-event SQL triggers no longer try to parse plain-text settings as JSON,
65
+ which could prevent account room tickets being signed. The automatic
66
+ `0020_function_meta.sql` migration preserves refund IDs and delivery behavior.
67
+
68
+ ### Upgrade notes
69
+
70
+ - First milestone-3 slice. Compatible deployments still restart the Worker and
71
+ reload old views; uninterrupted rule swaps, migrations, durable command retries,
72
+ atomic trades and restore points remain later slices. Commands should be
73
+ idempotent; a command lost before commitment has not happened. Queued events are
74
+ not atomic transfers when a recipient leaves. Existing cloud saves, shop orders
75
+ and grants, and app records retain their existing behavior.
76
+ - Version 0.46.0 is provisional. No deploy or merge is part of this PR.
77
+
20
78
  ## [0.45.3] - 2026-10-10
21
79
 
22
80
  **Plugin 0.45.3**
@@ -137,6 +137,58 @@ for an app. A wall/TV watches without taking a seat. Each interactive phone
137
137
  takes one of the room's declared seats; shared state is replicated, public and transient. An app's
138
138
  lasting, authorized business data belongs in `createAppRecords`, not in room fields.
139
139
 
140
+ ## Lasting worlds and account characters
141
+
142
+ For a world that should still exist tomorrow, set game.json:
143
+
144
+ ```json
145
+ {"room":{"host":"server","lifetime":"persistent","characters":true}}
146
+ ```
147
+
148
+ Lasting worlds default to `offline: false`: wait for the durable server. A studio
149
+ may explicitly set `offline: true` for separate local practice, whose progress
150
+ is not saved to the world or account. Never count that as proof of persistence.
151
+
152
+ `characters` is optional: `lifetime` alone keeps the world. Account characters
153
+ require the existing studio sign-in/passkey. Play redirects to the account page
154
+ when needed. In `room.join(ctx, player)`, return
155
+ `player.character ?? { kind: 'runner', at: spawn }`. Keep inventory in that player
156
+ kind's declared fields and change it only in rules. Mark inventory/currency and
157
+ collectible-ID sets with `{durable: true, visibility: 'owner'}`; use `durable: true`
158
+ for world changes and public progress too, omitting visibility only when public.
159
+ Durability and recipient visibility are separate declarations. The runtime returns its kind,
160
+ position, heading, fields and motion to the account when it leaves. Use
161
+ `player: true` or `leave: 'despawn'`; a returned character cannot also become a bot.
162
+ Do not reset inventory in `arrive`, round-start handlers or `world.finish`.
163
+
164
+ The public world has a stable room name; `?room=<name>` chooses another world.
165
+ Every durable change commits before publishing its results. An empty world sleeps and
166
+ retains state. A failed write stops play with the last good save intact. Another
167
+ room waits for the current holder to return the character; no timeout grants a
168
+ stale copy. No database or IDs are set up by hand. Ordinary games keep their
169
+ existing save frequency and behavior.
170
+
171
+ Write idempotent pickups using a saved collectible ID/set; duplicate commands
172
+ must not mint again. Events arrive later and can become `undeliverable` when a
173
+ recipient leaves; do not treat sending one as an atomic inventory transfer.
174
+ Trading and shop-grant consumption need the later economy slice. Browser saves
175
+ remain player-owned and are not an authoritative inventory.
176
+
177
+ Durable fields, entity lifecycle, pending events and character leases use an
178
+ atomic incremental journal. The rest is checkpointed every 30 seconds by default
179
+ (`room.durability.movementSeconds`, 1–60), and on pause/shutdown. Unchanged rooms
180
+ write nothing. A crash can rewind movement, but committed value and consumed
181
+ inputs replay together exactly once. Declare every fact needed to prevent a
182
+ repeat reward durable, including pickup IDs and cooldowns that protect value.
183
+ Compatible deploys still restart/reload and then restore the world.
184
+ A state-schema change is refused on restore with data retained; keep the previous
185
+ compatible build until migrations ship. Offline play has no account authority.
186
+
187
+ For proof, build normally, then sign two players in and test movement, pickup,
188
+ restart mid-pickup, a compatible rebuild, and a fresh profile signing in as the
189
+ same player. Verify actual inventory and world state. A lasting world need not
190
+ finish a round: its browser proof replaces the match check's round requirement.
191
+
140
192
  ## State and field types
141
193
 
142
194
  `f` declares state, event/command/effect payloads, ask state and guide views.
@@ -683,7 +735,8 @@ for a minute with delay. Movement should respond before the authoritative respon
683
735
  score and other shared outcomes still arrive with the network. The studio chooses `players.max`; neither frame rate nor feel is guaranteed by a passing build.
684
736
 
685
737
  Room state is saved automatically (default movementSeconds: 1, plus round end,
686
- pause and finish). After 60 seconds with no person the room ends even if screens or
738
+ pause and finish; a lasting world instead journals its durable fields at once and
739
+ checkpoints the rest every 30 seconds). After 60 seconds with no person the room ends even if screens or
687
740
  AI remain. Use browser createSaves for player-owned character progress; it is not
688
741
  server-verified money. Entity fields default to public. Declare visibility explicitly for private information (below); shared state and motion remain public. Use declared server round records for verified match history.
689
742
 
@@ -179,11 +179,55 @@ edits, build, act on the diagnostics and test the actual play before declaring i
179
179
  Never turn typing off to make room: place the strip instead (`chat/CHAT.md` has every field).
180
180
  - Update `game.json` `name` and `blurb`, and the `<title>`.
181
181
 
182
+ ## Lasting worlds and account characters
183
+
184
+ For a world that should still exist tomorrow, set game.json:
185
+
186
+ ```json
187
+ {"room":{"host":"server","lifetime":"persistent","characters":true}}
188
+ ```
189
+
190
+ Lasting worlds default to `offline: false`: wait for the durable server. A studio
191
+ may explicitly set `offline: true` for separate local practice, whose progress
192
+ is not saved to the world or account. Never count that as proof of persistence.
193
+
194
+ `characters` is optional: `lifetime` alone keeps the world. Account characters
195
+ require the existing studio sign-in/passkey. Play redirects to the account page
196
+ when needed. In `room.join(ctx, player)`, return
197
+ `player.character ?? { kind: 'runner', at: spawn }`. Keep inventory in that player
198
+ kind's declared fields and change it only in rules. The runtime returns its kind,
199
+ position, heading, fields and motion to the account when it leaves. Use
200
+ `player: true` or `leave: 'despawn'`; a returned character cannot also become a bot.
201
+ Do not reset inventory in `arrive`, round-start handlers or `world.finish`.
202
+
203
+ The public world has a stable room name; `?room=<name>` chooses another world.
204
+ Every tick commits before publishing its results. An empty world sleeps and
205
+ retains state. A failed write stops play with the last good save intact. Another
206
+ room waits for the current holder to return the character; no timeout grants a
207
+ stale copy. No database or IDs are set up by hand. Ordinary games keep their
208
+ existing save frequency and behavior.
209
+
210
+ Write idempotent pickups using a saved collectible ID/set; duplicate commands
211
+ must not mint again. Events arrive later and can become `undeliverable` when a
212
+ recipient leaves; do not treat sending one as an atomic inventory transfer.
213
+ Trading and shop-grant consumption need the later economy slice. Browser saves
214
+ remain player-owned and are not an authoritative inventory.
215
+
216
+ This first slice saves the whole world every tick, so opted-in games pay for
217
+ more writes. Compatible deploys still restart/reload and then restore the world.
218
+ A state-schema change is refused on restore with data retained; keep the previous
219
+ compatible build until migrations ship. Offline play has no account authority.
220
+
221
+ For proof, build normally, then sign two players in and test movement, pickup,
222
+ restart mid-pickup, a compatible rebuild, and a fresh profile signing in as the
223
+ same player. Verify actual inventory and world state. A lasting world need not
224
+ finish a round: its browser proof replaces the match check's round requirement.
225
+
182
226
  ## Progress that lasts: cloud saves
183
227
 
184
228
  When the plan says progress persists across sessions or devices (the codex's "Progress that lasts", or the person
185
- asks for a character, levels, unlocks, a collection, hardcore), wire in saves. Never keep it in the room: a room
186
- forgets everything 60 s after its last player leaves.
229
+ asks for a character, levels, unlocks, a collection, hardcore), wire in saves. For player-owned browser progress use the existing saves API below. For a server-authoritative
230
+ world or inventory, use Lasting worlds above. Ordinary match rooms expire after their last player leaves.
187
231
 
188
232
  - `game.json`: `"saves": true`. A new game of that kind can start from the starter:
189
233
  `npx --no-install homie-studio game new <id> --from ember-vale --name "<Name>"` (a hero that lasts, lifetime
package/lib/build.mjs CHANGED
@@ -602,7 +602,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
602
602
  // studio.json site.order: the catalogue's order is every listing's order.
603
603
  const shown = orderGames(all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))), s.order, log);
604
604
  // Server-hosted games: what this build made, and for a one-game build what the catalogue before it said of the others.
605
- const rulesOf = new Map(ruled.map((r) => [r.id, { host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
605
+ const rulesOf = new Map(ruled.map((r) => [r.id, { ...(r.settings.lifetime ? { lifetime: r.settings.lifetime } : {}), ...(r.settings.characters ? { characters: true } : {}), host: r.settings.host, offline: r.settings.offline, tickHz: r.settings.tickHz, inputHz: r.settings.inputHz, contract: r.schema.contract, build: r.build, stateHash: r.stateHash, rounds: r.rounds, ...(r.schema.records?{records:true}:{}) }]));
606
606
  if (only) for (const row of readJson(join(dist, 'games.json'))?.games ?? []) if (row?.room?.contract && row.id !== only && !rulesOf.has(row.id)) rulesOf.set(row.id, row.room);
607
607
  // What each game's build is (its landing's own pictures are not part of it): the digest the manifest names.
608
608
  const builds = {};
@@ -80,7 +80,7 @@ type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
80
80
  entities: ${entities}; move?: Moves;
81
81
  room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number;
82
82
  start?: (world: RW) => void;
83
- join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => ${joins};
83
+ join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: (${joins}) | null }) => ${joins};
84
84
  on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
85
85
  };
86
86
  asks?: ${record(Object.entries(d.asks).map(([n, a]) => [n, `{ state: Fields; questions: ${value(a.questions)}; floor: (state: ReadonlyState<${fields(a.state)}>) => ${picks(a.questions)} }`]))};
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
+ import { FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
2
2
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
3
3
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
4
4
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -802,6 +802,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
802
802
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
803
803
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
804
804
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
805
+ [`site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`]: FUNCTIONS_META_MIGRATION,
805
806
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
806
807
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
807
808
  '.claude/skills/.gitkeep': '',
@@ -973,6 +974,11 @@ function ensurePartsStateMigration(root) {
973
974
  return `site/migrations/${PURCHASE_STATE_FILE}`;
974
975
  }
975
976
 
977
+ function ensureFunctionMeta(root) {
978
+ const file=join(root,'site','migrations',FUNCTIONS_META_MIGRATION_FILE);
979
+ if(existsSync(file))return null;
980
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_META_MIGRATION);return `site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`;
981
+ }
976
982
  function ensureFunctionCursors(root) {
977
983
  const file=join(root,'site','migrations',FUNCTIONS_CURSOR_MIGRATION_FILE);
978
984
  if(existsSync(file))return null;
@@ -989,12 +995,12 @@ function ensureMcpMigration(root) {
989
995
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
990
996
  }
991
997
  export function ensureMigrations(root) {
992
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root)].filter(Boolean);
998
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root)].filter(Boolean);
993
999
  }
994
1000
 
995
1001
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
996
1002
  export function migrationWord(file) {
997
- return /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
1003
+ return /function_meta/.test(file) ? 'refund events alongside plain-text studio settings' : /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
998
1004
  }
999
1005
 
1000
1006
  /**
@@ -2691,6 +2691,48 @@
2691
2691
  }
2692
2692
  }
2693
2693
  }
2694
+ },
2695
+ "0.46.0": {
2696
+ "files": {
2697
+ "CLAUDE.md": "ddf2b2a0376202f6",
2698
+ "README.md": "684b3cca8001673c",
2699
+ "HANDOFF.md": "29337e2ace95b2e7",
2700
+ "games/README.md": "5ad32fe80c61f117",
2701
+ "music/README.md": "a3dc042dadd39df8",
2702
+ "videos/README.md": "16babc3437d5545b",
2703
+ "posts/README.md": "37841aae476bd540",
2704
+ "site/README.md": "732894273a32dc3f",
2705
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2706
+ },
2707
+ "sections": {
2708
+ "AGENTS.md": {
2709
+ "(intro)": "214aa46eb3a895d5",
2710
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2711
+ "## Layout": "f3a280b8c96cd068",
2712
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2713
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2714
+ "## The site": "0cb8dd9a6f8a0096",
2715
+ "## Making games": "bb715272fd1a615b",
2716
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2717
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2718
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2719
+ "## Servers and AI seats": "1ccfd549d2369a60",
2720
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2721
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2722
+ "## Rules": "c48971c1437414c2",
2723
+ "## Telling Homie": "80b46fdd9e578db8",
2724
+ "## Beta": "9d02638207a26e73"
2725
+ }
2726
+ },
2727
+ "named": {
2728
+ "Homie Arcade": {
2729
+ "sections": {
2730
+ "AGENTS.md": {
2731
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2732
+ }
2733
+ }
2734
+ }
2735
+ }
2694
2736
  }
2695
2737
  }
2696
2738
  }
@@ -2106,7 +2106,9 @@ from outside the studio run on its server.
2106
2106
  The Table writes the whole room to its SQLite `save` table in one synchronous transaction:
2107
2107
  entities, motion, shared state, rounds, seats and their tokens, pending events, timers and
2108
2108
  inputs, RNG state, tick, epoch, state hash and build hash. Saves larger than 2 MB use several
2109
- rows in that transaction. The default interval is `room.durability.movementSeconds: 1`;
2109
+ rows in that transaction. The default interval is `room.durability.movementSeconds: 1`
2110
+ (30 for a `lifetime: "persistent"` world, which writes its `durable` fields to a journal the moment they change and
2111
+ saves the rest on this interval);
2110
2112
  round end, `world.finish()` and pause save immediately. Both elapsed ticks and elapsed wall
2111
2113
  time bound the interval, including ticks that catch up after a delay. Cloudflare's output
2112
2114
  gate holds outgoing frames until preceding writes are confirmed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.45.3",
3
+ "version": "0.46.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",
package/rules/core.ts CHANGED
@@ -79,7 +79,7 @@ export const IGNORE_MAX = 16;
79
79
  export const ANSWER_MAX = 256;
80
80
 
81
81
  export type Driver = 'person' | 'bot' | 'ai';
82
- export interface SeatInfo { seat: number; driver: Driver; owner: string }
82
+ export interface SeatInfo { seat: number; driver: Driver; owner: string; character?: import('./rules.ts').Character | null }
83
83
  /** One seat's step for a tick: its input values (already held to their types) and, for an owner-moved body, its claim. */
84
84
  export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec3; vel: Vec3; heading: Vec3; r: number } | null }
85
85
  export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
@@ -163,6 +163,7 @@ export interface Core {
163
163
  round(): Extract<CoreOut, { t: 'round' }>;
164
164
  bodies(): Body[];
165
165
  /** The body in a seat, as the host runtime needs it for the input protocol. */
166
+ character(seat: number): import('./rules.ts').Character | null;
166
167
  bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
167
168
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
168
169
  drain(): CoreOut[];
@@ -183,7 +184,7 @@ export interface Core {
183
184
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
184
185
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
185
186
  */
186
- export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
187
+ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
187
188
  const dims = c.dims;
188
189
  const tickHz = c.settings.tickHz;
189
190
  const dt = 1 / tickHz;
@@ -838,7 +839,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
838
839
  return rows;
839
840
  }
840
841
  function joinBody(info: SeatInfo): Ent | null {
841
- const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner });
842
+ const player = Object.freeze({ seat: info.seat, driver: info.driver, owner: info.owner, ...(info.character !== undefined ? { character: deepFreeze(info.character) } : {}) });
842
843
  let made: Ent | null = null;
843
844
  run('room', 'join', null, 'join', () => {
844
845
  // What room.join returns is read by its own data properties, and its fields are charged before they are held to their shapes.
@@ -864,6 +865,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
864
865
  } else {
865
866
  if (e) {
866
867
  if (e.kind.on.leave) run(e.kind.name, 'on.leave', e, 'ent', () => e.kind.on.leave(world, e.self, Object.freeze({})));
868
+ opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
867
869
  e.dead = true; ents.delete(e.id);
868
870
  }
869
871
  seats.delete(s.seat);
@@ -975,7 +977,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
975
977
  if (tick < restartAt) return;
976
978
  startMatch();
977
979
  // Seats stay through a finished match: every holder's body is made again, as if it had just joined.
978
- for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
980
+ for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner, ...(opts.characterForSeat ? { character: opts.characterForSeat(s.seat) } : {}) } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
979
981
  return;
980
982
  }
981
983
  if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
@@ -992,7 +994,10 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
992
994
  function finishMatch(): void {
993
995
  finishing = false; restartAsked = false;
994
996
  out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
995
- for (const e of ents.values()) e.dead = true;
997
+ for (const e of ents.values()) {
998
+ if (e.kind.player) opts.characterSaved?.(e.seat, JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })));
999
+ e.dead = true;
1000
+ }
996
1001
  ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
997
1002
  for (const s of seats.values()) s.id = null;
998
1003
  shared = initFields(c.shared, dims); sharedChanged();
@@ -1456,7 +1461,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
1456
1461
  if (!floorCall && value) guideViews.set(seat, value);
1457
1462
  return value;
1458
1463
  },
1459
- seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128) } }); },
1464
+ seatJoin: (info) => { ops.push({ op: 'join', info: { seat: info.seat, driver: info.driver, owner: String(info.owner ?? '').slice(0, 128), ...(info.character !== undefined ? { character: info.character } : {}) } }); },
1460
1465
  seatAway: (seat, away) => { ops.push({ op: 'away', seat, away }); },
1461
1466
  seatLeave: (seat) => { ops.push({ op: 'leave', seat }); },
1462
1467
  setPolicy: (p) => { policy = normalizeCorePolicy(p); },
@@ -1477,6 +1482,7 @@ export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>nu
1477
1482
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1478
1483
  },
1479
1484
  shared: () => packFields(c.shared, shared, dims),
1485
+ character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
1480
1486
  bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
1481
1487
  bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
1482
1488
  drain: () => { const o = out; out = []; return o; },