@maka/maka-cli 5.10.0 → 5.12.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 (119) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +3 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +6 -5
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +28 -3
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +3 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.d.ts +13 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js +99 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js.map +1 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js +12 -2
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js.map +1 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +14 -8
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.d.ts +30 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.js +142 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/deck.js.map +1 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.d.ts +14 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js +87 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js.map +1 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/equipment.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/go.d.ts +11 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +29 -3
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +9 -3
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +9 -5
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +5 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +7 -6
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js.map +1 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/install.js +4 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/install.js.map +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.d.ts +32 -0
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js +94 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js.map +1 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.d.ts +39 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +187 -0
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js.map +1 -0
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.d.ts +16 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +86 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js.map +1 -0
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/pan.js +3 -2
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/pan.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.d.ts +13 -0
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js +48 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js.map +1 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js +8 -7
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +8 -3
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +9 -2
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/sell.js +3 -2
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/sell.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.d.ts +6 -4
  67. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +80 -65
  68. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  69. package/bundle/typescript/src/commands/game/sideQuest/commands/source.d.ts +11 -0
  70. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js +176 -0
  71. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js.map +1 -0
  72. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +6 -3
  73. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +71 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/game.js +424 -29
  76. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  77. package/bundle/typescript/src/commands/game/sideQuest/models/item.d.ts +28 -0
  78. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +83 -0
  79. package/bundle/typescript/src/commands/game/sideQuest/models/item.js.map +1 -1
  80. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +29 -7
  81. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +23 -3
  83. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +78 -17
  84. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +7 -1
  86. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  87. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +299 -60
  88. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +25 -0
  89. package/bundle/typescript/src/commands/game/sideQuest/ui.js +73 -3
  90. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  91. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.d.ts +32 -0
  92. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js +112 -0
  93. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog-data.js.map +1 -0
  94. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.d.ts +28 -0
  95. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +128 -0
  96. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js.map +1 -0
  97. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +3 -0
  98. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +41 -9
  99. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  100. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.d.ts +19 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js +13 -0
  102. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js.map +1 -1
  103. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.d.ts +33 -0
  104. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js +44 -0
  105. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js.map +1 -0
  106. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +8 -0
  107. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  108. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.d.ts +4 -2
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +35 -4
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js.map +1 -1
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.d.ts +39 -0
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js +63 -0
  113. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js.map +1 -0
  114. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-lock.d.ts +18 -0
  115. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-lock.js +91 -0
  116. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-lock.js.map +1 -0
  117. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +25 -16
  118. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  119. package/package.json +1 -1
@@ -5,8 +5,9 @@ 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, } from './utilities/persistence.js';
8
+ import { serializePlayer, restorePlayer, captureHubOverlay, applyHubOverlay, writeSaveAtomic, deleteSave, restoreItem, readSave, } from './utilities/persistence.js';
9
9
  import { pushSave, burnCloudSaveFor } from './utilities/cloud-saves.js';
10
+ import { DOCWAGON_TIERS, RESUSC_FEE, DEATH_COMP } from './utilities/docwagon.js';
10
11
  import { isFence, LIFESTYLE_TIERS } from './utilities/commerce.js';
11
12
  import { rollPool, formatRoll } from './utilities/dice.js';
12
13
  import { Direction } from './types/shared/direction-enum.js';
@@ -38,6 +39,14 @@ import { RestCommand } from './commands/rest.js';
38
39
  import { ReloadCommand } from './commands/reload.js';
39
40
  import { SubdueCommand } from './commands/subdue.js';
40
41
  import { GrappleCommand, ReleaseCommand, StruggleCommand } from './commands/grapple.js';
42
+ import { AcceptCommand, DeclineCommand } from './commands/job-offer.js';
43
+ import { SourceCommand } from './commands/source.js';
44
+ import { DocwagonCommand } from './commands/docwagon.js';
45
+ import { LeadCommand, RecruitCommand } from './commands/lead.js';
46
+ import { PalmCommand } from './commands/palm.js';
47
+ import { CatalogCommand } from './commands/catalog.js';
48
+ import { refreshCatalog } from './utilities/catalog.js';
49
+ import { PerformCommand } from './commands/perform.js';
41
50
  import { EdgeCommand } from './commands/edge.js';
42
51
  import { SheetCommand } from './commands/sheet.js';
43
52
  import { CallCommand } from './commands/call.js';
@@ -64,6 +73,8 @@ import { InitiateCommand } from './commands/initiate.js';
64
73
  import { SummonCommand } from './commands/summon.js';
65
74
  import { OrderCommand } from './commands/order.js';
66
75
  import { DeployCommand, AgentCommand, RecallCommand } from './commands/companions.js';
76
+ import { DeckCommand, LoadProgramCommand, UnloadProgramCommand } from './commands/deck.js';
77
+ import { getProgram } from './utilities/programs.js';
67
78
  import { MoveCommand, RunCommand, SprintCommand } from './commands/move.js';
68
79
  import { HintsCommand } from './commands/hints.js';
69
80
  import { hint } from './utilities/hints.js';
