@homie-rocks/studio 0.38.0 → 0.40.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/bin/homie-studio.mjs +13 -1
  3. package/lib/build.mjs +4 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/port-check.mjs +33 -7
  8. package/lib/rules-build.mjs +8 -1
  9. package/lib/rules-check-worker.mjs +3 -3
  10. package/lib/rules-check.mjs +3 -1
  11. package/lib/scaffold.mjs +19 -6
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +82 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +74 -2
  18. package/package.json +17 -6
  19. package/rules/collision.ts +115 -0
  20. package/rules/core.ts +65 -25
  21. package/rules/host.ts +5 -1
  22. package/rules/math.ts +15 -5
  23. package/rules/rules.ts +33 -10
  24. package/rules/types.ts +2 -2
  25. package/rules/view.ts +126 -32
  26. package/starters/ember-vale/game.json +16 -4
  27. package/starters/ember-vale/map/main.json +66 -0
  28. package/starters/ember-vale/src/move.ts +31 -0
  29. package/starters/ember-vale/src/rules.ts +244 -0
  30. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  31. package/starters/ember-vale/tunables.json +100 -11
  32. package/starters/gem-rush/src/move.ts +9 -1
  33. package/starters/gem-rush/src/view.ts +2 -1
  34. package/starters/gem-rush-3d/game.json +23 -5
  35. package/starters/gem-rush-3d/map/main.json +153 -0
  36. package/starters/gem-rush-3d/src/move.ts +37 -0
  37. package/starters/gem-rush-3d/src/rules.ts +133 -0
  38. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  39. package/starters/gem-rush-3d/tunables.json +98 -11
  40. package/starters/hero-rush-3d/game.json +23 -5
  41. package/starters/hero-rush-3d/map/main.json +153 -0
  42. package/starters/hero-rush-3d/src/move.ts +49 -0
  43. package/starters/hero-rush-3d/src/rules.ts +200 -0
  44. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  45. package/starters/hero-rush-3d/tunables.json +260 -30
  46. package/tools/TOOLS.md +113 -0
  47. package/tools/tools.ts +28 -0
  48. package/worker/app-records.mjs +8 -0
  49. package/worker/index.mjs +26 -0
  50. package/worker/mcp-room.mjs +61 -0
  51. package/worker/mcp-store.mjs +45 -0
  52. package/worker/mcp-tools.mjs +105 -0
  53. package/worker/mcp.mjs +159 -0
  54. package/worker/office-page.mjs +1 -1
  55. package/worker/office.mjs +2 -1
  56. package/worker/purchase-mcp.mjs +3 -3
  57. package/worker/tool-events.mjs +14 -0
  58. package/worker/tool-identity.mjs +4 -0
  59. package/worker/tool-webhook.mjs +28 -0
  60. package/worker/version.mjs +1 -1
package/rules/host.ts CHANGED
@@ -290,6 +290,10 @@ export function createHost(o: HostOptions): Host {
290
290
  agents.on('ask', (d) => o.send({ t: 'ev', k: 'agent:ask', d }));
291
291
  }
292
292
  function guideBeat(): void {
293
+ // Roles come from admitted peers and reserved server seats, never player input.
294
+ const guideSeats = agentSlots().filter(slot => slot.agent?.role === 'guide').map(slot => slot.slot).sort((a, b) => a - b);
295
+ const previous = (core.world as { guideSeats: readonly number[] }).guideSeats;
296
+ if (guideSeats.length !== previous.length || guideSeats.some((seat, i) => seat !== previous[i])) core.setPolicy({ guideSeats });
293
297
  if (!agents) return;
294
298
  const bodies = core.bodies();
295
299
  for (const b of bodies) {
@@ -498,7 +502,7 @@ export function createHost(o: HostOptions): Host {
498
502
  speech: raw.speech === 'off' || raw.speech === 'lines' ? raw.speech : 'game' };
499
503
  if (previous.kind !== policy.kind || previous.aiSeats !== policy.aiSeats || previous.guides !== policy.guides) rosterText = '';
500
504
  }
501
- if (p) core.setPolicy({ bots: p.bots === 'off' ? 'off' : 'fill', level: num(own(own(p, 'skill'), 'level') ?? own(p, 'level')), levelMax: num(own(p, 'levelMax')), reserved: policy.kind === 'hybrid' || policy.kind === 'beginner' ? policy.aiSeats + policy.guides : 0 });
505
+ if (p) core.setPolicy({ kids: own(p, 'kids') === true, ...(own(p, 'level') !== undefined ? { guideLevel: num(own(p, 'level')) } : {}), levelSet: Boolean(own(p, 'by')), bots: p.bots === 'off' ? 'off' : 'fill', level: num(own(own(p, 'skill'), 'level') ?? own(p, 'level')), levelMax: num(own(p, 'levelMax')), reserved: policy.kind === 'hybrid' || policy.kind === 'beginner' ? policy.aiSeats + policy.guides : 0 });
502
506
  return;
503
507
  }
