@homie-rocks/studio 0.39.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 (37) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/lib/build.mjs +1 -0
  3. package/lib/port-check.mjs +6 -3
  4. package/lib/rules-build.mjs +8 -1
  5. package/lib/rules-check-worker.mjs +3 -3
  6. package/lib/rules-check.mjs +3 -1
  7. package/lib/template-history.json +41 -0
  8. package/netplay/NETPLAY.md +74 -2
  9. package/package.json +1 -1
  10. package/rules/collision.ts +115 -0
  11. package/rules/core.ts +65 -25
  12. package/rules/host.ts +5 -1
  13. package/rules/math.ts +15 -5
  14. package/rules/rules.ts +33 -10
  15. package/rules/types.ts +2 -2
  16. package/rules/view.ts +126 -32
  17. package/starters/ember-vale/game.json +16 -4
  18. package/starters/ember-vale/map/main.json +66 -0
  19. package/starters/ember-vale/src/move.ts +31 -0
  20. package/starters/ember-vale/src/rules.ts +244 -0
  21. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  22. package/starters/ember-vale/tunables.json +100 -11
  23. package/starters/gem-rush/src/move.ts +9 -1
  24. package/starters/gem-rush/src/view.ts +2 -1
  25. package/starters/gem-rush-3d/game.json +23 -5
  26. package/starters/gem-rush-3d/map/main.json +153 -0
  27. package/starters/gem-rush-3d/src/move.ts +37 -0
  28. package/starters/gem-rush-3d/src/rules.ts +133 -0
  29. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  30. package/starters/gem-rush-3d/tunables.json +98 -11
  31. package/starters/hero-rush-3d/game.json +23 -5
  32. package/starters/hero-rush-3d/map/main.json +153 -0
  33. package/starters/hero-rush-3d/src/move.ts +49 -0
  34. package/starters/hero-rush-3d/src/rules.ts +200 -0
  35. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  36. package/starters/hero-rush-3d/tunables.json +260 -30
  37. package/worker/version.mjs +1 -1
package/rules/core.ts CHANGED
@@ -38,6 +38,7 @@
38
38
  * that is held to its shape, and paid for, before the handler's budget is closed.
39
39
  * =============================================================================
40
40
  */
41
+ import { castMap3, castSolid, restsOnMap, solidAt, type BodyShape, type Hit3 } from './collision.ts';
41
42
  import { argsWhy, type Vocabulary } from '../agents/agents.ts';
42
43
  import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
43
44
  import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
@@ -88,7 +89,7 @@ export type CoreOut =
88
89
  | { t: 'shared' }
89
90
  | { t: 'epoch'; epoch: number }
90
91
  | { t: 'fail'; why: 'budget'; kind: string; handler: string };
91
- export interface CorePolicy { reserved?: number; bots: 'fill' | 'off'; level: number; levelMax: number }
92
+ export interface CorePolicy { kids?: boolean; levelSet?: boolean; guideLevel?: number; guideSeats?: readonly number[]; reserved?: number; bots: 'fill' | 'off'; level: number; levelMax: number }
92
93
  export interface Body { seat: number; id: string; kind: string; driver: Driver; owner: string; away: boolean; score: number; r: number }
93
94
 
94
95
  interface Ent {
@@ -178,7 +179,6 @@ export interface Core {
178
179
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
179
180
  */
180
181
  export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
181
- if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
182
182
  const dims = c.dims;
183
183
  const tickHz = c.settings.tickHz;
184
184
  const dt = 1 / tickHz;
@@ -212,11 +212,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
212
212
  let vocabulary: Vocabulary | null = null;
213
213
  const restoredGoals = new Map<number, unknown>();
214
214
  const guideViews = new Map<number, Record<string, unknown>>();
215
+ const EMPTY_GUIDE_SEATS: readonly number[] = Object.freeze([]);
215
216
  let policy: CorePolicy = { reserved: 0, bots: 'fill', level: 3, levelMax: 5 };
216
217
  function normalizeCorePolicy(p: Partial<CorePolicy>): CorePolicy {
217
218
  const levelMax = Math.max(1, Math.min(5, Math.floor(num(own(p, 'levelMax') ?? policy.levelMax)) || 5));
219
+ const guideSeats = Object.freeze([...new Set(p.guideSeats ?? policy.guideSeats ?? [])].filter(seat => Number.isInteger(seat) && seat >= 0 && seat < c.seats).sort((a, b) => a - b));
218
220
  // The keys in the order the room starts with them: a save is compared byte for byte with the save of the room rebuilt from it.
219
- return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)), levelMax };
221
+ return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)), levelMax, ...((p.kids ?? policy.kids) ? { kids: true } : {}), ...((p.levelSet ?? policy.levelSet) ? { levelSet: true } : {}), ...((p.guideLevel ?? policy.guideLevel) !== undefined ? { guideLevel: Math.max(1, Math.min(levelMax, Math.floor(num(p.guideLevel ?? policy.guideLevel)) || 3)) } : {}), ...(guideSeats.length ? { guideSeats } : {}) };
220
222
  }
