@maka/maka-cli 5.66.0 → 5.68.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 (123) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/_index.d.ts +1 -0
  3. package/bundle/typescript/src/commands/game/_index.js +1 -0
  4. package/bundle/typescript/src/commands/game/_index.js.map +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +6 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +4 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js.map +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.d.ts +103 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +423 -25
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +15 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +61 -4
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.d.ts +39 -3
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js +85 -4
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js.map +1 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.d.ts +37 -17
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +53 -115
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.d.ts +55 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +203 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js.map +1 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +23 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +42 -7
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.d.ts +27 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +28 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +69 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/register.d.ts +15 -2
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/register.js +32 -6
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/register.js.map +1 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/review.d.ts +39 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/review.js +48 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/review.js.map +1 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.d.ts +35 -0
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +108 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js.map +1 -0
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.d.ts +27 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +7 -2
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js.map +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/unravel.d.ts +57 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/unravel.js +162 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/unravel.js.map +1 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +31 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.d.ts +87 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +320 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js.map +1 -0
  53. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +32 -0
  54. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +8 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +98 -11
  58. package/bundle/typescript/src/commands/game/sideQuest/game.js +239 -35
  59. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.d.ts +24 -0
  61. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +63 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js.map +1 -1
  63. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +9 -0
  64. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +9 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +57 -1
  67. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +62 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +10 -3
  70. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +5 -1
  71. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/resonance-actions.d.ts +108 -0
  73. package/bundle/typescript/src/commands/game/sideQuest/resonance-actions.js +186 -0
  74. package/bundle/typescript/src/commands/game/sideQuest/resonance-actions.js.map +1 -0
  75. package/bundle/typescript/src/commands/game/sideQuest/sprite-powers.d.ts +88 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/sprite-powers.js +137 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/sprite-powers.js.map +1 -0
  78. package/bundle/typescript/src/commands/game/sideQuest/types/repro.d.ts +106 -0
  79. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +2 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js.map +1 -0
  81. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +32 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.d.ts +12 -0
  83. package/bundle/typescript/src/commands/game/sideQuest/ui.d.ts +9 -0
  84. package/bundle/typescript/src/commands/game/sideQuest/ui.js +3 -0
  85. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  86. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.d.ts +17 -1
  87. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js +250 -36
  88. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js.map +1 -1
  89. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.d.ts +30 -1
  90. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js +58 -0
  91. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js.map +1 -1
  92. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-vocab.d.ts +85 -0
  93. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-vocab.js +100 -0
  94. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-vocab.js.map +1 -0
  95. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.d.ts +45 -3
  96. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js +45 -3
  97. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js.map +1 -1
  98. package/bundle/typescript/src/commands/game/sideQuest/utilities/commit-ref.d.ts +13 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/utilities/commit-ref.js +22 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/utilities/commit-ref.js.map +1 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.d.ts +13 -1
  102. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +36 -3
  103. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js.map +1 -1
  104. package/bundle/typescript/src/commands/game/sideQuest/utilities/handoff.d.ts +42 -0
  105. package/bundle/typescript/src/commands/game/sideQuest/utilities/handoff.js +57 -0
  106. package/bundle/typescript/src/commands/game/sideQuest/utilities/handoff.js.map +1 -0
  107. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +14 -0
  108. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/repro-launch.d.ts +68 -0
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/repro-launch.js +147 -0
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/repro-launch.js.map +1 -0
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +39 -0
  113. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
  114. package/bundle/typescript/src/commands/game/sideQuest-backlog.sub.cmd.d.ts +1 -0
  115. package/bundle/typescript/src/commands/game/sideQuest-backlog.sub.cmd.js +46 -0
  116. package/bundle/typescript/src/commands/game/sideQuest-backlog.sub.cmd.js.map +1 -0
  117. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.d.ts +3 -1
  118. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +352 -42
  119. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  120. package/bundle/typescript/src/tools/log/log.class.d.ts +17 -0
  121. package/bundle/typescript/src/tools/log/log.class.js +30 -1
  122. package/bundle/typescript/src/tools/log/log.class.js.map +1 -1
  123. package/package.json +1 -1
