@homie-rocks/studio 0.45.2 → 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,93 @@ 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
+
78
+ ## [0.45.3] - 2026-10-10
79
+
80
+ **Plugin 0.45.3**
81
+
82
+ Run richer server-authoritative combat, admission and bots with shared collision queries.
83
+
84
+ ### Added
85
+
86
+ - Opt-in ray options, ordered `rayAll` hits, named hit regions, projectile profiles, immutable query scenes and scratch movement queries.
87
+ - `f.pulse()` carries a nonzero byte action once over the reliable input-event channel. Use it for weapon selection; ordinary sampled fields must not carry one-frame actions.
88
+ - Entity field visibility: `owner`, `server` and `results`. Per-recipient projections also apply to welcome, watching and reconnect snapshots. Private-delivery deltas refer to a keyframe so packet loss cannot leave moving actors at stale poses.
89
+ - Authenticated room round records, with a durable retry outbox and idempotent player-history append. Browser saves cannot overwrite server records.
90
+ - Budgeted routes over baked walking graphs, ordered same-tick dispatch, player labels, ranked takeover and result-free retries. Named `navigation` graphs validate once at compilation, avoiding repeated graph-validation charges in bot handlers while retaining live obstacle checks and full searches.
91
+
92
+ ### Fixed
93
+
94
+ - **Ray compatibility:** the earlier PR revision changed all rays to hit inside solids and charged more. Three-argument calls now retain their existing results and cost. Supplying the new options explicitly opts into inside-solid hits and the new query cost.
95
+ - **Collider delivery:** the earlier PR revision copied every primitive field. Collider rows now carry only explicitly declared public `collider.fields`, and geometry revisions are sent only when changed relative to the snapshot baseline.
96
+ - **Build compatibility:** `label`, `dispatch`, `rayAll`, `rays` and `restart` remain legal ordinary property and destructuring names. Guarding capability calls does not reserve these data names.
97
+ - **Terrain compatibility:** the earlier PR revision changed terrain casting globally. Existing casts retain the 0.45.0 implementation; `body.terrain: 'exact'` and ray option `terrain: 'exact'` opt into the new algorithm and cost. Seeded compatibility tests pin old results and costs.
98
+ - Client-requested actor rewind is bounded by server-measured round-trip latency plus presentation and scheduling margins, within the declared history limit. A client cannot simply choose any point in the history window.
99
+ - Optional `sweep(body, delta, {ground:false})` skips redundant support work when the mover explicitly computes grounding. Default sweep behavior is unchanged. Exact terrain casts reject irrelevant support features early, reuse face-entry calculations and cache identical immutable queries within a handler. Ten thousand seeded exact casts pin zero result drift through the face optimization. Near-first tile traversal cuts charged work by 30% across 2,000 seeded dense-terrain casts; its maximum contact-component drift is 2.6e-14. These opt-in savings do not enlarge handler budgets.
100
+
101
+ ### Upgrade notes
102
+
103
+ - New ray and terrain policies are opt-in. Query filters and locally predicted feedback do not grant damage authority.
104
+ - `server:` save keys are reserved for declared room records; player-owned saves use other keys. Records retain at most 100 completed rounds.
105
+ - This capability work does not itself constitute a Stormbreak gameplay parity claim.
106
+
20
107
  ## [0.45.2] - 2026-10-10
21
108
 