221
223
  let shared: Record<string, unknown> = initFields(c.shared, dims);
222
224
  let sharedRO: Readonly<Record<string, unknown>> = Object.freeze({});
@@ -342,7 +344,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
342
344
  naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
343
345
  naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
344
346
  const e: Ent = {
345
- id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: true,
347
+ id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(c.map, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
346
348
  r: 0, born: tick, arrived: false, why, dead: false,
347
349
  f, m,
348
350
  seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
@@ -351,10 +353,11 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
351
353
  makeSelf(e);
352
354
  return e;
353
355
  }
354
- function clampIn(p: Vec3, r: number): Vec3 {
356
+ function clampIn(p: Vec3, r: number, height = 0): Vec3 {
355
357
  const b = c.map.bounds;
356
358
  const x = Math.max(b.min.x + r, Math.min(b.max.x - r, p.x)); const y = Math.max(b.min.y + r, Math.min(b.max.y - r, p.y));
357
- return x === p.x && y === p.y ? p : V({ x, y, z: p.z });
359
+ const z = dims === 3 ? Math.max(b.min.z, Math.min(b.max.z - height, p.z)) : 0;
360
+ return x === p.x && y === p.y && z === p.z ? p : V({ x, y, z });
358
361
  }
359
362
 
360
363
  /* ---------------------------------------------------------------- running a handler under the budget */
@@ -475,9 +478,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
475
478
  return sharedRO;
476
479
  };
477
480
 
481
+ function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
482
+ let best = castMap3(c.map, p, d, shape);
483
+ const solid = solidAt(p, shape);
484
+ for (const other of ents.values()) {
485
+ if (!other.kind.body || ignored.has(other.id)) continue;
486
+ const hit = castSolid(solid, d, solidAt(other.pos, other.kind.body));
487
+ if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
488
+ }
489
+ return best;
490
+ }
478
491
  const world = brand({} as Record<string, unknown>);
479
492
  const getters: Record<string, () => unknown> = {
480
- tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax,
493
+ tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
481
494
  stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
482
495
  };
483
496
  for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
@@ -558,9 +571,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
558
571
  charge(10 + 12 * COPY);
559
572
  const e = mine(self, 'world.place');
560
573
  const vel = own(o, 'vel'); const heading = own(o, 'heading');
561
- e.pos = clampIn(V(at), e.kind.body?.radius ?? 0);
574
+ e.pos = clampIn(V(at), e.kind.body?.radius ?? 0, e.kind.body ? e.kind.body.height || 2 * e.kind.body.radius : 0);
562
575
  e.vel = vel !== undefined ? V(vel) : ZERO;
563
576
  if (heading !== undefined) e.heading = dir(heading, dims);
577
+ e.grounded = dims === 2 || restsOnMap(c.map, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
564
578
  e.r = (e.r + 1) & 0xffff;
565
579
  e.allow = 0;
566
580
  },
@@ -572,7 +586,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
572
586
  for (const e of ents.values()) {
573
587
  if (k && e.kind !== k) continue;
574
588
  const dx = e.pos.x - p.x; const dy = e.pos.y - p.y;
575
- const d2 = dx * dx + dy * dy;
589
+ const dz = dims === 3 ? e.pos.z - p.z : 0;
590
+ const d2 = dx * dx + dy * dy + dz * dz;
576
591
  if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
577
592
  }
578
593
  // Sorting them and handing each out is charged before either is done.
@@ -583,10 +598,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
583
598
  inBox: (box: unknown, kind?: unknown): readonly unknown[] => {
584
599
  charge(20 + 2 * ents.size);
585
600
  const min = V(own(box, 'min')); const max = V(own(box, 'max')); const k = kindFilter(kind);
586
- if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
601
+ if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M || dims === 3 && max.z - min.z > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
587
602
  const found: Ent[] = [];
588
603
  let cost = 0;
589
- for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y) { found.push(e); cost += viewCost.get(e.kind) as number; }
604
+ for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y && (dims === 2 || e.pos.z >= min.z && e.pos.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }
590
605
  charge(cost);
591
606
  return Object.freeze(found.map(viewOf));
592
607
  },
@@ -594,6 +609,11 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
594
609
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
595
610
  charge(20 + 4 * (mapShapes + ents.size));