@@ -152,7 +163,191 @@ export default class Game {
152
163
  // against the real pending-run seed. One pull per contact per
153
164
  // homecoming (asked resets in returnToHub).
154
165
  contacts = new Map();
166
+ // SWAG orders in flight (SR5e p.387/418, commands/source.ts): a
167
+ // contact shopping the black market for you. Paid up front on a won
168
+ // Availability test; the goods land at the NEXT HOMECOMING (the
169
+ // delivery-times table, abstracted to the job-cycle clock the same
170
+ // way rest is). Rides the save (ISavedGameState.procurements).
171
+ pendingProcurements = [];
172
+ // DOCWAGON (SR5e p.450, utilities/docwagon.ts): the wristband between
173
+ // a runner and the morgue. Rides the save; ticks down at homecomings.
174
+ 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;
181
+ commandRefusals = {};
182
+ // PERFORMANCE's busking gate (commands/perform.ts): one set per job
183
+ // cycle, reset at homecoming like a contact's legwork. Deliberately
184
+ // NOT saved -- a relaunch is as good as a homecoming for new material.
185
+ 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),
200
+ });
201
+ return shell ? [`${ally.name} is already leaning on the wall when you arrive -- "Took my own ride. Let's work."`] : [];
202
+ }
203
+ /** Bills a DocWagon fee: cred first, shortfall onto the rent tab (the
204
+ * street's one universal collections agency). Returns the line. */
205
+ billDocwagon(amount, label) {
206
+ if (amount <= 0)
207
+ return '';
208
+ const paid = Math.min(this.player.currency, amount);
209
+ if (paid > 0)
210
+ this.player.adjustCurrency(-paid);
211
+ const owed = amount - paid;
212
+ if (owed > 0)
213
+ this.rentDebt += owed;
214
+ return owed > 0
215
+ ? `DocWagon bills ${amount} for ${label} -- ${paid} paid, ${owed} on your tab.`
216
+ : `DocWagon bills ${amount} for ${label}.`;
217
+ }
218
+ /**
219
+ * The band answers (SR5e p.450). 'htr': a High Threat Response team
220
+ * extracts a flatlining KO'd runner instead of the street deciding.
221
+ * 'resusc': clinical death reversed -- a free resuscitation or the
222
+ * 5,000 fee -- the sanctioned carve-out to permadeath (player ruling;
223
+ * overflow-past-Body instant death isn't modeled yet, so every death
224
+ * is currently within reach of the crash cart). Returns true when the
225
+ * rescue engages -- the caller must then NOT end the game.
226
+ */
227
+ tryDocwagonRescue(kind, opts = {}) {
228
+ const contract = this.docwagon;
229
+ if (!contract || !this.initialized || this._dead)
230
+ return false;
231
+ const tier = DOCWAGON_TIERS[contract.tier];
232
+ const logger = Logger.getInstance();
233
+ const bills = [];
234
+ if (kind === 'resusc') {
235
+ if (contract.resuscsLeft > 0) {
236
+ contract.resuscsLeft -= 1;
237
+ bills.push(`A contract resuscitation is burned -- ${contract.resuscsLeft} left this year.`);
238
+ }
239
+ else {
240
+ bills.push(this.billDocwagon(RESUSC_FEE, 'resuscitation'));
241
+ }
242
+ // Back from the white light, barely: two boxes shy of the track.
243
+ const p = this.player;
244
+ p.healDamage(Math.max(0, p.damageTaken - (p.maxConditionBoxes - 2)));
245
+ p.healStun(Math.max(0, p.stunTaken - Math.max(0, p.maxStunBoxes - 2)));
246
+ }
247
+ else {
248
+ bills.push(this.billDocwagon(tier.htrFee, 'High Threat Response'));
249
+ // Extraction under fire: hostiles still standing can cost DocWagon
250
+ // a medic -- and the client eats the 20,000 death compensation
251
+ // (p.450), unless the contract says otherwise.
252
+ const hostiles = opts.standingHostiles ?? [];
253
+ if (hostiles.length > 0 && tier.deathComp) {
254
+ const casualtyRoll = rollPool(Math.min(4, hostiles.length));
255
+ if (casualtyRoll.hits > 0) {
256
+ bills.push(this.billDocwagon(DEATH_COMP, 'employee death compensation -- a medic did not make it out'));
257
+ }
258
+ }
259
+ // Conscious enough to be carried: the band's medics stabilize.
260
+ const p = this.player;
261
+ p.healStun(Math.max(0, p.stunTaken - Math.max(0, p.maxStunBoxes - 2)));
262
+ }
263
+ logger.logWithColor(kind === 'resusc'
264
+ ? `${CALL_ICON} Your wristband SCREAMS the flatline. Ten minutes later there is foam, light, and a trauma team refusing to let you go.`
265
+ : `${CALL_ICON} The band reads the flatline and howls -- a DocWagon HTR team storms the scene behind riot shields and suppression foam.`, CALL_COLOR);
266
+ for (const b of bills.filter(Boolean))
267
+ logger.logWithColor(b, 'yellow');
268
+ this.scene.addWorldEvent(`A DocWagon team extracted ${this.player.name} under the ${contract.tier} contract.`);
269
+ void this.docwagonMedevac();
270
+ return true;
271
+ }
272
+ /** Where the wagon drops you (player ruling: the CLINIC, not your own
273
+ * cot): the district Street Doc's room, generically -- so generated
274
+ * hubs work too. Falls back to the hideout if the hub has no doc. */
275
+ docwagonDropOff() {
276
+ for (const [name, c] of this.contacts) {
277
+ if (c.role === 'Street Doc') {
278
+ const doc = this.hubActor(name);
279
+ if (doc?.currentLocation)
280
+ return doc.currentLocation;
281
+ }
282
+ }
283
+ return this._homeRoom;
284
+ }
285
+ /** The ride out on a stretcher: mid-run this is a homecoming (rent and
286
+ * all), then the recovery bed at the clinic. */
287
+ async docwagonMedevac() {
288
+ const logger = Logger.getInstance();
289
+ try {
290
+ const clinic = this.docwagonDropOff();
291
+ if (this.hasHub && this.scene !== this._hubScene) {
292
+ const home = await this.continueToNextScene();
293
+ logger.log(home);
294
+ }
295
+ this.player.currentLocation = clinic;
296
+ this.player.cameFrom = undefined;
297
+ this.updateExits(this.player.currentLocation);
298
+ this.updateStatus();
299
+ logger.log(clinic === this._homeRoom
300
+ ? `You wake on your own cot, taped, stapled, and alive.`
301
+ : `You wake on a recovery cot under bad fluorescents -- ${clinic.name}. Taped, stapled, and alive.`);
302
+ this.requestSave('homecoming');
303
+ }
304
+ catch (err) {
305
+ logger.error(`docwagonMedevac failed: ${err}`);
306
+ }
307
+ }
155
308
  /** How much juice the name carries, read off the role. */
