@homie-rocks/studio 0.47.0 → 0.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,97 @@ 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.1] - 2026-10-13
21
+
22
+ **Plugin 0.48.1** · Input history under loss.
23
+
24
+ Rules views recover missing input and acknowledge late movement scheduling.
25
+
26
+ ### Changed
27
+
28
+ - Snapshots that precede a predicted input's application receipt retain the live
29
+ movement prediction within the replay window; explicit placements still apply.
30
+ - Prediction clock steering is limited to 0.5% instead of 5%; it slews without
31
+ repeatedly rebasing stamped input when the jitter estimate changes.
32
+ - Rules input frames repeat a bounded history sized from measured latency and
33
+ jitter and missing application receipts, with input sequence numbers and sub-tick control changes. Snapshot
34
+ control rows report application ticks, including pulses. Retransmissions no
35
+ longer distort latency steering. A newer tick acknowledgement cannot stop
36
+ retries for an earlier missing input; only its own receipt does.
37
+ - Late movement replays up to one second of body history against historical
38
+ geometry, fenced by authoritative body writes and placements. Outside that
39
+ history an entry applies next tick. Entries with press handlers also apply
40
+ next tick, so historical replay cannot duplicate world actions. New views use application receipts instead
41
+ of negative held-input compensation; old wire entries retain their behaviour.
42
+ - Small visual corrections use `room.predict.blendMs` and expire within one
43
+ second (or an explicitly longer blend), including while stationary; `idleHold` no longer holds them indefinitely. Placement counters
44
+ still distinguish respawns from prediction corrections. Vertical travel cannot
45
+ erase a horizontal correction fast enough to reverse the held direction.
46
+ Remaining offsets drain over the final 250 ms rather than snapping at expiry.
47
+
48
+ ### Added
49
+
50
+ - `room.input(sample, {at})` timestamps a control inside the current predicted
51
+ tick. `player.pulses: 'hold'` queues actions while control is taken;
52
+ `'drop'` remains the default.
53
+ - Seeded virtual-time coverage of 40/80/150/300 ms each way and 0.5/1/2/5% loss,
54
+ dropped inputs and ordered stall/burst delivery, in small rooms and through Gates.
55
+ - A seeded baked-physics-world regression at 80 ms each way and 2% loss checks
56
+ zero drawn warps, application receipts, 300 Hz substeps, predictor room reads
57
+ and exact settling for two moving bodies.
58
+
59
+ ### Fixed
60
+
61
+ - Multiple pulse calls within one predicted tick queue instead of overwriting
62
+ one another. Their application ticks are distinct from the movement entry.
63
+ - Placements preserve the established prediction-clock phase, and the snapshot
64
+ body is advanced to its predicted tick instead of relabelling an old pose.
65
+ Projection is bounded to the server's one-second input window, and its controls
66
+ are stamped for that same path. Queued held state from an earlier placement
67
+ cannot later change the respawned body; discrete presses received before the
68
+ browser learns of the placement still belong to the player.
69
+ - A fresh control after a browser stall is recorded after resetting the stale
70
+ prediction clock, preventing a short press from moving by the stalled backlog.
71
+
72
+ ## [0.48.0] - 2026-10-13
73
+
74
+ **Plugin 0.48.0** · Baked physics worlds for server rules and local prediction.
75
+
76
+ Rules games can sweep capsules through a baked GLB with the same deterministic physics on the server and in local prediction.
77
+
78
+ ### Added
79
+
80
+ - Rules games can opt into deterministic Rapier collision through
81
+ `@homie-rocks/physics`: build-time GLB baking, asynchronous initialization before
82
+ ticking, synchronous metered capsule sweeps, casts, rays, overlaps and solver
83
+ steps, and complete physics snapshots saved with the room. Browser movement
84
+ replays against the same baked world. Games without physics retain their
85
+ existing behavior and budget costs.
86
+ - `ctx.substeps(body, hz, callback)` lets a game choose its own integration rate
87
+ within a room tick. Server and browser share the callback and budget; the view
88
+ draws its recorded intermediate poses. The build checks authoritative body
89
+ fields separately from these temporary drawing traces.
90
+ - Release tests compare 220 complete guarded-room hashes across Node, Chrome and
91
+ workerd for eight capsules at 300 Hz on a 1,754-triangle level, restore mid-fall,
92
+ and settle two predictions exactly with 150 ms each way and seeded packet loss.
93
+ The calibration and real two-Chrome-player Octree trial are recorded in
94
+ `docs/physics-048-calibration.md` and `docs/receipts/`.
95
+ - Physics 0.1.2 adds a stateless capsule cast for applications that own integration
96
+ and replay; it uses the package's existing character contact handling.
97
+
98
+ ### Fixed
99
+
100
+ - Predictor callbacks that inspect the room no longer reenter snapshot replay.
101
+ - Provider discovery recognizes existing Homie fal and ElevenLabs key files,
102
+ including the legacy workspace location, without copying or exposing keys.
103
+
104
+ ### Upgrade notes
105
+
106
+ - Physics is opt-in. Install `@homie-rocks/physics` 0.1.2 and name the
107
+ game's static GLB with `map.physics.level`; see the Physics section of the
108
+ rules guide. The complete solver save can exceed browser-host checkpoint caps,
109
+ so the real-mesh example uses server hosting.
110
+
20
111
  ## [0.47.0] - 2026-10-13