596
611
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
612
+ if (dims === 3) {
613
+ const delta = { x: d.x * far, y: d.y * far, z: d.z * far };
614
+ const hit = cast3(p, delta, { shape: 'sphere', radius: 0, height: 0 }, new Set(cx.ent ? [cx.ent.id] : []));
615
+ return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: V({ x: p.x + delta.x * hit.t, y: p.y + delta.y * hit.t, z: p.z + delta.z * hit.t }), normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }), dist: far * hit.t }) : undefined;
616
+ }
597
617
  const cast = castMap(c.map, p.x, p.y, d.x * far, d.y * far, 0);
598
618
  let best: Hit | null = cast.hit;
599
619
  for (const e of ents.values()) {
@@ -609,12 +629,23 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
609
629
  charge(20 + 4 * (mapShapes + ents.size));
610
630
  const d = V(delta);
611
631
  const radius = e.kind.body?.radius ?? 0;
612
- const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
613
- let best: Hit | null = cast.hit;
614
632
  // The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
615
633
  const list = own(o, 'ignore');
616
634
  const ignore = new Set<string>();
617
635
  if (Array.isArray(list)) for (let i = 0; i < list.length && i < IGNORE_MAX; i += 1) { const id = own(list, i); if (typeof id === 'string') ignore.add(id); }
636
+ if (dims === 3) {
637
+ ignore.add(e.id);
638
+ const shape = e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 };
639
+ const hit = cast3(e.pos, d, shape, ignore);
640
+ const length = Math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
641
+ const t = hit ? Math.max(0, hit.t - (length > 0 ? SKIN / length : 0)) : 1;
642
+ e.pos = V({ x: e.pos.x + d.x * t, y: e.pos.y + d.y * t, z: e.pos.z + d.z * t });
643
+ const support = cast3(e.pos, { x: 0, y: 0, z: -2 * SKIN }, shape, ignore);
644
+ e.grounded = Boolean(support && support.nz > 0.5);
645
+ return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: e.pos, normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }) }) : undefined;
646
+ }
647
+ const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
648
+ let best: Hit | null = cast.hit;
618
649
  for (const other of ents.values()) {
619
650
  if (other === e || !other.kind.body || ignore.has(other.id)) continue;
620
651
  const h = rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
@@ -647,8 +678,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
647
678
  });
648
679
  Object.freeze(world);
649
680
 
650
- const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims) }));
681
+ const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims, moveShape) }));
651
682
  let moveRadius = 0;
683
+ let moveShape = { shape: 'sphere', radius: 0, height: 0 };
652
684
  const moveCtx = brand({} as Record<string, unknown>);
653
685
  for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
654
686
  Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
@@ -887,14 +919,15 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
887
919
  /* ---------------------------------------------------------------- one tick */
888
920
 
889
921
  function inArea(a: any, p: Vec3): boolean {
890
- if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y;
922
+ if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y && (dims === 2 || p.z >= a.min.z && p.z <= a.max.z);
891
923
  const dx = p.x - a.at.x; const dy = p.y - a.at.y;
892
- const d2 = dx * dx + dy * dy;
924
+ const dz = dims === 3 ? p.z - a.at.z : 0;
925
+ const d2 = dx * dx + dy * dy + dz * dz;
893
926
  if (d2 > a.r * a.r) return false;
894
927
  if (a.k === 's' || d2 === 0) return true;
895
928
  const l = Math.sqrt(d2);
896
929
  // Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
897
- return (dx * a.dir.x + dy * a.dir.y) / l >= exact.cos(a.half);
930
+ return (dx * a.dir.x + dy * a.dir.y + dz * a.dir.z) / l >= exact.cos(a.half);
898
931
  }
899
932
  function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
900
933
  tick = (tick + 1) >>> 0;
@@ -992,10 +1025,14 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
992
1025
  // The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
993
1026
  if (go < dist) stats.held += 1;
994
1027
  e.allow -= go;
995
- if (dist > 0) e.pos = clampIn(V({ x: e.pos.x + (dx / dist) * go, y: e.pos.y + (dy / dist) * go, z: 0 }), body.radius);
1028
+ e.pos = clampIn(V({ x: e.pos.x + (dist > 0 ? (dx / dist) * go : 0), y: e.pos.y + (dist > 0 ? (dy / dist) * go : 0), z: dims === 3 ? claim.pos.z : 0 }), body.radius, body.height || 2 * body.radius);
996
1029
  const sp = Math.sqrt(claim.vel.x * claim.vel.x + claim.vel.y * claim.vel.y);
997
- e.vel = sp > body.maxSpeed && sp > 0 ? V({ x: (claim.vel.x / sp) * body.maxSpeed, y: (claim.vel.y / sp) * body.maxSpeed, z: 0 }) : V(claim.vel);
1030
+ e.vel = sp > body.maxSpeed && sp > 0 ? V({ x: (claim.vel.x / sp) * body.maxSpeed, y: (claim.vel.y / sp) * body.maxSpeed, z: claim.vel.z }) : V(claim.vel);
998
1031
  e.heading = dir(claim.heading, dims);