504
508
  default: return;
package/rules/math.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * Every call is charged 4 units to the running handler's budget (guard.ts), beside what any guarded call costs.
13
13
  * =============================================================================
14
14
  */
15
+ import { castMap3, restsOnMap, type BodyShape } from './collision.ts';
15
16
  import { brand, charge, own, put } from './guard.ts';
16
17
  import { num } from './pack.ts';
17
18
 
@@ -181,7 +182,7 @@ export const exact = Object.freeze({ sin, cos, atan, atan2, exp, log, pow });
181
182
  /* ------------------------------------------------------------------ the ground plane: a point or a circle against the map */
182
183
 
183
184
  /** The shapes of a map the sweep reads (rules.ts `GameMap` has them). */
184
- export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[] }
185
+ export interface MapShapes { bounds: { min: Vec3; max: Vec3 }; boxes: readonly { min: Vec3; max: Vec3 }[]; circles: readonly { at: Vec3; r: number }[]; spheres?: readonly { at: Vec3; r: number }[]; capsules?: readonly { at: Vec3; r: number; height: number }[]; heightTiles?: readonly import('./rules.ts').MapHeightTile[] }
185
186
  /** How close a swept body stops to what it hit, in metres. */
186
187
  export const SKIN = 0.001;
187
188
 
@@ -244,9 +245,9 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
244
245
  /**
245
246
  * `ctx.map.sweep(body, delta)`: move `body.pos` along `delta` and stop at the first static shape in the way. The server
246
247
  * and a browser both move a body with this, so they agree to the last bit. Returns nothing, or `{ at, normal }`.
247
- * Charged 20 units and 4 for each shape of the map. With `dims` 2 a body always rests on the ground.
248
+ * The 2D path charges 20 units and 4 per shape; 3D also charges each convex cast and terrain tile. With `dims` 2 a body always rests on the ground.
248
249
  */
249
- export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, _dims: number): unknown {
250
+ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
250
251
  // Charged before the cast, for every shape of the map it may test.
251
252
  charge(20 + 4 * (4 + map.boxes.length + map.circles.length));
252
253
  const fr = Math.fround;
@@ -256,6 +257,15 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
256
257
  const pos = own(body, 'pos');
257
258
  const dx = num(own(delta, 'x')); const dy = num(own(delta, 'y'));
258
259
  const px = num(own(pos, 'x')); const py = num(own(pos, 'y'));
260
+ if (dims === 3) {
261
+ const p = v(px, py, num(own(pos, 'z'))), d = v(dx, dy, num(own(delta, 'z')));
262
+ const h = castMap3(map, p, d, shape);
263
+ const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
264
+ const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
265
+ const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
266
+ put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
267
+ return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz) }) : undefined;
268
+ }
259
269
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
260
270
  put(body, 'grounded', true);
261
271
  if (!h) { put(body, 'pos', v(fr(px + dx), fr(py + dy), 0)); return undefined; }
@@ -271,12 +281,12 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
271
281
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
272
282
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
273
283
  */
274
- export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; dims: number }): unknown {
284
+ export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number }): unknown {
275
285
  const none = Object.freeze([]);
276
286
  return brand(Object.freeze({
277
287
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
278
288
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
279
289
  ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
280
- map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims) })),
290
+ map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims, o.shape?.()) })),
281
291
  }));
282
292
  }
package/rules/rules.ts CHANGED
@@ -71,7 +71,7 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
71
71
  /**
72
72
  * The types of declared state. Whole numbers (`u8` to `u32`, `i8` to `i32`) are held to their range; `bit` is true or
73
73
  * false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
74
- * unit vector on the ground plane; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
74
+ * unit vector in the declared dimensions; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
75
75
  * entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
76
76
  * fires once.
77
77
  */
@@ -236,7 +236,8 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
236
236
 
237
237
  export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
238
238
  export interface MapCircle { readonly at: Vec3; readonly r: number }