309
+ /**
310
+ * A contact's ROLE, the SR5e archetype list (p.98: fixer, arms dealer,
311
+ * smuggler, talismonger, street doc, mechanic, bartender...) -- same
312
+ * keyword school as connectionFor below. Stored on the contact entry
313
+ * at open time so the roster (and the save) carry it.
314
+ */
315
+ static roleOf(npc) {
316
+ 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))
324
+ return 'Fixer';
325
+ // "charms" deliberately absent: a market kid hawking LUCKY charms is
326
+ // a Vendor, not a talismonger (Skitter proved it).
327
+ if (/talismonger|magical goods|foci|spell formula/.test(hay))
328
+ return 'Talismonger';
329
+ if (/street doc|clinic|surgeon|medic|stitch|chop shop/.test(hay))
330
+ return 'Street Doc';
331
+ if (/fence|pawn|launder|no questions/.test(hay))
332
+ return 'Fence';
333
+ if (/smuggl/.test(hay))
334
+ return 'Smuggler';
335
+ if (/mechanic|drone|rigger|garage|airframe/.test(hay))
336
+ return 'Mechanic';
337
+ if (/gunsmith|weapons? (and armor|dealer)|armorer|ironmonger/.test(hay))
338
+ return 'Arms Dealer';
339
+ if (/bartender|barkeep|tends the .{0,40}bar|pour(s|ed) drinks/.test(hay))
340
+ return 'Bartender';
341
+ if (/decker|data broker|host|electronics/.test(hay))
342
+ return 'Data Broker';
343
+ if (/priest|sister|chapel|choir|shaman/.test(hay))
344
+ return 'Spiritual';
345
+ if (/noodle|food|cook|kitchen|vendor|stall|merchant|shopkeep|market/.test(hay))
346
+ return 'Vendor';
347
+ if (/gang|muscle|enforcer|lieutenant|sentry|lookout|bruiser/.test(hay))
348
+ return 'Muscle';
349
+ return 'Street Contact';
350
+ }
156
351
  static connectionFor(npc) {
157
352
  const hay = `${npc.description ?? ''} ${npc.name}`.toLowerCase();
158
353
  if (npc.name === FIXER_NAME || /fixer|johnson/.test(hay))
@@ -180,7 +375,7 @@ export default class Game {
180
375
  continue;
181
376
  if (this.contacts.has(actor.name))
182
377
  continue;
183
- this.contacts.set(actor.name, { connection: Game.connectionFor(actor), loyalty: 1, spent: 0, gigs: 0, asked: false });
378
+ this.contacts.set(actor.name, { connection: Game.connectionFor(actor), loyalty: 1, spent: 0, gigs: 0, asked: false, role: Game.roleOf(actor) });
184
379
  }
185
380
  Logger.getInstance().write(`Home turf: ${this.contacts.size} contacts seeded from the hub cast.`);
186
381
  }
