@homie-rocks/studio 0.45.2 → 0.46.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 +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -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 +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- 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 +108 -29
- 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/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- 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/worker/world-journal.mjs +109 -0
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 {
|
|
@@ -56,8 +56,13 @@ export interface HostClock {
|
|
|
56
56
|
setTimer(fn: () => void, ms: number): unknown;
|
|
57
57
|
clearTimer(handle: unknown): void;
|
|
58
58
|
}
|
|
59
|
+
export interface CharacterLease {
|
|
60
|
+
account: string; lease: number; seat: number; character: import('./rules.ts').Character | null; returning?: boolean;
|
|
61
|
+
}
|
|
59
62
|
export interface HostOptions {
|
|
60
63
|
game: string;
|
|
64
|
+
record?(owner:string,key:string,value:Record<string,unknown>):void;
|
|
65
|
+
characterFor?(peer: Record<string, unknown>): CharacterLease | null;
|
|
61
66
|
/** The build check compares a movement step before any tick handler can change it. */
|
|
62
67
|
moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
|
|
63
68
|
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
@@ -73,7 +78,7 @@ export interface HostOptions {
|
|
|
73
78
|
send(frame: Record<string, unknown>, text?: string): void;
|
|
74
79
|
clock: HostClock;
|
|
75
80
|
/** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
|
|
76
|
-
store?: { save(bytes: Uint8Array): void };
|
|
81
|
+
store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
|
|
77
82
|
/** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
|
|
78
83
|
restoreEpoch?: number;
|
|
79
84
|
startDelayMs?: number;
|
|
@@ -94,12 +99,18 @@ export interface HostOptions {
|
|
|
94
99
|
onEnd?(why: string, facts: Record<string, unknown>): void;
|
|
95
100
|
}
|
|
96
101
|
export interface Host {
|
|
102
|
+
readonly characters: ReadonlyMap<string, CharacterLease>;
|
|
103
|
+
characterReturned(account: string, lease: number): void;
|
|
104
|
+
releaseCharacter(account: string): void;
|
|
105
|
+
reserveCharacter(lease: CharacterLease): void;
|
|
97
106
|
readonly tick: number;
|
|
98
107
|
readonly epoch: number;
|
|
99
108
|
readonly running: boolean;
|
|
100
109
|
readonly paused: boolean;
|
|
101
110
|
readonly people: number;
|
|
102
111
|
readonly viewRadiusM: number | null;
|
|
112
|
+
readonly privateDelivery: boolean;
|
|
113
|
+
projectSnapshot(snap: any, seat: number | null): any;
|
|
103
114
|
readonly viewSettings: Compiled['settings']['view'];
|
|
104
115
|
frame(m: Record<string, unknown>): void;
|
|
105
116
|
/** Start the tick loop (the first person has joined). */
|
|
@@ -108,7 +119,7 @@ export interface Host {
|
|
|
108
119
|
resume(): void;
|
|
109
120
|
/** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
|
|
110
121
|
announce(): void;
|
|
111
|
-
/** Stop for good: the room has ended. */
|
|
122
|
+
/** Stop for good: the room has ended. A lasting world whose storage is healthy checkpoints first, and that write can throw. */
|
|
112
123
|
stop(): void;
|
|
113
124
|
/** Run the next tick now, whatever the clock says (tests, and the build check's runs). Like the timer's own tick, it never throws. */
|
|
114
125
|
tickNow(): void;
|
|
@@ -118,8 +129,9 @@ export interface Host {
|
|
|
118
129
|
readonly core: Core;
|
|
119
130
|
}
|
|
120
131
|
|
|
121
|
-
interface Entry { at: number; values: Record<string, unknown>; presses: string[]; claim: StepInput['claim'] }
|
|
132
|
+
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
|
|
122
133
|
interface SeatIn {
|
|
134
|
+
pulses?: [string,number][];
|
|
123
135
|
held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
|
|
124
136
|
/** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
|
|
125
137
|
newest: number; stamps: number[]; ack: number;
|
|
@@ -160,19 +172,33 @@ function blameLate(now: number, period: number): void {
|
|
|
160
172
|
|
|
161
173
|
export function createHost(o: HostOptions): Host {
|
|
162
174
|
const c = o.compiled;
|
|
175
|
+
const persistent = c.settings.lifetime === 'persistent';
|
|
176
|
+
// Keep even effects and agent requests behind the same commit as their tick.
|
|
177
|
+
// Match rooms retain their existing save frequency and output behaviour.
|
|
178
|
+
const publish = o.send;
|
|
179
|
+
let pendingOutput: [Record<string, unknown>, string | undefined][] | null = null;
|
|
180
|
+
if (persistent) o = { ...o, send: (frame, text) => {
|
|
181
|
+
if (pendingOutput) pendingOutput.push([frame, text]); else publish(frame, text);
|
|
182
|
+
} };
|
|
183
|
+
|
|
163
184
|
const period = 1000 / c.settings.tickHz;
|
|
164
185
|
const tickHz = c.settings.tickHz;
|
|
165
186
|
const lateTicks = Math.ceil(tickHz / 4);
|
|
166
187
|
// A log that throws takes nothing down with it.
|
|
167
188
|
const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
|
|
168
189
|
const random = o.random ?? Math.random;
|
|
169
|
-
const saved = o.restore ? fromBytes(o.restore) as { v: number; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
190
|
+
const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
170
191
|
if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
|
|
171
192
|
if (saved) {
|
|
172
193
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
|
|
173
194
|
const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
174
|
-
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs'].includes(k)));
|
|
195
|
+
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
|
|
175
196
|
check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
|
|
197
|
+
if (saved.characters !== undefined) {
|
|
198
|
+
check(c.settings.characters && Array.isArray(saved.characters));
|
|
199
|
+
check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
|
|
200
|
+
for (const held of saved.characters) check(typeof held.account === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(held.account) && uint(held.lease) && held.lease > 0 && Number.isInteger(held.seat) && held.seat >= -1 && held.seat < c.seats && (held.returning === undefined || typeof held.returning === 'boolean'));
|
|
201
|
+
}
|
|
176
202
|
if (saved.agents !== undefined) {
|
|
177
203
|
const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
|
|
178
204
|
check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
|
|
@@ -225,17 +251,34 @@ export function createHost(o: HostOptions): Host {
|
|
|
225
251
|
for (const [seat, q] of saved.inputs) {
|
|
226
252
|
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
253
|
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
|
-
|
|
254
|
+
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));
|
|
255
|
+
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
256
|
}
|
|
230
257
|
}
|
|
231
258
|
}
|
|
232
259
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
233
|
-
const
|
|
260
|
+
const measuredRtt = new Map<number, number>();
|
|
261
|
+
const characters = new Map<string, CharacterLease>((saved?.characters ?? []).map(c => [c.account, c.returning ? { ...c, seat: -1 } : c]));
|
|
262
|
+
const characterOptions = c.settings.characters ? {
|
|
263
|
+
characterForSeat: (seat: number) => [...characters.values()].find(c => c.seat === seat && !c.returning)?.character,
|
|
264
|
+
characterSaved: (seat: number, character: import('./rules.ts').Character) => {
|
|
265
|
+
const held = [...characters.values()].find(c => c.seat === seat);
|
|
266
|
+
if (held) {
|
|
267
|
+
held.character = character;
|
|
268
|
+
// Once removed, a delayed return must never follow a new occupant of this seat.
|
|
269
|
+
if (held.returning) held.seat = -1;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
} : {};
|
|
273
|
+
const core = createCore(c, { ...characterOptions, 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
274
|
let epoch = core.epoch;
|
|
235
275
|
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
276
|
let lastSaveAt = o.clock.now();
|
|
237
277
|
let saveTick = core.tick;
|
|
238
278
|
let forceSave = false;
|
|
279
|
+
let storeBroken = false;
|
|
280
|
+
/** Which state fields the game declared durable, by kind and for the shared state: what a lasting world journals at once. */
|
|
281
|
+
const durableFields = { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) };
|
|
239
282
|
let saveFailures = 0;
|
|
240
283
|
let retrySaveAt = 0;
|
|
241
284
|
let roundPhase = saved?.core.round[1] ?? 1;
|
|
@@ -367,7 +410,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
367
410
|
// A restored room keeps each seat's held input, its newest stamp and its acknowledgement.
|
|
368
411
|
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
412
|
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; };
|
|
413
|
+
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
414
|
function queueOf(seat: number, kind: KindTable): SeatIn {
|
|
372
415
|
let q = queues.get(seat);
|
|
373
416
|
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 +448,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
405
448
|
if (j > K + tickHz) { stats.dropped += 1; break; }
|
|
406
449
|
q.newest = j;
|
|
407
450
|
const values: Record<string, unknown> = {};
|
|
408
|
-
const presses: string[] = [];
|
|
451
|
+
const presses: (string | [string, number])[] = [];
|
|
409
452
|
let i = 1;
|
|
410
453
|
for (const [name, fd] of kind.input) {
|
|
411
454
|
// 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);
|
|
455
|
+
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
456
|
i += 1;
|
|
414
457
|
}
|
|
415
458
|
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 +480,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
437
480
|
}
|
|
438
481
|
function onFrame(m: Record<string, unknown>): void {
|
|
439
482
|
switch (m.t) {
|
|
483
|
+
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
484
|
case 'vocabulary': setAgents((m.vocab ?? null) as Vocabulary | null); return;
|
|
441
485
|
case 'decided': core.answer(String(m.n ?? ''), m); return;
|
|
442
486
|
case 'in': return onIn(m);
|
|
@@ -452,7 +496,16 @@ export function createHost(o: HostOptions): Host {
|
|
|
452
496
|
// The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
|
|
453
497
|
const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
|
|
454
498
|
const previous = core.bodies().find((s) => s.seat === seat);
|
|
455
|
-
|
|
499
|
+
const grant = driver === 'person' ? o.characterFor?.(p as Record<string, unknown>) : null;
|
|
500
|
+
if (c.settings.characters && driver === 'person' && !grant && o.characterFor) throw new Error('Character lease required');
|
|
501
|
+
let character = grant?.character;
|
|
502
|
+
if (grant) {
|
|
503
|
+
const held = characters.get(grant.account);
|
|
504
|
+
if (held && (held.lease !== grant.lease || held.returning || held.seat >= 0 && held.seat !== seat)) throw new Error('Character return is still pending');
|
|
505
|
+
character = held ? core.character(held.seat) ?? held.character : grant.character;
|
|
506
|
+
characters.set(grant.account, { ...grant, character: character ?? null, seat });
|
|
507
|
+
}
|
|
508
|
+
core.seatJoin({ seat, driver, owner, ...(grant ? { character } : {}) });
|
|
456
509
|
core.seatAway(seat, false);
|
|
457
510
|
if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
|
|
458
511
|
rosterText = '';
|
|
@@ -464,7 +517,13 @@ export function createHost(o: HostOptions): Host {
|
|
|
464
517
|
for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
|
|
465
518
|
present.delete(m.seat as number);
|
|
466
519
|
queues.delete(m.seat as number);
|
|
467
|
-
|
|
520
|
+
const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
|
|
521
|
+
if (held) {
|
|
522
|
+
held.character = core.character(held.seat) ?? held.character;
|
|
523
|
+
held.returning = true;
|
|
524
|
+
core.seatLeave(held.seat);
|
|
525
|
+
tick(o.clock.now());
|
|
526
|
+
} else core.seatAway(m.seat as number, true);
|
|
468
527
|
if (running && people() === 0) pause();
|
|
469
528
|
return;
|
|
470
529
|
}
|
|
@@ -474,7 +533,10 @@ export function createHost(o: HostOptions): Host {
|
|
|
474
533
|
for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
|
|
475
534
|
present.delete(m.seat as number);
|
|
476
535
|
queues.delete(m.seat as number);
|
|
536
|
+
const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
|
|
537
|
+
if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
|
|
477
538
|
core.seatLeave(m.seat as number);
|
|
539
|
+
if (held) tick(o.clock.now());
|
|
478
540
|
rosterText = '';
|
|
479
541
|
return;
|
|
480
542
|
}
|
|
@@ -486,6 +548,12 @@ export function createHost(o: HostOptions): Host {
|
|
|
486
548
|
return;
|
|
487
549
|
}
|
|
488
550
|
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; }
|
|
551
|
+
if(kind === 'pulse') {
|
|
552
|
+
const data=m.d,body=core.bodyOf(m.from as number);
|
|
553
|
+
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;
|
|
554
|
+
const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
|
|
555
|
+
if(value && q.pulses.length<16)q.pulses.push([data[1],value]);return;
|
|
556
|
+
}
|
|
489
557
|
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
558
|
// Speech keeps its payload, except the identity fields only the host may attach.
|
|
491
559
|
if (SPEECH.test(kind)) o.send({ t: 'ev', from: m.from, k: kind, d: cleanSpeech(m.d) });
|
|
@@ -528,6 +596,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
528
596
|
for (const x of out) {
|
|
529
597
|
if (x.t === 'round') {
|
|
530
598
|
if (x.phase === 'over') forceSave = true;
|
|
599
|
+
if(!retime && x.results && c.records && o.record){
|
|
600
|
+
const declaration=c.records, shared=unpackFields(c.shared,core.shared(),c.dims), snapshot=core.snapshot();
|
|
601
|
+
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;
|
|
602
|
+
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};
|
|
603
|
+
for(const [name,source] of Object.entries(declaration.fields))value[name]=Math.round(Number(fields[source]));
|
|
604
|
+
o.record(String(row[12]),declaration.key,value);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
531
607
|
const ms = (t: number): number => Math.round(now + (t - core.tick) * period);
|
|
532
608
|
const drivers = new Map(core.bodies().map((b) => [b.seat, b.driver]));
|
|
533
609
|
o.send({ t: 'round', ...(retime ? { retime: true } : {}), round: {
|
|
@@ -556,7 +632,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
556
632
|
for (const [seat, q] of queues) {
|
|
557
633
|
const body = core.bodyOf(seat);
|
|
558
634
|
if (!body) continue;
|
|
559
|
-
const presses: string[] = [];
|
|
635
|
+
const presses: (string | [string, number])[] = [];
|
|
560
636
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
561
637
|
const e = q.entries.shift() as Entry;
|
|
562
638
|
q.held = e.values; q.claim = e.claim;
|
|
@@ -566,7 +642,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
566
642
|
if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
|
|
567
643
|
while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
|
|
568
644
|
const values = { ...q.held };
|
|
569
|
-
for (const p of presses) values[p] = true;
|
|
645
|
+
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
646
|
+
const pulse=q.pulses?.shift();if(pulse)values[pulse[0]]=pulse[1];
|
|
570
647
|
inputs.set(seat, { values, claim: q.claim });
|
|
571
648
|
}
|
|
572
649
|
if (roundRebasePending) publishRebase(now);
|
|
@@ -612,7 +689,17 @@ export function createHost(o: HostOptions): Host {
|
|
|
612
689
|
function tick(now: number): void {
|
|
613
690
|
const cut = core.stats.ticksCut;
|
|
614
691
|
let faulted = false;
|
|
615
|
-
|
|
692
|
+
if (persistent) pendingOutput = [];
|
|
693
|
+
try {
|
|
694
|
+
tickOnce();
|
|
695
|
+
const committed = pendingOutput; pendingOutput = null;
|
|
696
|
+
if (committed && !ended) for (const [frame, text] of committed) publish(frame, text);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
pendingOutput = null;
|
|
699
|
+
faulted = true; fault(`tick ${core.tick} (${phase})`, error);
|
|
700
|
+
// Never continue from uncommitted memory or retire the last good save.
|
|
701
|
+
if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
|
|
702
|
+
}
|
|
616
703
|
if (ended) return;
|
|
617
704
|
if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
|
|
618
705
|
if (failingSince === null) failingSince = now;
|
|
@@ -708,7 +795,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
708
795
|
// A pending timer would keep the object in memory and billed: the pause clears it.
|
|
709
796
|
disarm();
|
|
710
797
|
log({ ev: 'host-pause', game: o.game, tick: core.tick });
|
|
711
|
-
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); }
|
|
798
|
+
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
|
|
712
799
|
}
|
|
713
800
|
function resume(): void {
|
|
714
801
|
if (running || ended || !paused) return;
|
|
@@ -750,9 +837,20 @@ export function createHost(o: HostOptions): Host {
|
|
|
750
837
|
}
|
|
751
838
|
|
|
752
839
|
function save(): Uint8Array {
|
|
753
|
-
return toBytes({ v: 1, core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !core.bodies().some(b => b.seat === seat && b.owner === 'reserved')), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
840
|
+
return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !core.bodies().some(b => b.seat === seat && b.owner === 'reserved')), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
754
841
|
}
|
|
755
842
|
function checkpoint(force = false): void {
|
|
843
|
+
if (persistent) {
|
|
844
|
+
// An empty match break or an unjoined reservation has no body-removal callback.
|
|
845
|
+
for (const held of characters.values()) if (held.returning) held.seat = -1;
|
|
846
|
+
if (!o.store) return; // Local checks/offline simulation have no durable account.
|
|
847
|
+
// A write that threw leaves memory ahead of storage: nothing later may checkpoint it over the committed world.
|
|
848
|
+
storeBroken = true;
|
|
849
|
+
o.store.save(save(), { force, seconds: c.settings.durability.movementSeconds, tickHz, fields: durableFields });
|
|
850
|
+
storeBroken = false;
|
|
851
|
+
saveTick = core.tick; lastSaveAt = o.clock.now(); forceSave = false;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
756
854
|
const seconds = c.settings.durability.movementSeconds;
|
|
757
855
|
// Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
|
|
758
856
|
if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
|
|
@@ -769,6 +867,26 @@ export function createHost(o: HostOptions): Host {
|
|
|
769
867
|
}
|
|
770
868
|
|
|
771
869
|
return {
|
|
870
|
+
get privateDelivery() { return c.kinds.some(k => k.collider || k.fields.some(([,f]) => f.visibility)); },
|
|
871
|
+
projectSnapshot(snap, seat) {
|
|
872
|
+
return {...snap,d:[snap.d[0],snap.d[1].map((row: any[]) => {
|
|
873
|
+
const k=c.kinds[row[1]], own=seat!==null && row[9]===seat && row[10]!==1;
|
|
874
|
+
if(!k.fields.some(([,f])=>f.visibility))return row;
|
|
875
|
+
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;});
|
|
876
|
+
return [...row.slice(0,7),fields,...row.slice(8)];
|
|
877
|
+
}),...snap.d.slice(2)]};
|
|
878
|
+
},
|
|
879
|
+
get characters() { return characters; },
|
|
880
|
+
reserveCharacter(lease) { if (!characters.has(lease.account)) characters.set(lease.account, { ...lease }); },
|
|
881
|
+
releaseCharacter(account) {
|
|
882
|
+
const held = characters.get(account);
|
|
883
|
+
if (!held || held.returning || ended) return;
|
|
884
|
+
held.character = core.character(held.seat) ?? held.character; held.returning = true;
|
|
885
|
+
present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
|
|
886
|
+
if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
|
|
887
|
+
if (running && people() === 0) pause();
|
|
888
|
+
},
|
|
889
|
+
characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
|
|
772
890
|
get viewSettings() { return c.settings.view; },
|
|
773
891
|
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
774
892
|
get tick() { return core.tick; },
|
|
@@ -776,7 +894,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
776
894
|
get running() { return running; },
|
|
777
895
|
get paused() { return paused; },
|
|
778
896
|
get people() { return people(); },
|
|
779
|
-
frame, start, pause, resume,
|
|
897
|
+
frame, start, pause, resume, announce,
|
|
898
|
+
// An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
|
|
899
|
+
stop() { try { if (persistent && !ended && !storeBroken) checkpoint(true); } finally { stop(); } },
|
|
780
900
|
tickNow: () => {
|
|
781
901
|
if (ended) return;
|
|
782
902
|
let now = lastStart;
|
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
|
}
|
package/rules/math.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { collisionQueries } from './live.ts';
|
|
|
14
14
|
* Every call is charged 4 units to the running handler's budget (guard.ts), beside what any guarded call costs.
|
|
15
15
|
* =============================================================================
|
|
16
16
|
*/
|
|
17
|
-
import { castMap3, restsOnMap, type BodyShape } from './collision.ts';
|
|
17
|
+
import { castMap3, castSolid, restsOnMap, type BodyShape } from './collision.ts';
|
|
18
18
|
import { brand, charge, own, put } from './guard.ts';
|
|
19
19
|
import { num } from './pack.ts';
|
|
20
20
|
|
|
@@ -237,12 +237,24 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
|
|
|
237
237
|
return best;
|
|
238
238
|
}
|
|
239
239
|
/** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
|
|
240
|
-
export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
|
|
240
|
+
export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number, hits?: Hit[], includeInside = false, square = false): { hit: Hit | null; tested: number } {
|
|
241
241
|
map = nearbyMap(map, {x:px,y:py,z:0}, {x:dx,y:dy,z:0}, r, 0, 2);
|
|
242
242
|
charge(16 + 4 * (map.boxes.length + map.circles.length));
|
|
243
243
|
let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
if (hits && best) hits.push(best);
|
|
245
|
+
const take=(h: Hit | null,id?:string): void => {if(!h)return;const hit={...h,...(id?{id}:{})};if(hits)hits.push(hit);if(!best||h.t<best.t)best=hit;};
|
|
246
|
+
const length=Math.sqrt(dx*dx+dy*dy)||1;
|
|
247
|
+
const inside: Hit={t:0,nx:-dx/length,ny:-dy/length};
|
|
248
|
+
for (const b of map.boxes) {
|
|
249
|
+
const min=square?{x:b.min.x-r,y:b.min.y-r,z:0}:b.min,max=square?{x:b.max.x+r,y:b.max.y+r,z:0}:b.max;
|
|
250
|
+
const embedded=includeInside&&px>min.x&&px<max.x&&py>min.y&&py<max.y;
|
|
251
|
+
take(embedded?inside:rayBox(px,py,dx,dy,min,max,square?0:r),(b as {id?:string}).id);
|
|
252
|
+
}
|
|
253
|
+
for (const c of map.circles) {
|
|
254
|
+
const embedded=includeInside&&(px-c.at.x)*(px-c.at.x)+(py-c.at.y)*(py-c.at.y)<(c.r+r)*(c.r+r);
|
|
255
|
+
const h=square?castSolid({min:{x:px-r,y:py-r,z:0},max:{x:px+r,y:py+r,z:1},r:0},{x:dx,y:dy,z:0},{min:{x:c.at.x,y:c.at.y,z:-1},max:{x:c.at.x,y:c.at.y,z:2},r:c.r},includeInside):embedded?inside:rayCircle(px,py,dx,dy,c.at.x,c.at.y,c.r+r);
|
|
256
|
+
take(h,(c as {id?:string}).id);
|
|
257
|
+
}
|
|
246
258
|
return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
|
|
247
259
|
}
|
|
248
260
|
|
|
@@ -251,7 +263,7 @@ export function castMap(map: MapShapes, px: number, py: number, dx: number, dy:
|
|
|
251
263
|
* and a browser both move a body with this, so they agree to the last bit. Returns nothing, or `{ at, normal }`.
|
|
252
264
|
* 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.
|
|
253
265
|
*/
|
|
254
|
-
export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }): unknown {
|
|
266
|
+
export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius: number, dims: number, shape: BodyShape = { shape: 'sphere', radius, height: 2 * radius }, options?: unknown): unknown {
|
|
255
267
|
// Charged before the cast, for every shape of the map it may test.
|
|
256
268
|
charge(20);
|
|
257
269
|
const fr = Math.fround;
|
|
@@ -267,7 +279,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
267
279
|
const length = Math.sqrt(dx * dx + dy * dy + d.z * d.z);
|
|
268
280
|
const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
269
281
|
const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
|
|
270
|
-
put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
|
|
282
|
+
put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
|
|
271
283
|
return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
|
|
272
284
|
}
|
|
273
285
|
const { hit: h } = castMap(map, px, py, dx, dy, radius);
|
|
@@ -285,9 +297,9 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
285
297
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
286
298
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
287
299
|
*/
|
|
288
|
-
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; geometry?: () => MapShapes }): unknown {
|
|
300
|
+
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; geometry?: () => MapShapes; self?: () => string | undefined }): unknown {
|
|
289
301
|
const none = Object.freeze([]);
|
|
290
|
-
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
|
|
302
|
+
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
|
|
291
303
|
return brand(Object.freeze({
|
|
292
304
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
293
305
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Deterministic routing over an authored, baked walking graph. Full searches
|
|
2
|
+
* either reach the goal or return no path; never silently truncate a route. */
|
|
3
|
+
import {own,charge} from './guard.ts';
|
|
4
|
+
import type {Solid} from './collision.ts';
|
|
5
|
+
import {castSolid} from './collision.ts';
|
|
6
|
+
export type RouteNode = readonly [number,number,number,readonly number[]];
|
|
7
|
+
const graphs = new WeakMap<object,readonly RouteNode[]>();
|
|
8
|
+
const incoming = new WeakMap<object,readonly (readonly number[])[]>();
|
|
9
|
+
function graphOf(value: unknown, billing=true): readonly RouteNode[] {
|
|
10
|
+
if(!Array.isArray(value)||value.length>8192)throw new Error('route graph has at most 8192 nodes');
|
|
11
|
+
if(billing)charge(value.length*12);const cached=graphs.get(value);if(cached)return cached;
|
|
12
|
+
const nodes:RouteNode[]=[];
|
|
13
|
+
for(let i=0;i<value.length;i++){
|
|
14
|
+
const n=own(value,i),links=own(n,3),x=own(n,0),y=own(n,1),z=own(n,2);
|
|
15
|
+
if(![x,y,z].every(v=>typeof v==='number'&&Number.isFinite(v))||!Array.isArray(links)||links.length>16)throw new Error('route nodes are [x,y,z,links], at most 16 neighbours');
|
|
16
|
+
const adjacent:number[]=[];for(let j=0;j<links.length;j++){const id=own(links,j);if(typeof id!=='number'||!Number.isInteger(id)||id<0||id>=value.length)throw new Error('route links name graph nodes');adjacent.push(id);}
|
|
17
|
+
nodes.push(Object.freeze([x as number,y as number,z as number,Object.freeze(adjacent)]));
|
|
18
|
+
}
|
|
19
|
+
const result=Object.freeze(nodes);const reverse:number[][]=nodes.map(()=>[]);nodes.forEach((n,i)=>{for(const next of n[3])reverse[next].push(i);});incoming.set(result,reverse);if(Object.isFrozen(value)&&value.every(n=>Object.isFrozen(n)&&Object.isFrozen(own(n,3))))graphs.set(value,result);return result;
|
|
20
|
+
}
|
|
21
|
+
export const prepareGraph=(value:unknown):readonly RouteNode[]=>graphOf(value,false);
|
|
22
|
+
export function routeGraph(value: unknown, origin: unknown, destination: unknown, solids: readonly Solid[], radius: number, height: number, prepared=false): readonly number[] {
|
|
23
|
+
const nodes=prepared?value as readonly RouteNode[]:graphOf(value);if(prepared)charge(16);
|
|
24
|
+
if(!Number.isInteger(origin)||!Number.isInteger(destination)||Number(origin)<0||Number(destination)<0||Number(origin)>=nodes.length||Number(destination)>=nodes.length)throw new Error('route endpoints name graph nodes');
|
|
25
|
+
let start=Number(origin),goal=Number(destination),end=nodes[goal];
|
|
26
|
+
const buckets=new Map<string,Solid[]>();
|
|
27
|
+
for(const solid of solids){const minX=Math.floor((solid.min.x-solid.r-radius)/4),maxX=Math.floor((solid.max.x+solid.r+radius)/4),minY=Math.floor((solid.min.y-solid.r-radius)/4),maxY=Math.floor((solid.max.y+solid.r+radius)/4);
|
|
28
|
+
if((maxX-minX+1)*(maxY-minY+1)>65536)throw new Error('route obstacle spans too many graph cells');
|
|
29
|
+
for(let x=minX;x<=maxX;x++)for(let y=minY;y<=maxY;y++){charge(2);const key=x+','+y;const rows=buckets.get(key)??[];rows.push(solid);buckets.set(key,rows);}
|
|
30
|
+
}
|
|
31
|
+
const blocked=(a:RouteNode,b:RouteNode)=>{
|
|
32
|
+
const from={min:{x:a[0]-radius,y:a[1]-radius,z:a[2]+.05},max:{x:a[0]+radius,y:a[1]+radius,z:a[2]+height},r:0},delta={x:b[0]-a[0],y:b[1]-a[1],z:b[2]-a[2]};
|
|
33
|
+
const candidates=new Set<Solid>();
|
|
34
|
+
for(let x=Math.floor(Math.min(a[0],b[0])/4);x<=Math.floor(Math.max(a[0],b[0])/4);x++)for(let y=Math.floor(Math.min(a[1],b[1])/4);y<=Math.floor(Math.max(a[1],b[1])/4);y++){charge(1);for(const solid of buckets.get(x+','+y)??[])candidates.add(solid);}
|
|
35
|
+
for(const s of candidates){charge(2);if(Math.max(a[0],b[0])+radius<s.min.x-s.r||Math.min(a[0],b[0])-radius>s.max.x+s.r||Math.max(a[1],b[1])+radius<s.min.y-s.r||Math.min(a[1],b[1])-radius>s.max.y+s.r||Math.max(a[2],b[2])+height<s.min.z-s.r||Math.min(a[2],b[2])+.05>s.max.z+s.r)continue;if(s.r===0){
|
|
36
|
+
charge(24);let near=0,far=1,hit=true;
|
|
37
|
+
for(const axis of ['x','y','z'] as const){const low=s.min[axis]-from.max[axis],high=s.max[axis]-from.min[axis],speed=delta[axis];if(!speed){if(low>=0||high<=0){hit=false;break;}}else{let a=low/speed,b=high/speed;if(a>b)[a,b]=[b,a];near=Math.max(near,a);far=Math.min(far,b);if(near>far){hit=false;break;}}}
|
|
38
|
+
if(hit&&far>=0)return true;
|
|
39
|
+
}else if(castSolid(from,delta,s,true))return true;}
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
const endpoint=(id:number,limit:number)=>{
|
|
43
|
+
if(!blocked(nodes[id],nodes[id]))return id;
|
|
44
|
+
const p=nodes[id];let best=-1,score=Infinity;
|
|
45
|
+
for(let i=0;i<nodes.length;i++){charge(4);const q=nodes[i],distance=Math.hypot(q[0]-p[0],q[1]-p[1]);const cost=distance+Math.abs(q[2]-p[2])*2;if(distance>limit||cost>=score||!q[3].length||blocked(q,q))continue;best=i;score=cost;}
|
|
46
|
+
return best;
|
|
47
|
+
};
|
|
48
|
+
start=endpoint(start,4);goal=endpoint(goal,Infinity);if(start<0||goal<0)return Object.freeze([]);end=nodes[goal];
|
|
49
|
+
type Entry={id:number;f:number;order:number};let sequence=0;
|
|
50
|
+
const before=(a:Entry,b:Entry)=>a.f<b.f||a.f===b.f&&a.order<b.order;
|
|
51
|
+
const push=(heap:Entry[],id:number,f:number)=>{const item={id,f,order:sequence++};let i=heap.length;heap.push(item);while(i){const p=(i-1)>>1;if(!before(item,heap[p]))break;heap[i]=heap[p];i=p;}heap[i]=item;};
|
|
52
|
+
const pop=(heap:Entry[])=>{const first=heap[0],last=heap.pop()!;if(heap.length){let i=0;while(i*2+1<heap.length){let j=i*2+1;if(j+1<heap.length&&before(heap[j+1],heap[j]))j++;if(!before(heap[j],last))break;heap[i]=heap[j];i=j;}heap[i]=last;}return first.id;};
|
|
53
|
+
const originPoint=nodes[start],potential=(id:number)=>(Math.hypot(nodes[id][0]-end[0],nodes[id][1]-end[1])-Math.hypot(nodes[id][0]-originPoint[0],nodes[id][1]-originPoint[1]))/2;
|
|
54
|
+
const heaps:Entry[][]=[[],[]],costs=[new Map([[start,0]]),new Map([[goal,0]])],parents=[new Map<number,number>(),new Map<number,number>()],closed=[new Set<number>(),new Set<number>()],reverse=incoming.get(nodes)!;
|
|
55
|
+
push(heaps[0],start,potential(start));push(heaps[1],goal,-potential(goal));let best=Infinity,meet=-1;
|
|
56
|
+
// Balanced bidirectional A*: either frontier can prove disconnection. No
|
|
57
|
+
// expansion limit, and every returned route connects the two endpoints.
|
|
58
|
+
while(heaps[0].length&&heaps[1].length){
|
|
59
|
+
if(heaps[0][0].f+heaps[1][0].f>=best)break;
|
|
60
|
+
const side=heaps[0][0].f<=heaps[1][0].f?0:1,other=1-side,current=pop(heaps[side]);charge(12);if(closed[side].has(current))continue;closed[side].add(current);
|
|
61
|
+
const cost=costs[side].get(current)!,opposite=costs[other].get(current);if(opposite!==undefined&&cost+opposite<best){best=cost+opposite;meet=current;}
|
|
62
|
+
for(const next of side?reverse[current]:nodes[current][3]){charge(4);if(closed[side].has(next))continue;const a=nodes[side?next:current],b=nodes[side?current:next];if(blocked(a,b))continue;
|
|
63
|
+
const score=cost+Math.hypot(b[0]-a[0],b[1]-a[1])+Math.max(0,b[2]-a[2])*.3;if(score>=(costs[side].get(next)??Infinity))continue;
|
|
64
|
+
costs[side].set(next,score);parents[side].set(next,current);push(heaps[side],next,score+(side?-potential(next):potential(next)));
|
|
65
|
+
const otherCost=costs[other].get(next);if(otherCost!==undefined&&score+otherCost<best){best=score+otherCost;meet=next;}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if(meet<0)return Object.freeze([]);const path=[meet];while(path[0]!==start)path.unshift(parents[0].get(path[0])!);while(path[path.length-1]!==goal)path.push(parents[1].get(path[path.length-1])!);charge(path.length);return Object.freeze(path);
|
|
69
|
+
}
|
package/rules/pack.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { cellsOf } from './rules.ts';
|
|
|
21
21
|
import type { Field, FieldList, Vec3 } from './rules.ts';
|
|
22
22
|
import { G, charge, keyCount, own, refusedName } from './guard.ts';
|
|
23
23
|
|
|
24
|
-
const RANGE: Record<string, [number, number]> = { u8: [0, 255], u16: [0, 65535], u32: [0, 4294967295], i8: [-128, 127], i16: [-32768, 32767], i32: [-2147483648, 2147483647], tick: [0, 4294967295], ticks: [0, 4294967295] };
|
|
24
|
+
const RANGE: Record<string, [number, number]> = { pulse: [0, 255], u8: [0, 255], u16: [0, 65535], u32: [0, 4294967295], i8: [-128, 127], i16: [-32768, 32767], i32: [-2147483648, 2147483647], tick: [0, 4294967295], ticks: [0, 4294967295] };
|
|
25
25
|
/** `f.fix`: steps of 1/4096, to plus or minus 2^31 (a tick count with a fraction fits for the life of any room). */
|
|
26
26
|
export const FIX_STEP = 4096;
|
|
27
27
|
const FIX_MAX = 2147483648;
|
|
@@ -262,7 +262,7 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
|
|
|
262
262
|
* `used` is the units it took. What it throws goes to `failed` (the budget's own stop too) and the body is still read.
|
|
263
263
|
*/
|
|
264
264
|
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
|
|
265
|
-
G.left = quota;
|
|
265
|
+
G.queryEpoch++; G.left = quota;
|
|
266
266
|
const bag = coerceFields(motion, body.motion, dims);
|
|
267
267
|
const target: Record<string, unknown> = {};
|
|
268
268
|
const work: Record<string, unknown> = {};
|