@maka/maka-cli 5.9.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 (81) 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 +13 -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 +79 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/game.js +483 -13
  44. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +2 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +12 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +21 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +56 -9
  50. package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +10 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +10 -0
  53. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
  54. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +17 -0
  55. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +39 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +299 -60
  58. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +230 -0
  59. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.js +14 -0
  60. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.js.map +1 -0
  61. package/bundle/typescript/src/commands/game/sideQuest/ui.d.ts +6 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/ui.js +48 -4
  63. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.d.ts +50 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js +232 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js.map +1 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +3 -0
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +41 -9
  69. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.d.ts +19 -0
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js +13 -0
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js.map +1 -1
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.d.ts +33 -0
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js +44 -0
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js.map +1 -0
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.d.ts +71 -0
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +447 -0
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -0
  79. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +165 -18
  80. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  81. package/package.json +1 -1
@@ -4,6 +4,10 @@ import { Category } from './types/shared/item-enum.js';
4
4
  import { Item } from './models/item.js';
5
5
  import { Room } from './models/_index.js';
6
6
  import { Door } from './models/door.js';
7
+ import { SAVE_VERSION } from './types/save-file.js';
8
+ import { serializePlayer, restorePlayer, captureHubOverlay, applyHubOverlay, writeSaveAtomic, deleteSave, restoreItem, readSave, } from './utilities/persistence.js';
9
+ import { pushSave, burnCloudSaveFor } from './utilities/cloud-saves.js';
10
+ import { DOCWAGON_TIERS, RESUSC_FEE, DEATH_COMP } from './utilities/docwagon.js';
7
11
  import { isFence, LIFESTYLE_TIERS } from './utilities/commerce.js';
8
12
  import { rollPool, formatRoll } from './utilities/dice.js';
9
13
  import { Direction } from './types/shared/direction-enum.js';
@@ -35,6 +39,12 @@ import { RestCommand } from './commands/rest.js';
35
39
  import { ReloadCommand } from './commands/reload.js';
36
40
  import { SubdueCommand } from './commands/subdue.js';
37
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';
38
48
  import { EdgeCommand } from './commands/edge.js';
39
49
  import { SheetCommand } from './commands/sheet.js';
40
50
  import { CallCommand } from './commands/call.js';
@@ -149,7 +159,191 @@ export default class Game {
149
159
  // against the real pending-run seed. One pull per contact per
150
160
  // homecoming (asked resets in returnToHub).
151
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
+ }
152
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
+ }
153
347
  static connectionFor(npc) {
154
348
  const hay = `${npc.description ?? ''} ${npc.name}`.toLowerCase();
155
349
  if (npc.name === FIXER_NAME || /fixer|johnson/.test(hay))
@@ -177,7 +371,7 @@ export default class Game {
177
371
  continue;
178
372
  if (this.contacts.has(actor.name))
179
373
  continue;
180
- 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) });
181
375
  }
182
376
  Logger.getInstance().write(`Home turf: ${this.contacts.size} contacts seeded from the hub cast.`);
183
377
  }
