@homie-rocks/studio 0.47.0 → 0.48.1
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 +91 -0
- package/guides/game/RULES.md +189 -4
- package/guides/game/SKILL.md +27 -0
- package/lib/doctor.mjs +8 -6
- package/lib/mcp-tools.mjs +2 -1
- package/lib/physics-mesh.mjs +40 -0
- package/lib/provider-key.mjs +21 -0
- package/lib/rules-build.mjs +18 -10
- package/lib/rules-check-worker.mjs +3 -0
- package/lib/rules-check.mjs +1 -1
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +20 -0
- package/package.json +11 -2
- package/rules/core.ts +63 -4
- package/rules/host.ts +38 -9
- package/rules/math.ts +10 -1
- package/rules/pack.ts +4 -2
- package/rules/physics-engine.ts +73 -0
- package/rules/physics.ts +26 -0
- package/rules/rules.ts +14 -4
- package/rules/substeps.ts +39 -0
- package/rules/types.ts +3 -2
- package/rules/view.ts +150 -44
- package/worker/build-id.mjs +1 -1
- package/worker/hosted.mjs +16 -0
- package/worker/index.mjs +19 -4
- package/worker/version.mjs +1 -1
package/rules/rules.ts
CHANGED
|
@@ -144,7 +144,7 @@ export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radiu
|
|
|
144
144
|
export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
|
|
145
145
|
export interface RoundRecords {key:string; identity:string; eligible?:string; fields:Readonly<Record<string,string>>}
|
|
146
146
|
export interface EntityDef {
|
|
147
|
-
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string };
|
|
147
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string };
|
|
148
148
|
fields?: Fields;
|
|
149
149
|
motion?: Fields;
|
|
150
150
|
input?: Fields;
|
|
@@ -291,7 +291,7 @@ export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
|
|
|
291
291
|
export interface MapCircle { readonly at: Vec3; readonly r: number }
|
|
292
292
|
export interface MapHeightTile { readonly base?: number; readonly diagonal?: '00-11' | '10-01'; readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
|
|
293
293
|
export type MapWrap = Readonly<Partial<Record<'x' | 'y' | 'z', readonly [number, number]>>>;
|
|
294
|
-
export interface GameMap { readonly tiles?: Tiles; readonly wrap?: MapWrap; readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
|
|
294
|
+
export interface GameMap { readonly physics?: import('./physics.ts').PhysicsMesh; readonly tiles?: Tiles; readonly wrap?: MapWrap; readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
|
|
295
295
|
|
|
296
296
|
const vecOf = (a: unknown, what: string): Vec3 => {
|
|
297
297
|
const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
|
|
@@ -340,7 +340,16 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
|
|
|
340
340
|
}
|
|
341
341
|
const tiled=compileTiles(m.tiles,wrap);
|
|
342
342
|
if(tiled) for(const k of ['x','y'] as const) if(wrap?.[k] && (tiled.tiles.at[k]!==bounds.min[k] || tiled.tiles.at[k]+tiled.tiles.size*(k==='x'?tiled.tiles.width:tiled.tiles.height)!==bounds.max[k])) throw new Error(`${where}: tiles must span each wrapped axis`);
|
|
343
|
+
let physics: import('./physics.ts').PhysicsMesh | undefined;
|
|
344
|
+
if (m.physics !== undefined) {
|
|
345
|
+
const {vertices, indices} = m.physics ?? {};
|
|
346
|
+
if (!Array.isArray(vertices) || !Array.isArray(indices) || !vertices.length || vertices.length % 3 || !indices.length || indices.length % 3 ||
|
|
347
|
+
vertices.some((n: unknown) => typeof n !== 'number' || !Number.isFinite(n) || !Number.isFinite(Math.fround(n))) ||
|
|
348
|
+
indices.some((n: unknown) => !Number.isSafeInteger(n) || (n as number) < 0 || (n as number) >= vertices.length / 3)) throw new Error(`${where}: physics needs a build-time baked triangle mesh`);
|
|
349
|
+
physics = Object.freeze({vertices:Object.freeze([...vertices]),indices:Object.freeze([...indices])});
|
|
350
|
+
}
|
|
343
351
|
const result = Object.freeze({
|
|
352
|
+
...(m.physics ? {physics: physics!} : {}),
|
|
344
353
|
...(tiled?{tiles:tiled.tiles}:{}),
|
|
345
354
|
name, ...(wrap ? {wrap} : {}),
|
|
346
355
|
heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
|
|
@@ -379,7 +388,7 @@ export type FieldList = readonly (readonly [string, Field])[];
|
|
|
379
388
|
export interface KindTable {
|
|
380
389
|
name: string;
|
|
381
390
|
index: number;
|
|
382
|
-
player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; takeover?: string };
|
|
391
|
+
player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string };
|
|
383
392
|
fields: FieldList;
|
|
384
393
|
motion: FieldList;
|
|
385
394
|
input: FieldList;
|
|
@@ -580,10 +589,11 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
580
589
|
if (!p || typeof p !== 'object') throw new Error(`${at}.player is true, or { away, leave }`);
|
|
581
590
|
if (p.away !== undefined && p.away !== 'neutral' && p.away !== 'think') throw new Error(`${at}.player.away is 'neutral' or 'think'`);
|
|
582
591
|
if (p.leave !== undefined && p.leave !== 'despawn' && p.leave !== 'bot') throw new Error(`${at}.player.leave is 'despawn' or 'bot'`);
|
|
592
|
+
if (p.pulses !== undefined && !['drop','hold'].includes(p.pulses)) throw new Error(`${at}.player.pulses is 'drop' or 'hold'`);
|
|
583
593
|
if (p.control !== undefined && !motion.some(([key, field]) => key === p.control && field.t === 'bit')) throw new Error(`${at}.player.control names a bit in motion`);
|
|
584
594
|
if (p.takeover !== undefined && !fields.some(([key, field]) => key === p.takeover && ['fix','i8','i16','i32','u8','u16','u32'].includes(field.t))) throw new Error(`${at}.player.takeover names a numeric field`);
|
|
585
595
|
if (p.control && typeof e.think !== 'function') throw new Error(`${at}.player.control needs think`);
|
|
586
|
-
player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
|
|
596
|
+
player = { ...(p.pulses ? {pulses:p.pulses}:{}), away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
|
|
587
597
|
if ((player.away === 'think' || player.leave === 'bot') && typeof e.think !== 'function') throw new Error(`${at}: away: 'think' and leave: 'bot' need a think(world, self) handler to steer the body`);
|
|
588
598
|
}
|
|
589
599
|
let body: KindTable['body'] = null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { brand, charge, own } from './guard.ts';
|
|
2
|
+
|
|
3
|
+
export interface Substep { readonly dt: number; readonly index: number; readonly count: number; readonly first: boolean; readonly fraction: number }
|
|
4
|
+
export interface StepPose { pos: {x:number;y:number;z:number}; vel: {x:number;y:number;z:number}; heading: {x:number;y:number;z:number}; grounded: boolean }
|
|
5
|
+
const traces = new WeakMap<object, readonly StepPose[]>();
|
|
6
|
+
const running = new WeakSet<object>();
|
|
7
|
+
const scalar = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
8
|
+
const vector = (value: unknown) => ({x:scalar(own(value,'x')),y:scalar(own(value,'y')),z:scalar(own(value,'z'))});
|
|
9
|
+
const pose = (body: object): StepPose => ({pos:vector(own(body,'pos')),vel:vector(own(body,'vel')),heading:vector(own(body,'heading')),grounded:own(body,'grounded') === true});
|
|
10
|
+
|
|
11
|
+
/** Same integration and trace accounting on authority and prediction; no independent quota. */
|
|
12
|
+
export function substeps(tickHz: number) {
|
|
13
|
+
return (body: object, hz: number, step: (context: Substep) => void): void => {
|
|
14
|
+
charge(8);
|
|
15
|
+
const count = typeof hz === 'number' ? hz / tickHz : NaN;
|
|
16
|
+
if (typeof hz !== 'number' || !Number.isSafeInteger(count) || count < 1) throw new RangeError('substeps: hz must be a positive integer multiple of room.tickHz');
|
|
17
|
+
if (!body || typeof body !== 'object' || typeof step !== 'function') throw new TypeError('substeps: expected body, hz, callback');
|
|
18
|
+
if (running.has(body) || traces.has(body)) throw new Error('substeps: integrate a body only once per move');
|
|
19
|
+
// Pay before allocation and callbacks. Each pose copies ten scalars and three vectors.
|
|
20
|
+
charge(64 * (count + 1));
|
|
21
|
+
const samples: StepPose[] = [pose(body)];
|
|
22
|
+
running.add(body);
|
|
23
|
+
traces.delete(body);
|
|
24
|
+
try {
|
|
25
|
+
for (let index = 0; index < count; index++) {
|
|
26
|
+
step(brand(Object.freeze({dt:1/hz,index,count,first:index === 0,fraction:(index+1)/count})));
|
|
27
|
+
samples.push(pose(body));
|
|
28
|
+
}
|
|
29
|
+
traces.set(body, samples);
|
|
30
|
+
} finally { running.delete(body); }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Runtime-only drawing data, never an extra part of a room save or wire frame. */
|
|
35
|
+
export function takeSubsteps(body: object): readonly StepPose[] | undefined {
|
|
36
|
+
const samples = traces.get(body);
|
|
37
|
+
traces.delete(body);
|
|
38
|
+
return samples;
|
|
39
|
+
}
|
package/rules/types.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface MapView { tile(x:number,y:number):number; distance(flow:string,
|
|
|
28
28
|
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
29
29
|
export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
|
|
30
30
|
export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
31
|
+
readonly physics: import('./physics.ts').RulesPhysics;
|
|
31
32
|
readonly tune: ReadonlyState<T>; readonly map: MapView & (Room extends true ? {replace(data:Record<string,unknown>):void} : {}); readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
|
|
32
33
|
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
33
34
|
readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
|
|
@@ -66,7 +67,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
|
|
|
66
67
|
export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
|
|
67
68
|
type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
|
|
68
69
|
export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
|
|
69
|
-
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
|
|
70
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
|
|
70
71
|
tick?: (world: W, self: S) => void;
|
|
71
72
|
think?: (world: W, self: S) => Partial<I>;
|
|
72
73
|
on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
|
|
@@ -76,4 +77,4 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
|
|
|
76
77
|
};
|
|
77
78
|
export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
|
|
78
79
|
export interface MoveCollision<M> extends RayQueries { sweep(body: MoveBody<M>, delta: Point, options?: {ground?: boolean}): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
|
|
79
|
-
export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
|
|
80
|
+
export type MoveContext<T, M> = Clock & { readonly physics: import('./physics.ts').PhysicsMovement; substeps(body: MoveBody<M>, hz: number, step: (context: import('./substeps.ts').Substep) => void): void; readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
|
package/rules/view.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { disposeMoveContext } from './math.ts';
|
|
1
2
|
import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
|
|
2
3
|
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
3
4
|
import { solidAt } from './collision.ts';
|
|
@@ -114,6 +115,8 @@ export interface OpenRoomOptions {
|
|
|
114
115
|
/** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
|
|
115
116
|
export interface PredictContext extends RayQueries {
|
|
116
117
|
readonly tick: number; readonly dt: number;
|
|
118
|
+
/** Read the authoritative snapshot this prediction started from, without advancing time. */
|
|
119
|
+
each(kind: string, fn: (e: Entity) => void): void;
|
|
117
120
|
/** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
|
|
118
121
|
readonly math: typeof math;
|
|
119
122
|
/**
|
|
@@ -147,7 +150,7 @@ export interface Room<R = unknown> extends RayQueries {
|
|
|
147
150
|
get(id: string): Entity | null;
|
|
148
151
|
on(name: string, fn: (e: any) => void): () => void;
|
|
149
152
|
readonly shared: Readonly<Record<string, unknown>>;
|
|
150
|
-
input(sample: Record<string, number | boolean
|
|
153
|
+
input(sample: Record<string, number | boolean>, options?: { at?: number }): void;
|
|
151
154
|
command(name: string, data?: Record<string, unknown>): void;
|
|
152
155
|
readonly round: RoomRound | null;
|
|
153
156
|
readonly roster: RosterRow[];
|
|
@@ -253,22 +256,29 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
253
256
|
let lastSent = '';
|
|
254
257
|
let lastSendAt = 0;
|
|
255
258
|
let entries: { t: number; row: number[] }[] = [];
|
|
259
|
+
let inputSequence = 0;
|
|
260
|
+
let controlChanges: number[][] = [];
|
|
261
|
+
let sentHistory: { t: number; row: number[] }[] = [];
|
|
256
262
|
let hiddenSent = false;
|
|
257
263
|
const history = new Map<number, Mine>();
|
|
258
|
-
let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
|
|
264
|
+
let pending: { seq?: number; applied?: number; t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number>; pulseTicks?: Record<string, number> }[] = [];
|
|
259
265
|
/** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
|
|
260
266
|
let pulseCount = 0;
|
|
267
|
+
const pulseQueue: {name:string; value:number; n:number; t:number}[] = [];
|
|
261
268
|
let pulseNumber = new Map<string, number>();
|
|
262
269
|
let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
263
270
|
/** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
|
|
264
271
|
let serverHeld: Record<string, number> = {};
|
|
265
272
|
let speed = 1;
|
|
266
273
|
let leadAfter = 0;
|
|
274
|
+
let calibratedRtt = 0;
|
|
275
|
+
let latencyCalibrated = false;
|
|
276
|
+
const applications = new Map<number, number>();
|
|
267
277
|
let observedLeadAt = 0;
|
|
268
278
|
let catchTick: number | null = null;
|
|
269
279
|
let catchAt = 0;
|
|
270
280
|
let headingBlend: { from: Vec3; at: number } | null = null;
|
|
271
|
-
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
|
|
281
|
+
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number; end?: number }[] = [];
|
|
272
282
|
const predict = schema.settings.predict;
|
|
273
283
|
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
274
284
|
const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
|
|
@@ -281,6 +291,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
281
291
|
const sum = { x: 0, y: 0, z: 0 };
|
|
282
292
|
let waiting = false;
|
|
283
293
|
for (const o of offsets) {
|
|
294
|
+
o.end ??= o.at + Math.max(1000, predict.blendMs);
|
|
295
|
+
const deadlineFade = Math.max(0, Math.min(1, (o.end - now) / 250));
|
|
284
296
|
if (path) {
|
|
285
297
|
// Loss can make even a small correction longer than the distance travelled in
|
|
286
298
|
// 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
|
|
@@ -294,13 +306,22 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
294
306
|
// A knock may travel faster than normal movement, and catch-up already accelerates it.
|
|
295
307
|
// Fading the offset must not add another fraction of that accelerated speed.
|
|
296
308
|
const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
|
|
297
|
-
const release = settled &&
|
|
298
|
-
|
|
299
|
-
|
|
309
|
+
const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
|
|
310
|
+
let progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
|
|
311
|
+
// Travel on one axis cannot pay for a correction on another. In particular,
|
|
312
|
+
// a jump must not erase a horizontal offset faster than running advances it.
|
|
313
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
314
|
+
const travelled = periodicDelta(o.path[axis], path[axis], map.wrap?.[axis]);
|
|
315
|
+
if (o.delta[axis] * travelled > 0) progress = Math.min(progress, .8 * Math.abs(travelled / o.delta[axis]));
|
|
316
|
+
}
|
|
317
|
+
const nominal = now >= o.end ? 0 : Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
|
|
300
318
|
o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
|
|
301
319
|
}
|
|
302
320
|
o.path = path; o.last = now;
|
|
303
321
|
}
|
|
322
|
+
// Drain any remaining correction over the final quarter second, including
|
|
323
|
+
// queued offsets. Expiring it in one frame would itself create a warp.
|
|
324
|
+
o.fade = Math.min(o.fade, deadlineFade);
|
|
304
325
|
waiting = true;
|
|
305
326
|
sum.x += o.delta.x * o.fade; sum.y += o.delta.y * o.fade; sum.z += o.delta.z * o.fade;
|
|
306
327
|
}
|
|
@@ -336,7 +357,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
336
357
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
337
358
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
338
359
|
const relayRtt = net.stats().rtt ?? 100;
|
|
339
|
-
return net.offline ? 0 :
|
|
360
|
+
return net.offline ? 0 : Math.max(relayRtt, 2 * quantile(hostAges, 0.5));
|
|
340
361
|
}
|
|
341
362
|
function phaseAt(at: number): number {
|
|
342
363
|
let p = phases[0];
|
|
@@ -347,7 +368,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
347
368
|
function steer(now: number): void {
|
|
348
369
|
if (!base || !leads.length) return;
|
|
349
370
|
base = { tick: tickAt(now), at: now };
|
|
350
|
-
|
|
371
|
+
// Input replay absorbs delivery jitter. Keep the physical tick cadence close to
|
|
372
|
+
// wall time: aggressive steering makes fixed-step custom renderers bunch ticks.
|
|
373
|
+
speed = 1 + Math.max(-0.005, Math.min(0.005, (targetLead() - medianLead()) * 0.005));
|
|
351
374
|
if (phases[phases.length - 1]?.rate !== speed) phases.push({ ...base, rate: speed });
|
|
352
375
|
while (phases.length > 2 && phases[1].at < now - Math.max(1000, 2 * authorityRtt() + period)) phases.shift();
|
|
353
376
|
}
|
|
@@ -355,19 +378,41 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
355
378
|
// Recalibrating an input clock is not a placement. Keep the drawn pose while
|
|
356
379
|
// the new prediction catches up, just as for a snapshot reconciliation.
|
|
357
380
|
const shown = reason === 'lead' ? meNow()?.pos : null;
|
|
381
|
+
const continuousTick = reason === 'placement' && base && tickAt(clock()) >= k && tickAt(clock()) - k <= tickHz ? tickAt(clock()) : null;
|
|
382
|
+
if(reason==='placement' || reason==='epoch')pulseQueue.length=0;
|
|
358
383
|
correction.rebaseReason = reason; correction.rebaseError = medianLead() - targetLead();
|
|
359
|
-
const rtt = authorityRtt();
|
|
384
|
+
const rtt = authorityRtt(); calibratedRtt = rtt;
|
|
360
385
|
// Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
|
|
361
386
|
const target = targetLead();
|
|
362
387
|
leads.length = 0;
|
|
363
388
|
leadAfter = clock() + 2 * rtt + period;
|
|
364
|
-
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
365
|
-
stepped = Math.floor(base.tick) - 1;
|
|
366
|
-
entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
389
|
+
base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
|
|
390
|
+
stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
|
|
391
|
+
entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
367
392
|
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
368
393
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
369
394
|
if (mine) {
|
|
395
|
+
// A snapshot is the body at k, not at the clock's future input tick.
|
|
396
|
+
// Project the intervening movement instead of relabelling that old pose;
|
|
397
|
+
// otherwise the next reconciliation suddenly adds a latency's worth of travel.
|
|
398
|
+
const kind = myKind()!;
|
|
399
|
+
const values: Record<string, unknown> = {};
|
|
400
|
+
for (const [name, fd] of kind.input) values[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, sample[name] ?? fd.init, dims);
|
|
401
|
+
held = replayHeld = Object.freeze(values);
|
|
402
|
+
history.set(k, copyMine(mine));
|
|
403
|
+
for (let t = k + 1; !kind.owner && t <= stepped; t++) {
|
|
404
|
+
Object.assign(mine, runMove(kind.name, mine, held, t));
|
|
405
|
+
runPredict(kind.name, mine, mine.fields, held, t, false, false);
|
|
406
|
+
history.set(t, copyMine(mine));
|
|
407
|
+
}
|
|
370
408
|
history.set(stepped, copyMine(mine));
|
|
409
|
+
if (!kind.owner && stepped > k) {
|
|
410
|
+
// The projection used these controls from k+1. Stamp that first input too,
|
|
411
|
+
// so authority can recover the identical post-placement path.
|
|
412
|
+
const row = kind.input.map(([name]) => Number(held[name]));
|
|
413
|
+
entries.push({ t: k + 1, row: [...row, -471, ++inputSequence, 0] });
|
|
414
|
+
pending.push({ seq: inputSequence, t: k + 1, input: held });
|
|
415
|
+
}
|
|
371
416
|
if (shown) offsets = [{ delta: { x: periodicDelta(mine.pos.x,shown.x,map.wrap?.x), y: periodicDelta(mine.pos.y,shown.y,map.wrap?.y), z: periodicDelta(mine.pos.z,shown.z,map.wrap?.z) }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
|
|
372
417
|
}
|
|
373
418
|
}
|
|
@@ -377,7 +422,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
377
422
|
mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
|
|
378
423
|
}
|
|
379
424
|
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
380
|
-
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
|
|
425
|
+
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body & {steps?: readonly import('./substeps.ts').StepPose[]} {
|
|
381
426
|
const fn = moves[kindName];
|
|
382
427
|
if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
|
|
383
428
|
moveTick = t;
|
|
@@ -393,6 +438,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
393
438
|
/* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
|
|
394
439
|
|
|
395
440
|
const predictors = opts.predict ?? {};
|
|
441
|
+
let predicting = false;
|
|
442
|
+
let predictionFrame: Frame | null = null;
|
|
443
|
+
function predictionEach(kind: string, fn: (e: Entity) => void): void {
|
|
444
|
+
for (const u of (predictionFrame ?? latest)?.ents.values() ?? []) if (u.kind === kind) fn(entityOf(u, u.id === mine?.id));
|
|
445
|
+
}
|
|
396
446
|
interface Ghost { id: string; kind: string; tick: number; pos: Vec3; fields: Record<string, unknown>; until: number; match?: (e: Entity) => boolean; /** The server's entity that confirmed it: the ghost is drawn until that one is. */ real?: string }
|
|
397
447
|
const ghosts = new Map<string, Ghost>();
|
|
398
448
|
let ghostSeq = 0;
|
|
@@ -422,7 +472,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
422
472
|
// Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
|
|
423
473
|
// code can be run on it instead of being restated in the view.
|
|
424
474
|
const ctx: PredictContext = brand({
|
|
425
|
-
tick: t, dt: 1 / tickHz, preview, math,
|
|
475
|
+
tick: t, dt: 1 / tickHz, preview, math, each: predictionEach,
|
|
426
476
|
judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
|
|
427
477
|
get viewTick() { return viewTickNow(); },
|
|
428
478
|
ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
|
|
@@ -451,7 +501,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
451
501
|
});
|
|
452
502
|
const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
|
|
453
503
|
for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
|
|
504
|
+
const wasPredicting = predicting, previousFrame = predictionFrame;
|
|
505
|
+
predicting = true; predictionFrame = latest;
|
|
454
506
|
try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
|
|
507
|
+
finally { predicting = wasPredicting; predictionFrame = previousFrame; }
|
|
455
508
|
// What it wrote is held to the declared types, as the server holds its own writes.
|
|
456
509
|
for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
|
|
457
510
|
for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
|
|
@@ -508,6 +561,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
508
561
|
function step(t: number, fresh = true): void {
|
|
509
562
|
const kind = myKind();
|
|
510
563
|
if (!mine || !kind) return;
|
|
564
|
+
if(fresh && pulseQueue.length && pulseQueue[0].t<=t){const p=pulseQueue.shift()!;pressed.set(p.name,p.value);pulseNumber.set(p.name,p.n);}
|
|
511
565
|
const values: number[] = [];
|
|
512
566
|
const input: Record<string, unknown> = {};
|
|
513
567
|
let press = false;
|
|
@@ -525,7 +579,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
525
579
|
}
|
|
526
580
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
527
581
|
const sig = JSON.stringify([values, claim]);
|
|
528
|
-
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
|
|
582
|
+
if (sig !== lastSent || press || controlChanges.length) { entries.push({ t, row: [...values, ...claim, -471, ++inputSequence, controlChanges.length, ...controlChanges.flat()] }); pending.push({ seq: inputSequence, t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
|
|
583
|
+
controlChanges = [];
|
|
529
584
|
history.set(t, copyMine(mine));
|
|
530
585
|
for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
|
|
531
586
|
}
|
|
@@ -537,11 +592,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
537
592
|
function flush(now: number, t: number): void {
|
|
538
593
|
if (!mine) { entries = []; return; }
|
|
539
594
|
if (!entries.length && now - lastSendAt >= 250) keepalive(t);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
595
|
+
const marker = myKind()!.input.length + (myKind()!.owner ? 9 : 0);
|
|
596
|
+
// A newer tick's acknowledgement cannot acknowledge a hole in the history.
|
|
597
|
+
if (!entries.length && !sentHistory.some(e => !applications.has(e.row[marker + 1]))) return;
|
|
598
|
+
const k = Math.min(entries[0]?.t ?? t, t - (t % sendEvery));
|
|
599
|
+
for (const entry of entries) {
|
|
600
|
+
const old = sentHistory.findIndex(e => e.t === entry.t);
|
|
601
|
+
if (old >= 0) sentHistory[old] = entry; else sentHistory.push(entry);
|
|
602
|
+
}
|
|
603
|
+
// Cover an authority round trip and jitter. Missing application receipts measure
|
|
604
|
+
// recovery delay (whether a dropped frame or a TCP stall): widen the history
|
|
605
|
+
// until they return, without allowing an unbounded packet or retained history.
|
|
606
|
+
const missing = sentHistory.find(e => e.t <= (latest?.k ?? 0) && !applications.has(e.row[marker + 1]));
|
|
607
|
+
const recoveryTicks = missing ? t - missing.t + 2 * sendEvery : 0;
|
|
608
|
+
const windowTicks = Math.min(64, Math.max(4, recoveryTicks, Math.ceil((2 * authorityRtt() + 250) / period + targetLead())));
|
|
609
|
+
sentHistory = sentHistory.filter(e => e.t >= t - windowTicks).slice(-64);
|
|
610
|
+
const first = Math.min(k, sentHistory[0]?.t ?? k);
|
|
611
|
+
const rows: StepEntry[] = sentHistory.map((e) => [e.t - first, ...e.row]);
|
|
543
612
|
entries = [];
|
|
544
|
-
if (net.steps(epoch,
|
|
613
|
+
if (net.steps(epoch, first, rows, mine.r)) lastSendAt = now;
|
|
545
614
|
}
|
|
546
615
|
/** The held values as an entry on tick `t`, without stepping the body again. */
|
|
547
616
|
function keepalive(t: number): void {
|
|
@@ -549,12 +618,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
549
618
|
if (!mine || !kind) return;
|
|
550
619
|
const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
551
620
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
552
|
-
entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
621
|
+
entries = [{ t, row: [...values, ...claim, -471, ++inputSequence, controlChanges.length, ...controlChanges.flat()] }]; pending.push({ seq: inputSequence, t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
553
622
|
}
|
|
554
623
|
let pumping = false;
|
|
555
624
|
function pump(): void {
|
|
556
625
|
// A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
|
|
557
|
-
if (closed || pumping) return;
|
|
626
|
+
if (closed || pumping || predicting) return;
|
|
558
627
|
pumping = true;
|
|
559
628
|
try { pumpOnce(); } finally { pumping = false; }
|
|
560
629
|
}
|
|
@@ -588,6 +657,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
588
657
|
}
|
|
589
658
|
|
|
590
659
|
function onSnapshot(s: Snapshot<unknown>): void {
|
|
660
|
+
const wasPumping = pumping;
|
|
661
|
+
pumping = true;
|
|
662
|
+
try { applySnapshot(s); } finally { pumping = wasPumping; }
|
|
663
|
+
}
|
|
664
|
+
function applySnapshot(s: Snapshot<unknown>): void {
|
|
591
665
|
const f = frameOf(s);
|
|
592
666
|
if (!f) return;
|
|
593
667
|
const before = latest;
|
|
@@ -612,13 +686,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
612
686
|
if (me) {
|
|
613
687
|
const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
|
|
614
688
|
const row = f.rows.find((x) => x[0] === seat);
|
|
689
|
+
if(changedEpoch)applications.clear();
|
|
690
|
+
if(row?.[6]===471){for(let i=7;i+1<row.length;i+=2)applications.set(row[i],row[i+1]);while(applications.size>128)applications.delete(applications.keys().next().value!);}
|
|
615
691
|
if (placed || !base) {
|
|
616
|
-
adopt(me); rebase(f.k);
|
|
692
|
+
adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
|
|
617
693
|
if (placed) emit('placed', entityOf(me, true));
|
|
618
694
|
} else if (kindOf.get(me.kind)?.owner) {
|
|
619
695
|
mine!.motion = motionOf(me.kind, me.motion);
|
|
620
696
|
if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
|
|
621
|
-
} else {
|
|
697
|
+
} else if (!pending.some(entry => entry.seq !== undefined && entry.t <= f.k && f.k - entry.t < tickHz && (applications.get(entry.seq) ?? Infinity) > f.k)) {
|
|
698
|
+
// This snapshot predates an input we already predicted. Within the rewind
|
|
699
|
+
// window it is provisional movement truth: wait for its application receipt
|
|
700
|
+
// rather than briefly adopting the path made with the missing control.
|
|
622
701
|
const previous = mine!.pos;
|
|
623
702
|
const shownBody = meNow();
|
|
624
703
|
const shown = shownBody?.pos ?? previous;
|
|
@@ -627,8 +706,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
627
706
|
adopt(me);
|
|
628
707
|
history.set(f.k, copyMine(mine!));
|
|
629
708
|
const ack = row?.[2] ?? 0;
|
|
709
|
+
const scheduled = row?.[6] === 471;
|
|
710
|
+
if (scheduled) for(let i=7;i+1<row.length;i+=2){
|
|
711
|
+
if(row[i]>0){const entry=pending.find(e=>e.seq===row[i]);if(entry)entry.applied=row[i+1];}
|
|
712
|
+
else for(const entry of pending)for(const [name,n] of Object.entries(entry.pulses??{}))if(n===-row[i]){entry.pulseTicks??={};entry.pulseTicks[name]=row[i+1];}
|
|
713
|
+
}
|
|
714
|
+
const appliedAt = (entry: (typeof pending)[number]): number => entry.applied ?? entry.t;
|
|
630
715
|
// A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
|
|
631
716
|
// can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
|
|
717
|
+
while(pulseQueue.length && pulseQueue[0].n <= (row?.[4] ?? 0))pulseQueue.shift();
|
|
632
718
|
const taken = typeof row?.[4] === 'number' ? row[4] : null;
|
|
633
719
|
const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
|
|
634
720
|
const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
|
|
@@ -638,7 +724,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
638
724
|
// predicted where the server will bring it and not where a late frame left it for a moment.
|
|
639
725
|
const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
|
|
640
726
|
const debt: Record<string, number> = {};
|
|
641
|
-
if (heldFields.length) {
|
|
727
|
+
if (heldFields.length && !scheduled) {
|
|
642
728
|
for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
|
|
643
729
|
const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
|
|
644
730
|
if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
|
|
@@ -647,9 +733,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
647
733
|
if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
|
|
648
734
|
}
|
|
649
735
|
}
|
|
650
|
-
for (const entry of pending) if (entry
|
|
736
|
+
for (const entry of pending) if (appliedAt(entry) <= f.k) {
|
|
651
737
|
replayHeld = entry.input;
|
|
652
|
-
if (fresh(entry))
|
|
738
|
+
if ((!scheduled || entry.applied === undefined) && fresh(entry))
|
|
653
739
|
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t === 'pulse' ? owed(entry, name) : entry.t > ack)) late.set(name, entry.input[name]);
|
|
654
740
|
}
|
|
655
741
|
// After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
|
|
@@ -658,9 +744,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
658
744
|
if (!Object.keys(input).length) { const zero: Record<string, unknown> = {}; for (const [name, fd] of myKind()!.input) zero[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, fd.init, dims); input = Object.freeze(zero); }
|
|
659
745
|
for (let t = f.k + 1; t <= stepped; t++) {
|
|
660
746
|
const presses = new Map(t === f.k + 1 ? late : []);
|
|
661
|
-
for (const entry of pending)
|
|
662
|
-
input = entry.input;
|
|
663
|
-
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) presses.set(name, entry.input[name]);
|
|
747
|
+
for (const entry of pending) {
|
|
748
|
+
if(appliedAt(entry)===t)input = entry.input;
|
|
749
|
+
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t==='pulse' ? (entry.pulseTicks?.[name]??entry.t)===t : appliedAt(entry)===t)) presses.set(name, entry.input[name]);
|
|
664
750
|
}
|
|
665
751
|
const stepInput = { ...input };
|
|
666
752
|
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
|
|
@@ -669,7 +755,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
669
755
|
runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
|
|
670
756
|
history.set(t, copyMine(mine!));
|
|
671
757
|
}
|
|
672
|
-
pending = pending.filter(e => e
|
|
758
|
+
pending = pending.filter(e => appliedAt(e) > f.k || (scheduled && ((e.applied === undefined && f.k-e.t<tickHz) || Object.keys(e.pulses??{}).some(name=>owed(e,name)))) || !scheduled && fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
|
|
673
759
|
if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
|
|
674
760
|
const error = distance(previous, mine!.pos);
|
|
675
761
|
correction.last = error;
|
|
@@ -708,6 +794,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
708
794
|
}
|
|
709
795
|
}
|
|
710
796
|
}
|
|
797
|
+
if (!latencyCalibrated && authorityRtt() > calibratedRtt + period && !Object.values(sample).some(Boolean) && !pending.some(e=>Object.values(e.input).some(Boolean))) { rebase(f.k, 'lead'); latencyCalibrated = true; }
|
|
711
798
|
if (row && row[3] !== -128 && base && clock() >= leadAfter) {
|
|
712
799
|
const now = clock(), rtt = authorityRtt();
|
|
713
800
|
// A reported lead describes an input sent roughly one authority round trip ago. Remove that
|
|
@@ -716,10 +803,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
716
803
|
observedLeadAt = now;
|
|
717
804
|
leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
|
|
718
805
|
const med = medianLead(), target = targetLead();
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
806
|
+
// A changing jitter estimate must not discard already stamped input. Slew the
|
|
807
|
+
// clock; late movement is settled by the server's retained history.
|
|
808
|
+
steer(now);
|
|
723
809
|
}
|
|
724
810
|
previewKey = '';
|
|
725
811
|
settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
|
|
@@ -810,6 +896,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
810
896
|
* drawn frame, not on the next tick.
|
|
811
897
|
*/
|
|
812
898
|
function meNow(): Entity | null {
|
|
899
|
+
if (predicting) { const u = mine && predictionFrame?.ents.get(mine.id); return u ? entityOf(u, true) : null; }
|
|
813
900
|
if (solo) return solo.me;
|
|
814
901
|
if (!mine || !latest) return null;
|
|
815
902
|
const server = latest.ents.get(mine.id);
|
|
@@ -827,7 +914,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
827
914
|
// A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
|
|
828
915
|
// the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
|
|
829
916
|
// and stops dead at a release, with nothing to blend away afterwards.
|
|
830
|
-
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
917
|
+
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
831
918
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
832
919
|
// A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
|
|
833
920
|
// read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
|
|
@@ -842,6 +929,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
842
929
|
velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
|
|
843
930
|
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
844
931
|
grounded = landed || mine.grounded && (a === 0 || next.grounded);
|
|
932
|
+
if (next.steps?.length) {
|
|
933
|
+
const n = next.steps.length - 1, at = a * n, index = Math.min(n - 1, Math.floor(at)), f = at - index;
|
|
934
|
+
const before = next.steps[index], after = next.steps[index + 1];
|
|
935
|
+
pos = vec3(blendPosition(before.pos, after.pos, f, map.wrap), dims);
|
|
936
|
+
heading = blendHeading(before.heading, after.heading, f, dims);
|
|
937
|
+
velocity = vec3({x:lerp(before.vel.x,after.vel.x,f),y:lerp(before.vel.y,after.vel.y,f),z:lerp(before.vel.z,after.vel.z,f)},dims);
|
|
938
|
+
grounded = f === 1 ? after.grounded : before.grounded && after.grounded;
|
|
939
|
+
}
|
|
845
940
|
preview = next;
|
|
846
941
|
if (predictors[kind.name]) {
|
|
847
942
|
// The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
|
|
@@ -946,7 +1041,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
946
1041
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
947
1042
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
948
1043
|
movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
|
|
949
|
-
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
|
|
1044
|
+
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length, applications: [...applications] }),
|
|
950
1045
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
951
1046
|
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
|
|
952
1047
|
});
|
|
@@ -1013,8 +1108,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1013
1108
|
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
1014
1109
|
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
1015
1110
|
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
1016
|
-
input(s) {
|
|
1017
|
-
|
|
1111
|
+
input(s, options) {
|
|
1112
|
+
pump();
|
|
1113
|
+
if (solo) { solo.input(s, options); return; }
|
|
1018
1114
|
const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
|
|
1019
1115
|
const next: Record<string, unknown> = {};
|
|
1020
1116
|
let changed = false;
|
|
@@ -1026,7 +1122,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1026
1122
|
// a jump). Keep the pose at the instant of the press; only subsequent time
|
|
1027
1123
|
// may advance the new path, rather than drawing elapsed time with new input.
|
|
1028
1124
|
// A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
|
|
1029
|
-
const
|
|
1125
|
+
const now = clock();
|
|
1126
|
+
const at = typeof options?.at === 'number' && Number.isFinite(options.at) ? Math.max(edgeOf(stepped), Math.min(now, options.at)) : now;
|
|
1127
|
+
for (const [index, [name, fd]] of (kind?.input ?? []).entries()) {
|
|
1128
|
+
const value = Number(coerce(fd, s[name] ?? fd.init, dims));
|
|
1129
|
+
if(value !== Number(coerce(fd, sample[name] ?? fd.init, dims)))controlChanges.push([index, ++inputSequence, Math.max(0,Math.min(1,tickAt(at)-stepped)), value]);
|
|
1130
|
+
}
|
|
1131
|
+
controlChanges=controlChanges.slice(-256);
|
|
1030
1132
|
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
|
|
1031
1133
|
const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
|
|
1032
1134
|
if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
|
|
@@ -1034,15 +1136,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1034
1136
|
}
|
|
1035
1137
|
if (changed) pump();
|
|
1036
1138
|
const shown = changed ? meNow()?.pos : null;
|
|
1037
|
-
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'
|
|
1139
|
+
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'){pulseCount+=1;const target=Math.max(stepped+1,(pulseQueue.at(-1)?.t??stepped)+1);pulseQueue.push({name,value:Number(value),n:pulseCount,t:target});net.send('pulse',[epoch,name,value,target,pulseCount]);} else pressed.set(name,value); }
|
|
1038
1140
|
sample = next;
|
|
1039
1141
|
if (shown) {
|
|
1040
1142
|
const now = clock(), nextShown = meNow()?.pos;
|
|
1041
1143
|
if (nextShown && distance(shown, nextShown) > 0.00001) {
|
|
1042
1144
|
const carried = visualOffset(now);
|
|
1043
|
-
//
|
|
1044
|
-
|
|
1045
|
-
|
|
1145
|
+
// A new control must not restart the lifetime of an older correction.
|
|
1146
|
+
const path = { x: nextShown.x-carried.x, y: nextShown.y-carried.y, z: nextShown.z-carried.z };
|
|
1147
|
+
for(const offset of offsets){offset.path=path;offset.last=now;}
|
|
1148
|
+
const delta={x:periodicDelta(nextShown.x,shown.x,map.wrap?.x),y:periodicDelta(nextShown.y,shown.y,map.wrap?.y),z:periodicDelta(nextShown.z,shown.z,map.wrap?.z)};
|
|
1149
|
+
const last=offsets.at(-1);
|
|
1150
|
+
if(last?.at===now && last.fade===1){last.delta={x:last.delta.x+delta.x,y:last.delta.y+delta.y,z:last.delta.z+delta.z};}
|
|
1151
|
+
else offsets.push({delta,at:now,last:now,fade:1,path});
|
|
1046
1152
|
}
|
|
1047
1153
|
}
|
|
1048
1154
|
},
|
|
@@ -1055,6 +1161,6 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1055
1161
|
askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
1056
1162
|
tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
|
|
1057
1163
|
pump,
|
|
1058
|
-
close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
1164
|
+
close() { stopSolo(); disposeMoveContext(moveCtx as object); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
1059
1165
|
};
|
|
1060
1166
|
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.48.1","sha256":"01670eff45e75c7fca0bee9df359442e097aa916cc837e2d8b772a452a105dea"});
|