@homie-rocks/studio 0.46.6 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/tiles.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { bakeGridFlow } from './nav/Flow.ts';
2
+ import { charge } from './guard.ts';
3
+ import type { GameMap, MapBox, MapWrap, Vec3 } from './rules.ts';
4
+ export interface Tiles {
5
+ readonly width: number;
6
+ readonly height: number;
7
+ readonly size: number;
8
+ readonly at: Vec3;
9
+ readonly solidHeight: number;
10
+ readonly data: readonly number[];
11
+ readonly flows: Readonly<Record<string, readonly number[]>>;
12
+ }
13
+ const fields = new WeakMap<object, Map<string, ReturnType<typeof bakeGridFlow>>>();
14
+ export function compileTiles(raw: any, wrap?: MapWrap): {
15
+ tiles: Tiles;
16
+ boxes: MapBox[];
17
+ } | undefined {
18
+ if (raw === undefined)
19
+ return;
20
+ const { width, height, size = 1, solidHeight = 1 } = raw, n = width * height, at = raw.at ?? [0, 0, 0];
21
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || !Number.isSafeInteger(n) || !Number.isFinite(size) || size <= 0 || !Number.isFinite(solidHeight) || solidHeight <= 0 || !Array.isArray(at) || at.length < 2 || at.length > 3 || at.some((x: unknown) => typeof x !== 'number' || !Number.isFinite(x)) || !Array.isArray(raw.data) || raw.data.length !== n)
22
+ throw new Error('map tiles: positive width, height, size, solidHeight, at and one data value per cell');
23
+ if (!Number.isFinite(at[0] + width * size) || !Number.isFinite(at[1] + height * size) || !Number.isFinite((at[2] ?? 0) + solidHeight))
24
+ throw new Error('map tiles: finite extent required');
25
+ const data = raw.data.map((v: unknown) => { if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 65535)
26
+ throw new Error('map tile values are integers 0..65535'); return v; });
27
+ const origin = { x: at[0], y: at[1], z: at[2] ?? 0 }, boxes: MapBox[] = [];
28
+ for (let y = 0; y < height; y++)
29
+ for (let x = 0; x < width; x++)
30
+ if (data[y * width + x]) {
31
+ const start = x;
32
+ while (x + 1 < width && data[y * width + x + 1])
33
+ x++;
34
+ boxes.push(Object.freeze({ min: Object.freeze({ x: origin.x + start * size, y: origin.y + y * size, z: origin.z }), max: Object.freeze({ x: origin.x + (x + 1) * size, y: origin.y + (y + 1) * size, z: origin.z + solidHeight }) }));
35
+ }
36
+ const flows: Record<string, readonly number[]> = {}, baked = new Map<string, ReturnType<typeof bakeGridFlow>>();
37
+ for (const [name, goals] of Object.entries(raw.flows ?? {})) {
38
+ if (!/^[a-z][A-Za-z0-9_]{0,31}$/.test(name) || !Array.isArray(goals))
39
+ throw new Error('tile flows name lists of goal cell indices');
40
+ flows[name] = Object.freeze([...goals]);
41
+ baked.set(name, bakeGridFlow({ width, height, data, wrapX: Boolean(wrap?.x), wrapY: Boolean(wrap?.y) }, goals, charge));
42
+ }
43
+ const tiles = Object.freeze({ width, height, size, solidHeight, at: Object.freeze(origin), data: Object.freeze(data), flows: Object.freeze(flows) });
44
+ fields.set(tiles, baked);
45
+ return { tiles, boxes };
46
+ }
47
+ export function tileApi(get: () => GameMap) {
48
+ const cell = (x: unknown, y: unknown) => { const t = get().tiles; if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y))
49
+ return -1; const w = get().wrap; if (w?.x)
50
+ x = ((x % t.width) + t.width) % t.width; if (w?.y)
51
+ y = ((y % t.height) + t.height) % t.height; return x < 0 || y < 0 || x >= t.width || y >= t.height ? -1 : y * t.width + x; };
52
+ return {
53
+ tile: (x: unknown, y: unknown) => { charge(4); const id = cell(x, y); return id < 0 ? 1 : get().tiles!.data[id]; },
54
+ distance: (name: unknown, x: unknown, y: unknown) => { charge(4); const id = cell(x, y), f = typeof name === 'string' && get().tiles ? fields.get(get().tiles!)?.get(name) : null; return id < 0 || !f ? -1 : f.distance[id]; },
55
+ flow: (name: unknown, x: unknown, y: unknown) => { charge(8); const id = cell(x, y), t = get().tiles, f = typeof name === 'string' && t ? fields.get(t)?.get(name) : null; if (!t || id < 0 || !f || f.next[id] < 0)
56
+ return undefined; const next = f.next[id], dx = next % t.width - id % t.width, dy = Math.floor(next / t.width) - Math.floor(id / t.width); return Object.freeze({ x: Math.abs(dx) > 1 ? -Math.sign(dx) : dx, y: Math.abs(dy) > 1 ? -Math.sign(dy) : dy, z: 0 }); },
57
+ };
58
+ }
@@ -0,0 +1,22 @@
1
+ /** Periodic coordinates. Half-period ties consistently choose the negative way. */
2
+ export const wrapValue = (value, range) => range ? range[0] + ((value - range[0]) % (range[1] - range[0]) + range[1] - range[0]) % (range[1] - range[0]) : value;
3
+ export const periodicDelta = (a, b, range) => range ? wrapValue(b - a, [-(range[1] - range[0]) / 2, (range[1] - range[0]) / 2]) : b - a;
4
+ export const wrapPoint = (p, wrap) => wrap ? {x: wrapValue(p.x, wrap.x), y: wrapValue(p.y, wrap.y), z: wrapValue(p.z ?? 0, wrap.z)} : p;
5
+ export const relativePoint = (p, at, wrap) => wrap ? {x:at.x+periodicDelta(at.x,p.x,wrap.x),y:at.y+periodicDelta(at.y,p.y,wrap.y),z:(at.z??0)+periodicDelta(at.z??0,p.z??0,wrap.z)} : p;
6
+ export const blendPosition = (a,b,t,wrap) => ({x:wrapValue(a.x+periodicDelta(a.x,b.x,wrap?.x)*t,wrap?.x),y:wrapValue(a.y+periodicDelta(a.y,b.y,wrap?.y)*t,wrap?.y),z:wrapValue(a.z+periodicDelta(a.z,b.z,wrap?.z)*t,wrap?.z)});
7
+ export function boundPosition(p, map, radius, height, dims) {
8
+ const b=map.bounds,w=map.wrap;
9
+ return {x:w?.x?wrapValue(p.x,w.x):Math.max(b.min.x+radius,Math.min(b.max.x-radius,p.x)),y:w?.y?wrapValue(p.y,w.y):Math.max(b.min.y+radius,Math.min(b.max.y-radius,p.y)),z:dims===3?(w?.z?wrapValue(p.z,w.z):Math.max(b.min.z,Math.min(b.max.z-height,p.z))):0};
10
+ }
11
+ /** Stream periodic images intersecting a query's swept bounds; callers meter each image. */
12
+ export function* imageOffsets(bounds, wrap, p, d, radius, height, dims=3) {
13
+ const ranges=['x','y','z'].map(k=>{
14
+ if(!wrap?.[k] || k==='z'&&dims===2)return [0,0,0];
15
+ const period=wrap[k][1]-wrap[k][0];
16
+ const lo=Math.min(p[k],p[k]+d[k])-(k==='z'?0:radius),hi=Math.max(p[k],p[k]+d[k])+(k==='z'?height:radius);
17
+ const first=Math.ceil((lo-bounds.max[k])/period),last=Math.floor((hi-bounds.min[k])/period);
18
+ if(!Number.isSafeInteger(first)||!Number.isSafeInteger(last))throw new RangeError('periodic query exceeds exact integer coordinates');
19
+ return [first,last,period];
20
+ });
21
+ for(let x=ranges[0][0];x<=ranges[0][1];x++)for(let y=ranges[1][0];y<=ranges[1][1];y++)for(let z=ranges[2][0];z<=ranges[2][1];z++)yield {x:x*ranges[0][2],y:y*ranges[1][2],z:z*ranges[2][2]};
22
+ }
package/rules/types.ts CHANGED
@@ -24,11 +24,12 @@ export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Poi
24
24
  export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
