@maka/maka-cli 5.7.0 → 5.8.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 (75) 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 +2 -2
  5. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +5 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +11 -8
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +32 -14
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.d.ts +33 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js +137 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js.map +1 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +2 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +53 -12
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +12 -4
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js +23 -2
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js.map +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +7 -6
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/order.d.ts +19 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +151 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js.map +1 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +1 -1
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +9 -2
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.d.ts +19 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +112 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js.map +1 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +81 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/game.js +367 -9
  39. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +33 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +162 -3
  42. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +2 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +9 -2
  45. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +1 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +6 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +7 -0
  50. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/ui.js +32 -5
  52. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +3 -1
  54. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +15 -3
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +111 -28
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.d.ts +7 -4
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +8 -5
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js.map +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.d.ts +14 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +15 -0
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js.map +1 -0
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.d.ts +16 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js +49 -4
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js.map +1 -1
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +23 -0
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  69. package/bundle/typescript/src/commands/ssl.js +10 -15
  70. package/bundle/typescript/src/commands/ssl.js.map +1 -1
  71. package/bundle/typescript/src/templates/react/kit/mui/auth/password-reset/basic-password-reset-page.js.tsx +3 -4
  72. package/bundle/typescript/src/tools/ssl/ssl.class.d.ts +8 -0
  73. package/bundle/typescript/src/tools/ssl/ssl.class.js +53 -0
  74. package/bundle/typescript/src/tools/ssl/ssl.class.js.map +1 -1
  75. package/package.json +1 -1
@@ -56,6 +56,9 @@ import { AskCommand } from './commands/ask.js';
56
56
  import { ContactsCommand } from './commands/contacts.js';
57
57
  import { JumpCommand } from './commands/jump.js';
58
58
  import { InitiateCommand } from './commands/initiate.js';
59
+ import { SummonCommand } from './commands/summon.js';
60
+ import { OrderCommand } from './commands/order.js';
61
+ import { DeployCommand, AgentCommand, RecallCommand } from './commands/companions.js';
59
62
  import { InstallCommand } from './commands/install.js';
60
63
  import { BuyCommand } from './commands/buy.js';
61
64
  import { SellCommand } from './commands/sell.js';
@@ -67,6 +70,7 @@ import { EatCommand } from './commands/eat.js';
67
70
  import { DrinkCommand } from './commands/drink.js';
68
71
  import { equipIntoEmptySlots } from './archetypes.js';
69
72
  import { NPC } from './models/npc.js';
73
+ import { CommandRegistry } from './commands/command-registry.js';
70
74
  import { HaggleCommand } from './commands/haggle.js';
71
75
  import { SneakCommand } from './commands/sneak.js';
72
76
  import { PersuadeCommand } from './commands/persuade.js';
@@ -76,6 +80,8 @@ import { CommandFactory } from './factories/command-factory.js';
76
80
  import { SceneSynthesizer } from './factories/scene-factory.js';
77
81
  import { SceneSeedGenerator, FIXER_NAME } from './factories/scene-seed-generator.js';
78
82
  import { CALL_ICON, CALL_COLOR } from './utilities/comm-style.js';
