@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.
Files changed (28) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +4 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +6 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +132 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +3 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +110 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +23 -4
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +15 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js +17 -3
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +3 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +16 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +6 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +23 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/factories/cleanup-seed.js +89 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/game.js +220 -6
  17. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +21 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/headless.js +1 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +24 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +43 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js +22 -2
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +5 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-brain.js +35 -2
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +107 -34
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +3 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +34 -0
  28. 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
- return `Welcome back, ${this.player.name}. ${where}\n ${this.scene.story}${pendingNote}`;
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 holds a mark on an intruder and its ice is hunting. */
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>". Undefined when nothing answers.
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
- const enemies = enc.enemiesOf(ice).filter(e => e.plane === 'matrix');
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;