@homie-rocks/studio 0.46.6 → 0.47.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 +44 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +67 -0
- package/guides/game/SKILL.md +6 -0
- package/lib/rules-check-worker.mjs +2 -1
- package/lib/rules-guard.mjs +2 -2
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/rules/collision.ts +1 -0
- package/rules/core.ts +81 -33
- package/rules/host.ts +2 -1
- package/rules/interest.mjs +29 -14
- package/rules/live.ts +4 -3
- package/rules/map-index.ts +24 -0
- package/rules/map-state.ts +54 -0
- package/rules/math.ts +8 -5
- package/rules/nav/Flow.ts +53 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +22 -4
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +2 -2
- package/rules/view.ts +45 -19
- package/worker/build-id.mjs +1 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,52 @@ 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.47.0] - 2026-10-13
|
|
21
|
+
|
|
22
|
+
**Plugin 0.47.0** · Wrapped worlds and generated collision maps.
|
|
23
|
+
|
|
24
|
+
Games can wrap across map edges and replace seeded tile levels during play, with the same collision geometry in server rules and prediction.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Maps may opt into wrapping independently on x, y and z with `wrap: {x:true}`;
|
|
29
|
+
periods come from bounds. Bodies, sweeps, rays, overlaps, support, near/box queries,
|
|
30
|
+
area events, ranged effects, spatial delivery, rewind and view interpolation use
|
|
31
|
+
periodic geometry. Crossing an edge wraps rather than clamps. Sweeps/rays retain
|
|
32
|
+
their supplied direction and pay for each periodic image they visit.
|
|
33
|
+
- Room handlers can call `world.map.replace(data)`. A replacement is validated,
|
|
34
|
+
indexed and activated atomically at the next tick, preserving bounds and wrapping.
|
|
35
|
+
Compact current/pending revisions are saved and sent once per collision revision;
|
|
36
|
+
local prediction replays the same compiled map by activation tick.
|
|
37
|
+
- Maps accept row-major `tiles` with width, height, size, optional origin and solid
|
|
38
|
+
height. Nonzero cells compile to indexed horizontal runs. Rules and movement can
|
|
39
|
+
query `map.tile(x,y)`; views read `room.map.tiles`.
|
|
40
|
+
- Named multi-source cardinal `tiles.flows` bake during map compilation. Rules and
|
|
41
|
+
movement use constant-work `map.distance(name,x,y)` and `map.flow(name,x,y)`.
|
|
42
|
+
The standalone rules runtime ships the nav package's new `Flow.js` baker, checked
|
|
43
|
+
against its source in the release gate. Generation, copying, indexing and baking
|
|
44
|
+
are charged to the rules handler; ordinary static-map budget charges are unchanged.
|
|
45
|
+
- Seeded virtual-time release cases cover two-view seam crossing in 2D/3D and
|
|
46
|
+
generated-map collision, mid-round regeneration and restore, including 300 ms
|
|
47
|
+
delay and 12% loss. Geometry and navigation tests cover periodic casts,
|
|
48
|
+
multi-period movement, atomic failure and independently checked shortest paths.
|
|
49
|
+
Eight-player generation/restore replays also match every save and snapshot
|
|
50
|
+
across Node, Chrome and workerd for 240 seeded ticks.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Handlers reached synchronously by `world.dispatch` now report their coverage to
|
|
55
|
+
the build checker, including failures, without opening a second handler budget.
|
|
56
|
+
- The rewrite guide specifies named helper re-exports (`export *` is refused), and
|
|
57
|
+
the rules guide explains that mandatory spawn fields precede deferred `arrive`.
|
|
58
|
+
|
|
59
|
+
This release covers wrapped worlds and generated levels. Arbitrary triangle-mesh
|
|
60
|
+
collision, the measured Rapier-versus-kernel decision, first-class integration
|
|
61
|
+
substeps, and the assisted rewrite command remain follow-up work.
|
|
62
|
+
|
|
20
63
|
## [0.46.6] - 2026-10-11
|
|
21
64
|
|
|
22
|
-
**Plugin 0.46.6** · Big rooms: what one room carries, measured.
|
|
65
|
+
**Plugin 0.46.6** · Big rooms: what one room carries, measured. · [release-2026-10-11-studio-0.46.6](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-11-studio-0.46.6)
|
|
23
66
|
|
|
24
67
|
What one room object carries on Cloudflare, measured in six trials and stated: with a light game at 20 ticks a second, 300 players with margin, 600 at the edge, 800 to 1,000 only by luck.
|
|
25
68
|
|
package/guides/game/REWRITE.md
CHANGED
|
@@ -17,7 +17,8 @@ Read RULES.md before starting.
|
|
|
17
17
|
An old file named rules.ts may already export pure helpers that tests import.
|
|
18
18
|
Preserve that public test API and every existing test file byte-for-byte,
|
|
19
19
|
including imports. Re-export the old helpers from rules.ts if extracted into
|
|
20
|
-
another module
|
|
20
|
+
another module with named exports (`export { helper } from './helper'`);
|
|
21
|
+
`export *` is refused. Do not redirect tests to that module. Record test-file hashes
|
|
21
22
|
before editing and compare them at the end. The filename alone does not
|
|
22
23
|
mean it is a rules-plus-view game. If tests now load toolkit TypeScript under
|
|
23
24
|
Node, use a narrowly scoped TypeScript loader and keep the normal test command
|
package/guides/game/RULES.md
CHANGED
|
@@ -1178,3 +1178,70 @@ still snap to authority. Opt in only after measuring stop/reversal and delayed
|
|
|
1178
1178
|
collision behavior for the game's mover.
|
|
1179
1179
|
|
|
1180
1180
|
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.
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
## Wrapped worlds and generated levels (0.47.0)
|
|
1184
|
+
|
|
1185
|
+
Declare wrapping in the map, independently per axis. The period is the bounds'
|
|
1186
|
+
extent; an omitted axis keeps its existing bounded behaviour:
|
|
1187
|
+
|
|
1188
|
+
```json
|
|
1189
|
+
{"bounds":{"min":[0,0,0],"max":[25,25,10]},"wrap":{"x":true}}
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
Bodies and hit positions use canonical coordinates in `[min,max)`. Sweeps and rays
|
|
1193
|
+
follow the supplied displacement through periodic copies of the geometry; they can
|
|
1194
|
+
cross more than one period and pay for each visited image. Near/box queries, area
|
|
1195
|
+
events, effect ranges, spatial delivery, rewind and drawing interpolation use the
|
|
1196
|
+
short way around. Exactly half a period chooses the negative direction. A renderer
|
|
1197
|
+
can read `room.map.wrap.x` (the pair `[min,max]`) to draw copies at the viewport's
|
|
1198
|
+
edges. The server does not duplicate entities. In a maze with one tunnel, keep
|
|
1199
|
+
solid border cells on every other row; wrapping itself is per axis, not per row.
|
|
1200
|
+
|
|
1201
|
+
For a generated maze, replace the room's current map in `room.start` or a room
|
|
1202
|
+
handler. Build its data using the rules' seeded random source, then publish once:
|
|
1203
|
+
|
|
1204
|
+
```ts
|
|
1205
|
+
world.map.replace({
|
|
1206
|
+
bounds: { min: [0, 0], max: [25, 25] },
|
|
1207
|
+
wrap: { x: true },
|
|
1208
|
+
tiles: { width: 25, height: 25, size: 1, data: cells,
|
|
1209
|
+
flows: { exit: [312] } },
|
|
1210
|
+
spots: { start: [[12.5, 19.5]] },
|
|
1211
|
+
});
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
`replace` preserves the original bounds and wrapping. It validates, bakes cardinal
|
|
1215
|
+
navigation fields and compiles the spatial index before publishing anything. A
|
|
1216
|
+
failed bake leaves the previous revision intact. The last successful replacement
|
|
1217
|
+
in a tick becomes current at the next tick; all bodies use the same revision.
|
|
1218
|
+
The compact map data and activation tick travel with collision revisions and saves,
|
|
1219
|
+
including both current and pending maps. Prediction replays against that tick's
|
|
1220
|
+
compiled geometry. Late knowledge is corrected by the normal reconciliation path.
|
|
1221
|
+
|
|
1222
|
+
Tiles are row-major integers: zero is open, nonzero is solid. `at` defaults to
|
|
1223
|
+
`[0,0,0]`, `size` and `solidHeight` to 1. Horizontal runs become indexed boxes,
|
|
1224
|
+
including in 3D. On a wrapped axis the grid must span the full map. Authored boxes,
|
|
1225
|
+
round shapes, terrain and spots can be included in the same replacement. The view
|
|
1226
|
+
reads `room.map.tiles`; rules and movement read `world.map.tile(x,y)` and
|
|
1227
|
+
`ctx.map.tile(x,y)`. Cell coordinates are integers, not metres. Outside an unwrapped
|
|
1228
|
+
grid is solid. A custom tile mover may read these cells instead of using sweeps.
|
|
1229
|
+
|
|
1230
|
+
`flows` names lists of open destination cell indices. `map.distance(name,x,y)` gives
|
|
1231
|
+
cardinal hops to the nearest destination, or -1 when unreachable/unknown;
|
|
1232
|
+
`map.flow(name,x,y)` gives the next unit direction, zero at a destination, or
|
|
1233
|
+
undefined when unreachable/unknown. Each lookup is constant work (4 or 8 budget
|
|
1234
|
+
units). Baking uses `@homie-rocks/nav`'s shared cardinal flow implementation;
|
|
1235
|
+
left/up/right/down resolves ties, including tunnels. Change destinations by
|
|
1236
|
+
publishing new flow declarations with the map. This is a bake, not an unmetered
|
|
1237
|
+
path search: budget for every field and regenerate only when its goals change.
|
|
1238
|
+
|
|
1239
|
+
Room replacement charges copying, grid scanning, index construction and each flow
|
|
1240
|
+
bake to the calling handler. Snapshots charge for the compact data. Existing static
|
|
1241
|
+
maps need no replacement and retain their previous budget charges. A few thousand
|
|
1242
|
+
cells regenerated a few times a round belong in one map revision; live collider
|
|
1243
|
+
entities remain suitable for independently moving doors, cover and platforms.
|
|
1244
|
+
|
|
1245
|
+
`spawn` exposes a new entity before its deferred `on.arrive` handler. Pass every
|
|
1246
|
+
field that must be valid immediately in the spawn data; use `arrive` for subsequent
|
|
1247
|
+
work, not for mandatory initialization.
|
package/guides/game/SKILL.md
CHANGED
|
@@ -75,6 +75,12 @@ Cloudflare or physical-phone capacity guarantees.
|
|
|
75
75
|
|
|
76
76
|
## Write and change rules plus view
|
|
77
77
|
|
|
78
|
+
For wrapping worlds or generated levels, use the map's opt-in `wrap` and room-scoped
|
|
79
|
+
`world.map.replace` APIs described in RULES.md's **Wrapped worlds and generated levels**.
|
|
80
|
+
Prefer one indexed map revision for a generated maze; keep independently moving
|
|
81
|
+
doors and platforms as live colliders. Generation and navigation baking spend the
|
|
82
|
+
room handler's budget.
|
|
83
|
+
|
|
78
84
|
**Read [RULES.md](RULES.md) before writing code.** All five starters use this contract.
|
|
79
85
|
Work in few, large edits: read each source file once, decide its changes, then
|
|
80
86
|
make every change it needs in ONE file_edit with an edits list. Build after each
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boundPosition } from '../rules/topology.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* The generated play of a rules game, in a worker thread of its own (rules-check.mjs starts it).
|
|
3
4
|
*
|
|
@@ -353,7 +354,7 @@ function play(c, companions, allow) {
|
|
|
353
354
|
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
354
355
|
units += result.used;
|
|
355
356
|
const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
|
|
356
|
-
result.pos = L.P.vec3(
|
|
357
|
+
result.pos = L.P.vec3(boundPosition(result.pos,geometry??c.map,r,height,c.dims),c.dims);
|
|
357
358
|
delete result.used;
|
|
358
359
|
if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
|
|
359
360
|
}
|
package/lib/rules-guard.mjs
CHANGED
|
@@ -66,11 +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', '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'];
|
|
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', 'replace', 'tile', 'distance', 'flow'];
|
|
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
|
+
const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
|
|
74
74
|
const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
|
|
75
75
|
const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
|
|
76
76
|
/** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
|
|
@@ -2901,6 +2901,48 @@
|
|
|
2901
2901
|
}
|
|
2902
2902
|
}
|
|
2903
2903
|
}
|
|
2904
|
+
},
|
|
2905
|
+
"0.47.0": {
|
|
2906
|
+
"files": {
|
|
2907
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2908
|
+
"README.md": "684b3cca8001673c",
|
|
2909
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2910
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2911
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2912
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2913
|
+
"posts/README.md": "37841aae476bd540",
|
|
2914
|
+
"site/README.md": "732894273a32dc3f",
|
|
2915
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2916
|
+
},
|
|
2917
|
+
"sections": {
|
|
2918
|
+
"AGENTS.md": {
|
|
2919
|
+
"(intro)": "394cbbe934e80722",
|
|
2920
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2921
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2922
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2923
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2924
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2925
|
+
"## Making games": "bb715272fd1a615b",
|
|
2926
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2927
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2928
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2929
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2930
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2931
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2932
|
+
"## Rules": "27881d11d3ff3803",
|
|
2933
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2934
|
+
"## Beta": "9d02638207a26e73"
|
|
2935
|
+
}
|
|
2936
|
+
},
|
|
2937
|
+
"named": {
|
|
2938
|
+
"Homie Arcade": {
|
|
2939
|
+
"sections": {
|
|
2940
|
+
"AGENTS.md": {
|
|
2941
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2904
2946
|
}
|
|
2905
2947
|
}
|
|
2906
2948
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/collision.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface BodyShape { shape: string; radius: number; height: number; terr
|
|
|
11
11
|
export interface Solid { min: Vec3; max: Vec3; r: number }
|
|
12
12
|
export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
|
|
13
13
|
export interface Map3 {
|
|
14
|
+
wrap?: import('./rules.ts').MapWrap;
|
|
14
15
|
staticMap?: Map3;
|
|
15
16
|
bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
|
|
16
17
|
heightTiles?: readonly MapHeightTile[];
|
package/rules/core.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { prepareMapReplacement, type MapRevision } from './map-state.ts';
|
|
2
|
+
import { tileApi } from './tiles.ts';
|
|
3
|
+
import { boundPosition, periodicDelta, relativePoint, blendPosition, wrapPoint } from './topology.mjs';
|
|
1
4
|
import {routeGraph} from './navigation.ts';
|
|
2
5
|
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
3
6
|
import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
|
|
@@ -141,6 +144,7 @@ export interface SavedCore {
|
|
|
141
144
|
round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
|
|
142
145
|
shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
|
|
143
146
|
collision?: [number,number];
|
|
147
|
+
maps?: {current:MapRevision|null;pending:MapRevision|null};
|
|
144
148
|
history?: [number, [string, number, number, number, number][]][];
|
|
145
149
|
asks: PendingAsk[];
|
|
146
150
|
guideViews: [number, Record<string, unknown>][];
|
|
@@ -191,6 +195,9 @@ export interface Core {
|
|
|
191
195
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
192
196
|
*/
|
|
193
197
|
export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
198
|
+
let currentMap=c.map;
|
|
199
|
+
let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
|
|
200
|
+
let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
|
|
194
201
|
const dims = c.dims;
|
|
195
202
|
const tickHz = c.settings.tickHz;
|
|
196
203
|
const dt = 1 / tickHz;
|
|
@@ -356,7 +363,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
356
363
|
naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
|
|
357
364
|
naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
|
|
358
365
|
const e: Ent = {
|
|
359
|
-
id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(
|
|
366
|
+
id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(currentMap, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
|
|
360
367
|
r: 0, born: tick, arrived: false, why, dead: false,
|
|
361
368
|
f, m,
|
|
362
369
|
seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
|
|
@@ -366,10 +373,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
366
373
|
return e;
|
|
367
374
|
}
|
|
368
375
|
function clampIn(p: Vec3, r: number, height = 0): Vec3 {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
376
|
+
if(!currentMap.wrap){
|
|
377
|
+
const b=currentMap.bounds;
|
|
378
|
+
const x=Math.max(b.min.x+r,Math.min(b.max.x-r,p.x));
|
|
379
|
+
const y=Math.max(b.min.y+r,Math.min(b.max.y-r,p.y));
|
|
380
|
+
const z=dims===3?Math.max(b.min.z,Math.min(b.max.z-height,p.z)):0;
|
|
381
|
+
return x===p.x&&y===p.y&&z===p.z?p:V({x,y,z});
|
|
382
|
+
}
|
|
383
|
+
const at = boundPosition(p,currentMap,r,height,dims);
|
|
384
|
+
return at.x===p.x&&at.y===p.y&&at.z===p.z?p:V(at);
|
|
373
385
|
}
|
|
374
386
|
|
|
375
387
|
/* ---------------------------------------------------------------- running a handler under the budget */
|
|
@@ -470,9 +482,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
470
482
|
return k;
|
|
471
483
|
};
|
|
472
484
|
const mapApi = brand(Object.freeze({
|
|
473
|
-
name
|
|
474
|
-
|
|
475
|
-
|
|
485
|
+
get name(){return currentMap.name;},
|
|
486
|
+
...tileApi(()=>currentMap),
|
|
487
|
+
replace:(raw:unknown)=>{
|
|
488
|
+
need('room','world.map.replace()');
|
|
489
|
+
const prepared=prepareMapReplacement(raw,c.map);
|
|
490
|
+
pendingMap=Object.freeze([tick+1,prepared.raw]);pendingGeometry=prepared.map;pendingMapCells=prepared.cells;mapCells=currentMapCells+pendingMapCells;
|
|
491
|
+
},
|
|
492
|
+
spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name][0] : undefined),
|
|
493
|
+
spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name] : NO_SPOTS),
|
|
476
494
|
}));
|
|
477
495
|
let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
478
496
|
const refreshRound = (): void => {
|
|
@@ -512,13 +530,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
512
530
|
const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
|
|
513
531
|
if(e.kind.collider&&!row)continue;
|
|
514
532
|
let at=e.pos;
|
|
515
|
-
if(!e.kind.collider && wanted<tick && before && after){charge(4*(before[1].length+after[1].length));const a=before[1].find(p=>p[0]===e.id&&p[4]===e.r),b=after[1].find(p=>p[0]===e.id&&p[4]===e.r);if(a&&b){const mix=after[0]===before[0]?0:Math.max(0,Math.min(1,(wanted-before[0])/(after[0]-before[0])));at={x:a[1]
|
|
533
|
+
if(!e.kind.collider && wanted<tick && before && after){charge(4*(before[1].length+after[1].length));const a=before[1].find(p=>p[0]===e.id&&p[4]===e.r),b=after[1].find(p=>p[0]===e.id&&p[4]===e.r);if(a&&b){const mix=after[0]===before[0]?0:Math.max(0,Math.min(1,(wanted-before[0])/(after[0]-before[0])));at=blendPosition({x:a[1],y:a[2],z:a[3]},{x:b[1],y:b[2],z:b[3]},mix,currentMap.wrap);}}
|
|
516
534
|
targets.push({id:e.id,kind:e.kind.name,query:e.kind.query,fields:e.f,geometry:Boolean(row),solid:row?colliderSolid(row):solidAt(at,e.kind.body),at});
|
|
517
535
|
}
|
|
518
536
|
return targets;
|
|
519
537
|
}
|
|
520
538
|
function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
|
|
521
|
-
let best = castMap3(
|
|
539
|
+
let best = castMap3(currentMap, p, d, shape);
|
|
522
540
|
const solid = solidAt(p, shape);
|
|
523
541
|
for (const other of ents.values()) {
|
|
524
542
|
if (!other.kind.body || ignored.has(other.id)) continue;
|
|
@@ -568,8 +586,16 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
568
586
|
const parent = cx, parentTouched = touched, note = G.note;
|
|
569
587
|
cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
|
|
570
588
|
if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
|
|
589
|
+
let failure: unknown;
|
|
571
590
|
try { fn(world,e.self,payload); }
|
|
572
|
-
|
|
591
|
+
catch(error) { failure=error; throw error; }
|
|
592
|
+
finally {
|
|
593
|
+
try { settle(); } catch(error) { failure=error; throw error; }
|
|
594
|
+
finally {
|
|
595
|
+
cx=parent; touched=parentTouched; G.note=note; dispatchDepth--;
|
|
596
|
+
opts.observe?.(e.kind.name, `on.${String(ev)}`, failure===undefined?undefined:errorText(failure));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
573
599
|
return true;
|
|
574
600
|
},
|
|
575
601
|
sendRoom: (ev: unknown, data?: unknown): void => {
|
|
@@ -651,7 +677,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
651
677
|
e.pos = clampIn(V(at), e.kind.body?.radius ?? 0, e.kind.body ? e.kind.body.height || 2 * e.kind.body.radius : 0);
|
|
652
678
|
e.vel = vel !== undefined ? V(vel) : ZERO;
|
|
653
679
|
if (heading !== undefined) e.heading = dir(heading, dims);
|
|
654
|
-
e.grounded = dims === 2 || restsOnMap(
|
|
680
|
+
e.grounded = dims === 2 || restsOnMap(currentMap, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
|
|
655
681
|
e.r = (e.r + 1) & 0xffff;
|
|
656
682
|
e.allow = 0;
|
|
657
683
|
},
|
|
@@ -662,8 +688,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
662
688
|
let cost = 0;
|
|
663
689
|
for (const e of ents.values()) {
|
|
664
690
|
if (k && e.kind !== k) continue;
|
|
665
|
-
const dx = e.pos.x
|
|
666
|
-
const dz = dims === 3 ? e.pos.z
|
|
691
|
+
const dx = periodicDelta(p.x,e.pos.x,currentMap.wrap?.x); const dy = periodicDelta(p.y,e.pos.y,currentMap.wrap?.y);
|
|
692
|
+
const dz = dims === 3 ? periodicDelta(p.z,e.pos.z,currentMap.wrap?.z) : 0;
|
|
667
693
|
const d2 = dx * dx + dy * dy + dz * dz;
|
|
668
694
|
if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
|
|
669
695
|
}
|
|
@@ -678,12 +704,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
678
704
|
if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M || dims === 3 && max.z - min.z > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
|
|
679
705
|
const found: Ent[] = [];
|
|
680
706
|
let cost = 0;
|
|
681
|
-
|
|
707
|
+
const centre={x:(min.x+max.x)/2,y:(min.y+max.y)/2,z:(min.z+max.z)/2};
|
|
708
|
+
for (const e of ents.values()) {const p=relativePoint(e.pos,centre,currentMap.wrap);if ((!k || e.kind === k) && p.x >= min.x && p.x <= max.x && p.y >= min.y && p.y <= max.y && (dims === 2 || p.z >= min.z && p.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }}
|
|
682
709
|
charge(cost);
|
|
683
710
|
return Object.freeze(found.map(viewOf));
|
|
684
711
|
},
|
|
685
712
|
ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
|
|
686
|
-
if (options !== undefined) return rayQuery(
|
|
713
|
+
if (options !== undefined || currentMap.wrap) return rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
|
|
687
714
|
// Charged before the cast, for every shape it may test: the map's, and every entity in the room.
|
|
688
715
|
charge(20 + 4 * ents.size);
|
|
689
716
|
const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
|
|
@@ -692,7 +719,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
692
719
|
const hit = cast3(p, delta, { shape: 'sphere', radius: 0, height: 0 }, new Set(cx.ent ? [cx.ent.id] : []));
|
|
693
720
|
return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: V({ x: p.x + delta.x * hit.t, y: p.y + delta.y * hit.t, z: p.z + delta.z * hit.t }), normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }), dist: far * hit.t }) : undefined;
|
|
694
721
|
}
|
|
695
|
-
const cast = castMap(
|
|
722
|
+
const cast = castMap(currentMap, p.x, p.y, d.x * far, d.y * far, 0);
|
|
696
723
|
let best: Hit | null = cast.hit;
|
|
697
724
|
for (const e of ents.values()) {
|
|
698
725
|
if (!e.kind.body || e === cx.ent) continue;
|
|
@@ -711,12 +738,19 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
711
738
|
const named=typeof graph==='string';if(named&&!Object.hasOwn(c.navigation,graph))throw new Error('unknown navigation graph');return routeGraph(named?c.navigation[graph]:graph,from,to,rayTargets({geometryOnly:true}).map(t=>t.solid),radius,height,named);
|
|
712
739
|
},
|
|
713
740
|
label: (id: unknown): string => {charge(20);const e=typeof id==='string'?ents.get(id):undefined;if(!e?.kind.player)return '';return String(opts.label?.(e.seat,e.driver)??`Player ${e.seat+1}`).slice(0,40);},
|
|
714
|
-
rays: (options?: unknown): unknown => raySnapshot(
|
|
715
|
-
rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(
|
|
741
|
+
rays: (options?: unknown): unknown => raySnapshot(currentMap,rayTargets(options),dims,options,cx.ent?.id),
|
|
742
|
+
rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
|
|
716
743
|
sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
|
|
717
744
|
const e = mine(self, 'world.sweep');
|
|
718
745
|
charge(20 + 4 * ents.size);
|
|
719
746
|
const d = V(delta);
|
|
747
|
+
if(currentMap.wrap){
|
|
748
|
+
const list=own(o,'ignore'),ignore:string[]=[];
|
|
749
|
+
if(Array.isArray(list))for(let i=0;i<list.length&&i<IGNORE_MAX;i++){const id=own(list,i);if(typeof id==='string')ignore.push(id);}
|
|
750
|
+
const scene=raySnapshot(currentMap,rayTargets(undefined),dims,{radius:e.kind.body?.radius??0,height:e.kind.body?.height||2*(e.kind.body?.radius??0),shape:e.kind.body?.shape==='circle'?'sphere':e.kind.body?.shape??'sphere',ignore},e.id);
|
|
751
|
+
const body={pos:e.pos,grounded:e.grounded};const hit=scene.sweep(body,d);
|
|
752
|
+
e.pos=V(wrapPoint(body.pos,currentMap.wrap));e.grounded=body.grounded;return hit;
|
|
753
|
+
}
|
|
720
754
|
const radius = e.kind.body?.radius ?? 0;
|
|
721
755
|
// The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
|
|
722
756
|
const list = own(o, 'ignore');
|
|
@@ -733,7 +767,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
733
767
|
e.grounded = Boolean(support && support.nz > 0.5);
|
|
734
768
|
return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: e.pos, normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }) }) : undefined;
|
|
735
769
|
}
|
|
736
|
-
const cast = castMap(
|
|
770
|
+
const cast = castMap(currentMap, e.pos.x, e.pos.y, d.x, d.y, radius);
|
|
737
771
|
let best: Hit | null = cast.hit;
|
|
738
772
|
for (const other of ents.values()) {
|
|
739
773
|
if (other === e || !other.kind.body || ignore.has(other.id)) continue;
|
|
@@ -778,14 +812,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
778
812
|
const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
|
|
779
813
|
return row ? [row] : [];
|
|
780
814
|
});
|
|
781
|
-
const
|
|
782
|
-
|
|
815
|
+
const metadata={...(currentMap.wrap?{wrap:currentMap.wrap}:{}),...((pendingMap??mapRevision)?{map:pendingMap??mapRevision,...(pendingMap&&mapRevision?{previousMap:mapRevision}:{})}:{})};
|
|
816
|
+
const text = JSON.stringify(Object.keys(metadata).length ? [rows,metadata] : rows);
|
|
817
|
+
if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows,...(Object.keys(metadata).length?[metadata]:[])];}
|
|
783
818
|
return collisionPrevious;
|
|
784
819
|
};
|
|
785
|
-
let moveGeometry =
|
|
820
|
+
let moveGeometry = currentMap as import('./math.ts').MapShapes;
|
|
786
821
|
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
|
|
787
822
|
const moveWorld = brand(Object.freeze(queries));
|
|
788
|
-
const moveMap = brand(Object.freeze({ name
|
|
823
|
+
const moveMap = brand(Object.freeze({ get name(){return currentMap.name;}, spot: mapApi.spot, spots: mapApi.spots, ...tileApi(()=>currentMap), ...queries }));
|
|
789
824
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
790
825
|
const moveCtx = brand({} as Record<string, unknown>);
|
|
791
826
|
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap, world: () => moveWorld })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
@@ -1037,6 +1072,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1037
1072
|
/* ---------------------------------------------------------------- one tick */
|
|
1038
1073
|
|
|
1039
1074
|
function inArea(a: any, p: Vec3): boolean {
|
|
1075
|
+
if(currentMap.wrap) p = relativePoint(p, a.k === 'b' ? {x:(a.min.x+a.max.x)/2,y:(a.min.y+a.max.y)/2,z:(a.min.z+a.max.z)/2} : a.at, currentMap.wrap);
|
|
1040
1076
|
if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y && (dims === 2 || p.z >= a.min.z && p.z <= a.max.z);
|
|
1041
1077
|
const dx = p.x - a.at.x; const dy = p.y - a.at.y;
|
|
1042
1078
|
const dz = dims === 3 ? p.z - a.at.z : 0;
|
|
@@ -1049,6 +1085,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1049
1085
|
}
|
|
1050
1086
|
function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
|
|
1051
1087
|
tick = (tick + 1) >>> 0;
|
|
1088
|
+
if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
|
|
1052
1089
|
// The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
|
|
1053
1090
|
left = c.settings.budget.tick - SNAP * snapCells;
|
|
1054
1091
|
cut = false; stopped = false; threw = null;
|
|
@@ -1107,7 +1144,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1107
1144
|
}
|
|
1108
1145
|
guides?.();
|
|
1109
1146
|
// All movers see the same world, independent of entity iteration order.
|
|
1110
|
-
if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(
|
|
1147
|
+
if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(currentMap, collisionState()[2]); }
|
|
1111
1148
|
// Phase 1: one input step and `move` for every body.
|
|
1112
1149
|
for (const e of [...ents.values()]) {
|
|
1113
1150
|
if (e.dead || !e.kind.body) continue;
|
|
@@ -1140,7 +1177,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1140
1177
|
// Owner movement: the browser says where its body is, and the server holds the claim to maxSpeed.
|
|
1141
1178
|
e.allow = Math.min(e.allow + body.maxSpeed * dt * 1.02, body.maxSpeed * 0.25 + 0.05);
|
|
1142
1179
|
if (claim && claim.r === e.r) {
|
|
1143
|
-
const dx =
|
|
1180
|
+
const dx = periodicDelta(e.pos.x,claim.pos.x,currentMap.wrap?.x); const dy = periodicDelta(e.pos.y,claim.pos.y,currentMap.wrap?.y);
|
|
1144
1181
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1145
1182
|
const go = Math.min(dist, e.allow);
|
|
1146
1183
|
// The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
|
|
@@ -1153,7 +1190,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1153
1190
|
e.grounded = dims === 2;
|
|
1154
1191
|
// The support cast is map work even when a browser supplies the pose.
|
|
1155
1192
|
// Keep it under the same movement quota as a server-driven body's sweep.
|
|
1156
|
-
if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(
|
|
1193
|
+
if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(currentMap, e.pos, body); }, 'body');
|
|
1157
1194
|
}
|
|
1158
1195
|
continue;
|
|
1159
1196
|
}
|
|
@@ -1245,7 +1282,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1245
1282
|
function save(): SavedCore {
|
|
1246
1283
|
return {
|
|
1247
1284
|
v: SAVE_REVISION, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
|
|
1248
|
-
...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
|
|
1285
|
+
...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
|
|
1286
|
+
...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
|
|
1249
1287
|
...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
|
|
1250
1288
|
shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: restartAsked ? [endAsked ? 1 : 0, finishing ? 1 : 0, 1] : [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
|
|
1251
1289
|
ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
|
|
@@ -1264,8 +1302,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1264
1302
|
check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
|
|
1265
1303
|
};
|
|
1266
1304
|
check(r.v === SAVE_REVISION);
|
|
1305
|
+
if(r.maps!==undefined){
|
|
1306
|
+
check(r.maps&&Object.keys(r.maps).length===2&&'current' in r.maps&&'pending' in r.maps&&Object.keys(r.maps).every(k=>k==='current'||k==='pending'));
|
|
1307
|
+
for(const [name,value] of Object.entries(r.maps)){if(value===null)continue;check(Array.isArray(value)&&value.length===2&&uint(value[0])&&(name==='pending'?value[0]===r.tick+1:value[0]<=r.tick));prepareMapReplacement(value[1],c.map);}
|
|
1308
|
+
}
|
|
1267
1309
|
if(r.collision!==undefined)check(Array.isArray(r.collision)&&r.collision.length===2&&uint(r.collision[0])&&uint(r.collision[1])&&r.collision[0]<=r.tick&&r.collision[1]===r.collision[0]+1);
|
|
1268
|
-
check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision'].includes(k)));
|
|
1310
|
+
check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision', 'maps'].includes(k)));
|
|
1269
1311
|
const askNames = new Set();
|
|
1270
1312
|
check(Array.isArray(r.asks));
|
|
1271
1313
|
for (const a of r.asks) {
|
|
@@ -1311,7 +1353,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1311
1353
|
for (const i of [7, 8, 9]) check(JSON.stringify(w[i]) === JSON.stringify(packVec(unpackVec(w[i], 3), 3)));
|
|
1312
1354
|
const goal = w[15] as any;
|
|
1313
1355
|
if (goal !== null) check(goal && typeof goal === 'object' && !Array.isArray(goal) && Object.keys(goal).every(k => ['goal', 'args', 'from', 'at', 'asked', 'state'].includes(k)) && typeof goal.goal === 'string' && goal.args && typeof goal.args === 'object' && !Array.isArray(goal.args) && ['brain', 'floor'].includes(goal.from) && uint(goal.at) && goal.at <= r.tick && typeof goal.asked === 'boolean' && goal.state === 'active');
|
|
1314
|
-
const pos = unpackVec(w[7] as number[], 3), bounds =
|
|
1356
|
+
const pos = unpackVec(w[7] as number[], 3), bounds = currentMap.bounds;
|
|
1315
1357
|
check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && (dims === 3 ? pos.z >= bounds.min.z && pos.z <= bounds.max.z : pos.z === 0));
|
|
1316
1358
|
check(Number.isInteger(w[11]) && (w[11] as number) >= -1 && (w[11] as number) < c.seats && ['person', 'bot', 'ai'].includes(w[12] as string) && typeof w[13] === 'string' && w[13].length <= 128);
|
|
1317
1359
|
if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
|
|
@@ -1398,6 +1440,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1398
1440
|
}
|
|
1399
1441
|
pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
|
|
1400
1442
|
if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
|
|
1443
|
+
if(r.maps){
|
|
1444
|
+
mapRevision=r.maps.current;pendingMap=r.maps.pending;
|
|
1445
|
+
if(mapRevision){const p=prepareMapReplacement(mapRevision[1],c.map);mapRevision=Object.freeze([mapRevision[0],p.raw]);currentMap=p.map;currentMapCells=p.cells;mapCells=p.cells;}
|
|
1446
|
+
if(pendingMap){const p=prepareMapReplacement(pendingMap[1],c.map);pendingMap=Object.freeze([pendingMap[0],p.raw]);pendingGeometry=p.map;pendingMapCells=p.cells;mapCells=currentMapCells+pendingMapCells;}
|
|
1447
|
+
moveGeometry=currentMap;
|
|
1448
|
+
}
|
|
1401
1449
|
tick = r.tick; epoch = opts.restoreEpoch ?? r.epoch; rng = r.rng; nextId = r.nextId; seq = r.seq; overAt = r.overAt; playing = r.match[0]; restartAt = r.match[1]; trips = r.trips;
|
|
1402
1450
|
endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
|
|
1403
1451
|
round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
|
|
@@ -1416,7 +1464,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1416
1464
|
});
|
|
1417
1465
|
areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(coerceFields(c.events[a[4]] ?? [], a[5], dims)) }));
|
|
1418
1466
|
ops = r.ops as typeof ops;
|
|
1419
|
-
if(hasColliders)collisionState();
|
|
1467
|
+
if(hasColliders||mapRevision||pendingMap)collisionState();
|
|
1420
1468
|
} else startMatch();
|
|
1421
1469
|
|
|
1422
1470
|
function decisionResult(value: unknown): Record<string, unknown> {
|
|
@@ -1501,8 +1549,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1501
1549
|
const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
|
|
1502
1550
|
// Include all three components of position, velocity and heading in a 3D snapshot.
|
|
1503
1551
|
snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
|
|
1504
|
-
const collision = hasColliders ? collisionState() : null;
|
|
1505
|
-
if (collision) snapCells += 3 + collision[2].length * 8;
|
|
1552
|
+
const collision = hasColliders||mapRevision||pendingMap ? collisionState() : currentMap.wrap ? [0,0,[],{wrap:currentMap.wrap}] : null;
|
|
1553
|
+
if (collision) snapCells += 3 + collision[2].length * 8 + mapCells;
|
|
1506
1554
|
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
|
|
1507
1555
|
},
|
|
1508
1556
|
shared: () => packFields(c.shared, shared, dims),
|
package/rules/host.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { periodicDelta } from './topology.mjs';
|
|
1
2
|
import { stageTimings } from './timing.mjs';
|
|
2
3
|
/*
|
|
3
4
|
* host.ts — the host runtime: a rules module playing the host's part on the wire.
|
|
@@ -675,7 +676,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
675
676
|
const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
|
|
676
677
|
for (const seat of present.keys()) {
|
|
677
678
|
const l = bodies.get(seat);
|
|
678
|
-
const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0]
|
|
679
|
+
const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(periodicDelta(l.pos.x,a[2][0],o.compiled.map.wrap?.x), periodicDelta(l.pos.y,a[2][1],o.compiled.map.wrap?.y), periodicDelta(l.pos.z,a[2][2],o.compiled.map.wrap?.z)) <= a[0]); });
|
|
679
680
|
if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
|
|
680
681
|
}
|
|
681
682
|
if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
|