@maka/maka-cli 5.175.0 → 5.176.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/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +8 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js +19 -24
- package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +13 -3
- package/bundle/typescript/src/commands/game/sideQuest/game.js +91 -12
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +336 -275
- package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +35 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +5 -19
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/hub-seed.js +284 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/site-transport.js +61 -0
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +53 -6
- package/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +0 -1413
|
@@ -188,6 +188,24 @@ export function buildPlayerFromSave(save) {
|
|
|
188
188
|
player.refreshEdge(player.ateThisCycle && player.sleptThisCycle);
|
|
189
189
|
return player;
|
|
190
190
|
}
|
|
191
|
+
/** Each rider's DocWagon contract comes off their OWN save (the
|
|
192
|
+
* ruling: the wristband works at anyone's table). */
|
|
193
|
+
function loadContract(game, save) {
|
|
194
|
+
const dw = save.game?.docwagon;
|
|
195
|
+
if (dw)
|
|
196
|
+
game.docwagonByPlayer.set(save.playerName, { ...dw });
|
|
197
|
+
}
|
|
198
|
+
/** The hosting server's death/extraction hooks, shared by runs and hubs. */
|
|
199
|
+
function wireDeathHooks(game, logger, opts) {
|
|
200
|
+
game.onPlayerDeath = (p) => {
|
|
201
|
+
logger.system(`${p.name} is dead. The street keeps the change.`, 'all', 'death', { player: p.name });
|
|
202
|
+
opts.onPlayerDeath?.(p.name);
|
|
203
|
+
};
|
|
204
|
+
game.onPlayerExtracted = (p) => {
|
|
205
|
+
logger.system(`A DocWagon team carried ${p.name} off the job -- alive, billed, and out of the run.`, 'all', 'extracted', { player: p.name });
|
|
206
|
+
opts.onPlayerExtracted?.(p.name);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
191
209
|
export async function createHeadlessSession(opts) {
|
|
192
210
|
const logger = new EmitterLogger(opts.sink, opts.debug);
|
|
193
211
|
const ctx = { sessionId: opts.sessionId, logger, client: opts.client };
|
|
@@ -199,22 +217,8 @@ export async function createHeadlessSession(opts) {
|
|
|
199
217
|
if (typeof opts.tier === 'number')
|
|
200
218
|
game.tier = opts.tier;
|
|
201
219
|
logger.bindGame(() => game);
|
|
202
|
-
game
|
|
203
|
-
|
|
204
|
-
opts.onPlayerDeath?.(p.name);
|
|
205
|
-
};
|
|
206
|
-
game.onPlayerExtracted = (p) => {
|
|
207
|
-
logger.system(`A DocWagon team carried ${p.name} off the job -- alive, billed, and out of the run.`, 'all', 'extracted', { player: p.name });
|
|
208
|
-
opts.onPlayerExtracted?.(p.name);
|
|
209
|
-
};
|
|
210
|
-
// Each rider's DocWagon contract comes off their OWN save (the
|
|
211
|
-
// ruling: the wristband works at anyone's table).
|
|
212
|
-
const loadContract = (save) => {
|
|
213
|
-
const dw = save.game?.docwagon;
|
|
214
|
-
if (dw)
|
|
215
|
-
game.docwagonByPlayer.set(save.playerName, { ...dw });
|
|
216
|
-
};
|
|
217
|
-
loadContract(opts.players[0]);
|
|
220
|
+
wireDeathHooks(game, logger, opts);
|
|
221
|
+
loadContract(game, opts.players[0]);
|
|
218
222
|
await game.initEngine({
|
|
219
223
|
logFilePath: '',
|
|
220
224
|
useAi: opts.useAi ?? false,
|
|
@@ -281,273 +285,330 @@ export async function createHeadlessSession(opts) {
|
|
|
281
285
|
catch (err) {
|
|
282
286
|
logger.write(`spawnCrew (hosted boot) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
283
287
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
288
|
+
return wireHeadless(game, ctx, logger);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* EVERYTHING A HOSTED SESSION SHARES, run or hub (2026-09-12): the
|
|
293
|
+
* per-player map/room pushes, the command queue with the typist's own
|
|
294
|
+
* auth token, late joins, status, snapshots, dispose, the first paint
|
|
295
|
+
* and the scene-swap hook. createHeadlessSession boots a RUN and hands
|
|
296
|
+
* its Game here; createHeadlessHub boots a HUB and does the same.
|
|
297
|
+
*/
|
|
298
|
+
function wireHeadless(game, ctx, logger) {
|
|
299
|
+
const spawnRoom = () => game.scene.determineStartRoom();
|
|
300
|
+
// PER-PLAYER MAP EVENTS (map-overlay wave 2026-08-24): each rider
|
|
301
|
+
// gets their own icon minimap -- the client holds no Room objects
|
|
302
|
+
// in a shared run. Fog of war and AR eyes are THIS player's. A
|
|
303
|
+
// jacked-in rider gets an EMPTY event -- their overlay blinks out
|
|
304
|
+
// until they jack back out (matrix ruling 2026-08-24).
|
|
305
|
+
const pushMap = () => {
|
|
306
|
+
const rooms = game.scene.getRooms();
|
|
307
|
+
const sealing = (r, d) => game.scene.barrierSealing(r, d);
|
|
308
|
+
for (const p of game.scene.getPlayers()) {
|
|
309
|
+
if (game.scene.deadPlayers.has(p.name))
|
|
310
|
+
continue;
|
|
311
|
+
const here = p.currentLocation;
|
|
312
|
+
if (!here)
|
|
313
|
+
continue;
|
|
314
|
+
if (p.plane === 'matrix') {
|
|
315
|
+
logger.system('', { actor: p.name }, 'map', {});
|
|
316
|
+
logger.system('', { actor: p.name }, 'room', {});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const eyes = { visited: p.visitedRooms, arEyes: p.hasMatrixEyes() };
|
|
320
|
+
// THE CAPTION THE SOLO PANEL HAS ALWAYS HAD
|
|
321
|
+
// (RwPkFsRwd4c2uHvGN: "in a new shared run, there is no name on
|
|
322
|
+
// the area map"). renderMapViewport returns grid rows only --
|
|
323
|
+
// the name underneath is the CALLER's to add, and Game.updateExits
|
|
324
|
+
// adds it on the solo path. This push never did, so a rider on a
|
|
325
|
+
// shared run read an unlabelled map: the room box beside it was
|
|
326
|
+
// captioned, which is what made the gap look like a bug rather
|
|
327
|
+
// than a design.
|
|
328
|
+
//
|
|
329
|
+
// Untrimmed on purpose: the server cannot know the client's box
|
|
330
|
+
// width, the same compromise the room caption below already
|
|
331
|
+
// ships on.
|
|
332
|
+
const mapLines = renderMapViewport(rooms, here, sealing, eyes);
|
|
333
|
+
if (mapLines.length > 0)
|
|
334
|
+
mapLines.push(mapCaption(here.name));
|
|
335
|
+
// STRUCTURED DISTRICT MAP, ADDITIVE (2026-09-11): the same layout
|
|
336
|
+
// as data, on this same event, so the browser client draws it in
|
|
337
|
+
// its own style instead of reparsing the text (the `data.grid`
|
|
338
|
+
// pattern below). A decoration must never kill the push.
|
|
339
|
+
let district;
|
|
340
|
+
try {
|
|
341
|
+
district = serializeDistrictMap(rooms, here, sealing, eyes);
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
logger.write(`District map serialize failed for ${p.name} in ${here.name}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
345
|
+
}
|
|
346
|
+
logger.system(mapLines.join('\n'), { actor: p.name }, 'map', district ? { district } : undefined);
|
|
347
|
+
// THE ROOM BOX'S TWIN (playtest 2026-08-27, reported twice:
|
|
348
|
+
// "the room minimap isn't updating in the shared run, I only
|
|
349
|
+
// see @open floor"). Game.updateExits early-returns on
|
|
350
|
+
// `remoteSession`, so in a shared run the room box kept
|
|
351
|
+
// whatever the HUB painted into it and then FROZE -- the stale
|
|
352
|
+
// caption the player was reading was from the bar they left.
|
|
353
|
+
// The district map has had a server push since the map-overlay
|
|
354
|
+
// wave; this is the same wiring for the panel beside it.
|
|
355
|
+
//
|
|
356
|
+
// Rendered at renderRoomLayoutCompact's DEFAULT width, exactly
|
|
357
|
+
// as renderMapViewport is above: the server cannot know the
|
|
358
|
+
// client's box width (23% of a terminal it never sees), and
|
|
359
|
+
// the district map has shipped on that same compromise since
|
|
360
|
+
// 2026-08-24. Width only moves the elevation bar's right
|
|
361
|
+
// justification and how much grid is windowed in.
|
|
362
|
+
//
|
|
363
|
+
// A DECORATION MUST NEVER KILL THE RUN -- the solo path wraps
|
|
364
|
+
// this same render for the same reason, and a throw HERE would
|
|
365
|
+
// take down the whole table's command, not one player's panel.
|
|
366
|
+
try {
|
|
367
|
+
const roomLines = renderRoomLayoutCompact(here, p, {
|
|
368
|
+
isOtherPlayer: a => game.scene.isHumanControlled(a),
|
|
369
|
+
isHostile: a => hostileByStance(a) || enemyInFight(game.scene, p, a),
|
|
370
|
+
// The leader's crew are the TABLE's crew -- without this
|
|
371
|
+
// every non-leader saw them as neutral bystanders.
|
|
372
|
+
isTableAlly: crewOfTable(game.scene.getPlayers()),
|
|
373
|
+
});
|
|
374
|
+
if (roomLines.length > 0) {
|
|
375
|
+
roomLines.push(mapCaption(roomCaption(here, p)));
|
|
376
|
+
}
|
|
377
|
+
// STRUCTURED ROOM GEOMETRY, ADDITIVE (2026-09-07): the ASCII
|
|
378
|
+
// above stays the log-adjacent text every consumer already
|
|
379
|
+
// reads; a browser client wanting the site's real SVG DotMap
|
|
380
|
+
// instead reads `data.grid` off this SAME event -- no new
|
|
381
|
+
// event kind, no publication change (GameEvent.data already
|
|
382
|
+
// round-trips end to end). ensureGrid() always synthesizes now
|
|
383
|
+
// ("EVERY ROOM IS MATRIXED", room.ts:357-370, the 2026-08-25
|
|
384
|
+
// ruling that retired the spotless-room compat gap) -- the
|
|
385
|
+
// `undefined` in its return type is a vestige, kept guarded
|
|
386
|
+
// here anyway rather than asserted, since a decoration must
|
|
387
|
+
// never take down the room push (same reasoning as the
|
|
388
|
+
// try/catch this sits inside).
|
|
389
|
+
const grid = here.ensureGrid();
|
|
390
|
+
const data = grid ? { grid: serializeRoomGrid(grid, here.name) } : undefined;
|
|
391
|
+
logger.system(roomLines.join('\n'), { actor: p.name }, 'room', data);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
logger.write(`Room overlay render failed for ${p.name} in ${here.name}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
395
|
+
logger.system('', { actor: p.name }, 'room', {});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const session = {
|
|
400
|
+
game,
|
|
401
|
+
logger,
|
|
402
|
+
command: (actorName, input, opts) => runInSession(ctx, () => runWithAuthToken(opts?.authToken, async () => {
|
|
403
|
+
const res = await game.handleInputAs(actorName, input);
|
|
404
|
+
session.pushStatus();
|
|
405
|
+
pushMap();
|
|
406
|
+
// THE WEB MAP FOLLOWS THE ROOM, NOT JUST YOUR OWN BODY
|
|
407
|
+
// (oHhjxjqKuptDGMHLD: a looted body stayed on the map "until I
|
|
408
|
+
// move"; 9aEMA4APbB6rYtWnT: a killed NPC's dot never changed).
|
|
409
|
+
// The condition beat is what the browser draws actors from. A
|
|
410
|
+
// kill or a loot mutates the room, not the killer, so this
|
|
411
|
+
// nudge -- one per command, coalesced -- still covers those.
|
|
412
|
+
// MOVEMENT NO LONGER WAITS FOR IT (q6tBaFn5EDmGMoJFh): the room
|
|
413
|
+
// publishes every occupant's position change to the humans in
|
|
414
|
+
// it the moment it happens (Room.noteOccupancyChanged), so an
|
|
415
|
+
// NPC stepping on its own reflect chain between commands
|
|
416
|
+
// reaches the browser without anyone typing.
|
|
417
|
+
game.player.noteConditionChanged();
|
|
418
|
+
// EVERY OTHER HUMAN WHO SAW THE ROOM CHANGE (genfBamjTvyHTdWhu):
|
|
419
|
+
// a member's overlay is written from the member's OWN beat now
|
|
420
|
+
// (Game.wireConditionReporting), so the same once-per-command
|
|
421
|
+
// nudge covers the typist and whoever shares their room -- a
|
|
422
|
+
// kill or a loot changes the room for all of them.
|
|
423
|
+
const typist = game.scene.getPlayerByName(actorName);
|
|
293
424
|
for (const p of game.scene.getPlayers()) {
|
|
294
|
-
if (game.scene.deadPlayers.has(p.name))
|
|
425
|
+
if (p === game.player || game.scene.deadPlayers.has(p.name))
|
|
295
426
|
continue;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
continue;
|
|
299
|
-
if (p.plane === 'matrix') {
|
|
300
|
-
logger.system('', { actor: p.name }, 'map', {});
|
|
301
|
-
logger.system('', { actor: p.name }, 'room', {});
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const eyes = { visited: p.visitedRooms, arEyes: p.hasMatrixEyes() };
|
|
305
|
-
// THE CAPTION THE SOLO PANEL HAS ALWAYS HAD
|
|
306
|
-
// (RwPkFsRwd4c2uHvGN: "in a new shared run, there is no name on
|
|
307
|
-
// the area map"). renderMapViewport returns grid rows only --
|
|
308
|
-
// the name underneath is the CALLER's to add, and Game.updateExits
|
|
309
|
-
// adds it on the solo path. This push never did, so a rider on a
|
|
310
|
-
// shared run read an unlabelled map: the room box beside it was
|
|
311
|
-
// captioned, which is what made the gap look like a bug rather
|
|
312
|
-
// than a design.
|
|
313
|
-
//
|
|
314
|
-
// Untrimmed on purpose: the server cannot know the client's box
|
|
315
|
-
// width, the same compromise the room caption below already
|
|
316
|
-
// ships on.
|
|
317
|
-
const mapLines = renderMapViewport(rooms, here, sealing, eyes);
|
|
318
|
-
if (mapLines.length > 0)
|
|
319
|
-
mapLines.push(mapCaption(here.name));
|
|
320
|
-
// STRUCTURED DISTRICT MAP, ADDITIVE (2026-09-11): the same layout
|
|
321
|
-
// as data, on this same event, so the browser client draws it in
|
|
322
|
-
// its own style instead of reparsing the text (the `data.grid`
|
|
323
|
-
// pattern below). A decoration must never kill the push.
|
|
324
|
-
let district;
|
|
325
|
-
try {
|
|
326
|
-
district = serializeDistrictMap(rooms, here, sealing, eyes);
|
|
327
|
-
}
|
|
328
|
-
catch (err) {
|
|
329
|
-
logger.write(`District map serialize failed for ${p.name} in ${here.name}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
330
|
-
}
|
|
331
|
-
logger.system(mapLines.join('\n'), { actor: p.name }, 'map', district ? { district } : undefined);
|
|
332
|
-
// THE ROOM BOX'S TWIN (playtest 2026-08-27, reported twice:
|
|
333
|
-
// "the room minimap isn't updating in the shared run, I only
|
|
334
|
-
// see @open floor"). Game.updateExits early-returns on
|
|
335
|
-
// `remoteSession`, so in a shared run the room box kept
|
|
336
|
-
// whatever the HUB painted into it and then FROZE -- the stale
|
|
337
|
-
// caption the player was reading was from the bar they left.
|
|
338
|
-
// The district map has had a server push since the map-overlay
|
|
339
|
-
// wave; this is the same wiring for the panel beside it.
|
|
340
|
-
//
|
|
341
|
-
// Rendered at renderRoomLayoutCompact's DEFAULT width, exactly
|
|
342
|
-
// as renderMapViewport is above: the server cannot know the
|
|
343
|
-
// client's box width (23% of a terminal it never sees), and
|
|
344
|
-
// the district map has shipped on that same compromise since
|
|
345
|
-
// 2026-08-24. Width only moves the elevation bar's right
|
|
346
|
-
// justification and how much grid is windowed in.
|
|
347
|
-
//
|
|
348
|
-
// A DECORATION MUST NEVER KILL THE RUN -- the solo path wraps
|
|
349
|
-
// this same render for the same reason, and a throw HERE would
|
|
350
|
-
// take down the whole table's command, not one player's panel.
|
|
351
|
-
try {
|
|
352
|
-
const roomLines = renderRoomLayoutCompact(here, p, {
|
|
353
|
-
isOtherPlayer: a => game.scene.isHumanControlled(a),
|
|
354
|
-
isHostile: a => hostileByStance(a) || enemyInFight(game.scene, p, a),
|
|
355
|
-
// The leader's crew are the TABLE's crew -- without this
|
|
356
|
-
// every non-leader saw them as neutral bystanders.
|
|
357
|
-
isTableAlly: crewOfTable(game.scene.getPlayers()),
|
|
358
|
-
});
|
|
359
|
-
if (roomLines.length > 0) {
|
|
360
|
-
roomLines.push(mapCaption(roomCaption(here, p)));
|
|
361
|
-
}
|
|
362
|
-
// STRUCTURED ROOM GEOMETRY, ADDITIVE (2026-09-07): the ASCII
|
|
363
|
-
// above stays the log-adjacent text every consumer already
|
|
364
|
-
// reads; a browser client wanting the site's real SVG DotMap
|
|
365
|
-
// instead reads `data.grid` off this SAME event -- no new
|
|
366
|
-
// event kind, no publication change (GameEvent.data already
|
|
367
|
-
// round-trips end to end). ensureGrid() always synthesizes now
|
|
368
|
-
// ("EVERY ROOM IS MATRIXED", room.ts:357-370, the 2026-08-25
|
|
369
|
-
// ruling that retired the spotless-room compat gap) -- the
|
|
370
|
-
// `undefined` in its return type is a vestige, kept guarded
|
|
371
|
-
// here anyway rather than asserted, since a decoration must
|
|
372
|
-
// never take down the room push (same reasoning as the
|
|
373
|
-
// try/catch this sits inside).
|
|
374
|
-
const grid = here.ensureGrid();
|
|
375
|
-
const data = grid ? { grid: serializeRoomGrid(grid, here.name) } : undefined;
|
|
376
|
-
logger.system(roomLines.join('\n'), { actor: p.name }, 'room', data);
|
|
377
|
-
}
|
|
378
|
-
catch (err) {
|
|
379
|
-
logger.write(`Room overlay render failed for ${p.name} in ${here.name}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
380
|
-
logger.system('', { actor: p.name }, 'room', {});
|
|
381
|
-
}
|
|
427
|
+
if (p === typist || (typist && p.currentLocation === typist.currentLocation))
|
|
428
|
+
p.noteConditionChanged();
|
|
382
429
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
430
|
+
return res;
|
|
431
|
+
})),
|
|
432
|
+
addPlayer: (save) => runInSession(ctx, async () => {
|
|
433
|
+
// REJOIN IS NOT A JOIN (playtest 2026-08-27, Sherry: "got an
|
|
434
|
+
// internal server error"). A dropped DDP connection -- a
|
|
435
|
+
// laptop lid, a wifi blip, a client restart -- calls
|
|
436
|
+
// sideQuest.join again with the same runner, and this used to
|
|
437
|
+
// walk straight into Scene.addActor, which throws "Actor with
|
|
438
|
+
// name X already exists in the scene." The method died, the
|
|
439
|
+
// player saw a raw 500, and their seat was unreachable for
|
|
440
|
+
// the rest of the run even though the engine still held a
|
|
441
|
+
// perfectly good actor for them.
|
|
442
|
+
//
|
|
443
|
+
// The right answer is to hand the existing actor back: their
|
|
444
|
+
// position, inventory, wounds and watchers are all still live
|
|
445
|
+
// on the table. Re-push status and the overlays so the
|
|
446
|
+
// reconnecting client repaints from the server's truth, and
|
|
447
|
+
// deliberately DON'T re-narrate the taxi -- they never left,
|
|
448
|
+
// and the table shouldn't be told they arrived twice.
|
|
449
|
+
const existing = game.scene.getPlayers().find(x => x.name === save.playerName);
|
|
450
|
+
if (existing) {
|
|
451
|
+
logger.write(`addPlayer: ${existing.name} is already seated -- treating as a RECONNECT, not a join.`);
|
|
452
|
+
logger.status(existing.name, game.statusLinesFor(existing));
|
|
390
453
|
pushMap();
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// perfectly good actor for them.
|
|
427
|
-
//
|
|
428
|
-
// The right answer is to hand the existing actor back: their
|
|
429
|
-
// position, inventory, wounds and watchers are all still live
|
|
430
|
-
// on the table. Re-push status and the overlays so the
|
|
431
|
-
// reconnecting client repaints from the server's truth, and
|
|
432
|
-
// deliberately DON'T re-narrate the taxi -- they never left,
|
|
433
|
-
// and the table shouldn't be told they arrived twice.
|
|
434
|
-
const existing = game.scene.getPlayers().find(x => x.name === save.playerName);
|
|
435
|
-
if (existing) {
|
|
436
|
-
logger.write(`addPlayer: ${existing.name} is already seated -- treating as a RECONNECT, not a join.`);
|
|
437
|
-
logger.status(existing.name, game.statusLinesFor(existing));
|
|
438
|
-
pushMap();
|
|
439
|
-
// The browser's Room box reads the overlay, not the room
|
|
440
|
-
// event: one fresh beat so a reconnecting member repaints
|
|
441
|
-
// their own map too (genfBamjTvyHTdWhu).
|
|
442
|
-
existing.noteConditionChanged();
|
|
443
|
-
return existing.name;
|
|
444
|
-
}
|
|
445
|
-
const p = buildPlayerFromSave(save);
|
|
446
|
-
// A MEMBER'S OWN LIVE-SHEET LANE (genfBamjTvyHTdWhu: "in a
|
|
447
|
-
// shared run, I'm not the leader. I have no room map"). The
|
|
448
|
-
// hook the constructor installs on the primary, installed on
|
|
449
|
-
// the joiner too: their beats reach the sink under their own
|
|
450
|
-
// name and the host writes them to THEIR overlay. Wired before
|
|
451
|
-
// the spawn so the seating below is the first beat.
|
|
452
|
-
game.wireConditionReporting(p);
|
|
453
|
-
const room = spawnRoom();
|
|
454
|
-
game.scene.addPlayer(p, room);
|
|
455
|
-
// ROOM GRIDS (spatial wave 2026-08-25, included shared-run
|
|
456
|
-
// fix): a late joiner used to land CIRCULATING in a spotted
|
|
457
|
-
// start room -- entrySpotFor gives them a real position
|
|
458
|
-
// (the doorway spot, same as a solo "go" arrival), set AFTER
|
|
459
|
-
// addPlayer sets currentLocation (the setter clears atSpot).
|
|
460
|
-
const spot = entrySpotFor(room, undefined);
|
|
461
|
-
if (spot)
|
|
462
|
-
p.atSpot = spot;
|
|
463
|
-
loadContract(save);
|
|
464
|
-
p.noteConditionChanged();
|
|
465
|
-
logger.system(`${p.name} steps out of a taxi and into the job.`, 'all', 'joined', { player: p.name });
|
|
454
|
+
// The browser's Room box reads the overlay, not the room
|
|
455
|
+
// event: one fresh beat so a reconnecting member repaints
|
|
456
|
+
// their own map too (genfBamjTvyHTdWhu).
|
|
457
|
+
existing.noteConditionChanged();
|
|
458
|
+
return existing.name;
|
|
459
|
+
}
|
|
460
|
+
const p = buildPlayerFromSave(save);
|
|
461
|
+
// A MEMBER'S OWN LIVE-SHEET LANE (genfBamjTvyHTdWhu: "in a
|
|
462
|
+
// shared run, I'm not the leader. I have no room map"). The
|
|
463
|
+
// hook the constructor installs on the primary, installed on
|
|
464
|
+
// the joiner too: their beats reach the sink under their own
|
|
465
|
+
// name and the host writes them to THEIR overlay. Wired before
|
|
466
|
+
// the spawn so the seating below is the first beat.
|
|
467
|
+
game.wireConditionReporting(p);
|
|
468
|
+
const room = spawnRoom();
|
|
469
|
+
game.scene.addPlayer(p, room);
|
|
470
|
+
// ROOM GRIDS (spatial wave 2026-08-25, included shared-run
|
|
471
|
+
// fix): a late joiner used to land CIRCULATING in a spotted
|
|
472
|
+
// start room -- entrySpotFor gives them a real position
|
|
473
|
+
// (the doorway spot, same as a solo "go" arrival), set AFTER
|
|
474
|
+
// addPlayer sets currentLocation (the setter clears atSpot).
|
|
475
|
+
const spot = entrySpotFor(room, undefined);
|
|
476
|
+
if (spot)
|
|
477
|
+
p.atSpot = spot;
|
|
478
|
+
loadContract(game, save);
|
|
479
|
+
p.noteConditionChanged();
|
|
480
|
+
logger.system(`${p.name} steps out of a taxi and into the job.`, 'all', 'joined', { player: p.name });
|
|
481
|
+
logger.status(p.name, game.statusLinesFor(p));
|
|
482
|
+
pushMap();
|
|
483
|
+
return p.name;
|
|
484
|
+
}),
|
|
485
|
+
pushStatus: () => {
|
|
486
|
+
for (const p of game.scene.getPlayers()) {
|
|
487
|
+
if (game.scene.deadPlayers.has(p.name))
|
|
488
|
+
continue;
|
|
466
489
|
logger.status(p.name, game.statusLinesFor(p));
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
snapshotPlayers: () => {
|
|
493
|
+
const out = {};
|
|
494
|
+
for (const p of game.scene.getPlayers()) {
|
|
495
|
+
if (game.scene.deadPlayers.has(p.name))
|
|
496
|
+
continue;
|
|
497
|
+
out[p.name] = serializePlayer(p);
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
},
|
|
501
|
+
docwagonFor: (actorName) => game.docwagonByPlayer.get(actorName),
|
|
502
|
+
dispose: () => runInSession(ctx, () => game.dispose()),
|
|
503
|
+
};
|
|
504
|
+
// THE FIRST PAINT (B7NNNesy5gvRSbZpp: "I was only moved to the
|
|
505
|
+
// shared run AFTER Jinx joined the shared run -- could be a refresh
|
|
506
|
+
// issue"). The reporter's guess was right.
|
|
507
|
+
//
|
|
508
|
+
// Nothing pushed at boot. Status and the overlays went out only from
|
|
509
|
+
// `command` and from `addPlayer` -- and addPlayer's pushMap loops the
|
|
510
|
+
// WHOLE TABLE, which is the whole illusion: a leader who started a
|
|
511
|
+
// run sat looking at their hub panels, with the session already live
|
|
512
|
+
// underneath, until somebody else's join repainted everyone. The
|
|
513
|
+
// second player's arrival appeared to move the FIRST player into the
|
|
514
|
+
// run.
|
|
515
|
+
//
|
|
516
|
+
// A leader alone at the table would have waited until they typed
|
|
517
|
+
// something, since their own next command pushes too. Which is why
|
|
518
|
+
// this reads as "Jinx joined and then I moved" rather than as a
|
|
519
|
+
// missing initial render.
|
|
520
|
+
//
|
|
521
|
+
// SAFE TO EMIT BEFORE THE CLIENT SUBSCRIBES, and that is the fact
|
|
522
|
+
// this fix rests on rather than an assumption: the leader subscribes
|
|
523
|
+
// AFTER `sideQuest.start` returns (SharedRunSession.startAsLeader),
|
|
524
|
+
// so these events are inserted before anyone is listening. The
|
|
525
|
+
// subscription is `sideQuest.events` from seq 0 and replays, which
|
|
526
|
+
// is what route()'s seq-dedupe exists to absorb -- so a late
|
|
527
|
+
// subscriber still receives them.
|
|
528
|
+
session.pushStatus();
|
|
529
|
+
pushMap();
|
|
530
|
+
// THE SCENE CAN MOVE WITHOUT ANYONE TYPING.
|
|
531
|
+
//
|
|
532
|
+
// Everything above pushes from three places and three only:
|
|
533
|
+
// `command`, `addPlayer`, and this boot. Game's own way of saying
|
|
534
|
+
// "the world changed, repaint" is repaintForScene -- and out here
|
|
535
|
+
// that is INERT, because `_mapBox` is undefined on a server. Not
|
|
536
|
+
// assumed: a real headless boot emits map/room/status 1,1,1, calling
|
|
537
|
+
// updateExits + updateStatus by hand leaves it at 1,1,1, and one
|
|
538
|
+
// `command` takes it to 2,2,2.
|
|
539
|
+
//
|
|
540
|
+
// So a scene swap that happens OUTSIDE a command moves the table's
|
|
541
|
+
// truth and sends nothing after it. draftNextSceneInBackground is
|
|
542
|
+
// that shape by design -- `void continueToNextScene()`, 20-40
|
|
543
|
+
// seconds, resolving long after the command that started it already
|
|
544
|
+
// pushed the OLD scene.
|
|
545
|
+
//
|
|
546
|
+
// Installed AFTER `session` exists, deliberately: repaintForScene
|
|
547
|
+
// runs during the boot synthesis above, and a hook wired earlier
|
|
548
|
+
// would reach for a `session` that is not there yet.
|
|
549
|
+
//
|
|
550
|
+
// Double-pushing on a swap inside a command is accepted and cheap:
|
|
551
|
+
// the client coalesces its repaints (Game.scheduleRender) and
|
|
552
|
+
// route()'s seq dedupe is indifferent. Missing the swap entirely is
|
|
553
|
+
// the failure worth spending frames on.
|
|
554
|
+
//
|
|
555
|
+
// HARDENING. The inert joint is measured; a swap arriving outside a
|
|
556
|
+
// command is read in the source and NOT executed here
|
|
557
|
+
// (continueToNextScene is gated on job completion, so the harness
|
|
558
|
+
// cannot drive one end to end). This is not offered as the cause of
|
|
559
|
+
// any live report.
|
|
560
|
+
game.onSceneChanged = () => {
|
|
513
561
|
session.pushStatus();
|
|
514
562
|
pushMap();
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
563
|
+
game.player.noteConditionChanged();
|
|
564
|
+
};
|
|
565
|
+
return session;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* A HUB HOSTED ON maka-cli.com (2026-09-12, mjmcee: "allow dropping into
|
|
569
|
+
* the hub from the browser"). The CLI's solo hub loop -- hideout, shops,
|
|
570
|
+
* contacts, call Krow, ride to the job and home again -- booted from a
|
|
571
|
+
* GameSaves doc over the served hub seed, in the site's own process.
|
|
572
|
+
* Solo by ruling: one runner, one hub instance, their overlay over the
|
|
573
|
+
* shared seed. See IMainGameConfig.hostedHub for what the flag turns off
|
|
574
|
+
* (presence, the DDP hub link, the entry-time draft) and where saves go.
|
|
575
|
+
*
|
|
576
|
+
* The boot is ui.ts's resume path, not buildPlayerFromSave: a bare
|
|
577
|
+
* Player from the save's attribute block, and Game.applyResumeState
|
|
578
|
+
* lays gear, augs, qualities, the hub overlay and the home block over
|
|
579
|
+
* the fresh synthesis -- exactly as a terminal continues a runner.
|
|
580
|
+
*/
|
|
581
|
+
export async function createHeadlessHub(opts) {
|
|
582
|
+
const logger = new EmitterLogger(opts.sink, opts.debug);
|
|
583
|
+
const ctx = { sessionId: opts.sessionId, logger, client: opts.client };
|
|
584
|
+
return runInSession(ctx, async () => {
|
|
585
|
+
const save = opts.save;
|
|
586
|
+
const nexus = new Room({ name: 'Nexus', description: 'A featureless room where lost souls drift.', roomType: 'void' });
|
|
587
|
+
const a = save.player.attributes;
|
|
588
|
+
const player = new Player({
|
|
589
|
+
playerName: save.playerName,
|
|
590
|
+
startLocation: nexus,
|
|
591
|
+
combat: { ...a, skills: save.player.skills },
|
|
592
|
+
});
|
|
593
|
+
player.archetypeName = save.player.archetypeName;
|
|
594
|
+
const game = Game.forHostedHub(player, opts.hubSeed, save, opts.conditionSink);
|
|
595
|
+
logger.bindGame(() => game);
|
|
596
|
+
wireDeathHooks(game, logger, opts);
|
|
597
|
+
await game.initEngine({
|
|
598
|
+
logFilePath: '',
|
|
599
|
+
useAi: opts.useAi ?? false,
|
|
600
|
+
hostedHub: true,
|
|
601
|
+
saveSink: opts.onSave,
|
|
602
|
+
owner: save.owner,
|
|
603
|
+
resume: true,
|
|
604
|
+
});
|
|
605
|
+
const base = wireHeadless(game, ctx, logger);
|
|
606
|
+
return {
|
|
607
|
+
...base,
|
|
608
|
+
save: (reason) => game.snapshotSave(reason),
|
|
609
|
+
flush: (reason) => runInSession(ctx, () => game.flushBoundSave(reason)),
|
|
610
|
+
inRun: () => !game.onHubScene,
|
|
549
611
|
};
|
|
550
|
-
return session;
|
|
551
612
|
});
|
|
552
613
|
}
|
|
553
614
|
//# sourceMappingURL=headless.js.map
|