25
25
  export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
26
26
  export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
27
- export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
27
+ export interface MapView { tile(x:number,y:number):number; distance(flow:string,x:number,y:number):number; flow(name:string,x:number,y:number):Vec3|undefined; readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
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 tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
31
+ readonly physics: import('./physics.ts').RulesPhysics;
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 } : {});
34
35
  /** Budget units the running handler may still spend (0.46.3). */
@@ -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,5 @@
1
+ import { disposeMoveContext } from './math.ts';
2
+ import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
1
3
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
2
4
  import { solidAt } from './collision.ts';
3
5
  import type { RayQueries, RayOptions } from './types.ts';
@@ -72,7 +74,7 @@ export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
72
74
  }
73
75
 
74
76
  /** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
75
- export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
77
+ export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; wrap?: Partial<Record<'x'|'y'|'z',true>>; tiles?: unknown; spots?: Record<string, unknown[]> } }
76
78
  let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
77
79
  /** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
78
80
  export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
@@ -113,6 +115,8 @@ export interface OpenRoomOptions {
113
115
  /** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
114
116
  export interface PredictContext extends RayQueries {
115
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;
116
120
  /** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
117
121
  readonly math: typeof math;
118
122
  /**
@@ -159,7 +163,7 @@ export interface Room<R = unknown> extends RayQueries {
159
163
  askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
160
164
  readonly tune: Readonly<Record<string, unknown>>;
161
165
  /** The game's static map: its edges, its solid shapes and its named spots, in metres. */
