@homie-rocks/studio 0.46.6 → 0.48.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 +83 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +207 -0
- package/guides/game/SKILL.md +24 -0
- package/lib/doctor.mjs +8 -6
- package/lib/mcp-tools.mjs +2 -1
- package/lib/physics-mesh.mjs +40 -0
- package/lib/provider-key.mjs +21 -0
- package/lib/rules-build.mjs +18 -10
- package/lib/rules-check-worker.mjs +5 -1
- package/lib/rules-check.mjs +1 -1
- package/lib/rules-guard.mjs +5 -4
- package/lib/template-history.json +84 -0
- package/package.json +11 -2
- package/rules/collision.ts +1 -0
- package/rules/core.ts +94 -34
- package/rules/host.ts +3 -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 +18 -6
- package/rules/nav/Flow.ts +53 -0
- package/rules/pack.ts +4 -2
- package/rules/physics-engine.ts +73 -0
- package/rules/physics.ts +26 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +31 -4
- package/rules/substeps.ts +39 -0
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +4 -3
- package/rules/view.ts +74 -23
- package/worker/build-id.mjs +1 -1
- package/worker/hosted.mjs +16 -0
- package/worker/index.mjs +19 -4
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,91 @@ 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.48.0] - 2026-10-13
|
|
21
|
+
|
|
22
|
+
**Plugin 0.48.0** · Baked physics worlds for server rules and local prediction.
|
|
23
|
+
|
|
24
|
+
Rules games can sweep capsules through a baked GLB with the same deterministic physics on the server and in local prediction.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Rules games can opt into deterministic Rapier collision through
|
|
29
|
+
`@homie-rocks/physics`: build-time GLB baking, asynchronous initialization before
|
|
30
|
+
ticking, synchronous metered capsule sweeps, casts, rays, overlaps and solver
|
|
31
|
+
steps, and complete physics snapshots saved with the room. Browser movement
|
|
32
|
+
replays against the same baked world. Games without physics retain their
|
|
33
|
+
existing behavior and budget costs.
|
|
34
|
+
- `ctx.substeps(body, hz, callback)` lets a game choose its own integration rate
|
|
35
|
+
within a room tick. Server and browser share the callback and budget; the view
|
|
36
|
+
draws its recorded intermediate poses. The build checks authoritative body
|
|
37
|
+
fields separately from these temporary drawing traces.
|
|
38
|
+
- Release tests compare 220 complete guarded-room hashes across Node, Chrome and
|
|
39
|
+
workerd for eight capsules at 300 Hz on a 1,754-triangle level, restore mid-fall,
|
|
40
|
+
and settle two predictions exactly with 150 ms each way and seeded packet loss.
|
|
41
|
+
The calibration and real two-Chrome-player Octree trial are recorded in
|
|
42
|
+
`docs/physics-048-calibration.md` and `docs/receipts/`.
|
|
43
|
+
- Physics 0.1.2 adds a stateless capsule cast for applications that own integration
|
|
44
|
+
and replay; it uses the package's existing character contact handling.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Predictor callbacks that inspect the room no longer reenter snapshot replay.
|
|
49
|
+
- Provider discovery recognizes existing Homie fal and ElevenLabs key files,
|
|
50
|
+
including the legacy workspace location, without copying or exposing keys.
|
|
51
|
+
|
|
52
|
+
### Upgrade notes
|
|
53
|
+
|
|
54
|
+
- Physics is opt-in. Install `@homie-rocks/physics` 0.1.2 and name the
|
|
55
|
+
game's static GLB with `map.physics.level`; see the Physics section of the
|
|
56
|
+
rules guide. The complete solver save can exceed browser-host checkpoint caps,
|
|
57
|
+
so the real-mesh example uses server hosting.
|
|
58
|
+
|
|
59
|
+
## [0.47.0] - 2026-10-13
|
|
60
|
+
|
|
61
|
+
**Plugin 0.47.0** · Wrapped worlds and generated collision maps.
|
|
62
|
+
|
|
63
|
+
Games can wrap across map edges and replace seeded tile levels during play, with the same collision geometry in server rules and prediction.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- Maps may opt into wrapping independently on x, y and z with `wrap: {x:true}`;
|
|
68
|
+
periods come from bounds. Bodies, sweeps, rays, overlaps, support, near/box queries,
|
|
69
|
+
area events, ranged effects, spatial delivery, rewind and view interpolation use
|
|
70
|
+
periodic geometry. Crossing an edge wraps rather than clamps. Sweeps/rays retain
|
|
71
|
+
their supplied direction and pay for each periodic image they visit.
|
|
72
|
+
- Room handlers can call `world.map.replace(data)`. A replacement is validated,
|
|
73
|
+
indexed and activated atomically at the next tick, preserving bounds and wrapping.
|
|
74
|
+
Compact current/pending revisions are saved and sent once per collision revision;
|
|
75
|
+
local prediction replays the same compiled map by activation tick.
|
|
76
|
+
- Maps accept row-major `tiles` with width, height, size, optional origin and solid
|
|
77
|
+
height. Nonzero cells compile to indexed horizontal runs. Rules and movement can
|
|
78
|
+
query `map.tile(x,y)`; views read `room.map.tiles`.
|
|
79
|
+
- Named multi-source cardinal `tiles.flows` bake during map compilation. Rules and
|
|
80
|
+
movement use constant-work `map.distance(name,x,y)` and `map.flow(name,x,y)`.
|
|
81
|
+
The standalone rules runtime ships the nav package's new `Flow.js` baker, checked
|
|
82
|
+
against its source in the release gate. Generation, copying, indexing and baking
|
|
83
|
+
are charged to the rules handler; ordinary static-map budget charges are unchanged.
|
|
84
|
+
- Seeded virtual-time release cases cover two-view seam crossing in 2D/3D and
|
|
85
|
+
generated-map collision, mid-round regeneration and restore, including 300 ms
|
|
86
|
+
delay and 12% loss. Geometry and navigation tests cover periodic casts,
|
|
87
|
+
multi-period movement, atomic failure and independently checked shortest paths.
|
|
88
|
+
Eight-player generation/restore replays also match every save and snapshot
|
|
89
|
+
across Node, Chrome and workerd for 240 seeded ticks.
|
|
90
|
+
|
|
91
|
+
### Fixed
|
|
92
|
+
|
|
93
|
+
- Handlers reached synchronously by `world.dispatch` now report their coverage to
|
|
94
|
+
the build checker, including failures, without opening a second handler budget.
|
|
95
|
+
- The rewrite guide specifies named helper re-exports (`export *` is refused), and
|
|
96
|
+
the rules guide explains that mandatory spawn fields precede deferred `arrive`.
|
|
97
|
+
|
|
98
|
+
This release covers wrapped worlds and generated levels. Arbitrary triangle-mesh
|
|
99
|
+
collision, the measured Rapier-versus-kernel decision, first-class integration
|
|
100
|
+
substeps, and the assisted rewrite command remain follow-up work.
|
|
101
|
+
|
|
20
102
|
## [0.46.6] - 2026-10-11
|
|
21
103
|
|
|
22
|
-
**Plugin 0.46.6** · Big rooms: what one room carries, measured.
|
|
104
|
+
**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
105
|
|
|
24
106
|
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
107
|
|
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
|
@@ -404,6 +404,46 @@ map.spots, and `world.sweep/support/overlaps` (also on map). Sweeps test static
|
|
|
404
404
|
`20 + 4S` plus copying, writes body.pos, and returns an optional hit.
|
|
405
405
|
Movement may write pos, vel, heading, grounded and declared motion; never return
|
|
406
406
|
an updated body. Normalize diagonals and respect maxSpeed. Collision movement needs a sweep; setting velocity alone does not move a body.
|
|
407
|
+
|
|
408
|
+
### Integrating at the game's own rate
|
|
409
|
+
|
|
410
|
+
Opt in from `move` with `ctx.substeps(body, hz, step)`. For a 20 Hz room,
|
|
411
|
+
300 Hz runs fifteen integrations inside each tick; 60 Hz runs three. The rate
|
|
412
|
+
must be a positive integer multiple of the room rate. Existing movement is
|
|
413
|
+
unchanged unless it calls this method.
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
export const move = defineMove({
|
|
417
|
+
runner(body, input, ctx) {
|
|
418
|
+
ctx.substeps(body, 300, step => {
|
|
419
|
+
if (step.first && input.jump && body.grounded)
|
|
420
|
+
body.vel = { ...body.vel, z: 8 };
|
|
421
|
+
body.vel = { ...body.vel, z: body.vel.z - 30 * step.dt };
|
|
422
|
+
ctx.map.sweep(body, ctx.math.scale(body.vel, step.dt));
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The callback receives `dt` (seconds), zero-based `index`, `count`, `first`
|
|
429
|
+
and end-of-step `fraction`. Use `step.dt` for integration; `ctx.dt` remains
|
|
430
|
+
the full tick. A pulse belongs to the tick: consume it on `step.first` when
|
|
431
|
+
it should act once. Held inputs retain their existing tick-mean semantics;
|
|
432
|
+
this API does not recover the timestamp of changes within a tick. A game
|
|
433
|
+
that already reconstructs held transitions can keep that policy in the
|
|
434
|
+
callback. It does not change Stormbreak's existing integration.
|
|
435
|
+
|
|
436
|
+
One body may be integrated once per move. The call charges 8 units plus
|
|
437
|
+
64 per pose (the initial pose and each completed substep), before allocating
|
|
438
|
+
the trace. Guarded callback operations and collision queries charge the
|
|
439
|
+
same movement-handler budget as usual. There is no separate quota or
|
|
440
|
+
implicit rate cap.
|
|
441
|
+
|
|
442
|
+
The browser predicts the identical callback and draws between the recorded
|
|
443
|
+
substep poses, including a path that leaves and returns to the same tick
|
|
444
|
+
endpoint. The trace is temporary drawing data: it adds no wire fields or
|
|
445
|
+
save fields. The final body and declared motion fields are saved as before.
|
|
446
|
+
This helper does not add triangle collision or a physics world.
|
|
407
447
|
Put freezes, boost expiry and knockback in declared motion. Initialize a late arrival
|
|
408
448
|
in on.arrive too: onRoom.roundStart already happened before it joined.
|
|
409
449
|
|
|
@@ -1078,6 +1118,15 @@ leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
|
|
|
1078
1118
|
player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
|
|
1079
1119
|
predictor runs often: keep it cheap, and keep per-entity state in `me`.
|
|
1080
1120
|
|
|
1121
|
+
`ctx.each(kind, fn)` reads frozen entities from the authoritative snapshot this
|
|
1122
|
+
prediction started from. Use it for pickups and other snapshot-based decisions;
|
|
1123
|
+
it excludes provisional ghosts. Reading `room.each`, `room.get`, `room.me` or
|
|
1124
|
+
ray queries inside a predictor cannot advance the clock or start a nested
|
|
1125
|
+
preview. Other room entities retain their normal drawn poses; `room.me` reads
|
|
1126
|
+
the authoritative own entity during the callback. Use the callback's `me` for
|
|
1127
|
+
the predicted own state. Snapshot reconciliation also prevents clock pumping
|
|
1128
|
+
through event listeners until replay finishes.
|
|
1129
|
+
|
|
1081
1130
|
To guess a shot the server will judge with rewind, ask for what the server
|
|
1082
1131
|
will use (0.46.5). `ctx.judgedTick(aimTick)` is the tick it will pose the others
|
|
1083
1132
|
at: the `aimTick` the input carries, held to how far back this seat may look
|
|
@@ -1178,3 +1227,161 @@ still snap to authority. Opt in only after measuring stop/reversal and delayed
|
|
|
1178
1227
|
collision behavior for the game's mover.
|
|
1179
1228
|
|
|
1180
1229
|
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.
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
## Wrapped worlds and generated levels (0.47.0)
|
|
1233
|
+
|
|
1234
|
+
Declare wrapping in the map, independently per axis. The period is the bounds'
|
|
1235
|
+
extent; an omitted axis keeps its existing bounded behaviour:
|
|
1236
|
+
|
|
1237
|
+
```json
|
|
1238
|
+
{"bounds":{"min":[0,0,0],"max":[25,25,10]},"wrap":{"x":true}}
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
Bodies and hit positions use canonical coordinates in `[min,max)`. Sweeps and rays
|
|
1242
|
+
follow the supplied displacement through periodic copies of the geometry; they can
|
|
1243
|
+
cross more than one period and pay for each visited image. Near/box queries, area
|
|
1244
|
+
events, effect ranges, spatial delivery, rewind and drawing interpolation use the
|
|
1245
|
+
short way around. Exactly half a period chooses the negative direction. A renderer
|
|
1246
|
+
can read `room.map.wrap.x` (the pair `[min,max]`) to draw copies at the viewport's
|
|
1247
|
+
edges. The server does not duplicate entities. In a maze with one tunnel, keep
|
|
1248
|
+
solid border cells on every other row; wrapping itself is per axis, not per row.
|
|
1249
|
+
|
|
1250
|
+
For a generated maze, replace the room's current map in `room.start` or a room
|
|
1251
|
+
handler. Build its data using the rules' seeded random source, then publish once:
|
|
1252
|
+
|
|
1253
|
+
```ts
|
|
1254
|
+
world.map.replace({
|
|
1255
|
+
bounds: { min: [0, 0], max: [25, 25] },
|
|
1256
|
+
wrap: { x: true },
|
|
1257
|
+
tiles: { width: 25, height: 25, size: 1, data: cells,
|
|
1258
|
+
flows: { exit: [312] } },
|
|
1259
|
+
spots: { start: [[12.5, 19.5]] },
|
|
1260
|
+
});
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
`replace` preserves the original bounds and wrapping. It validates, bakes cardinal
|
|
1264
|
+
navigation fields and compiles the spatial index before publishing anything. A
|
|
1265
|
+
failed bake leaves the previous revision intact. The last successful replacement
|
|
1266
|
+
in a tick becomes current at the next tick; all bodies use the same revision.
|
|
1267
|
+
The compact map data and activation tick travel with collision revisions and saves,
|
|
1268
|
+
including both current and pending maps. Prediction replays against that tick's
|
|
1269
|
+
compiled geometry. Late knowledge is corrected by the normal reconciliation path.
|
|
1270
|
+
|
|
1271
|
+
Tiles are row-major integers: zero is open, nonzero is solid. `at` defaults to
|
|
1272
|
+
`[0,0,0]`, `size` and `solidHeight` to 1. Horizontal runs become indexed boxes,
|
|
1273
|
+
including in 3D. On a wrapped axis the grid must span the full map. Authored boxes,
|
|
1274
|
+
round shapes, terrain and spots can be included in the same replacement. The view
|
|
1275
|
+
reads `room.map.tiles`; rules and movement read `world.map.tile(x,y)` and
|
|
1276
|
+
`ctx.map.tile(x,y)`. Cell coordinates are integers, not metres. Outside an unwrapped
|
|
1277
|
+
grid is solid. A custom tile mover may read these cells instead of using sweeps.
|
|
1278
|
+
|
|
1279
|
+
`flows` names lists of open destination cell indices. `map.distance(name,x,y)` gives
|
|
1280
|
+
cardinal hops to the nearest destination, or -1 when unreachable/unknown;
|
|
1281
|
+
`map.flow(name,x,y)` gives the next unit direction, zero at a destination, or
|
|
1282
|
+
undefined when unreachable/unknown. Each lookup is constant work (4 or 8 budget
|
|
1283
|
+
units). Baking uses `@homie-rocks/nav`'s shared cardinal flow implementation;
|
|
1284
|
+
left/up/right/down resolves ties, including tunnels. Change destinations by
|
|
1285
|
+
publishing new flow declarations with the map. This is a bake, not an unmetered
|
|
1286
|
+
path search: budget for every field and regenerate only when its goals change.
|
|
1287
|
+
|
|
1288
|
+
Room replacement charges copying, grid scanning, index construction and each flow
|
|
1289
|
+
bake to the calling handler. Snapshots charge for the compact data. Existing static
|
|
1290
|
+
maps need no replacement and retain their previous budget charges. A few thousand
|
|
1291
|
+
cells regenerated a few times a round belong in one map revision; live collider
|
|
1292
|
+
entities remain suitable for independently moving doors, cover and platforms.
|
|
1293
|
+
|
|
1294
|
+
`spawn` exposes a new entity before its deferred `on.arrive` handler. Pass every
|
|
1295
|
+
field that must be valid immediately in the spawn data; use `arrive` for subsequent
|
|
1296
|
+
work, not for mandatory initialization.
|
|
1297
|
+
|
|
1298
|
+
## Physics
|
|
1299
|
+
|
|
1300
|
+
Use the opt-in `@homie-rocks/physics` bridge when the level is an arbitrary triangle
|
|
1301
|
+
mesh: caves, ramps, stairs and the static environment of a game such as Octree
|
|
1302
|
+
Arena. Keep `ctx.map` for the kernel's boxes, circles, capsules and height tiles.
|
|
1303
|
+
Install `@homie-rocks/physics` 0.1.2 in the studio. A game that does not
|
|
1304
|
+
ask for physics loads no physics engine and pays no physics units.
|
|
1305
|
+
|
|
1306
|
+
In `map/main.json`, name a static GLB relative to the game directory:
|
|
1307
|
+
|
|
1308
|
+
```json
|
|
1309
|
+
{
|
|
1310
|
+
"bounds": { "min": [-100, -100, -100], "max": [100, 100, 100] },
|
|
1311
|
+
"physics": { "level": "assets/level.glb" }
|
|
1312
|
+
}
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
The build bakes the default scene's node transforms and triangles into the
|
|
1316
|
+
physics package's indexed mesh format. It converts glTF Y-up to rules Z-up
|
|
1317
|
+
(`x, -z, y`), preserves reflected winding, and ships the baked mesh with the
|
|
1318
|
+
server rules and the browser movement bundle. Skins, animations and morph targets
|
|
1319
|
+
need a separate static collision GLB. The server never loads or parses a GLB.
|
|
1320
|
+
|
|
1321
|
+
The smallest moving capsule uses the same guarded movement on both sides:
|
|
1322
|
+
|
|
1323
|
+
```ts
|
|
1324
|
+
// src/move.ts
|
|
1325
|
+
import { defineMove } from '@homie-rocks/studio/rules';
|
|
1326
|
+
export const move = defineMove({ runner(body, input, ctx) {
|
|
1327
|
+
ctx.substeps(body, 300, step => {
|
|
1328
|
+
if (step.first && input.jump && body.grounded)
|
|
1329
|
+
body.vel = { x: body.vel.x, y: body.vel.y, z: 10 };
|
|
1330
|
+
body.vel = { x: input.ax * 5, y: 0, z: body.vel.z - 30 * step.dt };
|
|
1331
|
+
ctx.physics.sweep(body, ctx.math.scale(body.vel, step.dt), .35, 1.35);
|
|
1332
|
+
});
|
|
1333
|
+
} });
|
|
1334
|
+
|
|
1335
|
+
// src/rules.ts
|
|
1336
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
1337
|
+
import { move } from './move';
|
|
1338
|
+
export default defineRules({
|
|
1339
|
+
contract: 2, space: { dims: 3 }, move,
|
|
1340
|
+
entities: { runner: {
|
|
1341
|
+
player: true,
|
|
1342
|
+
input: { ax: f.held(), jump: f.press() },
|
|
1343
|
+
body: { shape: 'capsule', radius: .35, height: 1.35, maxSpeed: 30 }
|
|
1344
|
+
} },
|
|
1345
|
+
room: { bots: { keep: 0 }, join() {
|
|
1346
|
+
return { kind: 'runner', at: { x: 0, y: 0, z: 8 } };
|
|
1347
|
+
} }
|
|
1348
|
+
});
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
Use `"room": {"host":"server","offline":false}` in `game.json`. Physics
|
|
1352
|
+
initialization is awaited once per isolate before any room can tick; calls inside
|
|
1353
|
+
a handler are synchronous. `sweep(body, delta, radius, height)` sweeps and slides
|
|
1354
|
+
an upright capsule whose `body.pos` is its centre and whose height includes its
|
|
1355
|
+
caps. It writes position, velocity and grounded state. `ctx.physics.cast`,
|
|
1356
|
+
`ray`, and `overlap` expose the package's convex shape cast, raycast and overlap
|
|
1357
|
+
queries; query shapes are boxes, spheres or capsules. Room/entity handlers have
|
|
1358
|
+
these operations at `world.physics` and can call `world.physics.step(seconds)`
|
|
1359
|
+
to advance the collision world's solver clock. Movement owns capsule integration;
|
|
1360
|
+
its queries do not create persistent physics bodies or advance another player's
|
|
1361
|
+
world. This bridge exposes the baked collision world, not rigid-body ownership or
|
|
1362
|
+
predicted dynamic-body interactions.
|
|
1363
|
+
|
|
1364
|
+
Every operation is charged before its engine work. On a 1,754-triangle level:
|
|
1365
|
+
|
|
1366
|
+
| Operation | Budget units |
|
|
1367
|
+
| --- | ---: |
|
|
1368
|
+
| Capsule/convex cast | 768 |
|
|
1369
|
+
| Ray | 640 |
|
|
1370
|
+
| Overlap | 640 |
|
|
1371
|
+
| Solver step | 8 + 2,400 per 1/300 second |
|
|
1372
|
+
| Capsule sweep and slide | 64 + 768 per cast, at most four casts |
|
|
1373
|
+
|
|
1374
|
+
Mesh-query and solver charges scale by `ceil(triangles / 1754)`, at least one.
|
|
1375
|
+
Substeps retain their usual pose and guarded callback charges. There is no new
|
|
1376
|
+
room budget, player cap or rate quota. The reproducible calibration is
|
|
1377
|
+
`scripts/calibrate-rules-physics.mjs`; its receipt compares physics on the real
|
|
1378
|
+
mesh with kernel operations on a box, since the kernel has no arbitrary-mesh
|
|
1379
|
+
or solver-step equivalent.
|
|
1380
|
+
|
|
1381
|
+
A room save includes the package's complete binary world snapshot as JSON bytes,
|
|
1382
|
+
including solver state and its time remainder. The capsule's position, velocity,
|
|
1383
|
+
grounded flag and declared motion are saved by rules as usual. Browser prediction
|
|
1384
|
+
creates the same baked world and replays pending guarded movement against it;
|
|
1385
|
+
substep drawing traces remain temporary. Physics worlds are disposed when their
|
|
1386
|
+
host or view closes. A full solver save can exceed browser-host checkpoint caps;
|
|
1387
|
+
the build measures this, and server-hosted rooms retain the full save.
|
package/guides/game/SKILL.md
CHANGED
|
@@ -6,6 +6,11 @@ metadata:
|
|
|
6
6
|
providers: cloudflare github ollama
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
For a game with its own fixed integration rate, use the opt-in
|
|
10
|
+
`ctx.substeps(body, hz, step)` facility in `RULES.md` (Integrating at the game's
|
|
11
|
+
own rate). Its callbacks share the tick budget and its recorded poses drive
|
|
12
|
+
local drawing. Keep the game's input-transition policy explicit.
|
|
13
|
+
|
|
9
14
|
Read the installed toolkit's `node_modules/@homie-rocks/studio/guides/game/{RULES,REWRITE}.md`
|
|
10
15
|
before authoring: these match its runtime even when this plugin is older. Toolkit
|
|
11
16
|
check/build report stale installed plugin copies and the update step. For solid slopes and terrain, use heightTiles with `base` and the source
|
|
@@ -75,6 +80,12 @@ Cloudflare or physical-phone capacity guarantees.
|
|
|
75
80
|
|
|
76
81
|
## Write and change rules plus view
|
|
77
82
|
|
|
83
|
+
For wrapping worlds or generated levels, use the map's opt-in `wrap` and room-scoped
|
|
84
|
+
`world.map.replace` APIs described in RULES.md's **Wrapped worlds and generated levels**.
|
|
85
|
+
Prefer one indexed map revision for a generated maze; keep independently moving
|
|
86
|
+
doors and platforms as live colliders. Generation and navigation baking spend the
|
|
87
|
+
room handler's budget.
|
|
88
|
+
|
|
78
89
|
**Read [RULES.md](RULES.md) before writing code.** All five starters use this contract.
|
|
79
90
|
Work in few, large edits: read each source file once, decide its changes, then
|
|
80
91
|
make every change it needs in ONE file_edit with an edits list. Build after each
|
|
@@ -445,3 +456,16 @@ nothing is cut off or runs off the side. Fix what you see; build again.
|
|
|
445
456
|
A whole landing of the studio's own (`site/pages/<id>/index.html`) replaces the generated one: only when
|
|
446
457
|
the person asks for a hand-made page, and start from the generated one's HTML so Play, the TV road and
|
|
447
458
|
"Made with Homie" stay.
|
|
459
|
+
|
|
460
|
+
## Physics
|
|
461
|
+
|
|
462
|
+
For movement against a static GLB's triangles, use the opt-in physics bridge in
|
|
463
|
+
[RULES.md](RULES.md#physics). Install `@homie-rocks/physics` 0.1.2, set
|
|
464
|
+
`map/main.json`'s `physics.level` to the game's GLB, and call
|
|
465
|
+
`ctx.physics.sweep(body, delta, radius, height)` inside the game's movement.
|
|
466
|
+
`ctx.substeps(body, 300, step => { ... })` shares its existing handler budget and
|
|
467
|
+
predicts the same baked world in the browser. The full example, cost table and
|
|
468
|
+
save contract are in that section. Prefer kernel collision for its primitive
|
|
469
|
+
maps; use physics for arbitrary static triangle levels. Initialization is awaited
|
|
470
|
+
before the first room tick, calls in handlers are synchronous, and the complete
|
|
471
|
+
physics snapshot is saved with the room. No opt-in means no physics load or cost.
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { providerKey } from './provider-key.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
|
|
3
4
|
* a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
|
|
@@ -231,16 +232,17 @@ export async function setupStatus({
|
|
|
231
232
|
const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
|
|
232
233
|
try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
|
|
233
234
|
}
|
|
234
|
-
|
|
235
|
+
const credential = providerKey('elevenlabs', env);
|
|
236
|
+
return { cli: v.code === 0, signedIn, key: Boolean(credential.key), source: credential.source, projects: root ? projectsElevenLabs(root) : false };
|
|
235
237
|
})(),
|
|
236
238
|
(async () => {
|
|
237
|
-
const key =
|
|
239
|
+
const { key, source } = providerKey('fal', env);
|
|
238
240
|
if (!key) return { key: false };
|
|
239
241
|
const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
|
|
240
242
|
try {
|
|
241
243
|
const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
|
|
242
|
-
return { key: true, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
|
|
243
|
-
} catch { return { key: true, valid: null }; }
|
|
244
|
+
return { key: true, source, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
|
|
245
|
+
} catch { return { key: true, source, valid: null }; }
|
|
244
246
|
})(),
|
|
245
247
|
]);
|
|
246
248
|
|
|
@@ -386,7 +388,7 @@ export async function setupStatus({
|
|
|
386
388
|
// ElevenLabs.
|
|
387
389
|
rows.push({
|
|
388
390
|
id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
|
|
389
|
-
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ?
|
|
391
|
+
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? `key from ${eleven.source}` : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
|
|
390
392
|
unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
|
|
391
393
|
fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
|
|
392
394
|
? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
|
|
@@ -396,7 +398,7 @@ export async function setupStatus({
|
|
|
396
398
|
// fal.
|
|
397
399
|
rows.push({
|
|
398
400
|
id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
|
|
399
|
-
detail: !fal.key ? 'no
|
|
401
|
+
detail: !fal.key ? 'no fal key in the environment or Homie key files' : `key from ${fal.source}; ${fal.valid === true ? 'fal accepts it' : fal.valid === false ? 'fal refused it (wrong or revoked)' : 'fal did not answer just now'}`,
|
|
400
402
|
unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
|
|
401
403
|
fix: fal.key && fal.valid !== false ? null : { who: 'ai', say: 'Use local art and captured footage. Paid fal scripts need an already connected account; never ask the person to find or set a key. Offer the provider browser connection only when generated media is requested.', open: 'https://fal.ai/dashboard' },
|
|
402
404
|
});
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { providerKey } from './provider-key.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
|
|
3
4
|
* desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
|
|
@@ -1497,7 +1498,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1497
1498
|
|
|
1498
1499
|
/** What the media tools need, cheaply (no network): ffmpeg on the PATH, ElevenLabs' CLI or key. */
|
|
1499
1500
|
export function availability(env = process.env) {
|
|
1500
|
-
return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(env.
|
|
1501
|
+
return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(providerKey('elevenlabs', env).key || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
|
|
1501
1502
|
}
|
|
1502
1503
|
|
|
1503
1504
|
/** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
2
|
+
import { Matrix4, Vector3 } from 'three';
|
|
3
|
+
|
|
4
|
+
/** Build-time only: bake the default GLB scene, including every node transform.
|
|
5
|
+
* Rules use Z-up; glTF is Y-up. (x,y,z) -> (x,-z,y) preserves handedness.
|
|
6
|
+
* Animated, skinned and morphing geometry needs an explicitly authored static collider.
|
|
7
|
+
*/
|
|
8
|
+
export async function bakePhysicsMesh(path) {
|
|
9
|
+
const document = await new NodeIO().read(path);
|
|
10
|
+
const root = document.getRoot();
|
|
11
|
+
const scene = root.getDefaultScene() ?? root.listScenes()[0];
|
|
12
|
+
if (!scene || root.listAnimations().length) throw new Error('physics mesh: needs a static default scene');
|
|
13
|
+
const vertices = [], indices = [];
|
|
14
|
+
scene.traverse(node => {
|
|
15
|
+
if (node.getSkin()) throw new Error('physics mesh: skinned nodes need a static collider');
|
|
16
|
+
const matrix = new Matrix4().fromArray(node.getWorldMatrix());
|
|
17
|
+
const mirrored = matrix.determinant() < 0;
|
|
18
|
+
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
|
|
19
|
+
if (primitive.getMode() !== 4 || primitive.listTargets().length) throw new Error('physics mesh: only static triangles are supported');
|
|
20
|
+
const positions = primitive.getAttribute('POSITION'), index = primitive.getIndices();
|
|
21
|
+
if (!positions || positions.getElementSize() !== 3) throw new Error('physics mesh: missing xyz positions');
|
|
22
|
+
const base = vertices.length / 3;
|
|
23
|
+
for (let i = 0; i < positions.getCount(); i++) {
|
|
24
|
+
const p = new Vector3().fromArray(positions.getElement(i, [])).applyMatrix4(matrix);
|
|
25
|
+
if (![p.x,p.y,p.z].every(Number.isFinite)) throw new Error('physics mesh: nonfinite vertex');
|
|
26
|
+
vertices.push(Math.fround(p.x), Math.fround(-p.z), Math.fround(p.y));
|
|
27
|
+
}
|
|
28
|
+
const count = index?.getCount() ?? positions.getCount();
|
|
29
|
+
if (count % 3) throw new Error('physics mesh: incomplete triangle');
|
|
30
|
+
for (let i = 0; i < count; i += 3) {
|
|
31
|
+
const triangle = [0,1,2].map(j => index ? index.getScalar(i+j) : i+j);
|
|
32
|
+
if (!triangle.every(n => Number.isSafeInteger(n) && n >= 0 && n < positions.getCount())) throw new Error('physics mesh: index out of range');
|
|
33
|
+
if (mirrored) [triangle[1],triangle[2]] = [triangle[2],triangle[1]];
|
|
34
|
+
indices.push(...triangle.map(n => base+n));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (!indices.length) throw new Error('physics mesh: no triangles');
|
|
39
|
+
return { vertices, indices };
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Read only; callers must expose source, never key. No environment mutation. */
|
|
6
|
+
export function providerKey(provider, env = process.env) {
|
|
7
|
+
const variable = { fal: 'FAL_KEY', elevenlabs: 'ELEVENLABS_API_KEY' }[provider];
|
|
8
|
+
if (!variable) throw new TypeError('unknown media provider');
|
|
9
|
+
const value = String(env[variable] ?? '').trim();
|
|
10
|
+
if (value) return { key: value, source: variable };
|
|
11
|
+
// An explicitly supplied environment is also an isolated home in tests/tools.
|
|
12
|
+
const home = env.HOME || env.USERPROFILE || (env === process.env ? homedir() : null);
|
|
13
|
+
if (home) for (const relative of [`keys/${provider}-key`, `workspace/${provider}-key`]) {
|
|
14
|
+
const path = join(home, '.homie', relative);
|
|
15
|
+
try {
|
|
16
|
+
const key = readFileSync(path, 'utf8').trim();
|
|
17
|
+
if (key && !/[\r\n]/.test(key)) return { key, source: `~/.homie/${relative}` };
|
|
18
|
+
} catch { /* Missing or unreadable files are not credentials. */ }
|
|
19
|
+
}
|
|
20
|
+
return { key: null, source: null };
|
|
21
|
+
}
|
package/lib/rules-build.mjs
CHANGED
|
@@ -23,8 +23,8 @@ import { seatsOf } from '../worker/seats.mjs';
|
|
|
23
23
|
*/
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
25
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
import { pathToFileURL } from 'node:url';
|
|
26
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
27
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
28
28
|
import { GUARD_MODULE, RULES_MODULE, guardRules, guardedPlugin, problemLine } from './rules-guard.mjs';
|
|
29
29
|
import { capOf, RULES_FRAMES, rulesRates } from '../worker/limits.mjs';
|
|
30
30
|
import { PACKAGE_ROOT, isRulesGame, rulesIndex } from './studio.mjs';
|
|
@@ -66,15 +66,15 @@ function readMap(g) {
|
|
|
66
66
|
* `W` (guard.ts, the wall), `P` (pack.ts) and `M` (math.ts). They are one instance together, apart from any other
|
|
67
67
|
* copy of the runtime in this process, so the guard's budget counter is theirs alone.
|
|
68
68
|
*/
|
|
69
|
-
export async function loadRules(esbuild, root, id, code, { bundleOnly = false } = {}) {
|
|
69
|
+
export async function loadRules(esbuild, root, id, code, { bundleOnly = false, physics = false } = {}) {
|
|
70
70
|
const dir = join(root, '.studio', 'rules');
|
|
71
71
|
mkdirSync(dir, { recursive: true });
|
|
72
72
|
const gen = join(dir, `${id}-${process.pid}-${++loads}.linked.mjs`);
|
|
73
73
|
writeFileSync(gen, code);
|
|
74
74
|
const lib = (name) => JSON.stringify(join(PACKAGE_ROOT, 'rules', name));
|
|
75
|
-
const entry = `import def from ${JSON.stringify(gen)};\nimport * as R from ${lib('rules.ts')};\nimport * as H from ${lib('host.ts')};\nimport * as C from ${lib('core.ts')};\nimport * as W from ${lib('guard.ts')};\nimport * as P from ${lib('pack.ts')};\nimport * as M from ${lib('math.ts')};\nexport { def, R, H, C, W, P, M };\n`;
|
|
75
|
+
const entry = `import def from ${JSON.stringify(gen)};\nimport * as R from ${lib('rules.ts')};\nimport * as H from ${lib('host.ts')};\nimport * as C from ${lib('core.ts')};\nimport * as W from ${lib('guard.ts')};\nimport * as P from ${lib('pack.ts')};\nimport * as M from ${lib('math.ts')};\nexport { def, R, H, C, W, P, M };\n${physics ? `export { initializePhysics } from ${lib('physics-engine.ts')};` : ''}`;
|
|
76
76
|
try {
|
|
77
|
-
const res = await esbuild.build({ stdin: { contents: entry, resolveDir: root, loader: 'js' }, bundle: true, format: bundleOnly ? 'cjs' : 'esm', target: 'es2022', platform: 'neutral', write: false, logLevel: 'silent', plugins: [rulesModulesPlugin()] });
|
|
77
|
+
const res = await esbuild.build({ stdin: { contents: entry, resolveDir: root, loader: 'js' }, bundle: true, format: bundleOnly ? 'cjs' : 'esm', target: 'es2022', platform: 'neutral', ...(physics ? {mainFields: ['module','main']} : {}), write: false, logLevel: 'silent', plugins: [rulesModulesPlugin()] });
|
|
78
78
|
const text = res.outputFiles[0].text;
|
|
79
79
|
if (bundleOnly) { rmSync(gen, { force: true }); return text; }
|
|
80
80
|
const out = join(dir, `${id}.check-${createHash('sha256').update(text).digest('hex').slice(0, 12)}-${process.pid}-${(loads += 1)}.mjs`);
|
|
@@ -146,8 +146,15 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
146
146
|
if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
|
|
147
147
|
const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
|
|
148
148
|
const map = readMap(g);
|
|
149
|
+
if (map.physics !== undefined) {
|
|
150
|
+
const { bakePhysicsMesh } = await import('./physics-mesh.mjs');
|
|
151
|
+
if (!map.physics || typeof map.physics.level !== 'string') throw new Error('map.physics.level must name the game’s static GLB');
|
|
152
|
+
const path = resolve(g.dir, map.physics.level), rel = relative(g.dir, path);
|
|
153
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('physics level must be inside the game');
|
|
154
|
+
map.physics = await bakePhysicsMesh(path);
|
|
155
|
+
}
|
|
149
156
|
const seats = seatsOf(g);
|
|
150
|
-
const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true });
|
|
157
|
+
const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true, physics: Boolean(map.physics) });
|
|
151
158
|
const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
|
|
152
159
|
// The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
|
|
153
160
|
await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
|
|
@@ -158,7 +165,8 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
158
165
|
const trial = RULES_CAPACITY_TRIAL;
|
|
159
166
|
log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
|
|
160
167
|
if (settings.tickHz > trial.tickHz) log(`info: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
161
|
-
const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
|
|
168
|
+
const { def, R, H, initializePhysics } = await loadRules(esbuild, root, g.id, guarded.code, {physics: Boolean(map.physics)});
|
|
169
|
+
if (initializePhysics) await initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
|
|
162
170
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
163
171
|
const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
164
172
|
const snapshotCap = capOf('snap', seats); const checkpointCap = capOf('ckpt', seats);
|
|
@@ -207,8 +215,8 @@ export function viewPlugin(g, rules, entry, { lab = false } = {}) {
|
|
|
207
215
|
if (a.path === 'homie:rules') return { contents: rules.code, resolveDir: g.dir, loader: 'js' };
|
|
208
216
|
if (a.path === 'homie:host') return { contents: `import def from 'homie:rules';\nimport { browserHost } from ${JSON.stringify(join(PACKAGE_ROOT, 'rules', 'browser.ts'))};\nexport const makeHost = browserHost(def, ${JSON.stringify({ id: g.id, build: rules.build, tune: rules.tune, map: rules.map, settings: rules.settings, seats: rules.seats, vocab: game.vocab })});\n`, resolveDir: g.dir, loader: 'js' };
|
|
209
217
|
return (a.path === 'homie:view'
|
|
210
|
-
? { contents: `import 'homie:game';\
|
|
211
|
-
: { contents: `${existsSync(move) ? `import { move } from ${JSON.stringify(move)};` : 'const move = {};'}\nimport { setGame${game.vocab ? ', setAgentFactory' : ''} } from '${RULES_MODULE}/view';\n${game.vocab ? `import { useAgents } from ${JSON.stringify(join(PACKAGE_ROOT, 'agents/agents.ts'))}; setAgentFactory(useAgents);` : ''}\n${factory}\nsetGame(${JSON.stringify(game)}, move, loadHost);\n`, resolveDir: g.dir, loader: 'js' });
|
|
218
|
+
? { contents: `import 'homie:game';\n${rules.map.physics ? `await import(${JSON.stringify(entry)});` : `import ${JSON.stringify(entry)};`}\n`, resolveDir: g.dir, loader: 'js' }
|
|
219
|
+
: { contents: `${existsSync(move) ? `import { move } from ${JSON.stringify(move)};` : 'const move = {};'}\nimport { setGame${game.vocab ? ', setAgentFactory' : ''} } from '${RULES_MODULE}/view';\n${game.vocab ? `import { useAgents } from ${JSON.stringify(join(PACKAGE_ROOT, 'agents/agents.ts'))}; setAgentFactory(useAgents);` : ''}\n${factory}\n${rules.map.physics ? `import {initializePhysics} from ${JSON.stringify(join(PACKAGE_ROOT, 'rules', 'physics-engine.ts'))}; import bytes from ${JSON.stringify(fileURLToPath(new URL('./dist/vendor/bytes.js', import.meta.resolve('@homie-rocks/physics/package.json'))))}; await initializePhysics(Uint8Array.from(atob(bytes), c => c.charCodeAt(0)));` : ''}\nsetGame(${JSON.stringify(game)}, move, loadHost);\n`, resolveDir: g.dir, loader: 'js' });
|
|
212
220
|
});
|
|
213
221
|
rulesModulesPlugin().setup(b);
|
|
214
222
|
guarded.setup(b);
|
|
@@ -228,7 +236,7 @@ export function writeRules(root, built, ids) {
|
|
|
228
236
|
mkdirSync(dir, { recursive: true });
|
|
229
237
|
for (const r of built.filter((r) => r.settings.host === 'server')) {
|
|
230
238
|
writeFileSync(join(dir, `${r.id}.mjs`), `${HEADER}// The rules of games/${r.id}, checked and guarded (build ${r.build}).\n${r.code}`);
|
|
231
|
-
writeFileSync(join(dir, `${r.id}.data.mjs`), `${HEADER}export default ${JSON.stringify({ tune: r.tune, map: r.map, settings: r.settings, seats: r.seats, build: r.build, stateHash: r.stateHash }, null, 2)};\n`);
|
|
239
|
+
writeFileSync(join(dir, `${r.id}.data.mjs`), `${HEADER}${r.map.physics ? `import {initializePhysics} from '@homie-rocks/studio/rules/physics-engine';\nimport wasm from '@homie-rocks/physics/vendor/rapier.wasm';\n` : ''}export default ${r.map.physics ? '{initialize: () => initializePhysics(wasm), ...' : ''}${JSON.stringify({ tune: r.tune, map: r.map, settings: r.settings, seats: r.seats, build: r.build, stateHash: r.stateHash }, null, 2)}${r.map.physics ? '}' : ''};\n`);
|
|
232
240
|
}
|
|
233
241
|
const have = ids.filter((id) => existsSync(join(dir, `${id}.mjs`)) && existsSync(join(dir, `${id}.data.mjs`)));
|
|
234
242
|
for (const name of readdirSync(dir)) {
|
|
@@ -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
|
*
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
* units used by the room and by the browsers' `move`, bytes of save rebuilt), never by a clock, so the same source gives
|
|
20
21
|
* the same verdict and the same lines on every computer.
|
|
21
22
|
*/
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
22
24
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
23
25
|
import { compileFunction } from 'node:vm';
|
|
24
26
|
import { checkDecide } from '../worker/brain.mjs';
|
|
@@ -353,8 +355,9 @@ function play(c, companions, allow) {
|
|
|
353
355
|
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
354
356
|
units += result.used;
|
|
355
357
|
const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
|
|
356
|
-
result.pos = L.P.vec3(
|
|
358
|
+
result.pos = L.P.vec3(boundPosition(result.pos,geometry??c.map,r,height,c.dims),c.dims);
|
|
357
359
|
delete result.used;
|
|
360
|
+
delete result.steps; // Drawing traces are not authoritative body fields.
|
|
358
361
|
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
362
|
}
|
|
360
363
|
if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
|
|
@@ -389,6 +392,7 @@ try {
|
|
|
389
392
|
let at = null; try { const g = linked.exports.W.G; if (g.line) at = `${g.file}:${g.line}`; } catch { /* the guard itself did not load */ }
|
|
390
393
|
throw new Error(`${at ?? here()}: ${diagnostic(String(error?.message ?? error))}`);
|
|
391
394
|
}
|
|
395
|
+
if (L.initializePhysics) await L.initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
|
|
392
396
|
const { c, problems } = compile();
|
|
393
397
|
const result = { vocab: data.vocab, schema: L.R.schemaOf(c), publicTune: c.publicTune, settings: c.settings, problems, rounds: c.rounds, stateHash: stateHash(c),
|
|
394
398
|
declarations: { events: c.events, asks: Object.fromEntries(Object.entries(c.asks).map(([n, a]) => [n, { state: a.stateFields, questions: a.questions }])), view: L.def.shapes?.view ?? {} } };
|
package/lib/rules-check.mjs
CHANGED
|
@@ -67,5 +67,5 @@ export function runRulesWorker(bundle, data, { declarationsOnly = false, allowan
|
|
|
67
67
|
export async function checkRules(esbuild, root, id, code, data, options = {}) {
|
|
68
68
|
// Imported here, not at the top: the worker imports this file for `stateHash`, and has no use for the build's tools.
|
|
69
69
|
const { loadRules } = await import('./rules-build.mjs');
|
|
70
|
-
return runRulesWorker(await loadRules(esbuild, root, id, code, { bundleOnly: true }), { id, ...data }, options);
|
|
70
|
+
return runRulesWorker(await loadRules(esbuild, root, id, code, { bundleOnly: true, physics: Boolean(data.map?.physics) }), { id, ...data }, options);
|
|
71
71
|
}
|