@maka/maka-cli 5.7.0 → 5.9.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 (178) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/deploy/ubuntu.sub.cmd.js +16 -31
  3. package/bundle/typescript/src/commands/deploy/ubuntu.sub.cmd.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +6 -4
  5. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +3 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +19 -7
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js +3 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/assense.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +35 -9
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +2 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js.map +1 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +31 -4
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +16 -2
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.d.ts +7 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +118 -29
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.d.ts +33 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js +138 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js.map +1 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +2 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +5 -4
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +2 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +5 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +6 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +138 -19
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.d.ts +38 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +178 -0
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +14 -5
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +7 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/hints.d.ts +10 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/hints.js +24 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/hints.js.map +1 -0
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +2 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +2 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js +25 -3
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/lifestyle.js +2 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/lifestyle.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +53 -8
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +3 -2
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js.map +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/move.d.ts +41 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +160 -0
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js.map +1 -0
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/order.d.ts +19 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +152 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js.map +1 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js +5 -0
  68. package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js.map +1 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +1 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  71. package/bundle/typescript/src/commands/game/sideQuest/commands/project.js +2 -1
  72. package/bundle/typescript/src/commands/game/sideQuest/commands/project.js.map +1 -1
  73. package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js +2 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js.map +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +3 -2
  76. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  77. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +1 -1
  78. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
  79. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +24 -6
  80. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  81. package/bundle/typescript/src/commands/game/sideQuest/commands/sell.js +7 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/commands/sell.js.map +1 -1
  83. package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js +2 -1
  84. package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.js +3 -2
  86. package/bundle/typescript/src/commands/game/sideQuest/commands/sneak.js.map +1 -1
  87. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js +11 -1
  88. package/bundle/typescript/src/commands/game/sideQuest/commands/solve.js.map +1 -1
  89. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +24 -13
  90. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js.map +1 -1
  91. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.d.ts +19 -0
  92. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +113 -0
  93. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js.map +1 -0
  94. package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +4 -0
  95. package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js.map +1 -1
  96. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +37 -4
  97. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  98. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +6 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  100. package/bundle/typescript/src/commands/game/sideQuest/commands/treat.js +5 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/commands/treat.js.map +1 -1
  102. package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js +9 -2
  103. package/bundle/typescript/src/commands/game/sideQuest/commands/unlock.js.map +1 -1
  104. package/bundle/typescript/src/commands/game/sideQuest/commands/work.js +2 -1
  105. package/bundle/typescript/src/commands/game/sideQuest/commands/work.js.map +1 -1
  106. package/bundle/typescript/src/commands/game/sideQuest/factories/item-factory.js +2 -1
  107. package/bundle/typescript/src/commands/game/sideQuest/factories/item-factory.js.map +1 -1
  108. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js +2 -1
  109. package/bundle/typescript/src/commands/game/sideQuest/factories/puzzle-factory.js.map +1 -1
  110. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +5 -0
  111. package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js.map +1 -1
  112. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +11 -0
  113. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +138 -23
  114. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  115. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +12 -0
  116. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  117. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +91 -0
  118. package/bundle/typescript/src/commands/game/sideQuest/game.js +501 -23
  119. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  120. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +2 -0
  121. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +3 -0
  122. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  123. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +44 -1
  124. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +349 -9
  125. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  126. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +10 -2
  127. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +36 -7
  128. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  129. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +2 -0
  130. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +9 -1
  131. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
  132. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +13 -0
  133. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +57 -4
  134. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  135. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +28 -0
  136. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +172 -4
  137. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  138. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene1.json +76 -20
  139. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +157 -34
  140. package/bundle/typescript/src/commands/game/sideQuest/types/seed/exit-seed.d.ts +1 -0
  141. package/bundle/typescript/src/commands/game/sideQuest/types/seed/item-seed.d.ts +1 -0
  142. package/bundle/typescript/src/commands/game/sideQuest/types/seed/player-seed.d.ts +1 -0
  143. package/bundle/typescript/src/commands/game/sideQuest/types/seed/puzzle-seed.d.ts +1 -0
  144. package/bundle/typescript/src/commands/game/sideQuest/types/seed/room-seed.d.ts +6 -0
  145. package/bundle/typescript/src/commands/game/sideQuest/ui.js +81 -5
  146. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  147. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +21 -5
  148. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -1
  149. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +25 -3
  150. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +128 -28
  151. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  152. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.d.ts +7 -4
  153. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +8 -5
  154. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js.map +1 -1
  155. package/bundle/typescript/src/commands/game/sideQuest/utilities/grapple.d.ts +36 -0
  156. package/bundle/typescript/src/commands/game/sideQuest/utilities/grapple.js +57 -0
  157. package/bundle/typescript/src/commands/game/sideQuest/utilities/grapple.js.map +1 -0
  158. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.d.ts +4 -0
  159. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.js +55 -0
  160. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.js.map +1 -0
  161. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.d.ts +14 -0
  162. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +15 -0
  163. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js.map +1 -0
  164. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.d.ts +16 -0
  165. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js +49 -4
  166. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js.map +1 -1
  167. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +31 -7
  168. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  169. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +121 -0
  170. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +315 -0
  171. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -0
  172. package/bundle/typescript/src/commands/ssl.js +10 -15
  173. package/bundle/typescript/src/commands/ssl.js.map +1 -1
  174. package/bundle/typescript/src/templates/react/kit/mui/auth/password-reset/basic-password-reset-page.js.tsx +3 -4
  175. package/bundle/typescript/src/tools/ssl/ssl.class.d.ts +8 -0
  176. package/bundle/typescript/src/tools/ssl/ssl.class.js +53 -0
  177. package/bundle/typescript/src/tools/ssl/ssl.class.js.map +1 -1
  178. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { capitalCase } from 'change-case';
3
3
  import { Category } from './types/shared/item-enum.js';
4
4
  import { Item } from './models/item.js';
5
5
  import { Room } from './models/_index.js';
6
+ import { Door } from './models/door.js';
6
7
  import { isFence, LIFESTYLE_TIERS } from './utilities/commerce.js';
7
8
  import { rollPool, formatRoll } from './utilities/dice.js';
8
9
  import { Direction } from './types/shared/direction-enum.js';
@@ -33,6 +34,7 @@ import { SilentCommand } from './commands/silent.js';
33
34
  import { RestCommand } from './commands/rest.js';
34
35
  import { ReloadCommand } from './commands/reload.js';
35
36
  import { SubdueCommand } from './commands/subdue.js';
37
+ import { GrappleCommand, ReleaseCommand, StruggleCommand } from './commands/grapple.js';
36
38
  import { EdgeCommand } from './commands/edge.js';
37
39
  import { SheetCommand } from './commands/sheet.js';
38
40
  import { CallCommand } from './commands/call.js';
@@ -56,6 +58,12 @@ import { AskCommand } from './commands/ask.js';
56
58
  import { ContactsCommand } from './commands/contacts.js';
