@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/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>): void;
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 && !predict.idleHold && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
298
- const progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
299
- const nominal = Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
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 : schema.settings.host === 'browser' ? Math.max(relayRtt, 2 * quantile(hostAges, 0.5)) : relayRtt;
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
- speed = 1 + Math.max(-0.05, Math.min(0.05, (targetLead() - medianLead()) * 0.05));
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
- if (!entries.length) return;
541
- const k = Math.min(entries[0].t, t - (t % sendEvery));
542
- const rows: StepEntry[] = entries.map((e) => [e.t - k, ...e.row]);
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, k, rows, mine.r)) lastSendAt = now;
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.t <= f.k) {
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) if (entry.t === t) {
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.t > f.k || fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
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
- if (leads.length >= 8 && Math.abs(med - target) > 4) rebase(f.k, 'lead');
720
- else {
721
- steer(now);
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
- if (solo) { solo.input(s); return; }
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 at = clock();
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'&&!pressed.has(name)){pulseCount+=1;pulseNumber.set(name,pulseCount);net.send('pulse',[epoch,name,value,stepped+1,pulseCount]);} pressed.set(name,value); }
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
- // Coalesce the carried blend: analogue controls may change every frame.
1044
- offsets = [{ delta: { x: carried.x + periodicDelta(nextShown.x,shown.x,map.wrap?.x), y: carried.y + periodicDelta(nextShown.y,shown.y,map.wrap?.y), z: carried.z + periodicDelta(nextShown.z,shown.z,map.wrap?.z) },
1045
- at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
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
  }
@@ -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.47.0","sha256":"4e1a1a503557e68e60b55a05dc289e4e688831c429d18c8f13ad2a24c3e05315"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.48.1","sha256":"01670eff45e75c7fca0bee9df359442e097aa916cc837e2d8b772a452a105dea"});