@@ -419,6 +614,19 @@ export default class Game {
419
614
  * cumulative spend raises it at each full 1,000 nuyen (cap 3).
420
615
  * Returns a player-facing line when something changed, else null.
421
616
  */
617
+ /** A hub-cast member by name, for roster displays that need the live
618
+ * actor (contacts.ts reads their commlink to show reachability). */
619
+ hubActor(name) {
620
+ return this._hubScene?.allActors?.find(a => a.name === name && a !== this.player);
621
+ }
622
+ /** The whole hub cast -- call.ts merges these into its candidates so
623
+ * a linked contact answers from ANYWHERE (a real mid-run session:
624
+ * "call Ratchet" bounced with "you don't know anyone by that name"
625
+ * while the roster promised 📞). The hub Scene instance survives
626
+ * runs, so these are live actors, not ghosts. */
627
+ hubCast() {
628
+ return this._hubScene?.allActors?.filter(a => a !== this.player) ?? [];
629
+ }
422
630
  touchContact(name, kind, spent = 0) {
423
631
  if (!this.hasHub)
424
632
  return null;
@@ -427,7 +635,7 @@ export default class Game {
427
635
  return null;
428
636
  let c = this.contacts.get(name);
429
637
  if (!c) {
430
- c = { connection: Game.connectionFor(npc), loyalty: 1, spent: 0, gigs: 0, asked: false };
638
+ c = { connection: Game.connectionFor(npc), loyalty: 1, spent: 0, gigs: 0, asked: false, role: Game.roleOf(npc) };
431
639
  this.contacts.set(name, c);
432
640
  if (kind === 'trade')
433
641
  c.spent += spent;
@@ -623,6 +831,18 @@ export default class Game {
623
831
  // "call Mr. Johnson" hears the offer, "travel" goes.
624
832
  _pendingRunSeed;
625
833
  _pendingRunTier;
834
+ // The explicit handshake (player ruling, commands/job-offer.ts):
835
+ // "accept" locks the offer -- rate final, haggle closed, decline
836
+ // refused -- until "call taxi" installs it. Reset wherever the
837
+ // pending seed changes hands.
838
+ pendingRunAccepted = false;
839
+ /** Trash the offer wholesale ("decline", and any future fixer snub). */
840
+ clearPendingRun() {
841
+ this._pendingRunSeed = undefined;
842
+ this._pendingRunTier = undefined;
843
+ this._pendingRide = undefined;
844
+ this.pendingRunAccepted = false;
845
+ }
626
846
  get pendingRun() {
627
847
  return this._pendingRunSeed;
628
848
  }
@@ -731,6 +951,23 @@ export default class Game {
731
951
  CommandFactory.registerCommand('reload', ReloadCommand);
732
952
  CommandFactory.registerCommand('subdue', SubdueCommand);
733
953
  // SUBDUING p.195 (see commands/grapple.ts): restrain without damage.
954
+ // Canon skill hooks (see SkillName in models/player.ts).
955
+ CommandFactory.registerCommand('palm', PalmCommand);
956
+ CommandFactory.registerCommand('perform', PerformCommand);
957
+ // LEADERSHIP (commands/lead.ts): rally/direct/inspire + Command recruiting.
958
+ CommandFactory.registerCommand('lead', LeadCommand);
959
+ CommandFactory.registerCommand('command', RecruitCommand);
960
+ // DOCWAGON (commands/docwagon.ts): the wristband between you and the morgue.
961
+ CommandFactory.registerCommand('docwagon', DocwagonCommand);
962
+ // SWAG (commands/source.ts): contacts shop the black market for you.
963
+ CommandFactory.registerCommand('source', SourceCommand);
964
+ CommandFactory.registerCommand('catalog', CatalogCommand);
965
+ CommandFactory.registerCommand('procure', SourceCommand);
966
+ // Job-offer outcomes (commands/job-offer.ts): explicit accept/decline.
967
+ CommandFactory.registerCommand('accept', AcceptCommand);
968
+ CommandFactory.registerCommand('decline', DeclineCommand);
969
+ CommandFactory.registerCommand('deny', DeclineCommand);
970
+ CommandFactory.registerCommand('refuse', DeclineCommand);
734
971
  CommandFactory.registerCommand('grapple', GrappleCommand);
735
972
  CommandFactory.registerCommand('restrain', GrappleCommand);
736
973
  CommandFactory.registerCommand('clinch', GrappleCommand);
@@ -786,6 +1023,9 @@ export default class Game {
786
1023
  CommandFactory.registerCommand('deploy', DeployCommand);
787
1024
  CommandFactory.registerCommand('recall', RecallCommand);
788
1025
  CommandFactory.registerCommand('agent', AgentCommand);
1026
+ CommandFactory.registerCommand('deck', DeckCommand);
1027
+ CommandFactory.registerCommand('load', LoadProgramCommand);
1028
+ CommandFactory.registerCommand('unload', UnloadProgramCommand);
789
1029
  CommandFactory.registerCommand('install', InstallCommand);
790
1030
  CommandFactory.registerCommand('chrome', InstallCommand);
791
1031
  CommandFactory.registerCommand('buy', BuyCommand);
@@ -860,6 +1100,9 @@ export default class Game {
860
1100
  this._useAi = options.useAi;
861
1101
  this._logFilePath = options.logFilePath;
862
1102
  this._saveFilePath = options.saveFilePath;
1103
+ // The swag catalog warms from maka-cli.com in the background
1104
+ // (utilities/catalog.ts): live list > local cache > baked-in floor.
1105
+ void refreshCatalog();
863
1106
  this.initInputBox(screen);
864
1107
  this.initLogBox(screen);
865
1108
  this.initMetaBox(screen);
@@ -982,6 +1225,39 @@ export default class Game {
982
1225
  this.player.lifestyleEdgeBonus = this.lifestyle.edgeBonus;
983
1226
  this._tier = save.game.tier;
984
1227
  this.contacts = new Map(save.game.contacts);
1228
+ // Roles are DERIVED, not earned -- re-derive them all on every
1229
+ // resume so old saves self-heal (pre-role saves read all "Street
1230
+ // Contact"; the next vintage had Johnson stored as "Fixer" before
1231
+ // the Job Broker split). Missing actors keep whatever the save said.
1232
+ for (const [name, c] of this.contacts) {
1233
+ const npc = this._hubScene?.allActors?.find(a => a.name === name);
1234
+ if (npc)
1235
+ c.role = Game.roleOf(npc);
1236
+ else if (!c.role)
1237
+ c.role = 'Street Contact';
1238
+ }
1239
+ // SEED-TRUTH for commlinks: the hub overlay replaces NPC inventories
1240
+ // wholesale, so a link granted to the CAST by a newer seed (player
1241
+ // request: Torque, Ratchet, Doc, Old Cray, Skitter, and Whisper all
1242
+ // answer calls now) would be wiped by an older save. Re-grant any
1243
+ // seed-held commlink its bearer no longer carries.
1244
+ for (const seedItem of this._hubSeed?.items ?? []) {
1245
+ if (seedItem.category !== 'Commlink' || !seedItem.heldBy)
1246
+ continue;
1247
+ const bearer = this._hubScene?.allActors?.find(a => a.name === seedItem.heldBy);
1248
+ if (!bearer)
1249
+ continue;
1250
+ const hasLink = bearer.inventory.getAllItems().some(i => i.category === Category.Commlink)
1251
+ || bearer.equipment.head?.commlink != null;
1252
+ if (!hasLink) {
1253
+ bearer.inventory.addItem(restoreItem(seedItem));
1254
+ Logger.getInstance().write(`Resume heal: re-granted "${seedItem.name}" to ${seedItem.heldBy}.`);
1255
+ }
1256
+ }
1257
+ this.pendingProcurements = [...(save.game.procurements ?? [])];
1258
+ this.docwagon = save.game.docwagon ? { ...save.game.docwagon } : undefined;
1259
+ this.enlistedAlly = save.game.enlistedAlly ? { ...save.game.enlistedAlly } : undefined;
1260
+ this.commandRefusals = { ...(save.game.commandRefusals ?? {}) };
985
1261
  this.fencedCategories = new Set(save.game.fencedCategories);
986
1262
  this._rumorIndex = save.game.rumorIndex;
987
1263
  this.activeRumor = save.game.activeRumor;
@@ -1022,6 +1298,15 @@ export default class Game {
1022
1298
  return;
1023
1299
  try {
1024
1300
  const save = this.buildSaveFile(reason);
1301
+ // Carry the cloud-sync stamp FORWARD through local writes: the
1302
+ // stamp means "the cloud's savedAt as of our last mirror", and a
1303
+ // new local beat doesn't change what the cloud holds. Without
1304
+ // this, every quit (whose async push can't outlive the process)
1305
+ // left a stampless file that the next launch misread as a FORK
1306
+ // and prompted over (a real session hit it on every reload).
1307
+ const prev = readSave(this._saveFilePath);
1308
+ if (prev.ok && prev.save.sync)
1309
+ save.sync = prev.save.sync;
1025
1310
  writeSaveAtomic(this._saveFilePath, save);
1026
1311
  logger.write(`Saved (${reason}) -> ${this._saveFilePath}`);
1027
1312
  // Local-first: the beat is DONE the moment the file lands. The
@@ -1053,6 +1338,10 @@ export default class Game {
1053
1338
  rentDebt: this.rentDebt,
1054
1339
  tier: this._tier,
1055
1340
  contacts: [...this.contacts.entries()],
1341
+ procurements: [...this.pendingProcurements],
1342
+ docwagon: this.docwagon ? { ...this.docwagon } : undefined,
1343
+ enlistedAlly: this.enlistedAlly ? { ...this.enlistedAlly } : undefined,
1344
+ commandRefusals: { ...this.commandRefusals },
1056
1345
  fencedCategories: [...this.fencedCategories],
1057
1346
  rumorIndex: this._rumorIndex,
1058
1347
  activeRumor: this.activeRumor,
@@ -1181,6 +1470,8 @@ export default class Game {
1181
1470
  // pull each per homecoming (see commands/ask.ts).
1182
1471
  for (const contact of this.contacts.values())
1183
1472
  contact.asked = false;
1473
+ // Fresh material for the buskers too (commands/perform.ts).
1474
+ this.performedThisCycle = false;
1184
1475
  // LIFESTYLE upkeep comes due with the homecoming: this tier's share
1185
1476
  // plus any standing debt, paid from carried cred as far as it goes.
1186
1477
  // Coming up short SLIDES YOU DOWN a tier (the district notices) and
@@ -1230,14 +1521,56 @@ export default class Game {
1230
1521
  // Drones spin back up beside the cot.
1231
1522
  const companionArrivalLines = this.unfoldCompanionsOnArrival();
1232
1523
  const companionBlock = [...companionFoldLines, ...companionArrivalLines];
1524
+ // The commanded ally's tour ends with the run (RAW's Command lasts
1525
+ // 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
+ }
1531
+ // DOCWAGON's year ticks in homecomings (12 = a street year, player
1532
+ // ruling): the contract lapses quietly unless renewed at Doc's.
1533
+ let docwagonLine = '';
1534
+ if (this.docwagon) {
1535
+ this.docwagon.homecomingsLeft -= 1;
1536
+ if (this.docwagon.homecomingsLeft <= 0) {
1537
+ docwagonLine = `\nYour DocWagon ${this.docwagon.tier} contract LAPSES -- the wristband goes dark.${hint(` ("docwagon <tier>" at Doc's renews it.)`)}`;
1538
+ this.docwagon = undefined;
1539
+ }
1540
+ else if (this.docwagon.homecomingsLeft <= 2) {
1541
+ docwagonLine = `\nDocWagon renewal coming due: ${this.docwagon.homecomingsLeft} homecoming${this.docwagon.homecomingsLeft === 1 ? '' : 's'} left on the ${this.docwagon.tier} contract.`;
1542
+ }
1543
+ }
1544
+ // SWAG deliveries (commands/source.ts): the contact's shopping run
1545
+ // lands with the homecoming -- straight into your gear, paid for at
1546
+ // order time.
1547
+ let deliveryLine = '';
1548
+ if (this.pendingProcurements.length > 0) {
1549
+ const delivered = [];
1550
+ for (const order of this.pendingProcurements.splice(0)) {
1551
+ try {
1552
+ const item = restoreItem(order.itemConfig);
1553
+ item.owner = this.player.name;
1554
+ this.player.inventory.addItem(item);
1555
+ delivered.push(`${item.name} (${order.contact} came through)`);
1556
+ }
1557
+ catch (err) {
1558
+ Logger.getInstance().error(`Procurement delivery failed: ${err}`);
1559
+ }
1560
+ }
1561
+ if (delivered.length > 0) {
1562
+ this.updateInventory(this.player.inventory, capitalCase(this.scene.getCurrencyType()));
1563
+ deliveryLine = `\nWaiting with your gear: ${delivered.join(', ')}.`;
1564
+ }
1565
+ }
1233
1566
  // The homecoming save: everything above (payout, rent, restock,
1234
- // contacts reset) settled first, so the save reflects the hub as
1235
- // the player will find it.
1567
+ // contacts reset, deliveries) settled first, so the save reflects
1568
+ // the hub as the player will find it.
1236
1569
  this.requestSave('homecoming');
1237
1570
  const fateLine = wrappedRun
1238
1571
  ? `The run's wrapped and the street swallows you back up.`
1239
1572
  : `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
1240
- return `\n=== ${this._hubName.toUpperCase()} ===\n${fateLine} You're home -- your hideout, one door off ${hub.determineStartRoom().name}.${hint(` Call ${FIXER_NAME} when you're hungry for the next job.`)}\n${rentLine}${comfortLine}${restockLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
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')}` : ''}`;
1241
1574
  }
1242
1575
  /**
1243
1576
  * The actual drafting work, split out from continueToNextScene() purely
@@ -1282,6 +1615,7 @@ export default class Game {
1282
1615
  this._pendingRunSeed = nextSeed;
1283
1616
  this._pendingRunTier = nextTier;
1284
1617
  this._pendingRide = Game.RIDE_POOL[Math.floor(Math.random() * Game.RIDE_POOL.length)];
1618
+ this.pendingRunAccepted = false;
1285
1619
  return `Your link pings -- 1 new message: ${FIXER_NAME}.${hint(` ("call ${FIXER_NAME}" to hear the job.)`)}`;
1286
1620
  }
1287
1621
  // Classic sessions transition immediately, hideout-first, as always.
@@ -1335,11 +1669,13 @@ export default class Game {
1335
1669
  // Deployed drones spin back up at the curb -- BEFORE appearance
1336
1670
  // warming so re-shelled companions get warmed with the cast.
1337
1671
  const arrivalLines = this.unfoldCompanionsOnArrival();
1672
+ // A commanded ally meets you at the jobsite (commands/lead.ts).
1673
+ const allyLines = this.spawnEnlistedAlly();
1338
1674
  this.warmNpcAppearancesInBackground();
1339
1675
  this.refreshLogLabel();
1340
1676
  // No "=== JOB #n ===" banner on entry (player request) -- the Game
1341
1677
  // Log window's own title carries the job name now.
1342
- const companionBlock = [...companionLines, ...arrivalLines];
1678
+ const companionBlock = [...companionLines, ...arrivalLines, ...allyLines];
1343
1679
  return `\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
1344
1680
  }
1345
1681
  /**
@@ -1426,16 +1762,12 @@ export default class Game {
1426
1762
  const haggledAtOffer = this.player.haggledThisJob;
1427
1763
  try {
1428
1764
  const banner = await this.activateRunScene(seed, tier, { startInHideout: false });
1429
- this._pendingRunSeed = undefined;
1430
- this._pendingRunTier = undefined;
1431
- this._pendingRide = undefined;
1765
+ this.clearPendingRun();
1432
1766
  this.player.haggledThisJob = haggledAtOffer;
1433
1767
  return `You travel to ${this.player.currentLocation.name}.\n${banner}`;
1434
1768
  }
1435
1769
  catch (err) {
1436
- this._pendingRunSeed = undefined;
1437
- this._pendingRunTier = undefined;
1438
- this._pendingRide = undefined;
1770
+ this.clearPendingRun();
1439
1771
  Logger.getInstance().error(`launchPendingRun failed: ${err}`);
1440
1772
  return `The ride never shows and the job address goes dead. ${FIXER_NAME} will not enjoy hearing this -- call him for another.`;
1441
1773
  }
@@ -1652,12 +1984,35 @@ export default class Game {
1652
1984
  style: {
1653
1985
  border: { fg: 'blue' }
1654
1986
  },
1655
- label: ' Mechanics ',
1987
+ // The label teaches its own scroll keys -- a real session hit
1988
+ // PgUp/PgDn (the world log's) and concluded the panel can't move.
1989
+ label: ' Mechanics (C-PgUp/Dn) ',
1656
1990
  scrollable: true,
1657
1991
  alwaysScroll: true,
1658
1992
  tags: true,
1659
1993
  });
1660
1994
  this._metaBox = metaBox;
1995
+ // Scrolling (player report: menus render here now and PgUp/PgDn
1996
+ // don't reach it): PgUp/PgDn stay the WORLD LOG's (that binding is
1997
+ // itself a playtest fix -- see initLogBox), so the Mechanics column
1998
+ // pages on Ctrl-PgUp/PgDn. Same focus problem, same cure: the input
1999
+ // box holds focus forever and grabKeys mutes screen-level handlers,
2000
+ // so the modified names must join ignoreLocked too. Ctrl-End snaps
2001
+ // back to the bottom (new content always scrolls to bottom anyway).
2002
+ screen.ignoreLocked.push('C-pageup', 'C-pagedown', 'C-end');
2003
+ const metaPage = () => Math.max(1, (typeof metaBox.height === 'number' ? metaBox.height : 20) - 3);
2004
+ screen.key(['C-pageup'], () => {
2005
+ metaBox.scroll(-metaPage());
2006
+ screen.render();
2007
+ });
2008
+ screen.key(['C-pagedown'], () => {
2009
+ metaBox.scroll(metaPage());
2010
+ screen.render();
2011
+ });
2012
+ screen.key(['C-end'], () => {
2013
+ metaBox.scrollTo(metaBox.getScrollHeight());
2014
+ screen.render();
2015
+ });
1661
2016
  screen.append(metaBox);
1662
2017
  }
1663
2018
  /**
@@ -1751,7 +2106,7 @@ export default class Game {
1751
2106
  // Off-plane the profile is attack programs / astral will and reads as
1752
2107
  // Armed. White, not gray, for the same legibility reason as the stance.
1753
2108
  const armed = player.plane === 'meat' && weaponItem && !player.weaponDrawn
1754
- ? `{white-fg}Holstered: ${weaponItem.name}${ammoNote} -- "brandish"{/white-fg}`
2109
+ ? `{white-fg}Holstered: ${weaponItem.name}${ammoNote}${hint(' -- "brandish"')}{/white-fg}`
1755
2110
  : weapon.name === 'bare fists'
1756
2111
  ? `{gray-fg}Unarmed{/gray-fg}`
1757
2112
  : `Armed: ${weapon.name} (DV ${weapon.damage}${weapon.poolBonus > 0 ? `, +${weapon.poolBonus} dice` : ''})${ammoNote}`;
@@ -1771,10 +2126,10 @@ export default class Game {
1771
2126
  lines.push(`{magenta-fg}Sustained: armor +${player.sustainedArmor} soak (-2 dice to everything else){/magenta-fg}`);
1772
2127
  }
1773
2128
  if (player.astralPerceiving) {
1774
- lines.push(`{magenta-fg}✧ ASTRAL SENSES OPEN (-2 dice sustained) -- "assense" to close{/magenta-fg}`);
2129
+ lines.push(`{magenta-fg}✧ ASTRAL SENSES OPEN (-2 dice sustained)${hint(' -- "assense" to close')}{/magenta-fg}`);
1775
2130
  }
1776
2131
  if (player.sneaking) {
1777
- lines.push(`{white-fg}🐾 SNEAKING -- entries are opposed vs their perception; loud acts break it{/white-fg}`);
2132
+ lines.push(`{white-fg}🐾 SNEAKING${hint(' -- entries are opposed vs their perception; loud acts break it')}{/white-fg}`);
1778
2133
  }
1779
2134
  if (player.activeCallPartner) {
1780
2135
  lines.push(`{${CALL_COLOR}-fg}${CALL_ICON} On a call with ${player.activeCallPartner.name}{/${CALL_COLOR}-fg}`);
@@ -1806,11 +2161,19 @@ export default class Game {
1806
2161
  // Off-plane context: which reality you're in, your persona's Matrix
1807
2162
  // track (cold-sim damage lands there), and where your empty body lies.
1808
2163
  if (player.plane === 'matrix') {
1809
- // Matrix damage is hardware damage: show the DECK's track. A
2164
+ // Matrix damage is hardware damage: show the DECK's track -- and
2165
+ // its stat line: the ASDF deal ("deck config" is a live combat
2166
+ // decision now, so the HUD shows what you're currently rigged for)
2167
+ // plus running programs, first-word-abbreviated to keep the line
2168
+ // inside the box (Biofeedback Filter -> Biofeedback). A
1810
2169
  // technomancer has no deck -- their stun track (already on line 2)
1811
2170
  // is what the ice chews on.
1812
- const track = player.activeDeck
1813
- ? ` Deck ${player.activeDeck.deckSummary()}${player.simMode === 'hot' ? ' +biofeedback' : ''}`
2171
+ const jackedDeck = player.activeDeck;
2172
+ const pgms = jackedDeck && jackedDeck.loadedPrograms.length > 0
2173
+ ? ` [${jackedDeck.loadedPrograms.map(k => getProgram(k)?.name.split(' ')[0] ?? k).join(' ')}]`
2174
+ : '';
2175
+ const track = jackedDeck
2176
+ ? ` Deck A${jackedDeck.attack} S${jackedDeck.sleaze} D${jackedDeck.dataProcessing} F${jackedDeck.firewall} ${jackedDeck.deckSummary()}${player.simMode === 'hot' ? ' +biofeedback' : ''}${pgms}`
1814
2177
  : ` (living persona -- damage is REAL stun)`;
1815
2178
  lines.push(`{cyan-fg}▣ MATRIX ${(player.simMode ?? 'cold').toUpperCase()}-SIM${track} -- body at ${player.bodyRoom?.name ?? '???'}{/cyan-fg}`);
1816
2179
  }
@@ -1854,6 +2217,14 @@ export default class Game {
1854
2217
  }
1855
2218
  return Game.instance;
1856
2219
  }
2220
+ /**
2221
+ * The verb the LAST handleInput actually ran, after typo correction
2222
+ * ("sjeet" -> "sheet"). ui.ts routes menu output to the Mechanics
2223
+ * panel on THIS, not the typed word -- a real session's corrected
2224
+ * "sjeet" dumped the whole sheet into the world log. The recursive
2225
+ * correction call overwrites it with the corrected verb.
2226
+ */
2227
+ lastResolvedVerb = '';
1857
2228
  async handleInput(input) {
1858
2229
  // "jack-in"/"jack-out" read naturally but parse as unknown verbs (a
1859
2230
  // real session typed "jack-in" and bounced) -- normalize the hyphen.
@@ -1861,6 +2232,7 @@ export default class Game {
1861
2232
  const parts = normalized.trim().split(' ');
1862
2233
  const verb = parts[0].toLowerCase();
1863
2234
  const args = parts.slice(1);
2235
+ this.lastResolvedVerb = verb;
1864
2236
  // Quitting takes a CONFIRM (player request: "I just exited by
1865
2237
  // mistake"): the first "quit" arms it, "y"/"yes" (or quit again)
1866
2238
  // seals it, anything else disarms and runs as the command it is.
@@ -1949,11 +2321,14 @@ export default class Game {
1949
2321
  static HELP_CATEGORIES = [
1950
2322
  { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach', 'run', 'sprint'], ['map', 'exits'], ['search'], ['sneak']] },
1951
2323
  { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['drop'], ['give'], ['store'], ['equip'], ['unequip'], ['brandish', 'draw'], ['holster', 'stow'], ['reload'], ['use'], ['read']] },
1952
- { title: 'combat', entries: [['attack', 'kill'], ['subdue'], ['order'], ['stance'], ['surrender'], ['edge'], ['rest']] },
2324
+ { title: 'combat', entries: [['attack', 'kill'], ['subdue'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['stance'], ['surrender'], ['edge'], ['rest']] },
2325
+ // The party layer: everyone who walks (or flies, or manifests) at
2326
+ // your side answers to these.
2327
+ { title: 'party', entries: [['order'], ['lead'], ['command'], ['deploy'], ['recall']] },
1953
2328
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project'], ['return'], ['assense']] },
1954
- { title: 'matrix', entries: [['jack'], ['hack'], ['pan'], ['silent'], ['reboot'], ['agent'], ['drone'], ['deploy'], ['recall'], ['jump', 'rig']] },
1955
- { title: 'social', entries: [['say'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
1956
- { title: 'street', entries: [['browse', 'buy'], ['sell'], ['work', 'gig'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
2329
+ { title: 'matrix', entries: [['jack'], ['deck'], ['load'], ['unload'], ['hack'], ['pan'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
2330
+ { title: 'social', entries: [['say'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['accept'], ['decline', 'deny', 'refuse'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
2331
+ { title: 'street', entries: [['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
1957
2332
  { title: 'barriers', entries: [['solve'], ['pick'], ['bluff'], ['unlock'], ['lock']] },
1958
2333
  { title: 'character', entries: [['sheet', 'stats'], ['advance', 'train'], ['qualities', 'quality'], ['initiate', 'submerge'], ['install', 'chrome']] },
1959
2334
  { title: 'session', entries: [['help'], ['hints'], ['clear'], ['quit', 'exit']] },
@@ -1961,8 +2336,8 @@ export default class Game {
1961
2336
  /** Descriptions for the verbs handled inline in handleInput() rather than registered. */
1962
2337
  static BUILTIN_DESCRIPTIONS = {
1963
2338
  help: 'This overview. "help <category>" for one group with full descriptions; "help <command>" for one command.',
1964
- clear: 'Clear the screen.',
1965
- quit: 'Exit the game.',
2339
+ clear: 'Clear the world log and the Mechanics panel.',
2340
+ quit: 'Exit the game (asks to confirm; a hub campaign autosaves on the way out).',
1966
2341
  };
1967
2342
  describeVerb(verb) {
1968
2343
  const builtin = Game.BUILTIN_DESCRIPTIONS[verb];
@@ -1999,14 +2374,34 @@ export default class Game {
1999
2374
  }
2000
2375
  return `No command or category called "${query}". Try plain "help".`;
2001
2376
  }
2002
- // The overview: compact category lines, primaries only.
2377
+ // The overview: category lines with primaries only, chunked so no
2378
+ // line outgrows the Mechanics column (help renders there now --
2379
+ // blessed's mid-list wrap lost the left-column alignment).
2003
2380
  const lines = ['=== COMMANDS ==='];
2004
2381
  const pad = Math.max(...Game.HELP_CATEGORIES.map(c => c.title.length));
2382
+ const pushWrapped = (title, verbs) => {
2383
+ const rows = [];
2384
+ let cur = '';
2385
+ for (const v of verbs) {
2386
+ const next = cur ? `${cur}, ${v}` : v;
2387
+ if (next.length > 40 && cur) {
2388
+ rows.push(cur + ',');
2389
+ cur = v;
2390
+ }
2391
+ else {
2392
+ cur = next;
2393
+ }
2394
+ }
2395
+ rows.push(cur);
2396
+ lines.push(`${title.padEnd(pad)} ${rows[0]}`);
2397
+ for (const r of rows.slice(1))
2398
+ lines.push(`${' '.repeat(pad)} ${r}`);
2399
+ };
2005
2400
  for (const category of Game.HELP_CATEGORIES) {
2006
- lines.push(`${category.title.padEnd(pad)} ${category.entries.map(e => e[0]).join(', ')}`);
2401
+ pushWrapped(category.title, category.entries.map(e => e[0]));
2007
2402
  }
2008
2403
  if (uncategorized.length > 0) {
2009
- lines.push(`${'other'.padEnd(pad)} ${uncategorized.join(', ')}`);
2404
+ pushWrapped('other', uncategorized);
2010
2405
  }
2011
2406
  lines.push('');
2012
2407
  lines.push(`"help <category>" for a group with descriptions ("help combat"); "help <command>" for one ("help haggle").`);