@homie-rocks/studio 0.45.3 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/guides/game/RULES.md +54 -1
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-types.mjs +1 -1
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +42 -0
- package/netplay/NETPLAY.md +3 -1
- package/package.json +1 -1
- package/rules/core.ts +12 -6
- package/rules/host.ts +100 -11
- package/rules/rules.ts +21 -8
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +152 -16
- package/worker/room-store.mjs +113 -11
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
package/rules/host.ts
CHANGED
|
@@ -56,9 +56,13 @@ export interface HostClock {
|
|
|
56
56
|
setTimer(fn: () => void, ms: number): unknown;
|
|
57
57
|
clearTimer(handle: unknown): void;
|
|
58
58
|
}
|
|
59
|
+
export interface CharacterLease {
|
|
60
|
+
account: string; lease: number; seat: number; character: import('./rules.ts').Character | null; returning?: boolean;
|
|
61
|
+
}
|
|
59
62
|
export interface HostOptions {
|
|
60
63
|
game: string;
|
|
61
64
|
record?(owner:string,key:string,value:Record<string,unknown>):void;
|
|
65
|
+
characterFor?(peer: Record<string, unknown>): CharacterLease | null;
|
|
62
66
|
/** The build check compares a movement step before any tick handler can change it. */
|
|
63
67
|
moved?: NonNullable<Parameters<typeof createCore>[1]>['moved'];
|
|
64
68
|
/** Told of every handler as it ends, and what it threw if it threw (core.ts `observe`): the build check listens. */
|
|
@@ -74,7 +78,7 @@ export interface HostOptions {
|
|
|
74
78
|
send(frame: Record<string, unknown>, text?: string): void;
|
|
75
79
|
clock: HostClock;
|
|
76
80
|
/** Synchronous save: the caller's storage output gate holds this tick's outgoing frames until confirmed. */
|
|
77
|
-
store?: { save(bytes: Uint8Array): void };
|
|
81
|
+
store?: { save(bytes: Uint8Array, commitment?: { force: boolean; seconds: number; tickHz: number; fields: { kinds: number[][]; shared: number[] } }): void };
|
|
78
82
|
/** Table recovery reserves this epoch in storage before starting this host. Ordinary deterministic restores omit it. */
|
|
79
83
|
restoreEpoch?: number;
|
|
80
84
|
startDelayMs?: number;
|
|
@@ -95,6 +99,10 @@ export interface HostOptions {
|
|
|
95
99
|
onEnd?(why: string, facts: Record<string, unknown>): void;
|
|
96
100
|
}
|
|
97
101
|
export interface Host {
|
|
102
|
+
readonly characters: ReadonlyMap<string, CharacterLease>;
|
|
103
|
+
characterReturned(account: string, lease: number): void;
|
|
104
|
+
releaseCharacter(account: string): void;
|
|
105
|
+
reserveCharacter(lease: CharacterLease): void;
|
|
98
106
|
readonly tick: number;
|
|
99
107
|
readonly epoch: number;
|
|
100
108
|
readonly running: boolean;
|
|
@@ -111,7 +119,7 @@ export interface Host {
|
|
|
111
119
|
resume(): void;
|
|
112
120
|
/** Publish the round, the roster, the shared state and the capabilities again (a browser host's relay restarted). */
|
|
113
121
|
announce(): void;
|
|
114
|
-
/** Stop for good: the room has ended. */
|
|
122
|
+
/** Stop for good: the room has ended. A lasting world whose storage is healthy checkpoints first, and that write can throw. */
|
|
115
123
|
stop(): void;
|
|
116
124
|
/** Run the next tick now, whatever the clock says (tests, and the build check's runs). Like the timer's own tick, it never throws. */
|
|
117
125
|
tickNow(): void;
|
|
@@ -164,19 +172,33 @@ function blameLate(now: number, period: number): void {
|
|
|
164
172
|
|
|
165
173
|
export function createHost(o: HostOptions): Host {
|
|
166
174
|
const c = o.compiled;
|
|
175
|
+
const persistent = c.settings.lifetime === 'persistent';
|
|
176
|
+
// Keep even effects and agent requests behind the same commit as their tick.
|
|
177
|
+
// Match rooms retain their existing save frequency and output behaviour.
|
|
178
|
+
const publish = o.send;
|
|
179
|
+
let pendingOutput: [Record<string, unknown>, string | undefined][] | null = null;
|
|
180
|
+
if (persistent) o = { ...o, send: (frame, text) => {
|
|
181
|
+
if (pendingOutput) pendingOutput.push([frame, text]); else publish(frame, text);
|
|
182
|
+
} };
|
|
183
|
+
|
|
167
184
|
const period = 1000 / c.settings.tickHz;
|
|
168
185
|
const tickHz = c.settings.tickHz;
|
|
169
186
|
const lateTicks = Math.ceil(tickHz / 4);
|
|
170
187
|
// A log that throws takes nothing down with it.
|
|
171
188
|
const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
|
|
172
189
|
const random = o.random ?? Math.random;
|
|
173
|
-
const saved = o.restore ? fromBytes(o.restore) as { v: number; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
190
|
+
const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
|
|
174
191
|
if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
|
|
175
192
|
if (saved) {
|
|
176
193
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
|
|
177
194
|
const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
178
|
-
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs'].includes(k)));
|
|
195
|
+
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
|
|
179
196
|
check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
|
|
197
|
+
if (saved.characters !== undefined) {
|
|
198
|
+
check(c.settings.characters && Array.isArray(saved.characters));
|
|
199
|
+
check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
|
|
200
|
+
for (const held of saved.characters) check(typeof held.account === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(held.account) && uint(held.lease) && held.lease > 0 && Number.isInteger(held.seat) && held.seat >= -1 && held.seat < c.seats && (held.returning === undefined || typeof held.returning === 'boolean'));
|
|
201
|
+
}
|
|
180
202
|
if (saved.agents !== undefined) {
|
|
181
203
|
const tables = ['goals', 'asks', 'avoid', 'sayAt', 'viewAt', 'floorAt', 'askAt'];
|
|
182
204
|
check(saved.agents && Object.keys(saved.agents).every(k => tables.includes(k)));
|
|
@@ -236,12 +258,27 @@ export function createHost(o: HostOptions): Host {
|
|
|
236
258
|
}
|
|
237
259
|
if (saved && saved.v !== 1) throw new Error('this save was written by another version of the runtime');
|
|
238
260
|
const measuredRtt = new Map<number, number>();
|
|
239
|
-
const
|
|
261
|
+
const characters = new Map<string, CharacterLease>((saved?.characters ?? []).map(c => [c.account, c.returning ? { ...c, seat: -1 } : c]));
|
|
262
|
+
const characterOptions = c.settings.characters ? {
|
|
263
|
+
characterForSeat: (seat: number) => [...characters.values()].find(c => c.seat === seat && !c.returning)?.character,
|
|
264
|
+
characterSaved: (seat: number, character: import('./rules.ts').Character) => {
|
|
265
|
+
const held = [...characters.values()].find(c => c.seat === seat);
|
|
266
|
+
if (held) {
|
|
267
|
+
held.character = character;
|
|
268
|
+
// Once removed, a delayed return must never follow a new occupant of this seat.
|
|
269
|
+
if (held.returning) held.seat = -1;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
} : {};
|
|
273
|
+
const core = createCore(c, { ...characterOptions, rewindTicks: seat => Math.ceil(((measuredRtt.get(seat) ?? 0) + 150 + period) / period), label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
240
274
|
let epoch = core.epoch;
|
|
241
275
|
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
242
276
|
let lastSaveAt = o.clock.now();
|
|
243
277
|
let saveTick = core.tick;
|
|
244
278
|
let forceSave = false;
|
|
279
|
+
let storeBroken = false;
|
|
280
|
+
/** Which state fields the game declared durable, by kind and for the shared state: what a lasting world journals at once. */
|
|
281
|
+
const durableFields = { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) };
|
|
245
282
|
let saveFailures = 0;
|
|
246
283
|
let retrySaveAt = 0;
|
|
247
284
|
let roundPhase = saved?.core.round[1] ?? 1;
|
|
@@ -459,7 +496,16 @@ export function createHost(o: HostOptions): Host {
|
|
|
459
496
|
// The relay numbers every stay in a seat (`occ`): the same number is the same holder, back.
|
|
460
497
|
const owner = `p${Number.isInteger(p.occ) ? p.occ : typeof p.id === 'string' ? p.id.slice(0, 40) : seat}`;
|
|
461
498
|
const previous = core.bodies().find((s) => s.seat === seat);
|
|
462
|
-
|
|
499
|
+
const grant = driver === 'person' ? o.characterFor?.(p as Record<string, unknown>) : null;
|
|
500
|
+
if (c.settings.characters && driver === 'person' && !grant && o.characterFor) throw new Error('Character lease required');
|
|
501
|
+
let character = grant?.character;
|
|
502
|
+
if (grant) {
|
|
503
|
+
const held = characters.get(grant.account);
|
|
504
|
+
if (held && (held.lease !== grant.lease || held.returning || held.seat >= 0 && held.seat !== seat)) throw new Error('Character return is still pending');
|
|
505
|
+
character = held ? core.character(held.seat) ?? held.character : grant.character;
|
|
506
|
+
characters.set(grant.account, { ...grant, character: character ?? null, seat });
|
|
507
|
+
}
|
|
508
|
+
core.seatJoin({ seat, driver, owner, ...(grant ? { character } : {}) });
|
|
463
509
|
core.seatAway(seat, false);
|
|
464
510
|
if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
|
|
465
511
|
rosterText = '';
|
|
@@ -471,7 +517,13 @@ export function createHost(o: HostOptions): Host {
|
|
|
471
517
|
for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
|
|
472
518
|
present.delete(m.seat as number);
|
|
473
519
|
queues.delete(m.seat as number);
|
|
474
|
-
|
|
520
|
+
const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
|
|
521
|
+
if (held) {
|
|
522
|
+
held.character = core.character(held.seat) ?? held.character;
|
|
523
|
+
held.returning = true;
|
|
524
|
+
core.seatLeave(held.seat);
|
|
525
|
+
tick(o.clock.now());
|
|
526
|
+
} else core.seatAway(m.seat as number, true);
|
|
475
527
|
if (running && people() === 0) pause();
|
|
476
528
|
return;
|
|
477
529
|
}
|
|
@@ -481,7 +533,10 @@ export function createHost(o: HostOptions): Host {
|
|
|
481
533
|
for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
|
|
482
534
|
present.delete(m.seat as number);
|
|
483
535
|
queues.delete(m.seat as number);
|
|
536
|
+
const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
|
|
537
|
+
if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
|
|
484
538
|
core.seatLeave(m.seat as number);
|
|
539
|
+
if (held) tick(o.clock.now());
|
|
485
540
|
rosterText = '';
|
|
486
541
|
return;
|
|
487
542
|
}
|
|
@@ -634,7 +689,17 @@ export function createHost(o: HostOptions): Host {
|
|
|
634
689
|
function tick(now: number): void {
|
|
635
690
|
const cut = core.stats.ticksCut;
|
|
636
691
|
let faulted = false;
|
|
637
|
-
|
|
692
|
+
if (persistent) pendingOutput = [];
|
|
693
|
+
try {
|
|
694
|
+
tickOnce();
|
|
695
|
+
const committed = pendingOutput; pendingOutput = null;
|
|
696
|
+
if (committed && !ended) for (const [frame, text] of committed) publish(frame, text);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
pendingOutput = null;
|
|
699
|
+
faulted = true; fault(`tick ${core.tick} (${phase})`, error);
|
|
700
|
+
// Never continue from uncommitted memory or retire the last good save.
|
|
701
|
+
if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); return; }
|
|
702
|
+
}
|
|
638
703
|
if (ended) return;
|
|
639
704
|
if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
|
|
640
705
|
if (failingSince === null) failingSince = now;
|
|
@@ -730,7 +795,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
730
795
|
// A pending timer would keep the object in memory and billed: the pause clears it.
|
|
731
796
|
disarm();
|
|
732
797
|
log({ ev: 'host-pause', game: o.game, tick: core.tick });
|
|
733
|
-
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); }
|
|
798
|
+
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
|
|
734
799
|
}
|
|
735
800
|
function resume(): void {
|
|
736
801
|
if (running || ended || !paused) return;
|
|
@@ -772,9 +837,20 @@ export function createHost(o: HostOptions): Host {
|
|
|
772
837
|
}
|
|
773
838
|
|
|
774
839
|
function save(): Uint8Array {
|
|
775
|
-
return toBytes({ v: 1, core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !core.bodies().some(b => b.seat === seat && b.owner === 'reserved')), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
840
|
+
return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !core.bodies().some(b => b.seat === seat && b.owner === 'reserved')), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
|
|
776
841
|
}
|
|
777
842
|
function checkpoint(force = false): void {
|
|
843
|
+
if (persistent) {
|
|
844
|
+
// An empty match break or an unjoined reservation has no body-removal callback.
|
|
845
|
+
for (const held of characters.values()) if (held.returning) held.seat = -1;
|
|
846
|
+
if (!o.store) return; // Local checks/offline simulation have no durable account.
|
|
847
|
+
// A write that threw leaves memory ahead of storage: nothing later may checkpoint it over the committed world.
|
|
848
|
+
storeBroken = true;
|
|
849
|
+
o.store.save(save(), { force, seconds: c.settings.durability.movementSeconds, tickHz, fields: durableFields });
|
|
850
|
+
storeBroken = false;
|
|
851
|
+
saveTick = core.tick; lastSaveAt = o.clock.now(); forceSave = false;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
778
854
|
const seconds = c.settings.durability.movementSeconds;
|
|
779
855
|
// Catch-up ticks can cover a second of play in less than a second of wall time. Bound both clocks.
|
|
780
856
|
if (!o.store || !force && (core.tick === saveTick || o.clock.now() - lastSaveAt < seconds * 1000 && core.tick - saveTick < seconds * tickHz)) return;
|
|
@@ -800,6 +876,17 @@ export function createHost(o: HostOptions): Host {
|
|
|
800
876
|
return [...row.slice(0,7),fields,...row.slice(8)];
|
|
801
877
|
}),...snap.d.slice(2)]};
|
|
802
878
|
},
|
|
879
|
+
get characters() { return characters; },
|
|
880
|
+
reserveCharacter(lease) { if (!characters.has(lease.account)) characters.set(lease.account, { ...lease }); },
|
|
881
|
+
releaseCharacter(account) {
|
|
882
|
+
const held = characters.get(account);
|
|
883
|
+
if (!held || held.returning || ended) return;
|
|
884
|
+
held.character = core.character(held.seat) ?? held.character; held.returning = true;
|
|
885
|
+
present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
|
|
886
|
+
if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
|
|
887
|
+
if (running && people() === 0) pause();
|
|
888
|
+
},
|
|
889
|
+
characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
|
|
803
890
|
get viewSettings() { return c.settings.view; },
|
|
804
891
|
get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
|
|
805
892
|
get tick() { return core.tick; },
|
|
@@ -807,7 +894,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
807
894
|
get running() { return running; },
|
|
808
895
|
get paused() { return paused; },
|
|
809
896
|
get people() { return people(); },
|
|
810
|
-
frame, start, pause, resume,
|
|
897
|
+
frame, start, pause, resume, announce,
|
|
898
|
+
// An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
|
|
899
|
+
stop() { try { if (persistent && !ended && !storeBroken) checkpoint(true); } finally { stop(); } },
|
|
811
900
|
tickNow: () => {
|
|
812
901
|
if (ended) return;
|
|
813
902
|
let now = lastStart;
|
package/rules/rules.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface Field {
|
|
|
61
61
|
readonly init?: unknown;
|
|
62
62
|
/** On one integer field of a player's body: this is the score a round's results rank by. */
|
|
63
63
|
readonly score?: true;
|
|
64
|
+
readonly durable?: boolean;
|
|
64
65
|
readonly visibility?: 'owner' | 'server' | 'results';
|
|
65
66
|
/** `text`: the most characters. `list` and `map`: the most entries. */
|
|
66
67
|
readonly max?: number;
|
|
@@ -69,11 +70,11 @@ export interface Field {
|
|
|
69
70
|
/** `struct`: its fields, in order. */
|
|
70
71
|
readonly fields?: Readonly<Record<string, Field>>;
|
|
71
72
|
}
|
|
72
|
-
export interface FieldOptions { init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
|
|
73
|
+
export interface FieldOptions { durable?: boolean; init?: unknown; score?: boolean; visibility?: 'owner' | 'server' | 'results' }
|
|
73
74
|
export type Fields = Readonly<Record<string, Field>>;
|
|
74
75
|
|
|
75
76
|
const made = (t: FieldKind, o: FieldOptions | undefined, extra: Partial<Field> = {}): Field =>
|
|
76
|
-
Object.freeze({ t, ...extra, ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
|
|
77
|
+
Object.freeze({ t, ...extra, ...(o?.durable !== undefined ? {durable:o.durable} : {}), ...(o?.visibility ? {visibility:o.visibility} : {}), ...(o && o.init !== undefined ? { init: o.init } : {}), ...(o && o.score === true ? { score: true as const } : {}) });
|
|
77
78
|
const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
|
|
78
79
|
|
|
79
80
|
/**
|
|
@@ -167,7 +168,7 @@ export interface RulesDef {
|
|
|
167
168
|
records?: RoundRecords;
|
|
168
169
|
historySeconds?: number;
|
|
169
170
|
start?: RoomHandler;
|
|
170
|
-
join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
|
|
171
|
+
join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
|
|
171
172
|
on?: Record<string, RoomHandler>;
|
|
172
173
|
};
|
|
173
174
|
asks?: Record<string, AskDef>;
|
|
@@ -189,9 +190,14 @@ export function defineMove<M extends Record<string, MoveFn>>(def: M): M {
|
|
|
189
190
|
|
|
190
191
|
/* ------------------------------------------------------------------ settings (game.json "room") */
|
|
191
192
|
|
|
193
|
+
export interface Character { kind: string; at: Vec3; heading: Vec3; fields: Record<string, unknown>; motion: Record<string, unknown> }
|
|
194
|
+
|
|
192
195
|
export interface RoomSettings {
|
|
193
196
|
host: 'server' | 'browser';
|
|
194
197
|
offline: boolean;
|
|
198
|
+
/** Persistent rooms retain their committed world until explicitly replaced. */
|
|
199
|
+
lifetime?: 'match' | 'persistent';
|
|
200
|
+
characters?: boolean;
|
|
195
201
|
tickHz: number;
|
|
196
202
|
inputHz: number;
|
|
197
203
|
/** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
|
|
@@ -232,12 +238,16 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
232
238
|
if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
|
|
233
239
|
return n;
|
|
234
240
|
};
|
|
235
|
-
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
|
|
241
|
+
const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters']);
|
|
236
242
|
for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
|
|
237
243
|
let host: 'server' | 'browser' = 'server';
|
|
238
244
|
if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
|
|
239
|
-
|
|
240
|
-
if (r.
|
|
245
|
+
if (r.lifetime !== undefined && !['match', 'persistent'].includes(r.lifetime)) problems.push('"room.lifetime" is "match" or "persistent"');
|
|
246
|
+
if (r.lifetime === 'persistent' && host !== 'server') problems.push('"room.lifetime: persistent" needs room.host "server"');
|
|
247
|
+
if (r.characters !== undefined && typeof r.characters !== 'boolean') problems.push('"room.characters" is true or false');
|
|
248
|
+
if (r.characters === true && r.lifetime !== 'persistent') problems.push('"room.characters" needs room.lifetime "persistent"');
|
|
249
|
+
let offline = r.lifetime !== 'persistent';
|
|
250
|
+
if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push(`"room.offline" is true or false; using ${offline}`); }
|
|
241
251
|
const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
|
|
242
252
|
const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
|
|
243
253
|
if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
|
|
@@ -248,9 +258,9 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
|
|
|
248
258
|
const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
|
|
249
259
|
return {
|
|
250
260
|
settings: {
|
|
251
|
-
host, offline, tickHz, inputHz,
|
|
261
|
+
host, offline, tickHz, inputHz, ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
|
|
252
262
|
view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
|
|
253
|
-
durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
|
|
263
|
+
durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
|
|
254
264
|
budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
|
|
255
265
|
predict: { ...(p.idleHold === true ? {idleHold:true} : {}), catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
|
|
256
266
|
},
|
|
@@ -403,6 +413,7 @@ function cleanField(fd: unknown, where: string, inInput: boolean): Field {
|
|
|
403
413
|
const init = own(x, 'init');
|
|
404
414
|
return Object.freeze({
|
|
405
415
|
t: x.t,
|
|
416
|
+
...(x.durable ? {durable:true} : {}),
|
|
406
417
|
...(x.visibility ? {visibility:x.visibility} : {}),
|
|
407
418
|
...(x.t === 'text' || x.t === 'list' || x.t === 'map' ? { max: x.max } : {}),
|
|
408
419
|
...(x.t === 'list' || x.t === 'map' ? { of: cleanField(x.of, `${where} (its entries)`, false) } : {}),
|
|
@@ -416,6 +427,7 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
|
|
|
416
427
|
if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
|
|
417
428
|
if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
|
|
418
429
|
if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.bit() or f.press()`);
|
|
430
|
+
if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
|
|
419
431
|
if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
|
|
420
432
|
if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
|
|
421
433
|
if (x.t === 'text' && !(Number.isInteger(x.max) && (x.max as number) >= 1 && (x.max as number) <= 4096)) throw new Error(`${where}: f.text(max) needs its largest length, 1 to 4096 characters`);
|
|
@@ -489,6 +501,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
489
501
|
const dims = d.space?.dims;
|
|
490
502
|
if (dims !== 2 && dims !== 3) throw new Error('rules name their space: space: { dims: 2 } or { dims: 3 }');
|
|
491
503
|
const settings = env.settings ?? ROOM_DEFAULTS;
|
|
504
|
+
if (settings.characters && Object.values(d.entities ?? {}).some(e => typeof e.player === 'object' && e.player.leave === 'bot')) throw new Error('Account characters return on leaving; player.leave must be despawn');
|
|
492
505
|
const map = env.map ?? compileMap({ bounds: { min: [-32, -32], max: [32, 32] } });
|
|
493
506
|
if (dims === 2 && (map.spheres.length || map.capsules.length || map.heightTiles.length)) throw new Error('spheres, capsules and height tiles need space.dims: 3');
|
|
494
507
|
if (dims === 3 && map.bounds.max.z <= map.bounds.min.z) throw new Error('a 3D map needs bounds with positive height');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Characters belong to existing player account IDs. Only trusted room code calls this store.
|
|
2
|
+
* A lease never expires: a delayed room cannot spend a second copy after a timeout.
|
|
3
|
+
* The existing Table namespace supplies per-account objects; no new database or hand-entered ID.
|
|
4
|
+
*/
|
|
5
|
+
export function characterStore(storage) {
|
|
6
|
+
const sql = storage.sql;
|
|
7
|
+
sql.exec('CREATE TABLE IF NOT EXISTS characters (game TEXT PRIMARY KEY, data TEXT NOT NULL)');
|
|
8
|
+
const read = game => JSON.parse(sql.exec('SELECT data FROM characters WHERE game = ?', game).toArray()[0]?.data ?? 'null');
|
|
9
|
+
const write = (game, value) => sql.exec('INSERT INTO characters(game,data) VALUES (?,?) ON CONFLICT(game) DO UPDATE SET data=excluded.data', game, JSON.stringify(value));
|
|
10
|
+
return {
|
|
11
|
+
read,
|
|
12
|
+
acquire(game, holder, schema) {
|
|
13
|
+
return storage.transactionSync(() => {
|
|
14
|
+
const row = read(game) ?? { lease: 0, holder: null, character: null, schema };
|
|
15
|
+
if (row.schema !== schema) return { ok: false, error: 'character-schema-changed' };
|
|
16
|
+
if (row.holder && row.holder !== holder) return { ok: false, error: 'character-held', holder: row.holder };
|
|
17
|
+
if (!row.holder) { row.holder = holder; row.lease += 1; write(game, row); }
|
|
18
|
+
return { ok: true, ...row };
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
return(game, holder, lease, character) {
|
|
22
|
+
return storage.transactionSync(() => {
|
|
23
|
+
const row = read(game);
|
|
24
|
+
if (!row || lease > row.lease) throw new Error('Unknown character lease');
|
|
25
|
+
// Lost acknowledgement or an older room: success means retire that outbox,
|
|
26
|
+
// never apply an old return to a newer lease.
|
|
27
|
+
if (lease < row.lease || !row.holder) return { ok: true };
|
|
28
|
+
if (row.holder !== holder) throw new Error('Wrong character holder');
|
|
29
|
+
write(game, { ...row, holder: null, character });
|
|
30
|
+
return { ok: true };
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function characterRequest(env, account, body) {
|
|
37
|
+
const object = env.TABLE.get(env.TABLE.idFromName(`__player/${account}`));
|
|
38
|
+
const response = await object.fetch('https://player/__character', { method: 'POST', body: JSON.stringify(body) });
|
|
39
|
+
if (!response.ok) throw new Error('Character storage unavailable');
|
|
40
|
+
return response.json();
|
|
41
|
+
}
|
|
@@ -53,3 +53,19 @@ ALTER TABLE customer_offers ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;
|
|
|
53
53
|
CREATE INDEX customer_offers_expiry ON customer_offers(expires_at);
|
|
54
54
|
${FUNCTIONS_MIGRATION.slice(FUNCTIONS_MIGRATION.indexOf('CREATE TRIGGER')).replace(/CREATE TRIGGER (\w+)/g, 'DROP TRIGGER IF EXISTS $1; CREATE TRIGGER $1').replace(/ON (shop_orders|purchase_orders|meta|mcp_audit)\n?WHEN /g, 'ON $1 WHEN ').replace(/WHEN OLD.paid_at/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid') AND OLD.paid_at").replace(/WHEN OLD.status/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.refunded') AND OLD.status").replace(/ON app_records BEGIN/g, "ON app_records WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='record.changed') BEGIN").replace(/WHEN NEW.key/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='payment.refunded') AND NEW.key").replace(/WHEN NEW.outcome/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='tool.called') AND NEW.outcome").replace(/INTO studio_events(?= VALUES|\n SELECT)/g,'INTO studio_events(id,type,data,at)')}
|
|
55
55
|
`;
|
|
56
|
+
|
|
57
|
+
// SQLite may evaluate AND operands in either order. Guard parsing itself: meta also
|
|
58
|
+
// holds plain strings, including the key used to sign player room tickets.
|
|
59
|
+
export const FUNCTIONS_META_MIGRATION_FILE = '0020_function_meta.sql';
|
|
60
|
+
export const FUNCTIONS_META_MIGRATION = ['INSERT', 'UPDATE'].map(op => `
|
|
61
|
+
DROP TRIGGER IF EXISTS money_function_refund_${op.toLowerCase()};
|
|
62
|
+
CREATE TRIGGER money_function_refund_${op.toLowerCase()} AFTER ${op} ON meta
|
|
63
|
+
WHEN CASE WHEN NEW.key LIKE 'shop-money:%:refund:%' AND json_valid(NEW.value)
|
|
64
|
+
THEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='payment.refunded')
|
|
65
|
+
AND json_extract(NEW.value,'$.status')='succeeded'
|
|
66
|
+
AND NOT EXISTS (SELECT 1 FROM studio_events WHERE id='refund:' || json_extract(NEW.value,'$.id'))
|
|
67
|
+
ELSE 0 END BEGIN
|
|
68
|
+
INSERT OR IGNORE INTO studio_events(id,type,data,at)
|
|
69
|
+
SELECT 'refund:' || json_extract(NEW.value,'$.id'),'payment.refunded',
|
|
70
|
+
json_object('refund',json_extract(NEW.value,'$.id'),'payment',json_extract(NEW.value,'$.payment_intent'),'amount',json_extract(NEW.value,'$.amount'),'currency',json_extract(NEW.value,'$.currency')),json_extract(NEW.value,'$.at')*1000;
|
|
71
|
+
END;`).join('\n');
|