162
- readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly heightTiles: readonly import('./rules.ts').MapHeightTile[]; readonly spheres: readonly { at: Vec3; r: number }[]; readonly capsules: readonly { at: Vec3; r: number; height: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
166
+ readonly map: import('./rules.ts').GameMap;
163
167
  readonly net: Netplay;
164
168
  /** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
165
169
  pump(): void;
@@ -191,6 +195,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
191
195
  const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
192
196
  const map = compileMap(game.map, game.map.name ?? 'main');
193
197
  const spots = map.spots;
198
+ const compiledMaps = new Map<string, typeof map>();
199
+ const mapOf = (data?: import('./map-state.ts').MapRevision | null) => {
200
+ if (!data) return map;
201
+ const key = `${epoch}:${data[0]}`;
202
+ let value = compiledMaps.get(key);
203
+ if (!value) {
204
+ value = compileMap(data[1], map.name);
205
+ compiledMaps.set(key, value);
206
+ // Only replay history needs old compiled indexes; saves carry the compact data.
207
+ if (compiledMaps.size > 64) compiledMaps.delete(compiledMaps.keys().next().value!);
208
+ }
209
+ return value;
210
+ };
194
211
  const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
195
212
 
196
213
  let status: RoomStatus = 'connecting';
@@ -259,7 +276,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
259
276
  const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
260
277
  const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
261
278
  const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
262
- const distance = (a: Vec3, b: Vec3): number => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
279
+ const distance = (a: Vec3, b: Vec3): number => Math.hypot(periodicDelta(b.x,a.x,map.wrap?.x),periodicDelta(b.y,a.y,map.wrap?.y),periodicDelta(b.z,a.z,map.wrap?.z));
263
280
  const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
264
281
  function visualOffset(now: number, path?: Vec3, settled = false): Vec3 {
265
282
  if (predict.blendMs <= 0) { offsets = []; return { x: 0, y: 0, z: 0 }; }
@@ -300,9 +317,21 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
300
317
  let collisionHistory: CollisionRevision[] = [];
301
318
  let cachedCollision: CollisionRevision | undefined;
302
319
  let geometry = map as import('./math.ts').MapShapes;
320
+ const mapAt = (tick:number) => {
321
+ if (!collisionHistory.at(-1)?.[3]?.map) return map;
322
+ // Map activation and live collider projection have independent effective ticks.
323
+ // A lost pending snapshot must not postpone a map already active in its successor.
324
+ for (let i=collisionHistory.length-1;i>=0;i--) {
325
+ const metadata=collisionHistory[i][3];
326
+ if (metadata?.map && metadata.map[0]<=tick) return mapOf(metadata.map);
327
+ if (metadata?.previousMap && metadata.previousMap[0]<=tick) return mapOf(metadata.previousMap);
328
+ }
329
+ return map;
330
+ };
303
331
  const geometryAt = () => {
304
332
  const revision = revisionAt(collisionHistory, moveTick);
305
- if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
333
+ const activeMap=mapAt(moveTick);
334
+ if (revision !== cachedCollision || (geometry.staticMap??geometry)!==activeMap) { geometry = collisionMap(activeMap, revision?.[2] ?? []); cachedCollision = revision; }
306
335
  return geometry;
307
336
  };
308
337
  const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, self: () => mine?.id, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ terrain: myKind()?.terrain, shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
@@ -342,7 +371,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
342
371
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
343
372
  if (mine) {
344
373
  history.set(stepped, copyMine(mine));
345
- if (shown) offsets = [{ delta: { x: shown.x - mine.pos.x, y: shown.y - mine.pos.y, z: shown.z - mine.pos.z }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
374
+ 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() }];
346
375
  }
347
376
  }
348
377
  /** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
@@ -351,7 +380,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
351
380
  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 };
352
381
  }
353
382
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
354
- 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 {
383
+ 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[]} {
355
384
  const fn = moves[kindName];
356
385
  if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
357
386
  moveTick = t;
@@ -361,12 +390,17 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
361
390
  const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
362
391
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
363
392
  const height = kind?.height || 2 * r;
364
- out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
393
+ out.pos = vec3(boundPosition(out.pos,map,r,height,dims), dims);
365
394
  return out;
366
395
  }
367
396
  /* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
368
397
 
369
398
  const predictors = opts.predict ?? {};
399
+ let predicting = false;
400
+ let predictionFrame: Frame | null = null;
401
+ function predictionEach(kind: string, fn: (e: Entity) => void): void {
402
+ for (const u of (predictionFrame ?? latest)?.ents.values() ?? []) if (u.kind === kind) fn(entityOf(u, u.id === mine?.id));
403
+ }
370
404
  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 }
371
405
  const ghosts = new Map<string, Ghost>();
372
406
  let ghostSeq = 0;
@@ -396,11 +430,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
396
430
  // Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
397
431
  // code can be run on it instead of being restated in the view.
398
432
  const ctx: PredictContext = brand({
399
- tick: t, dt: 1 / tickHz, preview, math,
433
+ tick: t, dt: 1 / tickHz, preview, math, each: predictionEach,
400
434
  judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
401
435
  get viewTick() { return viewTickNow(); },
402
436
  ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
403
- rays: (options) => raySnapshot(map, viewTargets(atTickOf(options)), dims, options, id),
437
+ rays: (options) => raySnapshot(mapAt(t), viewTargets(atTickOf(options)), dims, options, id),
404
438
  rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
405
439
  ghost(kindOfGhost, at, data = {}, options = {}) {
406
440
  const k = kindOf.get(kindOfGhost);
@@ -425,7 +459,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
425
459
  });
426
460
  const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
427
461
  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 });
462
+ const wasPredicting = predicting, previousFrame = predictionFrame;
463
+ predicting = true; predictionFrame = latest;
428
464
  try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
465
+ finally { predicting = wasPredicting; predictionFrame = previousFrame; }
429
466
  // What it wrote is held to the declared types, as the server holds its own writes.
430
467
  for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
431
468
  for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
@@ -528,7 +565,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
528
565
  let pumping = false;
529
566
  function pump(): void {
530
567
  // A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
531
- if (closed || pumping) return;
568
+ if (closed || pumping || predicting) return;
532
569
  pumping = true;
533
570
  try { pumpOnce(); } finally { pumping = false; }
534
571
  }
@@ -562,6 +599,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
562
599
  }
563
600
 
564
601
  function onSnapshot(s: Snapshot<unknown>): void {
602
+ const wasPumping = pumping;
603
+ pumping = true;
604
+ try { applySnapshot(s); } finally { pumping = wasPumping; }
605
+ }
606
+ function applySnapshot(s: Snapshot<unknown>): void {
565
607
  const f = frameOf(s);
566
608
  if (!f) return;
567
609
  const before = latest;
@@ -665,7 +707,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
665
707
  // Limit the new reconciliation, not an offset already being blended (which
666
708
  // can include a clock recalibration). Clipping that carried offset would
667
709
  // turn a small new error into a visible snap.
668
- const change = { x: shown.x - corrected.x - oldOffset.x, y: shown.y - corrected.y - oldOffset.y, z: shown.z - corrected.z - oldOffset.z };
710
+ const change = { x: periodicDelta(corrected.x,shown.x,map.wrap?.x) - oldOffset.x, y: periodicDelta(corrected.y,shown.y,map.wrap?.y) - oldOffset.y, z: periodicDelta(corrected.z,shown.z,map.wrap?.z) - oldOffset.z };
669
711
  const gap = Math.hypot(change.x, change.y, change.z);
670
712
  const ahead = Math.max(0, tickAt(clock()) - f.k) / tickHz;
671
713
  const cap = predict.snapM ?? myKind()!.maxSpeed * (2 * ahead + 0.1);
@@ -784,6 +826,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
784
826
  * drawn frame, not on the next tick.
785
827
  */
786
828
  function meNow(): Entity | null {
829
+ if (predicting) { const u = mine && predictionFrame?.ents.get(mine.id); return u ? entityOf(u, true) : null; }
787
830
  if (solo) return solo.me;
788
831
  if (!mine || !latest) return null;
789
832
  const server = latest.ents.get(mine.id);
@@ -811,11 +854,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
811
854
  const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
812
855
  av = Math.min(1, a / contact); landed = a >= contact;
813
856
  }
814
- pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, av) }, dims);
857
+ pos = vec3({...blendPosition(mine.pos,next.pos,a,map.wrap), z:wrapPoint({x:0,y:0,z:mine.pos.z+periodicDelta(mine.pos.z,next.pos.z,map.wrap?.z)*av},map.wrap).z}, dims);
815
858
  heading = blendHeading(mine.heading, next.heading, a, dims);