83
+ import { AI } from '../../../tools/ai/ai.class.js';
84
+ import { BULLET } from './utilities/log-style.js';
79
85
  export default class Game {
80
86
  initialized;
81
87
  static instance;
@@ -91,6 +97,7 @@ export default class Game {
91
97
  // give the log the whole screen.
92
98
  _screen;
93
99
  _logBox;
100
+ _metaBox;
94
101
  _statusBox;
95
102
  _inputBox;
96
103
  _useAi = false;
@@ -164,6 +171,227 @@ export default class Game {
164
171
  }
165
172
  Logger.getInstance().write(`Home turf: ${this.contacts.size} contacts seeded from the hub cast.`);
166
173
  }
174
+ // ==================== COMPANIONS: the party layer ====================
175
+ // Every companion is a per-scene NPC SHELL plus persistent STATE, and
176
+ // scene transitions are covered by narration, never instance rebinding
177
+ // (design decision with the player): spirits fade (RAW p.300: the
178
+ // summons doesn't outlast the ride), drones fold to the pack, agents
179
+ // into the deck -- damage lives on the bound Item and redeploys free.
180
+ // Tenants: summoned spirits (summon.ts), matrix agents (agent-cmd.ts),
181
+ // autonomous drones (deploy in drone-deploy.ts). Future tenants
182
+ // (enlisted allies, anthroform frames) ride the same rail with
183
+ // different words ("takes their own ride and meets you there").
184
+ // Roster invariant: entries are LIVE SHELLS only -- dismissal and
185
+ // death both remove the entry; persistent state (the frame, the deck)
186
+ // lives on Items in the player's possession, never here.
187
+ companions = [];
188
+ // Drones deployed at travel time re-shell on arrival ("the rotors spin
189
+ // back up at the curb") -- tracked as their items across the swap.
190
+ _pendingRedeploys = [];
191
+ /**
192
+ * The one correct runtime NPC spawn (mirrors scene-factory's seed
193
+ * path): construct with a scene-bound registry, add to the scene
194
+ * (names must be unique -- addActor throws), place via
195
+ * setCharacterLocation (the ONLY path that binds room observers), and
196
+ * hand it the scene for its AI context.
197
+ */
198
+ spawnCompanionShell(opts) {
199
+ if (this.scene.getActor(opts.name)) {
200
+ Logger.getInstance().write(`spawnCompanionShell: name "${opts.name}" already in scene -- refusing.`);
201
+ return null;
202
+ }
203
+ const npc = new NPC({
204
+ dialog: [],
205
+ description: opts.description,
206
+ playerConstructorConfig: { playerName: opts.name, startLocation: opts.room, combat: opts.combat },
207
+ }, new CommandRegistry(this.scene));
208
+ npc.plane = opts.plane;
209
+ npc.allyOf = this.player.name;
210
+ npc.companionKind = opts.kind;
211
+ npc.boundDevice = opts.boundDevice;
212
+ this.scene.addActor(npc);
213
+ this.scene.setCharacterLocation(npc, opts.room.name);
214
+ npc.scene = this.scene;
215
+ this.companions.push({ npc, kind: opts.kind, services: opts.services, force: opts.force, boundDevice: opts.boundDevice });
216
+ Logger.getInstance().write(`Companion spawned: ${opts.name} (${opts.kind}, force ${opts.force}) in ${opts.room.name}.`);
217
+ return npc;
218
+ }
219
+ /**
220
+ * The autonomous-drone shell, shared by the deploy command and
221
+ * arrival re-shelling: the frame's dog-brain (Pilot = 2 + quality)
222
+ * stands in for every attribute it lacks (RAW p.269-271).
223
+ */
224
+ // Cargo sling by frame class (player request: capacity enforced -- a
225
+ // palm-sized Fly-Spy is not a mule). Keyed by the frame Item's size.
226
+ static DRONE_CARGO_KG = {
227
+ tiny: 0.5, small: 2, medium: 8, large: 20, huge: 40,
228
+ };
229
+ deployDroneShell(item, room) {
230
+ const pilot = 2 + item.qualityBonus;
231
+ const armed = /armed|combat|gun|roto|assault|sentry|weapon/i.test(`${item.name} ${item.description ?? ''}`);
232
+ const shell = this.spawnCompanionShell({
233
+ name: `${item.name} (autopilot)`,
234
+ 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'}.`,
235
+ kind: 'drone',
236
+ 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 } : {}) } },
237
+ room, plane: 'meat', force: pilot, boundDevice: item,
238
+ });
239
+ // Take-command weight checks (Player.addInventory) enforce this cap
240
+ // for the shell exactly as they do for a runner.
241
+ if (shell)
242
+ shell.maxCarryingWeight = Game.DRONE_CARGO_KG[String(item.size)] ?? 2;
243
+ return shell;
244
+ }
245
+ /** Drops a companion's shell and its roster entry. */
246
+ dismissCompanion(entry, reason) {
247
+ if (this.scene.getActor(entry.npc.name) === entry.npc) {
248
+ // Cargo comes home with the shell (player request: fetch-and-carry
249
+ // runs -- a real session's drone took a credstick and had no way
250
+ // to hand it over). A deliberate dismissal passes whatever the
251
+ // shell carried to its master; without this, scene.removeActor
252
+ // dumps it on the floor wherever the shell happened to be.
253
+ const carried = entry.npc.inventory.getAllItems().map(i => i.name);
254
+ const delivered = carried.filter(n => entry.npc.inventory.transferItemTo(n, this.player.inventory));
255
+ if (delivered.length > 0) {
256
+ Logger.getInstance().logWithColor(`👥 From ${entry.npc.name}'s cargo, now yours: ${delivered.join(', ')}.`, 'green');
257
+ }
258
+ // Mark deliberate before removeActor fires onActorRemoved, so no
259
+ // wreckage is charged to a clean dismissal.
260
+ entry.npc.deathClaimed = true;
261
+ this.scene.removeActor(entry.npc.name);
262
+ }
263
+ const idx = this.companions.indexOf(entry);
264
+ if (idx >= 0)
265
+ this.companions.splice(idx, 1);
266
+ Logger.getInstance().write(`Companion dismissed: ${entry.npc.name} -- ${reason}`);
267
+ }
268
+ /**
269
+ * An actor left the world (Scene.removeActor). For a companion shell
270
+ * that DIED (not a deliberate dismissal -- those set deathClaimed
271
+ * first and prune themselves), settle the wreckage: a drone's frame is
272
+ * wrecked where it fell, an agent derezzes (the deck already ate the
273
+ * damage), a spirit is banished outright.
274
+ */
275
+ onActorRemoved(name) {
276
+ const entry = this.companions.find(c => c.npc.name === name);
277
+ if (!entry)
278
+ return;
279
+ const idx = this.companions.indexOf(entry);
280
+ if (idx >= 0)
281
+ this.companions.splice(idx, 1);
282
+ if (entry.kind === 'drone' && entry.boundDevice && !entry.npc.deathClaimed) {
283
+ entry.boundDevice.takeDroneDamage(entry.boundDevice.maxDroneBoxes);
284
+ Logger.getInstance().logWithColor(`👥 The ${entry.boundDevice.name} is WRECKED where it fell -- rest to repair the frame.`, 'yellow');
285
+ }
286
+ this.updateStatus();
287
+ }
288
+ /**
289
+ * Spirits with no services left depart once no exchange holds them
290
+ * (called from CombatExchange.finishExchange): the bargain is spent.
291
+ */
292
+ settleSpiritServices() {
293
+ for (const entry of [...this.companions]) {
294
+ if (entry.kind !== 'spirit' || (entry.services ?? 0) > 0)
295
+ continue;
296
+ if (entry.npc.inExchange)
297
+ continue;
298
+ // The paid fight is fought to the END. Between exchanges of the
299
+ // same fight inExchange is briefly false -- a real session's
300
+ // spirit spent its last service to join a fight and was dismissed
301
+ // 3ms later, before a single blow. Same freshness window as the
302
+ // per-fight billing in combat-exchange.
303
+ if (entry.npc.combatOpponent && Date.now() - entry.npc.lastExchangeAt < 60_000)
304
+ continue;
305
+ Logger.getInstance().logWithColor(`👥 ${entry.npc.name}'s bargain is spent -- it bows once and thins into nothing.`, 'green');
306
+ this.dismissCompanion(entry, 'services exhausted');
307
+ this.updateStatus();
308
+ }
309
+ }
310
+ // ==================== Pursuit heartbeat ====================
311
+ // Companions are stimulus-driven like every NPC: a directive burst ends
312
+ // at the reflect cap ("Max recursion reached") and a shell alone in a
313
+ // distant room then NEVER acts again -- a real session's drone was
314
+ // ordered to "find Mr. Krow", walked three rooms, and froze mid-hunt.
315
+ // The heartbeat re-spurs every shell with an open objective on a slow
316
+ // tick until it reports DONE/FAILED (which clears the objective -- see
317
+ // NPC.relayToMaster) or is dismissed.
318
+ _pursuitTimer;
319
+ _pursuitInFlight = new Set();
320
+ static PURSUIT_TICK_MS = 30_000;
321
+ startPursuitHeartbeat() {
322
+ if (this._pursuitTimer)
323
+ return;
324
+ this._pursuitTimer = setInterval(() => this.spurCompanionPursuits(), Game.PURSUIT_TICK_MS);
325
+ // Never hold the process open on our account.
326
+ this._pursuitTimer.unref?.();
327
+ }
328
+ stopPursuitHeartbeat() {
329
+ if (this._pursuitTimer)
330
+ clearInterval(this._pursuitTimer);
331
+ this._pursuitTimer = undefined;
332
+ }
333
+ spurCompanionPursuits() {
334
+ // The tick doubles as the spent-bargain sweep: finishExchange's own
335
+ // settle call now skips spirits whose fight is still warm (see
336
+ // settleSpiritServices), so without this a victorious spirit would
337
+ // linger at zero services forever.
338
+ this.settleSpiritServices();
339
+ if (!AI.isConfigured())
340
+ return;
341
+ for (const entry of this.companions) {
342
+ const npc = entry.npc;
343
+ if (!npc.objective)
344
+ continue;
345
+ if (npc.isIncapacitated() || npc.inExchange || npc.waitingForLull)
346
+ continue;
347
+ // A stale shell (already removed from the world) gets no spur.
348
+ if (this.scene.getActor(npc.name) !== npc)
349
+ continue;
350
+ // One spur in flight per shell -- a slow AI burst must not stack
351
+ // with the next tick.
352
+ if (this._pursuitInFlight.has(npc))
353
+ continue;
354
+ this._pursuitInFlight.add(npc);
355
+ Logger.getInstance().write(`Pursuit heartbeat: spurring ${npc.name} -- ${npc.objective}`);
356
+ 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 }))
357
+ .catch(err => Logger.getInstance().error(`Pursuit heartbeat failed for ${npc.name}: ${err}`))
358
+ .finally(() => this._pursuitInFlight.delete(npc));
359
+ }
360
+ }
361
+ /** Pre-travel: every shell folds, each with its own goodbye. */
362
+ foldCompanionsForTravel() {
363
+ const lines = [];
364
+ for (const entry of [...this.companions]) {
365
+ if (entry.kind === 'spirit') {
366
+ lines.push(`Your bound spirit thins into the rain -- the summons doesn't outlast the ride.`);
367
+ }
368
+ else if (entry.kind === 'drone') {
369
+ lines.push(`The ${entry.boundDevice?.name ?? 'drone'} autopilots home to your hand for the ride.`);
370
+ if (entry.boundDevice)
371
+ this._pendingRedeploys.push(entry.boundDevice);
372
+ }
373
+ else if (entry.kind === 'agent') {
374
+ lines.push(`Your agent folds back into the deck.`);
375
+ }
376
+ else {
377
+ lines.push(`${entry.npc.name} takes their own ride -- they'll meet you there.`);
378
+ }
379
+ this.dismissCompanion(entry, `travel: ${entry.kind} folds`);
380
+ }
381
+ return lines;
382
+ }
383
+ /** Post-arrival: re-shell what was deployed when you left (drones). */
384
+ unfoldCompanionsOnArrival() {
385
+ const lines = [];
386
+ for (const item of this._pendingRedeploys.splice(0)) {
387
+ if (item.isWrecked)
388
+ continue;
389
+ const shell = this.deployDroneShell(item, this.player.currentLocation);
390
+ if (shell)
391
+ lines.push(`The ${item.name}'s rotors spin back up at the curb.`);
392
+ }
393
+ return lines;
394
+ }
167
395
  /**
168
396
  * Records a real transaction with a hub NPC: first contact opens the
169
397
  * relationship at Loyalty 1 (a safeguard -- the hub cast is pre-seeded
@@ -517,6 +745,12 @@ export default class Game {
517
745
  CommandFactory.registerCommand('rig', JumpCommand);
518
746
  CommandFactory.registerCommand('initiate', InitiateCommand);
519
747
  CommandFactory.registerCommand('submerge', InitiateCommand);
748
+ CommandFactory.registerCommand('summon', SummonCommand);
749
+ CommandFactory.registerCommand('conjure', SummonCommand);
750
+ CommandFactory.registerCommand('order', OrderCommand);
751
+ CommandFactory.registerCommand('deploy', DeployCommand);
752
+ CommandFactory.registerCommand('recall', RecallCommand);
753
+ CommandFactory.registerCommand('agent', AgentCommand);
520
754
  CommandFactory.registerCommand('install', InstallCommand);
521
755
  CommandFactory.registerCommand('chrome', InstallCommand);
522
756
  CommandFactory.registerCommand('buy', BuyCommand);
@@ -568,6 +802,9 @@ export default class Game {
568
802
  get logBox() {
569
803
  return this._logBox;
570
804
  }
805
+ get metaBox() {
806
+ return this._metaBox;
807
+ }
571
808
  get inputBox() {
572
809
  return this._inputBox;
573
810
  }
@@ -576,6 +813,7 @@ export default class Game {
576
813
  this._logFilePath = options.logFilePath;
577
814
  this.initInputBox(screen);
578
815
  this.initLogBox(screen);
816
+ this.initMetaBox(screen);
579
817
  this.initStatusBox(screen);
580
818
  // No "Generating Scene... Please wait." box anymore: every path now
581
819
  // arrives with REAL prose (hand-authored static seeds, chunk-authored
@@ -633,6 +871,7 @@ export default class Game {
633
871
  // never swapped out.
634
872
  equipIntoEmptySlots(this.player);
635
873
  this.warmNpcAppearancesInBackground();
874
+ this.startPursuitHeartbeat();
636
875
  this.initialized = true;
637
876
  // Nudge the player toward "call" up front -- the fixer convention means
638
877
  // the story deliberately doesn't narrate a completed briefing, so
@@ -731,6 +970,8 @@ export default class Game {
731
970
  // Read the run's fate BEFORE the scene swap below discards it -- a
732
971
  // walked-away job gets a different homecoming line than a wrapped one.
733
972
  const wrappedRun = this.scene !== hub && this.scene.isCompleted();
973
+ // Companions fold for the ride home, same as the trip out.
974
+ const companionFoldLines = this.foldCompanionsForTravel();
734
975
  // Settle cross-plane and combat state that must not leak into the hub
735
976
  // (the run's rooms -- including a bodyRoom the player left behind --
736
977
  // are about to be orphaned wholesale).
@@ -801,10 +1042,13 @@ export default class Game {
801
1042
  this.updateExits(this.player.currentLocation);
802
1043
  this.updateStatus();
803
1044
  Logger.getInstance().write(`Returned to hub "${this._hubName}". Lifestyle ${this.lifestyleTier}: ${paid} paid, ${this.rentDebt} owed. Restocked ${restocked}. Rumor: ${this.activeRumor ?? '(none)'}`);
1045
+ // Drones spin back up beside the cot.
1046
+ const companionArrivalLines = this.unfoldCompanionsOnArrival();
1047
+ const companionBlock = [...companionFoldLines, ...companionArrivalLines];
804
1048
  const fateLine = wrappedRun
805
1049
  ? `The run's wrapped and the street swallows you back up.`
806
1050
  : `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}`;
1051
+ 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}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
808
1052
  }
809
1053
  /**
810
1054
  * The actual drafting work, split out from continueToNextScene() purely
@@ -868,6 +1112,9 @@ export default class Game {
868
1112
  this.player.activeCallPartner.activeCallPartner = undefined;
869
1113
  this.player.activeCallPartner = undefined;
870
1114
  }
1115
+ // Companions fold for the ride (spirits depart, drones to the pack,
1116
+ // agents into the deck) -- their lines join the JOB banner below.
1117
+ const companionLines = this.foldCompanionsForTravel();
871
1118
  // false, false: no starter kit (the player carries their own gear
872
1119
  // forward), and no blocking AI-redressing pass either -- the chunked
873
1120
  // rooms pass already wrote this scene's prose.
@@ -896,8 +1143,12 @@ export default class Game {
896
1143
  this.updateStatus();
897
1144
  // Fresh streets, no trail yet (see Player.cameFrom / the HUD crumb).
898
1145
  this.player.cameFrom = undefined;
1146
+ // Deployed drones spin back up at the curb -- BEFORE appearance
1147
+ // warming so re-shelled companions get warmed with the cast.
1148
+ const arrivalLines = this.unfoldCompanionsOnArrival();
899
1149
  this.warmNpcAppearancesInBackground();
900
- return `\n=== JOB #${this._tier}: ${seed.name} ===\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}`;
1150
+ const companionBlock = [...companionLines, ...arrivalLines];
1151
+ return `\n=== JOB #${this._tier}: ${seed.name} ===\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
901
1152
  }
902
1153
  /**
903
1154
  * The needs check at every job departure (SR5e p.56 / p.172, scaled to
@@ -991,6 +1242,7 @@ export default class Game {
991
1242
  }
992
1243
  }
993
1244
  async close() {
1245
+ this.stopPursuitHeartbeat();
994
1246
  this._inputBox.removeAllListeners(); // removes all event listeners
995
1247
  this._inputBox.cancel(); // cancels any active input session
996
1248
  this._inputBox.detach(); // optionally remove from screen
@@ -1047,13 +1299,17 @@ export default class Game {
1047
1299
  id: 'logBox',
1048
1300
  top: '0%',
1049
1301
  left: '0%',
1050
- width: '100%',
1302
+ // COLUMNS, not rows (playtest note: a 3-row Mechanics strip made
1303
+ // combat rolls "near impossible to read" -- they scrolled out as
1304
+ // fast as they landed). The world log takes the left ~two-thirds;
1305
+ // the Mechanics column stands full-height beside it.
1306
+ width: '65%',
1051
1307
  // Fixed-row footer: 8 rows of HUD (status box) + 3 rows of input.
1052
1308
  height: '100%-11',
1053
1309
  border: {
1054
1310
  type: 'line'
1055
1311
  },
1056
- label: ' Game Log ',
1312
+ label: ' Game Log (PgUp/PgDn to scroll) ',
1057
1313
  scrollable: true, // Enable scrolling
1058
1314
  alwaysScroll: true,
1059
1315
  scrollbar: {
@@ -1073,8 +1329,83 @@ export default class Game {
1073
1329
  tags: true // Intercept tags
1074
1330
  });
1075
1331
  this._logBox = logBox;
1332
+ // Scrolling without focus (player report: "can't scroll the game log
1333
+ // anymore"): focus lives in the input box on every path, so the
1334
+ // box's own keys never fire, and the wheel died with mouse tracking.
1335
+ // SCREEN-level PgUp/PgDn scroll the log while you keep typing; while
1336
+ // you're up in history, followBottom stops every new line from
1337
+ // yanking the view back down (Logger.log and ui.ts honor it).
1338
+ // Paging back to the bottom -- or End -- re-engages the follow.
1339
+ logBox.followBottom = true;
1340
+ const page = () => Math.max(1, (typeof logBox.height === 'number' ? logBox.height : 20) - 3);
1341
+ const setFollow = (on) => {
1342
+ logBox.followBottom = on;
1343
+ logBox.setLabel(on ? ' Game Log (PgUp/PgDn to scroll) ' : ' Game Log -- SCROLLED (End to follow) ');
1344
+ };
1345
+ const atBottom = () => logBox.getScrollPerc() >= 100 || logBox.getScrollHeight() <= page() + 3;
1346
+ screen.key(['pageup'], () => {
1347
+ logBox.scroll(-page());
1348
+ setFollow(false);
1349
+ screen.render();
1350
+ });
1351
+ screen.key(['pagedown'], () => {
1352
+ logBox.scroll(page());
1353
+ if (atBottom())
1354
+ setFollow(true);
1355
+ screen.render();
1356
+ });
1357
+ screen.key(['end'], () => {
1358
+ logBox.scrollTo(logBox.getScrollHeight());
1359
+ setFollow(true);
1360
+ screen.render();
1361
+ });
1076
1362
  screen.append(logBox);
1077
1363
  }
1364
+ /**
1365
+ * Scroll the log to its tail -- unless the player has paged up to read
1366
+ * history (followBottom off), in which case their place is left alone.
1367
+ * Every "new line arrived" site goes through here.
1368
+ */
1369
+ followLog() {
1370
+ const box = this._logBox;
1371
+ if (!box || box.followBottom === false)
1372
+ return;
1373
+ box.scrollTo(box.getScrollHeight());
1374
+ }
1375
+ /**
1376
+ * The MECHANICS column (readability pass, player request: the log was
1377
+ * a "wall of text"): dice anatomy and resource arithmetic land here
1378
+ * (via Logger.meta) instead of the world log, so the narrative stays
1379
+ * prose. A full-height COLUMN beside the world log, not a bottom
1380
+ * strip -- the first playtest's 3-row ticker scrolled combat rolls
1381
+ * away faster than they could be read. De-emphasis is the BLUE border
1382
+ * -- gray is banned (ANSI bright-black, illegible on stock dark
1383
+ * palettes). Follow-only (no keys); the full interleaved record lives
1384
+ * in session.log. Roll lines wrap at the column width -- fine in a
1385
+ * tall column. NO mouse (see initInputBox): never arm mouse tracking.
1386
+ */
1387
+ initMetaBox(screen) {
1388
+ const metaBox = blessed.box({
1389
+ id: 'metaBox',
1390
+ top: '0%',
1391
+ left: '65%',
1392
+ width: '35%',
1393
+ // Same height as the world log: HUD (8) + input (3) below both.
1394
+ height: '100%-11',
1395
+ border: {
1396
+ type: 'line'
1397
+ },
1398
+ style: {
1399
+ border: { fg: 'blue' }
1400
+ },
1401
+ label: ' Mechanics ',
1402
+ scrollable: true,
1403
+ alwaysScroll: true,
1404
+ tags: true,
1405
+ });
1406
+ this._metaBox = metaBox;
1407
+ screen.append(metaBox);
1408
+ }
1078
1409
  /**
1079
1410
  * The HUD: a fixed strip between the log and the input box for the
1080
1411
  * important real-time state -- condition tracks, armed/link status,
@@ -1191,6 +1522,30 @@ export default class Game {
1191
1522
  if (player.activeCallPartner) {
1192
1523
  lines.push(`{${CALL_COLOR}-fg}${CALL_ICON} On a call with ${player.activeCallPartner.name}{/${CALL_COLOR}-fg}`);
1193
1524
  }
1525
+ // The party: one line per live companion shell, with the tracks that
1526
+ // matter for its kind. A spirit shows its BODY too (it's a full
1527
+ // Force-statted combatant and hits land on it -- player request:
1528
+ // the track was real but invisible); drones show hull, agents deck.
1529
+ for (const c of this.companions) {
1530
+ const track = c.kind === 'spirit'
1531
+ ? `${c.services ?? 0} service${(c.services ?? 0) === 1 ? '' : 's'} ${c.npc.conditionSummary()}${c.npc.stunTaken > 0 ? ` stun ${c.npc.stunSummary()}` : ''}`
1532
+ : c.kind === 'drone' && c.boundDevice ? `hull ${c.boundDevice.droneSummary()}`
1533
+ : c.kind === 'agent' && c.boundDevice ? `deck ${c.boundDevice.deckSummary()}`
1534
+ : c.npc.conditionSummary();
1535
+ // Each kind speaks its own language (a real rigger session read a
1536
+ // generic "(F3)" as spell Force on a Fly-Spy): drones fly on a
1537
+ // PILOT rating, agents run at a RATING, and a spirit's Force is
1538
+ // already in its name.
1539
+ const grade = c.kind === 'drone' ? `Pilot ${c.force} -- ` : c.kind === 'agent' ? `Rating ${c.force} -- ` : '';
1540
+ lines.push(`{green-fg}👥 ${c.npc.name} -- ${grade}${track}{/green-fg}`);
1541
+ }
1542
+ // DECK-AR INDICATOR (player request): "Link online" is the
1543
+ // COMMLINK's AR overlay; this is the DECK's -- lit when an agent
1544
+ // works the grid while you're back in the meat (the AR-drop exit).
1545
+ const remoteAgent = this.companions.find(c => c.kind === 'agent');
1546
+ if (remoteAgent && player.plane !== 'matrix') {
1547
+ lines.push(`{cyan-fg}▣ DECK IN AR -- ${remoteAgent.npc.name} on the grid${remoteAgent.npc.objective ? ', working its directive' : ''} ("jack in" to rejoin it, "recall" to fold it home){/cyan-fg}`);
1548
+ }
1194
1549
  // Off-plane context: which reality you're in, your persona's Matrix
1195
1550
  // track (cold-sim damage lands there), and where your empty body lies.
1196
1551
  if (player.plane === 'matrix') {
@@ -1298,7 +1653,10 @@ export default class Game {
1298
1653
  return `I don't understand "${verb}" -- did you mean "${guess}"?`;
1299
1654
  }
1300
1655
  const corrected = await this.handleInput([guess, ...args].join(' '));
1301
- return corrected === null ? null : `(read "${verb}" as "${guess}")\n${corrected ?? ''}`;
1656
+ // The correction note is bookkeeping -- Mechanics ticker, not the
1657
+ // narrative (readability pass).
1658
+ Logger.getInstance().meta(`(read "${verb}" as "${guess}")`);
1659
+ return corrected;
1302
1660
  }
1303
1661
  return `I don't understand the command "${verb}".`;
1304
1662
  }
@@ -1314,9 +1672,9 @@ export default class Game {
1314
1672
  static HELP_CATEGORIES = [
1315
1673
  { title: 'moving', entries: [['look'], ['go'], ['map', 'exits'], ['search'], ['sneak']] },
1316
1674
  { 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']] },
1675
+ { title: 'combat', entries: [['attack', 'kill'], ['subdue'], ['order'], ['stance'], ['surrender'], ['edge'], ['rest']] },
1676
+ { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project'], ['return'], ['assense']] },
1677
+ { title: 'matrix', entries: [['jack'], ['hack'], ['pan'], ['silent'], ['reboot'], ['agent'], ['drone'], ['deploy'], ['recall'], ['jump', 'rig']] },
1320
1678
  { title: 'social', entries: [['say'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
1321
1679
  { title: 'street', entries: [['browse', 'buy'], ['sell'], ['work', 'gig'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
1322
1680
  { title: 'barriers', entries: [['solve'], ['pick'], ['bluff'], ['unlock'], ['lock']] },
@@ -1349,7 +1707,7 @@ export default class Game {
1349
1707
  const category = Game.HELP_CATEGORIES.find(c => c.title === pickedCategory);
1350
1708
  const lines = [`=== ${category.title.toUpperCase()} ===`];
1351
1709
  for (const [primary, ...aliases] of category.entries) {
1352
- lines.push(`- ${primary}${aliases.length > 0 ? ` (${aliases.join(', ')})` : ''}: ${this.describeVerb(primary)}`);
1710
+ lines.push(`${BULLET}${primary}${aliases.length > 0 ? ` (${aliases.join(', ')})` : ''}: ${this.describeVerb(primary)}`);
1353
1711
  }
1354
1712
  return lines.join('\n');
1355
1713
  }