@homie-rocks/studio 0.45.2 → 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 +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +108 -29
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- 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,12 +1025,18 @@ 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' });
|
|
1030
1032
|
return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
|
|
1031
1033
|
}
|
|
1032
1034
|
if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, days: d.days, origin: url.origin, next: `${url.pathname}${url.search}`, code: url.searchParams.get('invite') ?? '' });
|
|
1035
|
+
let recordsGuest=null;
|
|
1036
|
+
if(meta.room?.records&&!embed&&!screen&&!d.acct&&env.DB){
|
|
1037
|
+
const made=await playerAccounts.recordsGuest(request,env,url);if(made.error)return made.error;
|
|
1038
|
+
recordsGuest=made.session;d.acct='p-'+recordsGuest.player.id;d.holder=joinHolders(d.holder,d.acct);
|
|
1039
|
+
}
|
|
1033
1040
|
if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
|
|
1034
1041
|
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
1035
1042
|
const pol = policyFor(srv, named);
|
|
@@ -1054,7 +1061,8 @@ async function route(request, env, ctx) {
|
|
|
1054
1061
|
}
|
|
1055
1062
|
await countVisit(request, env, ctx, game, 'play');
|
|
1056
1063
|
shareDaily(cat, url, ctx);
|
|
1057
|
-
|
|
1064
|
+
const page=playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
|
|
1065
|
+
for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
|
|
1058
1066
|
}
|
|
1059
1067
|
if (sub === 'watch') {
|
|
1060
1068
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
@@ -1106,6 +1114,11 @@ async function route(request, env, ctx) {
|
|
|
1106
1114
|
// Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
|
|
1107
1115
|
const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
|
|
1108
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
|
+
}
|
|
1109
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' });
|
|
1110
1123
|
}
|
|
1111
1124
|
if (sub === 'api/agent') {
|
|
@@ -1312,6 +1325,8 @@ export class Table {
|
|
|
1312
1325
|
* room next needs the object's one alarm (the house guides and the day's counters share it).
|
|
1313
1326
|
*/
|
|
1314
1327
|
this.hostRt = null;
|
|
1328
|
+
this.characterGrants = new Map();
|
|
1329
|
+
this.accountTokens = {};
|
|
1315
1330
|
this.roomAlarmAt = 0;
|
|
1316
1331
|
this.durable = null;
|
|
1317
1332
|
this.roomSave = null;
|
|
@@ -1360,6 +1375,7 @@ export class Table {
|
|
|
1360
1375
|
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
1361
1376
|
// A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
|
|
1362
1377
|
this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
|
|
1378
|
+
if (this.isPersistent()) this.room.forgetMs = Infinity;
|
|
1363
1379
|
return this.room;
|
|
1364
1380
|
}
|
|
1365
1381
|
|
|
@@ -1370,6 +1386,8 @@ export class Table {
|
|
|
1370
1386
|
* elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
|
|
1371
1387
|
* this build, or do not fit the contract, do not start: the room refuses joins and says why.
|
|
1372
1388
|
*/
|
|
1389
|
+
isPersistent() { return this.persistentStored === true || hostedBuild(this.game)?.settings?.lifetime === 'persistent'; }
|
|
1390
|
+
|
|
1373
1391
|
ensureHost(room, hosted) {
|
|
1374
1392
|
if (this.hostRt || !this.game) return;
|
|
1375
1393
|
if (!hostedGame(this.game)) {
|
|
@@ -1380,9 +1398,11 @@ export class Table {
|
|
|
1380
1398
|
let pauseWriteFailed = false;
|
|
1381
1399
|
try {
|
|
1382
1400
|
const build = hostedBuild(this.game);
|
|
1383
|
-
|
|
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); }
|
|
1384
1403
|
let saved = this.cleanupPending ? null : this.readRoom();
|
|
1385
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.');
|
|
1386
1406
|
let boot = { epoch: 0, count: 0 };
|
|
1387
1407
|
try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
|
|
1388
1408
|
if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
|
|
@@ -1396,7 +1416,8 @@ export class Table {
|
|
|
1396
1416
|
const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
|
|
1397
1417
|
const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
|
|
1398
1418
|
const expired = Date.now() >= restoredEnd;
|
|
1399
|
-
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)) {
|
|
1400
1421
|
const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
|
|
1401
1422
|
const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
|
|
1402
1423
|
this.finishRoom(why);
|
|
@@ -1406,15 +1427,16 @@ export class Table {
|
|
|
1406
1427
|
this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
|
|
1407
1428
|
}
|
|
1408
1429
|
this.roomSave = saved;
|
|
1430
|
+
this.accountTokens = saved?.accountTokens ?? {};
|
|
1409
1431
|
if (saved) {
|
|
1410
1432
|
// The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
|
|
1411
1433
|
room.seats.clear(); room.state.clear(); room.stateBytes = 0;
|
|
1412
1434
|
room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
|
|
1413
|
-
this.pausedAt = restoredEnd - ROOM_PAUSE_MS;
|
|
1435
|
+
this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
|
|
1414
1436
|
this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
|
|
1415
1437
|
saved.pausedAt = this.pausedAt;
|
|
1416
1438
|
// A failed pause write must never be mistaken for an invalid host save.
|
|
1417
|
-
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); }
|
|
1418
1440
|
}
|
|
1419
1441
|
const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
|
|
1420
1442
|
if (saved) this.durable.boot({ epoch, count: boot.count });
|
|
@@ -1422,7 +1444,12 @@ export class Table {
|
|
|
1422
1444
|
let ticks = 0;
|
|
1423
1445
|
this.hostRt = startHost(this.game, {
|
|
1424
1446
|
...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
|
|
1425
|
-
|
|
1447
|
+
record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
|
|
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
|
+
} } : {}),
|
|
1426
1453
|
onTick: () => {
|
|
1427
1454
|
if (!saved) return;
|
|
1428
1455
|
ticks += 1;
|
|
@@ -1447,7 +1474,7 @@ export class Table {
|
|
|
1447
1474
|
this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
|
|
1448
1475
|
} catch (error) {
|
|
1449
1476
|
this.hostRt?.stop(); this.hostRt = null;
|
|
1450
|
-
if (restoring) {
|
|
1477
|
+
if (restoring && !this.isPersistent()) {
|
|
1451
1478
|
this.storageProblem(error);
|
|
1452
1479
|
room.forget(); this.clearRoom();
|
|
1453
1480
|
this.skipSave = true;
|
|
@@ -1458,19 +1485,35 @@ export class Table {
|
|
|
1458
1485
|
}
|
|
1459
1486
|
}
|
|
1460
1487
|
|
|
1461
|
-
saveRoom(bytes) {
|
|
1488
|
+
saveRoom(bytes, commitment) {
|
|
1489
|
+
if (this.persistentFailed) throw new Error('Lasting world stopped; restart from its committed save');
|
|
1462
1490
|
const now = Date.now();
|
|
1463
1491
|
if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
|
|
1464
1492
|
try {
|
|
1465
|
-
this.durable ??= roomStore(this.ctx.storage);
|
|
1493
|
+
this.durable ??= roomStore(this.ctx.storage, { persistent: this.isPersistent() });
|
|
1466
1494
|
const build = hostedBuild(this.game);
|
|
1467
1495
|
const host = new TextDecoder().decode(bytes);
|
|
1468
|
-
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 };
|
|
1469
1505
|
const clearing = this.cleanupPending;
|
|
1470
1506
|
if (clearing) this.durable.clear();
|
|
1471
|
-
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);
|
|
1472
1513
|
if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
|
|
1473
|
-
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;
|
|
1474
1517
|
this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
|
|
1475
1518
|
clearTimeout(this.saveRetry); this.saveRetry = null;
|
|
1476
1519
|
this.storageHealth = { ok: !this.pendingBoot };
|
|
@@ -1479,7 +1522,8 @@ export class Table {
|
|
|
1479
1522
|
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
|
|
1480
1523
|
this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
|
|
1481
1524
|
this.storageProblem(error);
|
|
1482
|
-
if (
|
|
1525
|
+
if (this.isPersistent()) this.endHosted('storage-failed');
|
|
1526
|
+
if (!this.isPersistent() && !this.saveRetry) {
|
|
1483
1527
|
this.saveRetry = setTimeout(() => {
|
|
1484
1528
|
this.saveRetry = null;
|
|
1485
1529
|
if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
|
|
@@ -1500,7 +1544,9 @@ export class Table {
|
|
|
1500
1544
|
readRoom(validateHost = false) {
|
|
1501
1545
|
if (this.skipSave) { this.skipSave = false; return null; }
|
|
1502
1546
|
try {
|
|
1503
|
-
|
|
1547
|
+
const boot = this.durable?.boots();
|
|
1548
|
+
this.persistentStored = boot?.persistent === true;
|
|
1549
|
+
if (boot?.retired === true) { this.clearRoom(); return null; }
|
|
1504
1550
|
const saved = this.durable?.read();
|
|
1505
1551
|
if (!saved) return null;
|
|
1506
1552
|
if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
|
|
@@ -1522,10 +1568,11 @@ export class Table {
|
|
|
1522
1568
|
probe.stop();
|
|
1523
1569
|
} else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
|
|
1524
1570
|
return saved;
|
|
1525
|
-
} catch (error) { this.storageProblem(error); this.clearRoom(); return null; }
|
|
1571
|
+
} catch (error) { this.storageProblem(error); if (this.isPersistent()) throw error; this.clearRoom(); return null; }
|
|
1526
1572
|
}
|
|
1527
1573
|
|
|
1528
1574
|
clearRoom() {
|
|
1575
|
+
if (this.isPersistent()) throw new Error('A lasting world cannot be cleared by match cleanup');
|
|
1529
1576
|
this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
|
|
1530
1577
|
this.roomSave = null; this.saved = null; this.pendingBoot = null;
|
|
1531
1578
|
try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
|
|
@@ -1574,6 +1621,7 @@ export class Table {
|
|
|
1574
1621
|
}
|
|
1575
1622
|
|
|
1576
1623
|
finishRoom(why) {
|
|
1624
|
+
if (this.isPersistent()) { this.hostRt?.stop(); this.room?.failServerHost(`Lasting world paused: ${why}. Its committed save is retained.`); return; }
|
|
1577
1625
|
this.hostRt?.stop(); this.hostRt = null;
|
|
1578
1626
|
clearInterval(this.timer); this.timer = null;
|
|
1579
1627
|
clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
|
|
@@ -1592,6 +1640,7 @@ export class Table {
|
|
|
1592
1640
|
|
|
1593
1641
|
/** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
|
|
1594
1642
|
armRoom(at) {
|
|
1643
|
+
if (this.isPersistent()) return;
|
|
1595
1644
|
this.roomAlarmAt = at;
|
|
1596
1645
|
this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
|
|
1597
1646
|
const cur = await this.ctx.storage.getAlarm();
|
|
@@ -1613,6 +1662,14 @@ export class Table {
|
|
|
1613
1662
|
* five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
|
|
1614
1663
|
*/
|
|
1615
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
|
+
}
|
|
1616
1673
|
const room = this.room;
|
|
1617
1674
|
if (!room) return;
|
|
1618
1675
|
this.finishRoom(why);
|
|
@@ -1654,6 +1711,7 @@ export class Table {
|
|
|
1654
1711
|
const saved = this.cleanupPending ? null : this.readRoom(true);
|
|
1655
1712
|
if (saved) {
|
|
1656
1713
|
this.game = saved.game; this.code = saved.room;
|
|
1714
|
+
if (this.isPersistent()) return;
|
|
1657
1715
|
if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
|
|
1658
1716
|
// No saved pause means people were present. The first cold wake starts the absence period.
|
|
1659
1717
|
if (saved.pausedAt === null) {
|
|
@@ -1668,6 +1726,7 @@ export class Table {
|
|
|
1668
1726
|
return;
|
|
1669
1727
|
}
|
|
1670
1728
|
if (!this.hostRt) return;
|
|
1729
|
+
if (this.isPersistent()) { await this.flushCharacters(); return; }
|
|
1671
1730
|
const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
|
|
1672
1731
|
if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
|
|
1673
1732
|
if (now < endAt) { this.armRoom(endAt); return; }
|
|
@@ -1683,12 +1742,32 @@ export class Table {
|
|
|
1683
1742
|
* a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
|
|
1684
1743
|
*/
|
|
1685
1744
|
say(line) {
|
|
1745
|
+
if(line?.ev==='hello'&&line.seat!==null&&this.ctx.storage.sql){const c=this.room?.clients.get(line.id),owner='p'+this.room?.seats.get(line.seat)?.occ;if(c?.player){this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');this.ctx.storage.sql.exec('INSERT OR REPLACE INTO round_players VALUES(?,?)',owner,c.player);}}
|
|
1686
1746
|
if(line?.ev==='hello'&&!line.resumed&&line.seat!==null) this.queueFunctionEvent('player.joined',{game:this.game,room:line.room,player:this.room?.clients.get(line.id)?.conn?.player??null,seat:line.seat});
|
|
1687
1747
|
const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
|
|
1688
1748
|
if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
|
|
1689
1749
|
try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
|
|
1690
1750
|
}
|
|
1691
1751
|
|
|
1752
|
+
queueRoundRecord(owner,key,value) {
|
|
1753
|
+
const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
|
|
1754
|
+
sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
|
|
1755
|
+
const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
|
|
1756
|
+
const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
|
|
1757
|
+
if(!player)return;
|
|
1758
|
+
sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
|
|
1759
|
+
sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
|
|
1760
|
+
this.ctx.waitUntil(this.flushRoundRecords());
|
|
1761
|
+
}
|
|
1762
|
+
async flushRoundRecords(){
|
|
1763
|
+
const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
|
|
1764
|
+
if(!sql.exec("SELECT name FROM sqlite_master WHERE name='round_outbox'").toArray().length)return;
|
|
1765
|
+
const {appendServerRecord}=await import('./saves.mjs');
|
|
1766
|
+
for(const row of sql.exec('SELECT * FROM round_outbox LIMIT 100').toArray()){
|
|
1767
|
+
try{await appendServerRecord(this.env,row.player,this.game,row.key,JSON.parse(row.value));sql.exec('DELETE FROM round_outbox WHERE id=?',row.id);}
|
|
1768
|
+
catch(error){this.say({ev:'record-retry',error:String(error)});this.armRoom(Date.now()+60000);return;}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1692
1771
|
queueFunctionEvent(type,data) {
|
|
1693
1772
|
if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
|
|
1694
1773
|
this.ctx.waitUntil((async()=>{
|
|
@@ -1928,6 +2007,7 @@ export class Table {
|
|
|
1928
2007
|
|
|
1929
2008
|
/** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
|
|
1930
2009
|
async alarm() {
|
|
2010
|
+
await this.flushRoundRecords();
|
|
1931
2011
|
await this.flushFunctionEvents();
|
|
1932
2012
|
// A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
|
|
1933
2013
|
try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
|
|
@@ -1935,7 +2015,63 @@ export class Table {
|
|
|
1935
2015
|
try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
|
|
1936
2016
|
}
|
|
1937
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
|
+
|
|
1938
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
|
+
}
|
|
1939
2075
|
if (new URL(request.url).pathname === '/__multiplex') {
|
|
1940
2076
|
return acceptMultiplex(request, (req, socket) => this.connect(req, socket));
|
|
1941
2077
|
}
|
|
@@ -1954,6 +2090,23 @@ export class Table {
|
|
|
1954
2090
|
return json({ ok: true });
|
|
1955
2091
|
}
|
|
1956
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
|
+
}
|
|
1957
2110
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1958
2111
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1959
2112
|
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
@@ -2016,6 +2169,12 @@ export class Table {
|
|
|
2016
2169
|
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
2017
2170
|
// Kept chat (0.29.0): the room's history is back in its window before this page hears it.
|
|
2018
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
|
+
}
|
|
2019
2178
|
const [client, server] = transport ? [null, transport] : Object.values(new WebSocketPair());
|
|
2020
2179
|
server.accept();
|
|
2021
2180
|
const via = url.searchParams.get('via');
|
|
@@ -2068,7 +2227,12 @@ export class Table {
|
|
|
2068
2227
|
// seats, so it is not counted twice). One counter write per room.
|
|
2069
2228
|
const fresh = room.seats.size === 0;
|
|
2070
2229
|
server.addEventListener('message', (e) => {
|
|
2071
|
-
|
|
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; }
|
|
2072
2236
|
// A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
|
|
2073
2237
|
if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.report();
|
|
2074
2238
|
if (fresh && !this.openCounted && room.seats.size > 0) {
|
package/worker/players.mjs
CHANGED
|
@@ -388,6 +388,7 @@ export const players = Object.freeze({
|
|
|
388
388
|
if(identity?.id && !identity.id.startsWith('office-')) { const p=await env.DB.prepare('SELECT id,name,guest,owner FROM players WHERE id=?1').bind(identity.id).first();return p?{...p,guest:Boolean(p.guest),owner:Boolean(p.owner)}:null; }
|
|
389
389
|
try { const s = await sessionOf(request, env); return s ? { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner } : null; } catch { return null; }
|
|
390
390
|
},
|
|
391
|
+
async recordsGuest(request,env,url){return newGuest(request,env,url);},
|
|
391
392
|
/** Shop guest creation uses the studio's new-guest address rate and the daily player limit. */
|
|
392
393
|
async shopGuest(request, env, url, addressLimit) { return newGuest(request, env, url, addressLimit, 'shop-new'); },
|
|
393
394
|
/** True when the request carries the session of an account its owner marked (`homie-studio players owner`). */
|
|
@@ -548,7 +549,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
|
|
|
548
549
|
if (head === 'stats') return withCookies(json({ ok: true, game: game.id, stats: s ? await readPlayerStats(env, s.player.id, game.id) : {} }), s?.cookies);
|
|
549
550
|
if (third && parts.length === 3) {
|
|
550
551
|
if (!s) return json({ ok: true, key: third, value: null, version: 0, updatedAt: null });
|
|
551
|
-
|
|
552
|
+
let key;try{key=decodeURIComponent(third);}catch{return fail(400,'key','invalid save key');}
|
|
553
|
+
const r = await getSave(env, s.player.id, game.id, key);
|
|
552
554
|
return withCookies(json(r, r.ok ? 200 : 400), s.cookies);
|
|
553
555
|
}
|
|
554
556
|
if (!s) return json({ ok: true, game: game.id, player: null, keys: [], used: { keys: 0, bytes: 0, blobBytes: 0 }, limits: SAVE_LIMITS });
|
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/room.mjs
CHANGED
|
@@ -484,15 +484,16 @@ export class NetRoom {
|
|
|
484
484
|
|
|
485
485
|
hasViewSchedule() {
|
|
486
486
|
const view = this.server?.viewSettings;
|
|
487
|
-
return this.server?.viewRadiusM != null || Boolean(view?.precisionM > 0 || view?.nearM != null && view?.farHz != null);
|
|
487
|
+
return this.server?.privateDelivery || this.server?.viewRadiusM != null || Boolean(view?.precisionM > 0 || view?.nearM != null && view?.farHz != null);
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
/** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
|
|
491
491
|
playerSnapshot(client, snap, welcome = false) {
|
|
492
492
|
if (!snap || !this.hasViewSchedule()) return snap;
|
|
493
493
|
client.viewSchedule ??= scheduledView(this.server.viewSettings ?? { radiusM: this.server.viewRadiusM }, this.tickHz);
|
|
494
|
-
|
|
495
|
-
|
|
494
|
+
let selected = client.viewSchedule(snap, client.seat);
|
|
495
|
+
if(this.server?.projectSnapshot) selected=this.server.projectSnapshot(selected,client.seat);
|
|
496
|
+
client.snapEncoder ??= snapshotEncoder(this.tickHz, !this.server?.privateDelivery);
|
|
496
497
|
if (welcome) client.snapEncoder.reset();
|
|
497
498
|
return client.snapEncoder.encode(selected, client.seat);
|
|
498
499
|
}
|
|
@@ -918,11 +919,19 @@ export class NetRoom {
|
|
|
918
919
|
this.onChat({ client: c }, m);
|
|
919
920
|
return;
|
|
920
921
|
}
|
|
922
|
+
case 'probeAck': {
|
|
923
|
+
if (c.probe && m.n === c.probe.n) {
|
|
924
|
+
const ms = now - c.probe.at; c.probe = null;
|
|
925
|
+
if(ms>=0 && ms<=10000){ c.measuredRtt=Math.min(c.measuredRtt??Infinity,ms); if(c.seat!==null)this.toServer({t:'latency',seat:c.seat,ms:c.measuredRtt}); }
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
921
929
|
case 'ping': {
|
|
922
930
|
if (this.rules && c.rules && Number.isFinite(m.readySpeed)) { c.readySpeed = Math.max(0, Math.min(1, m.readySpeed)); c.readyAt = now; }
|
|
923
931
|
const wasHidden = c.hidden;
|
|
924
932
|
c.hidden = m.hid === true;
|
|
925
933
|
this.send(c, { t: 'pong', c: m.c, st: now });
|
|
934
|
+
if(this.server && (!c.probe || now-c.probe.at>10000)){c.probe={n:crypto.randomUUID(),at:now};this.send(c,{t:'probe',n:c.probe.n});}
|
|
926
935
|
// A host whose tab went to the background will stop ticking: hand the round on now.
|
|
927
936
|
if (c.id === this.hostId && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
|
|
928
937
|
return;
|