@maka/maka-cli 5.13.0 → 5.14.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 (133) hide show
  1. package/bundle/typescript/package.json +2 -2
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +2 -2
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +5 -5
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +48 -37
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +14 -14
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js +25 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +6 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.d.ts +43 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js +259 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js.map +1 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +6 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +15 -2
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js.map +1 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +8 -6
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +3 -3
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +3 -3
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.d.ts +5 -4
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +45 -27
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js +12 -7
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js.map +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.d.ts +5 -4
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +31 -17
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js.map +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +21 -10
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js.map +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js.map +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +21 -0
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.d.ts +2 -1
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +81 -3
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js.map +1 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -3
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +4 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +2 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +15 -2
  69. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +70 -35
  70. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  71. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +16 -5
  72. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +36 -8
  73. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +139 -4
  75. package/bundle/typescript/src/commands/game/sideQuest/game.js +591 -85
  76. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  77. package/bundle/typescript/src/commands/game/sideQuest/headless.d.ts +86 -0
  78. package/bundle/typescript/src/commands/game/sideQuest/headless.js +185 -0
  79. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +50 -4
  81. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +59 -11
  83. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +242 -109
  84. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +11 -11
  86. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +22 -2
  87. package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +8 -0
  88. package/bundle/typescript/src/commands/game/sideQuest/ui.js +7 -5
  89. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  90. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.d.ts +6 -4
  91. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +36 -24
  92. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -1
  93. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +7 -1
  94. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +59 -18
  95. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  96. package/bundle/typescript/src/commands/game/sideQuest/utilities/command-queue.d.ts +19 -0
  97. package/bundle/typescript/src/commands/game/sideQuest/utilities/command-queue.js +56 -0
  98. package/bundle/typescript/src/commands/game/sideQuest/utilities/command-queue.js.map +1 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/utilities/crew-ledger.d.ts +21 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/utilities/crew-ledger.js +25 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/utilities/crew-ledger.js.map +1 -0
  102. package/bundle/typescript/src/commands/game/sideQuest/utilities/emitter.d.ts +44 -0
  103. package/bundle/typescript/src/commands/game/sideQuest/utilities/emitter.js +22 -0
  104. package/bundle/typescript/src/commands/game/sideQuest/utilities/emitter.js.map +1 -0
  105. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.d.ts +4 -1
  106. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js +5 -2
  107. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js.map +1 -1
  108. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.d.ts +4 -0
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.js +24 -7
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.js.map +1 -1
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.d.ts +6 -5
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js +30 -6
  113. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js.map +1 -1
  114. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-context.d.ts +28 -0
  115. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-context.js +9 -0
  116. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-context.js.map +1 -0
  117. package/bundle/typescript/src/commands/game/sideQuest/utilities/social.d.ts +50 -0
  118. package/bundle/typescript/src/commands/game/sideQuest/utilities/social.js +166 -0
  119. package/bundle/typescript/src/commands/game/sideQuest/utilities/social.js.map +1 -0
  120. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +1 -1
  121. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  122. package/bundle/typescript/src/commands/generate.js +7 -0
  123. package/bundle/typescript/src/commands/generate.js.map +1 -1
  124. package/bundle/typescript/src/tools/mpi/mpi.class.js +46 -3
  125. package/bundle/typescript/src/tools/mpi/mpi.class.js.map +1 -1
  126. package/bundle/typescript/src/tools/mpi/sdk-runtime.d.ts +1 -0
  127. package/bundle/typescript/src/tools/mpi/sdk-runtime.js +23 -0
  128. package/bundle/typescript/src/tools/mpi/sdk-runtime.js.map +1 -1
  129. package/bundle/typescript/src/tools/pkg/pkg.class.js +11 -1
  130. package/bundle/typescript/src/tools/pkg/pkg.class.js.map +1 -1
  131. package/bundle/typescript/src/tools/scaffold/scaffold.class.js +8 -1
  132. package/bundle/typescript/src/tools/scaffold/scaffold.class.js.map +1 -1
  133. package/package.json +2 -2
@@ -5,13 +5,17 @@ import { Item } from './models/item.js';
5
5
  import { Room } from './models/_index.js';
6
6
  import { Door } from './models/door.js';
7
7
  import { SAVE_VERSION } from './types/save-file.js';
8
- import { serializePlayer, restorePlayer, captureHubOverlay, applyHubOverlay, writeSaveAtomic, deleteSave, restoreItem, readSave, } from './utilities/persistence.js';
8
+ import { serializePlayer, restorePlayer, captureHubOverlay, applyHubOverlay, writeSaveAtomic, deleteSave, restoreItem, readSave, serializeItem, saveSlug, } from './utilities/persistence.js';
9
+ import { appendCrewLedger } from './utilities/crew-ledger.js';
10
+ import { heartbeatPresence, ghostHireable, syncCrewLedger, claimGhostEarnings } from './utilities/social.js';
9
11
  import { pushSave, burnCloudSaveFor } from './utilities/cloud-saves.js';
10
12
  import { DOCWAGON_TIERS, RESUSC_FEE, DEATH_COMP } from './utilities/docwagon.js';
11
13
  import { isFence, LIFESTYLE_TIERS } from './utilities/commerce.js';
12
14
  import { rollPool, formatRoll } from './utilities/dice.js';
13
15
  import { Direction } from './types/shared/direction-enum.js';
14
16
  import { Logger } from './utilities/logger.js';
17
+ import { CommandQueue } from './utilities/command-queue.js';
18
+ import { currentSession } from './utilities/session-context.js';
15
19
  import { LookCommand } from './commands/look.js';
16
20
  import { TakeCommand } from './commands/take.js';
17
21
  import { GoCommand } from './commands/go.js';
@@ -43,6 +47,7 @@ import { AcceptCommand, DeclineCommand } from './commands/job-offer.js';
43
47
  import { SourceCommand } from './commands/source.js';
44
48
  import { DocwagonCommand } from './commands/docwagon.js';
45
49
  import { LeadCommand, RecruitCommand } from './commands/lead.js';
50
+ import { CrewCommand, HireCommand, DismissCommand, TrainCommand } from './commands/crew.js';
46
51
  import { PalmCommand } from './commands/palm.js';
47
52
  import { CatalogCommand } from './commands/catalog.js';
48
53
  import { refreshCatalog } from './utilities/catalog.js';
@@ -87,7 +92,7 @@ import { TreatCommand } from './commands/treat.js';
87
92
  import { WorkCommand } from './commands/work.js';
88
93
  import { EatCommand } from './commands/eat.js';
89
94
  import { DrinkCommand } from './commands/drink.js';
90
- import { equipIntoEmptySlots } from './archetypes.js';
95
+ import { equipIntoEmptySlots, ARCHETYPES, createArchetypeGear } from './archetypes.js';
91
96
  import { NPC } from './models/npc.js';
92
97
  import { CommandRegistry } from './commands/command-registry.js';
93
98
  import { HaggleCommand } from './commands/haggle.js';
@@ -97,7 +102,7 @@ import { BluffCommand } from './commands/bluff.js';
97
102
  import { AutoFight } from './utilities/auto-fight.js';
98
103
  import { CommandFactory } from './factories/command-factory.js';
99
104
  import { SceneSynthesizer } from './factories/scene-factory.js';
100
- import { SceneSeedGenerator, FIXER_NAME } from './factories/scene-seed-generator.js';
105
+ import { SceneSeedGenerator, FIXER_NAME, isFixerName } from './factories/scene-seed-generator.js';
101
106
  import { CALL_ICON, CALL_COLOR } from './utilities/comm-style.js';
102
107
  import { AI } from '../../../tools/ai/ai.class.js';
103
108
  import { BULLET } from './utilities/log-style.js';