@@ -32,6 +32,7 @@ import { BacklogCommand } from './commands/backlog.js';
32
32
  // help index offers, never what the verb accepts (item D).
33
33
  import { isPlaytester, probePlaytesterStanding } from './utilities/playtester.js';
34
34
  import { RolesCommand } from './commands/roles.js';
35
+ import { ReviewCommand } from './commands/review.js';
35
36
  import { SinCommand } from './commands/sin.js';
36
37
  import { GoCommand } from './commands/go.js';
37
38
  import { UseCommand } from './commands/use.js';
@@ -95,9 +96,12 @@ import { ContactsCommand } from './commands/contacts.js';
95
96
  import { JumpCommand } from './commands/jump.js';
96
97
  import { InitiateCommand } from './commands/initiate.js';
97
98
  import { SummonCommand } from './commands/summon.js';
98
- import { CompileCommand, SPRITE_TYPES } from './commands/compile.js';
99
+ import { CompileCommand, SPRITE_TYPES, uniqueSpriteName } from './commands/compile.js';
100
+ import { DecompileCommand } from './commands/decompile.js';
99
101
  import { ThreadCommand } from './commands/thread.js';
100
102
  import { RegisterCommand } from './commands/register.js';
103
+ import { SpritesCommand } from './commands/sprites.js';
104
+ import { UnravelCommand } from './commands/unravel.js';
101
105
  import { OrderCommand } from './commands/order.js';
102
106
  import { DeployCommand, AgentCommand, RecallCommand } from './commands/companions.js';
103
107
  import { DeckCommand, LoadProgramCommand, UnloadProgramCommand } from './commands/deck.js';