@@ -198,6 +392,13 @@ export default class Game {
198
392
  // Drones deployed at travel time re-shell on arrival ("the rotors spin
199
393
  // back up at the curb") -- tracked as their items across the swap.
200
394
  _pendingRedeploys = [];
395
+ // ---------------- PERSISTENCE (see utilities/persistence.ts) ----------
396
+ // Character + hub world survive the process (player rulings): autosave
397
+ // at safe beats only, one slot per character, PERMADEATH on real death.
398
+ _saveFilePath; // from options.saveFilePath; unset = never save
399
+ _dead = false; // set in close(); suppresses any later save
400
+ _resume; // present when this session continues a save
401
+ _hubSeed; // the seed embedded into every save
201
402
  /**
202
403
  * The one correct runtime NPC spawn (mirrors scene-factory's seed
203
404
  * path): construct with a scene-bound registry, add to the scene
@@ -409,6 +610,11 @@ export default class Game {
409
610
  * cumulative spend raises it at each full 1,000 nuyen (cap 3).
410
611
  * Returns a player-facing line when something changed, else null.
411
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
+ }
412
618
  touchContact(name, kind, spent = 0) {
413
619
  if (!this.hasHub)
414
620
  return null;
@@ -417,7 +623,7 @@ export default class Game {
417
623
  return null;
418
624
  let c = this.contacts.get(name);
419
625
  if (!c) {
420
- 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) };
421
627
  this.contacts.set(name, c);
422
628
  if (kind === 'trade')
423
629
  c.spent += spent;
@@ -613,6 +819,18 @@ export default class Game {
613
819
  // "call Mr. Johnson" hears the offer, "travel" goes.
614
820
  _pendingRunSeed;
615
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
+ }
616
834
  get pendingRun() {
617
835
  return this._pendingRunSeed;
618
836
  }
@@ -721,6 +939,22 @@ export default class Game {
721
939
  CommandFactory.registerCommand('reload', ReloadCommand);
722
940
  CommandFactory.registerCommand('subdue', SubdueCommand);
723
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);
724
958
  CommandFactory.registerCommand('grapple', GrappleCommand);
725
959
  CommandFactory.registerCommand('restrain', GrappleCommand);
726
960
  CommandFactory.registerCommand('clinch', GrappleCommand);
@@ -849,6 +1083,7 @@ export default class Game {
849
1083
  async init(options, screen) {
850
1084
  this._useAi = options.useAi;
851
1085
  this._logFilePath = options.logFilePath;
1086
+ this._saveFilePath = options.saveFilePath;
852
1087
  this.initInputBox(screen);
853
1088
  this.initLogBox(screen);
854
1089
  this.initMetaBox(screen);
@@ -891,16 +1126,24 @@ export default class Game {
891
1126
  if (this.scene.isHub) {
892
1127
  this._hubScene = this.scene;
893
1128
  this._hubName = this.sceneSeed?.name ?? 'home';
1129
+ // Every save embeds this seed -- resume never depends on the
1130
+ // original scene file still existing or matching.
1131
+ this._hubSeed = this.sceneSeed;
894
1132
  // The street starts talking from minute one.
895
1133
  this._rumorPool = this.sceneSeed?.rumors ?? [];
896
1134
  this.advanceRumor();
897
1135
  // The economy wakes up with it: shelf blueprints for restocking,
898
1136
  // the first odd job on offer, and the lifestyle's edge perk.
1137
+ // snapshotVendorStock deliberately runs BEFORE any resume overlay:
1138
+ // the restock blueprint is the seed's PRISTINE shelves (config
1139
+ // copies), and restockVendorShelves only refills what the overlay
1140
+ // leaves empty.
899
1141
  this.snapshotVendorStock();
900
1142
  this._gigPool = this.sceneSeed?.gigs ?? [];
901
1143
  this.advanceGig();
902
1144
  this.player.lifestyleEdgeBonus = this.lifestyle.edgeBonus;
903
- // Home turf knows its own (see seedHomeTurfContacts).
1145
+ // Home turf knows its own (see seedHomeTurfContacts). On resume the
1146
+ // saved contacts overwrite these loyalty-1 seeds a few lines down.
904
1147
  this.seedHomeTurfContacts();
905
1148
  // The next run starts cooking the moment you're home (player
906
1149
  // request): generation takes 20-40s, and waiting for the player
@@ -908,7 +1151,9 @@ export default class Game {
908
1151
  // Mr. Johnson's "1 new message" ping lands mid-session, and the
909
1152
  // call finds work already waiting. The gate keeps this a no-op
910
1153
  // with AI off or a draft/pending seed somehow already in flight.
911
- if (this.canDraftNextScene().allowed) {
1154
+ // GATED on resume: a restored pendingRun must not be clobbered by
1155
+ // a fresh background draft landing 20-40s into the session.
1156
+ if (!this._resume && this.canDraftNextScene().allowed) {
912
1157
  this.draftNextSceneInBackground();
913
1158
  }
914
1159
  }
@@ -922,7 +1167,20 @@ export default class Game {
922
1167
  // The banner learns where we are only after the hub/scene bookkeeping
923
1168
  // above settles.
924
1169
  this.refreshLogLabel();
1170
+ // RESUME (see utilities/persistence.ts): lay the save over the
1171
+ // freshly-synthesized hub -- player sheet/gear, hub world diffs,
1172
+ // and the Game-level ledgers -- AFTER all the fresh-start seeding
1173
+ // above, so the save is authoritative everywhere they overlap.
1174
+ if (this._resume && this._hubScene) {
1175
+ this.applyResumeState(this._resume);
1176
+ }
925
1177
  this.initialized = true;
1178
+ if (this._resume) {
1179
+ const pendingNote = this._pendingRunSeed
1180
+ ? hint(`\n\n${FIXER_NAME}'s job is still on offer: "${this._pendingRunSeed.name}". "call taxi" when you're ready, or "call ${FIXER_NAME}" to hear it again.`)
1181
+ : '';
1182
+ return `Welcome back, ${this.player.name}. The cot remembers your shape.\n ${this.scene.story}${pendingNote}`;
1183
+ }
926
1184
  // Nudge the player toward "call" up front -- the fixer convention means
927
1185
  // the story deliberately doesn't narrate a completed briefing, so
928
1186
  // without this hint there'd be no in-game indication that "call" is how
@@ -933,6 +1191,152 @@ export default class Game {
933
1191
  : '';
934
1192
  return `Welcome, ${this.player.name}\n ${this.scene.story}${callHint}`;
935
1193
  }
1194
+ /**
1195
+ * Everything a save restores beyond the seed synthesis: the player,
1196
+ * the hub world overlay, and the Game-level ledgers. Ordering inside
1197
+ * matters less than its POSITION in init(): after seedHomeTurfContacts
1198
+ * and snapshotVendorStock (both deliberately pre-overlay).
1199
+ */
1200
+ applyResumeState(save) {
1201
+ restorePlayer(this.player, save.player);
1202
+ applyHubOverlay(this.scene, save.hub, this.player, this._homeRoom);
1203
+ this.lifestyleTier = save.game.lifestyleTier;
1204
+ this.rentDebt = save.game.rentDebt;
1205
+ this.syncHomeToLifestyle();
1206
+ this.player.lifestyleEdgeBonus = this.lifestyle.edgeBonus;
1207
+ this._tier = save.game.tier;
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 ?? {}) };
1242
+ this.fencedCategories = new Set(save.game.fencedCategories);
1243
+ this._rumorIndex = save.game.rumorIndex;
1244
+ this.activeRumor = save.game.activeRumor;
1245
+ this._gigPool = [...save.game.gigPool];
1246
+ this._gigIndex = save.game.gigIndex;
1247
+ this.gigAvailable = save.game.gigAvailable;
1248
+ this.activeGig = save.game.activeGig;
1249
+ if (save.game.pendingRun) {
1250
+ this._pendingRunSeed = save.game.pendingRun.seed;
1251
+ this._pendingRunTier = save.game.pendingRun.tier;
1252
+ this._pendingRide = save.game.pendingRun.ride;
1253
+ }
1254
+ this.player.currentLocation = this._homeRoom;
1255
+ this.updateEquipment(this.player.equipment);
1256
+ this.updateInventory(this.player.inventory, capitalCase(this.scene.getCurrencyType()));
1257
+ this.updateExits(this.player.currentLocation);
1258
+ this.updateStatus();
1259
+ this.refreshLogLabel();
1260
+ Logger.getInstance().write(`Resumed save for ${save.playerName} (saved ${save.savedAt}, reason ${save.reason}).`);
1261
+ }
1262
+ /**
1263
+ * THE single save routine -- every safe beat funnels here. Guards make
1264
+ * it a silent no-op anywhere a save would lie: mid-run (the "runs are
1265
+ * disposable" ruling lives in the scene === _hubScene check), downed
1266
+ * or KO'd, mid-exchange, dead, or before init finishes. Synchronous by
1267
+ * design so the process-exit quit hook can use it; failures log and
1268
+ * never crash a turn.
1269
+ */
1270
+ requestSave(reason) {
1271
+ const logger = Logger.getInstance();
1272
+ if (!this.initialized || this._dead || !this._saveFilePath)
1273
+ return;
1274
+ if (!this.hasHub || this.scene !== this._hubScene || !this._hubSeed)
1275
+ return;
1276
+ if (this.player.isDown() || this.player.isUnconscious())
1277
+ return;
1278
+ if (this.scene.isPlayerExchangeActive())
1279
+ return;
1280
+ try {
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;
1291
+ writeSaveAtomic(this._saveFilePath, save);
1292
+ logger.write(`Saved (${reason}) -> ${this._saveFilePath}`);
1293
+ // Local-first: the beat is DONE the moment the file lands. The
1294
+ // cloud mirror rides behind, fire-and-forget (see cloud-saves.ts
1295
+ // -- logged-out/offline/expired all degrade to a silent no-op).
1296
+ void pushSave(this._saveFilePath, save);
1297
+ }
1298
+ catch (err) {
1299
+ logger.error(`requestSave(${reason}) failed: ${err}`);
1300
+ }
1301
+ }
1302
+ buildSaveFile(reason) {
1303
+ return {
1304
+ version: SAVE_VERSION,
1305
+ savedAt: new Date().toISOString(),
1306
+ reason,
1307
+ playerName: this.player.name,
1308
+ summary: {
1309
+ archetypeName: this.player.archetypeName,
1310
+ karma: this.player.karma,
1311
+ currency: this.player.currency,
1312
+ lifestyleTier: this.lifestyleTier,
1313
+ hubName: this._hubName,
1314
+ },
1315
+ hubSeed: this._hubSeed,
1316
+ player: serializePlayer(this.player),
1317
+ game: {
1318
+ lifestyleTier: this.lifestyleTier,
1319
+ rentDebt: this.rentDebt,
1320
+ tier: this._tier,
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 },
1326
+ fencedCategories: [...this.fencedCategories],
1327
+ rumorIndex: this._rumorIndex,
1328
+ activeRumor: this.activeRumor,
1329
+ gigPool: [...this._gigPool],
1330
+ gigIndex: this._gigIndex,
1331
+ gigAvailable: this.gigAvailable,
1332
+ activeGig: this.activeGig,
1333
+ pendingRun: this._pendingRunSeed && this._pendingRunTier !== undefined
1334
+ ? { seed: this._pendingRunSeed, tier: this._pendingRunTier, ride: this._pendingRide }
1335
+ : undefined,
1336
+ },
1337
+ hub: captureHubOverlay(this._hubScene, this._hubSeed, this.player, this._homeRoom),
1338
+ };
1339
+ }
936
1340
  /**
937
1341
  * Synchronous readiness check for continueToNextScene() -- lets callers
938
1342
  * give instant feedback for the "not ready yet" cases (job not done, no
@@ -1047,6 +1451,8 @@ export default class Game {
1047
1451
  // pull each per homecoming (see commands/ask.ts).
1048
1452
  for (const contact of this.contacts.values())
1049
1453
  contact.asked = false;
1454
+ // Fresh material for the buskers too (commands/perform.ts).
1455
+ this.performedThisCycle = false;
1050
1456
  // LIFESTYLE upkeep comes due with the homecoming: this tier's share
1051
1457
  // plus any standing debt, paid from carried cred as far as it goes.
1052
1458
  // Coming up short SLIDES YOU DOWN a tier (the district notices) and
@@ -1096,10 +1502,56 @@ export default class Game {
1096
1502
  // Drones spin back up beside the cot.
1097
1503
  const companionArrivalLines = this.unfoldCompanionsOnArrival();
1098
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
+ }
1547
+ // The homecoming save: everything above (payout, rent, restock,
1548
+ // contacts reset, deliveries) settled first, so the save reflects
1549
+ // the hub as the player will find it.
1550
+ this.requestSave('homecoming');
1099
1551
  const fateLine = wrappedRun
1100
1552
  ? `The run's wrapped and the street swallows you back up.`
1101
1553
  : `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
1102
- return `\n=== ${this._hubName.toUpperCase()} ===\n${fateLine} You're home -- your hideout, one door off ${hub.determineStartRoom().name}.${hint(` Call ${FIXER_NAME} when you're hungry for the next job.`)}\n${rentLine}${comfortLine}${restockLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
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')}` : ''}`;
1103
1555
  }
1104
1556
  /**
1105
1557
  * The actual drafting work, split out from continueToNextScene() purely
@@ -1144,6 +1596,7 @@ export default class Game {
1144
1596
  this._pendingRunSeed = nextSeed;
1145
1597
  this._pendingRunTier = nextTier;
1146
1598
  this._pendingRide = Game.RIDE_POOL[Math.floor(Math.random() * Game.RIDE_POOL.length)];
1599
+ this.pendingRunAccepted = false;
1147
1600
  return `Your link pings -- 1 new message: ${FIXER_NAME}.${hint(` ("call ${FIXER_NAME}" to hear the job.)`)}`;
1148
1601
  }
1149
1602
  // Classic sessions transition immediately, hideout-first, as always.
@@ -1197,11 +1650,13 @@ export default class Game {
1197
1650
  // Deployed drones spin back up at the curb -- BEFORE appearance
1198
1651
  // warming so re-shelled companions get warmed with the cast.
1199
1652
  const arrivalLines = this.unfoldCompanionsOnArrival();
1653
+ // A commanded ally meets you at the jobsite (commands/lead.ts).
1654
+ const allyLines = this.spawnEnlistedAlly();
1200
1655
  this.warmNpcAppearancesInBackground();
1201
1656
  this.refreshLogLabel();
1202
1657
  // No "=== JOB #n ===" banner on entry (player request) -- the Game
1203
1658
  // Log window's own title carries the job name now.
1204
- const companionBlock = [...companionLines, ...arrivalLines];
1659
+ const companionBlock = [...companionLines, ...arrivalLines, ...allyLines];
1205
1660
  return `\n${this.scene.story}${needsLines.length > 0 ? `\n${needsLines.join('\n')}` : ''}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
1206
1661
  }
1207
1662
  /**
@@ -1282,24 +1737,38 @@ export default class Game {
1282
1737
  if (!seed || tier === undefined) {
1283
1738
  return `There's no job waiting. "call ${FIXER_NAME}" to drum one up.`;
1284
1739
  }
1740
+ // The departure save: hub state with the job still on offer -- what
1741
+ // a mid-run quit resumes to (the "runs are disposable" ruling).
1742
+ this.requestSave('job-accept');
1285
1743
  const haggledAtOffer = this.player.haggledThisJob;
1286
1744
  try {
1287
1745
  const banner = await this.activateRunScene(seed, tier, { startInHideout: false });
1288
- this._pendingRunSeed = undefined;
1289
- this._pendingRunTier = undefined;
1290
- this._pendingRide = undefined;
1746
+ this.clearPendingRun();
1291
1747
  this.player.haggledThisJob = haggledAtOffer;
1292
1748
  return `You travel to ${this.player.currentLocation.name}.\n${banner}`;
1293
1749
  }
1294
1750
  catch (err) {
1295
- this._pendingRunSeed = undefined;
1296
- this._pendingRunTier = undefined;
1297
- this._pendingRide = undefined;
1751
+ this.clearPendingRun();
1298
1752
  Logger.getInstance().error(`launchPendingRun failed: ${err}`);
1299
1753
  return `The ride never shows and the job address goes dead. ${FIXER_NAME} will not enjoy hearing this -- call him for another.`;
1300
1754
  }
1301
1755
  }
1302
1756
  async close() {
1757
+ // PERMADEATH (player ruling): close() is reachable ONLY via
1758
+ // Scene.endGame(), and endGame fires ONLY on real death (combat
1759
+ // resolveDeath, street-decides knockout, drain burnout, ward
1760
+ // backlash, stim crash) -- so this IS the one death choke point.
1761
+ // The save burns with the body; _dead also suppresses the quit
1762
+ // hook's save on the way out. Keep that invariant: any future
1763
+ // non-death caller of close() must set a flag before this line.
1764
+ this._dead = true;
1765
+ if (this._saveFilePath) {
1766
+ deleteSave(this._saveFilePath);
1767
+ // The cloud copy burns too -- tombstone-first, so an offline death
1768
+ // can't be resurrected from the mirror (see cloud-saves.ts).
1769
+ burnCloudSaveFor(this._saveFilePath, this.player.name);
1770
+ Logger.getInstance().write(`Permadeath: save deleted (${this._saveFilePath}).`);
1771
+ }
1303
1772
  this.stopPursuitHeartbeat();
1304
1773
  this._inputBox.removeAllListeners(); // removes all event listeners
1305
1774
  this._inputBox.cancel(); // cancels any active input session
@@ -1691,9 +2160,10 @@ export default class Game {
1691
2160
  this.updateStatus();
1692
2161
  }
1693
2162
  // Public method to get the instance of the Game class
1694
- static async getInstance(player, sceneSeed) {
2163
+ static async getInstance(player, sceneSeed, resume) {
1695
2164
  if (!Game.instance) {
1696
2165
  Game.instance = new Game(player, sceneSeed);
2166
+ Game.instance._resume = resume;
1697
2167
  }
1698
2168
  return Game.instance;
1699
2169
  }