@homie-rocks/studio 0.57.0 → 0.58.1
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 +56 -4
- package/docs/toolkit-place-patterns.md +16 -0
- package/guides/game/RULES.md +15 -1
- package/lib/agent-seat.mjs +10 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +10 -0
- package/netplay/netplay.ts +30 -9
- package/package.json +1 -1
- package/parts/README.md +28 -0
- package/parts/catalogue.json +236 -0
- package/parts/owner-action-checklist/LICENSE +202 -0
- package/parts/owner-action-checklist/NOTICE +3 -0
- package/parts/owner-action-checklist/README.md +68 -0
- package/parts/owner-action-checklist/part.json +95 -0
- package/parts/owner-action-checklist/preview/index.html +16 -0
- package/parts/owner-action-checklist/src/handler.mjs +44 -0
- package/parts/owner-action-checklist/src/index.mjs +96 -0
- package/parts/owner-action-checklist/src/style.css +3 -0
- package/parts/owner-action-checklist/tuning.json +3 -0
- package/parts/terrain-route-map/LICENSE +202 -0
- package/parts/terrain-route-map/NOTICE +3 -0
- package/parts/terrain-route-map/README.md +67 -0
- package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
- package/parts/terrain-route-map/part.json +133 -0
- package/parts/terrain-route-map/preview/demo.js +4127 -0
- package/parts/terrain-route-map/preview/heightmap.png +0 -0
- package/parts/terrain-route-map/preview/index.html +1 -0
- package/parts/terrain-route-map/preview/main.mjs +4 -0
- package/parts/terrain-route-map/preview/route.json +1 -0
- package/parts/terrain-route-map/src/data.mjs +81 -0
- package/parts/terrain-route-map/src/index.mjs +131 -0
- package/parts/terrain-route-map/src/style.css +3 -0
- package/parts/terrain-route-map/tuning.json +5 -0
- package/rules/browser.ts +2 -0
- package/rules/core.ts +3 -0
- package/rules/host.ts +170 -16
- package/rules/rules.ts +16 -3
- package/rules/view.ts +39 -7
- package/starters/coin-dash/game.json +2 -1
- package/starters/ember-vale/game.json +2 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/tools/tools.ts +1 -1
- package/worker/agents.mjs +20 -4
- package/worker/booking-calendar.mjs +15 -2
- package/worker/build-id.mjs +1 -1
- package/worker/limits.mjs +3 -1
- package/worker/room-sessions.mjs +1 -1
- package/worker/room.mjs +64 -9
- package/worker/table.mjs +51 -18
- package/worker/version.mjs +1 -1
package/worker/table.mjs
CHANGED
|
@@ -71,6 +71,8 @@ export class Table {
|
|
|
71
71
|
this.gateHeld = new Map();
|
|
72
72
|
this.gatePromised = new Map();
|
|
73
73
|
this.roomAlarmAt = 0;
|
|
74
|
+
/** The alarm a house guide asked for. A rest replaces it with the room's own deadline, or the guide would wake the object every few seconds. */
|
|
75
|
+
this.guideAlarmAt = 0;
|
|
74
76
|
this.durable = null;
|
|
75
77
|
this.roomSave = null;
|
|
76
78
|
this.endedRoom = null;
|
|
@@ -175,6 +177,8 @@ export class Table {
|
|
|
175
177
|
this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
|
|
176
178
|
// A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
|
|
177
179
|
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
180
|
+
// Server and browser hosts both rest through the relay. Guides stop here, including when the host is a page.
|
|
181
|
+
this.room.onRest = (on) => { this.house?.rest(on); };
|
|
178
182
|
// A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
|
|
179
183
|
this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
|
|
180
184
|
if (this.isPersistent()) this.room.forgetMs = Infinity;
|
|
@@ -216,7 +220,9 @@ export class Table {
|
|
|
216
220
|
// A restore's provisional pause is not a recorded departure. A stored alarm,
|
|
217
221
|
// even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
|
|
218
222
|
const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
|
|
219
|
-
|
|
223
|
+
// A resting room was saved with its people still here, and hibernated on purpose: its age is no absence.
|
|
224
|
+
const rested = saved?.resting === true && saved.pausedAt === null;
|
|
225
|
+
const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: rested ? Date.now() : this.recoveredAlarmAt }) : Infinity;
|
|
220
226
|
const expired = Date.now() >= restoredEnd;
|
|
221
227
|
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.');
|
|
222
228
|
if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
|
|
@@ -234,14 +240,21 @@ export class Table {
|
|
|
234
240
|
// The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
|
|
235
241
|
room.seats.clear(); room.state.clear(); room.stateBytes = 0;
|
|
236
242
|
room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
if (rested) {
|
|
244
|
+
// Rebuilt after hibernating on purpose: this save is exactly the world every browser holds. It comes back under
|
|
245
|
+
// the same epoch, so the input that wakes it applies, and nothing is written (the room wakes about once a minute).
|
|
246
|
+
this.pausedAt = null; this.pauseFromRestore = false;
|
|
247
|
+
} else {
|
|
248
|
+
this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
|
|
249
|
+
this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
|
|
250
|
+
saved.pausedAt = this.pausedAt;
|
|
251
|
+
// A failed pause write must never be mistaken for an invalid host save.
|
|
252
|
+
try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
|
|
253
|
+
}
|
|
242
254
|
}
|
|
243
|
-
|
|
244
|
-
|
|
255
|
+
// A rested save is replayed under its own epoch; any other restore (a crash, a deploy) takes a new one.
|
|
256
|
+
const epoch = saved ? (rested ? saved.host.core.epoch : Math.max(saved.host.core.epoch, boot.epoch) + 1) : undefined;
|
|
257
|
+
if (saved && !rested) this.durable.boot({ epoch, count: boot.count });
|
|
245
258
|
this.hostCreatedAt = Date.now();
|
|
246
259
|
let ticks = 0;
|
|
247
260
|
this.hostRt = startHost(this.game, {
|
|
@@ -257,8 +270,17 @@ export class Table {
|
|
|
257
270
|
// Alarm-driven suspension requires the native hibernation lifecycle.
|
|
258
271
|
// Legacy adapters keep their host clock, as browser hosts do.
|
|
259
272
|
...(this.ctx.acceptWebSocket ? { onRoundIdle: until => { room.tellWatchers(); this.report(); this.recordRound(); this.armRoom(until); this.ctx.waitUntil(this.sleepRoom()); } } : {}),
|
|
273
|
+
// A room nobody is playing rests with its people connected (rules/host.ts), and hibernates like a round break.
|
|
274
|
+
// A deliberate rest is a clean stop: the restore-loop count starts again, so waking it is never a crash.
|
|
275
|
+
...(this.ctx.acceptWebSocket && this.ctx.storage.sql ? { onRest: on => {
|
|
276
|
+
// Guides stop with the room: a think alarm would wake the object, and the command it sent would wake the sim.
|
|
277
|
+
this.house?.rest(on);
|
|
278
|
+
if (!on) { this.armRoom(Date.now() + ROOM_ALARM_MS); this.start(); return; }
|
|
279
|
+
try { this.durable?.boot({ epoch: this.hostRt?.epoch ?? epoch, count: 0 }); this.pendingBoot = null; } catch (error) { this.storageProblem(error); }
|
|
280
|
+
room.tellWatchers(); this.report(); this.ctx.waitUntil(this.sleepRoom());
|
|
281
|
+
} } : {}),
|
|
260
282
|
onTick: () => {
|
|
261
|
-
if (!saved) return;
|
|
283
|
+
if (!saved || rested) return;
|
|
262
284
|
ticks += 1;
|
|
263
285
|
if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
|
|
264
286
|
if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
|
|
@@ -315,7 +337,7 @@ export class Table {
|
|
|
315
337
|
const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
|
|
316
338
|
for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
|
|
317
339
|
}
|
|
318
|
-
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 };
|
|
340
|
+
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, ...(this.hostRt?.resting ? { resting: true } : {}), stateHash: build.stateHash, buildHash: build.build, host, net };
|
|
319
341
|
const clearing = this.cleanupPending;
|
|
320
342
|
if (clearing) this.durable.clear();
|
|
321
343
|
if (this.isPersistent()) {
|
|
@@ -370,7 +392,7 @@ export class Table {
|
|
|
370
392
|
if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
|
|
371
393
|
this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
|
|
372
394
|
!Number.isFinite(saved.at) || saved.at < 0 || saved.at > Date.now() + 5000 || saved.pausedAt !== null && (!Number.isFinite(saved.pausedAt) || saved.pausedAt < 0 || saved.pausedAt > Date.now() + 5000) ||
|
|
373
|
-
saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
|
|
395
|
+
saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' || saved.resting !== undefined && saved.resting !== true ||
|
|
374
396
|
saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
|
|
375
397
|
saved.at = Math.min(saved.at, Date.now());
|
|
376
398
|
if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
|
|
@@ -796,7 +818,10 @@ export class Table {
|
|
|
796
818
|
this.house = new HouseAgents({
|
|
797
819
|
room, env: this.env,
|
|
798
820
|
// The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
|
|
799
|
-
setAlarm: (at) => {
|
|
821
|
+
setAlarm: (at) => {
|
|
822
|
+
this.guideAlarmAt = at;
|
|
823
|
+
this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
|
|
824
|
+
},
|
|
800
825
|
budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
|
|
801
826
|
log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
|
|
802
827
|
});
|
|
@@ -1191,7 +1216,7 @@ export class Table {
|
|
|
1191
1216
|
} else {
|
|
1192
1217
|
const h = room.attach(conn,server.resume);
|
|
1193
1218
|
server.remember?.(h.id);
|
|
1194
|
-
if (server.resume && room.server) room.joinServer(room.clients.get(h.id));
|
|
1219
|
+
if (server.resume && room.server) room.joinServer(room.clients.get(h.id), true);
|
|
1195
1220
|
// A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
|
|
1196
1221
|
// seats, so it is not counted twice). One counter write per room.
|
|
1197
1222
|
const fresh = room.seats.size === 0;
|
|
@@ -1281,7 +1306,8 @@ export class Table {
|
|
|
1281
1306
|
// Older ports publish snapshots without a round protocol. Once play starts,
|
|
1282
1307
|
// treat them as active until empty: persisting every frame would turn their
|
|
1283
1308
|
// ordinary network traffic into a stream of SQLite writes.
|
|
1284
|
-
|
|
1309
|
+
// A browser host that rested its room (rules/host.ts in the page) sends nothing until somebody plays: it sleeps.
|
|
1310
|
+
const active = this.hostRt ? this.hostRt.running : !this.room?.resting && (this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0));
|
|
1285
1311
|
return this.room && this.hibernationReady && this.sessionStore && this.ctx.storage.sql && this.ctx.acceptWebSocket && (!this.relayed || this.relays.size > 0) && (!this.hostRt || this.hostRt.roundIdleUntil !== null || !this.hostRt.running) && !active && !this.restoringSockets;
|
|
1286
1312
|
}
|
|
1287
1313
|
async sleepRoom() {
|
|
@@ -1304,12 +1330,19 @@ export class Table {
|
|
|
1304
1330
|
}
|
|
1305
1331
|
this.saveSessions();
|
|
1306
1332
|
for(const relay of this.relays.values()){relay.session.flush();relay.socket.sleep();}
|
|
1307
|
-
// Edge-answered heartbeats need only a minute's liveness sweep
|
|
1308
|
-
// client still keeps its
|
|
1309
|
-
|
|
1333
|
+
// Edge-answered heartbeats need only a minute's liveness sweep: a browser that has pinged the edge, or one that
|
|
1334
|
+
// understands a resting room (it pings the edge from the moment it rests). A legacy client still keeps its
|
|
1335
|
+
// original hello/heartbeat deadline.
|
|
1336
|
+
const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping||c.rest&&c.conn.idlePing?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
|
|
1310
1337
|
for(const control of [room.vote?.open?room.vote:null,room.regate,room.agentsOut])if(control?.until)due.push(control.until);
|
|
1311
1338
|
const at=Math.max(now+1,Math.min(...due)),alarm=await this.ctx.storage.getAlarm();
|
|
1312
|
-
|
|
1339
|
+
// A guide's think is sooner than the liveness sweep, and sleep only moves an alarm earlier. While the host rests,
|
|
1340
|
+
// that think must not survive: it would wake the object every few seconds and its command would start the room.
|
|
1341
|
+
// A spectator's own shorter deadline stays, because `at` is already the soonest real one.
|
|
1342
|
+
const roomResting=this.hostRt?.resting===true||this.room?.resting===true;
|
|
1343
|
+
const guideSoon=roomResting && this.guideAlarmAt && alarm!=null && alarm<=this.guideAlarmAt+1000;
|
|
1344
|
+
if(alarm==null||alarm>at||guideSoon)await this.ctx.storage.setAlarm(at);
|
|
1345
|
+
if(guideSoon)this.guideAlarmAt=0;
|
|
1313
1346
|
}
|
|
1314
1347
|
start() {
|
|
1315
1348
|
if(this.restoringSockets)return;
|
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.58.1';
|