21
112
 
22
113
  **Plugin 0.47.0** · Wrapped worlds and generated collision maps.
@@ -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
@@ -1172,10 +1221,8 @@ occupied terrain nodes out of the graph, and choose reachable endpoints.
1172
1221
  `@homie-rocks/nav` is the richer mesh/crowd API for applications that own that
1173
1222
  runtime; rules graphs avoid importing mutable navigation state into handlers.
1174
1223
 
1175
- `room.predict.idleHold: true` holds small visual reconciliation offsets while
1176
- input and body are stationary. Movement resumes smoothing. Large invalid poses
1177
- still snap to authority. Opt in only after measuring stop/reversal and delayed
1178
- collision behavior for the game's mover.
1224
+ Small reconciliation offsets expire even while stationary; see “Input during loss
1225
+ and stalls” below for the visual correction settings.
1179
1226
 
1180
1227
  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
1228
 
@@ -1245,3 +1292,141 @@ entities remain suitable for independently moving doors, cover and platforms.
1245
1292
  `spawn` exposes a new entity before its deferred `on.arrive` handler. Pass every
1246
1293
  field that must be valid immediately in the spawn data; use `arrive` for subsequent
1247
1294
  work, not for mandatory initialization.
1295
+
1296
+ ## Physics
1297
+
1298
+ Use the opt-in `@homie-rocks/physics` bridge when the level is an arbitrary triangle
1299
+ mesh: caves, ramps, stairs and the static environment of a game such as Octree
1300
+ Arena. Keep `ctx.map` for the kernel's boxes, circles, capsules and height tiles.
1301
+ Install `@homie-rocks/physics` 0.1.2 in the studio. A game that does not
1302
+ ask for physics loads no physics engine and pays no physics units.
1303
+
1304
+ In `map/main.json`, name a static GLB relative to the game directory:
1305
+
1306
+ ```json
1307
+ {
1308
+ "bounds": { "min": [-100, -100, -100], "max": [100, 100, 100] },
1309
+ "physics": { "level": "assets/level.glb" }
1310
+ }
1311
+ ```
1312
+
1313
+ The build bakes the default scene's node transforms and triangles into the
1314
+ physics package's indexed mesh format. It converts glTF Y-up to rules Z-up
1315
+ (`x, -z, y`), preserves reflected winding, and ships the baked mesh with the
1316
+ server rules and the browser movement bundle. Skins, animations and morph targets
1317
+ need a separate static collision GLB. The server never loads or parses a GLB.
1318
+
1319
+ The smallest moving capsule uses the same guarded movement on both sides:
1320
+
1321
+ ```ts
1322
+ // src/move.ts
1323
+ import { defineMove } from '@homie-rocks/studio/rules';
1324
+ export const move = defineMove({ runner(body, input, ctx) {
1325
+ ctx.substeps(body, 300, step => {
1326
+ if (step.first && input.jump && body.grounded)
1327
+ body.vel = { x: body.vel.x, y: body.vel.y, z: 10 };
1328
+ body.vel = { x: input.ax * 5, y: 0, z: body.vel.z - 30 * step.dt };
1329
+ ctx.physics.sweep(body, ctx.math.scale(body.vel, step.dt), .35, 1.35);
1330
+ });
1331
+ } });
1332
+
1333
+ // src/rules.ts
1334
+ import { defineRules, f } from '@homie-rocks/studio/rules';
1335
+ import { move } from './move';
1336
+ export default defineRules({
1337
+ contract: 2, space: { dims: 3 }, move,
1338
+ entities: { runner: {
1339
+ player: true,
1340
+ input: { ax: f.held(), jump: f.press() },
1341
+ body: { shape: 'capsule', radius: .35, height: 1.35, maxSpeed: 30 }
1342
+ } },
1343
+ room: { bots: { keep: 0 }, join() {
1344
+ return { kind: 'runner', at: { x: 0, y: 0, z: 8 } };
1345
+ } }
1346
+ });
1347
+ ```
1348
+
1349
+ Use `"room": {"host":"server","offline":false}` in `game.json`. Physics
1350
+ initialization is awaited once per isolate before any room can tick; calls inside
1351
+ a handler are synchronous. `sweep(body, delta, radius, height)` sweeps and slides
1352
+ an upright capsule whose `body.pos` is its centre and whose height includes its
1353
+ caps. It writes position, velocity and grounded state. `ctx.physics.cast`,
1354
+ `ray`, and `overlap` expose the package's convex shape cast, raycast and overlap
1355
+ queries; query shapes are boxes, spheres or capsules. Room/entity handlers have
1356
+ these operations at `world.physics` and can call `world.physics.step(seconds)`
1357
+ to advance the collision world's solver clock. Movement owns capsule integration;
1358
+ its queries do not create persistent physics bodies or advance another player's
1359
+ world. This bridge exposes the baked collision world, not rigid-body ownership or
1360
+ predicted dynamic-body interactions.
1361
+
1362
+ Every operation is charged before its engine work. On a 1,754-triangle level:
1363
+
1364
+ | Operation | Budget units |
1365
+ | --- | ---: |
1366
+ | Capsule/convex cast | 768 |
1367
+ | Ray | 640 |
1368
+ | Overlap | 640 |
1369
+ | Solver step | 8 + 2,400 per 1/300 second |
1370
+ | Capsule sweep and slide | 64 + 768 per cast, at most four casts |
1371
+
1372
+ Mesh-query and solver charges scale by `ceil(triangles / 1754)`, at least one.
1373
+ Substeps retain their usual pose and guarded callback charges. There is no new
1374
+ room budget, player cap or rate quota. The reproducible calibration is
1375
+ `scripts/calibrate-rules-physics.mjs`; its receipt compares physics on the real
1376
+ mesh with kernel operations on a box, since the kernel has no arbitrary-mesh
1377
+ or solver-step equivalent.
1378
+
1379
+ A room save includes the package's complete binary world snapshot as JSON bytes,
1380
+ including solver state and its time remainder. The capsule's position, velocity,
1381
+ grounded flag and declared motion are saved by rules as usual. Browser prediction
1382
+ creates the same baked world and replays pending guarded movement against it;
1383
+ substep drawing traces remain temporary. Physics worlds are disposed when their
1384
+ host or view closes. A full solver save can exceed browser-host checkpoint caps;
1385
+ the build measures this, and server-hosted rooms retain the full save.
1386
+
1387
+ ## Input during loss and stalls (0.48.1)
1388
+
1389
+ Rules views resend recent input automatically: a 4–64 tick history follows the
1390
+ measured round trip, jitter and missing application receipts (loss or recovery
1391
+ delay). Each tick entry carries a sequence number; each
1392
+ control change carries a field index, sequence and fraction of the predicted tick.
1393
+ Duplicates neither execute again nor count as fresh latency measurements. Both
1394
+ missing application frames and ordered TCP stalls are covered.
1395
+
1396
+ The server retains up to one second of movement history and replays late movement
1397
+ against the geometry of those ticks. It never repeats commands or effects. An
1398
+ external body/motion write or placement fences that history. Beyond a fence or
1399
+ the retained history, input applies on the next tick instead. Entries carrying
1400
+ press handlers also apply next tick: world actions are never rerun as movement. Snapshot control
1401
+ rows report the application tick and the view uses it for replay. Within the
1402
+ replay window a snapshot still missing a predicted input does not replace live
1403
+ movement; an explicit placement always does. Legacy wire
1404
+ entries retain their old compensation; current views do not receive negative
1405
+ held values to repay late releases.
1406
+
1407
+ Held controls integrate their duration, sampled controls coalesce to the latest
1408
+ value per tick, presses coalesce per field per tick, and valued pulses execute
1409
+ in order, one per tick. More sampled input never buys extra simulation steps.
1410
+ The clock resets before a fresh control after a long browser stall, so the press
1411
+ cannot inherit ten seconds of elapsed time. The prediction clock slews by at
1412
+ most 0.5%, preserving the cadence for fixed-step renderers.
1413
+
1414
+ `room.input(sample, {at: event.timeStamp})` accepts the event's monotonic time
1415
+ (same origin as `performance.now()`), clamped to this predicted tick and now.
1416
+ Omit it to use the call time. A player can declare
1417
+ `player: {control: 'canMove', pulses: 'hold'}` to keep pulses during a knock or
1418
+ stun and deliver them when control returns. The default `'drop'` preserves the
1419
+ previous behaviour. The game's action handler still decides its cooldown.
1420
+
1421
+ Residual drawing corrections use `room.predict.blendMs` (100 ms by default),
1422
+ slowed on each moving axis to avoid reversing movement (jumping cannot spend
1423
+ horizontal correction faster), with a one-second maximum
1424
+ (or the explicitly longer blendMs). Any remainder drains over the final 250 ms
1425
+ rather than disappearing on the deadline. `snapM` selects the large-error threshold;
1426
+ placements still snap. A placement keeps the established clock phase and
1427
+ projects the new body from the snapshot tick to the own tick; it never labels an
1428
+ old pose as a future one. Projection is bounded to one second and sends the
1429
+ controls it used. Queued held state from an earlier placement cannot later
1430
+ change the new body. Newly received discrete presses still belong to the player
1431
+ when the browser has not yet learned of that placement. `idleHold` no longer keeps a stopped body displaced
1432
+ indefinitely. These drawing settings never alter server truth.
@@ -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
@@ -451,3 +456,25 @@ nothing is cut off or runs off the side. Fix what you see; build again.
451
456
  A whole landing of the studio's own (`site/pages/<id>/index.html`) replaces the generated one: only when
