@maka/maka-cli 5.91.0 → 5.92.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.
Files changed (87) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +9 -9
  3. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/augmentations.d.ts +54 -3
  5. package/bundle/typescript/src/commands/game/sideQuest/augmentations.js +987 -53
  6. package/bundle/typescript/src/commands/game/sideQuest/augmentations.js.map +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.d.ts +0 -14
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +22 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +14 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +5 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js +5 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +10 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +8 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +3 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +13 -7
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js.map +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/project.js +12 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/project.js.map +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +7 -2
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +3 -3
  30. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +69 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.d.ts +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js +62 -16
  35. package/bundle/typescript/src/commands/game/sideQuest/factories/chargen-factory.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +26 -3
  37. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +95 -3
  39. package/bundle/typescript/src/commands/game/sideQuest/game.js +191 -13
  40. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +5 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js.map +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/models/inventory.d.ts +13 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/models/inventory.js +45 -2
  45. package/bundle/typescript/src/commands/game/sideQuest/models/inventory.js.map +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +65 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +83 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +106 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +128 -9
  51. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/qualities.d.ts +33 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/qualities.js +12 -2
  54. package/bundle/typescript/src/commands/game/sideQuest/qualities.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +4 -4
  57. package/bundle/typescript/src/commands/game/sideQuest/skill-groups.d.ts +120 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/skill-groups.js +135 -0
  59. package/bundle/typescript/src/commands/game/sideQuest/skill-groups.js.map +1 -0
  60. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +20 -2
  61. package/bundle/typescript/src/commands/game/sideQuest/ui.js +14 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.d.ts +116 -0
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.js +127 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.js.map +1 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.d.ts +1 -1
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js +123 -2
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js.map +1 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.d.ts +131 -2
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +170 -12
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js.map +1 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.d.ts +139 -1
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js +307 -5
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js.map +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.d.ts +38 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +101 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-lock.js +19 -3
  79. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-lock.js.map +1 -1
  80. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js +1 -1
  81. package/bundle/typescript/src/commands/game/sideQuest/utilities/sin.js.map +1 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.d.ts +22 -0
  83. package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.js +27 -0
  84. package/bundle/typescript/src/commands/game/sideQuest/utilities/web-character-generator.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +63 -30
  86. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  87. package/package.json +1 -1
