@homie-rocks/studio 0.45.1 → 0.45.3
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 +53 -0
- package/guides/game/RULES.md +222 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +105 -21
- package/rules/guard.ts +1 -1
- package/rules/host.ts +40 -9
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +89 -23
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/index.mjs +29 -1
- package/worker/players.mjs +3 -1
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
package/rules/core.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {routeGraph} from './navigation.ts';
|
|
2
|
+
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
1
3
|
import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
|
|
2
4
|
/*
|
|
3
5
|
* core.ts — one room's world, stepped a tick at a time from a compiled rules module.
|
|
@@ -133,7 +135,9 @@ function errorText(error: unknown): string {
|
|
|
133
135
|
export interface SavedCore {
|
|
134
136
|
v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
|
|
135
137
|
round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
|
|
136
|
-
shared: unknown[]; policy: CorePolicy; intent?: [number, number];
|
|
138
|
+
shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
|
|
139
|
+
collision?: [number,number];
|
|
140
|
+
history?: [number, [string, number, number, number, number][]][];
|
|
137
141
|
asks: PendingAsk[];
|
|
138
142
|
guideViews: [number, Record<string, unknown>][];
|
|
139
143
|
ents: unknown[][]; spawns: unknown[][]; seats: [number, string, string, string | null, number][]; queue: unknown[][]; areas: unknown[][]; ops: unknown[];
|
|
@@ -179,7 +183,7 @@ export interface Core {
|
|
|
179
183
|
* `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
|
|
180
184
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
181
185
|
*/
|
|
182
|
-
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, geometry: import('./math.ts').MapShapes) => 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 {
|
|
186
|
+
export function createCore(c: Compiled, opts: { rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => 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 {
|
|
183
187
|
const dims = c.dims;
|
|
184
188
|
const tickHz = c.settings.tickHz;
|
|
185
189
|
const dt = 1 / tickHz;
|
|
@@ -210,6 +214,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
210
214
|
let restartAt = 0;
|
|
211
215
|
let finishing = false;
|
|
212
216
|
let endAsked = false;
|
|
217
|
+
let restartAsked = false;
|
|
213
218
|
let vocabulary: Vocabulary | null = null;
|
|
214
219
|
const restoredGoals = new Map<number, unknown>();
|
|
215
220
|
const guideViews = new Map<number, Record<string, unknown>>();
|
|
@@ -369,6 +374,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
369
374
|
* budget; and the counter and the scope are put back whatever happens. Nothing of the rules' is touched after this
|
|
370
375
|
* returns.
|
|
371
376
|
*/
|
|
377
|
+
let dispatchDepth = 0;
|
|
372
378
|
function run(kind: string, handler: string, ent: Ent | null, scope: Ctx['scope'], fn: () => void, always: boolean | 'body' = false): boolean {
|
|
373
379
|
if (left <= 0) {
|
|
374
380
|
cut = true;
|
|
@@ -387,7 +393,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
387
393
|
const E = Error as unknown as { stackTraceLimit?: number };
|
|
388
394
|
const limit = E.stackTraceLimit;
|
|
389
395
|
E.stackTraceLimit = 0;
|
|
390
|
-
G.left = quota;
|
|
396
|
+
G.queryEpoch++; G.left = quota;
|
|
391
397
|
// The build check is told of every written value that had to be changed to fit (pack.ts), with the handler it was in.
|
|
392
398
|
if (noted) G.note = (what, at, written) => noted(kind, handler, what, at, written);
|
|
393
399
|
let bad = false; let thrown: unknown;
|
|
@@ -463,7 +469,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
463
469
|
}));
|
|
464
470
|
let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
465
471
|
const refreshRound = (): void => {
|
|
466
|
-
roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; } }));
|
|
472
|
+
roundApi = brand(Object.freeze({ n: round.n, phase: round.phase, endsAt: round.endsAt, end: (): void => { need('room', 'world.round.end()'); endAsked = true; }, restart: (): void => { need('room', 'world.round.restart()'); restartAsked = true; } }));
|
|
467
473
|
};
|
|
468
474
|
refreshRound();
|
|
469
475
|
/** `world.shared`: in room scope the writable face of the stored record, held to its types as it is written; everywhere else a frozen record of the same stored values. */
|
|
@@ -478,6 +484,32 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
478
484
|
return sharedRO;
|
|
479
485
|
};
|
|
480
486
|
|
|
487
|
+
const historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
|
|
488
|
+
let poseHistory: [number, [string, number, number, number, number][]][] = [];
|
|
489
|
+
function rayTargets(options?: unknown): QueryTarget[] {
|
|
490
|
+
charge(8 * ents.size);
|
|
491
|
+
const targets: QueryTarget[] = [];
|
|
492
|
+
const filterKind=own(options,'kind'),filterLayer=own(options,'layer'),filterTag=own(options,'tag'),geometryOnly=own(options,'geometryOnly')===true;
|
|
493
|
+
|
|
494
|
+
const requested = own(options,'atTick');
|
|
495
|
+
if (requested !== undefined && (typeof requested !== 'number' || !Number.isFinite(requested))) throw new Error('ray.atTick is a finite simulation tick');
|
|
496
|
+
if (requested !== undefined && !historyTicks) throw new Error('ray.atTick needs room.historySeconds');
|
|
497
|
+
const allowance = cx.ent?.driver === 'person' ? Math.min(historyTicks,Math.max(0,opts.rewindTicks?.(cx.ent.seat) ?? 0)) : historyTicks;
|
|
498
|
+
const wanted = typeof requested === 'number' ? Math.max(tick-allowance,Math.min(tick,requested)) : tick;
|
|
499
|
+
let before: typeof poseHistory[number] | undefined, after: typeof poseHistory[number] | undefined;
|
|
500
|
+
if(wanted<tick)for(const frame of poseHistory){charge(2);if(frame[0]<=wanted)before=frame;if(frame[0]>=wanted){after=frame;break;}}
|
|
501
|
+
before ??= poseHistory[0];after ??= poseHistory[poseHistory.length-1];
|
|
502
|
+
for(const e of ents.values()) {
|
|
503
|
+
if(e.dead || !e.kind.body || typeof filterKind==='string'&&e.kind.name!==filterKind || geometryOnly&&!e.kind.collider || typeof filterLayer==='string'&&filterLayer!==(e.kind.query?.layer??(e.kind.collider?'geometry':'body')) || typeof filterTag==='string'&&!e.kind.query?.tags?.includes(filterTag))continue;
|
|
504
|
+
charge(64);
|
|
505
|
+
const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
|
|
506
|
+
if(e.kind.collider&&!row)continue;
|
|
507
|
+
let at=e.pos;
|
|
508
|
+
if(!e.kind.collider && wanted<tick && before && after){charge(4*(before[1].length+after[1].length));const a=before[1].find(p=>p[0]===e.id&&p[4]===e.r),b=after[1].find(p=>p[0]===e.id&&p[4]===e.r);if(a&&b){const mix=after[0]===before[0]?0:Math.max(0,Math.min(1,(wanted-before[0])/(after[0]-before[0])));at={x:a[1]+(b[1]-a[1])*mix,y:a[2]+(b[2]-a[2])*mix,z:a[3]+(b[3]-a[3])*mix};}}
|
|
509
|
+
targets.push({id:e.id,kind:e.kind.name,query:e.kind.query,fields:e.f,geometry:Boolean(row),solid:row?colliderSolid(row):solidAt(at,e.kind.body),at});
|
|
510
|
+
}
|
|
511
|
+
return targets;
|
|
512
|
+
}
|
|
481
513
|
function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
|
|
482
514
|
let best = castMap3(c.map, p, d, shape);
|
|
483
515
|
const solid = solidAt(p, shape);
|
|
@@ -514,6 +546,23 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
514
546
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.send is for handlers');
|
|
515
547
|
push({ due: tick + 1, to: typeof target === 'string' ? target.slice(0, 24) : '', kind: 'ev', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
516
548
|
},
|
|
549
|
+
dispatch: (target: unknown, ev: unknown, data?: unknown): boolean => {
|
|
550
|
+
charge(SEND);
|
|
551
|
+
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.dispatch is for handlers');
|
|
552
|
+
if (dispatchDepth >= 8) throw new Error('world.dispatch permits at most eight nested deliveries');
|
|
553
|
+
const payload = shapeData(c.events, ev, 'the event', data);
|
|
554
|
+
const e = typeof target === 'string' ? ents.get(target) : undefined;
|
|
555
|
+
const fn = e && typeof ev === 'string' ? e.kind.on[ev] : undefined;
|
|
556
|
+
if (!e || e.dead || !fn) return false;
|
|
557
|
+
// Synchronous delivery shares the caller's remaining quota. Each handler
|
|
558
|
+
// still writes only its own entity, and collection settlement stays scoped.
|
|
559
|
+
const parent = cx, parentTouched = touched, note = G.note;
|
|
560
|
+
cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
|
|
561
|
+
if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
|
|
562
|
+
try { fn(world,e.self,payload); }
|
|
563
|
+
finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
|
|
564
|
+
return true;
|
|
565
|
+
},
|
|
517
566
|
sendRoom: (ev: unknown, data?: unknown): void => {
|
|
518
567
|
charge(SEND);
|
|
519
568
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.sendRoom is for handlers');
|
|
@@ -608,7 +657,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
608
657
|
charge(cost);
|
|
609
658
|
return Object.freeze(found.map(viewOf));
|
|
610
659
|
},
|
|
611
|
-
ray: (from: unknown, direction: unknown, max: unknown): unknown => {
|
|
660
|
+
ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
|
|
661
|
+
if (options !== undefined) return rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
|
|
612
662
|
// Charged before the cast, for every shape it may test: the map's, and every entity in the room.
|
|
613
663
|
charge(20 + 4 * ents.size);
|
|
614
664
|
const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
|
|
@@ -630,6 +680,14 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
630
680
|
if (!best) return undefined;
|
|
631
681
|
return Object.freeze({ ...(best.id ? { entity: best.id } : {}), at: V({ x: p.x + d.x * far * best.t, y: p.y + d.y * far * best.t, z: 0 }), normal: V({ x: best.nx, y: best.ny, z: 0 }), dist: far * best.t });
|
|
632
682
|
},
|
|
683
|
+
route: (graph: unknown, from: unknown, to: unknown, options?: unknown) => {
|
|
684
|
+
const radius=own(options,'radius')??.5,height=own(options,'height')??1.8;
|
|
685
|
+
if(typeof radius!=='number'||!Number.isFinite(radius)||radius<0||radius>100||typeof height!=='number'||!Number.isFinite(height)||height<0||height>200)throw new Error('route radius/height are finite body dimensions');
|
|
686
|
+
const named=typeof graph==='string';if(named&&!Object.hasOwn(c.navigation,graph))throw new Error('unknown navigation graph');return routeGraph(named?c.navigation[graph]:graph,from,to,rayTargets({geometryOnly:true}).map(t=>t.solid),radius,height,named);
|
|
687
|
+
},
|
|
688
|
+
label: (id: unknown): string => {charge(20);const e=typeof id==='string'?ents.get(id):undefined;if(!e?.kind.player)return '';return String(opts.label?.(e.seat,e.driver)??`Player ${e.seat+1}`).slice(0,40);},
|
|
689
|
+
rays: (options?: unknown): unknown => raySnapshot(c.map,rayTargets(options),dims,options,cx.ent?.id),
|
|
690
|
+
rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
|
|
633
691
|
sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
|
|
634
692
|
const e = mine(self, 'world.sweep');
|
|
635
693
|
charge(20 + 4 * ents.size);
|
|
@@ -688,13 +746,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
688
746
|
Object.freeze(world);
|
|
689
747
|
|
|
690
748
|
const hasColliders = c.kinds.some(k => k.collider);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
749
|
+
let collisionPrevious: CollisionRevision | null = null, collisionText = '';
|
|
750
|
+
const collisionState = (): CollisionRevision => {
|
|
751
|
+
const rows = [...ents.values()].flatMap(e => {
|
|
752
|
+
if (e.dead || !e.kind.collider || !e.kind.body) return [];
|
|
753
|
+
const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
|
|
754
|
+
return row ? [row] : [];
|
|
755
|
+
});
|
|
756
|
+
const text = JSON.stringify(rows);
|
|
757
|
+
if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows];}
|
|
758
|
+
return collisionPrevious;
|
|
759
|
+
};
|
|
696
760
|
let moveGeometry = c.map as import('./math.ts').MapShapes;
|
|
697
|
-
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
|
|
761
|
+
const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
|
|
698
762
|
const moveWorld = brand(Object.freeze(queries));
|
|
699
763
|
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
|
|
700
764
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
@@ -833,13 +897,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
833
897
|
// The seat changed hands without a goodbye: its old holder has left.
|
|
834
898
|
if (s && s.driver !== 'bot') { leaveBody(s); s = seats.get(info.seat); }
|
|
835
899
|
let bot: Ent | null = s && s.id ? ents.get(s.id) ?? null : null;
|
|
836
|
-
|
|
900
|
+
const previousBot = bot;
|
|
901
|
+
if (!bot && playerBodies().length >= c.bots || bot?.kind.player?.takeover) {
|
|
837
902
|
// A person who joins a full room takes over a bot's body: the bot in the highest seat.
|
|
838
|
-
|
|
903
|
+
const priority = (e: Ent): number => e.kind.player?.takeover ? Number(e.f[e.kind.player.takeover]) : 0;
|
|
904
|
+
for (const e of playerBodies()) if (e.driver === 'bot' && (!bot || priority(e) > priority(bot) || (priority(e) === priority(bot) && e.seat > bot.seat))) bot = e;
|
|
839
905
|
}
|
|
840
906
|
if (bot) {
|
|
841
907
|
const e = bot;
|
|
842
908
|
seats.delete(e.seat);
|
|
909
|
+
if (previousBot && previousBot !== e) {
|
|
910
|
+
previousBot.seat = e.seat;
|
|
911
|
+
seats.set(previousBot.seat, {seat:previousBot.seat, driver:'bot', owner:'', id:previousBot.id, away:false});
|
|
912
|
+
}
|
|
843
913
|
e.goal = null; e.seat = info.seat; e.driver = info.driver; e.owner = info.owner; e.away = false; e.r = (e.r + 1) & 0xffff; e.allow = 0;
|
|
844
914
|
seats.set(info.seat, { seat: info.seat, driver: info.driver, owner: info.owner, id: e.id, away: false });
|
|
845
915
|
out.push({ t: 'seats' });
|
|
@@ -882,6 +952,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
882
952
|
const seat = seats.get(e.seat);
|
|
883
953
|
if (seat) leaveBody(seat);
|
|
884
954
|
}
|
|
955
|
+
poseHistory = [];
|
|
885
956
|
round = { n: round.n + 1, phase: 'live', endsAt: c.rounds && c.rounds.seconds > 0 ? tick + ticks(c.rounds.seconds) : 0, startedAt: tick };
|
|
886
957
|
overAt = 0;
|
|
887
958
|
refreshRound();
|
|
@@ -907,6 +978,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
907
978
|
for (const s of [...seats.values()]) { seats.delete(s.seat); if (s.driver !== 'bot') ops.push({ op: 'join', info: { seat: s.seat, driver: s.driver, owner: s.owner } }, ...(s.away ? [{ op: 'away' as const, seat: s.seat, away: true }] : [])); }
|
|
908
979
|
return;
|
|
909
980
|
}
|
|
981
|
+
if (restartAsked) { restartAsked = false; endAsked = false; startRound(); return; }
|
|
910
982
|
if (round.phase === 'live' && (endAsked || (round.endsAt > 0 && tick >= round.endsAt))) endRound();
|
|
911
983
|
endAsked = false;
|
|
912
984
|
if (overAt && tick >= overAt + 2) {
|
|
@@ -918,7 +990,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
918
990
|
} else if (round.phase === 'over' && !overAt && tick >= round.endsAt) startRound();
|
|
919
991
|
}
|
|
920
992
|
function finishMatch(): void {
|
|
921
|
-
finishing = false;
|
|
993
|
+
finishing = false; restartAsked = false;
|
|
922
994
|
out.push({ t: 'round', n: round.n, phase: 'over', endsAt: tick + Math.max(3, ticks(c.rounds ? c.rounds.breakSeconds : 0)), startedAt: round.startedAt, results: results() });
|
|
923
995
|
for (const e of ents.values()) e.dead = true;
|
|
924
996
|
ents.clear(); spawns = []; queue = []; pendingAsks = []; areas = []; fx = [];
|
|
@@ -1014,12 +1086,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1014
1086
|
const body = k.body as NonNullable<KindTable['body']>;
|
|
1015
1087
|
let claim: StepInput['claim'] = null;
|
|
1016
1088
|
if (k.player) {
|
|
1017
|
-
const
|
|
1089
|
+
const permitted = !k.player.control || Boolean(e.m[k.player.control]);
|
|
1090
|
+
const driven = permitted && (e.driver === 'person' || (e.driver === 'ai' && inputs.has(e.seat))) && !e.away;
|
|
1018
1091
|
if (driven) {
|
|
1019
1092
|
const s = inputs.get(e.seat);
|
|
1020
1093
|
e.input = Object.freeze(s ? coerceFields(k.input, s.values, dims) : initFields(k.input, dims));
|
|
1021
1094
|
claim = s?.claim ?? null;
|
|
1022
|
-
} else if (k.think && (e.driver !== 'person' || k.player.away === 'think')) {
|
|
1095
|
+
} else if (k.think && (!permitted || e.driver !== 'person' || k.player.away === 'think')) {
|
|
1023
1096
|
// `think` returns the step. It is held to the declared input inside the handler's own try and budget; a `think` that throws leaves the input neutral.
|
|
1024
1097
|
let stepIn: Record<string, unknown> | null = null;
|
|
1025
1098
|
run(k.name, 'think', e, 'ent', () => {
|
|
@@ -1068,7 +1141,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1068
1141
|
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();
|
|
1069
1142
|
G.note = null;
|
|
1070
1143
|
e.grounded = own(b, 'grounded') === true;
|
|
1071
|
-
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 } }, moveGeometry);
|
|
1144
|
+
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 } }, moveGeometry, e.id);
|
|
1072
1145
|
}
|
|
1073
1146
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
1074
1147
|
const list = [...ents.values()];
|
|
@@ -1109,6 +1182,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1109
1182
|
areas = [];
|
|
1110
1183
|
if (finishing) finishMatch();
|
|
1111
1184
|
}
|
|
1185
|
+
if(historyTicks){const rows:[string,number,number,number,number][]=[];for(const e of ents.values())if(e.kind.body&&!e.kind.collider&&!e.dead)rows.push([e.id,e.pos.x,e.pos.y,e.pos.z,e.r]);left-=16*rows.length;poseHistory.push([tick,rows]);while(poseHistory.length>historyTicks+1)poseHistory.shift();}
|
|
1112
1186
|
if (fx.length) { out.push({ t: 'fx', tick, list: fx }); fx = []; }
|
|
1113
1187
|
if (sharedDirty) { sharedDirty = false; out.push({ t: 'shared' }); }
|
|
1114
1188
|
stats.tickUnits = c.settings.budget.tick - left;
|
|
@@ -1142,7 +1216,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1142
1216
|
function save(): SavedCore {
|
|
1143
1217
|
return {
|
|
1144
1218
|
v: SAVE_REVISION, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
|
|
1145
|
-
|
|
1219
|
+
...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
|
|
1220
|
+
...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
|
|
1221
|
+
shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: restartAsked ? [endAsked ? 1 : 0, finishing ? 1 : 0, 1] : [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
|
|
1146
1222
|
ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
|
|
1147
1223
|
seats: [...seats.values()].map((s) => [s.seat, s.driver, s.owner, s.id, s.away ? 1 : 0]),
|
|
1148
1224
|
queue: queue.map((q) => [q.due, q.from, q.fromId, q.seq, q.to, q.kind, q.ev, q.data, q.at, q.builtIn ? 1 : 0]),
|
|
@@ -1159,7 +1235,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1159
1235
|
check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
|
|
1160
1236
|
};
|
|
1161
1237
|
check(r.v === SAVE_REVISION);
|
|
1162
|
-
check(
|
|
1238
|
+
if(r.collision!==undefined)check(Array.isArray(r.collision)&&r.collision.length===2&&uint(r.collision[0])&&uint(r.collision[1])&&r.collision[0]<=r.tick&&r.collision[1]===r.collision[0]+1);
|
|
1239
|
+
check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision'].includes(k)));
|
|
1163
1240
|
const askNames = new Set();
|
|
1164
1241
|
check(Array.isArray(r.asks));
|
|
1165
1242
|
for (const a of r.asks) {
|
|
@@ -1175,7 +1252,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1175
1252
|
check(Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && !views.has(row[0])); views.add(row[0]);
|
|
1176
1253
|
check(JSON.stringify(row[1]) === JSON.stringify(coerceFields(c.view, row[1], dims)));
|
|
1177
1254
|
}
|
|
1178
|
-
check(r.intent === undefined || Array.isArray(r.intent) && r.intent.length === 2 && r.intent.every(bit));
|
|
1255
|
+
check(r.intent === undefined || Array.isArray(r.intent) && (r.intent.length === 2 || r.intent.length === 3) && r.intent.every(bit));
|
|
1179
1256
|
for (const v of [r.tick, r.epoch, r.rng, r.nextId, r.seq, r.overAt, r.trips]) check(uint(v));
|
|
1180
1257
|
check(r.tick < 0xffffffff);
|
|
1181
1258
|
check(r.rng <= 4294967295 && r.epoch > 0 && r.nextId > 0 && r.seq > 0);
|
|
@@ -1284,10 +1361,16 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1284
1361
|
if (r) {
|
|
1285
1362
|
if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
|
|
1286
1363
|
validateSave(r);
|
|
1364
|
+
if(historyTicks){
|
|
1365
|
+
if(!Array.isArray(r.history)||r.history.length>historyTicks+1)throw new Error('saved query history is invalid');
|
|
1366
|
+
let previous=-1;
|
|
1367
|
+
for(const frame of r.history){if(!Array.isArray(frame)||frame.length!==2||!Number.isInteger(frame[0])||frame[0]<=previous||frame[0]>r.tick||frame[0]<r.tick-historyTicks||!Array.isArray(frame[1])||frame[1].length>ENTITY_MAX)throw new Error('saved query history is invalid');previous=frame[0];const ids=new Set<string>();for(const p of frame[1]){if(!Array.isArray(p)||p.length!==5||typeof p[0]!=='string'||p[0].length>24||ids.has(p[0])||!p.slice(1,4).every(v=>typeof v==='number'&&Number.isFinite(v))||!Number.isInteger(p[4])||p[4]<0||p[4]>65535)throw new Error('saved query history is invalid');ids.add(p[0]);}}
|
|
1368
|
+
poseHistory=r.history.map(([at,rows])=>[at,rows.map(row=>[...row])]);
|
|
1369
|
+
}
|
|
1287
1370
|
pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
|
|
1288
1371
|
if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
|
|
1289
1372
|
tick = r.tick; epoch = opts.restoreEpoch ?? r.epoch; rng = r.rng; nextId = r.nextId; seq = r.seq; overAt = r.overAt; playing = r.match[0]; restartAt = r.match[1]; trips = r.trips;
|
|
1290
|
-
endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1;
|
|
1373
|
+
endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
|
|
1291
1374
|
round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
|
|
1292
1375
|
refreshRound();
|
|
1293
1376
|
shared = unpackFields(c.shared, r.shared, dims); sharedChanged();
|
|
@@ -1304,6 +1387,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1304
1387
|
});
|
|
1305
1388
|
areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(coerceFields(c.events[a[4]] ?? [], a[5], dims)) }));
|
|
1306
1389
|
ops = r.ops as typeof ops;
|
|
1390
|
+
if(hasColliders)collisionState();
|
|
1307
1391
|
} else startMatch();
|
|
1308
1392
|
|
|
1309
1393
|
function decisionResult(value: unknown): Record<string, unknown> {
|
package/rules/guard.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const refusedName = (k: string): boolean => REFUSED_NAMES.has(k) || k.sta
|
|
|
37
37
|
* of the rules that ran last: the guarded module sets them as it goes, and the runtime reads them when a handler throws.
|
|
38
38
|
* `note` is the build check's listener for a written value that had to be changed to fit (pack.ts); a room has none.
|
|
39
39
|
*/
|
|
40
|
-
export const G = { left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
|
|
40
|
+
export const G = { queryEpoch: 0, left: Infinity, file: '', line: 0, note: null as null | ((what: string, at: string, written: string) => void) };
|
|
41
41
|
/** The line a guarded call is on, set again after its arguments ran (they may have called into another file). */
|
|
42
42
|
export function l(file: string, line: number): number { G.file = file; G.line = line; return line; }
|
|
43
43
|
|
package/rules/host.ts
CHANGED
|
@@ -47,7 +47,7 @@ import { AGENT_RULES, useAgents, type Agents, type AgentsSaved, type Vocabulary
|
|
|
47
47
|
import { DEFAULT_POLICY, type Netplay, type Peer, type Policy, type Slot } from '../netplay/netplay.ts';
|
|
48
48
|
import { createCore } from './core.ts';
|
|
49
49
|
import type { Core, CoreOut, Driver, SavedCore, StepInput } from './core.ts';
|
|
50
|
-
import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec } from './pack.ts';
|
|
50
|
+
import { coerce, coerceFields, num, own, fromBytes, initFields, toBytes, unpackVec, unpackFields } from './pack.ts';
|
|
51
51
|
import type { Compiled, KindTable, Vec3 } from './rules.ts';
|
|
52
52
|
|
|
53
53
|
export interface HostClock {
|
|
@@ -58,6 +58,7 @@ export interface HostClock {
|
|
|
58
58
|
}
|
|
59
59
|
export interface HostOptions {
|
|
60
60
|
game: string;
|
|
61
|
+
record?(owner:string,key:string,value:Record<string,unknown>):void;
|
|
61
62
|
/** The build check compares a movement step before any tick handler can change it. */
|
|
62
63
|
moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
|
|
63
64
|
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
@@ -100,6 +101,8 @@ export interface Host {
|
|
|
100
101
|
readonly paused: boolean;
|
|
101
102
|
readonly people: number;
|
|
102
103
|
readonly viewRadiusM: number | null;
|
|
104
|
+
readonly privateDelivery: boolean;
|
|
105
|
+
projectSnapshot(snap: any, seat: number | null): any;
|
|
103
106
|
readonly viewSettings: Compiled['settings']['view'];
|
|
104
107
|
frame(m: Record<string, unknown>): void;
|
|
105
108
|
/** Start the tick loop (the first person has joined). */
|
|
@@ -118,8 +121,9 @@ export interface Host {
|
|
|
118
121
|
readonly core: Core;
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
interface Entry { at: number; values: Record<string, unknown>; presses: string[]; claim: StepInput['claim'] }
|
|
124
|
+
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
|
|
122
125
|
interface SeatIn {
|
|
126
|
+
pulses?: [string,number][];
|
|
123
127
|
held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
|
|
124
128
|
/** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
|
|
125
129
|
newest: number; stamps: number[]; ack: number;
|
|
@@ -225,12 +229,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
225
229
|
for (const [seat, q] of saved.inputs) {
|
|
226
230
|
check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
|
|
227
231
|
check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
|
|
228
|
-
|
|
232
|
+
if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&p.length===2&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
|
|
233
|
+
for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
|
|
229
234
|
}
|
|
230
235
|
}
|
|
231
236
|
}
|
|
232
237
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
233
|
-
const
|
|
238
|
+
const measuredRtt = new Map<number, number>();
|
|
239
|
+
const core = createCore(c, { rewindTicks: seat => Math.ceil(((measuredRtt.get(seat) ?? 0) + 150 + period) / period), label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
234
240
|
let epoch = core.epoch;
|
|
235
241
|
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
236
242
|
let lastSaveAt = o.clock.now();
|
|
@@ -367,7 +373,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
367
373
|
// A restored room keeps each seat's held input, its newest stamp and its acknowledgement.
|
|
368
374
|
for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
|
|
369
375
|
for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
|
|
370
|
-
const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press') v[name] = false; return v; };
|
|
376
|
+
const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press' || fd.t === 'pulse') v[name] = fd.t === 'press' ? false : 0; return v; };
|
|
371
377
|
function queueOf(seat: number, kind: KindTable): SeatIn {
|
|
372
378
|
let q = queues.get(seat);
|
|
373
379
|
if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
|
|
@@ -405,11 +411,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
405
411
|
if (j > K + tickHz) { stats.dropped += 1; break; }
|
|
406
412
|
q.newest = j;
|
|
407
413
|
const values: Record<string, unknown> = {};
|
|
408
|
-
const presses: string[] = [];
|
|
414
|
+
const presses: (string | [string, number])[] = [];
|
|
409
415
|
let i = 1;
|
|
410
416
|
for (const [name, fd] of kind.input) {
|
|
411
417
|
// Input values are held to their declared types, whatever was sent.
|
|
412
|
-
if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else values[name] = coerce(fd, raw[i], c.dims);
|
|
418
|
+
if (fd.t === 'press') { values[name] = false; if (raw[i] === 1) presses.push(name); } else if (fd.t === 'pulse') { values[name] = 0; } else values[name] = coerce(fd, raw[i], c.dims);
|
|
413
419
|
i += 1;
|
|
414
420
|
}
|
|
415
421
|
const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
|
|
@@ -437,6 +443,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
437
443
|
}
|
|
438
444
|
function onFrame(m: Record<string, unknown>): void {
|
|
439
445
|
switch (m.t) {
|
|
446
|
+
case 'latency': if(Number.isInteger(m.seat) && typeof m.ms==='number' && Number.isFinite(m.ms) && m.ms>=0) measuredRtt.set(m.seat as number,Math.min(m.ms,2000)); return;
|
|
440
447
|
case 'vocabulary': setAgents((m.vocab ?? null) as Vocabulary | null); return;
|
|
441
448
|
case 'decided': core.answer(String(m.n ?? ''), m); return;
|
|
442
449
|
case 'in': return onIn(m);
|
|
@@ -486,6 +493,12 @@ export function createHost(o: HostOptions): Host {
|
|
|
486
493
|
return;
|
|
487
494
|
}
|
|
488
495
|
if (agents && (kind === 'agent:do' || /^ask:/.test(kind) || (SPEECH.test(kind) && present.get(m.from as number) === false))) { if (agentInbox.length < 128) agentInbox.push(m); return; }
|
|
496
|
+
if(kind === 'pulse') {
|
|
497
|
+
const data=m.d,body=core.bodyOf(m.from as number);
|
|
498
|
+
if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length!==3 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
|
|
499
|
+
const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
|
|
500
|
+
if(value && q.pulses.length<16)q.pulses.push([data[1],value]);return;
|
|
501
|
+
}
|
|
489
502
|
if (kind === 'cmd') { const d = m.d as unknown[]; if (Array.isArray(d) && typeof d[0] === 'string') core.command(m.from as number, d[0], d[1]); return; }
|
|
490
503
|
// Speech keeps its payload, except the identity fields only the host may attach.
|
|
491
504
|
if (SPEECH.test(kind)) o.send({ t: 'ev', from: m.from, k: kind, d: cleanSpeech(m.d) });
|
|
@@ -528,6 +541,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
528
541
|
for (const x of out) {
|
|
529
542
|
if (x.t === 'round') {
|
|
530
543
|
if (x.phase === 'over') forceSave = true;
|
|
544
|
+
if(!retime && x.results && c.records && o.record){
|
|
545
|
+
const declaration=c.records, shared=unpackFields(c.shared,core.shared(),c.dims), snapshot=core.snapshot();
|
|
546
|
+
for(const result of x.results){if(result.driver!=='person')continue;const row=(snapshot[1] as any[]).find(e=>e[0]===result.id);if(!row)continue;const kind=c.kinds[row[1]],fields=unpackFields(kind.fields,row[7],c.dims);if(declaration.eligible&&!fields[declaration.eligible])continue;
|
|
547
|
+
const value:Record<string,unknown>={id:String(shared[declaration.identity]),endedAt:Math.round(now),place:result.place,won:result.place===1&&x.results.filter(r=>r.place===1).length===1};
|
|
548
|
+
for(const [name,source] of Object.entries(declaration.fields))value[name]=Math.round(Number(fields[source]));
|
|
549
|
+
o.record(String(row[12]),declaration.key,value);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
531
552
|
const ms = (t: number): number => Math.round(now + (t - core.tick) * period);
|
|
532
553
|
const drivers = new Map(core.bodies().map((b) => [b.seat, b.driver]));
|
|
533
554
|
o.send({ t: 'round', ...(retime ? { retime: true } : {}), round: {
|
|
@@ -556,7 +577,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
556
577
|
for (const [seat, q] of queues) {
|
|
557
578
|
const body = core.bodyOf(seat);
|
|
558
579
|
if (!body) continue;
|
|
559
|
-
const presses: string[] = [];
|
|
580
|
+
const presses: (string | [string, number])[] = [];
|
|
560
581
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
561
582
|
const e = q.entries.shift() as Entry;
|
|
562
583
|
q.held = e.values; q.claim = e.claim;
|
|
@@ -566,7 +587,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
566
587
|
if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
|
|
567
588
|
while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
|
|
568
589
|
const values = { ...q.held };
|
|
569
|
-
for (const p of presses) values[p] = true;
|
|
590
|
+
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
591
|
+
const pulse=q.pulses?.shift();if(pulse)values[pulse[0]]=pulse[1];
|
|
570
592
|
inputs.set(seat, { values, claim: q.claim });
|
|
571
593
|
}
|
|
572
594
|
if (roundRebasePending) publishRebase(now);
|
|
@@ -769,6 +791,15 @@ export function createHost(o: HostOptions): Host {
|
|
|
769
791
|
}
|
|
770
792
|
|
|
771
793
|
return {
|
|
794
|
+
get privateDelivery() { return c.kinds.some(k => k.collider || k.fields.some(([,f]) => f.visibility)); },
|
|
795
|
+
projectSnapshot(snap, seat) {
|
|
796
|
+
return {...snap,d:[snap.d[0],snap.d[1].map((row: any[]) => {
|
|
797
|
+
const k=c.kinds[row[1]], own=seat!==null && row[9]===seat && row[10]!==1;
|
|
798
|
+
if(!k.fields.some(([,f])=>f.visibility))return row;
|
|
799
|
+
const fields=row[7].map((v: unknown,i: number)=>{const visibility=k.fields[i][1].visibility;return !visibility || visibility==='owner'&&own || visibility==='results'&&snap.d[0][1]===0 ? v : null;});
|
|
800
|
+
return [...row.slice(0,7),fields,...row.slice(8)];
|
|
801
|
+
}),...snap.d.slice(2)]};
|
|
802
|
+
},
|
|
772
803
|
get viewSettings() { return c.settings.view; },
|
|
773
804
|
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
774
805
|
get tick() { return core.tick; },
|
package/rules/interest.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function snapshotEncoder(keyframeTicks, chained = false) {
|
|
|
69
69
|
for (let i = 1; i < entity.length; i++) if (rowText(entity)[i] !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
|
|
70
70
|
if (mask) changed.push([id, mask, values]);
|
|
71
71
|
}
|
|
72
|
-
const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
|
|
72
|
+
const out = { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 && JSON.stringify(snap.d[2]) !== JSON.stringify(base.d[2]) ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)), ...(chained ? { chain: true } : {}), ...(own ? { own } : {}) } };
|
|
73
73
|
if (chained) { base = snap; rows = new Map(snap.d[1].map(e => [e[0], rowText(e)])); }
|
|
74
74
|
return out;
|
|
75
75
|
},
|
|
@@ -103,7 +103,7 @@ export function snapshotDecoder() {
|
|
|
103
103
|
const visible = new Map(rows);
|
|
104
104
|
for (const id of d.removed) visible.delete(id);
|
|
105
105
|
for (const e of d.own) visible.set(e[0], e);
|
|
106
|
-
return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ?
|
|
106
|
+
return { ...snap, d: [d.round, [...visible.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
|
|
107
107
|
}
|
|
108
108
|
const next = new Map(rows);
|
|
109
109
|
for (const id of d.removed) next.delete(id);
|
|
@@ -122,7 +122,7 @@ export function snapshotDecoder() {
|
|
|
122
122
|
next.set(entity[0], entity);
|
|
123
123
|
}
|
|
124
124
|
for (const e of d.own ?? []) next.set(e[0], e);
|
|
125
|
-
const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ?
|
|
125
|
+
const decoded = { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? base.d.slice(2) : [d.collision])] };
|
|
126
126
|
if (d.chain) { base = decoded; rows = next; }
|
|
127
127
|
return decoded;
|
|
128
128
|
},
|
package/rules/live.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
+
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
1
2
|
import { overlapsTerrain } from './terrain.ts';
|
|
2
3
|
import { nearbyMap } from './map-index.ts';
|
|
3
4
|
/** Declared collision geometry, shared by authority and prediction. Entity fields
|
|
4
5
|
* own its lifetime and dimensions; this projection contains no gameplay state. */
|
|
5
|
-
import type { Vec3, ColliderDef } from './rules.ts';
|
|
6
|
+
import type { Vec3, ColliderDef, QueryDef } from './rules.ts';
|
|
6
7
|
import type { MapShapes } from './math.ts';
|
|
7
8
|
import { charge, own } from './guard.ts';
|
|
8
9
|
import { castMap3, solidAt, type BodyShape } from './collision.ts';
|
|
9
10
|
import { sweepMap, rayBox, rayCircle } from './math.ts';
|
|
10
11
|
|
|
11
|
-
export type ColliderRow = [string, string, number, number, number, number, number, number];
|
|
12
|
+
export type ColliderRow = [string, string, number, number, number, number, number, number, {kind: string; query?: QueryDef; fields: Record<string, number | string | boolean>}?];
|
|
12
13
|
export type CollisionRevision = [number, number, ColliderRow[]];
|
|
13
|
-
export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown
|
|
14
|
+
export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>, kind?: string, query?: QueryDef): ColliderRow | null {
|
|
14
15
|
const opts = def === true ? {} : def;
|
|
15
16
|
if (opts.enabled && fields[opts.enabled] !== true) return null;
|
|
16
17
|
const size = opts.size ? fields[opts.size] as Vec3 : null;
|
|
17
18
|
const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
|
|
18
19
|
if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
|
|
19
|
-
|
|
20
|
+
const row: ColliderRow = [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
|
|
21
|
+
if(kind) row.push({kind, ...(query?{query}:{}), fields:Object.fromEntries((opts.fields ?? []).filter(name=>['number','string','boolean'].includes(typeof fields[name])).map(name=>[name,fields[name]])) as Record<string,number|string|boolean>});
|
|
22
|
+
return row;
|
|
20
23
|
}
|
|
21
24
|
export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
|
|
22
25
|
const [, shape, x, y, z, w, d, h] = row;
|
|
@@ -27,6 +30,10 @@ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number
|
|
|
27
30
|
const solid = colliderSolid(row);
|
|
28
31
|
return row[1] === 'box' ? rayBox(p.x,p.y,d.x,d.y,solid.min,solid.max,radius) : rayCircle(p.x,p.y,d.x,d.y,row[2],row[3],row[5]/2+radius);
|
|
29
32
|
}
|
|
33
|
+
const rowsOf = new WeakMap<object, readonly ColliderRow[]>();
|
|
34
|
+
export function collisionTargets(map: MapShapes): QueryTarget[] {
|
|
35
|
+
return (rowsOf.get(map) ?? []).map(row=>({id:row[0],kind:row[8]?.kind??'',query:row[8]?.query,fields:row[8]?.fields??{},geometry:true,solid:colliderSolid(row),at:{x:row[2],y:row[3],z:row[4]}}));
|
|
36
|
+
}
|
|
30
37
|
export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
|
|
31
38
|
if (!rows.length) return map;
|
|
32
39
|
const boxes: any[] = [], circles: any[] = [], spheres: any[] = [], capsules: any[] = [];
|
|
@@ -37,7 +44,9 @@ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapS
|
|
|
37
44
|
else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
|
|
38
45
|
else capsules.push({ at, r: w / 2, height: h, id } as any);
|
|
39
46
|
}
|
|
40
|
-
|
|
47
|
+
const combined = { bounds: map.bounds, staticMap: map, boxes, circles, spheres, capsules };
|
|
48
|
+
rowsOf.set(combined, rows);
|
|
49
|
+
return combined;
|
|
41
50
|
}
|
|
42
51
|
/** Latest known revision at the requested tick. Unknown future edits are never
|
|
43
52
|
* guessed: a later snapshot rebases the body and replays its pending inputs. */
|
|
@@ -45,14 +54,17 @@ export function revisionAt(history: readonly CollisionRevision[], tick: number):
|
|
|
45
54
|
for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
|
|
46
55
|
return undefined;
|
|
47
56
|
}
|
|
48
|
-
export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
|
|
57
|
+
export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number, self: () => string | undefined = () => undefined): object {
|
|
49
58
|
const position = (body: unknown): Vec3 => {
|
|
50
59
|
const p = own(body, 'pos');
|
|
51
60
|
const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
|
|
52
61
|
return { x: n('x'), y: n('y'), z: n('z') };
|
|
53
62
|
};
|
|
54
63
|
return {
|
|
55
|
-
|
|
64
|
+
ray: (from: unknown, direction: unknown, max: unknown, options?: unknown) => {const m=map();return rayQuery(m.staticMap??m,collisionTargets(m),dims,from,direction,max,options,self())[0];},
|
|
65
|
+
rays: (options?: unknown) => {const m=map();return raySnapshot(m.staticMap??m,collisionTargets(m),dims,options,self());},
|
|
66
|
+
rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown) => {const m=map();return rayQuery(m.staticMap??m,collisionTargets(m),dims,from,direction,max,options,self(),true);},
|
|
67
|
+
sweep: (body: unknown, delta: unknown, options?: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape(), options),
|
|
56
68
|
support: (body: unknown, distance: unknown = 0.002) => {
|
|
57
69
|
charge(20);
|
|
58
70
|
const p = position(body), m = map();
|
package/rules/map-index.ts
CHANGED
|
@@ -46,6 +46,12 @@ export function nearbyMap(map: MapShapes, p:Vec3, d:Vec3, radius:number, height:
|
|
|
46
46
|
function visit(n:Node){if(!touches(n))return;if(n.entries){for(const e of n.entries)if(touches(e))found.push(e);}else{visit(n.left!);visit(n.right!);}}
|
|
47
47
|
visit(root);charge(found.length*Math.ceil(Math.log2(found.length+1)));
|
|
48
48
|
found.sort((a,b)=>a.order-b.order);for(const e of found)out[e.key].push(e.value);
|
|
49
|
-
if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const
|
|
49
|
+
if(parent)for(const key of ['boxes','circles','spheres','capsules','heightTiles'] as const)for(const raw of map[key]??[]){
|
|
50
|
+
const v:any=raw;let min:Vec3,max:Vec3;
|
|
51
|
+
if(key==='boxes'){min=v.min;max=v.max;}
|
|
52
|
+
else if(key==='heightTiles'){min={...v.at,z:v.at.z+Math.min(v.base??Infinity,...v.heights)};max={x:v.at.x+v.size.x,y:v.at.y+v.size.y,z:v.at.z+Math.max(...v.heights)};}
|
|
53
|
+
else{min={x:v.at.x-v.r,y:v.at.y-v.r,z:key==='circles'?-Infinity:key==='capsules'?v.at.z:v.at.z-v.r};max={x:v.at.x+v.r,y:v.at.y+v.r,z:key==='circles'?Infinity:key==='capsules'?v.at.z+v.height:v.at.z+v.r};}
|
|
54
|
+
if(touches({min,max}))out[key].push(raw);
|
|
55
|
+
}
|
|
50
56
|
return out;
|
|
51
57
|
}
|