@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.
- package/CHANGELOG.md +28 -1
- package/lib/build.mjs +1 -0
- package/lib/port-check.mjs +6 -3
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/template-history.json +41 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +1 -1
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- 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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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;
|