239
- export interface GameMap { readonly name: string; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
239
+ export interface MapHeightTile { readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
240
+ export interface GameMap { readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
240
241
 
241
242
  const vecOf = (a: unknown, what: string): Vec3 => {
242
243
  const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
@@ -247,8 +248,7 @@ const vecOf = (a: unknown, what: string): Vec3 => {
247
248
 
248
249
  /**
249
250
  * One map file (`games/<id>/map/<name>.json`), checked: `bounds` (the world's edges), `boxes` and `circles` (solid
250
- * shapes on the ground plane) and named `spots`. Positions are metres, `z` up. Height tiles, spheres and capsules
251
- * arrive with 3D bodies.
251
+ * shapes on the ground plane), spheres, upright capsules, height tiles and named `spots`. Positions are metres, `z` up.
252
252
  */
253
253
  export function compileMap(raw: unknown, name = 'main'): GameMap {
254
254
  const m = raw && typeof raw === 'object' ? raw as Record<string, any> : {};
@@ -256,7 +256,7 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
256
256
  if (!m.bounds) throw new Error(`${where} needs "bounds": { "min": [x, y], "max": [x, y] }, the edges of the world in metres`);
257
257
  const box = (b: any, what: string): MapBox => {
258
258
  const min = vecOf(b?.min, `${what}.min`); const max = vecOf(b?.max, `${what}.max`);
259
- if (!(max.x > min.x) || !(max.y > min.y)) throw new Error(`${what}: "max" is above and to the right of "min"`);
259
+ if (!(max.x > min.x) || !(max.y > min.y) || max.z < min.z) throw new Error(`${what}: "max" is above and to the right of "min"`);
260
260
  return Object.freeze({ min, max });
261
261
  };
262
262
  const spots: Record<string, readonly Vec3[]> = {};
@@ -264,13 +264,33 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
264
264
  if (!/^[a-z][A-Za-z0-9_-]{0,31}$/.test(key) || !Array.isArray(list)) throw new Error(`${where} spots.${key}: a spot name is a short word and holds a list of points`);
265
265
  spots[key] = Object.freeze(list.map((p, i) => vecOf(p, `${where} spots.${key}[${i}]`)));
266
266
  }
267
+ const roundShape = (c: any, what: string): MapCircle => {
268
+ const r = Number(c?.r);
269
+ if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${what} needs a finite positive radius`);
270
+ return Object.freeze({ at: vecOf(c?.at, `${what}.at`), r });
271
+ };
272
+ if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].some(list => list !== undefined && !Array.isArray(list))) throw new Error(`${where}: shapes are lists`);
273
+ if ([m.boxes, m.circles, m.spheres, m.capsules, m.heightTiles].reduce((n, list) => n + (list?.length ?? 0), 0) > 4096) throw new Error(`${where}: at most 4096 static shapes`);
267
274
  return Object.freeze({
268
275
  name,
276
+ heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
277
+ const what = `${where} heightTiles[${i}]`;
278
+ const at = vecOf(t?.at, `${what}.at`);
279
+ if (!Array.isArray(t.size) || t.size.length !== 2 || t.size.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v) || v <= 0)) throw new Error(`${what}: size is two positive finite lengths`);
280
+ if (!Array.isArray(t.heights) || t.heights.length !== 4 || t.heights.some((v: unknown) => typeof v !== 'number' || !Number.isFinite(v))) throw new Error(`${what}: heights are four finite offsets, in row order`);
281
+ return Object.freeze({ at, size: Object.freeze({ x: t.size[0], y: t.size[1] }), heights: Object.freeze([...t.heights]) as unknown as MapHeightTile['heights'] });
282
+ })),
283
+ spheres: Object.freeze((m.spheres ?? []).map((c: any, i: number) => roundShape(c, `${where} spheres[${i}]`))),
284
+ capsules: Object.freeze((m.capsules ?? []).map((c: any, i: number) => {
285
+ const shape = roundShape(c, `${where} capsules[${i}]`);
286
+ if (!Number.isFinite(c.height) || c.height < 2 * shape.r) throw new Error(`${where} capsules[${i}]: height is at least twice the radius`);
287
+ return Object.freeze({ ...shape, height: c.height });
288
+ })),
269
289
  bounds: box(m.bounds, `${where} bounds`),
270
290
  boxes: Object.freeze((Array.isArray(m.boxes) ? m.boxes : []).map((b: unknown, i: number) => box(b, `${where} boxes[${i}]`))),
271
291
  circles: Object.freeze((Array.isArray(m.circles) ? m.circles : []).map((c: any, i: number) => {
272
292
  const r = Number(c?.r);
273
- if (!(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
293
+ if (!Number.isFinite(r) || !(r > 0)) throw new Error(`${where} circles[${i}] needs "r", its radius in metres`);
274
294
  return Object.freeze({ at: vecOf(c?.at, `${where} circles[${i}].at`), r });
275
295
  })),
276
296
  spots: Object.freeze(spots),
@@ -430,6 +450,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
430
450
  if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
431
451
  const settings = env.settings ?? ROOM_DEFAULTS;
432
452
  const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
453
+ if (dims === 2 && (map.spheres.length || map.capsules.length || map.heightTiles.length)) throw new Error('spheres, capsules and height tiles need space.dims: 3');
454
+ if (dims === 3 && map.bounds.max.z <= map.bounds.min.z) throw new Error('a 3D map needs bounds with positive height');
433
455
  const { tune, publicTune } = tunablesOf(env.tune);
434
456
 
435
457
  const events: Record<string, FieldList> = {};
@@ -475,9 +497,10 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
475
497
  const b = e.body;
476
498
  const shapes = dims === 2 ? ['circle'] : ['sphere', 'capsule', 'box'];
477
499
  if (!b || !shapes.includes(b.shape)) throw new Error(`${at}.body.shape is ${shapes.map((s) => `'${s}'`).join(' or ')} when space.dims is ${dims}`);
478
- if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
500
+ if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !Number.isFinite(b.radius) || !Number.isFinite(b.maxSpeed) || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
479
501
  if (b.move !== undefined && b.move !== 'owner') throw new Error(`${at}.body.move is 'owner', or left out`);
480
- body = { shape: b.shape, radius: b.radius, height: typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner' };
502
+ if (dims === 3 && b.height !== undefined && (!Number.isFinite(b.height) || b.height <= 0 || b.shape === 'capsule' && b.height < b.radius * 2)) throw new Error(`${at}.body.height must be finite, positive and at least twice the radius for a capsule`);
503
+ body = { shape: b.shape, radius: b.radius, height: dims === 3 && b.shape === 'sphere' ? 2 * b.radius : typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner' };
481
504
  }
482
505
  if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
483
506
  const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
@@ -535,7 +558,7 @@ export interface Schema {
535
558
  contract: 2;
536
559
  dims: 2 | 3;
537
560
  seats: number;
538
- kinds: { name: string; player: boolean; owner: boolean; radius: number; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
561
+ kinds: { name: string; player: boolean; owner: boolean; radius: number; shape?: string; height?: number; maxSpeed: number; score: string | null; fields: FieldList; motion: FieldList; input: FieldList }[];
539
562
  effects: Record<string, FieldList>;
540
563
  effectNames: string[];
541
564
  commands: Record<string, FieldList>;
@@ -546,7 +569,7 @@ export interface Schema {
546
569
  export function schemaOf(c: Compiled): Schema {
547
570
  return {
548
571
  contract: RULES_CONTRACT, dims: c.dims, seats: c.seats,
549
- kinds: c.kinds.map((k) => ({ name: k.name, player: Boolean(k.player), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields, motion: k.motion, input: k.input })),
572
+ kinds: c.kinds.map((k) => ({ name: k.name, player: Boolean(k.player), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields, motion: k.motion, input: k.input })),
550
573
  effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
551
574
  };
552
575
  }
package/rules/types.ts CHANGED
@@ -22,7 +22,7 @@ export interface MapView { readonly name: string; spot(name: string): Vec3 | und
22
22
  export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
23
23
  export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
24
24
  export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
25
- readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number;
25
+ readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
26
26
  readonly shared: Room extends true ? S : ReadonlyState<S>;
27
27
  readonly round: Round & (Room extends true ? { end(): void } : {});
28
28
  random(): number;
@@ -49,7 +49,7 @@ export type Undeliverable<E> = { [K in keyof E]: { to: string; event: K; data: E
49
49
  export type BuiltIns<E = Record<string, never>> = { arrive: { why: 'join' | 'spawn' }; leave: {}; takeover: {}; undeliverable: Undeliverable<E>; answer: { ask: string; by: string; picks: Readonly<Record<string, unknown>>; why?: string } };
50
50
  export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined: { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; owner: string; took: boolean }; seatAway: { seat: number; id: string; away: boolean }; seatLeft: { seat: number; id: string }; answer: BuiltIns['answer']; undeliverable: BuiltIns<E>['undeliverable'] };
51
51
  export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
52
- type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
52
+ type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
53
53
  export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
54
54
  player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
55
55
  tick?: (world: W, self: S) => void;
package/rules/view.ts CHANGED
@@ -46,10 +46,28 @@ import { BudgetError } from './guard.ts';
46
46
  import { moveContext } from './math.ts';
47
47
  import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
48
48
  import type { Unpacked } from './pack.ts';
49
+ import { compileMap } from './rules.ts';
49
50
  import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
50
51
 
52
+ /** Unit headings take the shortest arc, including an exact half turn. Linear
53
+ * interpolation passes through zero at a half turn and visibly flips a character. */
54
+ export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
55
+ if (t <= 0) return a;
56
+ if (t >= 1) return b;
57
+ const dot = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
58
+ if (dot > 0.9995) return dir({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), z: lerp(a.z, b.z, t) }, dims);
59
+ if (dot < -0.9995) {
60
+ const side = Math.abs(a.z) < 0.9 ? dir({ x: -a.y, y: a.x, z: 0 }, 3) : dir({ x: a.z, y: 0, z: -a.x }, 3);
61
+ const c = Math.cos(Math.PI * t), s = Math.sin(Math.PI * t);
62
+ return dir({ x: a.x * c + side.x * s, y: a.y * c + side.y * s, z: a.z * c + side.z * s }, dims);
63
+ }
64
+ const angle = Math.acos(dot), scale = Math.sin(angle);
65
+ const from = Math.sin((1 - t) * angle) / scale, to = Math.sin(t * angle) / scale;
66
+ return dir({ x: a.x * from + b.x * to, y: a.y * from + b.y * to, z: a.z * from + b.z * to }, dims);
67
+ }
68
+
51
69
  /** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
52
- export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
70
+ export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
53
71
  let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
54
72
  /** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
55
73
  export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
@@ -67,6 +85,8 @@ export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
67
85
  export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
68
86
  export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
69
87
  export interface OpenRoomOptions {
88
+ /** Play a private rules round while an online room has no body for this player. */
89
+ fallback?: 'solo';
70
90
  game?: GameData;
71
91
  move?: Record<string, MoveFn>;
72
92
  net?: NetplayOptions;
@@ -97,7 +117,7 @@ export interface Room<R = unknown> {
97
117
  askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
98
118
  readonly tune: Readonly<Record<string, unknown>>;
99
119
  /** The game's static map: its edges, its solid shapes and its named spots, in metres. */
100
- readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
120
+ readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly heightTiles: readonly import('./rules.ts').MapHeightTile[]; readonly spheres: readonly { at: Vec3; r: number }[]; readonly capsules: readonly { at: Vec3; r: number; height: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
101
121
  readonly net: Netplay;
102
122
  /** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
103
123
  pump(): void;
@@ -124,11 +144,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
124
144
  const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
125
145
  const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
126
146
  const listeners = new Map<string, Set<(e: any) => void>>();
127
- const emit = (name: string, e: unknown): void => { for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
128
- const vecOf = (a: unknown): Vec3 => { const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a; return vec3(p, dims); };
129
- const map = { bounds: { min: vecOf(game.map.bounds.min), max: vecOf(game.map.bounds.max) }, boxes: (game.map.boxes ?? []).map((b: any) => ({ min: vecOf(b.min), max: vecOf(b.max) })), circles: (game.map.circles ?? []).map((c: any) => ({ at: vecOf(c.at), r: Number(c.r) })) };
130
- const spots: Record<string, readonly Vec3[]> = {};
131
- for (const [key, list] of Object.entries(game.map.spots ?? {})) spots[key] = Object.freeze(list.map(vecOf));
147
+ let solo: Room<R> | null = null, noBodyMs = 0, checkedAt = clock();
148
+ const soloListeners = new Map<string, () => void>();
149
+ const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
150
+ const map = compileMap(game.map, game.map.name ?? 'main');
151
+ const spots = map.spots;
132
152
  const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
133
153
 
134
154
  let status: RoomStatus = 'connecting';
@@ -183,6 +203,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
183
203
  let observedLeadAt = 0;
184
204
  let catchTick: number | null = null;
185
205
  let catchAt = 0;
206
+ let headingBlend: { from: Vec3; at: number } | null = null;
186
207
  let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
187
208
  const predict = schema.settings.predict;
188
209
  const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
@@ -199,6 +220,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
199
220
  if (path) {
200
221
  // Loss can make even a small correction longer than the distance travelled in
201
222
  // 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
223
+ // A forced knock can travel much faster than running. It must not also
224
+ // erase a large offset at that speed and almost double the drawn impulse.
202
225
  // Only one queued fade advances at a time. A stopped body finishes its correction.
203
226
  if (waiting) o.at = now;
204
227
  else {
@@ -224,7 +247,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
224
247
  const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
225
248
  /** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
226
249
  let moveTick = 0;
227
- const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
250
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
228
251
  function authorityRtt(): number {
229
252
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
230
253
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
@@ -257,7 +280,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
257
280
  base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
258
281
  stepped = Math.floor(base.tick) - 1;
259
282
  entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
260
- speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; correction.rebases += 1;
283
+ speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
261
284
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
262
285
  if (mine) {
263
286
  history.set(stepped, copyMine(mine));
@@ -275,8 +298,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
275
298
  if (!fn) return body;
276
299
  moveTick = t;
277
300
  const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
278
- const r = kindOf.get(kindName)?.radius ?? 0;
279
- out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: out.pos.z }, dims);
301
+ const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
302
+ const height = kind?.height || 2 * r;
303
+ out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
280
304
  return out;
281
305
  }
282
306
  /** The input values the last step held (a press is never held). */
@@ -329,6 +353,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
329
353
  if (closed) return;
330
354
  if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
331
355
  const now = clock();
356
+ checkSolo(now);
332
357
  const hidden = typeof document !== 'undefined' && document.hidden === true;
333
358
  // Effects play for everyone who draws the room: a watcher has no body and still sees them.
334
359
  fire();
@@ -378,7 +403,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
378
403
  mine!.motion = motionOf(me.kind, me.motion);
379
404
  } else {
380
405
  const previous = mine!.pos;
381
- const shown = meNow()?.pos ?? previous;
406
+ const shownBody = meNow();
407
+ const shown = shownBody?.pos ?? previous;
408
+ const oldHeading = mine!.heading;
382
409
  const oldOffset = visualOffset(clock()), oldOffsets = offsets;
383
410
  adopt(me);
384
411
  history.set(f.k, copyMine(mine!));
@@ -402,6 +429,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
402
429
  history.set(t, copyMine(mine!));
403
430
  }
404
431
  pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
432
+ if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
405
433
  const error = distance(previous, mine!.pos);
406
434
  correction.last = error;
407
435
  if (error > 0.00001) { correction.count++; correction.max = Math.max(correction.max, error); }
@@ -466,6 +494,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
466
494
  setStatus(seat === null || me ? 'playing' : status);
467
495
  }
468
496
  function roundNow(): RoomRound | null {
497
+ if (solo) return solo.round;
469
498
  if (!round || !latest) return null;
470
499
  // The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
471
500
  const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
@@ -527,21 +556,26 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
527
556
  * drawn frame, not on the next tick.
528
557
  */
529
558
  function meNow(): Entity | null {
559
+ if (solo) return solo.me;
530
560
  if (!mine || !latest) return null;
531
561
  const server = latest.ents.get(mine.id);
532
562
  if (!server) return null;
533
563
  const kind = kindOf.get(mine.kind);
534
564
  let pos = mine.pos;
535
565
  let heading = mine.heading;
566
+ let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
536
567
  let settled = true;
537
- let preview: { pos: Vec3; heading: Vec3 } | null = null;
568
+ let preview: ReturnType<typeof runMove> | null = null;
538
569
  if (kind && base) {
539
570
  const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
540
571
  const now: Record<string, unknown> = {};
541
572
  for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
542
573
  const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
543
574
  pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
544
- heading = next.heading;
575
+ heading = blendHeading(mine.heading, next.heading, a, dims);
576
+ velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
577
+ // A takeoff is airborne as soon as it rises; a landing waits for its contact.
578
+ grounded = mine.grounded && (a === 0 || next.grounded);
545
579
  preview = next;
546
580
  settled = distance(next.pos, mine.pos) < 0.00001;
547
581
  }
@@ -551,17 +585,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
551
585
  if (catchTick >= tickAt(now)) catchTick = null;
552
586
  else {
553
587
  // Catch-up can reach the fractional current tick before its next whole step exists.
554
- // Use the same preview as normal drawing, rather than hold and jump when that step arrives.
588
+ // Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
555
589
  const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
556
- if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = b.heading; }
557
- else if (a) pos = a.pos;
590
+ if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = blendHeading(a.heading, b.heading, f, dims);
591
+ velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
592
+ grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
593
+ } else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
558
594
  }
559
595
  }
560
596
  const offset = visualOffset(clock(), pos, settled);
561
597
  pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
562
- return entityOf({ ...server, pos, vel: mine.vel, heading, grounded: mine.grounded, motion: mine.motion }, true);
598
+ if (headingBlend) {
599
+ const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
600
+ heading = blendHeading(headingBlend.from, heading, t, dims);
601
+ if (t >= 1) headingBlend = null;
602
+ }
603
+ return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
563
604
  }
564
605
  function drawn(id: string): Entity | null {
606
+ if (solo) return solo.get(id);
565
607
  if (mine && id === mine.id) return meNow();
566
608
  const s = net.sample();
567
609
  const a = s ? frameOf(s.a) : latest;
@@ -571,12 +613,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
571
613
  // A placed body jumps: nobody's view glides across a placement.
572
614
  if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
573
615
  const t = s.alpha;
574
- const h = dir({ x: lerp(ea.heading.x, eb.heading.x, t), y: lerp(ea.heading.y, eb.heading.y, t), z: lerp(ea.heading.z, eb.heading.z, t) }, dims);
575
- return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
616
+ const h = blendHeading(ea.heading, eb.heading, t, dims);
617
+ return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
576
618
  }
577
619
  function get(id: string): Entity | null { return drawn(id); }
578
620
  function each(kind: string, fn: (e: Entity) => void): void {
579
621
  pump();
622
+ if (solo) { solo.each(kind, fn); return; }
580
623
  const s = net.sample();
581
624
  const a = s ? frameOf(s.a) : latest;
582
625
  const b = s ? frameOf(s.b) : latest;
@@ -587,20 +630,51 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
587
630
  for (const id of ids) { const e = drawn(id); if (e) fn(e); }
588
631
  }
589
632
  function roster(): RosterRow[] {
633
+ if (solo) return solo.roster;
590
634
  const scores = new Map<number, number>();
591
635
  if (latest) for (const u of latest.ents.values()) { const k = kindOf.get(u.kind); if (u.seat !== undefined && k?.score) scores.set(u.seat, Number(u.fields[k.score]) || 0); }
592
636
  return (net.slots ?? []).map((sl) => ({ seat: sl.slot, name: sl.name, driver: sl.agent ? 'ai' as const : sl.bot ? 'bot' as const : 'person' as const, score: scores.get(sl.slot) ?? 0, me: sl.seat !== null && sl.seat === net.seat }));
593
637
  }
638
+ function forwardSolo(name: string): void {
639
+ if (solo && !soloListeners.has(name)) soloListeners.set(name, solo.on(name, e => emit(name, e, true)));
640
+ }
641
+ function stopSolo(): void {
642
+ for (const off of soloListeners.values()) off(); soloListeners.clear();
643
+ const old = solo; solo = null; old?.close();
644
+ }
645
+ function checkSolo(now: number): void {
646
+ if (opts.fallback !== 'solo') return;
647
+ const elapsed = Math.min(300, Math.max(0, now - checkedAt)); checkedAt = now;
648
+ if (net.offline || net.watching) { noBodyMs = 0; stopSolo(); return; }
649
+ if (net.link !== 'online') { noBodyMs = 0; return; }
650
+ if (mine && net.seat !== null && latest?.ents.has(mine.id)) {
651
+ noBodyMs = 0;
652
+ if (solo) { stopSolo(); net.line(null); emit('status', status); emit('placed', { id: mine.id }); }
653
+ return;
654
+ }
655
+ if (solo || net.seat === null && !net.full) return;
656
+ noBodyMs += elapsed;
657
+ if (noBodyMs < 4000 || !(schema.settings.offline || schema.settings.host === 'browser')) return;
658
+ // Keep the online socket and its seat queue. The separate offline room emits no wire
659
+ // traffic, writes no room save to the server, and closes as soon as a body arrives.
660
+ const g = globalThis as unknown as Record<string, unknown>;
661
+ const probes = [g.__homieNet, g.__homiePort];
662
+ solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
663
+ [g.__homieNet, g.__homiePort] = probes;
664
+ for (const name of listeners.keys()) forwardSolo(name);
665
+ net.line(net.full ? 'This room is full · playing on your own until a seat is free' : 'Playing on your own until the next round');
666
+ emit('status', 'offline', true);
667
+ }
594
668
  let extra: Record<string, () => unknown> = {};
595
- const myScore = (): number | null => { const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
596
- const busy = (): boolean => status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
669
+ const myScore = (): number | null => { if (solo) { const k = schema.kinds.find(k => k.player); return k?.score && solo.me ? Number(solo.me[k.score]) || 0 : null; } const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
670
+ const busy = (): boolean => solo ? !solo.me : status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
597
671
  // The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
598
672
  net.expose({
599
673
  self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
600
674
  peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
601
675
  prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
602
676
  scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
603
- score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
677
+ score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
604
678
  });
605
679
  if (typeof window !== 'undefined') {
606
680
  exposePort(net as Netplay<unknown, unknown, unknown>, {
@@ -616,27 +690,47 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
616
690
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
617
691
 
618
692
  return {
619
- get status() { return status; },
620
- get seat() { return net.seat; },
693
+ get status() { return solo ? 'offline' : status; },
694
+ get seat() { return solo ? solo.seat : net.seat; },
621
695
  get me() { pump(); return meNow(); },
622
696
  each, get,
623
- on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); return () => { set?.delete(fn); }; },
624
- get shared() { return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
697
+ on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
698
+ get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
625
699
  input(s) {
700
+ if (solo) { solo.input(s); return; }
626
701
  const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
627
702
  const next: Record<string, unknown> = {};
628
- for (const [name, fd] of kind?.input ?? []) { if (fd.t === 'press') { if (s[name] === true || s[name] === 1) pressed.add(name); } else if (s[name] !== undefined) next[name] = s[name]; }
703
+ let changed = false;
704
+ for (const [name, fd] of kind?.input ?? []) {
705
+ if (fd.t === 'press') { if ((s[name] === true || s[name] === 1) && !pressed.has(name)) changed = true; }
706
+ else { if (s[name] !== undefined) next[name] = s[name]; if ((next[name] ?? fd.init) !== (sample[name] ?? fd.init)) changed = true; }
707
+ }
708
+ // A fresh control can replace a preview partway through a tick (especially
709
+ // a jump). Keep the pose at the instant of the press; only subsequent time
710
+ // may advance the new path, rather than drawing elapsed time with new input.
711
+ if (changed) pump();
712
+ const shown = changed ? meNow()?.pos : null;
713
+ for (const [name, fd] of kind?.input ?? []) if (fd.t === 'press' && (s[name] === true || s[name] === 1)) pressed.add(name);
629
714
  sample = next;
715
+ if (shown) {
716
+ const now = clock(), nextShown = meNow()?.pos;
717
+ if (nextShown && distance(shown, nextShown) > 0.00001) {
718
+ const carried = visualOffset(now);
719
+ // Coalesce the carried blend: analogue controls may change every frame.
720
+ offsets = [{ delta: { x: carried.x + shown.x - nextShown.x, y: carried.y + shown.y - nextShown.y, z: carried.z + shown.z - nextShown.z },
721
+ at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
722
+ }
723
+ }
630
724
  },
631
- command(name, data = {}) { if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
725
+ command(name, data = {}) { if (solo) { solo.command(name, data); return; } if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
632
726
  get round() { return roundNow(); },
633
727
  get roster() { return roster(); },
634
728
  follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
635
729
  probe(x) { extra = { ...extra, ...x }; },
636
- ask(id, askId, args = {}) { const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
637
- askButtons(id, offer = {}) { const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
730
+ ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
731
+ askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
638
732
  tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
639
733
  pump,
640
- close() { closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
734
+ close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
641
735
  };
642
736
  }
@@ -2,10 +2,16 @@
2
2
  "id": "ember-vale",
3
3
  "name": "Ember Vale",
4
4
  "blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
5
- "players": { "min": 1, "max": 8 },
5
+ "players": {
6
+ "min": 1,
7
+ "max": 8
8
+ },
6
9
  "roundSeconds": 90,
7
- "entry": "src/main.ts",
8
- "netplay": { "v": 1, "public": true, "movement": "owner" },
10
+ "entry": "src/view.ts",
11
+ "netplay": {
12
+ "v": 1,
13
+ "public": true
14
+ },
9
15
  "saves": true,
10
16
  "decide": false,
11
17
  "landing": {
@@ -16,6 +22,12 @@
16
22
  "Every night ends with a ranking; your hero keeps what it earned.",
17
23
  "Hardcore: one life. Fall and you join the Hall of the Fallen."
18
24
  ],
19
- "controls": { "phone": "Drag on the left to move, STRIKE on the right.", "computer": "WASD or arrows to move, Space to strike." }
25
+ "controls": {
26
+ "phone": "Drag on the left to move, STRIKE on the right.",
27
+ "computer": "WASD or arrows to move, Space to strike."
28
+ }
29
+ },
30
+ "room": {
31
+ "host": "server"
20
32
  }
21
33
  }