@@ -49,6 +49,16 @@ export default class Game {
49
49
  private _logBox;
50
50
  private _metaBox;
51
51
  private _quitArmed;
52
+ /**
53
+ * The quit verb already sealed this runner to the grid, so the
54
+ * process-exit hook has nothing left to do. Set ONLY after an awaited
55
+ * flush -- an optimistic set would make the exit hook skip the spill
56
+ * write on a push that never landed, which is the one way this design
57
+ * can silently lose a beat. (Mutation-checked: never setting it is
58
+ * caught, and so is setting it without the flush.)
59
+ */
60
+ private _quitSealed;
61
+ get quitSealed(): boolean;
52
62
  private _escArmedAt;
53
63
  private _statusBox;
54
64
  /** The clock gutter left of the prompt, its ticker, and the face it
@@ -937,15 +947,63 @@ export default class Game {
937
947
  * and snapshotVendorStock (both deliberately pre-overlay).
938
948
  */
939
949
  private applyResumeState;
950
+ /**
951
+ * IS THIS RUNNER'S LIFE THE SERVER'S? (server-only storage.)
952
+ *
953
+ * A bound runner has an account behind them; an anonymous one never
954
+ * leaves this machine, and a bench is a scratch session with nothing
955
+ * worth keeping. Every new branch in the save path gates on this, so
956
+ * the anonymous route is the code that was already here -- which is
957
+ * what the unmodified tripwire suites prove.
958
+ */
959
+ private get boundRunner();
960
+ /** Where this runner's crash journal lives, when they are bound. */
961
+ private get spillPath();
940
962
  /**
941
963
  * THE single save routine -- every safe beat funnels here. Guards make
942
964
  * it a silent no-op anywhere a save would lie: mid-run (the "runs are
943
965
  * disposable" ruling lives in the scene === _hubScene check), downed
944
- * or KO'd, mid-exchange, dead, or before init finishes. Synchronous by
945
- * design so the process-exit quit hook can use it; failures log and
946
- * never crash a turn.
966
+ * or KO'd, mid-exchange, dead, or before init finishes.
967
+ *
968
+ * SYNCHRONOUS BY DESIGN, AND FOR TWO DIFFERENT REASONS NOW. It always
969
+ * had to be, because `process.on('exit')` can run nothing
970
+ * asynchronous. For a BOUND runner that same constraint is what makes
971
+ * the spill the durability guarantee rather than the push: the beat
972
+ * writes `<slug>.spill` synchronously and returns, and the upload
973
+ * happens behind it on a queue that may never get the chance to
974
+ * finish. Nothing here awaits the grid, ever -- a save beat that could
975
+ * block on a network is a turn that can hang.
976
+ *
977
+ * PR1 KEEPS WRITING `<slug>.json` FOR BOUND RUNNERS TOO. That is
978
+ * deliberate and temporary: this PR is a superset of today's
979
+ * behaviour, so reverting it strands no data and loses no character.
980
+ * PR2 is where the bound branch stops writing the local slot, once
981
+ * the server contract is deployed and the launcher can source the menu
982
+ * from it.
947
983
  */
948
984
  requestSave(reason: SaveReason): void;
985
+ /**
986
+ * SEAL A BOUND RUNNER TO THE GRID -- the awaited counterpart of
987
+ * requestSave, for the two beats where "eventually" is a bug.
988
+ *
989
+ * QUIT: nothing runs after this process. An unpushed beat is not
990
+ * "late", it is gone until the next launch sweeps the spill -- and
991
+ * the player was told their game was saved.
992
+ *
993
+ * JOB ACCEPT: the shared run loads this character FROM THE SERVER.
994
+ * An unpushed beat means the table plays a stale snapshot of them,
995
+ * which is a real bug today (the unawaited push before shared runs)
996
+ * and closes here for free.
997
+ *
998
+ * Returns true when the server has the newest save. FALSE IS NOT A
999
+ * FAILURE PATH -- it means the spill stands and the next launch
1000
+ * uploads it, which is ruling 2 working exactly as designed. Callers
1001
+ * report, they do not block the player.
1002
+ *
1003
+ * Anonymous and bench runners return true immediately: they have
1004
+ * nothing to seal and must never be made to wait on a network.
1005
+ */
1006
+ flushBoundSave(reason: SaveReason): Promise<boolean>;
949
1007
  private buildSaveFile;
950
1008
  /**
951
1009
  * Synchronous readiness check for continueToNextScene() -- lets callers
@@ -1358,6 +1416,40 @@ export default class Game {
1358
1416
  * utilities/ephemeral.ts decides what each kind does with its beat;
1359
1417
  * this only carries the narration back. Only a mob ever produces any.
1360
1418
  */
1419
+ /** One beat in flight at a time (see utilities/alarmed-staff.ts). */
1420
+ private _alarmBeat;
1421
+ /**
1422
+ * THE ALARMED SITE'S TURN, hung off the same single dispatch the host's
1423
+ * and the ephemerals' are -- one player action is one beat for the
1424
+ * people who work here.
1425
+ *
1426
+ * AN ENGINE MAPPING, NOT A RULE, in exactly the sense withICTurn says
1427
+ * it: SR5 writes no general mechanics for how guards react to an alarm
1428
+ * (asked twice, two wordings, same answer), and p.352's "delay the
1429
+ * runner team" is conditioned on an HTR team being en route, which in
1430
+ * a Zone 0 barrens it never is. Nothing here invents a response table,
1431
+ * a convergence rate or an aggression dial. What changed is only this:
1432
+ * NPC.act()'s two callers both live inside respondTo(), so a scene NPC
1433
+ * was mute unless the player addressed it. The engine stops muzzling
1434
+ * them while a site is alarmed. What any of them DOES with a beat is
1435
+ * the judgment it already exercises when spoken to.
1436
+ *
1437
+ * IT APPENDS NOTHING, and that is worth saying because it makes the
1438
+ * one thing this wrapper's POSITION was chosen for moot. The seam
1439
+ * agreement put this inner to withEphemeralTurn so an alarmed guard
1440
+ * would print above somebody eating noodles -- sound reasoning for an
1441
+ * appending wrapper, and irrelevant to a fire-and-forget one that
1442
+ * returns `result` untouched. The position is kept as agreed so the
1443
+ * slot exists if this ever narrates inline; the ordering argument is
1444
+ * recorded as not currently biting rather than quietly inherited.
1445
+ *
1446
+ * FIRE AND FORGET on the player's own ruling, offered three shapes:
1447
+ * reactions "arrive a moment later" while play continues at full
1448
+ * speed, one in flight so they never pile up. The precedent is
1449
+ * draftNextSceneInBackground -- never make the player wait on a model
1450
+ * call.
1451
+ */
1452
+ private withAlarmTurn;
1361
1453
  private withEphemeralTurn;
1362
1454
  private withICTurn;
1363
1455
  /**
@@ -7,14 +7,15 @@ import { Item } from './models/item.js';
7
7
  import { Room } from './models/_index.js';
8
8
  import { Door } from './models/door.js';
9
9
  import { SAVE_VERSION } from './types/save-file.js';
10
- import { serializePlayer, restorePlayer, captureHubOverlay, applyHubOverlay, writeSaveAtomic, deleteSave, restoreItem, readSave, serializeItem, saveSlug, } from './utilities/persistence.js';
10
+ import { serializePlayer, restorePlayer, captureHubOverlay, applyHubOverlay, writeSaveAtomic, deleteSave, restoreItem, readSave, serializeItem, saveSlug, spillPathFor, } from './utilities/persistence.js';
11
11
  import { heartbeatPresence, heartbeatPresenceNow } from './utilities/social.js';
12
12
  import { presenceBeatAllowed } from './utilities/presence-gate.js';
13
- import { pushSave, burnCloudSaveFor } from './utilities/cloud-saves.js';
13
+ import { pushSave, burnCloudSaveFor, enqueueBoundPush, flushPushQueue, writeSpill, clearSpill, hasPendingPush, } from './utilities/cloud-saves.js';
14
14
  import { DOCWAGON_TIERS, RESUSC_FEE, DEATH_COMP } from './utilities/docwagon.js';
15
15
  import { isFence, LIFESTYLE_TIERS } from './utilities/commerce.js';
16
16
  import { bestFakeSin, wandRead, processArrest } from './utilities/sin.js';
17
17
  import { tickEphemerals } from './utilities/ephemeral.js';
18
+ import { requestAlarmBeat, newAlarmBeatLatch, ALARM_STIMULUS } from './utilities/alarmed-staff.js';
18
19
  import { renderMapViewport, mapCaption } from './utilities/map-view.js';
19
20
  import { tallestRoomRows } from './utilities/room-grid.js';
20
21
  import { renderRoomLayoutCompact, crewOfTable, roomCaption } from './utilities/room-view.js';
@@ -179,6 +180,16 @@ export default class Game {
179
180
  // Quit confirmation state (see handleInput / initInputBox): armed by
180
181
  // the first quit/exit or ESC, cleared by anything that isn't a yes.
181
182
  _quitArmed = false;
183
+ /**
184
+ * The quit verb already sealed this runner to the grid, so the
185
+ * process-exit hook has nothing left to do. Set ONLY after an awaited
186
+ * flush -- an optimistic set would make the exit hook skip the spill
187
+ * write on a push that never landed, which is the one way this design
188
+ * can silently lose a beat. (Mutation-checked: never setting it is
189
+ * caught, and so is setting it without the flush.)
190
+ */
191
+ _quitSealed = false;
192
+ get quitSealed() { return this._quitSealed; }
182
193
  _escArmedAt = 0;
183
194
  _statusBox;
184
195
  /** The clock gutter left of the prompt, its ticker, and the face it
@@ -2872,13 +2883,45 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2872
2883
  this.refreshLogLabel();
2873
2884
  Logger.getInstance().write(`Resumed save for ${save.playerName} (saved ${save.savedAt}, reason ${save.reason}).`);
2874
2885
  }
2886
+ /**
2887
+ * IS THIS RUNNER'S LIFE THE SERVER'S? (server-only storage.)
2888
+ *
2889
+ * A bound runner has an account behind them; an anonymous one never
2890
+ * leaves this machine, and a bench is a scratch session with nothing
2891
+ * worth keeping. Every new branch in the save path gates on this, so
2892
+ * the anonymous route is the code that was already here -- which is
2893
+ * what the unmodified tripwire suites prove.
2894
+ */
2895
+ get boundRunner() {
2896
+ return !this._anonymous && !this._bench && !!this._owner;
2897
+ }
2898
+ /** Where this runner's crash journal lives, when they are bound. */
2899
+ get spillPath() {
2900
+ if (!this._saveFilePath || !this.boundRunner)
2901
+ return undefined;
2902
+ return spillPathFor(dirname(this._saveFilePath), this.player.name);
2903
+ }
2875
2904
  /**
2876
2905
  * THE single save routine -- every safe beat funnels here. Guards make
2877
2906
  * it a silent no-op anywhere a save would lie: mid-run (the "runs are
2878
2907
  * disposable" ruling lives in the scene === _hubScene check), downed
2879
- * or KO'd, mid-exchange, dead, or before init finishes. Synchronous by
2880
- * design so the process-exit quit hook can use it; failures log and
2881
- * never crash a turn.
2908
+ * or KO'd, mid-exchange, dead, or before init finishes.
2909
+ *
2910
+ * SYNCHRONOUS BY DESIGN, AND FOR TWO DIFFERENT REASONS NOW. It always
2911
+ * had to be, because `process.on('exit')` can run nothing
2912
+ * asynchronous. For a BOUND runner that same constraint is what makes
2913
+ * the spill the durability guarantee rather than the push: the beat
2914
+ * writes `<slug>.spill` synchronously and returns, and the upload
2915
+ * happens behind it on a queue that may never get the chance to
2916
+ * finish. Nothing here awaits the grid, ever -- a save beat that could
2917
+ * block on a network is a turn that can hang.
2918
+ *
2919
+ * PR1 KEEPS WRITING `<slug>.json` FOR BOUND RUNNERS TOO. That is
2920
+ * deliberate and temporary: this PR is a superset of today's
2921
+ * behaviour, so reverting it strands no data and loses no character.
2922
+ * PR2 is where the bound branch stops writing the local slot, once
2923
+ * the server contract is deployed and the launcher can source the menu
2924
+ * from it.
2882
2925
  */
2883
2926
  requestSave(reason) {
2884
2927
  const logger = Logger.getInstance();
@@ -2903,17 +2946,74 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2903
2946
  save.sync = prev.save.sync;
2904
2947
  writeSaveAtomic(this._saveFilePath, save);
2905
2948
  logger.write(`Saved (${reason}) -> ${this._saveFilePath}`);
2906
- // Local-first: the beat is DONE the moment the file lands. The
2907
- // cloud mirror rides behind, fire-and-forget (see cloud-saves.ts
2908
- // -- logged-out/offline/expired all degrade to a silent no-op).
2909
- // An ANONYMOUS runner's life never leaves this machine.
2910
- if (!this._anonymous)
2911
- void pushSave(this._saveFilePath, save);
2949
+ // An ANONYMOUS runner's life never leaves this machine, and this
2950
+ // is where that stays true: no spill, no queue, no request.
2951
+ if (this._anonymous)
2952
+ return;
2953
+ const spill = this.spillPath;
2954
+ if (spill) {
2955
+ // THE JOURNAL, WRITTEN BEFORE THE PUSH IS EVEN ATTEMPTED. If the
2956
+ // grid is down -- or the process dies in the next millisecond --
2957
+ // this file is the beat. Ordering matters: enqueueing first and
2958
+ // spilling second would leave a window where a crash loses a
2959
+ // beat that a confirmed push had not yet covered.
2960
+ writeSpill(spill, save);
2961
+ enqueueBoundPush(this._saveFilePath, save, spill);
2962
+ return;
2963
+ }
2964
+ // Legacy/unbound-but-not-anonymous: the pre-existing mirror, still
2965
+ // fire-and-forget (cloud-saves.ts -- logged-out, offline and
2966
+ // expired all degrade to a silent no-op).
2967
+ void pushSave(this._saveFilePath, save);
2912
2968
  }
2913
2969
  catch (err) {
2914
2970
  logger.error(`requestSave(${reason}) failed: ${err}`);
2915
2971
  }
2916
2972
  }