57
59
  import { JumpCommand } from './commands/jump.js';
58
60
  import { InitiateCommand } from './commands/initiate.js';
61
+ import { SummonCommand } from './commands/summon.js';
62
+ import { OrderCommand } from './commands/order.js';
63
+ import { DeployCommand, AgentCommand, RecallCommand } from './commands/companions.js';
64
+ import { MoveCommand, RunCommand, SprintCommand } from './commands/move.js';
65
+ import { HintsCommand } from './commands/hints.js';
66
+ import { hint } from './utilities/hints.js';
59
67
  import { InstallCommand } from './commands/install.js';
60
68
  import { BuyCommand } from './commands/buy.js';
61
69
  import { SellCommand } from './commands/sell.js';
@@ -67,6 +75,7 @@ import { EatCommand } from './commands/eat.js';
67
75
  import { DrinkCommand } from './commands/drink.js';
68
76
  import { equipIntoEmptySlots } from './archetypes.js';
69
77
  import { NPC } from './models/npc.js';
78
+ import { CommandRegistry } from './commands/command-registry.js';
70
79
  import { HaggleCommand } from './commands/haggle.js';
71
80
  import { SneakCommand } from './commands/sneak.js';
72
81
  import { PersuadeCommand } from './commands/persuade.js';
@@ -76,6 +85,9 @@ import { CommandFactory } from './factories/command-factory.js';
76
85
  import { SceneSynthesizer } from './factories/scene-factory.js';
77
86
  import { SceneSeedGenerator, FIXER_NAME } from './factories/scene-seed-generator.js';
78
87
  import { CALL_ICON, CALL_COLOR } from './utilities/comm-style.js';