816
859
  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);
817
860
  // A takeoff is airborne as soon as it rises; a landing waits for its contact.
818
861
  grounded = landed || mine.grounded && (a === 0 || next.grounded);
862
+ if (next.steps?.length) {
863
+ const n = next.steps.length - 1, at = a * n, index = Math.min(n - 1, Math.floor(at)), f = at - index;
864
+ const before = next.steps[index], after = next.steps[index + 1];
865
+ pos = vec3(blendPosition(before.pos, after.pos, f, map.wrap), dims);
866
+ heading = blendHeading(before.heading, after.heading, f, dims);
867
+ 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);
868
+ grounded = f === 1 ? after.grounded : before.grounded && after.grounded;
869
+ }
819
870
  preview = next;
820
871
  if (predictors[kind.name]) {
821
872
  // The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
@@ -832,14 +883,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
832
883
  // Catch-up can reach the fractional current tick before its next whole step exists.
833
884
  // Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
834
885
  const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
835
- if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = blendHeading(a.heading, b.heading, f, dims);
886
+ if (a && b) { const f = catchTick - t; pos = blendPosition(a.pos, b.pos, f, map.wrap); heading = blendHeading(a.heading, b.heading, f, dims);
836
887
  velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
837
888
  grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
838
889
  } else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
839
890
  }
