@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 +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +108 -29
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
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)
|
package/guides/game/RULES.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
package/guides/game/SKILL.md
CHANGED
|
@@ -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.
|
|
186
|
-
|
|
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;
|
package/lib/rules-check.mjs
CHANGED
|
@@ -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
|
/**
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -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
|
},
|
package/lib/rules-types.mjs
CHANGED
|
@@ -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
|
/**
|