452
457
  the person asks for a hand-made page, and start from the generated one's HTML so Play, the TV road and
453
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.
472
+
473
+ ### Input under loss
474
+
475
+ Rules games inherit redundant sequenced input, late movement replay and applied-tick
476
+ receipts. Do not add a game-side resend loop or interpret negative held values as
477
+ late-release repayment. Use `f.held()` for duration, sampled fields for latest
478
+ state and `f.pulse()` for ordered actions. Choose `player.pulses: 'hold'` when a
479
+ knock should buffer an action; the default drops it. See RULES.md, “Input during
480
+ loss and stalls”, for timestamps, replay boundaries and visual correction settings.
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
- return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY), projects: root ? projectsElevenLabs(root) : false };
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 = String(env.FAL_KEY ?? '').trim();
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 ? 'ELEVENLABS_API_KEY is set' : 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',
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 FAL_KEY in this environment' : fal.valid === true ? 'FAL_KEY is set and fal accepts it' : fal.valid === false ? 'fal refused the FAL_KEY that is set (wrong or revoked)' : 'FAL_KEY is set (fal did not answer just now)',
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.ELEVENLABS_API_KEY || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
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
+ }
@@ -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';\nimport ${JSON.stringify(entry)};\n`, resolveDir: g.dir, loader: 'js' }
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)) {
@@ -20,6 +20,7 @@ import { boundPosition } from '../rules/topology.mjs';
20
20
  * units used by the room and by the browsers' `move`, bytes of save rebuilt), never by a clock, so the same source gives
21
21
  * the same verdict and the same lines on every computer.
22
22
  */
23
+ import { readFileSync } from 'node:fs';
23
24
  import { parentPort, workerData } from 'node:worker_threads';
24
25
  import { compileFunction } from 'node:vm';
25
26
  import { checkDecide } from '../worker/brain.mjs';
@@ -356,6 +357,7 @@ function play(c, companions, allow) {
356
357
  const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
357
358
  result.pos = L.P.vec3(boundPosition(result.pos,geometry??c.map,r,height,c.dims),c.dims);
358
359
  delete result.used;
360
+ delete result.steps; // Drawing traces are not authoritative body fields.
359
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()}`;
360
362
  }
361
363
  if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
@@ -390,6 +392,7 @@ try {
390
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 */ }
391
393
  throw new Error(`${at ?? here()}: ${diagnostic(String(error?.message ?? error))}`);
392
394
  }
395
+ if (L.initializePhysics) await L.initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
393
396
  const { c, problems } = compile();
394
397
  const result = { vocab: data.vocab, schema: L.R.schemaOf(c), publicTune: c.publicTune, settings: c.settings, problems, rounds: c.rounds, stateHash: stateHash(c),
395
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 ?? {} } };
@@ -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
  }
@@ -69,9 +69,10 @@ export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
69
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
- export const MOVE_METHODS = ['support', 'overlaps'];
73
- const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
74
- const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
72
+ export const MOVE_METHODS = ['substeps', 'support', 'overlaps'];
73
+ export const PHYSICS_METHODS = ['step', 'cast', 'overlap'];
74
+ const NEW_METHODS = new Set(['step','cast','overlap','substeps','route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
75
+ const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...PHYSICS_METHODS, ...MATH_METHODS]);
75
76
  const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
76
77
  /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
77
78
  const CONVERTING = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', '<', '>', '<=', '>=']);
@@ -119,7 +119,7 @@ export type Room<R = never> = Omit<BaseRoom<R>, 'me' | 'each' | 'get' | 'on' | '
119
119
  each<K extends keyof Entities>(kind: K, fn: (entity: Entities[K]) => void): void;
120
120
  get(id: string): Entity | null;
121
121
  on<K extends keyof Effects>(name: K, fn: (event: Effects[K]) => void): () => void;
122
- input(sample: ${input}): void;
122
+ input(sample: ${input}, options?: {at?: number}): void;
123
123
  command<K extends keyof Commands>(name: K, ...data: {} extends Commands[K] ? [data?: Commands[K]] : [data: Commands[K]]): void;
124
124
  };
125
125
  export declare function openRoom<R = never>(options?: OpenRoomOptions): Room<R>;