840
891
  }
841
892
  const offset = visualOffset(clock(), pos, settled);
842
- pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
893
+ pos = wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap);
843
894
  if (headingBlend) {
844
895
  const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
845
896
  heading = blendHeading(headingBlend.from, heading, t, dims);
@@ -859,7 +910,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
859
910
  if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
860
911
  const t = s.alpha;
861
912
  const h = blendHeading(ea.heading, eb.heading, t, dims);
862
- return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
913
+ return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(blendPosition(ea.pos, eb.pos, t, map.wrap), dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
863
914
  }
864
915
  function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
865
916
  function each(kind: string, fn: (e: Entity) => void): void {
@@ -948,8 +999,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
948
999
  let before: Frame | null = null, after: Frame | null = null;
949
1000
  for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
950
1001
  const a = before?.ents.get(id), b = after?.ents.get(id);
951
- if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3({ x: lerp(a.pos.x, b.pos.x, m), y: lerp(a.pos.y, b.pos.y, m), z: lerp(a.pos.z, b.pos.z, m) }, dims), vel: b.vel, known: true }; }
952
- if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
1002
+ if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3(blendPosition(a.pos, b.pos, m, map.wrap), dims), vel: b.vel, known: true }; }
1003
+ if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3(wrapPoint({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s },map.wrap), dims), vel: a.vel, known: false }; }
953
1004
  return b ? { pos: b.pos, vel: b.vel, known: false } : null;
