@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.
@@ -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.onPlayerDeath = (p) => {
203
- logger.system(`${p.name} is dead. The street keeps the change.`, 'all', 'death', { player: p.name });
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
- const spawnRoom = () => game.scene.determineStartRoom();
285
- // PER-PLAYER MAP EVENTS (map-overlay wave 2026-08-24): each rider
286
- // gets their own icon minimap -- the client holds no Room objects
287
- // in a shared run. Fog of war and AR eyes are THIS player's. A
288
- // jacked-in rider gets an EMPTY event -- their overlay blinks out
289
- // until they jack back out (matrix ruling 2026-08-24).
290
- const pushMap = () => {
291
- const rooms = game.scene.getRooms();
292
- const sealing = (r, d) => game.scene.barrierSealing(r, d);
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
- const here = p.currentLocation;
297
- if (!here)
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
- const session = {
385
- game,
386
- logger,
387
- command: (actorName, input, opts) => runInSession(ctx, () => runWithAuthToken(opts?.authToken, async () => {
388
- const res = await game.handleInputAs(actorName, input);
389
- session.pushStatus();
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
- // THE WEB MAP FOLLOWS THE ROOM, NOT JUST YOUR OWN BODY
392
- // (oHhjxjqKuptDGMHLD: a looted body stayed on the map "until I
393
- // move"; 9aEMA4APbB6rYtWnT: a killed NPC's dot never changed).
394
- // The condition beat is what the browser draws actors from. A
395
- // kill or a loot mutates the room, not the killer, so this
396
- // nudge -- one per command, coalesced -- still covers those.
397
- // MOVEMENT NO LONGER WAITS FOR IT (q6tBaFn5EDmGMoJFh): the room
398
- // publishes every occupant's position change to the humans in
399
- // it the moment it happens (Room.noteOccupancyChanged), so an
400
- // NPC stepping on its own reflect chain between commands
401
- // reaches the browser without anyone typing.
402
- game.player.noteConditionChanged();
403
- // EVERY OTHER HUMAN WHO SAW THE ROOM CHANGE (genfBamjTvyHTdWhu):
404
- // a member's overlay is written from the member's OWN beat now
405
- // (Game.wireConditionReporting), so the same once-per-command
406
- // nudge covers the typist and whoever shares their room -- a
407
- // kill or a loot changes the room for all of them.
408
- const typist = game.scene.getPlayerByName(actorName);
409
- for (const p of game.scene.getPlayers()) {
410
- if (p === game.player || game.scene.deadPlayers.has(p.name))
411
- continue;
412
- if (p === typist || (typist && p.currentLocation === typist.currentLocation))
413
- p.noteConditionChanged();
414
- }
415
- return res;
416
- })),
417
- addPlayer: (save) => runInSession(ctx, async () => {
418
- // REJOIN IS NOT A JOIN (playtest 2026-08-27, Sherry: "got an
419
- // internal server error"). A dropped DDP connection -- a
420
- // laptop lid, a wifi blip, a client restart -- calls
421
- // sideQuest.join again with the same runner, and this used to
422
- // walk straight into Scene.addActor, which throws "Actor with
423
- // name X already exists in the scene." The method died, the
424
- // player saw a raw 500, and their seat was unreachable for
425
- // the rest of the run even though the engine still held a
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
- pushMap();
468
- return p.name;
469
- }),
470
- pushStatus: () => {
471
- for (const p of game.scene.getPlayers()) {
472
- if (game.scene.deadPlayers.has(p.name))
473
- continue;
474
- logger.status(p.name, game.statusLinesFor(p));
475
- }
476
- },
477
- snapshotPlayers: () => {
478
- const out = {};
479
- for (const p of game.scene.getPlayers()) {
480
- if (game.scene.deadPlayers.has(p.name))
481
- continue;
482
- out[p.name] = serializePlayer(p);
483
- }
484
- return out;
485
- },
486
- docwagonFor: (actorName) => game.docwagonByPlayer.get(actorName),
487
- dispose: () => runInSession(ctx, () => game.dispose()),
488
- };
489
- // THE FIRST PAINT (B7NNNesy5gvRSbZpp: "I was only moved to the
490
- // shared run AFTER Jinx joined the shared run -- could be a refresh
491
- // issue"). The reporter's guess was right.
492
- //
493
- // Nothing pushed at boot. Status and the overlays went out only from
494
- // `command` and from `addPlayer` -- and addPlayer's pushMap loops the
495
- // WHOLE TABLE, which is the whole illusion: a leader who started a
496
- // run sat looking at their hub panels, with the session already live
497
- // underneath, until somebody else's join repainted everyone. The
498
- // second player's arrival appeared to move the FIRST player into the
499
- // run.
500
- //
501
- // A leader alone at the table would have waited until they typed
502
- // something, since their own next command pushes too. Which is why
503
- // this reads as "Jinx joined and then I moved" rather than as a
504
- // missing initial render.
505
- //
506
- // SAFE TO EMIT BEFORE THE CLIENT SUBSCRIBES, and that is the fact
507
- // this fix rests on rather than an assumption: the leader subscribes
508
- // AFTER `sideQuest.start` returns (SharedRunSession.startAsLeader),
509
- // so these events are inserted before anyone is listening. The
510
- // subscription is `sideQuest.events` from seq 0 and replays, which
511
- // is what route()'s seq-dedupe exists to absorb -- so a late
512
- // subscriber still receives them.
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
- // THE SCENE CAN MOVE WITHOUT ANYONE TYPING.
516
- //
517
- // Everything above pushes from three places and three only:
518
- // `command`, `addPlayer`, and this boot. Game's own way of saying
519
- // "the world changed, repaint" is repaintForScene -- and out here
520
- // that is INERT, because `_mapBox` is undefined on a server. Not
521
- // assumed: a real headless boot emits map/room/status 1,1,1, calling
522
- // updateExits + updateStatus by hand leaves it at 1,1,1, and one
523
- // `command` takes it to 2,2,2.
524
- //
525
- // So a scene swap that happens OUTSIDE a command moves the table's
526
- // truth and sends nothing after it. draftNextSceneInBackground is
527
- // that shape by design -- `void continueToNextScene()`, 20-40
528
- // seconds, resolving long after the command that started it already
529
- // pushed the OLD scene.
530
- //
531
- // Installed AFTER `session` exists, deliberately: repaintForScene
532
- // runs during the boot synthesis above, and a hook wired earlier
533
- // would reach for a `session` that is not there yet.
534
- //
535
- // Double-pushing on a swap inside a command is accepted and cheap:
536
- // the client coalesces its repaints (Game.scheduleRender) and
537
- // route()'s seq dedupe is indifferent. Missing the swap entirely is
538
- // the failure worth spending frames on.
539
- //
540
- // HARDENING. The inert joint is measured; a swap arriving outside a
541
- // command is read in the source and NOT executed here
542
- // (continueToNextScene is gated on job completion, so the harness
543
- // cannot drive one end to end). This is not offered as the cause of
544
- // any live report.
545
- game.onSceneChanged = () => {
546
- session.pushStatus();
547
- pushMap();
548
- game.player.noteConditionChanged();
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