@@ -186,6 +190,9 @@ export default class Game {
186
190
  // social layer stays dark (presence, cloud mirror, street, drafts).
187
191
  _owner;
188
192
  _anonymous = false;
193
+ _bench = false;
194
+ _reproItemId;
195
+ _closing = false;
189
196
  _logFilePath = '';
190
197
  _tier = 1;
191
198
  /**
@@ -254,6 +261,66 @@ export default class Game {
254
261
  get isAnonymous() {
255
262
  return this._anonymous;
256
263
  }
264
+ /**
265
+ * A REPRO BENCH: a scratch session stood up to reproduce one backlog
266
+ * item. Not a life -- no save, no street presence, no shared table.
267
+ *
268
+ * SEPARATE FROM `isAnonymous`, and the separation is the point. The
269
+ * bench first reused the anonymous flag, since both want the street
270
+ * left alone -- and that quietly took the BACKLOG with it, because
271
+ * anonymity is a promise about a RUNNER ("nothing about them leaves
272
+ * this machine") and the backlog is one of the things it forbids. So
273
+ * a play-tester standing in the bench built for their own report was
274
+ * refused when they tried to answer it, which is the one thing the
275
+ * bench exists for.
276
+ *
277
+ * Two different concerns wearing one flag: "this character is
278
+ * private" and "this session is scratch". A bench is not private --
279
+ * it is the opposite, a place to file and confirm from.
280
+ */
281
+ get isBench() {
282
+ return this._bench;
283
+ }
284
+ /** The backlog item this bench was stood up for, if any.
285
+ *
286
+ * The review commands read it so an answer needs no index. Player
287
+ * ruling 2026-08-28: "I shouldn't have to pick from the list of
288
+ * review items -- I AM reviewing an item." Making someone name which
289
+ * report they mean, while they are standing in the scene built for
290
+ * that report, is asking a question the session already answered. */
291
+ get reproItemId() {
292
+ return this._reproItemId;
293
+ }
294
+ /**
295
+ * CLOSE THE SESSION SHORTLY, leaving the last message on screen.
296
+ *
297
+ * For a bench that has just been answered (player request 2026-08-28:
298
+ * "after accepting or rejecting, give a 3s timeout and then close the
299
+ * session"). The pause is the point -- exiting the instant the post
300
+ * lands would wipe the confirmation before it could be read, and the
301
+ * confirmation is the only proof the answer went anywhere.
302
+ *
303
+ * Exits code 0, which is the same door `quit` goes through: the save
304
+ * seal in ui.ts fires (a no-op for a bench, which has no save path)
305
+ * and the launcher parent, which is the terminal's guaranteed
306
+ * janitor, restores the terminal exactly as it would after any other
307
+ * exit. No second teardown path.
308
+ *
309
+ * Idempotent: the first request wins, so answering twice in three
310
+ * seconds cannot stack timers.
311
+ */
312
+ closeAfter(ms, reason) {
313
+ if (this._closing)
314
+ return;
315
+ this._closing = true;
316
+ const timer = setTimeout(() => {
317
+ Logger.getInstance().write(`<<<<<<<<--------- Closing session: ${reason}`);
318
+ process.exit(0);
319
+ }, ms);
320
+ // Never hold the process open on its own account -- if everything
321
+ // else has finished, going now is right.
322
+ timer.unref?.();
323
+ }
257
324
  get sessionDir() {
258
325
  return this._logFilePath ? dirname(this._logFilePath) : undefined;
259
326
  }
@@ -452,13 +519,13 @@ export default class Game {
452
519
  * push one right now and say so.
453
520
  */
454
521
  async pushPresenceNow() {
455
- if (this._anonymous)
522
+ if (this._anonymous || this._bench)
456
523
  return 'offline';
457
524
  return heartbeatPresenceNow(this.presencePayload());
458
525
  }
459
526
  startPresenceHeartbeat() {
460
- if (this._anonymous)
461
- return; // account-less: never on the street
527
+ if (this._anonymous || this._bench)
528
+ return; // account-less, or a bench: never on the street
462
529
  if (this._presenceTimer)
463
530
  return;
464
531
  this.beatPresence();
@@ -883,9 +950,98 @@ export default class Game {
883
950
  // death both remove the entry; persistent state (the frame, the deck)
884
951
  // lives on Items in the player's possession, never here.
885
952
  companions = [];
886
- /** A registered sprite folded for travel, waiting to re-shell at the
887
- * destination (the drone _pendingRedeploys school). */
888
- _pendingSpriteRedeploy;
953
+ /** REGISTERED SPRITES WAITING IN THE RESONANCE (SR5 p.254-256):
954
+ * folded for travel, or staged from a resumed save, waiting to
955
+ * re-shell (the drone _pendingRedeploys school).
956
+ *
957
+ * An ARRAY since 2026-08-28. It was a single slot back when one
958
+ * sprite was the hard maximum; the canon cap is one COMPILED sprite
959
+ * with registered ones exempt, up to LOGIC of them (register.ts), so
960
+ * a single slot would have quietly dropped every registered sprite
961
+ * but one at the next save or journey -- the same "your permanent
962
+ * companion vanished" the fix is for, moved one step downstream. */
963
+ _pendingSpriteRedeploy = [];
964
+ /** How many registrations are spent: the ones standing here plus the
965
+ * ones waiting in the Resonance. register.ts bills this against
966
+ * Logic. */
967
+ registeredSpriteCount() {
968
+ return this.companions.filter(c => c.kind === 'sprite' && c.registered).length
969
+ + this._pendingSpriteRedeploy.length;
970
+ }
971
+ // THE STASH AND THE LIVE ROSTER ARE DISJOINT, and every mover below
972
+ // must keep them that way: a registered sprite is EITHER standing in
973
+ // `companions` OR waiting here, never both and never neither.
974
+ // foldCompanionsForTravel pushes here and dismisses the shell in the
975
+ // same pass; both drains splice(0); dismissSpriteToResonance and
976
+ // callSpriteFromResonance are the two halves of Call/Dismiss and move
977
+ // a sprite across in one step each.
978
+ //
979
+ // This is why decompile.ts needs no "forget this registration" call,
980
+ // and why one written there could not be made to fail under mutation:
981
+ // decompile only reaches actors in the room, and a sprite waiting
982
+ // HERE is not in the room. That stays true now that Call/Dismiss can
983
+ // put one here mid-run -- dismissing does not make it decompilable,
984
+ // it makes it absent.
985
+ /** Registered sprites waiting in the Resonance, for the surfaces that
986
+ * have to tell a player what they still have. A copy: the stash is
987
+ * maintained by the movers above and nowhere else. */
988
+ registeredSpritesWaiting() {
989
+ return this._pendingSpriteRedeploy.map(s => ({ ...s }));
990
+ }
991
+ /**
992
+ * DISMISS half of Call/Dismiss Sprite (SR5 p.250, Simple Action):
993
+ * "send a sprite back to the Resonance, releasing it from any tasks it
994
+ * still owes you."
995
+ *
996
+ * The task release is canon and it is the expensive part -- p.256
997
+ * offers STANDBY (costs 1 task, keeps the rest) precisely because
998
+ * dismissing burns them all. So tasks go to 0 here rather than being
999
+ * carried, and the command that calls this says so out loud before a
1000
+ * player spends a companion's usefulness to tidy the grid.
1001
+ *
1002
+ * Only a REGISTERED sprite has anywhere to go. A compiled one that is
1003
+ * sent away is simply gone, which is decompile's job, not this one --
1004
+ * so this refuses it rather than quietly destroying it.
1005
+ */
1006
+ dismissSpriteToResonance(entry) {
1007
+ if (entry.kind !== 'sprite' || !entry.registered || !entry.spriteType)
1008
+ return false;
1009
+ this._pendingSpriteRedeploy.push({
1010
+ spriteType: entry.spriteType,
1011
+ level: entry.force,
1012
+ tasks: 0,
1013
+ name: entry.npc.name,
1014
+ });
1015
+ this.dismissCompanion(entry, 'dismissed to the Resonance');
1016
+ return true;
1017
+ }
1018
+ /**
1019
+ * CALL half of Call/Dismiss Sprite (p.250): "You call a registered
1020
+ * sprite you have waiting in the Resonance."
1021
+ *
1022
+ * `name` picks one when several wait; without it, the first. Returns
1023
+ * the arrival line, or undefined when nothing answered -- the caller
1024
+ * owns the refusal wording, because "you have none" and "you have
1025
+ * some but not that one" are different things to be told.
1026
+ */
1027
+ callSpriteFromResonance(name) {
1028
+ const idx = name === undefined
1029
+ ? (this._pendingSpriteRedeploy.length > 0 ? 0 : -1)
1030
+ : this._pendingSpriteRedeploy.findIndex(s => s.name === name);
1031
+ if (idx < 0)
1032
+ return undefined;
1033
+ // Removed BEFORE the re-shell, so a failed spawn cannot leave the
1034
+ // same sprite both waiting and standing.
1035
+ const [spec] = this._pendingSpriteRedeploy.splice(idx, 1);
1036
+ const line = this.respawnRegisteredSprite(spec);
1037
+ if (line === undefined) {
1038
+ // It could not land. Put it back rather than losing it: the
1039
+ // registration is the player's property, not this call's.
1040
+ this._pendingSpriteRedeploy.splice(idx, 0, spec);
1041
+ return undefined;
1042
+ }
1043
+ return line;
1044
+ }
889
1045
  // Drones deployed at travel time re-shell on arrival ("the rotors spin
890
1046
  // back up at the curb") -- tracked as their items across the swap.
891
1047
  _pendingRedeploys = [];
@@ -1164,7 +1320,7 @@ export default class Game {
1164
1320
  // A REGISTERED sprite persists (register.ts): it folds into
1165
1321
  // your PAN for the ride and re-shells at the curb.
1166
1322
  if (entry.registered && entry.spriteType) {
1167
- this._pendingSpriteRedeploy = { spriteType: entry.spriteType, level: entry.force, tasks: entry.services ?? 0 };
1323
+ this._pendingSpriteRedeploy.push({ spriteType: entry.spriteType, level: entry.force, tasks: entry.services ?? 0, name: entry.npc.name });
1168
1324
  lines.push(`${entry.npc.name} folds into your PAN for the ride -- registered code travels with you.`);
1169
1325
  }
1170
1326
  else {
@@ -1196,10 +1352,11 @@ export default class Game {
1196
1352
  if (shell)
1197
1353
  lines.push(`The ${item.name}'s rotors spin back up at the curb.`);
1198
1354
  }
1199
- // A REGISTERED sprite re-shells from the PAN (register.ts).
1200
- if (this._pendingSpriteRedeploy) {
1201
- const spec = this._pendingSpriteRedeploy;
1202
- this._pendingSpriteRedeploy = undefined;
1355
+ // EVERY registered sprite re-shells from the PAN (register.ts).
1356
+ // splice(0) drains the stash exactly once, the way the drone loop
1357
+ // above does -- a sprite that fails to land is not left staged to
1358
+ // try again at the next curb.
1359
+ for (const spec of this._pendingSpriteRedeploy.splice(0)) {
1203
1360
  const line = this.respawnRegisteredSprite(spec);
1204
1361
  if (line)
1205
1362
  lines.push(line);
@@ -1214,7 +1371,11 @@ export default class Game {
1214
1371
  if (!type)
1215
1372
  return undefined;
1216
1373
  const shell = this.spawnCompanionShell({
1217
- name: `${type.name} Sprite (Level ${spec.level})`,
1374
+ // The name it left with, so "order <name>" still works after the
1375
+ // ride -- but never on top of a live actor, which is how two
1376
+ // registered sprites of the same type and Level used to collide
1377
+ // and silently lose the second one.
1378
+ name: this.scene.getActor(spec.name) ? uniqueSpriteName(this.scene, type.name, spec.level) : spec.name,
1218
1379
  description: `A registered ${type.name.toLowerCase()} sprite riding ${this.player.name}'s PAN -- ${type.blurb}. It serves until its ${spec.tasks} task${spec.tasks === 1 ? '' : 's'} are spent.`,
1219
1380
  kind: 'sprite',
1220
1381
  combat: type.combat(spec.level),
@@ -1512,8 +1673,8 @@ export default class Game {
1512
1673
  /** The transport, hooks-first, CLI default second; undefined =
1513
1674
  * offline/logged out/classic mode (callers degrade quietly). */
1514
1675
  async streetTransport() {
1515
- if (this._anonymous)
1516
- return undefined; // account-less: no street
1676
+ if (this._anonymous || this._bench)
1677
+ return undefined; // account-less, or a bench: no street
1517
1678
  if (this.listStreetPlayers && this.sendPage) {
1518
1679
  return { list: this.listStreetPlayers, send: this.sendPage };
1519
1680
  }
@@ -1805,6 +1966,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
1805
1966
  CommandFactory.registerCommand('note', NoteCommand);
1806
1967
  CommandFactory.registerCommand('backlog', BacklogCommand);
1807
1968
  CommandFactory.registerCommand('roles', RolesCommand);
1969
+ // The short form of "backlog review" -- see commands/review.ts.
1970
+ CommandFactory.registerCommand('review', ReviewCommand);
1808
1971
  CommandFactory.registerCommand('sin', SinCommand);
1809
1972
  CommandFactory.registerCommand('sins', SinCommand);
1810
1973
  CommandFactory.registerCommand('papers', SinCommand);
@@ -1910,9 +2073,13 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
1910
2073
  CommandFactory.registerCommand('summon', SummonCommand);
1911
2074
  CommandFactory.registerCommand('conjure', SummonCommand);
1912
2075
  CommandFactory.registerCommand('compile', CompileCommand);
1913
- CommandFactory.registerCommand('decompile', CompileCommand);
2076
+ CommandFactory.registerCommand('decompile', DecompileCommand);
1914
2077
  CommandFactory.registerCommand('thread', ThreadCommand);
1915
2078
  CommandFactory.registerCommand('register', RegisterCommand);
2079
+ // The sprite database (p.256-259) -- reference, the way "spells" is.
2080
+ CommandFactory.registerCommand('sprites', SpritesCommand);
2081
+ // Kill Complex Form (p.250) -- the Resonance counterpart to dispel.
2082
+ CommandFactory.registerCommand('unravel', UnravelCommand);
1916
2083
  // "At" spots (utilities/spots.ts): walk/move/approach are one
1917
2084
  // crossing; run and sprint are the fast modes (combat semantics in
1918
2085
  // the spot-move phase).
@@ -2078,6 +2245,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2078
2245
  this._saveFilePath = options.saveFilePath;
2079
2246
  this._owner = options.owner;
2080
2247
  this._anonymous = options.anonymousRunner === true;
2248
+ this._bench = options.reproBench === true;
2249
+ this._reproItemId = options.reproItemId;
2081
2250
  // Ask the server, once, whether this account is a play-tester, so
2082
2251
  // the help index can stop advertising a backlog that will only 403
2083
2252
  // (item D). Fire-and-forget on purpose: it never blocks the boot,
@@ -2130,7 +2299,23 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2130
2299
  // spawned in a phantom hideout and read it as a kick back to the
2131
2300
  // hub). The room object still exists for any code that touches
2132
2301
  // _homeRoom; it's just not in the scene.
2133
- if (!options.sharedRun) {
2302
+ // A BENCH GETS NO HIDEOUT EITHER, for the same reason a shared run
2303
+ // does not: nobody lives here. It is two rooms and a door.
2304
+ //
2305
+ // FOUND FROM A LIVE SESSION LOG (2026-08-28). The bench promises
2306
+ // "you start standing at the barrier", and it did not: the hideout
2307
+ // was added and the player was moved INTO it, so the first thing a
2308
+ // reviewer had to do was "go up" out of a home the bench had built
2309
+ // them. They reported the steps as wrong; the steps were right and
2310
+ // the scene had grown a room.
2311
+ //
2312
+ // WORTH RECORDING WHY THE DRY RUN MISSED IT. The headless harness
2313
+ // boots with sharedRun: true, so it took the branch that skips all
2314
+ // of this -- the replay I had been calling proof of the bench was
2315
+ // quietly proving a DIFFERENT scene from the one a player gets.
2316
+ // Adding reproBench here is what makes the two agree, which is the
2317
+ // property the dry run was supposed to have all along.
2318
+ if (!options.sharedRun && !options.reproBench) {
2134
2319
  this.scene.addRoom(this._homeRoom);
2135
2320
  this.connectHomeRoom(this.player.currentLocation);
2136
2321
  // Every session starts AT HOME -- the hideout, one door from the
@@ -2230,12 +2415,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2230
2415
  }
2231
2416
  // A REGISTERED sprite from the save re-shells at the cot (the
2232
2417
  // technomancer's persistent companion -- register.ts).
2233
- if (this._pendingSpriteRedeploy && this.scene === this._hubScene) {
2234
- const spec = this._pendingSpriteRedeploy;
2235
- this._pendingSpriteRedeploy = undefined;
2236
- const line = this.respawnRegisteredSprite(spec);
2237
- if (line)
2238
- Logger.getInstance().logWithColor(line, 'cyan');
2418
+ if (this._pendingSpriteRedeploy.length > 0 && this.scene === this._hubScene) {
2419
+ for (const spec of this._pendingSpriteRedeploy.splice(0)) {
2420
+ const line = this.respawnRegisteredSprite(spec);
2421
+ if (line)
2422
+ Logger.getInstance().logWithColor(line, 'cyan');
2423
+ }
2239
2424
  }
2240
2425
  if (this._resume) {
2241
2426
  const pendingNote = this._pendingRunSeed
@@ -2319,9 +2504,22 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2319
2504
  if (this.crew.length > 0 && this.crewForHire.some(c => this.crew.some(m => m.name === c.name))) {
2320
2505
  this.rollHireCandidates();
2321
2506
  }
2322
- // The registered sprite stages for its re-shell at hub arrival.
2323
- if (save.game.registeredSprite) {
2324
- this._pendingSpriteRedeploy = { ...save.game.registeredSprite };
2507
+ // Registered sprites stage for their re-shell at hub arrival. The
2508
+ // array is the live field; the singular `registeredSprite` is only
2509
+ // read when it is absent, which is exactly the pre-2026-08-28 saves
2510
+ // written before a technomancer could hold more than one. Reading
2511
+ // BOTH would double a sprite on the first save that carries each.
2512
+ if (save.game.registeredSprites && save.game.registeredSprites.length > 0) {
2513
+ this._pendingSpriteRedeploy = save.game.registeredSprites.map(s => ({ ...s }));
2514
+ }
2515
+ else if (save.game.registeredSprite) {
2516
+ const legacy = save.game.registeredSprite;
2517
+ const type = SPRITE_TYPES[legacy.spriteType];
2518
+ this._pendingSpriteRedeploy = [{
2519
+ ...legacy,
2520
+ // Old saves carry no name; rebuild the one it would have had.
2521
+ name: `${type?.name ?? 'Courier'} Sprite (Level ${legacy.level})`,
2522
+ }];
2325
2523
  }
2326
2524
  if (this.crew.length === 0 && save.game.enlistedAlly) {
2327
2525
  const legacy = save.game.enlistedAlly;
@@ -2430,13 +2628,19 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2430
2628
  contacts: [...this.contacts.entries()],
2431
2629
  procurements: [...this.pendingProcurements],
2432
2630
  docwagon: this.docwagon ? { ...this.docwagon } : undefined,
2433
- registeredSprite: (() => {
2434
- // A live registered sprite rides the save; a travel-folded one
2435
- // is in the redeploy stash. Either way it survives the session.
2436
- const live = this.companions.find(c => c.kind === 'sprite' && c.registered && c.spriteType);
2437
- if (live)
2438
- return { spriteType: live.spriteType, level: live.force, tasks: live.services ?? 0 };
2439
- return this._pendingSpriteRedeploy ? { ...this._pendingSpriteRedeploy } : undefined;
2631
+ registeredSprites: (() => {
2632
+ // Registered sprites ride the save from wherever they are:
2633
+ // STANDING here (a live companion, whose services are the
2634
+ // truth for its task count) or WAITING in the redeploy stash
2635
+ // after a fold. Both sources, because a technomancer can have
2636
+ // some of each -- taking only one of the two lists is how a
2637
+ // sprite that happened to be manifest at save time would
2638
+ // survive while its folded sibling silently did not.
2639
+ const live = this.companions
2640
+ .filter(c => c.kind === 'sprite' && c.registered && c.spriteType)
2641
+ .map(c => ({ spriteType: c.spriteType, level: c.force, tasks: c.services ?? 0, name: c.npc.name }));
2642
+ const all = [...live, ...this._pendingSpriteRedeploy.map(s => ({ ...s }))];
2643
+ return all.length > 0 ? all : undefined;
2440
2644
  })(),
2441
2645
  crew: this.crew.map(m => ({ ...m, gear: [...m.gear] })),
2442
2646
  commandRefusals: { ...this.commandRefusals },
@@ -2796,7 +3000,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2796
3000
  // offer flow (his call, accept/decline, the taxi) works unchanged.
2797
3001
  // Offline owners fall back to the Phase B ghost riders below.
2798
3002
  let sharedDeclineNote = '';
2799
- if (this.hasHub && !this._anonymous) {
3003
+ if (this.hasHub && !this._anonymous && !this._bench) {
2800
3004
  const srMod = await import('./utilities/shared-run.js').catch(() => undefined);
2801
3005
  const preferred = this.preferredDraftSessionID;
2802
3006
  this.preferredDraftSessionID = undefined;
@@ -4163,7 +4367,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4163
4367
  { title: 'matrix', entries: [['jack'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
4164
4368
  // The Emerged get their own shelf (player request: "there MUST be
4165
4369
  // resonance" -- matrix is the place, Resonance is the talent).
4166
- { title: 'resonance', entries: [['compile', 'decompile'], ['thread'], ['register']] },
4370
+ { title: 'resonance', entries: [['compile', 'decompile'], ['thread'], ['unravel'], ['register'], ['sprites']] },
4167
4371
  { title: 'social', entries: [['say'], ['emote', 'me'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['accept'], ['decline', 'deny', 'refuse'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
4168
4372
  { title: 'street', entries: [['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['jobs'], ['journal'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
4169
4373
  { title: 'barriers', entries: [['pick'], ['breach'], ['dispel', 'dispell'], ['bluff'], ['unlock'], ['lock']] },