1032
+ e.grounded = dims === 2;
1033
+ // The support cast is map work even when a browser supplies the pose.
1034
+ // Keep it under the same movement quota as a server-driven body's sweep.
1035
+ if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(c.map, e.pos, body); }, 'body');
999
1036
  }
1000
1037
  continue;
1001
1038
  }
@@ -1003,13 +1040,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1003
1040
  if (!k.move) continue;
1004
1041
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1005
1042
  const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
1006
- moveRadius = body.radius;
1043
+ moveRadius = body.radius; moveShape = body;
1007
1044
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
1008
1045
  // The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
1009
1046
  // `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
1010
1047
  // (A listener hears of a position that was not a number here too, as `move`'s.)
1011
1048
  if (noted) G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
1012
- naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1049
+ naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1013
1050
  G.note = null;
1014
1051
  e.grounded = own(b, 'grounded') === true;
1015
1052
  if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
@@ -1128,7 +1165,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1128
1165
  const duration = r.round[1] === 1 ? ticks(c.rounds?.seconds ?? 0) : Math.max(3, ticks(c.rounds?.breakSeconds ?? 0));
1129
1166
  check(r.round[3] <= r.tick && r.round[2] <= r.tick + duration && !(r.round[1] === 1 && !duration && r.round[2] !== 0));
1130
1167
  check(r.policy && ['fill', 'off'].includes(r.policy.bots) && Number.isInteger(r.policy.level) && Number.isInteger(r.policy.levelMax) && r.policy.level >= 1 && r.policy.level <= r.policy.levelMax && r.policy.levelMax <= 5);
1131
- check(Object.keys(r.policy).every((key) => ['bots', 'level', 'levelMax', 'reserved'].includes(key)));
1168
+ check(r.policy.guideLevel === undefined || Number.isInteger(r.policy.guideLevel) && r.policy.guideLevel >= 1 && r.policy.guideLevel <= r.policy.levelMax);
1169
+ check(r.policy.guideSeats === undefined || Array.isArray(r.policy.guideSeats) && r.policy.guideSeats.length > 0 && r.policy.guideSeats.length <= c.seats && r.policy.guideSeats.every((seat, i, seats) => uint(seat) && seat < c.seats && (i === 0 || seat > seats[i - 1])));
1170
+ check((r.policy.kids === undefined || r.policy.kids === true) && (r.policy.levelSet === undefined || r.policy.levelSet === true));
1171
+ check(Object.keys(r.policy).every((key) => ['bots', 'level', 'levelMax', 'reserved', 'kids', 'levelSet', 'guideLevel', 'guideSeats'].includes(key)));
1132
1172
  check(r.policy.reserved === undefined || uint(r.policy.reserved) && r.policy.reserved < c.seats);
1133
1173
  fields(c.shared, r.shared);
1134
1174
  check(Array.isArray(r.ents) && Array.isArray(r.spawns) && r.ents.length + r.spawns.length <= ENTITY_MAX);
@@ -1147,7 +1187,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1147
1187
  const goal = w[15] as any;
1148
1188
  if (goal !== null) check(goal && typeof goal === 'object' && !Array.isArray(goal) && Object.keys(goal).every(k => ['goal', 'args', 'from', 'at', 'asked', 'state'].includes(k)) && typeof goal.goal === 'string' && goal.args && typeof goal.args === 'object' && !Array.isArray(goal.args) && ['brain', 'floor'].includes(goal.from) && uint(goal.at) && goal.at <= r.tick && typeof goal.asked === 'boolean' && goal.state === 'active');
1149
1189
  const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
1150
- check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && pos.z === 0);
1190
+ check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && (dims === 3 ? pos.z >= bounds.min.z && pos.z <= bounds.max.z : pos.z === 0));
1151
1191
  check(Number.isInteger(w[11]) && (w[11] as number) >= -1 && (w[11] as number) < c.seats && ['person', 'bot', 'ai'].includes(w[12] as string) && typeof w[13] === 'string' && w[13].length <= 128);
1152
1192
  if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
1153
1193
  fields(k.fields, w[17]); fields(k.motion, w[18]); fields(k.input, w[19]);
@@ -1327,8 +1367,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1327
1367
  snapshot: () => {
1328
1368
  packed.n = 0;
1329
1369
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1330
- // Ten values an entity for what every entity carries (its id, its place, its velocity and heading), and one for each value of its fields.
1331
- snapCells = packed.n + 10 * list.length;
1370
+ // Include all three components of position, velocity and heading in a 3D snapshot.
1371
+ snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1332
1372
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list];
1333
1373
  },
1334
1374
  shared: () => packFields(c.shared, shared, dims),
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;