2973
+ /**
2974
+ * SEAL A BOUND RUNNER TO THE GRID -- the awaited counterpart of
2975
+ * requestSave, for the two beats where "eventually" is a bug.
2976
+ *
2977
+ * QUIT: nothing runs after this process. An unpushed beat is not
2978
+ * "late", it is gone until the next launch sweeps the spill -- and
2979
+ * the player was told their game was saved.
2980
+ *
2981
+ * JOB ACCEPT: the shared run loads this character FROM THE SERVER.
2982
+ * An unpushed beat means the table plays a stale snapshot of them,
2983
+ * which is a real bug today (the unawaited push before shared runs)
2984
+ * and closes here for free.
2985
+ *
2986
+ * Returns true when the server has the newest save. FALSE IS NOT A
2987
+ * FAILURE PATH -- it means the spill stands and the next launch
2988
+ * uploads it, which is ruling 2 working exactly as designed. Callers
2989
+ * report, they do not block the player.
2990
+ *
2991
+ * Anonymous and bench runners return true immediately: they have
2992
+ * nothing to seal and must never be made to wait on a network.
2993
+ */
2994
+ async flushBoundSave(reason) {
2995
+ if (!this.boundRunner)
2996
+ return true;
2997
+ // Take a final beat first, so the thing we seal is the state the
2998
+ // player is quitting FROM rather than whatever the last autosave
2999
+ // happened to catch.
3000
+ this.requestSave(reason);
3001
+ if (!hasPendingPush())
3002
+ return true;
3003
+ const sealed = await flushPushQueue();
3004
+ if (sealed) {
3005
+ const spill = this.spillPath;
3006
+ // The queue clears the spill on a confirmed push; this is the
3007
+ // belt for the case where the confirmation raced the final beat.
3008
+ if (spill) {
3009
+ const current = readSave(this._saveFilePath);
3010
+ if (current.ok)
3011
+ clearSpill(spill, current.save.savedAt);
3012
+ }
3013
+ }
3014
+ Logger.getInstance().write(`flushBoundSave(${reason}): ${sealed ? 'sealed' : 'left in the spill'}.`);
3015
+ return sealed;
3016
+ }
2917
3017
  buildSaveFile(reason) {
2918
3018
  return {
2919
3019
  version: SAVE_VERSION,
@@ -3865,7 +3965,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3865
3965
  // session's homecoming hands back to it.
3866
3966
  if (this.pendingSharedRun) {
3867
3967
  const { sessionID } = this.pendingSharedRun;
3868
- this.requestSave('job-accept');
3968
+ // AWAITED, unlike the solo departure below, because the shared
3969
+ // session loads this character FROM THE SERVER. A fire-and-forget
3970
+ // push here is the known stale-snapshot race: the table starts
3971
+ // playing a version of you that predates the beat you just took.
3972
+ // For an anonymous or unbound runner this returns immediately.
3973
+ await this.flushBoundSave('job-accept');
3869
3974
  try {
3870
3975
  const { SharedRunSession } = await import('./utilities/shared-run.js');
3871
3976
  const banner = await SharedRunSession.startAsLeader(this, sessionID);
@@ -3888,6 +3993,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3888
3993
  }
3889
3994
  // The departure save: hub state with the job still on offer -- what
3890
3995
  // a mid-run quit resumes to (the "runs are disposable" ruling).
3996
+ //
3997
+ // NOT awaited, unlike the shared-run branch above, and the asymmetry
3998
+ // is deliberate: nothing loads this save from the server on a solo
3999
+ // run, so there is no stale-snapshot race to close -- and awaiting a
4000
+ // push here would put a network round trip (up to TIMEOUT_MS) in
4001
+ // front of every departure for no gain. The spill covers the crash.
3891
4002
  this.requestSave('job-accept');
3892
4003
  // CARRY THE OFFER'S HAGGLE ACROSS THE TRAVEL, BUT ONLY THIS
3893
4004
  // OFFER'S (d4yoYPcRAtWpLtm9y: "I got a notice that I've already
@@ -4992,6 +5103,29 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4992
5103
  if (this._quitArmed) {
4993
5104
  this._quitArmed = false;
4994
5105
  if (/^(y|yes|quit|exit)$/i.test(verb)) {
5106
+ // THE SEAL. This is the last moment anything asynchronous can
5107
+ // run for this character -- ui.ts turns a null response into
5108
+ // process.exit(0), and the exit hook that follows can only write
5109
+ // the spill. So a bound runner's final beat is pushed HERE,
5110
+ // awaited, or it does not get pushed at all.
5111
+ //
5112
+ // The player is told, because a quit that pauses for a network
5113
+ // round trip with no explanation reads as a hang.
5114
+ if (this.boundRunner) {
5115
+ const logger = Logger.getInstance();
5116
+ logger.logWithColor(`Sealing your save to the grid...`, 'cyan');
5117
+ this._screen?.render();
5118
+ const sealed = await this.flushBoundSave('quit');
5119
+ if (!sealed) {
5120
+ // NOT an error, and worded so it does not read as one: the
5121
+ // spill is on disk and the next launch uploads it. Saying
5122
+ // nothing here would be the actual failure -- the player
5123
+ // would believe their evening reached the server.
5124
+ logger.logWithColor(`The grid didn't answer -- your progress is saved on this machine and will upload next launch.`, 'yellow');
5125
+ this._screen?.render();
5126
+ }
5127
+ this._quitSealed = true;
5128
+ }
4995
5129
  return out(null);
4996
5130
  }
4997
5131
  // fall through -- the answer was a real command; play on.
@@ -5042,7 +5176,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5042
5176
  const command = CommandFactory.createCommand(verb, context);
5043
5177
  if (command) {
5044
5178
  const result = await command.execute(args);
5045
- return out(this.withEphemeralTurn(actor, this.withICTurn(actor, result)));
5179
+ return out(this.withEphemeralTurn(actor, this.withAlarmTurn(actor, this.withICTurn(actor, result))));
5046
5180
  }
5047
5181
  // Typo tolerance: two straight sessions bounced on slips like "searh"
5048
5182
  // and "maop". Reuse the same fuzzy matcher item/NPC names go through
@@ -5101,6 +5235,50 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5101
5235
  * utilities/ephemeral.ts decides what each kind does with its beat;
5102
5236
  * this only carries the narration back. Only a mob ever produces any.
5103
5237
  */
5238
+ /** One beat in flight at a time (see utilities/alarmed-staff.ts). */
5239
+ _alarmBeat = newAlarmBeatLatch();
5240
+ /**
5241
+ * THE ALARMED SITE'S TURN, hung off the same single dispatch the host's
5242
+ * and the ephemerals' are -- one player action is one beat for the
5243
+ * people who work here.
5244
+ *
5245
+ * AN ENGINE MAPPING, NOT A RULE, in exactly the sense withICTurn says
5246
+ * it: SR5 writes no general mechanics for how guards react to an alarm
5247
+ * (asked twice, two wordings, same answer), and p.352's "delay the
5248
+ * runner team" is conditioned on an HTR team being en route, which in
5249
+ * a Zone 0 barrens it never is. Nothing here invents a response table,
5250
+ * a convergence rate or an aggression dial. What changed is only this:
5251
+ * NPC.act()'s two callers both live inside respondTo(), so a scene NPC
5252
+ * was mute unless the player addressed it. The engine stops muzzling
5253
+ * them while a site is alarmed. What any of them DOES with a beat is
5254
+ * the judgment it already exercises when spoken to.
5255
+ *
5256
+ * IT APPENDS NOTHING, and that is worth saying because it makes the
5257
+ * one thing this wrapper's POSITION was chosen for moot. The seam
5258
+ * agreement put this inner to withEphemeralTurn so an alarmed guard
5259
+ * would print above somebody eating noodles -- sound reasoning for an
5260
+ * appending wrapper, and irrelevant to a fire-and-forget one that
5261
+ * returns `result` untouched. The position is kept as agreed so the
5262
+ * slot exists if this ever narrates inline; the ordering argument is
5263
+ * recorded as not currently biting rather than quietly inherited.
5264
+ *
5265
+ * FIRE AND FORGET on the player's own ruling, offered three shapes:
5266
+ * reactions "arrive a moment later" while play continues at full
5267
+ * speed, one in flight so they never pile up. The precedent is
5268
+ * draftNextSceneInBackground -- never make the player wait on a model
5269
+ * call.
5270
+ */
5271
+ withAlarmTurn(actor, result) {
5272
+ if (result === null)
5273
+ return null;
5274
+ // The primary's actions drive the site. A second human's commands
5275
+ // arrive through the same dispatch, and two humans each triggering a
5276
+ // beat would double the site's voice without doubling the site.
5277
+ if (actor !== this.player)
5278
+ return result;
5279
+ requestAlarmBeat(this._alarmBeat, this.scene, actor, npc => npc.respondTo(actor, ALARM_STIMULUS));
5280
+ return result;
5281
+ }
5104
5282
  withEphemeralTurn(actor, result) {
5105
5283
  if (result === null)
5106
5284
  return null;