@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/worker/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { characterStore, characterRequest } from './characters.mjs';
|
|
1
2
|
export { useFunctions } from './functions.mjs';
|
|
2
3
|
import { appAccess, appRecordsRoute } from './app-records.mjs';
|
|
3
4
|
import { appRole, openPath } from './app-format.mjs';
|
|
@@ -88,7 +89,7 @@ import { appRole, openPath } from './app-format.mjs';
|
|
|
88
89
|
*/
|
|
89
90
|
import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
|
|
90
91
|
import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
|
|
91
|
-
import { hostedGame, hostedBuild, startHost } from './hosted.mjs';
|
|
92
|
+
import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
|
|
92
93
|
import { roomStore, restoreDelay } from './room-store.mjs';
|
|
93
94
|
import { appCors, isAppOrigin } from './standalone.mjs';
|
|
94
95
|
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
@@ -1024,6 +1025,7 @@ async function route(request, env, ctx) {
|
|
|
1024
1025
|
srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
|
|
1025
1026
|
}
|
|
1026
1027
|
const d = await pageDoor(srv);
|
|
1028
|
+
if (meta.room?.characters && !screen && !embed && (!d.acct || !(await chatWho(env, game, srv, d.acct)).acct)) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 303);
|
|
1027
1029
|
// The owner hid the public server: Play shows the servers to pick from, and a named room is off (owner aside).
|
|
1028
1030
|
if (srv.id === 'public' && publicHidden() && !d.owner) {
|
|
1029
1031
|
if (named) return doorPage(cat, meta, srv, { why: 'closed' });
|
|
@@ -1112,6 +1114,11 @@ async function route(request, env, ctx) {
|
|
|
1112
1114
|
// Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
|
|
1113
1115
|
const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
|
|
1114
1116
|
if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
|
|
1117
|
+
if (meta.room?.lifetime === 'persistent') {
|
|
1118
|
+
const room = srv.id === 'public' ? 'pub-1' : `s-${srv.id}-1`;
|
|
1119
|
+
if (not.split(',').includes(room)) return json({ ok: false, error: 'world-unavailable', message: 'This world is unavailable. Choose another named world.' }, 409);
|
|
1120
|
+
return json({ room, players: 0, max: humanSeats(pol), server: srv.id });
|
|
1121
|
+
}
|
|
1115
1122
|
return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `¬=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
|
|
1116
1123
|
}
|
|
1117
1124
|
if (sub === 'api/agent') {
|
|
@@ -1318,6 +1325,8 @@ export class Table {
|
|
|
1318
1325
|
* room next needs the object's one alarm (the house guides and the day's counters share it).
|
|
1319
1326
|
*/
|
|
1320
1327
|
this.hostRt = null;
|
|
1328
|
+
this.characterGrants = new Map();
|
|
1329
|
+
this.accountTokens = {};
|
|
1321
1330
|
this.roomAlarmAt = 0;
|
|
1322
1331
|
this.durable = null;
|
|
1323
1332
|
this.roomSave = null;
|
|
@@ -1366,6 +1375,7 @@ export class Table {
|
|
|
1366
1375
|
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
1367
1376
|
// A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
|
|
1368
1377
|
this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
|
|
1378
|
+
if (this.isPersistent()) this.room.forgetMs = Infinity;
|
|
1369
1379
|
return this.room;
|
|
1370
1380
|
}
|
|
1371
1381
|
|
|
@@ -1376,6 +1386,8 @@ export class Table {
|
|
|
1376
1386
|
* elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
|
|
1377
1387
|
* this build, or do not fit the contract, do not start: the room refuses joins and says why.
|
|
1378
1388
|
*/
|
|
1389
|
+
isPersistent() { return this.persistentStored === true || hostedBuild(this.game)?.settings?.lifetime === 'persistent'; }
|
|
1390
|
+
|
|
1379
1391
|
ensureHost(room, hosted) {
|
|
1380
1392
|
if (this.hostRt || !this.game) return;
|
|
1381
1393
|
if (!hostedGame(this.game)) {
|
|
@@ -1386,9 +1398,11 @@ export class Table {
|
|
|
1386
1398
|
let pauseWriteFailed = false;
|
|
1387
1399
|
try {
|
|
1388
1400
|
const build = hostedBuild(this.game);
|
|
1389
|
-
|
|
1401
|
+
const persistent = this.isPersistent();
|
|
1402
|
+
try { this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() }); } catch (error) { if (persistent) throw error; this.storageProblem(error); }
|
|
1390
1403
|
let saved = this.cleanupPending ? null : this.readRoom();
|
|
1391
1404
|
restoring = Boolean(saved);
|
|
1405
|
+
if (this.persistentStored && build.settings.lifetime !== 'persistent') throw new Error('This room holds a lasting world. Keep room.lifetime persistent; its save is retained.');
|
|
1392
1406
|
let boot = { epoch: 0, count: 0 };
|
|
1393
1407
|
try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
|
|
1394
1408
|
if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
|
|
@@ -1402,7 +1416,8 @@ export class Table {
|
|
|
1402
1416
|
const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
|
|
1403
1417
|
const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
|
|
1404
1418
|
const expired = Date.now() >= restoredEnd;
|
|
1405
|
-
if (saved &&
|
|
1419
|
+
if (saved && persistent && saved.stateHash !== build.stateHash) throw new Error('This lasting world needs its previous state schema. Restore the compatible build; the saved world has been retained.');
|
|
1420
|
+
if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
|
|
1406
1421
|
const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
|
|
1407
1422
|
const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
|
|
1408
1423
|
this.finishRoom(why);
|
|
@@ -1412,15 +1427,16 @@ export class Table {
|
|
|
1412
1427
|
this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
|
|
1413
1428
|
}
|
|
1414
1429
|
this.roomSave = saved;
|
|
1430
|
+
this.accountTokens = saved?.accountTokens ?? {};
|
|
1415
1431
|
if (saved) {
|
|
1416
1432
|
// The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
|
|
1417
1433
|
room.seats.clear(); room.state.clear(); room.stateBytes = 0;
|
|
1418
1434
|
room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
|
|
1419
|
-
this.pausedAt = restoredEnd - ROOM_PAUSE_MS;
|
|
1435
|
+
this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
|
|
1420
1436
|
this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
|
|
1421
1437
|
saved.pausedAt = this.pausedAt;
|
|
1422
1438
|
// A failed pause write must never be mistaken for an invalid host save.
|
|
1423
|
-
try { this.durable.write(saved); } catch (error) { pauseWriteFailed = true; this.storageProblem(error); }
|
|
1439
|
+
try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
|
|
1424
1440
|
}
|
|
1425
1441
|
const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
|
|
1426
1442
|
if (saved) this.durable.boot({ epoch, count: boot.count });
|
|
@@ -1429,7 +1445,11 @@ export class Table {
|
|
|
1429
1445
|
this.hostRt = startHost(this.game, {
|
|
1430
1446
|
...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
|
|
1431
1447
|
record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
|
|
1432
|
-
store: { save: (bytes) => this.saveRoom(bytes) },
|
|
1448
|
+
store: { save: (bytes, commitment) => this.saveRoom(bytes, commitment) },
|
|
1449
|
+
...(build.settings.characters ? { characterFor: (peer) => {
|
|
1450
|
+
const account = room.clients.get(peer.id)?.player;
|
|
1451
|
+
return account ? this.characterGrants.get(account) ?? null : null;
|
|
1452
|
+
} } : {}),
|
|
1433
1453
|
onTick: () => {
|
|
1434
1454
|
if (!saved) return;
|
|
1435
1455
|
ticks += 1;
|
|
@@ -1454,7 +1474,7 @@ export class Table {
|
|
|
1454
1474
|
this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
|
|
1455
1475
|
} catch (error) {
|
|
1456
1476
|
this.hostRt?.stop(); this.hostRt = null;
|
|
1457
|
-
if (restoring) {
|
|
1477
|
+
if (restoring && !this.isPersistent()) {
|
|
1458
1478
|
this.storageProblem(error);
|
|
1459
1479
|
room.forget(); this.clearRoom();
|
|
1460
1480
|
this.skipSave = true;
|
|
@@ -1465,19 +1485,35 @@ export class Table {
|
|
|
1465
1485
|
}
|
|
1466
1486
|
}
|
|
1467
1487
|
|
|
1468
|
-
saveRoom(bytes) {
|
|
1488
|
+
saveRoom(bytes, commitment) {
|
|
1489
|
+
if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
|
|
1469
1490
|
const now = Date.now();
|
|
1470
1491
|
if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
|
|
1471
1492
|
try {
|
|
1472
|
-
this.durable ??= roomStore(this.ctx.storage);
|
|
1493
|
+
this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() });
|
|
1473
1494
|
const build = hostedBuild(this.game);
|
|
1474
1495
|
const host = new TextDecoder().decode(bytes);
|
|
1475
|
-
const
|
|
1496
|
+
const net = this.room.saved();
|
|
1497
|
+
if (build.settings.characters) for (const client of this.room.clients.values()) {
|
|
1498
|
+
if (client.player && client.seat !== null && client.token) this.accountTokens[client.player] = client.token;
|
|
1499
|
+
}
|
|
1500
|
+
if (build.settings.characters) {
|
|
1501
|
+
const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
|
|
1502
|
+
for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
|
|
1503
|
+
}
|
|
1504
|
+
const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net };
|
|
1476
1505
|
const clearing = this.cleanupPending;
|
|
1477
1506
|
if (clearing) this.durable.clear();
|
|
1478
|
-
this.
|
|
1507
|
+
if (this.isPersistent()) {
|
|
1508
|
+
const c = compiledFor(this.game);
|
|
1509
|
+
commitment ??= { force: false, seconds: c.settings.durability.movementSeconds, tickHz: c.settings.tickHz,
|
|
1510
|
+
fields: { kinds: c.kinds.map(k => k.fields.flatMap(([,f],i) => f.durable ? [i] : [])), shared: c.shared.flatMap(([,f],i) => f.durable ? [i] : []) } };
|
|
1511
|
+
this.durable.commit(saved, commitment);
|
|
1512
|
+
} else this.durable.write(saved);
|
|
1479
1513
|
if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
|
|
1480
|
-
this.roomSave = saved;
|
|
1514
|
+
this.roomSave = saved;
|
|
1515
|
+
if (build.settings.characters) this.ctx.waitUntil(this.flushCharacters().catch(error => this.storageProblem(error)));
|
|
1516
|
+
this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
|
|
1481
1517
|
this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
|
|
1482
1518
|
clearTimeout(this.saveRetry); this.saveRetry = null;
|
|
1483
1519
|
this.storageHealth = { ok: !this.pendingBoot };
|
|
@@ -1486,7 +1522,8 @@ export class Table {
|
|
|
1486
1522
|
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
|
|
1487
1523
|
this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
|
|
1488
1524
|
this.storageProblem(error);
|
|
1489
|
-
if (
|
|
1525
|
+
if (this.isPersistent()) this.endHosted('storage-failed');
|
|
1526
|
+
if (!this.isPersistent() && !this.saveRetry) {
|
|
1490
1527
|
this.saveRetry = setTimeout(() => {
|
|
1491
1528
|
this.saveRetry = null;
|
|
1492
1529
|
if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
|
|
@@ -1507,7 +1544,9 @@ export class Table {
|
|
|
1507
1544
|
readRoom(validateHost = false) {
|
|
1508
1545
|
if (this.skipSave) { this.skipSave = false; return null; }
|
|
1509
1546
|
try {
|
|
1510
|
-
|
|
1547
|
+
const boot = this.durable?.boots();
|
|
1548
|
+
this.persistentStored = boot?.persistent === true;
|
|
1549
|
+
if (boot?.retired === true) { this.clearRoom(); return null; }
|
|
1511
1550
|
const saved = this.durable?.read();
|
|
1512
1551
|
if (!saved) return null;
|
|
1513
1552
|
if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
|
|
@@ -1529,10 +1568,11 @@ export class Table {
|
|
|
1529
1568
|
probe.stop();
|
|
1530
1569
|
} else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
|
|
1531
1570
|
return saved;
|
|
1532
|
-
} catch (error) { this.storageProblem(error); this.clearRoom(); return null; }
|
|
1571
|
+
} catch (error) { this.storageProblem(error); if (this.isPersistent()) throw error; this.clearRoom(); return null; }
|
|
1533
1572
|
}
|
|
1534
1573
|
|
|
1535
1574
|
clearRoom() {
|
|
1575
|
+
if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
|
|
1536
1576
|
this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
|
|
1537
1577
|
this.roomSave = null; this.saved = null; this.pendingBoot = null;
|
|
1538
1578
|
try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
|
|
@@ -1581,6 +1621,7 @@ export class Table {
|
|
|
1581
1621
|
}
|
|
1582
1622
|
|
|
1583
1623
|
finishRoom(why) {
|
|
1624
|
+
if (this.isPersistent()) { this.hostRt?.stop(); this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`); return; }
|
|
1584
1625
|
this.hostRt?.stop(); this.hostRt = null;
|
|
1585
1626
|
clearInterval(this.timer); this.timer = null;
|
|
1586
1627
|
clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
|
|
@@ -1599,6 +1640,7 @@ export class Table {
|
|
|
1599
1640
|
|
|
1600
1641
|
/** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
|
|
1601
1642
|
armRoom(at) {
|
|
1643
|
+
if (this.isPersistent()) return;
|
|
1602
1644
|
this.roomAlarmAt = at;
|
|
1603
1645
|
this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
|
|
1604
1646
|
const cur = await this.ctx.storage.getAlarm();
|
|
@@ -1620,6 +1662,14 @@ export class Table {
|
|
|
1620
1662
|
* five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
|
|
1621
1663
|
*/
|
|
1622
1664
|
endHosted(why) {
|
|
1665
|
+
if (this.isPersistent()) {
|
|
1666
|
+
this.persistentFailed = true;
|
|
1667
|
+
// The host's own stop would checkpoint a healthy world; this one is refused by `persistentFailed`, and the committed save stands.
|
|
1668
|
+
try { this.hostRt?.stop(); } catch { /* stopped all the same */ }
|
|
1669
|
+
clearTimeout(this.saveRetry); this.saveRetry = null;
|
|
1670
|
+
this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1623
1673
|
const room = this.room;
|
|
1624
1674
|
if (!room) return;
|
|
1625
1675
|
this.finishRoom(why);
|
|
@@ -1661,6 +1711,7 @@ export class Table {
|
|
|
1661
1711
|
const saved = this.cleanupPending ? null : this.readRoom(true);
|
|
1662
1712
|
if (saved) {
|
|
1663
1713
|
this.game = saved.game; this.code = saved.room;
|
|
1714
|
+
if (this.isPersistent()) return;
|
|
1664
1715
|
if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
|
|
1665
1716
|
// No saved pause means people were present. The first cold wake starts the absence period.
|
|
1666
1717
|
if (saved.pausedAt === null) {
|
|
@@ -1675,6 +1726,7 @@ export class Table {
|
|
|
1675
1726
|
return;
|
|
1676
1727
|
}
|
|
1677
1728
|
if (!this.hostRt) return;
|
|
1729
|
+
if (this.isPersistent()) { await this.flushCharacters(); return; }
|
|
1678
1730
|
const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
|
|
1679
1731
|
if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
|
|
1680
1732
|
if (now < endAt) { this.armRoom(endAt); return; }
|
|
@@ -1963,7 +2015,63 @@ export class Table {
|
|
|
1963
2015
|
try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
|
|
1964
2016
|
}
|
|
1965
2017
|
|
|
2018
|
+
async flushCharacters() {
|
|
2019
|
+
if (this.characterFlush) { await this.characterFlush; return; }
|
|
2020
|
+
const saved = this.roomSave;
|
|
2021
|
+
if (!saved) return;
|
|
2022
|
+
const host = typeof saved.host === 'string' ? JSON.parse(saved.host) : saved.host;
|
|
2023
|
+
const returns = (host.characters ?? []).filter(c => c.returning);
|
|
2024
|
+
if (!returns.length) return;
|
|
2025
|
+
this.characterFlush = (async () => {
|
|
2026
|
+
for (const item of returns) {
|
|
2027
|
+
await characterRequest(this.env, item.account, { op: 'return', game: this.game, holder: this.code, lease: item.lease, character: item.character });
|
|
2028
|
+
this.hostRt?.characterReturned(item.account, item.lease);
|
|
2029
|
+
if (this.characterGrants.get(item.account)?.lease === item.lease) this.characterGrants.delete(item.account);
|
|
2030
|
+
}
|
|
2031
|
+
})();
|
|
2032
|
+
try { await this.characterFlush; } finally { this.characterFlush = null; }
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Network awaits may interleave another join or recall. Serialize each account
|
|
2036
|
+
// within this room so a delayed acquisition cannot revive a returned lease.
|
|
2037
|
+
async withCharacter(account, operation) {
|
|
2038
|
+
this.characterOperations ??= new Map();
|
|
2039
|
+
const previous = this.characterOperations.get(account) ?? Promise.resolve();
|
|
2040
|
+
const current = previous.catch(() => {}).then(operation);
|
|
2041
|
+
this.characterOperations.set(account, current);
|
|
2042
|
+
try { return await current; }
|
|
2043
|
+
finally { if (this.characterOperations.get(account) === current) this.characterOperations.delete(account); }
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
acquireCharacter(account) { return this.withCharacter(account, () => this.acquireCharacterOnce(account)); }
|
|
2047
|
+
|
|
2048
|
+
async acquireCharacterOnce(account) {
|
|
2049
|
+
for (const c of [...this.room.clients.values()]) if (c.player === account && c.helloed) this.room.kick(c, 'character-moved', 4001);
|
|
2050
|
+
await this.flushCharacters();
|
|
2051
|
+
const body = { op: 'acquire', game: this.game, holder: this.code, schema: hostedBuild(this.game).stateHash };
|
|
2052
|
+
let grant = await characterRequest(this.env, account, body);
|
|
2053
|
+
if (grant.error === 'character-held') {
|
|
2054
|
+
const old = this.env.TABLE.get(this.env.TABLE.idFromName(`${this.game}/${grant.holder}`));
|
|
2055
|
+
const url = `https://room/__recall?game=${encodeURIComponent(this.game)}&room=${encodeURIComponent(grant.holder)}`;
|
|
2056
|
+
const response = await old.fetch(url, { method: 'POST', body: JSON.stringify({ account }) });
|
|
2057
|
+
if (!response.ok) throw new Error('Finishing your last session; its room is unavailable');
|
|
2058
|
+
grant = await characterRequest(this.env, account, body);
|
|
2059
|
+
}
|
|
2060
|
+
if (!grant.ok) throw new Error(grant.error);
|
|
2061
|
+
const held = { account, lease: grant.lease, seat: -1, character: grant.character };
|
|
2062
|
+
this.characterGrants.set(account, held);
|
|
2063
|
+
this.hostRt.reserveCharacter(held);
|
|
2064
|
+
this.saveRoom(this.hostRt.save());
|
|
2065
|
+
}
|
|
2066
|
+
|
|
1966
2067
|
async fetch(request) {
|
|
2068
|
+
if (new URL(request.url).pathname === '/__character') {
|
|
2069
|
+
const body = await request.json();
|
|
2070
|
+
this.characters ??= characterStore(this.ctx.storage);
|
|
2071
|
+
if (body.op === 'acquire') return json(this.characters.acquire(body.game, body.holder, body.schema));
|
|
2072
|
+
if (body.op === 'return') return json(this.characters.return(body.game, body.holder, body.lease, body.character));
|
|
2073
|
+
return json({ ok: false }, 400);
|
|
2074
|
+
}
|
|
1967
2075
|
if (new URL(request.url).pathname === '/__multiplex') {
|
|
1968
2076
|
return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
|
|
1969
2077
|
}
|
|
@@ -1982,6 +2090,23 @@ export class Table {
|
|
|
1982
2090
|
return json({ ok: true });
|
|
1983
2091
|
}
|
|
1984
2092
|
const room = this.roomFor(game, code, max);
|
|
2093
|
+
if (url.pathname === '/__recall') {
|
|
2094
|
+
const { account } = await request.json();
|
|
2095
|
+
return this.withCharacter(account, async () => {
|
|
2096
|
+
this.ensureHost(room, true);
|
|
2097
|
+
if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
|
|
2098
|
+
await this.flushCharacters();
|
|
2099
|
+
if (!this.hostRt.characters.has(account)) {
|
|
2100
|
+
const grant = await characterRequest(this.env, account, { op: 'acquire', game, holder: code, schema: hostedBuild(game).stateHash });
|
|
2101
|
+
if (!grant.ok) return json({ ok: true });
|
|
2102
|
+
this.hostRt.reserveCharacter({ account, lease: grant.lease, seat: -1, character: grant.character });
|
|
2103
|
+
}
|
|
2104
|
+
this.hostRt.releaseCharacter(account);
|
|
2105
|
+
for (const c of [...room.clients.values()]) if (c.player === account) room.kick(c, 'character-moved', 4001);
|
|
2106
|
+
await this.flushCharacters();
|
|
2107
|
+
return json({ ok: true });
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
1985
2110
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1986
2111
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1987
2112
|
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
@@ -2044,6 +2169,12 @@ export class Table {
|
|
|
2044
2169
|
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
2045
2170
|
// Kept chat (0.29.0): the room's history is back in its window before this page hears it.
|
|
2046
2171
|
await this.hydrateHistory(room);
|
|
2172
|
+
const account = holders(url.searchParams.get('via')).find(p => p.startsWith('p-'))?.slice(2) ?? null;
|
|
2173
|
+
if (hostedBuild(game)?.settings.characters && url.pathname !== '/__watch') {
|
|
2174
|
+
if (!account || url.searchParams.get('acct') !== '1') return new Response('Sign in to keep your character', { status: 401 });
|
|
2175
|
+
try { await this.acquireCharacter(account); }
|
|
2176
|
+
catch (error) { return new Response(String(error.message), { status: 409 }); }
|
|
2177
|
+
}
|
|
2047
2178
|
const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
|
|
2048
2179
|
server.accept();
|
|
2049
2180
|
const via = url.searchParams.get('via');
|
|
@@ -2096,7 +2227,12 @@ export class Table {
|
|
|
2096
2227
|
// seats, so it is not counted twice). One counter write per room.
|
|
2097
2228
|
const fresh = room.seats.size === 0;
|
|
2098
2229
|
server.addEventListener('message', (e) => {
|
|
2099
|
-
|
|
2230
|
+
let text = typeof e.data === 'string' ? e.data : '';
|
|
2231
|
+
if (hostedBuild(game)?.settings.characters && account) {
|
|
2232
|
+
try { const message = JSON.parse(text); if (message.t === 'hello') { delete message.token; if (this.accountTokens[account]) message.token = this.accountTokens[account]; text = JSON.stringify(message); } } catch { /* normal parser diagnoses it */ }
|
|
2233
|
+
}
|
|
2234
|
+
h.onMessage(text);
|
|
2235
|
+
if (hostedBuild(game)?.settings.characters && account) { const joined = room.clients.get(h.id); if (joined?.token) this.accountTokens[account] = joined.token; }
|
|
2100
2236
|
// A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
|
|
2101
2237
|
if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.report();
|
|
2102
2238
|
if (fresh && !this.openCounted && room.seats.size > 0) {
|
package/worker/room-store.mjs
CHANGED
|
@@ -1,28 +1,77 @@
|
|
|
1
|
+
import { normalizeWorld, denormalizeWorld, durableWorld, ownDurable, checkpointWorld, changes, applyChanges } from './world-journal.mjs';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
3
|
+
* Match rooms keep their whole save and its cadence. A lasting world (`persistent`) writes what the game declared
|
|
4
|
+
* durable the moment it changes, as one small journal transaction, and folds the rest of the room into a chunked
|
|
5
|
+
* checkpoint on an interval and on the way down. Restore is the last checkpoint and then the journal after it.
|
|
6
|
+
*
|
|
7
|
+
* The journal is a ring. Row 0 holds the floor: the last sequence number the checkpoint already contains, written in
|
|
8
|
+
* the checkpoint's own transaction. A record's row id is its sequence less the floor, so the records after a
|
|
9
|
+
* checkpoint overwrite the ones before it and a checkpoint never pays a row for each record it folds. A read takes
|
|
10
|
+
* only the rows above the floor, in order, and refuses a gap.
|
|
11
|
+
*
|
|
12
|
+
* Only integer primary keys: no secondary-index write amplification.
|
|
4
13
|
* The boot row outlives a save being replayed, so two restores of that save can never reuse an epoch.
|
|
5
14
|
*/
|
|
6
|
-
export function roomStore(storage) {
|
|
15
|
+
export function roomStore(storage, { persistent = false } = {}) {
|
|
7
16
|
const sql = storage.sql;
|
|
8
17
|
sql.exec('CREATE TABLE IF NOT EXISTS save (id INTEGER PRIMARY KEY, data BLOB NOT NULL)');
|
|
9
18
|
sql.exec('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY, data TEXT NOT NULL)');
|
|
19
|
+
let journalReady = false;
|
|
20
|
+
/** Opened once: a store that writes before it reads still continues the sequence on disk, so its checkpoint retires what is there. */
|
|
21
|
+
function journal() {
|
|
22
|
+
if (journalReady) return;
|
|
23
|
+
sql.exec('CREATE TABLE IF NOT EXISTS world_journal (id INTEGER PRIMARY KEY, seq INTEGER NOT NULL, data BLOB NOT NULL)');
|
|
24
|
+
floor = sql.exec('SELECT seq FROM world_journal WHERE id = 0').toArray()[0]?.seq ?? 0;
|
|
25
|
+
sequence = Math.max(floor, sql.exec('SELECT MAX(seq) AS seq FROM world_journal WHERE id > 0').toArray()[0]?.seq ?? 0);
|
|
26
|
+
journalReady = true;
|
|
27
|
+
}
|
|
28
|
+
// `base` is the world as last read or checkpointed, kept whole until a commit says which fields are durable;
|
|
29
|
+
// `durable` is the projection of the last world a commit saw, and is what the next one is compared with.
|
|
30
|
+
let sequence = 0, floor = 0, base = null, durable = null, checkpoint = null, checkpointAt = 0, checkpointTick = 0, committedInputs = null;
|
|
31
|
+
let lasting = persistent;
|
|
32
|
+
if (persistent) { const old = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); if (!old?.persistent) sql.exec('INSERT INTO boots(id,data) VALUES (1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', JSON.stringify({ epoch: 0, count: 0, ...old, persistent: true })); }
|
|
10
33
|
const encoder = new TextEncoder();
|
|
11
34
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
12
35
|
const pieceBytes = 1_000_000;
|
|
36
|
+
const inputState = v => ({inputs:v.host?.inputs ?? [],queues:v.host?.queues ?? []});
|
|
37
|
+
/** What is on disk now equals `value`: the next commit is compared with it. */
|
|
38
|
+
function settle(value) {
|
|
39
|
+
base = normalizeWorld(structuredClone(value)); durable = null;
|
|
40
|
+
checkpoint = JSON.stringify(checkpointWorld(base)); checkpointAt = value.at ?? 0; checkpointTick = base.host?.core?.tick ?? 0; committedInputs = inputState(base);
|
|
41
|
+
}
|
|
13
42
|
return {
|
|
14
43
|
read() {
|
|
15
|
-
|
|
44
|
+
const boot = this.boots();
|
|
45
|
+
if (boot?.retired === true) return null;
|
|
46
|
+
const maxBytes = boot?.persistent ? Infinity : 16_000_000;
|
|
16
47
|
const total = sql.exec('SELECT SUM(length(data)) AS bytes FROM save').toArray()[0]?.bytes ?? 0;
|
|
17
|
-
if (total >
|
|
48
|
+
if (total > maxBytes) throw new Error('save exceeds 16 MB');
|
|
18
49
|
const rows = sql.exec('SELECT id, data FROM save ORDER BY id').toArray();
|
|
19
50
|
if (!rows.length) return null;
|
|
20
|
-
if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) >
|
|
51
|
+
if (rows.some((r, i) => r.id !== i) || rows.reduce((n, r) => n + r.data.byteLength, 0) > maxBytes) throw new Error('save pieces missing or save exceeds 16 MB');
|
|
21
52
|
const pieces = rows.map((r) => new Uint8Array(r.data));
|
|
22
53
|
const bytes = new Uint8Array(pieces.reduce((n, p) => n + p.length, 0));
|
|
23
54
|
let at = 0;
|
|
24
55
|
for (const p of pieces) { bytes.set(p, at); at += p.length; }
|
|
25
|
-
|
|
56
|
+
let value = JSON.parse(decoder.decode(bytes));
|
|
57
|
+
if (lasting) journal();
|
|
58
|
+
sequence = floor;
|
|
59
|
+
const journals = lasting ? sql.exec('SELECT id, seq, data FROM world_journal WHERE id > 0 AND seq > ? ORDER BY seq', floor).toArray() : [];
|
|
60
|
+
if (journals.length) {
|
|
61
|
+
let normalized = normalizeWorld(value);
|
|
62
|
+
let pending = '';
|
|
63
|
+
const stream = new TextDecoder('utf-8', {fatal:true});
|
|
64
|
+
for (const row of journals) {
|
|
65
|
+
if (row.seq !== sequence + 1 || row.id !== row.seq - floor) throw new Error('World journal has a gap');
|
|
66
|
+
pending += stream.decode(new Uint8Array(row.data), {stream:true}); sequence = row.seq;
|
|
67
|
+
let end;
|
|
68
|
+
while ((end = pending.indexOf('\n')) >= 0) { normalized = applyChanges(normalized, JSON.parse(pending.slice(0,end))); pending = pending.slice(end+1); }
|
|
69
|
+
}
|
|
70
|
+
pending += stream.decode();
|
|
71
|
+
if (pending) throw new Error('Incomplete world journal transaction');
|
|
72
|
+
value = denormalizeWorld(normalized);
|
|
73
|
+
}
|
|
74
|
+
if (lasting) settle(value);
|
|
26
75
|
if (typeof value.host === 'string') {
|
|
27
76
|
const hostText = value.host;
|
|
28
77
|
value.host = JSON.parse(hostText);
|
|
@@ -30,19 +79,72 @@ export function roomStore(storage) {
|
|
|
30
79
|
}
|
|
31
80
|
return value;
|
|
32
81
|
},
|
|
82
|
+
/**
|
|
83
|
+
* A lasting world's tick. Nothing durable changed and no checkpoint is due: no write. Something durable changed:
|
|
84
|
+
* one journal transaction holding just that. The interval has passed (or `force`) and the room differs from its
|
|
85
|
+
* checkpoint: the checkpoint, which takes the journal with it.
|
|
86
|
+
*/
|
|
87
|
+
commit(value, { force = false, seconds = 30, tickHz = 20, fields }) {
|
|
88
|
+
journal();
|
|
89
|
+
if (!base) { this.write(value); return; }
|
|
90
|
+
const next = normalizeWorld(value);
|
|
91
|
+
durable ??= ownDurable(base, fields);
|
|
92
|
+
const moved = changes(durable, durableWorld(next, fields));
|
|
93
|
+
// The comparison is over sparse field maps. What is written is the full validated representation at each
|
|
94
|
+
// path, so a new entity is recorded with its initial pose.
|
|
95
|
+
const patches = moved.map((patch) => {
|
|
96
|
+
if (patch.length === 1) return patch;
|
|
97
|
+
let full = next;
|
|
98
|
+
for (const key of patch[0]) full = full[key];
|
|
99
|
+
return [patch[0], full];
|
|
100
|
+
});
|
|
101
|
+
if (force || value.at - checkpointAt >= seconds * 1000 || next.host.core.tick - checkpointTick >= seconds * tickHz) {
|
|
102
|
+
if (patches.length || JSON.stringify(checkpointWorld(next)) !== checkpoint) { this.write(value); return; }
|
|
103
|
+
// Still the checkpoint's own room: it is asked again an interval from now, not on every tick until then.
|
|
104
|
+
checkpointAt = value.at; checkpointTick = next.host.core.tick;
|
|
105
|
+
}
|
|
106
|
+
if (patches.length) {
|
|
107
|
+
// Input acknowledgements and pending pulses share the value transaction.
|
|
108
|
+
// A restart must not consume a checkpoint's already-spent pulse again.
|
|
109
|
+
const inputs = inputState(next);
|
|
110
|
+
changes(committedInputs, inputs, ['host'], patches);
|
|
111
|
+
// Advance logical time and RNG with a commitment, never once per tick.
|
|
112
|
+
for (const key of ['tick','rng','nextId','seq']) patches.push([['host','core',key],next.host.core[key]]);
|
|
113
|
+
patches.push([['at'],next.at]);
|
|
114
|
+
// One transaction may span several bounded rows. A read folds the whole
|
|
115
|
+
// transaction; no prefix can become visible across a crash.
|
|
116
|
+
const bytes = encoder.encode(JSON.stringify(patches) + '\n');
|
|
117
|
+
let last = sequence;
|
|
118
|
+
storage.transactionSync(() => {
|
|
119
|
+
for (let at = 0; at < bytes.length; at += pieceBytes) {
|
|
120
|
+
last += 1;
|
|
121
|
+
sql.exec('INSERT INTO world_journal (id, seq, data) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET seq = excluded.seq, data = excluded.data', last - floor, last, bytes.slice(at,at+pieceBytes));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
sequence = last;
|
|
125
|
+
committedInputs = structuredClone(inputs);
|
|
126
|
+
}
|
|
127
|
+
// Brought up to this tick by what changed in it (each value copied), never by copying the room.
|
|
128
|
+
durable = applyChanges(durable, moved);
|
|
129
|
+
},
|
|
33
130
|
write(value) {
|
|
131
|
+
if (lasting) journal();
|
|
34
132
|
const bytes = encoder.encode(JSON.stringify(value));
|
|
35
|
-
if (bytes.length > 16_000_000) throw new Error('save exceeds 16 MB');
|
|
133
|
+
if (bytes.length > 16_000_000 && !lasting) throw new Error('save exceeds 16 MB');
|
|
36
134
|
storage.transactionSync(() => {
|
|
37
135
|
let id = 0;
|
|
38
136
|
for (let at = 0; at < bytes.length; at += pieceBytes) sql.exec('INSERT INTO save (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', id++, bytes.slice(at, at + pieceBytes));
|
|
39
137
|
sql.exec('DELETE FROM save WHERE id >= ?', id);
|
|
138
|
+
// The checkpoint holds every record so far: raising the floor retires them without touching their rows.
|
|
139
|
+
if (lasting && sequence !== floor) sql.exec("INSERT INTO world_journal (id, seq, data) VALUES (0, ?, x'') ON CONFLICT(id) DO UPDATE SET seq = excluded.seq", sequence);
|
|
40
140
|
});
|
|
141
|
+
floor = sequence;
|
|
142
|
+
if (lasting) settle(value);
|
|
41
143
|
},
|
|
42
|
-
boots() {
|
|
43
|
-
boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify(value)); },
|
|
144
|
+
boots() { const value = JSON.parse(sql.exec('SELECT data FROM boots WHERE id = 1').toArray()[0]?.data ?? 'null'); lasting ||= value?.persistent === true; return value; },
|
|
145
|
+
boot(value) { sql.exec('INSERT INTO boots (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data', JSON.stringify({ ...value, ...(lasting ? { persistent: true } : {}) })); },
|
|
44
146
|
retire() { this.boot({ retired: true }); },
|
|
45
|
-
clear() { storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); }); },
|
|
147
|
+
clear() { if (lasting) journal(); storage.transactionSync(() => { sql.exec('DELETE FROM save'); sql.exec('DELETE FROM boots'); if (lasting) sql.exec('DELETE FROM world_journal'); }); journalReady = false; base = null; durable = null; checkpoint = null; committedInputs = null; sequence = 0; floor = 0; },
|
|
46
148
|
};
|
|
47
149
|
}
|
|
48
150
|
|
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.46.0';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Absolute assignments, never increments: replay cannot mint value twice.
|
|
2
|
+
* Entity IDs are keys so removing one entity never rewrites the rest of a world.
|
|
3
|
+
* The snapshot format remains the host's validated format at the read boundary.
|
|
4
|
+
*/
|
|
5
|
+
const collections = [
|
|
6
|
+
[['host','core','ents'], row => row[0]], [['host','core','spawns'], row => row[0]],
|
|
7
|
+
[['host','characters'], row => row.account], [['host','names'], row => row[0]],
|
|
8
|
+
[['host','inputs'], row => row[0]], [['host','queues'], row => row[0]],
|
|
9
|
+
[['host','core','seats'], row => row[0]], [['net','seats'], row => row[0]],
|
|
10
|
+
[['host','core','queue'], row => `${row[3]}:${row[4]}`],
|
|
11
|
+
[['host','core','areas'], row => row[2]], [['host','core','asks'], row => row.n],
|
|
12
|
+
];
|
|
13
|
+
function collectionsOf(copy, encode) {
|
|
14
|
+
for (const [path, identity] of collections) {
|
|
15
|
+
let parent = copy;
|
|
16
|
+
for (const key of path.slice(0,-1)) parent = parent?.[key];
|
|
17
|
+
const key = path.at(-1), rows = parent?.[key];
|
|
18
|
+
if (rows) parent[key] = encode ? Object.fromEntries(rows.map(row => [`k${identity(row)}`,row])) : Object.values(rows);
|
|
19
|
+
}
|
|
20
|
+
return copy;
|
|
21
|
+
}
|
|
22
|
+
/** Keyed collections on a copied spine. The rows themselves are shared with `value`: callers that keep the result clone first. */
|
|
23
|
+
export function normalizeWorld(value) {
|
|
24
|
+
const host = typeof value.host === 'string' ? JSON.parse(value.host) : value.host;
|
|
25
|
+
const copy = { ...value, host: { ...host, ...(host.core ? { core: { ...host.core } } : {}) }, ...(value.net ? { net: { ...value.net } } : {}) };
|
|
26
|
+
return collectionsOf(copy, true);
|
|
27
|
+
}
|
|
28
|
+
export function denormalizeWorld(value) {
|
|
29
|
+
return collectionsOf(structuredClone(value), false);
|
|
30
|
+
}
|
|
31
|
+
const isField = path => path[4] === 17 && path.length >= 6 || path[2] === 'shared' && path.length >= 4;
|
|
32
|
+
/**
|
|
33
|
+
* One walk of both values, no serialising of what did not change: a tick's cost follows the durable state, and a
|
|
34
|
+
* record's size follows what changed in it. Arrays are diffed by cell, packed maps and lists included.
|
|
35
|
+
* `path` is where the two values sit; a patch is `[path, value]`, or `[path]` for a key that is gone.
|
|
36
|
+
*/
|
|
37
|
+
export function changes(before, after, path = [], out = []) {
|
|
38
|
+
walk(before, after, [...path], out);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** `at` is one stack for the whole walk, copied only into a patch: nothing is allocated for what is equal. */
|
|
42
|
+
function walk(before, after, at, out) {
|
|
43
|
+
if (before === after) return;
|
|
44
|
+
if (!before || !after || typeof before !== 'object' || typeof after !== 'object' || Array.isArray(before) !== Array.isArray(after)) { out.push([[...at], after]); return; }
|
|
45
|
+
const start = out.length;
|
|
46
|
+
if (Array.isArray(after)) {
|
|
47
|
+
// By index, as numbers: a cell past the shorter array's end is new, and the length patch drops what is gone.
|
|
48
|
+
for (let i = 0; i < after.length; i += 1) { at.push(i); walk(before[i], after[i], at, out); at.pop(); }
|
|
49
|
+
if (before.length !== after.length) out.push([[...at, 'length'], after.length]);
|
|
50
|
+
} else {
|
|
51
|
+
for (const key in before) if (!Object.hasOwn(after, key)) out.push([[...at, key]]);
|
|
52
|
+
for (const key in after) { at.push(key); walk(before[key], after[key], at, out); at.pop(); }
|
|
53
|
+
}
|
|
54
|
+
// A packed inventory whose every slot changes is smaller as one assignment.
|
|
55
|
+
// Never coalesce a projected entity or room (that would pull movement in).
|
|
56
|
+
if (out.length > start && isField(at) && JSON.stringify([at, after]).length < JSON.stringify(out.slice(start)).length) out.splice(start, out.length - start, [[...at], after]);
|
|
57
|
+
}
|
|
58
|
+
export function applyChanges(value, patches) {
|
|
59
|
+
for (const [path, next] of patches) {
|
|
60
|
+
if (!path.length) { value = structuredClone(next); continue; }
|
|
61
|
+
let target = value;
|
|
62
|
+
for (const key of path.slice(0,-1)) target = target[key];
|
|
63
|
+
const key = path.at(-1);
|
|
64
|
+
if (next === undefined) delete target[key];
|
|
65
|
+
else if (Array.isArray(target) && key === 'length') target.length = next;
|
|
66
|
+
else Object.defineProperty(target, key, {value:structuredClone(next),writable:true,enumerable:true,configurable:true});
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/** Durable fields plus the causal machinery that can create/consume value.
|
|
71
|
+
* Movement, inputs, pose history and undeclared fields wait for a checkpoint.
|
|
72
|
+
* Takes a normalised world and leaves it untouched. The view it returns shares its leaves with that world: it is for
|
|
73
|
+
* comparing now, and `ownDurable` makes the copy that is kept.
|
|
74
|
+
*/
|
|
75
|
+
const POSE = [3, 7, 8, 9, 10, 15, 16, 18, 19];
|
|
76
|
+
export function durableWorld(value, fields) {
|
|
77
|
+
const { at, ...v } = value, { inputs, queues, guideViews, agents, ...h } = v.host, { tick, rng, nextId, seq, history, collision, guideViews: views, ...c } = h.core;
|
|
78
|
+
if (v.net) { const { savedAt, ckpt, state, round, roster, ...net } = v.net; v.net = net; }
|
|
79
|
+
for (const key of ['ents', 'spawns']) {
|
|
80
|
+
const rows = {};
|
|
81
|
+
for (const [id, row] of Object.entries(c[key] ?? {})) {
|
|
82
|
+
const list = fields.kinds[row[2]] ?? [];
|
|
83
|
+
// Undeclared non-player entities (for example visual projectiles) belong
|
|
84
|
+
// to checkpoints. Seat-bearing bodies remain part of the ownership protocol.
|
|
85
|
+
if (!list.length && row[11] < 0) continue;
|
|
86
|
+
// Preserve membership and ownership; pose/motion rewind to the checkpoint.
|
|
87
|
+
const kept = row.slice();
|
|
88
|
+
for (const i of POSE) if (i < kept.length) kept[i] = null;
|
|
89
|
+
kept[17] = {};
|
|
90
|
+
for (const i of list) if (row[17][i] !== undefined) kept[17][i] = row[17][i];
|
|
91
|
+
rows[id] = kept;
|
|
92
|
+
}
|
|
93
|
+
c[key] = rows;
|
|
94
|
+
}
|
|
95
|
+
const shared = {};
|
|
96
|
+
for (const i of fields.shared) if (c.shared?.[i] !== undefined) shared[i] = c.shared[i];
|
|
97
|
+
c.shared = shared; h.core = c; v.host = h;
|
|
98
|
+
return v;
|
|
99
|
+
}
|
|
100
|
+
/** A durable view that shares nothing with the world it was taken from. */
|
|
101
|
+
export const ownDurable = (value, fields) => JSON.parse(JSON.stringify(durableWorld(value, fields)));
|
|
102
|
+
/** Ignore clocks when deciding if an unchanged room needs a checkpoint. */
|
|
103
|
+
export function checkpointWorld(value) {
|
|
104
|
+
const v = structuredClone(value);
|
|
105
|
+
delete v.at; if(v.net) delete v.net.savedAt;
|
|
106
|
+
if(v.host?.core) { delete v.host.core.tick; delete v.host.core.history; delete v.host.core.collision; }
|
|
107
|
+
for(const [,q] of Object.values(v.host?.inputs ?? {})) delete q.lastFrameTick;
|
|
108
|
+
return v;
|
|
109
|
+
}
|