954
1005
  }
955
1006
  const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
@@ -963,7 +1014,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
963
1014
  }
964
1015
  const viewTargets = (atTick?: number) => {
965
1016
  pump();
966
- const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
1017
+ const targets: QueryTarget[] = collisionTargets(collisionMap(mapAt(latest?.k??0), collisionHistory.at(-1)?.[2] ?? []));
967
1018
  for(const k of schema.kinds) {
968
1019
  if(k.collider || !k.radius)continue;
969
1020
  each(k.name,e=>{
@@ -974,7 +1025,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
974
1025
  }
975
1026
  return targets;
976
1027
  };
977
- const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
1028
+ const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
978
1029
  return {
979
1030
  get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
980
1031
  get status() { return solo ? 'offline' : status; },
@@ -982,7 +1033,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
982
1033
  get me() { pump(); return meNow(); },
983
1034
  each, get,
984
1035
  ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
985
- rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(atTickOf(options)),dims,options,mine?.id),
1036
+ rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
986
1037
  poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
987
1038
  rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
988
1039
  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,7 +1066,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1015
1066
  if (nextShown && distance(shown, nextShown) > 0.00001) {
1016
1067
  const carried = visualOffset(now);
1017
1068
  // Coalesce the carried blend: analogue controls may change every frame.
1018
- offsets = [{ delta: { x: carried.x + shown.x - nextShown.x, y: carried.y + shown.y - nextShown.y, z: carried.z + shown.z - nextShown.z },
1069
+ 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) },
1019
1070
  at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
1020
1071
  }
1021
1072
  }
@@ -1027,8 +1078,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1027
1078
  probe(x) { extra = { ...extra, ...x }; },
1028
1079
  ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
1029
1080
  askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
1030
- tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
1081
+ tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
1031
1082
  pump,
1032
- close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
1083
+ 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'); },
1033
1084
  };
1034
1085
  }
@@ -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.46.6","sha256":"efd3ddbf72033f5d24b8107a41db2bc1a518f1d3f92963f5afd77c5c0e2f5059"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.48.0","sha256":"3c0bdb436a2a600fc529827f340b470cdce1cfa40f7d692ff0b9963b5b07c622"});
package/worker/hosted.mjs CHANGED
@@ -19,6 +19,7 @@ import { compileMap, compileRules } from '../rules/rules.ts';
19
19
  /** game id → { rules, tune, map, settings, seats, build } as the build wrote it, and what compiling each gave. */
20
20
  const games = new Map();
21
21
  const compiled = new Map();
22
+ const readiness = new WeakMap();
22
23
 
23
24
  /** The table of this studio's server-hosted games (the build's site/src/rules/index.mjs). Called once, at load. */
