@maka/maka-cli 5.10.0 → 5.11.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 (65) 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/ask.js +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +10 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +2 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +8 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.d.ts +14 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js +86 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js.map +1 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +8 -2
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +6 -4
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +5 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.d.ts +32 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js +94 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js.map +1 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.d.ts +39 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +187 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js.map +1 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.d.ts +16 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +86 -0
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js.map +1 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.d.ts +13 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js +48 -0
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js.map +1 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +8 -3
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +7 -2
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/source.d.ts +9 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js +159 -0
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js.map +1 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +6 -3
  41. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +57 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/game.js +326 -13
  44. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +4 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +19 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +48 -9
  49. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +7 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +299 -60
  53. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +22 -0
  54. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +3 -0
  55. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +41 -9
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.d.ts +19 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js +13 -0
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.d.ts +33 -0
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js +44 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js.map +1 -0
  63. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +7 -16
  64. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  65. 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,12 @@ 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 { PerformCommand } from './commands/perform.js';
41
48
  import { EdgeCommand } from './commands/edge.js';
42
49
  import { SheetCommand } from './commands/sheet.js';
43
50
  import { CallCommand } from './commands/call.js';
@@ -152,7 +159,191 @@ export default class Game {
152
159
  // against the real pending-run seed. One pull per contact per
153
160
  // homecoming (asked resets in returnToHub).
154
161
  contacts = new Map();
162
+ // SWAG orders in flight (SR5e p.387/418, commands/source.ts): a
163
+ // contact shopping the black market for you. Paid up front on a won
164
+ // Availability test; the goods land at the NEXT HOMECOMING (the
165
+ // delivery-times table, abstracted to the job-cycle clock the same
166
+ // way rest is). Rides the save (ISavedGameState.procurements).
167
+ pendingProcurements = [];
168
+ // DOCWAGON (SR5e p.450, utilities/docwagon.ts): the wristband between
169
+ // a runner and the morgue. Rides the save; ticks down at homecomings.
170
+ docwagon;
171
+ // LEADERSHIP's Command action (SR5e p.141-142, commands/lead.ts): a
172
+ // recruited contact rides the NEXT run as an 'ally' companion shell
173
+ // (spawned at activateRunScene, released at homecoming). RAW's teeth:
174
+ // a refusal stands until Loyalty deepens past the level it happened
175
+ // at. Both ride the save.
176
+ enlistedAlly;
177
+ commandRefusals = {};
178
+ // PERFORMANCE's busking gate (commands/perform.ts): one set per job
179
+ // cycle, reset at homecoming like a contact's legwork. Deliberately
180
+ // NOT saved -- a relaunch is as good as a homecoming for new material.
181
+ performedThisCycle = false;
182
+ /** The recruited contact joins at the jobsite curb ("takes their own
183
+ * ride and meets you there" -- the companion convention). */
184
+ spawnEnlistedAlly() {
185
+ const ally = this.enlistedAlly;
186
+ if (!ally)
187
+ return [];
188
+ const shell = this.spawnCompanionShell({
189
+ name: ally.name,
190
+ 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.`,
191
+ kind: 'ally',
192
+ combat: ally.combat,
193
+ room: this.player.currentLocation,
194
+ plane: 'meat',
195
+ force: Number(ally.combat.body ?? 3),
196
+ });
197
+ return shell ? [`${ally.name} is already leaning on the wall when you arrive -- "Took my own ride. Let's work."`] : [];
198
+ }
199
+ /** Bills a DocWagon fee: cred first, shortfall onto the rent tab (the
200
+ * street's one universal collections agency). Returns the line. */
201
+ billDocwagon(amount, label) {
202
+ if (amount <= 0)
203
+ return '';
204
+ const paid = Math.min(this.player.currency, amount);
205
+ if (paid > 0)
206
+ this.player.adjustCurrency(-paid);
207
+ const owed = amount - paid;
208
+ if (owed > 0)
209
+ this.rentDebt += owed;
210
+ return owed > 0
211
+ ? `DocWagon bills ${amount} for ${label} -- ${paid} paid, ${owed} on your tab.`
212
+ : `DocWagon bills ${amount} for ${label}.`;
213
+ }
214
+ /**
215
+ * The band answers (SR5e p.450). 'htr': a High Threat Response team
216
+ * extracts a flatlining KO'd runner instead of the street deciding.
217
+ * 'resusc': clinical death reversed -- a free resuscitation or the
218
+ * 5,000 fee -- the sanctioned carve-out to permadeath (player ruling;
219
+ * overflow-past-Body instant death isn't modeled yet, so every death
220
+ * is currently within reach of the crash cart). Returns true when the
221
+ * rescue engages -- the caller must then NOT end the game.
222
+ */
223
+ tryDocwagonRescue(kind, opts = {}) {
224
+ const contract = this.docwagon;
225
+ if (!contract || !this.initialized || this._dead)
226
+ return false;
227
+ const tier = DOCWAGON_TIERS[contract.tier];
228
+ const logger = Logger.getInstance();
229
+ const bills = [];
230
+ if (kind === 'resusc') {
231
+ if (contract.resuscsLeft > 0) {
232
+ contract.resuscsLeft -= 1;
233
+ bills.push(`A contract resuscitation is burned -- ${contract.resuscsLeft} left this year.`);
234
+ }
235
+ else {
236
+ bills.push(this.billDocwagon(RESUSC_FEE, 'resuscitation'));
237
+ }
238
+ // Back from the white light, barely: two boxes shy of the track.
239
+ const p = this.player;
240
+ p.healDamage(Math.max(0, p.damageTaken - (p.maxConditionBoxes - 2)));
241
+ p.healStun(Math.max(0, p.stunTaken - Math.max(0, p.maxStunBoxes - 2)));
242
+ }
243
+ else {
244
+ bills.push(this.billDocwagon(tier.htrFee, 'High Threat Response'));
245
+ // Extraction under fire: hostiles still standing can cost DocWagon
246
+ // a medic -- and the client eats the 20,000 death compensation
247
+ // (p.450), unless the contract says otherwise.
248
+ const hostiles = opts.standingHostiles ?? [];
249
+ if (hostiles.length > 0 && tier.deathComp) {
250
+ const casualtyRoll = rollPool(Math.min(4, hostiles.length));
251
+ if (casualtyRoll.hits > 0) {
252
+ bills.push(this.billDocwagon(DEATH_COMP, 'employee death compensation -- a medic did not make it out'));
253
+ }
254
+ }
255
+ // Conscious enough to be carried: the band's medics stabilize.
256
+ const p = this.player;
257
+ p.healStun(Math.max(0, p.stunTaken - Math.max(0, p.maxStunBoxes - 2)));
258
+ }
259
+ logger.logWithColor(kind === 'resusc'
260
+ ? `${CALL_ICON} Your wristband SCREAMS the flatline. Ten minutes later there is foam, light, and a trauma team refusing to let you go.`
261
+ : `${CALL_ICON} The band reads the flatline and howls -- a DocWagon HTR team storms the scene behind riot shields and suppression foam.`, CALL_COLOR);
262
+ for (const b of bills.filter(Boolean))
263
+ logger.logWithColor(b, 'yellow');
264
+ this.scene.addWorldEvent(`A DocWagon team extracted ${this.player.name} under the ${contract.tier} contract.`);
265
+ void this.docwagonMedevac();
266
+ return true;
267
+ }
268
+ /** Where the wagon drops you (player ruling: the CLINIC, not your own
269
+ * cot): the district Street Doc's room, generically -- so generated
270
+ * hubs work too. Falls back to the hideout if the hub has no doc. */
271
+ docwagonDropOff() {
272
+ for (const [name, c] of this.contacts) {
273
+ if (c.role === 'Street Doc') {
274
+ const doc = this.hubActor(name);
275
+ if (doc?.currentLocation)
276
+ return doc.currentLocation;
277
+ }
278
+ }
279
+ return this._homeRoom;
280
+ }
281
+ /** The ride out on a stretcher: mid-run this is a homecoming (rent and
282
+ * all), then the recovery bed at the clinic. */
283
+ async docwagonMedevac() {
284
+ const logger = Logger.getInstance();
285
+ try {
286
+ const clinic = this.docwagonDropOff();
287
+ if (this.hasHub && this.scene !== this._hubScene) {
288
+ const home = await this.continueToNextScene();
289
+ logger.log(home);
290
+ }
291
+ this.player.currentLocation = clinic;
292
+ this.player.cameFrom = undefined;
293
+ this.updateExits(this.player.currentLocation);
294
+ this.updateStatus();
295
+ logger.log(clinic === this._homeRoom
296
+ ? `You wake on your own cot, taped, stapled, and alive.`
297
+ : `You wake on a recovery cot under bad fluorescents -- ${clinic.name}. Taped, stapled, and alive.`);
298
+ this.requestSave('homecoming');
299
+ }
300
+ catch (err) {
301
+ logger.error(`docwagonMedevac failed: ${err}`);
302
+ }
303
+ }
155
304
  /** How much juice the name carries, read off the role. */
305
+ /**
306
+ * A contact's ROLE, the SR5e archetype list (p.98: fixer, arms dealer,
307
+ * smuggler, talismonger, street doc, mechanic, bartender...) -- same
308
+ * keyword school as connectionFor below. Stored on the contact entry
309
+ * at open time so the roster (and the save) carry it.
310
+ */
311
+ static roleOf(npc) {
312
+ const hay = `${npc.description ?? ''} ${npc.name}`.toLowerCase();
313
+ // Mr. Johnson is NOT the fixer (player ruling: don't overload his
314
+ // role). Canon splits them: a Johnson is the anonymous CLIENT who
315
+ // commissions the run; a fixer is the street middleman (scene1's
316
+ // Mr. Krow, e.g.). He gets his own archetype label.
317
+ if (npc.name === FIXER_NAME || /job broker|johnson/.test(hay))
318
+ return 'Job Broker';
319
+ if (/fixer/.test(hay))
320
+ return 'Fixer';
321
+ // "charms" deliberately absent: a market kid hawking LUCKY charms is
322
+ // a Vendor, not a talismonger (Skitter proved it).
323
+ if (/talismonger|magical goods|foci|spell formula/.test(hay))
324
+ return 'Talismonger';
325
+ if (/street doc|clinic|surgeon|medic|stitch|chop shop/.test(hay))
326
+ return 'Street Doc';
327
+ if (/fence|pawn|launder|no questions/.test(hay))
328
+ return 'Fence';
329
+ if (/smuggl/.test(hay))
330
+ return 'Smuggler';
331
+ if (/mechanic|drone|rigger|garage|airframe/.test(hay))
332
+ return 'Mechanic';
333
+ if (/gunsmith|weapons? (and armor|dealer)|armorer|ironmonger/.test(hay))
334
+ return 'Arms Dealer';
335
+ if (/bartender|barkeep|tends the .{0,40}bar|pour(s|ed) drinks/.test(hay))
336
+ return 'Bartender';
337
+ if (/decker|data broker|host|electronics/.test(hay))
338
+ return 'Data Broker';
339
+ if (/priest|sister|chapel|choir|shaman/.test(hay))
340
+ return 'Spiritual';
341
+ if (/noodle|food|cook|kitchen|vendor|stall|merchant|shopkeep|market/.test(hay))
342
+ return 'Vendor';
343
+ if (/gang|muscle|enforcer|lieutenant|sentry|lookout|bruiser/.test(hay))
344
+ return 'Muscle';
345
+ return 'Street Contact';
346
+ }
156
347
  static connectionFor(npc) {
157
348
  const hay = `${npc.description ?? ''} ${npc.name}`.toLowerCase();
158
349
  if (npc.name === FIXER_NAME || /fixer|johnson/.test(hay))
@@ -180,7 +371,7 @@ export default class Game {
180
371
  continue;
181
372
  if (this.contacts.has(actor.name))
182
373
  continue;
183
- this.contacts.set(actor.name, { connection: Game.connectionFor(actor), loyalty: 1, spent: 0, gigs: 0, asked: false });
374
+ this.contacts.set(actor.name, { connection: Game.connectionFor(actor), loyalty: 1, spent: 0, gigs: 0, asked: false, role: Game.roleOf(actor) });
184
375
  }
185
376
  Logger.getInstance().write(`Home turf: ${this.contacts.size} contacts seeded from the hub cast.`);
186
377
  }
@@ -419,6 +610,11 @@ export default class Game {
419
610
  * cumulative spend raises it at each full 1,000 nuyen (cap 3).
420
611
  * Returns a player-facing line when something changed, else null.
421
612
  */
613
+ /** A hub-cast member by name, for roster displays that need the live
614
+ * actor (contacts.ts reads their commlink to show reachability). */
615
+ hubActor(name) {
616
+ return this._hubScene?.allActors?.find(a => a.name === name && a !== this.player);
617
+ }
422
618
  touchContact(name, kind, spent = 0) {
423
619
  if (!this.hasHub)
424
620
  return null;
@@ -427,7 +623,7 @@ export default class Game {
427
623
  return null;
428
624
  let c = this.contacts.get(name);
429
625
  if (!c) {
430
- c = { connection: Game.connectionFor(npc), loyalty: 1, spent: 0, gigs: 0, asked: false };
626
+ c = { connection: Game.connectionFor(npc), loyalty: 1, spent: 0, gigs: 0, asked: false, role: Game.roleOf(npc) };
431
627
  this.contacts.set(name, c);
432
628
  if (kind === 'trade')
433
629
  c.spent += spent;
@@ -623,6 +819,18 @@ export default class Game {
623
819
  // "call Mr. Johnson" hears the offer, "travel" goes.
624
820
  _pendingRunSeed;
625
821
  _pendingRunTier;
822
+ // The explicit handshake (player ruling, commands/job-offer.ts):
823
+ // "accept" locks the offer -- rate final, haggle closed, decline
824
+ // refused -- until "call taxi" installs it. Reset wherever the
825
+ // pending seed changes hands.
826
+ pendingRunAccepted = false;
827
+ /** Trash the offer wholesale ("decline", and any future fixer snub). */
828
+ clearPendingRun() {
829
+ this._pendingRunSeed = undefined;
830
+ this._pendingRunTier = undefined;
831
+ this._pendingRide = undefined;
832
+ this.pendingRunAccepted = false;
833
+ }
626
834
  get pendingRun() {
627
835
  return this._pendingRunSeed;
628
836
  }
@@ -731,6 +939,22 @@ export default class Game {
731
939
  CommandFactory.registerCommand('reload', ReloadCommand);
732
940
  CommandFactory.registerCommand('subdue', SubdueCommand);
733
941
  // SUBDUING p.195 (see commands/grapple.ts): restrain without damage.
942
+ // Canon skill hooks (see SkillName in models/player.ts).
943
+ CommandFactory.registerCommand('palm', PalmCommand);
944
+ CommandFactory.registerCommand('perform', PerformCommand);
945
+ // LEADERSHIP (commands/lead.ts): rally/direct/inspire + Command recruiting.
946
+ CommandFactory.registerCommand('lead', LeadCommand);
947
+ CommandFactory.registerCommand('command', RecruitCommand);
948
+ // DOCWAGON (commands/docwagon.ts): the wristband between you and the morgue.
949
+ CommandFactory.registerCommand('docwagon', DocwagonCommand);
950
+ // SWAG (commands/source.ts): contacts shop the black market for you.
951
+ CommandFactory.registerCommand('source', SourceCommand);
952
+ CommandFactory.registerCommand('procure', SourceCommand);
953
+ // Job-offer outcomes (commands/job-offer.ts): explicit accept/decline.
954
+ CommandFactory.registerCommand('accept', AcceptCommand);
955
+ CommandFactory.registerCommand('decline', DeclineCommand);
956
+ CommandFactory.registerCommand('deny', DeclineCommand);
957
+ CommandFactory.registerCommand('refuse', DeclineCommand);
734
958
  CommandFactory.registerCommand('grapple', GrappleCommand);
735
959
  CommandFactory.registerCommand('restrain', GrappleCommand);
736
960
  CommandFactory.registerCommand('clinch', GrappleCommand);
@@ -982,6 +1206,39 @@ export default class Game {
982
1206
  this.player.lifestyleEdgeBonus = this.lifestyle.edgeBonus;
983
1207
  this._tier = save.game.tier;
984
1208
  this.contacts = new Map(save.game.contacts);
1209
+ // Roles are DERIVED, not earned -- re-derive them all on every
1210
+ // resume so old saves self-heal (pre-role saves read all "Street
1211
+ // Contact"; the next vintage had Johnson stored as "Fixer" before
1212
+ // the Job Broker split). Missing actors keep whatever the save said.
1213
+ for (const [name, c] of this.contacts) {
1214
+ const npc = this._hubScene?.allActors?.find(a => a.name === name);
1215
+ if (npc)
1216
+ c.role = Game.roleOf(npc);
1217
+ else if (!c.role)
1218
+ c.role = 'Street Contact';
1219
+ }
1220
+ // SEED-TRUTH for commlinks: the hub overlay replaces NPC inventories
1221
+ // wholesale, so a link granted to the CAST by a newer seed (player
1222
+ // request: Torque, Ratchet, Doc, Old Cray, Skitter, and Whisper all
1223
+ // answer calls now) would be wiped by an older save. Re-grant any
1224
+ // seed-held commlink its bearer no longer carries.
1225
+ for (const seedItem of this._hubSeed?.items ?? []) {
1226
+ if (seedItem.category !== 'Commlink' || !seedItem.heldBy)
1227
+ continue;
1228
+ const bearer = this._hubScene?.allActors?.find(a => a.name === seedItem.heldBy);
1229
+ if (!bearer)
1230
+ continue;
1231
+ const hasLink = bearer.inventory.getAllItems().some(i => i.category === Category.Commlink)
1232
+ || bearer.equipment.head?.commlink != null;
1233
+ if (!hasLink) {
1234
+ bearer.inventory.addItem(restoreItem(seedItem));
1235
+ Logger.getInstance().write(`Resume heal: re-granted "${seedItem.name}" to ${seedItem.heldBy}.`);
1236
+ }
1237
+ }
1238
+ this.pendingProcurements = [...(save.game.procurements ?? [])];
1239
+ this.docwagon = save.game.docwagon ? { ...save.game.docwagon } : undefined;
1240
+ this.enlistedAlly = save.game.enlistedAlly ? { ...save.game.enlistedAlly } : undefined;
1241
+ this.commandRefusals = { ...(save.game.commandRefusals ?? {}) };
985
1242
  this.fencedCategories = new Set(save.game.fencedCategories);
986
1243
  this._rumorIndex = save.game.rumorIndex;
987
1244
  this.activeRumor = save.game.activeRumor;
@@ -1022,6 +1279,15 @@ export default class Game {
1022
1279
  return;
1023
1280
  try {
1024
1281
  const save = this.buildSaveFile(reason);
1282
+ // Carry the cloud-sync stamp FORWARD through local writes: the
1283
+ // stamp means "the cloud's savedAt as of our last mirror", and a
1284
+ // new local beat doesn't change what the cloud holds. Without
1285
+ // this, every quit (whose async push can't outlive the process)
1286
+ // left a stampless file that the next launch misread as a FORK
1287
+ // and prompted over (a real session hit it on every reload).
1288
+ const prev = readSave(this._saveFilePath);
1289
+ if (prev.ok && prev.save.sync)
1290
+ save.sync = prev.save.sync;
1025
1291
  writeSaveAtomic(this._saveFilePath, save);
1026
1292
  logger.write(`Saved (${reason}) -> ${this._saveFilePath}`);
1027
1293
  // Local-first: the beat is DONE the moment the file lands. The
@@ -1053,6 +1319,10 @@ export default class Game {
1053
1319
  rentDebt: this.rentDebt,
1054
1320
  tier: this._tier,
1055
1321
  contacts: [...this.contacts.entries()],
1322
+ procurements: [...this.pendingProcurements],
1323
+ docwagon: this.docwagon ? { ...this.docwagon } : undefined,
1324
+ enlistedAlly: this.enlistedAlly ? { ...this.enlistedAlly } : undefined,
1325
+ commandRefusals: { ...this.commandRefusals },
1056
1326
  fencedCategories: [...this.fencedCategories],
1057
1327
  rumorIndex: this._rumorIndex,
1058
1328
  activeRumor: this.activeRumor,
@@ -1181,6 +1451,8 @@ export default class Game {
1181
1451
  // pull each per homecoming (see commands/ask.ts).
1182
1452
  for (const contact of this.contacts.values())
1183
1453
  contact.asked = false;
1454
+ // Fresh material for the buskers too (commands/perform.ts).
1455
+ this.performedThisCycle = false;
1184
1456
  // LIFESTYLE upkeep comes due with the homecoming: this tier's share
1185
1457
  // plus any standing debt, paid from carried cred as far as it goes.
1186
1458
  // Coming up short SLIDES YOU DOWN a tier (the district notices) and
@@ -1230,14 +1502,56 @@ export default class Game {
1230
1502
  // Drones spin back up beside the cot.
1231
1503
  const companionArrivalLines = this.unfoldCompanionsOnArrival();
1232
1504
  const companionBlock = [...companionFoldLines, ...companionArrivalLines];
1505
+ // The commanded ally's tour ends with the run (RAW's Command lasts
1506
+ // turns; ours lasts the job): the crew disbands at the curb.
1507
+ let allyLine = '';
1508
+ if (this.enlistedAlly) {
1509
+ allyLine = `\n${this.enlistedAlly.name} peels off at the curb -- "Call it even, chummer." The crew's back to just you.`;
1510
+ this.enlistedAlly = undefined;
1511
+ }
1512
+ // DOCWAGON's year ticks in homecomings (12 = a street year, player
1513
+ // ruling): the contract lapses quietly unless renewed at Doc's.
1514
+ let docwagonLine = '';
1515
+ if (this.docwagon) {
1516
+ this.docwagon.homecomingsLeft -= 1;
1517
+ if (this.docwagon.homecomingsLeft <= 0) {
1518
+ docwagonLine = `\nYour DocWagon ${this.docwagon.tier} contract LAPSES -- the wristband goes dark.${hint(` ("docwagon <tier>" at Doc's renews it.)`)}`;
1519
+ this.docwagon = undefined;
1520
+ }
1521
+ else if (this.docwagon.homecomingsLeft <= 2) {
1522
+ docwagonLine = `\nDocWagon renewal coming due: ${this.docwagon.homecomingsLeft} homecoming${this.docwagon.homecomingsLeft === 1 ? '' : 's'} left on the ${this.docwagon.tier} contract.`;
1523
+ }
1524
+ }
1525
+ // SWAG deliveries (commands/source.ts): the contact's shopping run
1526
+ // lands with the homecoming -- straight into your gear, paid for at
1527
+ // order time.
1528
+ let deliveryLine = '';
1529
+ if (this.pendingProcurements.length > 0) {
1530
+ const delivered = [];
1531
+ for (const order of this.pendingProcurements.splice(0)) {
1532
+ try {
1533
+ const item = restoreItem(order.itemConfig);
1534
+ item.owner = this.player.name;
1535
+ this.player.inventory.addItem(item);
1536
+ delivered.push(`${item.name} (${order.contact} came through)`);
1537
+ }
1538
+ catch (err) {
1539
+ Logger.getInstance().error(`Procurement delivery failed: ${err}`);
1540
+ }
1541
+ }
1542
+ if (delivered.length > 0) {
1543
+ this.updateInventory(this.player.inventory, capitalCase(this.scene.getCurrencyType()));
1544
+ deliveryLine = `\nWaiting with your gear: ${delivered.join(', ')}.`;
1545
+ }
1546
+ }
1233
1547
  // 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.
1548
+ // contacts reset, deliveries) settled first, so the save reflects
1549
+ // the hub as the player will find it.
1236
1550
  this.requestSave('homecoming');
1237
1551
  const fateLine = wrappedRun
1238
1552
  ? `The run's wrapped and the street swallows you back up.`
1239
1553
  : `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')}` : ''}`;
1554
+ 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
1555
  }
1242
1556
  /**
1243
1557
  * The actual drafting work, split out from continueToNextScene() purely
@@ -1282,6 +1596,7 @@ export default class Game {
1282
1596
  this._pendingRunSeed = nextSeed;
1283
1597
  this._pendingRunTier = nextTier;
1284
1598
  this._pendingRide = Game.RIDE_POOL[Math.floor(Math.random() * Game.RIDE_POOL.length)];
1599
+ this.pendingRunAccepted = false;
1285
1600
  return `Your link pings -- 1 new message: ${FIXER_NAME}.${hint(` ("call ${FIXER_NAME}" to hear the job.)`)}`;
1286
1601
  }
1287
1602
  // Classic sessions transition immediately, hideout-first, as always.
@@ -1335,11 +1650,13 @@ export default class Game {
1335
1650
  // Deployed drones spin back up at the curb -- BEFORE appearance
1336
1651
  // warming so re-shelled companions get warmed with the cast.
1337
1652
  const arrivalLines = this.unfoldCompanionsOnArrival();
1653
+ // A commanded ally meets you at the jobsite (commands/lead.ts).
1654
+ const allyLines = this.spawnEnlistedAlly();
1338
1655
  this.warmNpcAppearancesInBackground();
1339
1656
  this.refreshLogLabel();
1340
1657
  // No "=== JOB #n ===" banner on entry (player request) -- the Game
1341
1658
  // Log window's own title carries the job name now.
1342
- const companionBlock = [...companionLines, ...arrivalLines];
1659
+ const companionBlock = [...companionLines, ...arrivalLines, ...allyLines];
1343
1660
  return `\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
1344
1661
  }
1345
1662
  /**
@@ -1426,16 +1743,12 @@ export default class Game {
1426
1743
  const haggledAtOffer = this.player.haggledThisJob;
1427
1744
  try {
1428
1745
  const banner = await this.activateRunScene(seed, tier, { startInHideout: false });
1429
- this._pendingRunSeed = undefined;
1430
- this._pendingRunTier = undefined;
1431
- this._pendingRide = undefined;
1746
+ this.clearPendingRun();
1432
1747
  this.player.haggledThisJob = haggledAtOffer;
1433
1748
  return `You travel to ${this.player.currentLocation.name}.\n${banner}`;
1434
1749
  }
1435
1750
  catch (err) {
1436
- this._pendingRunSeed = undefined;
1437
- this._pendingRunTier = undefined;
1438
- this._pendingRide = undefined;
1751
+ this.clearPendingRun();
1439
1752
  Logger.getInstance().error(`launchPendingRun failed: ${err}`);
1440
1753
  return `The ride never shows and the job address goes dead. ${FIXER_NAME} will not enjoy hearing this -- call him for another.`;
1441
1754
  }