@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +10 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +8 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.d.ts +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js +86 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/docwagon.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +8 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +6 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.d.ts +32 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js +94 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/lead.d.ts +39 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +187 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/palm.d.ts +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +86 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/perform.d.ts +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js +48 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +13 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +7 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/source.d.ts +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/source.js +159 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/source.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +6 -3
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +79 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +483 -13
- package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +12 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.d.ts +21 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +56 -9
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +17 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +39 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +299 -60
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +230 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/types/save-file.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.d.ts +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +48 -4
- package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.d.ts +50 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js +232 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/cloud-saves.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +41 -9
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.d.ts +19 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/comm-style.js.map +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.d.ts +33 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/docwagon.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.d.ts +71 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +447 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -0
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +165 -18
- package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
- 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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|