24
25
  export function hostRules(table) {
@@ -33,6 +34,21 @@ export function hostRules(table) {
33
34
  export const hostedGame = (id) => games.has(id);
34
35
  export const hostedBuild = (id) => games.get(id);
35
36
 
37
+ /** Optional engines load at room start. Ordinary games keep their synchronous startup path. */
38
+ export function prepareHost(id) {
39
+ const entry = games.get(id);
40
+ if (!entry?.initialize) return;
41
+ const previous = readiness.get(entry);
42
+ if (previous === true) return;
43
+ if (previous) return previous;
44
+ const pending = Promise.resolve().then(() => entry.initialize()).then(
45
+ () => { readiness.set(entry, true); },
46
+ error => { readiness.delete(entry); throw error; },
47
+ );
48
+ readiness.set(entry, pending);
49
+ return pending;
50
+ }
51
+
36
52
  /** A game's rules, compiled once per isolate. Throws, naming the declaration, when they do not fit the contract. */
37
53
  export function compiledFor(id) {
38
54
  if (compiled.has(id)) return compiled.get(id);
package/worker/index.mjs CHANGED
@@ -89,7 +89,7 @@ import { appRole, openPath } from './app-format.mjs';
89
89
  */
90
90
  import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
91
91
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
92
- import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
92
+ import { hostedGame, hostedBuild, compiledFor, prepareHost, startHost } from './hosted.mjs';
93
93
  import { roomStore, restoreDelay, LOOSE_SAVE } from './room-store.mjs';
94
94
  import { appCors, isAppOrigin } from './standalone.mjs';
95
95
  import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
@@ -1592,13 +1592,13 @@ export class Table {
1592
1592
  this.say({ ev: 'persist-failed', build: hostedBuild(this.game)?.build, error: this.storageHealth.message });
1593
1593
  }
1594
1594
 
1595
- readRoom(validateHost = false) {
1595
+ readRoom(validateHost = false, alreadyRead) {
1596
1596
  if (this.skipSave) { this.skipSave = false; return null; }
1597
1597
  try {
1598
1598
  const boot = this.durable?.boots();
1599
1599
  this.persistentStored = boot?.persistent === true;
1600
1600
  if (boot?.retired === true) { this.clearRoom(); return null; }
1601
- const saved = this.durable?.read();
1601
+ const saved = alreadyRead ?? this.durable?.read();
1602
1602
  if (!saved) return null;
1603
1603
  if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
1604
1604
  this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
@@ -1759,7 +1759,18 @@ export class Table {
1759
1759
  if (!room || !this.hostRt) {
1760
1760
  if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
1761
1761
  this.durable ??= this.openStore(false);
1762
- const saved = this.cleanupPending ? null : this.readRoom(true);
1762
+ let saved = this.cleanupPending ? null : this.readRoom();
1763
+ if (saved) {
1764
+ const generation = this.cleanupGeneration;
1765
+ const preparing = prepareHost(saved.game);
1766
+ if (preparing) {
1767
+ await preparing;
1768
+ // A join or office action can arrive while the optional engine loads.
1769
+ // Re-read that lifecycle instead of writing the pre-await checkpoint.
1770
+ if (this.room !== room || this.hostRt || this.cleanupGeneration !== generation) return this.runRoomAlarm();
1771
+ }
1772
+ saved = this.readRoom(true, saved);
1773
+ }
1763
1774
  if (saved) {
1764
1775
  this.game = saved.game; this.code = saved.room;
1765
1776
  if (this.isPersistent()) return;
@@ -2167,6 +2178,8 @@ export class Table {
2167
2178
  if (url.pathname === '/__recall') {
2168
2179
  const { account } = await request.json();
2169
2180
  return this.withCharacter(account, async () => {
2181
+ const preparing = prepareHost(game);
2182
+ if (preparing) await preparing;
2170
2183
  this.ensureHost(room, true);
2171
2184
  if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
2172
2185
  await this.flushCharacters();
@@ -2232,6 +2245,8 @@ export class Table {
2232
2245
  const said = url.searchParams.has('cur');
2233
2246
  if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
2234
2247
  // Restore before applying the Worker's current policy: a save must not replace a newer policy.
2248
+ const preparing = prepareHost(game);
2249
+ if (preparing) await preparing;
2235
2250
  this.ensureHost(room, url.searchParams.get('host') === 'server');
2236
2251
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
2237
2252
  const pol = decodeFacts(url.searchParams.get('pol'));
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.46.6';
2
+ export const STUDIO_VERSION_TAG = '0.48.0';