@maka/maka-cli 5.184.0 → 5.186.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/commands/disable.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +132 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +110 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +23 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js +17 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +23 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/cleanup-seed.js +89 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.js +220 -6
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +21 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/host.js +24 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js +22 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-brain.js +35 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +107 -34
- package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +34 -0
- package/package.json +1 -1
|
@@ -9,7 +9,9 @@ import { hostLabel } from './utilities/grid-names.js';
|
|
|
9
9
|
import { marksPlaced, marksOnYou } from './utilities/marks.js';
|
|
10
10
|
import { settleUnresolvedHarm } from './utilities/harm-settlement.js';
|
|
11
11
|
import { hostInteriorPanel } from './utilities/grid-view.js';
|
|
12
|
-
import { DEFAULT_GRID_PROVIDER, PUBLIC_GRID, globalGrid } from './utilities/grids.js';
|
|
12
|
+
import { DEFAULT_GRID_PROVIDER, PUBLIC_GRID, globalGrid, looseEndGrid } from './utilities/grids.js';
|
|
13
|
+
import { buildCleanupSeed } from './factories/cleanup-seed.js';
|
|
14
|
+
import { EditFileCommand, EraseCommand } from './commands/edit-file.js';
|
|
13
15
|
import { hostsInReach, hostDockLine, gridVicinity } from './utilities/grid-reach.js';
|
|
14
16
|
import { nameThem, scrubHandles } from './utilities/identity.js';
|
|
15
17
|
import { dirname } from 'path';
|
|
@@ -149,7 +151,7 @@ import { MoveCommand, RunCommand, SprintCommand } from './commands/move.js';
|
|
|
149
151
|
import { ClimbCommand, DescendCommand } from './commands/climb.js';
|
|
150
152
|
import { HintsCommand } from './commands/hints.js';
|
|
151
153
|
import { hint } from './utilities/hints.js';
|
|
152
|
-
import { camerasLive } from './utilities/surveillance.js';
|
|
154
|
+
import { camerasLive, liveFootage } from './utilities/surveillance.js';
|
|
153
155
|
import { InstallCommand } from './commands/install.js';
|
|
154
156
|
import { BuyCommand } from './commands/buy.js';
|
|
155
157
|
import { SellCommand } from './commands/sell.js';
|
|
@@ -177,6 +179,7 @@ import { CounterspellCommand } from './commands/counterspell.js';
|
|
|
177
179
|
import { BreachCommand } from './commands/breach.js';
|
|
178
180
|
import { EmoteCommand } from './commands/emote.js';
|
|
179
181
|
import { OverwatchCommand } from './commands/overwatch.js';
|
|
182
|
+
import { HideCommand } from './commands/hide.js';
|
|
180
183
|
import { MarkCommand } from './commands/mark.js';
|
|
181
184
|
import { SnoopCommand } from './commands/snoop.js';
|
|
182
185
|
import { TapCommand } from './commands/tap.js';
|
|
@@ -522,6 +525,192 @@ export default class Game {
|
|
|
522
525
|
this._runHeat += billed;
|
|
523
526
|
Logger.getInstance().write(`Heat: +${billed}${billed !== amount ? ` (${amount} doubled on camera)` : ''} (${why}) -> ${this._runHeat}.`);
|
|
524
527
|
}
|
|
528
|
+
// ==================== LOOSE ENDS (2026-09-14) ====================
|
|
529
|
+
// What a run leaves behind that the runner can still go back and fix.
|
|
530
|
+
// The camera line above promised "that footage is already somewhere
|
|
531
|
+
// else" and nothing modelled where: a run ended, _camSeenRooms was
|
|
532
|
+
// cleared, and the promise evaporated. Now a run that ends with the
|
|
533
|
+
// feeds still live on an uncracked host leaves an ILooseEnd on the
|
|
534
|
+
// save; from the hub a Matrix-capable runner jacks in, "hop"s onto
|
|
535
|
+
// the site's grid (utilities/grids.ts looseEndGrid) and erases the
|
|
536
|
+
// file (commands/edit-file.ts) in a one-host scene entered and left
|
|
537
|
+
// through the Matrix alone (enterCleanup / onPersonaLeftMatrix). Left
|
|
538
|
+
// for LOOSE_END_GRACE full homecomings, the desk reviews the feeds and
|
|
539
|
+
// it converts to Public Awareness (p.368, "leaving significant
|
|
540
|
+
// physical evidence"). Infinity here is the "no consequence" switch.
|
|
541
|
+
_looseEnds = [];
|
|
542
|
+
static LOOSE_END_GRACE = 1;
|
|
543
|
+
/** The cleanup in progress: which record, where the body is slumped,
|
|
544
|
+
* the Matrix companions carried across, and the hub to hand back. */
|
|
545
|
+
_cleanup;
|
|
546
|
+
get looseEnds() {
|
|
547
|
+
return this._looseEnds ?? [];
|
|
548
|
+
}
|
|
549
|
+
get inCleanup() {
|
|
550
|
+
return this._cleanup !== undefined;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* THE FOOTAGE THIS RUN LEAVES BEHIND, read BEFORE the scene swap that
|
|
554
|
+
* discards the run (returnToHub), or off the live handle by the site
|
|
555
|
+
* before dispose (run-summary.ts). Undefined when no camera saw
|
|
556
|
+
* anything, when the feeds died before the end (host cracked, cluster
|
|
557
|
+
* looped or bricked), or when there is no host to hold them.
|
|
558
|
+
*/
|
|
559
|
+
footageLeft() {
|
|
560
|
+
if (!this.onRun || this.sceneSeed?.cleanup)
|
|
561
|
+
return undefined;
|
|
562
|
+
const found = liveFootage(this.scene.getRooms(), this._camSeenRooms);
|
|
563
|
+
if (!found)
|
|
564
|
+
return undefined;
|
|
565
|
+
const host = found.host;
|
|
566
|
+
const site = host.name;
|
|
567
|
+
return {
|
|
568
|
+
id: `${site.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}-${Date.now()}`,
|
|
569
|
+
kind: 'footage',
|
|
570
|
+
jobName: this.currentJobName ?? 'a job',
|
|
571
|
+
site,
|
|
572
|
+
host: { room: host.name, rating: host.hostRating, purpose: host.host?.purpose, sculpt: host.host?.sculpt },
|
|
573
|
+
rooms: found.rooms.map(r => r.name),
|
|
574
|
+
capturedAt: new Date().toISOString(),
|
|
575
|
+
homecomingsLeft: Game.LOOSE_END_GRACE,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* EVERY FULL HOMECOMING (returnToHub, applyHostedHomecoming): the open
|
|
580
|
+
* records lose a grace point; expired ones become the Public Awareness
|
|
581
|
+
* incident they were always going to be; tonight's fresh one is
|
|
582
|
+
* appended untouched. A cleanup's own exit never comes through here.
|
|
583
|
+
*/
|
|
584
|
+
settleLooseEnds(fresh) {
|
|
585
|
+
const lines = [];
|
|
586
|
+
const kept = [];
|
|
587
|
+
// `?? []`: hosted-homecoming.test.ts drives a Game off its prototype,
|
|
588
|
+
// where field initialisers never ran.
|
|
589
|
+
for (const le of this._looseEnds ?? []) {
|
|
590
|
+
const left = le.homecomingsLeft - 1;
|
|
591
|
+
if (left > 0) {
|
|
592
|
+
kept.push({ ...le, homecomingsLeft: left });
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const stained = this.player.addPublicAwareness(`caught on the cameras at "${le.site}" during "${le.jobName}"`);
|
|
596
|
+
Logger.getInstance().write(`Loose end "${le.id}" expired: ${le.site}'s desk reviewed the feeds (awareness ${stained ? '+1' : 'already counted'}).`);
|
|
597
|
+
lines.push(`\n{red-fg}${le.site}'s security desk finally reviews the night's feeds from "${le.jobName}". A still of your face goes out on the district's channels.{/red-fg}${stained ? ` (+1 Public Awareness, now ${this.player.publicAwareness})` : ''}`);
|
|
598
|
+
}
|
|
599
|
+
this._looseEnds = kept;
|
|
600
|
+
if (fresh) {
|
|
601
|
+
this._looseEnds.push(fresh);
|
|
602
|
+
Logger.getInstance().write(`Loose end "${fresh.id}" left: ${fresh.site} kept footage of "${fresh.jobName}" (${fresh.rooms.join(', ')}).`);
|
|
603
|
+
lines.push(`\n{yellow-fg}A camera at ${fresh.site} still has your face from tonight -- the footage sits on the site's own host, unreviewed.{/yellow-fg}${hint(` ("jack in", then "hop ${fresh.site}" -- erase it before the desk finds it.)`)}`);
|
|
604
|
+
}
|
|
605
|
+
return lines;
|
|
606
|
+
}
|
|
607
|
+
/** The grids "hop" lists for the open loose ends -- from the hub only. */
|
|
608
|
+
looseEndGrids() {
|
|
609
|
+
if (!this.hasHub || this.scene !== this._hubScene)
|
|
610
|
+
return [];
|
|
611
|
+
return this._looseEnds.map(le => ({ grid: looseEndGrid(le), looseEnd: le }));
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* THE HOP LANDS (commands/hop.ts): the persona is on the site's grid,
|
|
615
|
+
* so the site's host is what hangs overhead now. The body never
|
|
616
|
+
* moves -- its hub room joins the cleanup scene so the persona has a
|
|
617
|
+
* vicinity (grid-reach.ts) and the jack-out has somewhere to wake up
|
|
618
|
+
* -- and nothing that a job's arrival does happens here: no crew at
|
|
619
|
+
* the curb, no departure needs, no companion fold, no meet. The seed
|
|
620
|
+
* is deterministic (factories/cleanup-seed.ts) and disposable: it is
|
|
621
|
+
* rebuilt on every hop and dropped on every jack-out.
|
|
622
|
+
*/
|
|
623
|
+
async enterCleanup(grid) {
|
|
624
|
+
const hub = this._hubScene;
|
|
625
|
+
const entry = this.looseEndGrids().find(g => g.grid.key === grid.key);
|
|
626
|
+
if (!hub || !entry || this._cleanup)
|
|
627
|
+
return [];
|
|
628
|
+
const le = entry.looseEnd;
|
|
629
|
+
const body = this.player.bodyRoom ?? this.player.currentLocation;
|
|
630
|
+
const seed = buildCleanupSeed(le, this.player.name);
|
|
631
|
+
const scene = await SceneSynthesizer.synthesizeFromJson(seed, this.player, false, false);
|
|
632
|
+
scene.localGrid = grid;
|
|
633
|
+
scene.addRoom(body);
|
|
634
|
+
scene.adopt(this);
|
|
635
|
+
const carried = this.carryPersonasAcross(hub, scene, body);
|
|
636
|
+
this._cleanup = { id: le.id, body, carried, hub };
|
|
637
|
+
this.sceneSeed = seed;
|
|
638
|
+
this.scene = scene;
|
|
639
|
+
this.resolveHostGrids();
|
|
640
|
+
// Cold, like any run: the cleanup's own heat is the Matrix's price
|
|
641
|
+
// (Overwatch, convergence) and is dropped on the way out.
|
|
642
|
+
this._runHeat = 0;
|
|
643
|
+
this._camSeenRooms.clear();
|
|
644
|
+
this.camPingedRooms.clear();
|
|
645
|
+
this.sweptRooms.clear();
|
|
646
|
+
this.runStartedAt = worldNow();
|
|
647
|
+
this.currentClient = undefined;
|
|
648
|
+
this.forfeitArmed = false;
|
|
649
|
+
this.repaintForScene();
|
|
650
|
+
this.refreshLogLabel();
|
|
651
|
+
this.onSceneChanged?.();
|
|
652
|
+
Logger.getInstance().write(`Loose end "${le.id}" entered: ${seed.name} (host ${le.host.room} HR ${le.host.rating}, body in ${body.name}).`);
|
|
653
|
+
return [
|
|
654
|
+
`\n${scene.story}`,
|
|
655
|
+
hint(`("hack" the host, "enter" it, "search" its archive, then "erase" the file. "jack out" is the only way home.)`),
|
|
656
|
+
];
|
|
657
|
+
}
|
|
658
|
+
/** Sprites and agents ride with the persona; drones and meat companions
|
|
659
|
+
* stay wherever the body is. Reversed by leaveCleanup. */
|
|
660
|
+
carryPersonasAcross(from, to, body) {
|
|
661
|
+
const moved = [];
|
|
662
|
+
for (const c of this.companions) {
|
|
663
|
+
if (c.npc.plane !== 'matrix')
|
|
664
|
+
continue;
|
|
665
|
+
from.removeActorQuietly(c.npc.name);
|
|
666
|
+
to.addActor(c.npc);
|
|
667
|
+
to.setCharacterLocation(c.npc, body.name);
|
|
668
|
+
moved.push(c.npc);
|
|
669
|
+
}
|
|
670
|
+
return moved;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* EVERY WAY OUT OF THE MATRIX comes through planes.ts leaveMatrix, and
|
|
674
|
+
* this is the hook it calls: a clean jack-out, a torn link-lock, a
|
|
675
|
+
* bricked deck, Scramble, GOD's convergence. If a cleanup was live,
|
|
676
|
+
* the hub takes the scene back here.
|
|
677
|
+
*/
|
|
678
|
+
onPersonaLeftMatrix(actor) {
|
|
679
|
+
if (!this._cleanup || actor !== this.player)
|
|
680
|
+
return [];
|
|
681
|
+
return this.leaveCleanup();
|
|
682
|
+
}
|
|
683
|
+
leaveCleanup() {
|
|
684
|
+
const c = this._cleanup;
|
|
685
|
+
const cleanup = this.scene;
|
|
686
|
+
const erased = cleanup.isCompleted();
|
|
687
|
+
const le = this._looseEnds.find(l => l.id === c.id);
|
|
688
|
+
if (erased)
|
|
689
|
+
this._looseEnds = this._looseEnds.filter(l => l.id !== c.id);
|
|
690
|
+
for (const npc of c.carried) {
|
|
691
|
+
cleanup.removeActorQuietly(npc.name);
|
|
692
|
+
c.hub.addActor(npc);
|
|
693
|
+
c.hub.setCharacterLocation(npc, c.body.name);
|
|
694
|
+
}
|
|
695
|
+
this._cleanup = undefined;
|
|
696
|
+
this.scene = c.hub;
|
|
697
|
+
this.sceneSeed = this._hubSeed ?? this.sceneSeed;
|
|
698
|
+
this.runStartedAt = undefined;
|
|
699
|
+
// No ride home, no checkpoint: the cleanup's heat is dropped. GOD's
|
|
700
|
+
// own stain (overwatch.ts addNotoriety) already landed if it did.
|
|
701
|
+
this._runHeat = 0;
|
|
702
|
+
this._camSeenRooms.clear();
|
|
703
|
+
this.camPingedRooms.clear();
|
|
704
|
+
this.sweptRooms.clear();
|
|
705
|
+
this.repaintForScene();
|
|
706
|
+
this.refreshLogLabel();
|
|
707
|
+
this.onSceneChanged?.();
|
|
708
|
+
Logger.getInstance().write(`Loose end "${c.id}" left: ${erased ? 'ERASED' : 'still open'}; hub is the scene again.`);
|
|
709
|
+
this.requestSave('cleanup');
|
|
710
|
+
return [erased
|
|
711
|
+
? `\n{lightblue-fg}The grid closes behind you. ${le?.site ?? 'The site'}'s feeds run clean -- whatever they had of you is gone.{/lightblue-fg}`
|
|
712
|
+
: `\n{yellow-fg}The grid closes behind you. The footage is still up there.{/yellow-fg}${hint(` (Jack in and "hop" again while the desk hasn't reviewed it.)`)}`];
|
|
713
|
+
}
|
|
525
714
|
/** The HUD's read on the district (bands, never the number). */
|
|
526
715
|
heatBand() {
|
|
527
716
|
if (this._runHeat <= 0)
|
|
@@ -2746,6 +2935,13 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
2746
2935
|
// GOD's tally ("os" for the tabletop shorthand).
|
|
2747
2936
|
CommandFactory.registerCommand('overwatch', OverwatchCommand);
|
|
2748
2937
|
CommandFactory.registerCommand('os', OverwatchCommand);
|
|
2938
|
+
// Hide (SR5 p.240): drop off a host that has spotted you.
|
|
2939
|
+
CommandFactory.registerCommand('hide', HideCommand);
|
|
2940
|
+
// Edit File (SR5 p.239): "edit delete <file>", and the delete form
|
|
2941
|
+
// by its own names -- the loose-end cleanup's verb.
|
|
2942
|
+
CommandFactory.registerCommand('edit', EditFileCommand);
|
|
2943
|
+
CommandFactory.registerCommand('erase', EraseCommand);
|
|
2944
|
+
CommandFactory.registerCommand('wipe', EraseCommand);
|
|
2749
2945
|
// MAtrix Recognition Keys (canon p.235-236).
|
|
2750
2946
|
CommandFactory.registerCommand('mark', MarkCommand);
|
|
2751
2947
|
// Camera feeds you own (surveillance).
|
|
@@ -3131,7 +3327,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3131
3327
|
const where = this._resumedAt && this._resumedAt !== this._homeRoom
|
|
3132
3328
|
? `You're back at the ${this._resumedAt.name}${this.player.atSpot ? `, by the ${this.player.atSpot}` : ''} -- right where you left off.`
|
|
3133
3329
|
: 'The cot remembers your shape.';
|
|
3134
|
-
|
|
3330
|
+
const looseNote = this._looseEnds.length > 0
|
|
3331
|
+
? `\n\n{yellow-fg}${this._looseEnds.map(le => `${le.site} still has your face from "${le.jobName}".`).join(' ')}{/yellow-fg}${hint(` ("jack in", then "hop" -- the site's grid is listed while the footage is still up there.)`)}`
|
|
3332
|
+
: '';
|
|
3333
|
+
return `Welcome back, ${this.player.name}. ${where}\n ${this.scene.story}${pendingNote}${looseNote}`;
|
|
3135
3334
|
}
|
|
3136
3335
|
// Nudge the player toward "call" up front -- the fixer convention means
|
|
3137
3336
|
// the story deliberately doesn't narrate a completed briefing, so
|
|
@@ -3255,6 +3454,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3255
3454
|
applyHubOverlay(this.scene, save.hub, this.player, this._homeRoom);
|
|
3256
3455
|
this._tier = save.game.tier;
|
|
3257
3456
|
this._pendingHomecoming = save.game.pendingHomecoming;
|
|
3457
|
+
this._looseEnds = (save.game.looseEnds ?? []).map(l => ({ ...l }));
|
|
3258
3458
|
this.contacts = new Map(save.game.contacts);
|
|
3259
3459
|
// Roles are DERIVED, not earned -- re-derive them all on every
|
|
3260
3460
|
// resume so old saves self-heal (pre-role saves read all "Street
|
|
@@ -3572,6 +3772,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3572
3772
|
})(),
|
|
3573
3773
|
crew: this.crew.map(m => ({ ...m, gear: [...m.gear] })),
|
|
3574
3774
|
pendingHomecoming: this._pendingHomecoming,
|
|
3775
|
+
looseEnds: this._looseEnds.length > 0 ? this._looseEnds.map(l => ({ ...l })) : undefined,
|
|
3575
3776
|
commandRefusals: { ...this.commandRefusals },
|
|
3576
3777
|
fencedCategories: [...this.fencedCategories],
|
|
3577
3778
|
rumorIndex: this._rumorIndex,
|
|
@@ -3667,6 +3868,13 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3667
3868
|
// will carry a runner away from a job gone sideways (see call.ts) --
|
|
3668
3869
|
// walking away just means the payout never landed. Classic sessions
|
|
3669
3870
|
// skip this and keep the old continue->draft loop.
|
|
3871
|
+
// A LOOSE-END CLEANUP IS NOT A RUN TO COME HOME FROM: there is no
|
|
3872
|
+
// ride, and the homecoming ritual (rent, restock, the checkpoint)
|
|
3873
|
+
// must not run over a scene the persona merely hopped into. The
|
|
3874
|
+
// only door is the Matrix's (onPersonaLeftMatrix).
|
|
3875
|
+
if (this._cleanup) {
|
|
3876
|
+
return `You're jacked in on a loose end -- "jack out" is what ends it.`;
|
|
3877
|
+
}
|
|
3670
3878
|
if (this._hubScene && this.scene !== this._hubScene) {
|
|
3671
3879
|
return this.returnToHub();
|
|
3672
3880
|
}
|
|
@@ -3877,6 +4085,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3877
4085
|
// saying canon does not do that, and spending the consequence
|
|
3878
4086
|
// somewhere the books actually put it.
|
|
3879
4087
|
const loudRun = this.scene !== hub && this.scene.alarmRaised;
|
|
4088
|
+
// THE FOOTAGE LEFT BEHIND (loose ends, 2026-09-14) -- read here for
|
|
4089
|
+
// the same reason, before the run's rooms are gone.
|
|
4090
|
+
const footage = this.scene !== hub ? this.footageLeft() : undefined;
|
|
3880
4091
|
const awarenessLine = loudRun && this.player.addPublicAwareness(`left "${this.currentJobName ?? 'a job'}" screaming`)
|
|
3881
4092
|
? `\nYou left the place howling behind you. Somebody filed a report, somebody kept a still -- your face is a little more public than it was this morning. (+1 Public Awareness, now ${this.player.publicAwareness})`
|
|
3882
4093
|
: '';
|
|
@@ -3972,6 +4183,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3972
4183
|
const allyLine = crewLines.length > 0 ? `\n${crewLines.join('\n')}` : '';
|
|
3973
4184
|
const docwagonLine = this.tickDocwagonHomecoming();
|
|
3974
4185
|
const deliveryLine = this.deliverProcurements();
|
|
4186
|
+
// Loose ends: last night's expire, tonight's is filed.
|
|
4187
|
+
const looseEndLines = this.settleLooseEnds(footage);
|
|
3975
4188
|
// The night catches up on the ride (SINs & getting caught): a hot
|
|
3976
4189
|
// run means the wire between there and home. Settled AFTER the
|
|
3977
4190
|
// payout (the fee comes out of tonight's take) and BEFORE the save
|
|
@@ -3984,7 +4197,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3984
4197
|
const fateLine = wrappedRun
|
|
3985
4198
|
? `The run's wrapped and the street swallows you back up.`
|
|
3986
4199
|
: `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
|
|
3987
|
-
return `${checkpointBlock ? `\n${checkpointBlock}\n` : ''}\n${this._hubName.toUpperCase()}\n${fateLine} You're home -- your hideout, one door off ${hub.determineStartRoom().name}.${hint(` Call ${FIXER_NAME} when you're hungry for the next job.`)}${clientLine}${awarenessLine}\n${rentLine}${comfortLine}${restockLine}${deliveryLine}${docwagonLine}${allyLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
|
|
4200
|
+
return `${checkpointBlock ? `\n${checkpointBlock}\n` : ''}\n${this._hubName.toUpperCase()}\n${fateLine} You're home -- your hideout, one door off ${hub.determineStartRoom().name}.${hint(` Call ${FIXER_NAME} when you're hungry for the next job.`)}${clientLine}${awarenessLine}${looseEndLines.join('')}\n${rentLine}${comfortLine}${restockLine}${deliveryLine}${docwagonLine}${allyLine}${companionBlock.length > 0 ? `\n${companionBlock.join('\n')}` : ''}`;
|
|
3988
4201
|
}
|
|
3989
4202
|
/** The rent, the slide, the tab, and the High-lifestyle shower --
|
|
3990
4203
|
* homecoming's money half, for the solo ride home and the hosted
|
|
@@ -4118,6 +4331,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
4118
4331
|
this.rollHireCandidates();
|
|
4119
4332
|
const docwagonLine = this.tickDocwagonHomecoming();
|
|
4120
4333
|
const deliveryLine = this.deliverProcurements();
|
|
4334
|
+
const looseEndLines = this.settleLooseEnds(owed.footage);
|
|
4121
4335
|
Logger.getInstance().write(`Hosted homecoming applied (${owed.outcome}, ended ${owed.endedAt}): rent ${this.rentDebt} owed, lifestyle ${this.lifestyleTier}.`);
|
|
4122
4336
|
this.requestSave('homecoming');
|
|
4123
4337
|
const fateLine = owed.outcome === 'wrapped'
|
|
@@ -4125,7 +4339,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
4125
4339
|
: owed.outcome === 'interrupted'
|
|
4126
4340
|
? `The run was lost to the static -- the street doesn't ask.`
|
|
4127
4341
|
: `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
|
|
4128
|
-
return `${fateLine} The table's settled; home takes its cut.${clientLine}${awarenessLine}\n${rentLine}${comfortLine}${restockLine}${deliveryLine}${docwagonLine}`;
|
|
4342
|
+
return `${fateLine} The table's settled; home takes its cut.${clientLine}${awarenessLine}${looseEndLines.join('')}\n${rentLine}${comfortLine}${restockLine}${deliveryLine}${docwagonLine}`;
|
|
4129
4343
|
}
|
|
4130
4344
|
/**
|
|
4131
4345
|
* KNIGHT ERRANT CHECKPOINT (SINs & getting caught, ruling 2026-08-24;
|
|
@@ -6278,7 +6492,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
6278
6492
|
// players" / "crew street"); the bare verb still answers quietly.
|
|
6279
6493
|
{ title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
|
|
6280
6494
|
{ title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
|
|
6281
|
-
{ title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
|
|
6495
|
+
{ title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
|
|
6282
6496
|
// The Emerged get their own shelf (player request: "there MUST be
|
|
6283
6497
|
// resonance" -- matrix is the place, Resonance is the talent).
|
|
6284
6498
|
// `sustain` shelves here and not under magic: the command is
|
|
@@ -289,4 +289,25 @@ export function markHostOver(session, actorName, marks = 1) {
|
|
|
289
289
|
throw new Error('markHostOver: this scene has no host');
|
|
290
290
|
host.hostMarksBy.set(actorName, marks);
|
|
291
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* THE HOST HAS MADE THIS PERSONA -- test setup, not a game move.
|
|
294
|
+
*
|
|
295
|
+
* Since 2026-09-14 an alerted host hunts only what it has SPOTTED
|
|
296
|
+
* (models/host.ts `spotted`, utilities/ic.ts hostIsHunting); alert alone
|
|
297
|
+
* makes it look, every action. A fixture that wants the ice already on a
|
|
298
|
+
* persona states both facts here instead of hoping a Patrol roll lands.
|
|
299
|
+
*/
|
|
300
|
+
export function spotPersona(session, actorName) {
|
|
301
|
+
const scene = session.game.scene;
|
|
302
|
+
const actor = scene.getPlayerByName(actorName);
|
|
303
|
+
if (!actor)
|
|
304
|
+
throw new Error(`spotPersona: no actor called ${actorName}`);
|
|
305
|
+
const rooms = Object.values(scene.getRooms());
|
|
306
|
+
const here = actor.hostInside?.over.name;
|
|
307
|
+
const host = rooms.find(r => r.hasNode && r.name === here) ?? rooms.find(r => r.hasNode);
|
|
308
|
+
if (!host?.host)
|
|
309
|
+
throw new Error('spotPersona: this scene has no host');
|
|
310
|
+
host.host.alert = true;
|
|
311
|
+
host.host.spotted.add(actorName);
|
|
312
|
+
}
|
|
292
313
|
//# sourceMappingURL=headless-harness.js.map
|
|
@@ -609,6 +609,7 @@ export async function createHeadlessHub(opts) {
|
|
|
609
609
|
save: (reason) => game.snapshotSave(reason),
|
|
610
610
|
flush: (reason) => runInSession(ctx, () => game.flushBoundSave(reason)),
|
|
611
611
|
inRun: () => !game.onHubScene,
|
|
612
|
+
inSession: fn => runInSession(ctx, fn),
|
|
612
613
|
};
|
|
613
614
|
});
|
|
614
615
|
}
|
|
@@ -45,8 +45,31 @@ export class Host {
|
|
|
45
45
|
* this host's files: the archive is listed for them from then on. A
|
|
46
46
|
* cracked host lists it for everyone. Session-only. */
|
|
47
47
|
searchedBy = new Set();
|
|
48
|
-
/** Walls up: the host
|
|
48
|
+
/** Walls up: the host knows it has an intruder (a blown Sleaze, a
|
|
49
|
+
* successful Attack, a Patrol sighting) and its Patrol is looking
|
|
50
|
+
* every action (Data Trails p.86). Never clears on its own -- canon
|
|
51
|
+
* prints no stand-down. */
|
|
49
52
|
alert = false;
|
|
53
|
+
/**
|
|
54
|
+
* PERSONAS THE HOST CAN SEE (2026-09-14, "constantly in combat" report).
|
|
55
|
+
*
|
|
56
|
+
* Alert and spotted are two different facts, and canon keeps them
|
|
57
|
+
* apart: a successful Attack action makes the target "aware that it is
|
|
58
|
+
* under attack ... but it doesn't automatically spot you" (p.236), while
|
|
59
|
+
* a blown Sleaze hands the target a free mark on you, which is being
|
|
60
|
+
* seen (p.236). A host and its IC share spotting (p.247), so one set on
|
|
61
|
+
* the host is every program's eyes. Ice hunts what the host can SEE
|
|
62
|
+
* (utilities/ic.ts hostIsHunting); an alerted host that cannot see you
|
|
63
|
+
* yet sweeps for you every action instead (ic.ts ambientPatrol, the
|
|
64
|
+
* focused branch). Cleared by a successful Hide (p.240) and by the
|
|
65
|
+
* persona's reboot (utilities/marks.ts clearMarksOn). Session-only.
|
|
66
|
+
*/
|
|
67
|
+
spotted = new Set();
|
|
68
|
+
/** Personas that just Hid (p.240): the focused sweep lets ONE beat pass
|
|
69
|
+
* before it looks again -- "the target must perform a new Matrix
|
|
70
|
+
* Perception action", and the hide was this beat's action, not the
|
|
71
|
+
* host's. Consumed by ic.ts ambientPatrol. Session-only. */
|
|
72
|
+
justHid = new Set();
|
|
50
73
|
/** IC running now, by kind name. Names, not actors -- until increment 5. */
|
|
51
74
|
runningIC = [];
|
|
52
75
|
/** The running IC as actors, by kind (utilities/ic-actors.ts). Empty where no scene could host them. */
|
|
@@ -104,6 +104,19 @@ export class Scene extends AbstractScene {
|
|
|
104
104
|
this.addActor(this.player);
|
|
105
105
|
this.game = game;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* ADOPT A PLAYER WHOSE BODY DOES NOT MOVE (Game.enterCleanup, 2026-09-14).
|
|
109
|
+
* initialize() seats the player at the seed's start room, which for a
|
|
110
|
+
* run is the arrival. A loose-end cleanup is entered from the Matrix:
|
|
111
|
+
* the body stays slumped wherever it jacked in (that room is added to
|
|
112
|
+
* this scene by the caller), so the seat must not change -- the
|
|
113
|
+
* currentLocation setter would clear the body's spot and fire a
|
|
114
|
+
* "move" save beat for a move that never happened.
|
|
115
|
+
*/
|
|
116
|
+
adopt(game) {
|
|
117
|
+
this.addActor(this.player);
|
|
118
|
+
this.game = game;
|
|
119
|
+
}
|
|
107
120
|
updateExits(room) {
|
|
108
121
|
this.game.updateExits(room);
|
|
109
122
|
}
|
|
@@ -979,6 +992,36 @@ export class Scene extends AbstractScene {
|
|
|
979
992
|
this.logger.logWithColor(banner, 'green');
|
|
980
993
|
return banner;
|
|
981
994
|
}
|
|
995
|
+
/**
|
|
996
|
+
* THE ERASE OBJECTIVE (loose ends, 2026-09-14): met when the named file
|
|
997
|
+
* is DELETED by Edit File (commands/edit-file.ts) -- a state change on
|
|
998
|
+
* something you leave behind, which is why it is not a possession
|
|
999
|
+
* check. No payout ever: nobody hired the runner for this. Karma is a
|
|
1000
|
+
* flat 1 -- a loose end tied off is worth a point (p.375's "survived"
|
|
1001
|
+
* floor), and there is no stake to scale it by. Returns null when this
|
|
1002
|
+
* is not the objective or it is already met.
|
|
1003
|
+
*/
|
|
1004
|
+
checkErased(itemName, byName) {
|
|
1005
|
+
const condition = this._winCondition;
|
|
1006
|
+
if (!condition || this._completed || condition.type !== 'erase')
|
|
1007
|
+
return null;
|
|
1008
|
+
if (condition.item.toLowerCase() !== itemName.toLowerCase())
|
|
1009
|
+
return null;
|
|
1010
|
+
this._completed = true;
|
|
1011
|
+
this.addWorldEvent(`${byName} erased ${itemName} -- the loose end is tied off.`);
|
|
1012
|
+
this.logger.write(`Scene erase objective met: "${byName}" erased "${itemName}".`);
|
|
1013
|
+
const karmaEarned = 1;
|
|
1014
|
+
for (const p of this.players) {
|
|
1015
|
+
if (this.deadPlayers.has(p.name))
|
|
1016
|
+
continue;
|
|
1017
|
+
p.karma += karmaEarned;
|
|
1018
|
+
p.careerKarma += karmaEarned;
|
|
1019
|
+
}
|
|
1020
|
+
const karmaNote = `\nKarma earned: ${karmaEarned} (${this.player.karma} banked${hint(` -- "advance" at your hideout to train`)}).`;
|
|
1021
|
+
const banner = `${condition.completionMessage}${karmaNote}\n\nLOOSE END TIED OFF\n${hint(`"jack out" when you're done here -- the grid closes behind you.`)}`;
|
|
1022
|
+
this.logger.logWithColor(banner, 'green');
|
|
1023
|
+
return banner;
|
|
1024
|
+
}
|
|
982
1025
|
/**
|
|
983
1026
|
* Money actually landing in the runner's hands: accumulates on ONE
|
|
984
1027
|
* certified credstick across jobs (minting a same-named stick per job
|
|
@@ -57,11 +57,25 @@ export function hasAccess(lifestyleTier, district, target, provider = DEFAULT_GR
|
|
|
57
57
|
export function hopDefenceDice(target) {
|
|
58
58
|
return target.kind === 'public' ? 0 : target.kind === 'local' ? 4 : 6;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* THE GRID A LOOSE END HANGS ON (Game.enterCleanup, 2026-09-14): the
|
|
62
|
+
* site whose cameras kept the runner's face runs its own local grid, and
|
|
63
|
+
* "hop" lists it while the record is open. Nobody's lifestyle buys
|
|
64
|
+
* access to it, so the hop is always the illegal one (p.240, Hack on the
|
|
65
|
+
* Fly v. 4 dice -- a local grid) -- exactly the rule hop.ts already
|
|
66
|
+
* implements, not a new gate. `kind: 'local'` is the same metroplex:
|
|
67
|
+
* canon lets you reach a local grid only from inside its service area
|
|
68
|
+
* (p.220-221), and the site is across town.
|
|
69
|
+
*/
|
|
70
|
+
export function looseEndGrid(le) {
|
|
71
|
+
return { key: `local:loose-end-${slug(le.id)}`, kind: 'local', name: `the ${le.site.trim()} site grid`, userPenalty: 0 };
|
|
72
|
+
}
|
|
60
73
|
/**
|
|
61
74
|
* A grid by what a player types: "public", "local", the district's name,
|
|
62
|
-
* a Big Ten name, or "global <corp>"
|
|
75
|
+
* a Big Ten name, or "global <corp>" -- or one of `extras` (a loose end's
|
|
76
|
+
* site grid) by its site or full name. Undefined when nothing answers.
|
|
63
77
|
*/
|
|
64
|
-
export function resolveGridName(query, district) {
|
|
78
|
+
export function resolveGridName(query, district, extras = []) {
|
|
65
79
|
const q = query.trim().toLowerCase().replace(/^the\s+/, '').replace(/\s+grid$/, '');
|
|
66
80
|
if (q === '')
|
|
67
81
|
return undefined;
|
|
@@ -69,6 +83,12 @@ export function resolveGridName(query, district) {
|
|
|
69
83
|
return PUBLIC_GRID;
|
|
70
84
|
if (q === 'local' || slug(q) === district.key.slice('local:'.length))
|
|
71
85
|
return district;
|
|
86
|
+
const extra = extras.find(g => {
|
|
87
|
+
const name = g.name.toLowerCase().replace(/^the\s+/, '').replace(/\s+grid$/, '');
|
|
88
|
+
return slug(name) === slug(q) || slug(name.replace(/\s+site$/, '')) === slug(q);
|
|
89
|
+
});
|
|
90
|
+
if (extra)
|
|
91
|
+
return extra;
|
|
72
92
|
const corpWord = q.replace(/^global\s+/, '');
|
|
73
93
|
const corp = BIG_TEN.find(c => c.toLowerCase() === corpWord || slug(c) === slug(corpWord));
|
|
74
94
|
return corp ? globalGrid(corp) : undefined;
|
|
@@ -55,8 +55,12 @@ export async function hostTurnStart(scene, host, enc) {
|
|
|
55
55
|
* inside it -- even with every program crashed, the host gets its next
|
|
56
56
|
* turn to relaunch (p.355-356). */
|
|
57
57
|
export function hostKeepsFighting(scene, host) {
|
|
58
|
+
// ...and someone it can SEE (Host.spotted, 2026-09-14). A persona that
|
|
59
|
+
// Hides (p.240) is still inside and the host is still alert, but with
|
|
60
|
+
// nobody in view the ice has nothing to swing at: the fight closes and
|
|
61
|
+
// the focused Patrol sweep (ic.ts ambientPatrol) takes over.
|
|
58
62
|
return host.alert && !host.sanctioned
|
|
59
|
-
&& personasInside(scene, host).some(p => scene.isHumanControlled(p) && !p.isIncapacitated());
|
|
63
|
+
&& personasInside(scene, host).some(p => scene.isHumanControlled(p) && !p.isIncapacitated() && host.spotted.has(p.name));
|
|
60
64
|
}
|
|
61
65
|
/**
|
|
62
66
|
* OPEN THE HOST'S FIGHT on an intruder it has made. The aggressor is a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { IC_TYPES, icActs } from './ic.js';
|
|
1
|
+
import { IC_TYPES, icActs, isSpotted, patrolPerception, spot } from './ic.js';
|
|
2
2
|
import { leaveMatrix } from './planes.js';
|
|
3
3
|
import { Logger } from './logger.js';
|
|
4
|
+
import { personasInside } from './matrix-roster.js';
|
|
4
5
|
/**
|
|
5
6
|
* A PROGRAM'S ACTION PHASE (SR5 p.247): one Complex Action, by rule.
|
|
6
7
|
*
|
|
@@ -25,6 +26,34 @@ export async function runIcActionPhase(enc, ice) {
|
|
|
25
26
|
const budget = enc.budgetOf(ice);
|
|
26
27
|
if (!budget)
|
|
27
28
|
return;
|
|
29
|
+
// PATROL'S PHASE IS A SEARCH, NOT A SWING (p.248; Data Trails p.86: a
|
|
30
|
+
// suspicious Patrol runs Matrix Perception every action). It spends its
|
|
31
|
+
// Complex on the personas the host has NOT found yet -- a hidden one,
|
|
32
|
+
// a silent newcomer -- and a hit hands them to every other program.
|
|
33
|
+
if (type.kind === 'patrol') {
|
|
34
|
+
const out = { lines: [], meta: [], world: [] };
|
|
35
|
+
const unseen = personasInside(enc.scene, host)
|
|
36
|
+
.filter(p => enc.scene.isHumanControlled(p) && !isSpotted(host.over, p));
|
|
37
|
+
for (const p of unseen) {
|
|
38
|
+
if (patrolPerception(host.over, p, out)) {
|
|
39
|
+
spot(host.over, p);
|
|
40
|
+
if (!enc.has(p))
|
|
41
|
+
enc.join(p);
|
|
42
|
+
out.lines.push(` {red-fg}Patrol IC finds ${p.name}'s icon in the traffic -- the host has them now.{/red-fg}`);
|
|
43
|
+
out.world.push(`${host.name}'s Patrol IC found ${p.name}.`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
out.lines.push(` Patrol IC quarters the datastream for ${p.name} and comes up empty -- this pass.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (unseen.length === 0)
|
|
50
|
+
out.lines.push(` Patrol IC keeps every icon it can see in view and hands each move to the host.`);
|
|
51
|
+
budget.spend('complex', type.name);
|
|
52
|
+
for (const w of out.world)
|
|
53
|
+
enc.scene.addWorldEvent(w);
|
|
54
|
+
await enc.announce(out.lines, out.meta);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
28
57
|
const target = pickTarget(enc, ice);
|
|
29
58
|
if (!target) {
|
|
30
59
|
logger.write(`Ice: ${ice.name} has nobody in the host to hunt -- phase forfeited.`);
|
|
@@ -66,7 +95,11 @@ export async function runIcActionPhase(enc, ice) {
|
|
|
66
95
|
* read), then whoever it last engaged, then the first enemy standing.
|
|
67
96
|
*/
|
|
68
97
|
function pickTarget(enc, ice) {
|
|
69
|
-
|
|
98
|
+
// Only what the host can SEE (Host.spotted, shared by all its IC,
|
|
99
|
+
// p.247): a persona that Hid is still a participant until the fight
|
|
100
|
+
// notices nobody is left in view, and nothing may swing at it meanwhile.
|
|
101
|
+
const over = ice.icHost?.over;
|
|
102
|
+
const enemies = enc.enemiesOf(ice).filter(e => e.plane === 'matrix' && (!over || isSpotted(over, e)));
|
|
70
103
|
if (enemies.length === 0)
|
|
71
104
|
return undefined;
|
|
72
105
|
const marksOn = ice.icHost?.marksOn;
|