22
109
  **Plugin 0.45.2** · [#76](https://github.com/homie-rocks/homie/pull/76)
@@ -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,10 +735,10 @@ 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
- server-verified money. All replicated fields are public, including disguised roles.
689
- Use a separate authorized records service for private or lasting app records.
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.
690
742
 
691
743
 
692
744
  ## Spatial delivery (0.45.0; milestone 2 slice 2)
@@ -772,3 +824,224 @@ known geometry beyond received ticks: a client cannot predict an unseen remote
772
824
  build/destruction. Once that update arrives, replay uses its geometry immediately.
773
825
  Moving platforms use world.place in their handlers; sweep/support see each tick's
774
826
  position. Passenger carrying is explicit movement logic, not automatic physics.
827
+
828
+ ## Filtered combat and camera queries
829
+
830
+ `world.ray(from, direction, metres, options?)` returns the nearest hit or undefined.
831
+ `world.rayAll` returns all entry hits in distance order (one per shape or named
832
+ part, not exit faces). Static geometry wins equal-distance ties; entity and part
833
+ insertion order break remaining ties. Both queries charge their scans, geometry,
834
+ filtering and output to the normal handler budget. The maximum ray distance is 10,000 metres; radius queries keep their 64 m bound; no query raises the game's budget.
835
+
836
+ ```ts
837
+ runner: {
838
+ // Movement remains a capsule; these boxes apply only to ray queries.
839
+ body: {shape: 'capsule', radius: .42, height: 1.8, maxSpeed: 22},
840
+ query: {layer: 'fighters', tags: ['damageable'], parts: {
841
+ body: {shape: 'box', radius: .42, height: 1.5},
842
+ head: {shape: 'box', radius: .24, height: .4, offset: {x: 0, y: 0, z: 1.5}},
843
+ }},
844
+ fields: {hp: f.fix({init: 100})},
845
+ // ...player, inputs, movement and handlers...
846
+ }
847
+ ```
848
+
849
+ Parts are axis-aligned, feet-relative shapes, at most 16 per kind. `hit.part`
850
+ is their declared name. A kind without parts uses its normal body or enabled
851
+ collider. Parts do not resize movement bodies. Optional `query.profiles` holds up to eight named part sets; `profile` in ray options selects one, falling back to the default parts when that kind has no matching profile. For example, projectile travel may use a full-height box while bullets use separate body/head boxes. Geometry-only rays always use collision shapes. Query layers and tags are static
852
+ kind declarations. A live collider's `enabled` and `size` fields still control
853
+ its presence and dimensions.
854
+
855
+ Options are plain data, never callbacks:
856
+
857
+ - `kind`, `tag`, `layer`: exact entity-kind, declared-tag and layer matches.
858
+ Undeclared layers default to `geometry` for live colliders and `body` otherwise.
859
+ Static map shapes use `geometry` and are not subject to kind/tag/field filters.
860
+ - `where: {hp: {gt: 0}}`: filter declared entity fields. Values may be exact
861
+ numbers, strings or booleans; numeric tests are `gt`, `gte`, `lt`, `lte`, plus
862
+ `eq`. All tests must match. Missing fields do not match. At most 16 fields.
863
+ - `ignore: [ref]`: at most 16 opaque entity refs. Entity-handler and movement rays ignore their own entity
864
+ by default; `ignoreSelf: false` includes it. This also applies when a moving
865
+ non-player entity is itself a live collider. Local view rays use the own player
866
+ as caller; room-handler rays have no implicit caller. Rocket travel can ignore its owner explicitly; blast damage
867
+ may include the owner.
868
+ - `geometryOnly: true`: map plus enabled live colliders, using their collision
869
+ shape rather than hit parts. Fighters do not shield other fighters. Use an
870
+ exclusion when testing the visibility of the cover being damaged. Evaluate
871
+ every visibility ray before sending damage events to preserve pre-blast cover.
872
+ - `entitiesOnly: true`: omit the static map. Combine with the filters above.
873
+ - `radius` and `shape`: cast a sphere (default) or axis-aligned box with this
874
+ half-size around the ray centre, useful for camera clearance. Radius defaults
875
+ to zero and is at most 100 metres. A point ray beginning inside a solid reports
876
+ an immediate hit; movement sweeps continue to allow escape from overlap.
877
+
878
+ `room.ray` and `room.rayAll` use the same implementation for local aiming and
879
+ cosmetic feedback. They use the drawn entity poses and latest known live geometry;
880
+ only server results award damage. Spatially absent non-collider entities cannot
881
+ be targeted by the view. Unknown remote edits are not predicted.
882
+
883
+ Movement's `ctx.world.ray/rayAll` (also on `ctx.map`) query its tick's frozen static
884
+ and live collision geometry with these same options. Collider kind, layer, tags
885
+ and scalar fields accompany collision revisions, including outside visual
886
+ interest, so a predicted geometry filter has the same data as authority.
887
+ Movement cannot query fighters or award damage. These APIs do not implement lag
888
+ compensation: they query the current authoritative or presented world.
889
+
890
+ ### Consent before driving an inherited bot
891
+
892
+ A player's declaration may set `control: 'accepted'`, naming a declared bit in
893
+ `motion`. When false, authority calls `think` even for a present person, and the
894
+ view draws the authoritative body without predicting that person's movement.
895
+ Commands remain available: validate an explicit consent command and set the bit
896
+ true. Initialize it on arrival and reset it in `on.takeover` when appropriate.
897
+ The server still owns driver/seat identity; this flag only controls input.
898
+
899
+ Set `takeover: 'fitness'` to name a numeric field used when selecting a fill bot.
900
+ The highest value wins (equal values use the highest seat). Update it in rules
901
+ from health, position or whatever makes a body suitable. Reserved companions
902
+ never yield. The selected entity keeps its ID, inventory and all state; only its
903
+ seat and driver change. A displaced bot retains its own state at the donor's old
904
+ seat. Without this declaration, existing seat-based selection is unchanged.
905
+
906
+ Movement queries reject distant live colliders by swept bounds before detailed
907
+ intersection, just as static map queries do. Bounds include capsule radius and
908
+ height, vertical columns and the full sweep, preserving fast travel and support.
909
+
910
+ ### Ordered same-tick interactions
911
+
912
+ `world.dispatch(id, event, payload)` delivers a declared event immediately and
913
+ returns whether a live receiver had a handler. The receiver changes only its own
914
+ state; after it returns, later queries see those changes. Use it for ordered
915
+ pellets, where destroying a wall or killing a fighter changes the next ray.
916
+ Ordinary `send` retains next-tick delivery. For a blast, collect every visibility
917
+ decision before dispatching damage so one broken wall still shields that blast.
918
+
919
+ Dispatch consumes the caller's remaining handler budget, including receiver work,
920
+ and permits at most eight nested deliveries. It does not grant writes to another
921
+ entity or shared state. It is not a transaction: mutations before an error remain
922
+ subject to ordinary field coercion. Missing targets return false; there is no
923
+ queued undeliverable event for immediate delivery. Movement cannot dispatch.
924
+
925
+ Solid-terrain capsule distance queries reject triangle faces whose bounds cannot
926
+ beat the closest feature already found. This preserves exact sampled geometry,
927
+ including both source triangles and the solid sides and underside.
928
+
929
+ ### Bounded actor-pose rewind
930
+
931
+ Declare `room.historySeconds: 1` (maximum two seconds). The server retains and
932
+ saves non-collider body positions and placement revisions on its virtual clock.
933
+ The view's `room.viewTick` is the tick used to interpolate remote bodies. Send it
934
+ with an action, then use `world.ray(..., {atTick, ...filters})`. Fractional ticks
935
+ interpolate saved poses. Player requests clamp to the smaller of the configured window and the server-measured round-trip latency plus 150 ms presentation delay and one simulation tick. Without a measurement only that small margin is allowed. The relay measures a nonce challenge; client-supplied latency figures are ignored.
936
+ A placement or new round prevents rewinding through a teleport or reset.
937
+
938
+ Rewind changes actor poses only. Eligibility filters use current fields, so a
939
+ corpse or a fighter killed by an earlier pellet cannot absorb later pellets.
940
+ Static terrain and live cover stay current: new cover blocks an old shot, and
941
+ destroyed cover no longer blocks. This conservative cover policy is explicit;
942
+ it is not a historical replay of world destruction. Rewind does not move actors,
943
+ change movement prediction, roll back damage or rewind projectiles. Choose the
944
+ window in game rules and validate actions normally; the client's tick is a claim.
945
+
946
+ Vertical capsule sweeps against solid terrain use an exact swept sphere against
947
+ the prism extended by the capsule centreline. Face, edge and vertex contacts
948
+ replace iterative grazing convergence; geometry and handler budgets stay intact.
949
+
950
+ For many related visibility or navigation queries in one handler, use
951
+ `const scene = world.rays(options)`, then `scene.ray(from, direction, metres)`
952
+ or `scene.rayAll(...)`. It captures actor poses, live geometry and filter fields
953
+ once; later damage or placement does not change that scene. Each ray is still
954
+ budgeted. Keep it local to the handler. Use ordinary `world.ray` when subsequent
955
+ hits must see mutations, such as sequential pellets. Predicted movement exposes
956
+ `ctx.world.rays` for its current geometry, too.
957
+
958
+ `world.label(playerRef)` reads the relay's current display name (including bot
959
+ names), at most 40 characters, or an empty string for a missing/non-player ref.
960
+ Copy it into a declared field when a historical message must survive departures
961
+ or seat exchanges. Names are presentation text, never proof of player identity;
962
+ use the existing owner/ref for identity. The host restores labels with its save.
963
+
964
+ For a body's planned route, capture `world.rays({geometryOnly:true,
965
+ shape:'capsule', radius:.4, height:1.8})`. A capsule ray starts at its centre;
966
+ `scene.sweep(scratchBody, delta)` and `scene.support(scratchBody, distance)` use
967
+ its foot position instead, like shared movement. A scratch body has plain `pos`
968
+ and `grounded` fields. These bounded queries change only that local copy, never
969
+ a player or the captured scene. Use the same movement helper to evaluate a
970
+ jump or dive without granting an action to the real body. Height is at least
971
+ twice the radius, at most 200 metres; spherical casts retain their diameter.
972
+
973
+ `world.round.restart()` is room-only. It abandons the current attempt and starts the
974
+ next round on the next tick, keeping seats and entities and running the normal
975
+ `roundStart` handlers. It publishes no completed results or `roundOver` event and
976
+ skips the intermission. Use it for an authorized retry; validate eligibility in
977
+ your room handler. Pending restart intent survives save/restore.
978
+
979
+
980
+ ### Discrete actions with a value
981
+
982
+ Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
983
+ `room.input({select: 2})` on the press and return to zero on release. A nonzero
984
+ value is queued once, including a pulse shorter than a simulation tick; held
985
+ samples do not create repeated actions. Zero is neutral. The reliable event
986
+ channel carries these actions, with at most 16 pending per seat and one consumed
987
+ per tick. Epoch changes discard old-round actions. `f.press()` remains the
988
+ boolean equivalent; `f.u8()` is sampled state and can miss a one-frame pulse.
989
+
990
+ ### Private entity fields and verified round history
991
+
992
+ Declare an entity field with `{visibility: 'server'}` for bot brains or secrets,
993
+ `{visibility: 'owner'}` for private inventory, or `{visibility: 'results'}` for
994
+ statistics revealed during intermission. Nested contents follow their top-level
995
+ field. Hidden values become neutral on the client; never depend on them in shared
996
+ movement. Server rules still read full state. Watchers receive public fields and
997
+ results only. Entity positions, driver information, shared state and motion are
998
+ public. This is field visibility, not fog-of-war entity hiding.
999
+
1000
+ A collider transmits only geometry plus explicitly named public scalar fields:
1001
+ `collider: {size: 'size', enabled: 'solid', fields: ['material']}`. Do not put
1002
+ private fields in that list. Unchanged collision rows are inherited from the
1003
+ snapshot baseline; keyframes and reconnects remain complete.
1004
+
1005
+ For verified history declare
1006
+ `room.records: {key: 'server:career-v1', identity: 'matchKey', eligible: 'ready',
1007
+ fields: {kills: 'earnedKills', damage: 'earnedDamage'}}`. The identity names a
1008
+ shared text field that rules make unique for each round. The optional eligibility
1009
+ names a player boolean field; result fields name numeric player fields. The
1010
+ Worker associates the participant with its authenticated player ID and stores
1011
+ `id`, `endedAt`, `place`, `won` and the declared totals when the round completes.
1012
+ A durable outbox retries failed writes; duplicate round IDs are ignored. Read
1013
+ with `createSaves().get(key)`. Browser writes, deletes and save resets cannot
1014
+ change `server:` records. Keep guest/authentication setup active before playing.
1015
+
1016
+ ### Opt-in exact terrain and baked navigation
1017
+
1018
+ Legacy three-argument rays keep their historical result and cost. Supplying ray
1019
+ options enables the richer query policy, including hits at distance zero when
1020
+ starting inside solids. Terrain changes are separate: set `terrain: 'exact'` in
1021
+ ray options or the entity body declaration to use exact face/edge/vertex terrain
1022
+ casts. Existing terrain behavior is retained when omitted. Test your map's
1023
+ slopes, ledges and movement when opting in.
1024
+
1025
+ Declare static graphs once with `navigation: {walk: graph}` in `defineRules`, then
1026
+ call `world.route('walk', from, to, {radius, height})`. Compilation validates and
1027
+ copies the graph once; handlers pay for the live search, not graph validation.
1028
+ At most eight named graphs with 8192 nodes total are accepted. Unknown names fail.
1029
+ The array form remains available for occasional dynamic graphs.
1030
+
1031
+ `world.route(graph, from, to, {radius, height})` searches an immutable authored
1032
+ walking graph. Nodes are `[x, y, z, neighbourIndices]`; endpoints are node indices.
1033
+ The graph has at most 8192 nodes and 16 neighbours per node. Edges must already
1034
+ encode static terrain walkability. The runtime checks live colliders, returns
1035
+ all node indices of a route or an empty list, and charges the search to the
1036
+ handler budget. It never substitutes a partial route at an expansion cap.
1037
+ Replan when the goal changes and when live geometry changes. Bake invalid or
1038
+ occupied terrain nodes out of the graph, and choose reachable endpoints.
1039
+ `@homie-rocks/nav` is the richer mesh/crowd API for applications that own that
1040
+ runtime; rules graphs avoid importing mutable navigation state into handlers.
1041
+
1042
+ `room.predict.idleHold: true` holds small visual reconciliation offsets while
1043
+ input and body are stationary. Movement resumes smoothing. Large invalid poses
1044
+ still snap to authority. Opt in only after measuring stop/reversal and delayed
1045
+ collision behavior for the game's mover.
1046
+
1047
+ For multi-cast movement or scratch trajectory checks, `ctx.world.sweep(body, delta, {ground:false})` (also captured-scene `sweep`) moves and returns the same collision but leaves `body.grounded` unchanged. Use it only when your mover computes support explicitly. Omitting the option retains the existing support query and grounding behavior. This avoids paying for identical support checks after horizontal and upward probes.
@@ -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 }]));
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 = {};
@@ -184,7 +184,7 @@ function play(c, companions, allow) {
184
184
  const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
185
185
 
186
186
  const movesToCheck = [];
187
- const moved = (...args) => movesToCheck.push(args);
187
+ const moved = (...args) => { movesToCheck.push(args); if (fault && fault.includes(`${args[0]}.move:`) && !fault.includes('Movement input:')) fault += ` Movement input: ${JSON.stringify(args[2])}; before: ${JSON.stringify(args[3])}.`; };
188
188
  let A = open({ observe, noted, moved, send });
189
189
  for (const m of told()) A.frame(m);
190
190
  /** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
@@ -236,8 +236,9 @@ function play(c, companions, allow) {
236
236
  const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
237
237
  const steering = new Map();
238
238
  let moveTick = 0;
239
+ let moveSelf;
239
240
  let moveGeometry = c.map;
240
- const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, geometry: () => moveGeometry, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
241
+ const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, geometry: () => moveGeometry, self: () => moveSelf, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
241
242
  /**
242
243
  * An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
243
244
  * the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
@@ -249,7 +250,7 @@ function play(c, companions, allow) {
249
250
  const e = w && L.P.unpackEntity(c.kinds, w, c.dims);
250
251
  if (!e) return null;
251
252
  G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
252
- moveTick = t;
253
+ moveTick = t; moveSelf = b.id;
253
254
  const body = L.P.stepMove(k.move, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: L.P.thawFields(k.motion, e.motion) }, Object.freeze(values), moveCtx.get(k.name), Math.max(1, Math.floor(budget / 4)), k.motion, c.dims,
254
255
  (error) => { fault ??= `${G.file}:${G.line} ${k.name}.move: ${fix(diagnostic(String(error?.message ?? error)))} A player's own browser runs this move for its body.${when()}`; });
