@homie-rocks/studio 0.45.1 → 0.45.3

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,59 @@ 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.45.3] - 2026-10-10
21
+
22
+ **Plugin 0.45.3**
23
+
24
+ Run richer server-authoritative combat, admission and bots with shared collision queries.
25
+
26
+ ### Added
27
+
28
+ - Opt-in ray options, ordered `rayAll` hits, named hit regions, projectile profiles, immutable query scenes and scratch movement queries.
29
+ - `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.
30
+ - 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.
31
+ - Authenticated room round records, with a durable retry outbox and idempotent player-history append. Browser saves cannot overwrite server records.
32
+ - 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.
33
+
34
+ ### Fixed
35
+
36
+ - **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.
37
+ - **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.
38
+ - **Build compatibility:** `label`, `dispatch`, `rayAll`, `rays` and `restart` remain legal ordinary property and destructuring names. Guarding capability calls does not reserve these data names.
39
+ - **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.
40
+ - 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.
41
+ - 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.
42
+
43
+ ### Upgrade notes
44
+
45
+ - New ray and terrain policies are opt-in. Query filters and locally predicted feedback do not grant damage authority.
46
+ - `server:` save keys are reserved for declared room records; player-owned saves use other keys. Records retain at most 100 completed rounds.
47
+ - This capability work does not itself constitute a Stormbreak gameplay parity claim.
48
+
49
+ ## [0.45.2] - 2026-10-10
50
+
51
+ **Plugin 0.45.2** · [#76](https://github.com/homie-rocks/homie/pull/76)
52
+
53
+ Give a game or app solid bodies, characters that climb steps, and a complete physics save.
54
+
55
+ ### Added
56
+
57
+ - **Deterministic 3D physics.** `@homie-rocks/physics` 0.1.0 brings rigid bodies, joints, collision and trigger events, spatial queries, triangle meshes and heightfields to browsers, Node and Workers. A world can use Y-up or Z-up, and a complete save resumes the same commands exactly.
58
+ - **Characters that share the world with moving bodies.** Capsules walk up and down stairs, follow slopes and platforms, push eligible props, and report the floors they touch. Small residual moves stop before reaching the engine. Large floors use nearby geometry, and terrain queries retain the engine's spatial index.
59
+
60
+ ### Fixed
61
+
62
+ - Crowds walking into walls no longer report being crushed. A moving body must leave no room to resolve the character for more than one substep. Crowd correction uses a fixed pass limit.
63
+ - Body, character and joint edits no longer serialize the world. Pending solver edits are included when a save is requested and resume at the same substep.
64
+
65
+ ### Upgrade notes
66
+
67
+ - Next release after merged [#75](https://github.com/homie-rocks/homie/pull/75). This branch is based on main 0.45.1 alone; paid parts and navigation are inherited from main. Rebase and run `node scripts/renumber-release.mjs` if main advances again. Physics is optional; existing studios gain no dependency.
68
+ - Physics remains private and studio does not depend on it, so releases skip it safely. After merge, run `node scripts/first-publish-private.mjs physics`, then remove the private flag and update the lockfile and package contract test in a follow-up after trusted publishing is registered.
69
+
70
+ - Initialize the owned WebAssembly engine before creating a world; the package README includes browser and Worker setup, impulses and walking on a heightfield. Physics does not automatically replace `@homie-rocks/walk` or its gait and biped parts.
71
+ - The README reports CPU measurements with machine load: 100 terrain walkers plus 500 awake bodies used 20.08 ms median and 24.88 ms p95 per 50 ms tick on an M4; adding and removing a body added 3.1% median CPU time. Dense rigid-body piles can still exceed that budget.
72
+
20
73
  ## [0.45.1] - 2026-10-09
21
74
 
22
75
  **Plugin 0.45.1** · [#75](https://github.com/homie-rocks/homie/pull/75)
@@ -685,8 +685,7 @@ score and other shared outcomes still arrive with the network. The studio choose
685
685
  Room state is saved automatically (default movementSeconds: 1, plus round end,
686
686
  pause and finish). After 60 seconds with no person the room ends even if screens or
687
687
  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.
688
+ 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
689
 
691
690
 
692
691
  ## Spatial delivery (0.45.0; milestone 2 slice 2)
@@ -772,3 +771,224 @@ known geometry beyond received ticks: a client cannot predict an unseen remote
772
771
  build/destruction. Once that update arrives, replay uses its geometry immediately.
773
772
  Moving platforms use world.place in their handlers; sweep/support see each tick's
774
773
  position. Passenger carrying is explicit movement logic, not automatic physics.
774
+
775
+ ## Filtered combat and camera queries
776
+
777
+ `world.ray(from, direction, metres, options?)` returns the nearest hit or undefined.
778
+ `world.rayAll` returns all entry hits in distance order (one per shape or named
779
+ part, not exit faces). Static geometry wins equal-distance ties; entity and part
780
+ insertion order break remaining ties. Both queries charge their scans, geometry,
781
+ 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.
782
+
783
+ ```ts
784
+ runner: {
785
+ // Movement remains a capsule; these boxes apply only to ray queries.
786
+ body: {shape: 'capsule', radius: .42, height: 1.8, maxSpeed: 22},
787
+ query: {layer: 'fighters', tags: ['damageable'], parts: {
788
+ body: {shape: 'box', radius: .42, height: 1.5},
789
+ head: {shape: 'box', radius: .24, height: .4, offset: {x: 0, y: 0, z: 1.5}},
790
+ }},
791
+ fields: {hp: f.fix({init: 100})},
792
+ // ...player, inputs, movement and handlers...
793
+ }
794
+ ```
795
+
796
+ Parts are axis-aligned, feet-relative shapes, at most 16 per kind. `hit.part`
797
+ is their declared name. A kind without parts uses its normal body or enabled
798
+ 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
799
+ kind declarations. A live collider's `enabled` and `size` fields still control
800
+ its presence and dimensions.
801
+
802
+ Options are plain data, never callbacks:
803
+
804
+ - `kind`, `tag`, `layer`: exact entity-kind, declared-tag and layer matches.
805
+ Undeclared layers default to `geometry` for live colliders and `body` otherwise.
806
+ Static map shapes use `geometry` and are not subject to kind/tag/field filters.
807
+ - `where: {hp: {gt: 0}}`: filter declared entity fields. Values may be exact
808
+ numbers, strings or booleans; numeric tests are `gt`, `gte`, `lt`, `lte`, plus
809
+ `eq`. All tests must match. Missing fields do not match. At most 16 fields.
810
+ - `ignore: [ref]`: at most 16 opaque entity refs. Entity-handler and movement rays ignore their own entity
811
+ by default; `ignoreSelf: false` includes it. This also applies when a moving
812
+ non-player entity is itself a live collider. Local view rays use the own player
813
+ as caller; room-handler rays have no implicit caller. Rocket travel can ignore its owner explicitly; blast damage
814
+ may include the owner.
815
+ - `geometryOnly: true`: map plus enabled live colliders, using their collision
816
+ shape rather than hit parts. Fighters do not shield other fighters. Use an
817
+ exclusion when testing the visibility of the cover being damaged. Evaluate
818
+ every visibility ray before sending damage events to preserve pre-blast cover.
819
+ - `entitiesOnly: true`: omit the static map. Combine with the filters above.
820
+ - `radius` and `shape`: cast a sphere (default) or axis-aligned box with this
821
+ half-size around the ray centre, useful for camera clearance. Radius defaults
822
+ to zero and is at most 100 metres. A point ray beginning inside a solid reports
823
+ an immediate hit; movement sweeps continue to allow escape from overlap.
824
+
825
+ `room.ray` and `room.rayAll` use the same implementation for local aiming and
826
+ cosmetic feedback. They use the drawn entity poses and latest known live geometry;
827
+ only server results award damage. Spatially absent non-collider entities cannot
828
+ be targeted by the view. Unknown remote edits are not predicted.
829
+
830
+ Movement's `ctx.world.ray/rayAll` (also on `ctx.map`) query its tick's frozen static
831
+ and live collision geometry with these same options. Collider kind, layer, tags
832
+ and scalar fields accompany collision revisions, including outside visual
833
+ interest, so a predicted geometry filter has the same data as authority.
834
+ Movement cannot query fighters or award damage. These APIs do not implement lag
835
+ compensation: they query the current authoritative or presented world.
836
+
837
+ ### Consent before driving an inherited bot
838
+
839
+ A player's declaration may set `control: 'accepted'`, naming a declared bit in
840
+ `motion`. When false, authority calls `think` even for a present person, and the
841
+ view draws the authoritative body without predicting that person's movement.
842
+ Commands remain available: validate an explicit consent command and set the bit
843
+ true. Initialize it on arrival and reset it in `on.takeover` when appropriate.
844
+ The server still owns driver/seat identity; this flag only controls input.
845
+
846
+ Set `takeover: 'fitness'` to name a numeric field used when selecting a fill bot.
847
+ The highest value wins (equal values use the highest seat). Update it in rules
848
+ from health, position or whatever makes a body suitable. Reserved companions
849
+ never yield. The selected entity keeps its ID, inventory and all state; only its
850
+ seat and driver change. A displaced bot retains its own state at the donor's old
851
+ seat. Without this declaration, existing seat-based selection is unchanged.
852
+
853
+ Movement queries reject distant live colliders by swept bounds before detailed
854
+ intersection, just as static map queries do. Bounds include capsule radius and
855
+ height, vertical columns and the full sweep, preserving fast travel and support.
856
+
857
+ ### Ordered same-tick interactions
858
+
859
+ `world.dispatch(id, event, payload)` delivers a declared event immediately and
860
+ returns whether a live receiver had a handler. The receiver changes only its own
861
+ state; after it returns, later queries see those changes. Use it for ordered
862
+ pellets, where destroying a wall or killing a fighter changes the next ray.
863
+ Ordinary `send` retains next-tick delivery. For a blast, collect every visibility
864
+ decision before dispatching damage so one broken wall still shields that blast.
865
+
866
+ Dispatch consumes the caller's remaining handler budget, including receiver work,
867
+ and permits at most eight nested deliveries. It does not grant writes to another
868
+ entity or shared state. It is not a transaction: mutations before an error remain
869
+ subject to ordinary field coercion. Missing targets return false; there is no
870
+ queued undeliverable event for immediate delivery. Movement cannot dispatch.
871
+
872
+ Solid-terrain capsule distance queries reject triangle faces whose bounds cannot
873
+ beat the closest feature already found. This preserves exact sampled geometry,
874
+ including both source triangles and the solid sides and underside.
875
+
876
+ ### Bounded actor-pose rewind
877
+
878
+ Declare `room.historySeconds: 1` (maximum two seconds). The server retains and
879
+ saves non-collider body positions and placement revisions on its virtual clock.
880
+ The view's `room.viewTick` is the tick used to interpolate remote bodies. Send it
881
+ with an action, then use `world.ray(..., {atTick, ...filters})`. Fractional ticks
882
+ 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.
883
+ A placement or new round prevents rewinding through a teleport or reset.
884
+
885
+ Rewind changes actor poses only. Eligibility filters use current fields, so a
886
+ corpse or a fighter killed by an earlier pellet cannot absorb later pellets.
887
+ Static terrain and live cover stay current: new cover blocks an old shot, and
888
+ destroyed cover no longer blocks. This conservative cover policy is explicit;
889
+ it is not a historical replay of world destruction. Rewind does not move actors,
890
+ change movement prediction, roll back damage or rewind projectiles. Choose the
891
+ window in game rules and validate actions normally; the client's tick is a claim.
892
+
893
+ Vertical capsule sweeps against solid terrain use an exact swept sphere against
894
+ the prism extended by the capsule centreline. Face, edge and vertex contacts
895
+ replace iterative grazing convergence; geometry and handler budgets stay intact.
896
+
897
+ For many related visibility or navigation queries in one handler, use
898
+ `const scene = world.rays(options)`, then `scene.ray(from, direction, metres)`
899
+ or `scene.rayAll(...)`. It captures actor poses, live geometry and filter fields
900
+ once; later damage or placement does not change that scene. Each ray is still
901
+ budgeted. Keep it local to the handler. Use ordinary `world.ray` when subsequent
902
+ hits must see mutations, such as sequential pellets. Predicted movement exposes
903
+ `ctx.world.rays` for its current geometry, too.
904
+
905
+ `world.label(playerRef)` reads the relay's current display name (including bot
906
+ names), at most 40 characters, or an empty string for a missing/non-player ref.
907
+ Copy it into a declared field when a historical message must survive departures
908
+ or seat exchanges. Names are presentation text, never proof of player identity;
909
+ use the existing owner/ref for identity. The host restores labels with its save.
910
+
911
+ For a body's planned route, capture `world.rays({geometryOnly:true,
912
+ shape:'capsule', radius:.4, height:1.8})`. A capsule ray starts at its centre;
913
+ `scene.sweep(scratchBody, delta)` and `scene.support(scratchBody, distance)` use
914
+ its foot position instead, like shared movement. A scratch body has plain `pos`
915
+ and `grounded` fields. These bounded queries change only that local copy, never
916
+ a player or the captured scene. Use the same movement helper to evaluate a
917
+ jump or dive without granting an action to the real body. Height is at least
918
+ twice the radius, at most 200 metres; spherical casts retain their diameter.
919
+
920
+ `world.round.restart()` is room-only. It abandons the current attempt and starts the
921
+ next round on the next tick, keeping seats and entities and running the normal
922
+ `roundStart` handlers. It publishes no completed results or `roundOver` event and
923
+ skips the intermission. Use it for an authorized retry; validate eligibility in
924
+ your room handler. Pending restart intent survives save/restore.
925
+
926
+
927
+ ### Discrete actions with a value
928
+
929
+ Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
930
+ `room.input({select: 2})` on the press and return to zero on release. A nonzero
931
+ value is queued once, including a pulse shorter than a simulation tick; held
932
+ samples do not create repeated actions. Zero is neutral. The reliable event
933
+ channel carries these actions, with at most 16 pending per seat and one consumed
934
+ per tick. Epoch changes discard old-round actions. `f.press()` remains the
935
+ boolean equivalent; `f.u8()` is sampled state and can miss a one-frame pulse.
936
+
937
+ ### Private entity fields and verified round history
938
+
939
+ Declare an entity field with `{visibility: 'server'}` for bot brains or secrets,
940
+ `{visibility: 'owner'}` for private inventory, or `{visibility: 'results'}` for
941
+ statistics revealed during intermission. Nested contents follow their top-level
942
+ field. Hidden values become neutral on the client; never depend on them in shared
943
+ movement. Server rules still read full state. Watchers receive public fields and
944
+ results only. Entity positions, driver information, shared state and motion are
945
+ public. This is field visibility, not fog-of-war entity hiding.
946
+
947
+ A collider transmits only geometry plus explicitly named public scalar fields:
948
+ `collider: {size: 'size', enabled: 'solid', fields: ['material']}`. Do not put
949
+ private fields in that list. Unchanged collision rows are inherited from the
950
+ snapshot baseline; keyframes and reconnects remain complete.
951
+
952
+ For verified history declare
953
+ `room.records: {key: 'server:career-v1', identity: 'matchKey', eligible: 'ready',
954
+ fields: {kills: 'earnedKills', damage: 'earnedDamage'}}`. The identity names a
955
+ shared text field that rules make unique for each round. The optional eligibility
956
+ names a player boolean field; result fields name numeric player fields. The
957
+ Worker associates the participant with its authenticated player ID and stores
958
+ `id`, `endedAt`, `place`, `won` and the declared totals when the round completes.
959
+ A durable outbox retries failed writes; duplicate round IDs are ignored. Read
960
+ with `createSaves().get(key)`. Browser writes, deletes and save resets cannot
961
+ change `server:` records. Keep guest/authentication setup active before playing.
962
+
963
+ ### Opt-in exact terrain and baked navigation
964
+
965
+ Legacy three-argument rays keep their historical result and cost. Supplying ray
966
+ options enables the richer query policy, including hits at distance zero when
967
+ starting inside solids. Terrain changes are separate: set `terrain: 'exact'` in
968
+ ray options or the entity body declaration to use exact face/edge/vertex terrain
969
+ casts. Existing terrain behavior is retained when omitted. Test your map's
970
+ slopes, ledges and movement when opting in.
971
+
972
+ Declare static graphs once with `navigation: {walk: graph}` in `defineRules`, then
973
+ call `world.route('walk', from, to, {radius, height})`. Compilation validates and
974
+ copies the graph once; handlers pay for the live search, not graph validation.
975
+ At most eight named graphs with 8192 nodes total are accepted. Unknown names fail.
976
+ The array form remains available for occasional dynamic graphs.
977
+
978
+ `world.route(graph, from, to, {radius, height})` searches an immutable authored
979
+ walking graph. Nodes are `[x, y, z, neighbourIndices]`; endpoints are node indices.
980
+ The graph has at most 8192 nodes and 16 neighbours per node. Edges must already
981
+ encode static terrain walkability. The runtime checks live colliders, returns
982
+ all node indices of a route or an empty list, and charges the search to the
983
+ handler budget. It never substitutes a partial route at an expansion cap.
984
+ Replan when the goal changes and when live geometry changes. Bake invalid or
985
+ occupied terrain nodes out of the graph, and choose reachable endpoints.
986
+ `@homie-rocks/nav` is the richer mesh/crowd API for applications that own that
987
+ runtime; rules graphs avoid importing mutable navigation state into handlers.
988
+
989
+ `room.predict.idleHold: true` holds small visual reconciliation offsets while
990
+ input and body are stationary. Movement resumes smoothing. Large invalid poses
991
+ still snap to authority. Opt in only after measuring stop/reversal and delayed
992
+ collision behavior for the game's mover.
993
+
994
+ 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.
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, { 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,7 +78,7 @@ 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
83
  join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => ${joins};
84
84
  on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
@@ -2607,6 +2607,90 @@
2607
2607
  }
2608
2608
  }
2609
2609
  }
2610
+ },
2611
+ "0.45.2": {
2612
+ "files": {
2613
+ "CLAUDE.md": "ddf2b2a0376202f6",
2614
+ "README.md": "684b3cca8001673c",
2615
+ "HANDOFF.md": "29337e2ace95b2e7",
2616
+ "games/README.md": "5ad32fe80c61f117",
2617
+ "music/README.md": "a3dc042dadd39df8",
2618
+ "videos/README.md": "16babc3437d5545b",
2619
+ "posts/README.md": "37841aae476bd540",
2620
+ "site/README.md": "732894273a32dc3f",
2621
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2622
+ },
2623
+ "sections": {
2624
+ "AGENTS.md": {
2625
+ "(intro)": "214aa46eb3a895d5",
2626
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2627
+ "## Layout": "f3a280b8c96cd068",
2628
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2629
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2630
+ "## The site": "0cb8dd9a6f8a0096",
2631
+ "## Making games": "bb715272fd1a615b",
2632
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2633
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2634
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2635
+ "## Servers and AI seats": "1ccfd549d2369a60",
2636
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2637
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2638
+ "## Rules": "c48971c1437414c2",
2639
+ "## Telling Homie": "80b46fdd9e578db8",
2640
+ "## Beta": "9d02638207a26e73"
2641
+ }
2642
+ },
2643
+ "named": {
2644
+ "Homie Arcade": {
2645
+ "sections": {
2646
+ "AGENTS.md": {
2647
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2648
+ }
2649
+ }
2650
+ }
2651
+ }
2652
+ },
2653
+ "0.45.3": {
2654
+ "files": {
2655
+ "CLAUDE.md": "ddf2b2a0376202f6",
2656
+ "README.md": "684b3cca8001673c",
2657
+ "HANDOFF.md": "29337e2ace95b2e7",
2658
+ "games/README.md": "5ad32fe80c61f117",
2659
+ "music/README.md": "a3dc042dadd39df8",
2660
+ "videos/README.md": "16babc3437d5545b",
2661
+ "posts/README.md": "37841aae476bd540",
2662
+ "site/README.md": "732894273a32dc3f",
2663
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2664
+ },
2665
+ "sections": {
2666
+ "AGENTS.md": {
2667
+ "(intro)": "214aa46eb3a895d5",
2668
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2669
+ "## Layout": "f3a280b8c96cd068",
2670
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2671
+ "## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
2672
+ "## The site": "0cb8dd9a6f8a0096",
2673
+ "## Making games": "bb715272fd1a615b",
2674
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2675
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2676
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2677
+ "## Servers and AI seats": "1ccfd549d2369a60",
2678
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2679
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2680
+ "## Rules": "c48971c1437414c2",
2681
+ "## Telling Homie": "80b46fdd9e578db8",
2682
+ "## Beta": "9d02638207a26e73"
2683
+ }
2684
+ },
2685
+ "named": {
2686
+ "Homie Arcade": {
2687
+ "sections": {
2688
+ "AGENTS.md": {
2689
+ "## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
2690
+ }
2691
+ }
2692
+ }
2693
+ }
2610
2694
  }
2611
2695
  }
2612
2696
  }
@@ -2395,6 +2395,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2395
2395
  return;
2396
2396
  }
2397
2397
  case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
2398
+ case 'probe': if (typeof m.n === 'string') raw({t:'probeAck',n:m.n}); return;
2398
2399
  case 'pong': return onPong(m);
2399
2400
  case 'announce': {
2400
2401
  if (typeof m['id'] !== 'string') return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.45.1",
3
+ "version": "0.45.3",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,12 +1,13 @@
1
1
  import { castTerrain } from './terrain.ts';
2
+ import { castTerrain as legacyTerrain } from './terrain-legacy.ts';
2
3
  import { nearbyMap } from './map-index.ts';
3
4
  /** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
4
5
  * Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
5
6
  */
6
- import { charge } from './guard.ts';
7
+ import { charge, G } from './guard.ts';
7
8
  import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
8
9
 
9
- export interface BodyShape { shape: string; radius: number; height: number }
10
+ export interface BodyShape { shape: string; radius: number; height: number; terrain?: 'exact' }
10
11
  export interface Solid { min: Vec3; max: Vec3; r: number }
11
12
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
12
13
  export interface Map3 {
@@ -14,7 +15,7 @@ export interface Map3 {
14
15
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
15
16
  heightTiles?: readonly MapHeightTile[];
16
17
  spheres?: readonly MapCircle[];
17
- capsules?: readonly (MapCircle & { height: number })[];
18
+ capsules?: readonly (MapCircle & { height: number; terrain?: 'exact' })[];
18
19
  }
19
20
  const EPS = 1e-7;
20
21
  const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
@@ -33,8 +34,15 @@ function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
33
34
  /** Continuous convex cast: each separating plane gives a lower bound on impact.
34
35
  * Every iteration is charged, and a fixed cap bounds grazing contacts as well.
35
36
  */
36
- export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
37
+ export function castSolid(a: Solid, d: Vec3, b: Solid, includeInside = false): Hit3 | null {
37
38
  charge(30);
39
+ if (includeInside) {
40
+ const gap = separation(a, b, d, 0);
41
+ const sq = gap.x*gap.x + gap.y*gap.y + gap.z*gap.z;
42
+ const radius=a.r+b.r;
43
+ const inside = radius > 0 ? sq < radius*radius : a.max.x > b.min.x && a.min.x < b.max.x && a.max.y > b.min.y && a.min.y < b.max.y && a.max.z > b.min.z && a.min.z < b.max.z;
44
+ if (inside) {const length = Math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z)||1; return {t:0,nx:-d.x/length,ny:-d.y/length,nz:-d.z/length};}
45
+ }
38
46
  let t = 0;
39
47
  const r = a.r + b.r;
40
48
  let normal = point(0, 0, 0);
@@ -94,12 +102,27 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
94
102
  return best;
95
103
  }
96
104
 
97
- export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
105
+ let memoEpoch = -1;
106
+ let castMemo = new WeakMap<object, Map<string, Hit3 | null>>();
107
+ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
108
+ // Exact casts can reuse identical immutable-scene queries within one handler.
109
+ // The epoch resets at every authority handler, including after restore.
110
+ if(body.terrain !== 'exact' || hits || !Number.isFinite(G.left)) return castMap3Uncached(map,p,d,body,hits,includeInside);
111
+ if(memoEpoch!==G.queryEpoch){memoEpoch=G.queryEpoch;castMemo=new WeakMap();}
112
+ charge(16);
113
+ let rows=castMemo.get(map);if(!rows){rows=new Map();castMemo.set(map,rows);}
114
+ const key=[p.x,p.y,p.z,d.x,d.y,d.z,body.shape,body.radius,body.height,includeInside].join(',');
115
+ if(rows.has(key))return rows.get(key)!;
116
+ const hit=castMap3Uncached(map,p,d,body,hits,includeInside);
117
+ if(rows.size<512)rows.set(key,hit);
118
+ return hit;
119
+ }
120
+ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: Hit3[], includeInside = false): Hit3 | null {
98
121
  map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
99
122
  charge(24);
100
123
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
101
124
  let best: Hit3 | null = null;
102
- const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = id ? {...h, id} : h; };
125
+ const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
103
126
  for (const axis of ['x', 'y', 'z'] as const) {
104
127
  const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
105
128
  const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
@@ -107,12 +130,29 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
107
130
  const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
108
131
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
109
132
  }
110
- for (const tile of map.heightTiles ?? []) take(tile.base === undefined ? castHeightTile(tile, p, d) : castTerrain(tile, a, d));
111
- for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
133
+ const exactTerrain = body.terrain === 'exact';
134
+ const terrain = [...(map.heightTiles ?? [])];
135
+ if(exactTerrain && d.z<0){charge(terrain.length*Math.ceil(Math.log2(terrain.length+1)));terrain.sort((a,b)=>(b.at.z+Math.max(...b.heights))-(a.at.z+Math.max(...a.heights)));}
136
+ // Visit the nearest possible tile contacts first so later casts are shortened.
137
+ // All-hit queries retain authored ordering; legacy casts retain cost and behavior.
138
+ if(exactTerrain && (d.x||d.y) && !hits){
139
+ const first=(t:MapHeightTile)=>{let near=0;for(const k of ['x','y'] as const){if(d[k])near=Math.max(near,((d[k]>0?t.at[k]-r:t.at[k]+t.size[k]+r)-p[k])/d[k]);}return near;};
140
+ charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
141
+ }
142
+ for (const tile of terrain) {
143
+ if(exactTerrain && !hits && best?.t===0)break;
144
+ // Once a nearer surface is known, later solids only need to beat that time.
145
+ // This also bounds work on densely tessellated terrain behind the first hit.
146
+ const limit = exactTerrain && !hits && best ? best.t : 1;
147
+ const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
148
+ const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
149
+ if (hit) take({...hit, t: hit.t * limit});
150
+ }
151
+ for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }, includeInside), (b as any).id);
112
152
  // Legacy map circles are vertical columns in a 3D map.
113
- for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
114
- for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
115
- for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
153
+ for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }, includeInside), (c as any).id);
154
+ for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }, includeInside), (s as any).id);
155
+ for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height }), includeInside), (c as any).id);
116
156
  return best;
117
157
  }
118
158
  export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {