@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.
- package/CHANGELOG.md +83 -1
- package/guides/game/REWRITE.md +2 -1
- package/guides/game/RULES.md +207 -0
- package/guides/game/SKILL.md +24 -0
- package/lib/doctor.mjs +8 -6
- package/lib/mcp-tools.mjs +2 -1
- package/lib/physics-mesh.mjs +40 -0
- package/lib/provider-key.mjs +21 -0
- package/lib/rules-build.mjs +18 -10
- package/lib/rules-check-worker.mjs +5 -1
- package/lib/rules-check.mjs +1 -1
- package/lib/rules-guard.mjs +5 -4
- package/lib/template-history.json +84 -0
- package/package.json +11 -2
- package/rules/collision.ts +1 -0
- package/rules/core.ts +94 -34
- package/rules/host.ts +3 -1
- package/rules/interest.mjs +29 -14
- package/rules/live.ts +4 -3
- package/rules/map-index.ts +24 -0
- package/rules/map-state.ts +54 -0
- package/rules/math.ts +18 -6
- package/rules/nav/Flow.ts +53 -0
- package/rules/pack.ts +4 -2
- package/rules/physics-engine.ts +73 -0
- package/rules/physics.ts +26 -0
- package/rules/query.ts +19 -4
- package/rules/rules.ts +31 -4
- package/rules/substeps.ts +39 -0
- package/rules/tiles.ts +58 -0
- package/rules/topology.mjs +22 -0
- package/rules/types.ts +4 -3
- package/rules/view.ts +74 -23
- package/worker/build-id.mjs +1 -1
- package/worker/hosted.mjs +16 -0
- package/worker/index.mjs +19 -4
- package/worker/version.mjs +1 -1
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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({
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.48.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
|
-
|
|
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'));
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.48.0';
|