255
256
  G.note = null;
@@ -345,9 +346,9 @@ function play(c, companions, allow) {
345
346
  const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
346
347
  A.tickNow();
347
348
  units += A.core.stats.tickUnits;
348
- for (const [name, tick, input, before, after, geometry] of movesToCheck.splice(0)) {
349
+ for (const [name, tick, input, before, after, geometry, id] of movesToCheck.splice(0)) {
349
350
  const kind = c.kinds.find(k => k.name === name);
350
- moveTick = tick; moveGeometry = geometry ?? c.map;
351
+ moveTick = tick; moveGeometry = geometry ?? c.map; moveSelf = id;
351
352
  const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
352
353
  error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
353
354
  units += result.used;
@@ -28,10 +28,10 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
28
28
 
29
29
  /** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
30
30
  export function stateHash(c) {
31
- const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save } = c;
31
+ const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds } = c;
32
32
  // Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
33
33
  const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
34
- return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
34
+ return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
35
35
  }
36
36
 
37
37
  /**
@@ -66,10 +66,11 @@ export const REFUSED_NAMES = ['constructor', 'prototype', '__proto__', 'stack',
66
66
  /** The names JavaScript calls by itself to turn an object into a number or a text: no function may sit under one. */
67
67
  export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
68
68
  /** What the host runtime's own objects offer: `world`, `ctx`, the round, the map (rules/core.ts) and `world.math` (rules/math.ts). */
69
- export const WORLD_METHODS = ['ticks', 'random', 'send', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'spot', 'spots'];
69
+ export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots'];
70
70
  export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
71
71
  const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
72
72
  export const MOVE_METHODS = ['support', 'overlaps'];
73
+ const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart']);
73
74
  const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
74
75
  const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
75
76
  /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
@@ -398,7 +399,7 @@ export function guardSource(code, { file = 'rules.js', map = null, linked = fals
398
399
  const decl = path.parentPath.isVariableDeclarator() && path.parentPath.parentPath.isVariableDeclaration() && statementsOf(path.parentPath.parentPath);
399
400
  for (const p of path.node.properties) {
400
401
  if (t.isRestElement(p)) continue;
401
- const risky = p.computed || CALLABLE.has(p.key.name ?? p.key.value);
402
+ const risky = p.computed || (CALLABLE.has(p.key.name ?? p.key.value) && !NEW_METHODS.has(p.key.name ?? p.key.value));
402
403
  if (risky && !(decl && t.isIdentifier(p.value))) bad(p, `take "${p.computed ? 'a computed key' : p.key.name ?? p.key.value}" out with a plain \`const { … } = value\` declaration, or read it with a dot: a pattern here could take a method out of a value`);
403
404
  }
404
405
  },
@@ -457,7 +458,7 @@ export function guardSource(code, { file = 'rules.js', map = null, linked = fals
457
458
  const checks = [];
458
459
  for (const d of path.node.declarations) {
459
460
  if (!t.isObjectPattern(d.id)) continue;
460
- for (const p of d.id.properties) if (!t.isRestElement(p) && t.isIdentifier(p.value) && (p.computed || CALLABLE.has(p.key.name ?? p.key.value))) checks.push(t.expressionStatement(call('nf', [t.identifier(p.value.name), line(p)])));
461
+ for (const p of d.id.properties) if (!t.isRestElement(p) && t.isIdentifier(p.value) && (p.computed || (CALLABLE.has(p.key.name ?? p.key.value) && !NEW_METHODS.has(p.key.name ?? p.key.value)))) checks.push(t.expressionStatement(call('nf', [t.identifier(p.value.name), line(p)])));
461
462
  }
462
463
  if (checks.length) path.insertAfter(checks);
463
464
  },
@@ -78,9 +78,9 @@ type W = World<${parameters}>;
78
78
  type RW = World<${parameters}, true>;
79
79
  type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
80
80
  entities: ${entities}; move?: Moves;
81
- room?: { rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number };
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
  /**