@@ -162,6 +167,9 @@ export default class Game {
162
167
  // grow. "ask" (commands/ask.ts) is what the roster is FOR: legwork
163
168
  // against the real pending-run seed. One pull per contact per
164
169
  // homecoming (asked resets in returnToHub).
170
+ // `note` is the relationship's one-line memory -- a client's grudge
171
+ // ("walked out on their job") or a sloppy wrap-up -- rendered as a
172
+ // compact marker on the roster (contacts.ts) and carried by the save.
165
173
  contacts = new Map();
166
174
  // SWAG orders in flight (SR5e p.387/418, commands/source.ts): a
167
175
  // contact shopping the black market for you. Paid up front on a won
@@ -172,33 +180,232 @@ export default class Game {
172
180
  // DOCWAGON (SR5e p.450, utilities/docwagon.ts): the wristband between
173
181
  // a runner and the morgue. Rides the save; ticks down at homecomings.
174
182
  docwagon;
175
- // LEADERSHIP's Command action (SR5e p.141-142, commands/lead.ts): a
176
- // recruited contact rides the NEXT run as an 'ally' companion shell
177
- // (spawned at activateRunScene, released at homecoming). RAW's teeth:
178
- // a refusal stands until Loyalty deepens past the level it happened
179
- // at. Both ride the save.
180
- enlistedAlly;
183
+ // ==================== THE CREW (the party system) ====================
184
+ // Persistent teammates who ride EVERY run (player vision: the hub
185
+ // stays solo housekeeping; the party is for the job). A slot holds an
186
+ // NPC hire, a deep-loyalty contact (Leadership's Command action,
187
+ // p.141-142), or -- Phase B -- another PLAYER's character riding as a
188
+ // ghost (source 'ghost', their cut credited home via the ledger).
189
+ // Live shells spawn per run (spawnCrew) and are disposable; the
190
+ // roster below is the persistent state and rides the save. Changing
191
+ // the crew SCRAPS any pending job (player ruling: the quote was for a
192
+ // different team -- Mr. Krow needs a fresh call).
193
+ crew = [];
194
+ // RAW's teeth on the Command recruit: a refusal stands until Loyalty
195
+ // deepens past the level it happened at. Rides the save.
181
196
  commandRefusals = {};
197
+ // DEPRECATED one-slot predecessor -- read once by applyResumeState to
198
+ // migrate old saves into `crew`, never written again.
199
+ enlistedAlly;
200
+ /** Leadership fields the table: anyone runs with a partner, a leader
201
+ * runs a team. Ceiling 5 -- the size of the future multiplayer table. */
202
+ get crewCap() {
203
+ return Math.min(5, 1 + this.player.skillRating('leadership'));
204
+ }
205
+ /** The crew-change rule (player ruling): any roster change voids the
206
+ * standing quote. Returns the line to show, or ''. */
207
+ scrapPendingRunForCrewChange() {
208
+ if (!this._pendingRunSeed)
209
+ return '';
210
+ this.clearPendingRun();
211
+ return `\nThe standing job was quoted for a different crew -- ${FIXER_NAME} needs a fresh call.`;
212
+ }
182
213
  // PERFORMANCE's busking gate (commands/perform.ts): one set per job
183
214
  // cycle, reset at homecoming like a contact's legwork. Deliberately
184
215
  // NOT saved -- a relaunch is as good as a homecoming for new material.
185
216
  performedThisCycle = false;
186
- /** The recruited contact joins at the jobsite curb ("takes their own
187
- * ride and meets you there" -- the companion convention). */
188
- spawnEnlistedAlly() {
189
- const ally = this.enlistedAlly;
190
- if (!ally)
191
- return [];
192
- const shell = this.spawnCompanionShell({
193
- name: ally.name,
194
- description: `${ally.description} Right now they run as ${this.player.name}'s crew: they fight beside them, follow their lead, and expect to be home by morning.`,
195
- kind: 'ally',
196
- combat: ally.combat,
197
- room: this.player.currentLocation,
198
- plane: 'meat',
199
- force: Number(ally.combat.body ?? 3),
217
+ // THE FOR-HIRE BOARD: archetype-built candidates, fresh faces each
218
+ // homecoming (the gig-pool rotation school). Not saved -- the street's
219
+ // talent pool churns; whoever you HIRED is the persistent part.
220
+ crewForHire = [];
221
+ // PRESENCE (social layer, Phase B): a hub heartbeat announces this
222
+ // runner to the street's player list on maka-cli.com -- and carries
223
+ // the "hireable while away" ghost opt-in. Only beats while the scene
224
+ // IS the hub (the run is nobody's business); silent no-op logged out.
225
+ _presenceTimer;
226
+ static PRESENCE_TICK_MS = 60_000;
227
+ beatPresence() {
228
+ if (!this.hasHub || this.scene !== this._hubScene)
229
+ return;
230
+ void heartbeatPresence({
231
+ playerName: this.player.name,
232
+ slug: saveSlug(this.player.name),
233
+ hubName: this._hubName,
234
+ ghostHireable: ghostHireable(),
235
+ summary: {
236
+ archetypeName: this.player.archetypeName,
237
+ karma: this.player.careerKarma,
238
+ streetCred: this.player.streetCred,
239
+ },
200
240
  });
201
- return shell ? [`${ally.name} is already leaning on the wall when you arrive -- "Took my own ride. Let's work."`] : [];
241
+ }
242
+ startPresenceHeartbeat() {
243
+ if (this._presenceTimer)
244
+ return;
245
+ this.beatPresence();
246
+ this._presenceTimer = setInterval(() => this.beatPresence(), Game.PRESENCE_TICK_MS);
247
+ this._presenceTimer.unref();
248
+ }
249
+ /** Ghost earnings home delivery: what YOUR runner made riding in
250
+ * other players' crews since last time. Fire-and-forget from hub
251
+ * arrival beats; pays onto the job credstick when anything landed. */
252
+ async collectGhostEarnings() {
253
+ try {
254
+ const claimed = await claimGhostEarnings(saveSlug(this.player.name));
255
+ if (!claimed || this.scene !== this._hubScene)
256
+ return;
257
+ const paid = this.scene.payRunner(claimed.total, 'ghost earnings');
258
+ if (!paid)
259
+ return;
260
+ const jobs = claimed.entries.map(e => `"${e.job}" with ${e.hiredBy} (${e.amount}¥)`).join(', ');
261
+ Logger.getInstance().logWithColor(`While you were gone, your runner rode other crews: ${jobs}. ${paid.net}¥ certified lands on your stick.`, 'green');
262
+ this.updateStatus();
263
+ }
264
+ catch { /* offline -- next homecoming */ }
265
+ }
266
+ static HIRE_NAMES = [
267
+ 'Jax', 'Sable', 'Vex', 'Rook', 'Nyx', 'Cinder', 'Halo', 'Grifter',
268
+ 'Static', 'Mercy', 'Tinder', 'Chrome Marie', 'Deacon', 'Fetch', 'Loa',
269
+ ];
270
+ /** Deal 2-3 fresh candidates. Names dodge the live cast and roster. */
271
+ rollHireCandidates() {
272
+ const taken = new Set([
273
+ ...this.crew.map(m => m.name),
274
+ ...(this._hubScene?.allActors ?? []).map(a => a.name),
275
+ ]);
276
+ const names = Game.HIRE_NAMES.filter(n => !taken.has(n)).sort(() => Math.random() - 0.5);
277
+ const count = 2 + Math.floor(Math.random() * 2);
278
+ this.crewForHire = [];
279
+ for (let i = 0; i < count && i < names.length; i++) {
280
+ const archetype = ARCHETYPES[Math.floor(Math.random() * ARCHETYPES.length)];
281
+ // Asking cut scales with muscle: sum of the offensive statline.
282
+ const c = archetype.combat;
283
+ const power = (c.body ?? 3) + (c.agility ?? 3) + (c.logic ?? 3) + (c.magic ?? 0);
284
+ const asking = Math.min(30, 12 + Math.floor(power / 2) + Math.floor(Math.random() * 5));
285
+ this.crewForHire.push({
286
+ name: names[i],
287
+ source: 'hired',
288
+ archetypeName: archetype.name,
289
+ description: `${names[i]}, a ${archetype.name.toLowerCase()} for hire off the Purple Haze boards. ${archetype.description}`,
290
+ combat: { ...archetype.combat },
291
+ gear: createArchetypeGear(archetype).map(item => serializeItem(item)),
292
+ cutPct: asking,
293
+ jobsRun: 0,
294
+ });
295
+ }
296
+ }
297
+ /** The crew joins at the jobsite curb ("took their own ride" -- the
298
+ * companion convention): one 'ally' shell per member, carrying and
299
+ * wearing their own gear. */
300
+ spawnCrew() {
301
+ const lines = [];
302
+ for (const member of this.crew) {
303
+ const shell = this.spawnCompanionShell({
304
+ name: member.name,
305
+ description: `${member.description} Right now they run as ${this.player.name}'s crew: they fight beside them, follow their lead, and expect their ${member.cutPct}% of the take.`,
306
+ kind: 'ally',
307
+ combat: member.combat,
308
+ room: this.player.currentLocation,
309
+ plane: 'meat',
310
+ force: Number(member.combat.body ?? 3),
311
+ });
312
+ if (!shell)
313
+ continue;
314
+ // Their kit comes with them -- weapons/armor worn, the rest carried
315
+ // (the scene-factory auto-equip convention).
316
+ for (const g of member.gear) {
317
+ try {
318
+ const item = restoreItem(g);
319
+ // Carried gear has no "at" spot -- a stale one from the room
320
+ // it was once shelved in haunts search hints scenes later.
321
+ item.atSpot = undefined;
322
+ shell.addInventory(item);
323
+ // Category enum VALUES are lowercase ('ranged weapon') --
324
+ // match case-insensitively or the crew arrives unarmed.
325
+ if (/weapon|armor|helmet|shield|clothing|boots|gloves/i.test(String(item.category))) {
326
+ shell.equip(item); // refusals return strings; extras stay carried
327
+ }
328
+ }
329
+ catch {
330
+ Logger.getInstance().write(`spawnCrew: could not restore "${g.name}" for ${member.name} (carry cap?).`);
331
+ }
332
+ }
333
+ lines.push(`${member.name} is already leaning on the wall when you arrive -- "Took my own ride. Let's work."`);
334
+ }
335
+ return lines;
336
+ }
337
+ /**
338
+ * Homecoming settlement for the crew (returnToHub, BEFORE the fold --
339
+ * dismissCompanion sends shell cargo to the PLAYER, and crew gear is
340
+ * THEIRS): write each survivor's inventory+equipment back into the
341
+ * roster (loot they picked up stays with them), heal them (the cot
342
+ * convention), and bury the fallen -- crew death is PERMANENT, like
343
+ * everything else on this street. Returns homecoming lines.
344
+ */
345
+ settleCrewAfterRun() {
346
+ if (this.crew.length === 0)
347
+ return [];
348
+ const lines = [];
349
+ const fallen = [];
350
+ for (const member of this.crew) {
351
+ const entry = this.companions.find(c => c.kind === 'ally' && c.npc.name === member.name);
352
+ if (!entry || entry.npc.deathClaimed) {
353
+ fallen.push(member.name);
354
+ lines.push(`${member.name} didn't make it home. The crew pours one out; the street keeps the rest.`);
355
+ continue;
356
+ }
357
+ // Out cold is NOT dead (a real session's KO'd Jax -- "breathing"
358
+ // -- got an obituary and fell off the roster): the crew drags
359
+ // their own to the cab. Gear they dropped in the scene stays
360
+ // dropped unless somebody picked it up.
361
+ if (entry.npc.isDown() || entry.npc.isUnconscious()) {
362
+ lines.push(`${member.name} gets dragged to the cab, breathing. They'll wake up mean, but they'll wake up.`);
363
+ }
364
+ // Gear write-back: everything carried or worn rides the roster.
365
+ const shell = entry.npc;
366
+ const kit = shell.inventory.getAllItems().map(i => serializeItem(i));
367
+ for (const group of Object.values(shell.equipment)) {
368
+ if (!group)
369
+ continue;
370
+ for (const item of Object.values(group)) {
371
+ if (item)
372
+ kit.push(serializeItem(item));
373
+ }
374
+ }
375
+ member.gear = kit;
376
+ // Empty the shell so the fold's cargo-transfer moves nothing.
377
+ for (const i of [...shell.inventory.getAllItems()])
378
+ shell.inventory.removeItem(i.name);
379
+ }
380
+ for (const name of fallen) {
381
+ this.crew = this.crew.filter(m => m.name !== name);
382
+ }
383
+ return lines;
384
+ }
385
+ /**
386
+ * The team's split, taken at every pay moment (canon: the crew shares
387
+ * the take; in multiplayer each player literally receives their
388
+ * share). Total cut capped at 60% -- the wheelman never out-earns the
389
+ * one holding the job. Ghost members' shares land on the local ledger
390
+ * for Phase B to sync home. Returns the player's share + the itemized
391
+ * line (empty crew: full amount, no line).
392
+ */
393
+ applyCrewCut(gross) {
394
+ if (this.crew.length === 0 || gross <= 0)
395
+ return { share: gross, line: '' };
396
+ const totalPct = Math.min(60, this.crew.reduce((s, m) => s + m.cutPct, 0));
397
+ const scale = totalPct / this.crew.reduce((s, m) => s + m.cutPct, 0);
398
+ const parts = [];
399
+ let taken = 0;
400
+ for (const member of this.crew) {
401
+ const cut = Math.floor(gross * (member.cutPct * scale) / 100);
402
+ taken += cut;
403
+ parts.push(`${member.name} ${member.cutPct}% (${cut}¥)`);
404
+ if (member.ghost) {
405
+ appendCrewLedger({ slug: member.ghost.slug, owner: member.ghost.owner, amount: cut, job: this.currentJobName ?? 'a job', when: new Date().toISOString() });
406
+ }
407
+ }
408
+ return { share: gross - taken, line: `Crew cut: ${parts.join(', ')}.` };
202
409
  }
203
410
  /** Bills a DocWagon fee: cred first, shortfall onto the rent tab (the
204
411
  * street's one universal collections agency). Returns the line. */
@@ -314,14 +521,15 @@ export default class Game {
314
521
  */
315
522
  static roleOf(npc) {
316
523
  const hay = `${npc.description ?? ''} ${npc.name}`.toLowerCase();
317
- // Mr. Johnson is NOT the fixer (player ruling: don't overload his
318
- // role). Canon splits them: a Johnson is the anonymous CLIENT who
319
- // commissions the run; a fixer is the street middleman (scene1's
320
- // Mr. Krow, e.g.). He gets his own archetype label.
321
- if (npc.name === FIXER_NAME || /job broker|johnson/.test(hay))
322
- return 'Job Broker';
323
- if (/fixer/.test(hay))
524
+ // Canon roles (p.390-392, player ruling): Mr. Krow is the FIXER --
525
+ // the broker who sets up the meets. A JOHNSON is a per-run client's
526
+ // deniable face; those join the roster from finished runs
527
+ // (returnToHub's client upsert), tagged by the /johnson|client rep/
528
+ // text their generated descriptions carry.
529
+ if (isFixerName(npc.name) || /fixer|job broker/.test(hay))
324
530
  return 'Fixer';
531
+ if (/johnson|client rep|deniable/.test(hay))
532
+ return 'Johnson';
325
533
  // "charms" deliberately absent: a market kid hawking LUCKY charms is
326
534
  // a Vendor, not a talismonger (Skitter proved it).
327
535
  if (/talismonger|magical goods|foci|spell formula/.test(hay))
@@ -350,7 +558,7 @@ export default class Game {
350
558
  }
351
559
  static connectionFor(npc) {
352
560
  const hay = `${npc.description ?? ''} ${npc.name}`.toLowerCase();
353
- if (npc.name === FIXER_NAME || /fixer|johnson/.test(hay))
561
+ if (isFixerName(npc.name) || /fixer|johnson/.test(hay))
354
562
  return 5;
355
563
  if (/fence|pawn|launder|smuggl|no questions/.test(hay))
356
564
  return 4;
@@ -488,6 +696,19 @@ export default class Game {
488
696
  * damage), a spirit is banished outright.
489
697
  */
490
698
  onActorRemoved(name) {
699
+ // KILLING YOUR OWN JOHNSON (player ruling: it must FAIL the job; a
700
+ // real session shot Ms. Veil over a maglock code and the run kept
701
+ // pretending she'd pay). The paymaster's death zeroes the payout,
702
+ // charges the p.368 notoriety, and marks the client dead so the
703
+ // homecoming files a corpse, not a contact.
704
+ if (this.currentClient && !this.currentClient.dead && name === this.currentClient.name) {
705
+ this.currentClient.dead = true;
706
+ const wc = this.scene?.getWinCondition?.();
707
+ if (wc)
708
+ wc.payout = 0;
709
+ const stained = this.player.addNotoriety(`killed their own Johnson on "${this.currentJobName ?? 'a job'}"`);
710
+ Logger.getInstance().logWithColor(`\n=== JOB DEAD === You just killed your paymaster. Whatever this was worth, nobody's left to pay it${stained ? ` -- and word WILL travel (+1 Notoriety, now ${this.player.notoriety})` : ''}.`, 'red');
711
+ }
491
712
  const entry = this.companions.find(c => c.npc.name === name);
492
713
  if (!entry)
493
714
  return;
@@ -849,6 +1070,134 @@ export default class Game {
849
1070
  get pendingRunTier() {
850
1071
  return this._pendingRunTier;
851
1072
  }
1073
+ // ==================== The run's CLIENT (canon p.27-29) ====================
1074
+ // Every generated run carries its own Johnson (seed.client) -- the
1075
+ // employer's deniable face who holds THE MEET. The fixer brokered the
1076
+ // introduction and is out of the loop from here. Not saved: mid-run
1077
+ // state never is (runs are disposable); reconstructed fresh by
1078
+ // activateRunScene, settled into the roster by returnToHub.
1079
+ currentClient;
1080
+ // Taxi warn-once (player ruling): leaving with the remainder unclaimed
1081
+ // arms this; the second "call taxi" forfeits. Mirrors _quitArmed.
1082
+ forfeitArmed = false;
1083
+ /**
1084
+ * The meet and the wrap-up, triggered by the player's first/next real
1085
+ * interaction with the client (talk-to, a connected call, a give).
1086
+ * Returns the beat's text (advance/remainder lines) or null when this
1087
+ * NPC isn't the client / nothing new happens. Canon p.28-29: half up
1088
+ * front at the meet, the remainder on delivering proof. Completing the
1089
+ * objective BEFORE ever meeting them collapses both beats into one.
1090
+ */
1091
+ onClientInteraction(npcName) {
1092
+ const client = this.currentClient;
1093
+ if (!client || !this.scene || this.scene === this._hubScene)
1094
+ return null;
1095
+ if (npcName !== client.name)
1096
+ return null;
1097
+ if (client.dead)
1098
+ return null;
1099
+ const payout = this.scene.getWinCondition()?.payout ?? 0;
1100
+ const lines = [];
1101
+ if (!client.met) {
1102
+ client.met = true;
1103
+ const advance = Math.floor(payout / 2);
1104
+ // The team's split comes off the top of every payment (canon: the
1105
+ // crew shares the take) -- the player banks their share only.
1106
+ const split = this.applyCrewCut(advance);
1107
+ const paid = this.scene.payRunner(split.share, 'meet advance');
1108
+ client.advancePaid = advance;
1109
+ if (paid) {
1110
+ lines.push(`THE MEET: ${client.name} slides half across up front -- ${paid.net}¥ certified${paid.tax > 0 ? ` (${paid.tax}¥ skimmed: SIN on file)` : ''}. The other half on delivery.${hint(` (Canon terms, p.28 -- and the rate is negotiable exactly once: "haggle" while you have them.)`)}`);
1111
+ if (split.line)
1112
+ lines.push(` ${split.line}`);
1113
+ }
1114
+ Logger.getInstance().write(`Client meet: ${this.player.name} met ${client.name}, advance ${advance} (gross, crew took ${advance - split.share}).`);
1115
+ }
1116
+ if (this.scene.isCompleted() && !client.reported) {
1117
+ client.reported = true;
1118
+ const remainder = payout - client.advancePaid;
1119
+ const split = this.applyCrewCut(remainder);
1120
+ const paid = this.scene.payRunner(split.share, 'wrap-up remainder');
1121
+ if (paid) {
1122
+ lines.push(`WRAP-UP: ${client.name} settles the balance -- ${paid.net}¥ certified${paid.tax > 0 ? ` (${paid.tax}¥ skimmed: SIN on file)` : ''}. Clean work; the name will remember yours.`);
1123
+ if (split.line)
1124
+ lines.push(` ${split.line}`);
1125
+ }
1126
+ Logger.getInstance().write(`Client wrap-up: ${this.player.name} reported to ${client.name}, remainder ${remainder} (gross, crew took ${remainder - split.share}).`);
1127
+ // JOHNSONS DON'T LINGER (real session: a settled Ms. Slate paced
1128
+ // in and out of rooms and tailed the runner around the scene).
1129
+ // After the settlement beat lands, the client walks -- their AI
1130
+ // gets no say in staying, because deniable faces leave. Guarded
1131
+ // against the scene having ended or swapped by the time it fires.
1132
+ const sceneAtWrap = this.scene;
1133
+ const clientName = client.name;
1134
+ setTimeout(() => {
1135
+ try {
1136
+ if (this.scene !== sceneAtWrap)
1137
+ return;
1138
+ const actor = sceneAtWrap.allActors.find(a => a.name === clientName);
1139
+ if (!actor || actor.inExchange || actor.deathClaimed)
1140
+ return;
1141
+ Logger.getInstance().logWithColor(`${clientName} settles the tab, turns up a collar, and is gone -- Johnsons don't linger.`, 'cyan');
1142
+ sceneAtWrap.removeActorQuietly(clientName);
1143
+ this.updateStatus();
1144
+ }
1145
+ catch { /* the exit must never crash a scene */ }
1146
+ }, 8000);
1147
+ }
1148
+ return lines.length > 0 ? lines.join('\n') : null;
1149
+ }
1150
+ /**
1151
+ * The client's verdict, filed at homecoming (returnToHub): whatever
1152
+ * happened, their name joins the roster -- clean work opens a
1153
+ * relationship (role 'Johnson', repeat clients deepen like gigs), a
1154
+ * forfeited remainder or a walked-out job files a note the roster
1155
+ * shows (p.368 already charged the walkout's notoriety at the curb).
1156
+ * Clears currentClient; returns the homecoming line.
1157
+ */
1158
+ settleClientReputation(wrappedRun) {
1159
+ const client = this.currentClient;
1160
+ this.currentClient = undefined;
1161
+ this.forfeitArmed = false;
1162
+ if (!client)
1163
+ return '';
1164
+ // Dead Johnsons don't take calls (player ruling: a killed client
1165
+ // must not appear in contacts) -- the notoriety was charged at the
1166
+ // kill; homecoming just states the arithmetic.
1167
+ if (client.dead) {
1168
+ this.contacts.delete(client.name);
1169
+ Logger.getInstance().write(`Client reputation settled: ${client.name} (dead -- no contact).`);
1170
+ return `
1171
+ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and their friends ask questions.`;
1172
+ }
1173
+ const existing = this.contacts.get(client.name);
1174
+ let line;
1175
+ if (wrappedRun && client.reported) {
1176
+ if (existing) {
1177
+ existing.gigs += 1;
1178
+ existing.loyalty = Math.min(6, existing.loyalty + 1);
1179
+ existing.note = undefined;
1180
+ }
1181
+ else {
1182
+ this.contacts.set(client.name, { connection: 4, loyalty: 2, spent: 0, gigs: 1, asked: false, role: 'Johnson' });
1183
+ }
1184
+ line = `\nWord gets around: ${client.name} is good for it -- and so are you. (CONTACT: Johnson, Loyalty ${this.contacts.get(client.name).loyalty})`;
1185
+ }
1186
+ else if (wrappedRun && !client.reported) {
1187
+ const entry = existing ?? { connection: 4, loyalty: 1, spent: 0, gigs: 0, asked: false, role: 'Johnson' };
1188
+ entry.note = 'sloppy wrap-up';
1189
+ this.contacts.set(client.name, entry);
1190
+ line = `\nThe job got done, but you never settled with ${client.name} -- the remainder stays theirs, and that's how they'll tell it.`;
1191
+ }
1192
+ else {
1193
+ const entry = existing ?? { connection: 4, loyalty: 1, spent: 0, gigs: 0, asked: false, role: 'Johnson' };
1194
+ entry.note = 'grudge -- walked out on their job';
1195
+ this.contacts.set(client.name, entry);
1196
+ line = `\n${client.name} will remember this one. Not fondly.`;
1197
+ }
1198
+ Logger.getInstance().write(`Client reputation settled: ${client.name} (${wrappedRun ? (client.reported ? 'clean' : 'unsettled') : 'walked'}).`);
1199
+ return line;
1200
+ }
852
1201
  /**
853
1202
  * The cab that answers "call taxi" varies per job (player request: not
854
1203
  * always a van) -- picked once when the pending run lands, so the
@@ -957,6 +1306,10 @@ export default class Game {
957
1306
  // LEADERSHIP (commands/lead.ts): rally/direct/inspire + Command recruiting.
958
1307
  CommandFactory.registerCommand('lead', LeadCommand);
959
1308
  CommandFactory.registerCommand('command', RecruitCommand);
1309
+ CommandFactory.registerCommand('crew', CrewCommand);
1310
+ CommandFactory.registerCommand('hire', HireCommand);
1311
+ CommandFactory.registerCommand('dismiss', DismissCommand);
1312
+ CommandFactory.registerCommand('train', TrainCommand);
960
1313
  // DOCWAGON (commands/docwagon.ts): the wristband between you and the morgue.
961
1314
  CommandFactory.registerCommand('docwagon', DocwagonCommand);
962
1315
  // SWAG (commands/source.ts): contacts shop the black market for you.
@@ -998,8 +1351,9 @@ export default class Game {
998
1351
  // wanted to end a call -- the intent is unambiguous mid-call.
999
1352
  CommandFactory.registerCommand('end', EndCallCommand);
1000
1353
  CommandFactory.registerCommand('hangup', EndCallCommand);
1354
+ // 'train' used to alias advance; it belongs to crew instruction now
1355
+ // (commands/crew.ts) -- self-training is "advance".
1001
1356
  CommandFactory.registerCommand('advance', AdvanceCommand);
1002
- CommandFactory.registerCommand('train', AdvanceCommand);
1003
1357
  CommandFactory.registerCommand('qualities', QualityTradeCommand);
1004
1358
  CommandFactory.registerCommand('quality', QualityTradeCommand);
1005
1359
  CommandFactory.registerCommand('ask', AskCommand);
@@ -1097,16 +1451,28 @@ export default class Game {
1097
1451
  return this._inputBox;
1098
1452
  }
1099
1453
  async init(options, screen) {
1454
+ this.initUi(screen);
1455
+ return this.initEngine(options);
1456
+ }
1457
+ /** The blessed half of startup -- widgets only. Headless sessions
1458
+ * (Phase C) never call this; everything below survives without it
1459
+ * (updateStatus/refreshLogLabel/close are all screen-guarded). */
1460
+ initUi(screen) {
1461
+ this.initInputBox(screen);
1462
+ this.initLogBox(screen);
1463
+ this.initMetaBox(screen);
1464
+ this.initStatusBox(screen);
1465
+ this._screen = screen;
1466
+ }
1467
+ /** The engine half of startup: scene synthesis, command registration,
1468
+ * hub seeding, resume overlay -- no widget contact. */
1469
+ async initEngine(options) {
1100
1470
  this._useAi = options.useAi;
1101
1471
  this._logFilePath = options.logFilePath;
1102
1472
  this._saveFilePath = options.saveFilePath;
1103
1473
  // The swag catalog warms from maka-cli.com in the background
1104
1474
  // (utilities/catalog.ts): live list > local cache > baked-in floor.
1105
1475
  void refreshCatalog();
1106
- this.initInputBox(screen);
1107
- this.initLogBox(screen);
1108
- this.initMetaBox(screen);
1109
- this.initStatusBox(screen);
1110
1476
  // No "Generating Scene... Please wait." box anymore: every path now
1111
1477
  // arrives with REAL prose (hand-authored static seeds, chunk-authored
1112
1478
  // generated scenes), so the old blocking AI-redressing pass -- 30-60
@@ -1123,7 +1489,6 @@ export default class Game {
1123
1489
  this.scene.initialize(this);
1124
1490
  this.player.currentLocation = this.determineStartRoom();
1125
1491
  this.registerCommands();
1126
- this._screen = screen;
1127
1492
  // The description follows the rent from day one (see
1128
1493
  // syncHomeToLifestyle) -- a fresh session at the default Squatter tier
1129
1494
  // reads exactly as the hideout always has.
@@ -1164,6 +1529,14 @@ export default class Game {
1164
1529
  // Home turf knows its own (see seedHomeTurfContacts). On resume the
1165
1530
  // saved contacts overwrite these loyalty-1 seeds a few lines down.
1166
1531
  this.seedHomeTurfContacts();
1532
+ // First deal of the for-hire board (rotates each homecoming).
1533
+ this.rollHireCandidates();
1534
+ // The street learns you're home (social layer): presence beats,
1535
+ // unsynced ghost earnings go up, and anything YOUR runner earned
1536
+ // riding other crews comes down. All fire-and-forget.
1537
+ this.startPresenceHeartbeat();
1538
+ void syncCrewLedger();
1539
+ void this.collectGhostEarnings();
1167
1540
  // The next run starts cooking the moment you're home (player
1168
1541
  // request): generation takes 20-40s, and waiting for the player
1169
1542
  // to CALL first meant "give me a bit" every time. Draft now --
@@ -1192,6 +1565,15 @@ export default class Game {
1192
1565
  // above, so the save is authoritative everywhere they overlap.
1193
1566
  if (this._resume && this._hubScene) {
1194
1567
  this.applyResumeState(this._resume);
1568
+ // The fixer reaches out on resume TOO (player report: "Krow
1569
+ // doesn't kick off a new job when I load in"). The init-time
1570
+ // draft above stays gated on resume because the overlay hadn't
1571
+ // landed yet -- HERE we know whether a pendingRun actually
1572
+ // survived the save, so an empty docket starts cooking now and
1573
+ // Krow's "1 new message" ping lands mid-session as usual.
1574
+ if (!this._pendingRunSeed && this.canDraftNextScene().allowed) {
1575
+ this.draftNextSceneInBackground();
1576
+ }
1195
1577
  }
1196
1578
  this.initialized = true;
1197
1579
  if (this._resume) {
@@ -1256,7 +1638,23 @@ export default class Game {
1256
1638
  }
1257
1639
  this.pendingProcurements = [...(save.game.procurements ?? [])];
1258
1640
  this.docwagon = save.game.docwagon ? { ...save.game.docwagon } : undefined;
1259
- this.enlistedAlly = save.game.enlistedAlly ? { ...save.game.enlistedAlly } : undefined;
1641
+ // THE CREW rides the save; the deprecated one-slot enlistedAlly
1642
+ // migrates in once (old saves) and is never written again.
1643
+ this.crew = (save.game.crew ?? []).map(m => ({ ...m, gear: [...m.gear] }));
1644
+ if (this.crew.length === 0 && save.game.enlistedAlly) {
1645
+ const legacy = save.game.enlistedAlly;
1646
+ this.crew.push({
1647
+ name: legacy.name,
1648
+ source: 'contact',
1649
+ description: legacy.description,
1650
+ combat: { ...legacy.combat },
1651
+ gear: [],
1652
+ cutPct: 10,
1653
+ jobsRun: 0,
1654
+ });
1655
+ Logger.getInstance().write(`Migrated legacy enlistedAlly "${legacy.name}" into the crew roster.`);
1656
+ }
1657
+ this.enlistedAlly = undefined;
1260
1658
  this.commandRefusals = { ...(save.game.commandRefusals ?? {}) };
1261
1659
  this.fencedCategories = new Set(save.game.fencedCategories);
1262
1660
  this._rumorIndex = save.game.rumorIndex;
@@ -1340,7 +1738,7 @@ export default class Game {
1340
1738
  contacts: [...this.contacts.entries()],
1341
1739
  procurements: [...this.pendingProcurements],
1342
1740
  docwagon: this.docwagon ? { ...this.docwagon } : undefined,
1343
- enlistedAlly: this.enlistedAlly ? { ...this.enlistedAlly } : undefined,
1741
+ crew: this.crew.map(m => ({ ...m, gear: [...m.gear] })),
1344
1742
  commandRefusals: { ...this.commandRefusals },
1345
1743
  fencedCategories: [...this.fencedCategories],
1346
1744
  rumorIndex: this._rumorIndex,
@@ -1443,6 +1841,18 @@ export default class Game {
1443
1841
  // Read the run's fate BEFORE the scene swap below discards it -- a
1444
1842
  // walked-away job gets a different homecoming line than a wrapped one.
1445
1843
  const wrappedRun = this.scene !== hub && this.scene.isCompleted();
1844
+ // THE CLIENT REMEMBERS (player ruling: the run's outcome is
1845
+ // reputation with the CLIENT, not the fixer).
1846
+ const clientLine = this.scene !== hub ? this.settleClientReputation(wrappedRun) : '';
1847
+ // CREW settlement BEFORE the fold (gear write-back + obituaries --
1848
+ // dismissCompanion would ship their kit to the player otherwise),
1849
+ // then the wrapped-run tally and the training window reset.
1850
+ const crewLines = this.scene !== hub ? this.settleCrewAfterRun() : [];
1851
+ for (const member of this.crew) {
1852
+ if (wrappedRun)
1853
+ member.jobsRun += 1;
1854
+ member.trainedThisCycle = false;
1855
+ }
1446
1856
  // Companions fold for the ride home, same as the trip out.
1447
1857
  const companionFoldLines = this.foldCompanionsForTravel();
1448
1858
  // Settle cross-plane and combat state that must not leak into the hub
@@ -1523,11 +1933,16 @@ export default class Game {
1523
1933
  const companionBlock = [...companionFoldLines, ...companionArrivalLines];
1524
1934
  // The commanded ally's tour ends with the run (RAW's Command lasts
1525
1935
  // turns; ours lasts the job): the crew disbands at the curb.
1526
- let allyLine = '';
1527
- if (this.enlistedAlly) {
1528
- allyLine = `\n${this.enlistedAlly.name} peels off at the curb -- "Call it even, chummer." The crew's back to just you.`;
1529
- this.enlistedAlly = undefined;
1530
- }
1936
+ // The crew stays hired between jobs (the roster persists); their
1937
+ // homecoming lines (obituaries, write-back) rode in above. Fresh
1938
+ // faces on the for-hire board each homecoming.
1939
+ this.rollHireCandidates();
1940
+ // Social settlement: presence resumes, ghost cuts sync up, earnings
1941
+ // come home.
1942
+ this.beatPresence();
1943
+ void syncCrewLedger();
1944
+ void this.collectGhostEarnings();
1945
+ const allyLine = crewLines.length > 0 ? `\n${crewLines.join('\n')}` : '';
1531
1946
  // DOCWAGON's year ticks in homecomings (12 = a street year, player
1532
1947
  // ruling): the contract lapses quietly unless renewed at Doc's.
1533
1948
  let docwagonLine = '';
@@ -1570,7 +1985,7 @@ export default class Game {
1570
1985
  const fateLine = wrappedRun
1571
1986
  ? `The run's wrapped and the street swallows you back up.`
1572
1987
  : `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
1573
- 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}${deliveryLine}${docwagonLine}${allyLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
1988
+ 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.`)}${clientLine}\n${rentLine}${comfortLine}${restockLine}${deliveryLine}${docwagonLine}${allyLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
1574
1989
  }
1575
1990
  /**
1576
1991
  * The actual drafting work, split out from continueToNextScene() purely
@@ -1580,15 +1995,9 @@ export default class Game {
1580
1995
  */
1581
1996
  async draftNextScene() {
1582
1997
  const nextTier = this._tier + 1;
1583
- // Carry the current fixer's identity forward -- without this, each
1584
- // "continue" generates a scene with zero knowledge of the last one, so
1585
- // the AI just invents a brand new job-giver every time. Looked up by
1586
- // identity (is there an actor actually named FIXER_NAME?), not via the
1587
- // win condition's toNpc -- that's often a one-off contact the fixer
1588
- // pointed the runner toward, not the fixer himself (see the "broker,
1589
- // not always the destination" convention).
1590
- const fixerStillInScene = this.scene.allActors.some(a => a.name === FIXER_NAME);
1591
- const previousFixerName = fixerStillInScene ? FIXER_NAME : undefined;
1998
+ // Each run invents its OWN client Johnson (canon roles: the fixer
1999
+ // brokers from the hub and never appears in run scenes) -- the old
2000
+ // fixer-identity carryforward is gone with him.
1592
2001
  // Append, don't replace -- this used to be setContent(), which wiped
1593
2002
  // the whole log box (including the JOB COMPLETE banner and everything
1594
2003
  // before it) for the entire generation wait, which can run 20-40+
@@ -1602,7 +2011,7 @@ export default class Game {
1602
2011
  //
1603
2012
  // The player object rides along so the job is drafted FOR this runner
1604
2013
  // -- their archetype, stats, and toolkit (see buildPlayerDossier).
1605
- const nextSeed = await SceneSeedGenerator.generate(this._logFilePath, nextTier, previousFixerName, this.player, this.activeRumor);
2014
+ const nextSeed = await SceneSeedGenerator.generate(this._logFilePath, nextTier, this.player, this.activeRumor, this.crew);
1606
2015
  if (!nextSeed) {
1607
2016
  return `Incoming call -- ${FIXER_NAME}: "Job fell through, chummer. Client got cold feet before the ink dried. Give me another call in a bit -- I'll shake something loose."`;
1608
2017
  }
@@ -1669,14 +2078,26 @@ export default class Game {
1669
2078
  // Deployed drones spin back up at the curb -- BEFORE appearance
1670
2079
  // warming so re-shelled companions get warmed with the cast.
1671
2080
  const arrivalLines = this.unfoldCompanionsOnArrival();
1672
- // A commanded ally meets you at the jobsite (commands/lead.ts).
1673
- const allyLines = this.spawnEnlistedAlly();
2081
+ // The crew meets you at the jobsite (commands/crew.ts, lead.ts).
2082
+ const allyLines = this.spawnCrew();
1674
2083
  this.warmNpcAppearancesInBackground();
1675
2084
  this.refreshLogLabel();
2085
+ // THE MEET opens the job (canon p.28): the seed's client is this
2086
+ // run's Johnson -- track them, and point the arrival at the meet.
2087
+ this.currentClient = seed.client
2088
+ ? { name: seed.client, met: false, advancePaid: 0, reported: false }
2089
+ : undefined;
2090
+ this.forfeitArmed = false;
2091
+ const clientRoom = seed.client
2092
+ ? this.scene.allActors.find(a => a.name === seed.client)?.currentLocation?.name
2093
+ : undefined;
2094
+ const meetLine = seed.client
2095
+ ? `\nYour contact on this one goes by ${seed.client}${clientRoom ? ` -- holding the meet at ${clientRoom}` : ''}. Half up front when you sit down; the rest on delivery.${hint(` (Find them and talk, or "call ${seed.client}" -- the rate is negotiable at the meet, once.)`)}`
2096
+ : '';
1676
2097
  // No "=== JOB #n ===" banner on entry (player request) -- the Game
1677
2098
  // Log window's own title carries the job name now.
1678
2099
  const companionBlock = [...companionLines, ...arrivalLines, ...allyLines];
1679
- return `\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
2100
+ return `\n${this.scene.story}${meetLine}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
1680
2101
  }
1681
2102
  /**
1682
2103
  * The needs check at every job departure (SR5e p.56 / p.172, scaled to
@@ -1789,13 +2210,41 @@ export default class Game {
1789
2210
  Logger.getInstance().write(`Permadeath: save deleted (${this._saveFilePath}).`);
1790
2211
  }
1791
2212
  this.stopPursuitHeartbeat();
1792
- this._inputBox.removeAllListeners(); // removes all event listeners
1793
- this._inputBox.cancel(); // cancels any active input session
1794
- this._inputBox.detach(); // optionally remove from screen
1795
- this._screen.render(); // re-render if needed
1796
- this._screen.key(['C-c'], () => {
1797
- process.exit(2);
1798
- });
2213
+ // Widget teardown only where widgets exist -- endGame() reaches here
2214
+ // from headless sessions too (Phase C), where there is no screen.
2215
+ if (this._inputBox) {
2216
+ this._inputBox.removeAllListeners(); // removes all event listeners
2217
+ this._inputBox.cancel(); // cancels any active input session
2218
+ this._inputBox.detach(); // optionally remove from screen
2219
+ }
2220
+ if (this._screen) {
2221
+ this._screen.render(); // re-render if needed
2222
+ this._screen.key(['C-c'], () => {
2223
+ process.exit(2);
2224
+ });
2225
+ }
2226
+ }
2227
+ stopPresenceHeartbeat() {
2228
+ if (this._presenceTimer) {
2229
+ clearInterval(this._presenceTimer);
2230
+ this._presenceTimer = undefined;
2231
+ }
2232
+ }
2233
+ /**
2234
+ * Full per-session teardown (Phase C): everything init/play armed that
2235
+ * outlives a turn -- for the hosting server's idle eviction and
2236
+ * session close. Solo play never calls this (process exit does it).
2237
+ */
2238
+ async dispose() {
2239
+ this.stopPresenceHeartbeat();
2240
+ this.stopPursuitHeartbeat();
2241
+ this.autoFight.cancelPending();
2242
+ for (const ko of this.scene?.playerKnockouts.values() ?? []) {
2243
+ if (ko.timer)
2244
+ clearTimeout(ko.timer);
2245
+ }
2246
+ this.scene?.playerKnockouts.clear();
2247
+ await this.commandQueue.drain();
1799
2248
  }
1800
2249
  initInputBox(screen) {
1801
2250
  // Create an input box for user commands
@@ -2052,7 +2501,15 @@ export default class Game {
2052
2501
  // fire from synthesis paths before the blessed UI exists.
2053
2502
  if (!this._statusBox)
2054
2503
  return;
2055
- const player = this.player;
2504
+ this._statusBox.setContent(this.statusLinesFor(this.player).join('\n'));
2505
+ this._screen.render();
2506
+ }
2507
+ /**
2508
+ * The HUD as data: every status line for `player`, pure state reads --
2509
+ * no widgets. updateStatus paints it for the local player; a shared-run
2510
+ * server ships it per player as status events (Phase C).
2511
+ */
2512
+ statusLinesFor(player) {
2056
2513
  const lines = [];
2057
2514
  // Line 1: where you are, and the fight autopilot mode. The stance
2058
2515
  // lives HERE -- the shortest HUD line -- because appended to the long
@@ -2189,8 +2646,7 @@ export default class Game {
2189
2646
  const mount = player.droneArmed ? ' ⌖ armed' : ' (recon -- no mounts)';
2190
2647
  lines.push(`{yellow-fg}✈ JUMPED IN: ${drone?.name ?? 'drone'} ${(player.simMode ?? 'cold').toUpperCase()}-SIM${mount}${hull} -- body at ${player.bodyRoom?.name ?? '???'}{/yellow-fg}`);
2191
2648
  }
2192
- this._statusBox.setContent(lines.join('\n'));
2193
- this._screen.render();
2649
+ return lines;
2194
2650
  }
2195
2651
  // The three methods below were the render paths for the old always-on
2196
2652
  // sidebar boxes. Those boxes are gone (the log owns the whole screen; see
@@ -2225,7 +2681,50 @@ export default class Game {
2225
2681
  * correction call overwrites it with the corrected verb.
2226
2682
  */
2227
2683
  lastResolvedVerb = '';
2684
+ /**
2685
+ * Serializes every inbound command (see utilities/command-queue.ts):
2686
+ * the engine's await points assume one command in flight, and a second
2687
+ * human on a DDP connection must wait its turn, not interleave.
2688
+ */
2689
+ commandQueue = new CommandQueue();
2690
+ /**
2691
+ * Shared-run hook (Phase C): fires when a NON-primary human player
2692
+ * dies in this scene, so the server can burn their save. The primary
2693
+ * player's death keeps the solo path (Scene.endGame -> close(),
2694
+ * permadeath).
2695
+ */
2696
+ onPlayerDeath;
2228
2697
  async handleInput(input) {
2698
+ return (await this.handleInputAs(this.player.name, input)).text;
2699
+ }
2700
+ /**
2701
+ * Runs `input` AS the named human player -- the symmetric-multiplayer
2702
+ * command entry: every human gets the same full privileged context the
2703
+ * solo player always had. Queued; internal re-entries (typo
2704
+ * correction, say.ts's speech-accept) run inline via the queue's
2705
+ * re-entrancy guard.
2706
+ */
2707
+ async handleInputAs(actorName, input) {
2708
+ return this.commandQueue.enqueue(() => {
2709
+ // Ambient event scoping (see emitter.ts): while this command runs,
2710
+ // the session context knows WHOSE command it is -- headless
2711
+ // emitters default its output to that player's room/eyes. Solo
2712
+ // play has no session context; this is a no-op there.
2713
+ const ctx = currentSession();
2714
+ const prev = ctx?.dispatch;
2715
+ if (ctx)
2716
+ ctx.dispatch = { actorName };
2717
+ return Promise.resolve(this.runInputAs(actorName, input)).finally(() => {
2718
+ if (ctx)
2719
+ ctx.dispatch = prev;
2720
+ });
2721
+ });
2722
+ }
2723
+ async runInputAs(actorName, input) {
2724
+ const actor = this.scene.getPlayerByName(actorName);
2725
+ if (!actor) {
2726
+ return { text: `No runner named "${actorName}" is in this scene.`, verb: '' };
2727
+ }
2229
2728
  // "jack-in"/"jack-out" read naturally but parse as unknown verbs (a
2230
2729
  // real session typed "jack-in" and bounced) -- normalize the hyphen.
2231
2730
  const normalized = input.replace(/^\s*jack-(in|out)\b/i, 'jack $1');
@@ -2233,43 +2732,48 @@ export default class Game {
2233
2732
  const verb = parts[0].toLowerCase();
2234
2733
  const args = parts.slice(1);
2235
2734
  this.lastResolvedVerb = verb;
2735
+ const out = (text) => ({ text, verb: this.lastResolvedVerb });
2736
+ // Empty input is a nervous Enter, not a command (a real session's
2737
+ // triple keystroke printed three 'I don't understand ""' lines).
2738
+ if (!verb)
2739
+ return out('');
2236
2740
  // Quitting takes a CONFIRM (player request: "I just exited by
2237
2741
  // mistake"): the first "quit" arms it, "y"/"yes" (or quit again)
2238
2742
  // seals it, anything else disarms and runs as the command it is.
2239
2743
  if (this._quitArmed) {
2240
2744
  this._quitArmed = false;
2241
2745
  if (/^(y|yes|quit|exit)$/i.test(verb)) {
2242
- return null;
2746
+ return out(null);
2243
2747
  }
2244
2748
  // fall through -- the answer was a real command; play on.
2245
2749
  }
2246
2750
  else if (verb === 'quit' || verb === 'exit') {
2247
2751
  this._quitArmed = true;
2248
- return `Really quit? ("y" to confirm -- anything else keeps playing.)`;
2752
+ return out(`Really quit? ("y" to confirm -- anything else keeps playing.)`);
2249
2753
  }
2250
2754
  // Out cold with the fight settling around you (see
2251
2755
  // Scene.beginPlayerKnockout): unconsciousness is not a prompt. Only
2252
2756
  // quit (handled above) still answers.
2253
- if (this.player.isUnconscious() && this.scene.playerKnockout) {
2254
- return `You're out cold -- the world moves without you.`;
2757
+ if (actor.isUnconscious() && this.scene.getKnockout(actor.name)) {
2758
+ return out(`You're out cold -- the world moves without you.`);
2255
2759
  }
2256
2760
  if (verb === 'maka') {
2257
- return 'You are still in the game, type "quit" to exit';
2761
+ return out('You are still in the game, type "quit" to exit');
2258
2762
  }
2259
2763
  if (verb === 'continue') {
2260
2764
  // RETIRED verb, kept only as a redirect: jobs flow through the
2261
2765
  // fixer ("call Mr. Johnson"), and a finished run ends by walking
2262
2766
  // home to your hideout -- no system command in the loop.
2263
- return `That's not how the street works anymore, chummer: "call ${FIXER_NAME}" when you want work. When a run's wrapped, head home to your hideout -- the door knows the way.`;
2767
+ return out(`That's not how the street works anymore, chummer: "call ${FIXER_NAME}" when you want work. When a run's wrapped, head home to your hideout -- the door knows the way.`);
2264
2768
  }
2265
2769
  if (verb === 'help') {
2266
- return this.getHelpMessage(args);
2770
+ return out(this.getHelpMessage(args));
2267
2771
  }
2268
2772
  if (verb === 'clear') {
2269
- return '';
2773
+ return out('');
2270
2774
  }
2271
2775
  const context = {
2272
- actor: this.player,
2776
+ actor,
2273
2777
  rooms: this.scene.getRooms(),
2274
2778
  scene: this.scene,
2275
2779
  screen: this._screen,
@@ -2278,7 +2782,7 @@ export default class Game {
2278
2782
  const command = CommandFactory.createCommand(verb, context);
2279
2783
  if (command) {
2280
2784
  const result = await command.execute(args);
2281
- return result;
2785
+ return out(result);
2282
2786
  }
2283
2787
  // Typo tolerance: two straight sessions bounced on slips like "searh"
2284
2788
  // and "maop". Reuse the same fuzzy matcher item/NPC names go through
@@ -2297,17 +2801,19 @@ export default class Game {
2297
2801
  }
2298
2802
  if (guess && guess !== verb) {
2299
2803
  if (guess === 'quit' || guess === 'exit') {
2300
- return `I don't understand "${verb}" -- did you mean "${guess}"?`;
2804
+ return out(`I don't understand "${verb}" -- did you mean "${guess}"?`);
2301
2805
  }
2302
- const corrected = await this.handleInput([guess, ...args].join(' '));
2806
+ const corrected = await this.runInputAs(actorName, [guess, ...args].join(' '));
2303
2807
  // The correction note stays INLINE and always-on (player ruling:
2304
2808
  // it briefly moved to the Mechanics ticker, and a conversational
2305
2809
  // "well I'm here..." silently became a SELL with nothing in the
2306
2810
  // world log explaining the refusal that came back). It's not dice
2307
2811
  // noise -- it's the only receipt for a command you didn't type.
2308
- return corrected === null ? null : `(read "${verb}" as "${guess}")\n${corrected ?? ''}`;
2812
+ return corrected.text === null
2813
+ ? out(null)
2814
+ : out(`(read "${verb}" as "${guess}")\n${corrected.text ?? ''}`);
2309
2815
  }
2310
- return `I don't understand the command "${verb}".`;
2816
+ return out(`I don't understand the command "${verb}".`);
2311
2817
  }
2312
2818
  /**
2313
2819
  * The help taxonomy: every entry is [primary, ...aliases], grouped the
@@ -2324,13 +2830,13 @@ export default class Game {
2324
2830
  { title: 'combat', entries: [['attack', 'kill'], ['subdue'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['stance'], ['surrender'], ['edge'], ['rest']] },
2325
2831
  // The party layer: everyone who walks (or flies, or manifests) at
2326
2832
  // your side answers to these.
2327
- { title: 'party', entries: [['order'], ['lead'], ['command'], ['deploy'], ['recall']] },
2833
+ { title: 'party', entries: [['crew'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall']] },
2328
2834
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project'], ['return'], ['assense']] },
2329
2835
  { title: 'matrix', entries: [['jack'], ['deck'], ['load'], ['unload'], ['hack'], ['pan'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
2330
2836
  { title: 'social', entries: [['say'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['accept'], ['decline', 'deny', 'refuse'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
2331
2837
  { title: 'street', entries: [['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
2332
2838
  { title: 'barriers', entries: [['solve'], ['pick'], ['bluff'], ['unlock'], ['lock']] },
2333
- { title: 'character', entries: [['sheet', 'stats'], ['advance', 'train'], ['qualities', 'quality'], ['initiate', 'submerge'], ['install', 'chrome']] },
2839
+ { title: 'character', entries: [['sheet', 'stats'], ['advance'], ['qualities', 'quality'], ['initiate', 'submerge'], ['install', 'chrome']] },
2334
2840
  { title: 'session', entries: [['help'], ['hints'], ['clear'], ['quit', 'exit']] },
2335
2841
  ];
2336
2842
  /** Descriptions for the verbs handled inline in handleInput() rather than registered. */