88
+ import { AI } from '../../../tools/ai/ai.class.js';
89
+ import { BULLET } from './utilities/log-style.js';
90
+ import { spotOf } from './utilities/spots.js';
79
91
  export default class Game {
80
92
  initialized;
81
93
  static instance;
@@ -91,6 +103,11 @@ export default class Game {
91
103
  // give the log the whole screen.
92
104
  _screen;
93
105
  _logBox;
106
+ _metaBox;
107
+ // Quit confirmation state (see handleInput / initInputBox): armed by
108
+ // the first quit/exit or ESC, cleared by anything that isn't a yes.
109
+ _quitArmed = false;
110
+ _escArmedAt = 0;
94
111
  _statusBox;
95
112
  _inputBox;
96
113
  _useAi = false;
@@ -164,6 +181,227 @@ export default class Game {
164
181
  }
165
182
  Logger.getInstance().write(`Home turf: ${this.contacts.size} contacts seeded from the hub cast.`);
166
183
  }
184
+ // ==================== COMPANIONS: the party layer ====================
185
+ // Every companion is a per-scene NPC SHELL plus persistent STATE, and
186
+ // scene transitions are covered by narration, never instance rebinding
187
+ // (design decision with the player): spirits fade (RAW p.300: the
188
+ // summons doesn't outlast the ride), drones fold to the pack, agents
189
+ // into the deck -- damage lives on the bound Item and redeploys free.
190
+ // Tenants: summoned spirits (summon.ts), matrix agents (agent-cmd.ts),
191
+ // autonomous drones (deploy in drone-deploy.ts). Future tenants
192
+ // (enlisted allies, anthroform frames) ride the same rail with
193
+ // different words ("takes their own ride and meets you there").
194
+ // Roster invariant: entries are LIVE SHELLS only -- dismissal and
195
+ // death both remove the entry; persistent state (the frame, the deck)
196
+ // lives on Items in the player's possession, never here.
197
+ companions = [];
198
+ // Drones deployed at travel time re-shell on arrival ("the rotors spin
199
+ // back up at the curb") -- tracked as their items across the swap.
200
+ _pendingRedeploys = [];
201
+ /**
202
+ * The one correct runtime NPC spawn (mirrors scene-factory's seed
203
+ * path): construct with a scene-bound registry, add to the scene
204
+ * (names must be unique -- addActor throws), place via
205
+ * setCharacterLocation (the ONLY path that binds room observers), and
206
+ * hand it the scene for its AI context.
207
+ */
208
+ spawnCompanionShell(opts) {
209
+ if (this.scene.getActor(opts.name)) {
210
+ Logger.getInstance().write(`spawnCompanionShell: name "${opts.name}" already in scene -- refusing.`);
211
+ return null;
212
+ }
213
+ const npc = new NPC({
214
+ dialog: [],
215
+ description: opts.description,
216
+ playerConstructorConfig: { playerName: opts.name, startLocation: opts.room, combat: opts.combat },
217
+ }, new CommandRegistry(this.scene));
218
+ npc.plane = opts.plane;
219
+ npc.allyOf = this.player.name;
220
+ npc.companionKind = opts.kind;
221
+ npc.boundDevice = opts.boundDevice;
222
+ this.scene.addActor(npc);
223
+ this.scene.setCharacterLocation(npc, opts.room.name);
224
+ npc.scene = this.scene;
225
+ this.companions.push({ npc, kind: opts.kind, services: opts.services, force: opts.force, boundDevice: opts.boundDevice });
226
+ Logger.getInstance().write(`Companion spawned: ${opts.name} (${opts.kind}, force ${opts.force}) in ${opts.room.name}.`);
227
+ return npc;
228
+ }
229
+ /**
230
+ * The autonomous-drone shell, shared by the deploy command and
231
+ * arrival re-shelling: the frame's dog-brain (Pilot = 2 + quality)
232
+ * stands in for every attribute it lacks (RAW p.269-271).
233
+ */
234
+ // Cargo sling by frame class (player request: capacity enforced -- a
235
+ // palm-sized Fly-Spy is not a mule). Keyed by the frame Item's size.
236
+ static DRONE_CARGO_KG = {
237
+ tiny: 0.5, small: 2, medium: 8, large: 20, huge: 40,
238
+ };
239
+ deployDroneShell(item, room) {
240
+ const pilot = 2 + item.qualityBonus;
241
+ const armed = /armed|combat|gun|roto|assault|sentry|weapon/i.test(`${item.name} ${item.description ?? ''}`);
242
+ const shell = this.spawnCompanionShell({
243
+ name: `${item.name} (autopilot)`,
244
+ description: `${this.player.name}'s drone running its dog-brain autopilot: it follows them, watches, and ${armed ? 'shoots what threatens them' : 'has cameras where guns would go -- it never attacks'}.`,
245
+ kind: 'drone',
246
+ combat: { body: 2 + item.qualityBonus, agility: pilot, reaction: pilot, logic: pilot, intuition: pilot, willpower: pilot, strength: 1, charisma: 1, skills: { perception: pilot, ...(armed ? { firearms: pilot } : {}) } },
247
+ room, plane: 'meat', force: pilot, boundDevice: item,
248
+ });
249
+ // Take-command weight checks (Player.addInventory) enforce this cap
250
+ // for the shell exactly as they do for a runner.
251
+ if (shell)
252
+ shell.maxCarryingWeight = Game.DRONE_CARGO_KG[String(item.size)] ?? 2;
253
+ return shell;
254
+ }
255
+ /** Drops a companion's shell and its roster entry. */
256
+ dismissCompanion(entry, reason) {
257
+ if (this.scene.getActor(entry.npc.name) === entry.npc) {
258
+ // Cargo comes home with the shell (player request: fetch-and-carry
259
+ // runs -- a real session's drone took a credstick and had no way
260
+ // to hand it over). A deliberate dismissal passes whatever the
261
+ // shell carried to its master; without this, scene.removeActor
262
+ // dumps it on the floor wherever the shell happened to be.
263
+ const carried = entry.npc.inventory.getAllItems().map(i => i.name);
264
+ const delivered = carried.filter(n => entry.npc.inventory.transferItemTo(n, this.player.inventory));
265
+ if (delivered.length > 0) {
266
+ Logger.getInstance().logWithColor(`👥 From ${entry.npc.name}'s cargo, now yours: ${delivered.join(', ')}.`, 'green');
267
+ }
268
+ // Mark deliberate before removeActor fires onActorRemoved, so no
269
+ // wreckage is charged to a clean dismissal.
270
+ entry.npc.deathClaimed = true;
271
+ this.scene.removeActor(entry.npc.name);
272
+ }
273
+ const idx = this.companions.indexOf(entry);
274
+ if (idx >= 0)
275
+ this.companions.splice(idx, 1);
276
+ Logger.getInstance().write(`Companion dismissed: ${entry.npc.name} -- ${reason}`);
277
+ }
278
+ /**
279
+ * An actor left the world (Scene.removeActor). For a companion shell
280
+ * that DIED (not a deliberate dismissal -- those set deathClaimed
281
+ * first and prune themselves), settle the wreckage: a drone's frame is
282
+ * wrecked where it fell, an agent derezzes (the deck already ate the
283
+ * damage), a spirit is banished outright.
284
+ */
285
+ onActorRemoved(name) {
286
+ const entry = this.companions.find(c => c.npc.name === name);
287
+ if (!entry)
288
+ return;
289
+ const idx = this.companions.indexOf(entry);
290
+ if (idx >= 0)
291
+ this.companions.splice(idx, 1);
292
+ if (entry.kind === 'drone' && entry.boundDevice && !entry.npc.deathClaimed) {
293
+ entry.boundDevice.takeDroneDamage(entry.boundDevice.maxDroneBoxes);
294
+ Logger.getInstance().logWithColor(`👥 The ${entry.boundDevice.name} is WRECKED where it fell -- rest to repair the frame.`, 'yellow');
295
+ }
296
+ this.updateStatus();
297
+ }
298
+ /**
299
+ * Spirits with no services left depart once no exchange holds them
300
+ * (called from CombatExchange.finishExchange): the bargain is spent.
301
+ */
302
+ settleSpiritServices() {
303
+ for (const entry of [...this.companions]) {
304
+ if (entry.kind !== 'spirit' || (entry.services ?? 0) > 0)
305
+ continue;
306
+ if (entry.npc.inExchange)
307
+ continue;
308
+ // The paid fight is fought to the END. Between exchanges of the
309
+ // same fight inExchange is briefly false -- a real session's
310
+ // spirit spent its last service to join a fight and was dismissed
311
+ // 3ms later, before a single blow. Same freshness window as the
312
+ // per-fight billing in combat-exchange.
313
+ if (entry.npc.combatOpponent && Date.now() - entry.npc.lastExchangeAt < 60_000)
314
+ continue;
315
+ Logger.getInstance().logWithColor(`👥 ${entry.npc.name}'s bargain is spent -- it bows once and thins into nothing.`, 'green');
316
+ this.dismissCompanion(entry, 'services exhausted');
317
+ this.updateStatus();
318
+ }
319
+ }
320
+ // ==================== Pursuit heartbeat ====================
321
+ // Companions are stimulus-driven like every NPC: a directive burst ends
322
+ // at the reflect cap ("Max recursion reached") and a shell alone in a
323
+ // distant room then NEVER acts again -- a real session's drone was
324
+ // ordered to "find Mr. Krow", walked three rooms, and froze mid-hunt.
325
+ // The heartbeat re-spurs every shell with an open objective on a slow
326
+ // tick until it reports DONE/FAILED (which clears the objective -- see
327
+ // NPC.relayToMaster) or is dismissed.
328
+ _pursuitTimer;
329
+ _pursuitInFlight = new Set();
330
+ static PURSUIT_TICK_MS = 30_000;
331
+ startPursuitHeartbeat() {
332
+ if (this._pursuitTimer)
333
+ return;
334
+ this._pursuitTimer = setInterval(() => this.spurCompanionPursuits(), Game.PURSUIT_TICK_MS);
335
+ // Never hold the process open on our account.
336
+ this._pursuitTimer.unref?.();
337
+ }
338
+ stopPursuitHeartbeat() {
339
+ if (this._pursuitTimer)
340
+ clearInterval(this._pursuitTimer);
341
+ this._pursuitTimer = undefined;
342
+ }
343
+ spurCompanionPursuits() {
344
+ // The tick doubles as the spent-bargain sweep: finishExchange's own
345
+ // settle call now skips spirits whose fight is still warm (see
346
+ // settleSpiritServices), so without this a victorious spirit would
347
+ // linger at zero services forever.
348
+ this.settleSpiritServices();
349
+ if (!AI.isConfigured())
350
+ return;
351
+ for (const entry of this.companions) {
352
+ const npc = entry.npc;
353
+ if (!npc.objective)
354
+ continue;
355
+ if (npc.isIncapacitated() || npc.inExchange || npc.waitingForLull)
356
+ continue;
357
+ // A stale shell (already removed from the world) gets no spur.
358
+ if (this.scene.getActor(npc.name) !== npc)
359
+ continue;
360
+ // One spur in flight per shell -- a slow AI burst must not stack
361
+ // with the next tick.
362
+ if (this._pursuitInFlight.has(npc))
363
+ continue;
364
+ this._pursuitInFlight.add(npc);
365
+ Logger.getInstance().write(`Pursuit heartbeat: spurring ${npc.name} -- ${npc.objective}`);
366
+ void Promise.resolve(npc.hear(this.player, `[System note: HEARTBEAT -- your standing directive is still open: "${npc.objective}". Continue it NOW with [COMMAND] actions -- several steps if needed. If it is complete, say a line starting "DONE: <result>"; if it cannot be done, say a line starting "FAILED: <why>". Do not go quiet.]`, { viaLink: true }))
367
+ .catch(err => Logger.getInstance().error(`Pursuit heartbeat failed for ${npc.name}: ${err}`))
368
+ .finally(() => this._pursuitInFlight.delete(npc));
369
+ }
370
+ }
371
+ /** Pre-travel: every shell folds, each with its own goodbye. */
372
+ foldCompanionsForTravel() {
373
+ const lines = [];
374
+ for (const entry of [...this.companions]) {
375
+ if (entry.kind === 'spirit') {
376
+ lines.push(`Your bound spirit thins into the rain -- the summons doesn't outlast the ride.`);
377
+ }
378
+ else if (entry.kind === 'drone') {
379
+ lines.push(`The ${entry.boundDevice?.name ?? 'drone'} autopilots home to your hand for the ride.`);
380
+ if (entry.boundDevice)
381
+ this._pendingRedeploys.push(entry.boundDevice);
382
+ }
383
+ else if (entry.kind === 'agent') {
384
+ lines.push(`Your agent folds back into the deck.`);
385
+ }
386
+ else {
387
+ lines.push(`${entry.npc.name} takes their own ride -- they'll meet you there.`);
388
+ }
389
+ this.dismissCompanion(entry, `travel: ${entry.kind} folds`);
390
+ }
391
+ return lines;
392
+ }
393
+ /** Post-arrival: re-shell what was deployed when you left (drones). */
394
+ unfoldCompanionsOnArrival() {
395
+ const lines = [];
396
+ for (const item of this._pendingRedeploys.splice(0)) {
397
+ if (item.isWrecked)
398
+ continue;
399
+ const shell = this.deployDroneShell(item, this.player.currentLocation);
400
+ if (shell)
401
+ lines.push(`The ${item.name}'s rotors spin back up at the curb.`);
402
+ }
403
+ return lines;
404
+ }
167
405
  /**
168
406
  * Records a real transaction with a hub NPC: first contact opens the
169
407
  * relationship at Loyalty 1 (a safeguard -- the hub cast is pre-seeded
@@ -186,7 +424,7 @@ export default class Game {
186
424
  if (kind === 'gig')
187
425
  c.gigs += 1;
188
426
  Logger.getInstance().write(`Contact opened: ${name} (connection ${c.connection}).`);
189
- return `${name} knows your name now -- a CONTACT (Connection ${c.connection}, Loyalty 1). ("contacts" for the roster, "ask ${name} about ..." for legwork.)`;
427
+ return `${name} knows your name now -- a CONTACT (Connection ${c.connection}, Loyalty 1).${hint(` ("contacts" for the roster, "ask ${name} about ..." for legwork.)`)}`;
190
428
  }
191
429
  if (kind === 'gig') {
192
430
  c.gigs += 1;
@@ -200,7 +438,7 @@ export default class Game {
200
438
  c.spent += spent;
201
439
  if (Math.floor(c.spent / 1000) > thousandsBefore && c.loyalty < 3) {
202
440
  c.loyalty += 1;
203
- return `${name} has started keeping your usual ready. (Loyalty ${c.loyalty} -- patronage tops out at 3; run their errands to go deeper.)`;
441
+ return `${name} has started keeping your usual ready.${hint(` (Loyalty ${c.loyalty} -- patronage tops out at 3; run their errands to go deeper.)`)}`;
204
442
  }
205
443
  return null;
206
444
  }
@@ -482,6 +720,12 @@ export default class Game {
482
720
  CommandFactory.registerCommand('rest', RestCommand);
483
721
  CommandFactory.registerCommand('reload', ReloadCommand);
484
722
  CommandFactory.registerCommand('subdue', SubdueCommand);
723
+ // SUBDUING p.195 (see commands/grapple.ts): restrain without damage.
724
+ CommandFactory.registerCommand('grapple', GrappleCommand);
725
+ CommandFactory.registerCommand('restrain', GrappleCommand);
726
+ CommandFactory.registerCommand('clinch', GrappleCommand);
727
+ CommandFactory.registerCommand('release', ReleaseCommand);
728
+ CommandFactory.registerCommand('struggle', StruggleCommand);
485
729
  CommandFactory.registerCommand('edge', EdgeCommand);
486
730
  CommandFactory.registerCommand('sheet', SheetCommand);
487
731
  CommandFactory.registerCommand('stats', SheetCommand);
@@ -517,6 +761,21 @@ export default class Game {
517
761
  CommandFactory.registerCommand('rig', JumpCommand);
518
762
  CommandFactory.registerCommand('initiate', InitiateCommand);
519
763
  CommandFactory.registerCommand('submerge', InitiateCommand);
764
+ CommandFactory.registerCommand('summon', SummonCommand);
765
+ CommandFactory.registerCommand('conjure', SummonCommand);
766
+ // "At" spots (utilities/spots.ts): walk/move/approach are one
767
+ // crossing; run and sprint are the fast modes (combat semantics in
768
+ // the spot-move phase).
769
+ CommandFactory.registerCommand('walk', MoveCommand);
770
+ CommandFactory.registerCommand('move', MoveCommand);
771
+ CommandFactory.registerCommand('approach', MoveCommand);
772
+ CommandFactory.registerCommand('run', RunCommand);
773
+ CommandFactory.registerCommand('sprint', SprintCommand);
774
+ CommandFactory.registerCommand('hints', HintsCommand);
775
+ CommandFactory.registerCommand('order', OrderCommand);
776
+ CommandFactory.registerCommand('deploy', DeployCommand);
777
+ CommandFactory.registerCommand('recall', RecallCommand);
778
+ CommandFactory.registerCommand('agent', AgentCommand);
520
779
  CommandFactory.registerCommand('install', InstallCommand);
521
780
  CommandFactory.registerCommand('chrome', InstallCommand);
522
781
  CommandFactory.registerCommand('buy', BuyCommand);
@@ -550,6 +809,19 @@ export default class Game {
550
809
  if (this._homeRoomExitDirection) {
551
810
  this._homeRoom.exits.delete(this._homeRoomExitDirection);
552
811
  }
812
+ // The OTHER half of the previous link: sweep any exit on the anchor
813
+ // room that already leads home before choosing a direction. Without
814
+ // this, every homecoming re-ran the search against a room still
815
+ // holding last time's hideout door and stacked a second one on the
816
+ // next free direction (a real session's Neon Strip ended up with
817
+ // "up" AND "down" both reading "To Your Hideout").
818
+ for (const [dir, ex] of [...jobStartRoom.exits.entries()]) {
819
+ const leadsHome = ex instanceof Door
820
+ ? ex.getOtherSide(jobStartRoom) === this._homeRoom
821
+ : ex === this._homeRoom.name;
822
+ if (leadsHome)
823
+ jobStartRoom.exits.delete(dir);
824
+ }
553
825
  const direction = Game.HOME_DIRECTION_PRIORITY.find(d => !jobStartRoom.exits.has(d));
554
826
  if (!direction) {
555
827
  // Every direction is already spoken for on this job's start room --
@@ -568,6 +840,9 @@ export default class Game {
568
840
  get logBox() {
569
841
  return this._logBox;
570
842
  }
843
+ get metaBox() {
844
+ return this._metaBox;
845
+ }
571
846
  get inputBox() {
572
847
  return this._inputBox;
573
848
  }
@@ -576,6 +851,7 @@ export default class Game {
576
851
  this._logFilePath = options.logFilePath;
577
852
  this.initInputBox(screen);
578
853
  this.initLogBox(screen);
854
+ this.initMetaBox(screen);
579
855
  this.initStatusBox(screen);
580
856
  // No "Generating Scene... Please wait." box anymore: every path now
581
857
  // arrives with REAL prose (hand-authored static seeds, chunk-authored
@@ -626,6 +902,15 @@ export default class Game {
626
902
  this.player.lifestyleEdgeBonus = this.lifestyle.edgeBonus;
627
903
  // Home turf knows its own (see seedHomeTurfContacts).
628
904
  this.seedHomeTurfContacts();
905
+ // The next run starts cooking the moment you're home (player
906
+ // request): generation takes 20-40s, and waiting for the player
907
+ // to CALL first meant "give me a bit" every time. Draft now --
908
+ // Mr. Johnson's "1 new message" ping lands mid-session, and the
909
+ // call finds work already waiting. The gate keeps this a no-op
910
+ // with AI off or a draft/pending seed somehow already in flight.
911
+ if (this.canDraftNextScene().allowed) {
912
+ this.draftNextSceneInBackground();
913
+ }
629
914
  }
630
915
  // Gear up automatically: wearables (and the commlink) slot into any
631
916
  // EMPTY slots at load -- no "equip all" ritual to start a session
@@ -633,6 +918,10 @@ export default class Game {
633
918
  // never swapped out.
634
919
  equipIntoEmptySlots(this.player);
635
920
  this.warmNpcAppearancesInBackground();
921
+ this.startPursuitHeartbeat();
922
+ // The banner learns where we are only after the hub/scene bookkeeping
923
+ // above settles.
924
+ this.refreshLogLabel();
636
925
  this.initialized = true;
637
926
  // Nudge the player toward "call" up front -- the fixer convention means
638
927
  // the story deliberately doesn't narrate a completed briefing, so
@@ -640,7 +929,7 @@ export default class Game {
640
929
  // you actually get your orders.
641
930
  const wc = this.scene.getWinCondition();
642
931
  const callHint = (wc?.type === 'give' && wc.toNpc)
643
- ? `\n\nYour commlink is quiet for now, but ${wc.toNpc} is expecting to hear from you -- try "call ${wc.toNpc}" when you're ready to check in.`
932
+ ? hint(`\n\nYour commlink is quiet for now, but ${wc.toNpc} is expecting to hear from you -- try "call ${wc.toNpc}" when you're ready to check in.`)
644
933
  : '';
645
934
  return `Welcome, ${this.player.name}\n ${this.scene.story}${callHint}`;
646
935
  }
@@ -731,6 +1020,8 @@ export default class Game {
731
1020
  // Read the run's fate BEFORE the scene swap below discards it -- a
732
1021
  // walked-away job gets a different homecoming line than a wrapped one.
733
1022
  const wrappedRun = this.scene !== hub && this.scene.isCompleted();
1023
+ // Companions fold for the ride home, same as the trip out.
1024
+ const companionFoldLines = this.foldCompanionsForTravel();
734
1025
  // Settle cross-plane and combat state that must not leak into the hub
735
1026
  // (the run's rooms -- including a bodyRoom the player left behind --
736
1027
  // are about to be orphaned wholesale).
@@ -774,10 +1065,10 @@ export default class Game {
774
1065
  this.lifestyleTier = LIFESTYLE_TIERS[tierIndex - 1].name;
775
1066
  // The downgrade is visible the moment you look around.
776
1067
  this.syncHomeToLifestyle();
777
- rentLine = `The landlord's drone pings before the door even shuts: ${paid > 0 ? `${paid} paid, ` : ''}${this.rentDebt} nuyen still owed. Your ${tier.name} arrangement lapses -- you're living ${this.lifestyleTier} now. ("pay" the tab, "lifestyle" to climb back.)`;
1068
+ rentLine = `The landlord's drone pings before the door even shuts: ${paid > 0 ? `${paid} paid, ` : ''}${this.rentDebt} nuyen still owed. Your ${tier.name} arrangement lapses -- you're living ${this.lifestyleTier} now.${hint(` ("pay" the tab, "lifestyle" to climb back.)`)}`;
778
1069
  }
779
1070
  else {
780
- rentLine = `Even the doorway costs something: ${this.rentDebt} nuyen on the tab. ("pay" when you're flush.)`;
1071
+ rentLine = `Even the doorway costs something: ${this.rentDebt} nuyen on the tab.${hint(` ("pay" when you're flush.)`)}`;
781
1072
  }
782
1073
  }
783
1074
  else {
@@ -800,11 +1091,15 @@ export default class Game {
800
1091
  this.updateInventory(this.player.inventory, capitalCase(this.scene.getCurrencyType()));
801
1092
  this.updateExits(this.player.currentLocation);
802
1093
  this.updateStatus();
1094
+ this.refreshLogLabel();
803
1095
  Logger.getInstance().write(`Returned to hub "${this._hubName}". Lifestyle ${this.lifestyleTier}: ${paid} paid, ${this.rentDebt} owed. Restocked ${restocked}. Rumor: ${this.activeRumor ?? '(none)'}`);
1096
+ // Drones spin back up beside the cot.
1097
+ const companionArrivalLines = this.unfoldCompanionsOnArrival();
1098
+ const companionBlock = [...companionFoldLines, ...companionArrivalLines];
804
1099
  const fateLine = wrappedRun
805
1100
  ? `The run's wrapped and the street swallows you back up.`
806
1101
  : `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
807
- return `\n=== ${this._hubName.toUpperCase()} ===\n${fateLine} You're home -- your hideout, one door off ${hub.determineStartRoom().name}. Call ${FIXER_NAME} when you're hungry for the next job.\n${rentLine}${comfortLine}${restockLine}`;
1102
+ return `\n=== ${this._hubName.toUpperCase()} ===\n${fateLine} You're home -- your hideout, one door off ${hub.determineStartRoom().name}.${hint(` Call ${FIXER_NAME} when you're hungry for the next job.`)}\n${rentLine}${comfortLine}${restockLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
808
1103
  }
809
1104
  /**
810
1105
  * The actual drafting work, split out from continueToNextScene() purely
@@ -849,7 +1144,7 @@ export default class Game {
849
1144
  this._pendingRunSeed = nextSeed;
850
1145
  this._pendingRunTier = nextTier;
851
1146
  this._pendingRide = Game.RIDE_POOL[Math.floor(Math.random() * Game.RIDE_POOL.length)];
852
- return `Your link pings -- 1 new message: ${FIXER_NAME}. ("call ${FIXER_NAME}" to hear the job.)`;
1147
+ return `Your link pings -- 1 new message: ${FIXER_NAME}.${hint(` ("call ${FIXER_NAME}" to hear the job.)`)}`;
853
1148
  }
854
1149
  // Classic sessions transition immediately, hideout-first, as always.
855
1150
  return await this.activateRunScene(nextSeed, nextTier, { startInHideout: true });
@@ -868,6 +1163,9 @@ export default class Game {
868
1163
  this.player.activeCallPartner.activeCallPartner = undefined;
869
1164
  this.player.activeCallPartner = undefined;
870
1165
  }
1166
+ // Companions fold for the ride (spirits depart, drones to the pack,
1167
+ // agents into the deck) -- their lines join the JOB banner below.
1168
+ const companionLines = this.foldCompanionsForTravel();
871
1169
  // false, false: no starter kit (the player carries their own gear
872
1170
  // forward), and no blocking AI-redressing pass either -- the chunked
873
1171
  // rooms pass already wrote this scene's prose.
@@ -896,8 +1194,15 @@ export default class Game {
896
1194
  this.updateStatus();
897
1195
  // Fresh streets, no trail yet (see Player.cameFrom / the HUD crumb).
898
1196
  this.player.cameFrom = undefined;
1197
+ // Deployed drones spin back up at the curb -- BEFORE appearance
1198
+ // warming so re-shelled companions get warmed with the cast.
1199
+ const arrivalLines = this.unfoldCompanionsOnArrival();
899
1200
  this.warmNpcAppearancesInBackground();
900
- return `\n=== JOB #${this._tier}: ${seed.name} ===\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}`;
1201
+ this.refreshLogLabel();
1202
+ // No "=== JOB #n ===" banner on entry (player request) -- the Game
1203
+ // Log window's own title carries the job name now.
1204
+ const companionBlock = [...companionLines, ...arrivalLines];
1205
+ return `\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
901
1206
  }
902
1207
  /**
903
1208
  * The needs check at every job departure (SR5e p.56 / p.172, scaled to
@@ -920,11 +1225,15 @@ export default class Game {
920
1225
  p.sleepStreak = slept ? 0 : p.sleepStreak + 1;
921
1226
  p.refreshEdge(fed && slept);
922
1227
  if (fed && slept) {
923
- lines.push(`Fed and slept -- you head out SHARP. (Edge ${p.edgeRemaining}/${p.edge + p.lifestyleEdgeBonus}.)`);
1228
+ // Good-news status with a rules lesson inside -- hint-gated
1229
+ // (player request); the HUD's edge count carries the number anyway.
1230
+ const note = hint(`Fed and slept -- you head out SHARP. (Edge ${p.edgeRemaining}/${p.edge + p.lifestyleEdgeBonus}.)`);
1231
+ if (note)
1232
+ lines.push(note);
924
1233
  }
925
1234
  else {
926
1235
  const missing = !fed && !slept ? 'meal and no sleep' : !fed ? 'meal' : 'sleep';
927
- lines.push(`No ${missing} since the last job -- your edge doesn't come back (${p.edgeRemaining} left). A hot meal and a night in your own bed reset it.`);
1236
+ lines.push(`No ${missing} since the last job -- your edge doesn't come back (${p.edgeRemaining} left).${hint(` A hot meal and a night in your own bed reset it.`)}`);
928
1237
  }
929
1238
  // Escalating, resisted, LOCKED fatigue per ignored need.
930
1239
  const events = [];
@@ -991,6 +1300,7 @@ export default class Game {
991
1300
  }
992
1301
  }
993
1302
  async close() {
1303
+ this.stopPursuitHeartbeat();
994
1304
  this._inputBox.removeAllListeners(); // removes all event listeners
995
1305
  this._inputBox.cancel(); // cancels any active input session
996
1306
  this._inputBox.detach(); // optionally remove from screen
@@ -1021,8 +1331,18 @@ export default class Game {
1021
1331
  });
1022
1332
  this._inputBox = inputBox;
1023
1333
  screen.append(inputBox);
1024
- // Key bindings
1025
- screen.key(['escape', 'C-c'], () => process.exit(0));
1334
+ // Key bindings. ESC/Ctrl-C take a SECOND press within 3s to quit
1335
+ // (player request: "I just exited by mistake") -- the first press
1336
+ // warns; typing anything disarms nothing (the window just expires).
1337
+ screen.key(['escape', 'C-c'], () => {
1338
+ const now = Date.now();
1339
+ if (now - this._escArmedAt < 3000) {
1340
+ process.exit(0);
1341
+ }
1342
+ this._escArmedAt = now;
1343
+ Logger.getInstance().logWithColor(`Press again within 3s to quit. (Or "quit" + "y".)`, 'yellow');
1344
+ this._screen?.render();
1345
+ });
1026
1346
  // Focus on the input box by default -- but ONLY if it isn't already
1027
1347
  // focused. screen.append() auto-focuses the first keyable element
1028
1348
  // (Node.insert), and since the sidebar boxes were removed this textbox
@@ -1047,13 +1367,17 @@ export default class Game {
1047
1367
  id: 'logBox',
1048
1368
  top: '0%',
1049
1369
  left: '0%',
1050
- width: '100%',
1370
+ // COLUMNS, not rows (playtest note: a 3-row Mechanics strip made
1371
+ // combat rolls "near impossible to read" -- they scrolled out as
1372
+ // fast as they landed). The world log takes the left ~two-thirds;
1373
+ // the Mechanics column stands full-height beside it.
1374
+ width: '65%',
1051
1375
  // Fixed-row footer: 8 rows of HUD (status box) + 3 rows of input.
1052
1376
  height: '100%-11',
1053
1377
  border: {
1054
1378
  type: 'line'
1055
1379
  },
1056
- label: ' Game Log ',
1380
+ label: this.buildLogLabel(false),
1057
1381
  scrollable: true, // Enable scrolling
1058
1382
  alwaysScroll: true,
1059
1383
  scrollbar: {
@@ -1073,8 +1397,113 @@ export default class Game {
1073
1397
  tags: true // Intercept tags
1074
1398
  });
1075
1399
  this._logBox = logBox;
1400
+ // Scrolling without focus (player report: "can't scroll the game log
1401
+ // anymore"): focus lives in the input box on every path, so the
1402
+ // box's own keys never fire, and the wheel died with mouse tracking.
1403
+ // SCREEN-level PgUp/PgDn scroll the log while you keep typing; while
1404
+ // you're up in history, followBottom stops every new line from
1405
+ // yanking the view back down (Logger.log and ui.ts honor it).
1406
+ // Paging back to the bottom -- or End -- re-engages the follow.
1407
+ logBox.followBottom = true;
1408
+ // CRITICAL: the input box is ALWAYS reading (focus never leaves it),
1409
+ // and blessed's textarea.readInput sets screen.grabKeys = true --
1410
+ // which mutes every screen-level key handler EXCEPT keys listed in
1411
+ // screen.ignoreLocked (screen.js:591). Without this, the bindings
1412
+ // below simply never fire (a real session: "PgUp/Dn don't work").
1413
+ // The textbox itself ignores these keys, so nothing is stolen.
1414
+ screen.ignoreLocked.push('pageup', 'pagedown', 'end');
1415
+ const page = () => Math.max(1, (typeof logBox.height === 'number' ? logBox.height : 20) - 3);
1416
+ const setFollow = (on) => {
1417
+ logBox.followBottom = on;
1418
+ logBox.setLabel(this.buildLogLabel(!on));
1419
+ };
1420
+ const atBottom = () => logBox.getScrollPerc() >= 100 || logBox.getScrollHeight() <= page() + 3;
1421
+ screen.key(['pageup'], () => {
1422
+ logBox.scroll(-page());
1423
+ setFollow(false);
1424
+ screen.render();
1425
+ });
1426
+ screen.key(['pagedown'], () => {
1427
+ logBox.scroll(page());
1428
+ if (atBottom())
1429
+ setFollow(true);
1430
+ screen.render();
1431
+ });
1432
+ screen.key(['end'], () => {
1433
+ logBox.scrollTo(logBox.getScrollHeight());
1434
+ setFollow(true);
1435
+ screen.render();
1436
+ });
1076
1437
  screen.append(logBox);
1077
1438
  }
1439
+ /**
1440
+ * The Game Log banner names WHERE you are in the campaign (player
1441
+ * request): the hub by name at home, "JOB #n: <name>" on a run --
1442
+ * plus the scroll state the label already carried.
1443
+ */
1444
+ buildLogLabel(scrolled) {
1445
+ const onHub = this._hubScene !== undefined && this.scene === this._hubScene;
1446
+ const name = onHub ? this._hubName : this.sceneSeed?.name;
1447
+ const where = name ? ` -- ${name}` : '';
1448
+ // The key coaching is hint-gated (player request); the SCROLLED
1449
+ // state itself stays -- it's status, not teaching.
1450
+ return scrolled
1451
+ ? ` Game Log${where} -- SCROLLED${hint(' (End to follow)')} `
1452
+ : ` Game Log${where}${hint(' (PgUp/PgDn to scroll)')} `;
1453
+ }
1454
+ /** Re-stamps the banner after a scene change (init, run start, home). */
1455
+ refreshLogLabel() {
1456
+ const box = this._logBox;
1457
+ if (!box)
1458
+ return;
1459
+ box.setLabel(this.buildLogLabel(box.followBottom === false));
1460
+ this._screen?.render();
1461
+ }
1462
+ /**
1463
+ * Scroll the log to its tail -- unless the player has paged up to read
1464
+ * history (followBottom off), in which case their place is left alone.
1465
+ * Every "new line arrived" site goes through here.
1466
+ */
1467
+ followLog() {
1468
+ const box = this._logBox;
1469
+ if (!box || box.followBottom === false)
1470
+ return;
1471
+ box.scrollTo(box.getScrollHeight());
1472
+ }
1473
+ /**
1474
+ * The MECHANICS column (readability pass, player request: the log was
1475
+ * a "wall of text"): dice anatomy and resource arithmetic land here
1476
+ * (via Logger.meta) instead of the world log, so the narrative stays
1477
+ * prose. A full-height COLUMN beside the world log, not a bottom
1478
+ * strip -- the first playtest's 3-row ticker scrolled combat rolls
1479
+ * away faster than they could be read. De-emphasis is the BLUE border
1480
+ * -- gray is banned (ANSI bright-black, illegible on stock dark
1481
+ * palettes). Follow-only (no keys); the full interleaved record lives
1482
+ * in session.log. Roll lines wrap at the column width -- fine in a
1483
+ * tall column. NO mouse (see initInputBox): never arm mouse tracking.
1484
+ */
1485
+ initMetaBox(screen) {
1486
+ const metaBox = blessed.box({
1487
+ id: 'metaBox',
1488
+ top: '0%',
1489
+ left: '65%',
1490
+ width: '35%',
1491
+ // Same height as the world log: HUD (8) + input (3) below both.
1492
+ height: '100%-11',
1493
+ border: {
1494
+ type: 'line'
1495
+ },
1496
+ style: {
1497
+ border: { fg: 'blue' }
1498
+ },
1499
+ label: ' Mechanics ',
1500
+ scrollable: true,
1501
+ alwaysScroll: true,
1502
+ tags: true,
1503
+ });
1504
+ this._metaBox = metaBox;
1505
+ screen.append(metaBox);
1506
+ }
1078
1507
  /**
1079
1508
  * The HUD: a fixed strip between the log and the input box for the
1080
1509
  * important real-time state -- condition tracks, armed/link status,
@@ -1130,7 +1559,10 @@ export default class Game {
1130
1559
  // a real question.
1131
1560
  const crumb = player.cameFrom ? `${player.cameFrom.room} (${player.cameFrom.back}) > ` : '';
1132
1561
  if (player.currentLocation) {
1133
- lines.push(`${crumb}{bold}📍 ${player.currentLocation.name}{/bold} | ${stance}`);
1562
+ // "At" spots: where you stand WITHIN the room, when it has places
1563
+ // to stand (see utilities/spots.ts).
1564
+ const atNote = spotOf(player) ? ` @ ${player.atSpot}` : '';
1565
+ lines.push(`${crumb}{bold}📍 ${player.currentLocation.name}${atNote}{/bold} | ${stance}`);
1134
1566
  }
1135
1567
  else {
1136
1568
  lines.push(stance);
@@ -1191,6 +1623,30 @@ export default class Game {
1191
1623
  if (player.activeCallPartner) {
1192
1624
  lines.push(`{${CALL_COLOR}-fg}${CALL_ICON} On a call with ${player.activeCallPartner.name}{/${CALL_COLOR}-fg}`);
1193
1625
  }
1626
+ // The party: one line per live companion shell, with the tracks that
1627
+ // matter for its kind. A spirit shows its BODY too (it's a full
1628
+ // Force-statted combatant and hits land on it -- player request:
1629
+ // the track was real but invisible); drones show hull, agents deck.
1630
+ for (const c of this.companions) {
1631
+ const track = c.kind === 'spirit'
1632
+ ? `${c.services ?? 0} service${(c.services ?? 0) === 1 ? '' : 's'} ${c.npc.conditionSummary()}${c.npc.stunTaken > 0 ? ` stun ${c.npc.stunSummary()}` : ''}`
1633
+ : c.kind === 'drone' && c.boundDevice ? `hull ${c.boundDevice.droneSummary()}`
1634
+ : c.kind === 'agent' && c.boundDevice ? `deck ${c.boundDevice.deckSummary()}`
1635
+ : c.npc.conditionSummary();
1636
+ // Each kind speaks its own language (a real rigger session read a
1637
+ // generic "(F3)" as spell Force on a Fly-Spy): drones fly on a
1638
+ // PILOT rating, agents run at a RATING, and a spirit's Force is
1639
+ // already in its name.
1640
+ const grade = c.kind === 'drone' ? `Pilot ${c.force} -- ` : c.kind === 'agent' ? `Rating ${c.force} -- ` : '';
1641
+ lines.push(`{green-fg}👥 ${c.npc.name} -- ${grade}${track}{/green-fg}`);
1642
+ }
1643
+ // DECK-AR INDICATOR (player request): "Link online" is the
1644
+ // COMMLINK's AR overlay; this is the DECK's -- lit when an agent
1645
+ // works the grid while you're back in the meat (the AR-drop exit).
1646
+ const remoteAgent = this.companions.find(c => c.kind === 'agent');
1647
+ if (remoteAgent && player.plane !== 'matrix') {
1648
+ lines.push(`{cyan-fg}▣ DECK IN AR -- ${remoteAgent.npc.name} on the grid${remoteAgent.npc.objective ? ', working its directive' : ''}${hint(' ("jack in" to rejoin it, "recall" to fold it home)')}{/cyan-fg}`);
1649
+ }
1194
1650
  // Off-plane context: which reality you're in, your persona's Matrix
1195
1651
  // track (cold-sim damage lands there), and where your empty body lies.
1196
1652
  if (player.plane === 'matrix') {
@@ -1248,8 +1704,25 @@ export default class Game {
1248
1704
  const parts = normalized.trim().split(' ');
1249
1705
  const verb = parts[0].toLowerCase();
1250
1706
  const args = parts.slice(1);
1251
- if (verb === 'quit' || verb === 'exit') {
1252
- return null;
1707
+ // Quitting takes a CONFIRM (player request: "I just exited by
1708
+ // mistake"): the first "quit" arms it, "y"/"yes" (or quit again)
1709
+ // seals it, anything else disarms and runs as the command it is.
1710
+ if (this._quitArmed) {
1711
+ this._quitArmed = false;
1712
+ if (/^(y|yes|quit|exit)$/i.test(verb)) {
1713
+ return null;
1714
+ }
1715
+ // fall through -- the answer was a real command; play on.
1716
+ }
1717
+ else if (verb === 'quit' || verb === 'exit') {
1718
+ this._quitArmed = true;
1719
+ return `Really quit? ("y" to confirm -- anything else keeps playing.)`;
1720
+ }
1721
+ // Out cold with the fight settling around you (see
1722
+ // Scene.beginPlayerKnockout): unconsciousness is not a prompt. Only
1723
+ // quit (handled above) still answers.
1724
+ if (this.player.isUnconscious() && this.scene.playerKnockout) {
1725
+ return `You're out cold -- the world moves without you.`;
1253
1726
  }
1254
1727
  if (verb === 'maka') {
1255
1728
  return 'You are still in the game, type "quit" to exit';
@@ -1298,6 +1771,11 @@ export default class Game {
1298
1771
  return `I don't understand "${verb}" -- did you mean "${guess}"?`;
1299
1772
  }
1300
1773
  const corrected = await this.handleInput([guess, ...args].join(' '));
1774
+ // The correction note stays INLINE and always-on (player ruling:
1775
+ // it briefly moved to the Mechanics ticker, and a conversational
1776
+ // "well I'm here..." silently became a SELL with nothing in the
1777
+ // world log explaining the refusal that came back). It's not dice
1778
+ // noise -- it's the only receipt for a command you didn't type.
1301
1779
  return corrected === null ? null : `(read "${verb}" as "${guess}")\n${corrected ?? ''}`;
1302
1780
  }
1303
1781
  return `I don't understand the command "${verb}".`;
@@ -1312,16 +1790,16 @@ export default class Game {
1312
1790
  * future command can't silently fall out of help.
1313
1791
  */
1314
1792
  static HELP_CATEGORIES = [
1315
- { title: 'moving', entries: [['look'], ['go'], ['map', 'exits'], ['search'], ['sneak']] },
1793
+ { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach', 'run', 'sprint'], ['map', 'exits'], ['search'], ['sneak']] },
1316
1794
  { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['drop'], ['give'], ['store'], ['equip'], ['unequip'], ['brandish', 'draw'], ['holster', 'stow'], ['reload'], ['use'], ['read']] },
1317
- { title: 'combat', entries: [['attack', 'kill'], ['subdue'], ['stance'], ['surrender'], ['edge'], ['rest']] },
1318
- { title: 'magic', entries: [['spells'], ['cast'], ['project'], ['return'], ['assense']] },
1319
- { title: 'matrix', entries: [['jack'], ['hack'], ['pan'], ['silent'], ['reboot'], ['drone'], ['jump', 'rig']] },
1795
+ { title: 'combat', entries: [['attack', 'kill'], ['subdue'], ['order'], ['stance'], ['surrender'], ['edge'], ['rest']] },
1796
+ { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project'], ['return'], ['assense']] },
1797
+ { title: 'matrix', entries: [['jack'], ['hack'], ['pan'], ['silent'], ['reboot'], ['agent'], ['drone'], ['deploy'], ['recall'], ['jump', 'rig']] },
1320
1798
  { title: 'social', entries: [['say'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
1321
1799
  { title: 'street', entries: [['browse', 'buy'], ['sell'], ['work', 'gig'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
1322
1800
  { title: 'barriers', entries: [['solve'], ['pick'], ['bluff'], ['unlock'], ['lock']] },
1323
1801
  { title: 'character', entries: [['sheet', 'stats'], ['advance', 'train'], ['qualities', 'quality'], ['initiate', 'submerge'], ['install', 'chrome']] },
1324
- { title: 'session', entries: [['help'], ['clear'], ['quit', 'exit']] },
1802
+ { title: 'session', entries: [['help'], ['hints'], ['clear'], ['quit', 'exit']] },
1325
1803
  ];
1326
1804
  /** Descriptions for the verbs handled inline in handleInput() rather than registered. */
1327
1805
  static BUILTIN_DESCRIPTIONS = {
@@ -1349,7 +1827,7 @@ export default class Game {
1349
1827
  const category = Game.HELP_CATEGORIES.find(c => c.title === pickedCategory);
1350
1828
  const lines = [`=== ${category.title.toUpperCase()} ===`];
1351
1829
  for (const [primary, ...aliases] of category.entries) {
1352
- lines.push(`- ${primary}${aliases.length > 0 ? ` (${aliases.join(', ')})` : ''}: ${this.describeVerb(primary)}`);
1830
+ lines.push(`${BULLET}${primary}${aliases.length > 0 ? ` (${aliases.join(', ')})` : ''}: ${this.describeVerb(primary)}`);
1353
1831
  }
1354
1832
  return lines.join('\n');
1355
1833
  }