@maka/maka-cli 5.185.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.185.0",
3
+ "version": "5.186.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -110,6 +110,12 @@ export class DownloadCommand extends Command {
110
110
  const fromSprite = carried.find(c => c.item.name === picked);
111
111
  const fromFar = !fromSprite && !loose.some(i => i.name === picked) ? far.find(f => f.item.name === picked) : undefined;
112
112
  const item = fromSprite ? fromSprite.item : fromFar ? fromFar.item : loose.find(i => i.name === picked);
113
+ // A LOOSE END IS ERASED, NOT DOWNLOADED (take.ts has the same gate):
114
+ // the desk keeps its copy whatever comes down to your chip.
115
+ const wc = this.scene.getWinCondition?.();
116
+ if (wc?.type === 'erase' && wc.item.toLowerCase() === item.name.toLowerCase()) {
117
+ return `Pulling ${item.name} down changes nothing -- the desk keeps its own. ${hint(`"erase ${item.name}" is what makes it go away (Edit File, p.239).`)}`;
118
+ }
113
119
  // THE CONVERSION, identical to take.ts's. Same two fields, because a
114
120
  // file that came down by one verb and a file that came down by the
115
121
  // other must be the same object afterwards -- a chip you can `give`.
@@ -0,0 +1,132 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { rollPool, formatRoll } from '../utilities/dice.js';
4
+ import { billAction } from '../utilities/action-cost.js';
5
+ import { hostDefensePool } from '../utilities/matrix-intrusion.js';
6
+ import { hostLabel } from '../utilities/grid-names.js';
7
+ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
8
+ /**
9
+ * EDIT FILE (SR5 p.239, RAG-checked 2026-09-14): a Complex Action, Marks
10
+ * Required 1, Computer + Logic [Data Processing] v. Intuition + Firewall.
11
+ * "Edit File allows you to create, change, copy, delete, or protect any
12
+ * kind of file. The defender against this test is either the host
13
+ * holding the file or the owner of the file." It is a DATA PROCESSING
14
+ * action -- not Attack, not Sleaze -- so it is legal and GOD does not
15
+ * count it (p.231, and the worked example at p.231-232: "since that's a
16
+ * Data Processing action, he doesn't need to worry about his OS going up
17
+ * again"). A failed edit is not a blown Sleaze either: nothing upstairs
18
+ * notices a checksum that held.
19
+ *
20
+ * WHAT SHIPS (loose ends, 2026-09-14): DELETE. The cleanup's objective
21
+ * is a file the site keeps -- camera footage with the runner's face --
22
+ * and copying it (the engine's `take` / `download`, which already
23
+ * converts a matrix file to a chip) changes nothing about what the desk
24
+ * still holds. Create, change and protect are named here as not built;
25
+ * Crack File and the Data Bomb family wait on a per-file protection
26
+ * rating (models/item.ts) and are their own item.
27
+ *
28
+ * TWO ENGINE RULINGS, both labelled. Canon is silent on how a file INSIDE
29
+ * a host is marked (only archive files carry marks, p.247): here the
30
+ * persona must be inside the host and hold a mark on it -- the host is
31
+ * the file's keeper and the door was the mark. And a host has no
32
+ * Intuition to defend with: Host Rating + Firewall stands in, exactly
33
+ * the hostDefensePool ruling the intrusion roll already makes.
34
+ *
35
+ * FOUND FIRST: the archive is listed for a persona whose Matrix Search
36
+ * turned it up (Host.searchedBy, commands/search.ts) or once the host is
37
+ * cracked. You cannot delete what you have not seen.
38
+ */
39
+ export class EditFileCommand extends Command {
40
+ static verb = 'edit';
41
+ static description = 'Edit File (Complex Action, SR5 p.239): "edit delete <file>" -- Computer + Logic [Data Processing] v. the host\'s Rating + Firewall, from inside the host with a mark on it, once a search has turned the file up. A Data Processing action: legal, and GOD does not count it. "erase <file>" is the same delete.';
42
+ /** The operation this verb form performs; the delete forms fix it. */
43
+ operation(args) {
44
+ const first = (args[0] ?? '').toLowerCase();
45
+ if (/^(delete|erase|wipe|remove)$/.test(first))
46
+ return { op: 'delete', rest: args.slice(1) };
47
+ if (/^(create|change|copy|protect)$/.test(first))
48
+ return { op: 'other', word: first, rest: args.slice(1) };
49
+ return { op: 'other', rest: args };
50
+ }
51
+ async execute(args = []) {
52
+ const actor = this.actor;
53
+ const { op, word, rest } = this.operation(args);
54
+ if (op !== 'delete') {
55
+ const named = word ? `"${word}"` : 'that';
56
+ return `Edit File (p.239) can create, change, copy, delete or protect a file -- and this engine builds DELETE: "edit delete <file>" (or "erase <file>"). ${word === 'copy' ? 'Copying is "take" or "download" -- the file comes down to a chip.' : `${named} isn't built yet.`}`;
57
+ }
58
+ if (actor.plane !== 'matrix') {
59
+ return `Files are edited by a persona. ${hint(`"jack in" first.`)}`;
60
+ }
61
+ const host = actor.hostInside;
62
+ if (!host) {
63
+ return `You edit a host's files from INSIDE it (p.246). ${hint(`"enter" the host that keeps it.`)}`;
64
+ }
65
+ const room = host.over;
66
+ const files = host.files();
67
+ const query = rest.join(' ').trim();
68
+ if (!query) {
69
+ return files.length === 0
70
+ ? `${hostLabel(room, { capital: true })} keeps nothing here to edit.`
71
+ : `Edit which file? ${hint(`("edit delete <file>". "search" lists the archive.)`)}`;
72
+ }
73
+ const found = host.searchedBy.has(actor.name) || room.hostCracked;
74
+ if (!found) {
75
+ return `You haven't found ${hostLabel(room)}'s archive yet -- there is nothing on your display to edit. ${hint(`"search" runs a Matrix Search inside the host (p.241).`)}`;
76
+ }
77
+ const picked = fuzzyPickName(query, files.map(f => f.name));
78
+ const file = picked ? files.find(f => f.name === picked) : undefined;
79
+ if (!file) {
80
+ return `No file called "${query}" in ${hostLabel(room)}'s archive.${files.length > 0 ? hint(` (It holds: ${files.map(f => f.name).join(', ')}.)`) : ''}`;
81
+ }
82
+ const marks = room.hostMarksBy.get(actor.name) ?? 0;
83
+ if (marks < 1) {
84
+ return `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
85
+ }
86
+ // Binder IC eats Data Processing; a bracket of 0 permits no hits.
87
+ const burned = actor.burnedAttributeRefusal('dataProcessing');
88
+ if (burned)
89
+ return burned;
90
+ const bill = billAction(this.scene, actor, 'complex', 'Edit File');
91
+ if (bill)
92
+ return bill;
93
+ const computer = actor.skillRating('computer');
94
+ // Inside a host no grid penalty applies (p.246); silence still taxes
95
+ // every Matrix action (p.235-236, Player.matrixActionPenalty).
96
+ const pool = Math.max(1, actor.logic + (computer > 0 ? computer : -1)
97
+ + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty);
98
+ const limit = actor.matrixAttribute('dataProcessing');
99
+ const defence = hostDefensePool(host);
100
+ const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
101
+ const resist = rollPool(defence.pool);
102
+ if (this.scene.isHumanControlled(actor)) {
103
+ this.logger.meta(`Edit File (delete) -- Computer + Logic [Data Processing]: ${formatRoll(attempt)}`);
104
+ this.logger.meta(` v. ${hostLabel(room)} (${defence.label}, rating standing in for Intuition): ${formatRoll(resist)}`);
105
+ }
106
+ actor.performAction('edits a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
107
+ if (attempt.hits <= resist.hits) {
108
+ this.logger.write(`${actor.name} failed to delete "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
109
+ return `The file's checksum holds -- your edit rolls off it and the archive keeps ${file.name}. Nothing upstairs noticed: a failed edit is not a failed sleaze.${hint(` (Try again; each attempt is a Complex Action.)`)}`;
110
+ }
111
+ room.inventory.removeItem(file.name);
112
+ room.plainSightItems.delete(file.name.toLowerCase());
113
+ this.logger.write(`${actor.name} deleted "${file.name}" from ${room.name}'s host: ${attempt.hits} v ${resist.hits}.`);
114
+ this.scene.addWorldEvent(`${file.name} was erased from ${host.name}'s archive.`);
115
+ const banner = this.scene.checkErased?.(file.name, actor.name);
116
+ this.scene.updateStatus();
117
+ return [
118
+ `{lightblue-fg}${file.name} comes apart under your hand -- sectors zeroed, index entry gone, the host's own checksum rewritten to agree. ${hostLabel(room, { capital: true })} no longer has it.{/lightblue-fg}`,
119
+ ...(banner ? [banner] : []),
120
+ ].join('\n');
121
+ }
122
+ }
123
+ /** "erase <file>" / "wipe <file>": the delete form under its own name. */
124
+ export class EraseCommand extends EditFileCommand {
125
+ static verb = 'erase';
126
+ static description = 'Erase a file inside a host -- Edit File\'s delete (SR5 p.239): Computer + Logic [Data Processing] v. the host\'s Rating + Firewall, with a mark on the host, once "search" has turned the file up. Legal: GOD does not count it.';
127
+ operation(args) {
128
+ const first = (args[0] ?? '').toLowerCase();
129
+ return { op: 'delete', rest: /^(delete|erase|wipe|remove)$/.test(first) ? args.slice(1) : args };
130
+ }
131
+ }
132
+ //# sourceMappingURL=edit-file.js.map
@@ -26,34 +26,52 @@ export class HopCommand extends Command {
26
26
  if (actor.hostInside) {
27
27
  return `You're inside ${actor.hostInside.name}'s host -- there is no grid under you to hop from. ${hint(`"exit" first.`)}`;
28
28
  }
29
- const district = this.scene.localGrid;
30
29
  const game = this.game ?? undefined;
30
+ // A LOOSE-END CLEANUP HAS ONE DOOR (Game.enterCleanup, 2026-09-14):
31
+ // the persona hopped onto the site's grid and the only way off it is
32
+ // "jack out" -- hopping elsewhere would leave a scene with nobody in
33
+ // it and a body with no way back.
34
+ if (game?.inCleanup) {
35
+ return `Not from here -- this is the site's grid, and the only way off it is "jack out".`;
36
+ }
37
+ const district = this.scene.localGrid;
31
38
  const tier = game?.lifestyleTier ?? 'Street';
32
39
  const provider = game?.gridProvider ?? DEFAULT_GRID_PROVIDER;
33
40
  const query = args.join(' ').trim();
34
41
  const reachable = gridsWithAccess(tier, district, provider);
42
+ // LOOSE ENDS (Game.looseEndGrids): a site that kept your face runs a
43
+ // grid of its own, listed while the footage is still up there. No
44
+ // subscription buys it, so it is always the Hack on the Fly hop.
45
+ // Optional call: test stubs stand in for Game here (hop.test.ts).
46
+ const looseEnds = game?.looseEndGrids?.() ?? [];
47
+ const extras = looseEnds.map(e => e.grid);
35
48
  if (!query) {
36
49
  const here = actor.currentGrid?.name ?? 'an unnamed grid';
37
50
  return [
38
51
  `You're on ${here}.`,
39
52
  ` Grids you can hop to freely: ${reachable.map(g => g.name).join(', ')}.`,
40
- hint(` ("hop <grid>" -- public, local, or a Big Ten name. Anywhere else takes Hack on the Fly.)`),
53
+ ...(extras.length > 0 ? [` {yellow-fg}Loose ends: ${looseEnds.map(e => `${e.grid.name} (your face from "${e.looseEnd.jobName}")`).join(', ')}{/yellow-fg} -- no subscription: Hack on the Fly v. 4 dice (p.240).`] : []),
54
+ hint(` ("hop <grid>" -- public, local, or a Big Ten name${extras.length > 0 ? ', or a site with a loose end' : ''}. Anywhere else takes Hack on the Fly.)`),
41
55
  ].join('\n');
42
56
  }
43
- const target = resolveGridName(query, district);
57
+ const target = resolveGridName(query, district, extras);
44
58
  if (!target) {
45
59
  return `No grid answers to "${query}". ${hint(`Try "hop public", "hop local", or a Big Ten name -- Ares, Aztechnology, Evo, Horizon, Mitsuhama, NeoNET, Renraku, Saeder-Krupp, Shiawase, Wuxing.`)}`;
46
60
  }
47
61
  if (actor.currentGrid && actor.currentGrid.key === target.key) {
48
62
  return `You're already on ${target.name}.`;
49
63
  }
64
+ const isLooseEnd = extras.some(g => g.key === target.key);
65
+ // THE DROP (Game.enterCleanup): after the hop lands on a site grid,
66
+ // the site's host is what hangs overhead now.
67
+ const land = async () => isLooseEnd && game ? (await game.enterCleanup(target)).join('\n') : '';
50
68
  if (hasAccess(tier, district, target, provider)) {
51
69
  actor.matrixPosition = { kind: 'grid', grid: target };
52
70
  movePersonaWith(game, actor);
53
71
  actor.performAction('hops grids', `persona blinking across to ${target.name}`);
54
72
  this.logger.write(`${actor.name} hopped to ${target.key} (legal, ${tier}).`);
55
73
  this.scene.updateStatus();
56
- return `{lightblue-fg}The sky changes texture and you're on ${target.name}.{/lightblue-fg}${target.userPenalty ? ` ${hint(`Public grid: -2 on everything you do out here.`)}` : ''}`;
74
+ return `{lightblue-fg}The sky changes texture and you're on ${target.name}.{/lightblue-fg}${target.userPenalty ? ` ${hint(`Public grid: -2 on everything you do out here.`)}` : ''}${await land()}`;
57
75
  }
58
76
  // ILLEGAL HOP (p.240): Hack on the Fly against the grid itself.
59
77
  const burned = actor.burnedAttributeRefusal('sleaze');
@@ -89,6 +107,7 @@ export class HopCommand extends Command {
89
107
  return [
90
108
  `{lightblue-fg}You slip through a seam nobody sold you and come up on ${target.name}.{/lightblue-fg}`,
91
109
  ...bill.lines,
110
+ ...(isLooseEnd ? [await land()] : []),
92
111
  ].join('\n');
93
112
  }
94
113
  }
@@ -65,6 +65,15 @@ export class JobsCommand extends Command {
65
65
  entries.push({ tag: 'LOCAL', kind: 'active', name: pending.name, detail: `this machine only${tail}` });
66
66
  }
67
67
  }
68
+ // LOOSE ENDS (2026-09-14): not jobs -- nobody is paying -- but they
69
+ // are the other thing on the runner's plate, and the board is where a
70
+ // player looks. Read-only here: the way in is the Matrix (journal.ts).
71
+ for (const le of game.looseEnds ?? []) {
72
+ entries.push({
73
+ tag: 'LOOSE END', kind: 'loose-end', name: `Footage at ${le.site}`,
74
+ detail: `${le.site}'s cameras kept your face from "${le.jobName}" -- "jack in", then "hop ${le.site}"${le.homecomingsLeft <= 1 ? '; the desk reviews the feeds after your next job' : ''}`,
75
+ });
76
+ }
68
77
  // Standing drafts: ONLY the ones whose quoted crew is seated right
69
78
  // now (the ruling), and never the one already on the table.
70
79
  if (game.hasHub && !this.gameAnonymous()) {
@@ -92,6 +101,9 @@ export class JobsCommand extends Command {
92
101
  }
93
102
  async take(entry) {
94
103
  const game = this.game;
104
+ if (entry.kind === 'loose-end') {
105
+ return `A loose end isn't taken, it's tied off -- from the Matrix. "jack in", then "hop" onto the site's grid and erase what they kept.`;
106
+ }
95
107
  if (entry.kind === 'active') {
96
108
  // Same phantom verb as buildBoard's, same fix: the ride is "call
97
109
  // taxi" whether the seat is yours alone or the table's, so there
@@ -117,6 +129,9 @@ export class JobsCommand extends Command {
117
129
  }
118
130
  async dismiss(entry) {
119
131
  const game = this.game;
132
+ if (entry.kind === 'loose-end') {
133
+ return `You can't wave off what a camera already has. Erase it, or let the desk find it.`;
134
+ }
120
135
  if (entry.kind === 'active') {
121
136
  const wasCrew = entry.tag === 'CREW';
122
137
  // Explicit dismissal kills the server draft too -- that's the
@@ -92,10 +92,19 @@ export class JournalCommand extends Command {
92
92
  if (wc.payout !== undefined && wc.payout > 0) {
93
93
  rows.push(`${BULLET}{bold}Pay:{/bold} ${wc.payout.toLocaleString()}¥ ${isDelivery ? 'on delivery' : 'on the wrap'}`);
94
94
  }
95
- const objective = isDelivery
96
- ? `deliver ${wc.item} to ${wc.toNpc}`
97
- : `come away with ${wc.item}`;
95
+ // A loose end is ERASED, not carried (win-condition-seed.ts 'erase').
96
+ const isErase = wc.type === 'erase';
97
+ const objective = isErase
98
+ ? `erase ${wc.item}`
99
+ : isDelivery
100
+ ? `deliver ${wc.item} to ${wc.toNpc}`
101
+ : `come away with ${wc.item}`;
98
102
  rows.push(`${BULLET}{bold}Objective:{/bold} ${objective}`);
103
+ if (isErase) {
104
+ if (this.scene?.isCompleted?.())
105
+ rows.push(`${BULLET}{bold}Status:{/bold} erased -- "jack out" closes the grid`);
106
+ return rows;
107
+ }
99
108
  // Held or not is a live read of the player's own pack.
100
109
  const holding = this.actor.inventory?.hasItem?.(wc.item);
101
110
  if (holding)
@@ -130,6 +139,11 @@ export class JournalCommand extends Command {
130
139
  if (game.rentDebt > 0) {
131
140
  lines.push(`${BULLET}{bold}Owed:{/bold} ${game.rentDebt.toLocaleString()}¥ on the tab`);
132
141
  }
142
+ // LOOSE ENDS (2026-09-14): what a run left behind that can still be
143
+ // fixed -- from the Matrix, not the curb.
144
+ for (const le of game.looseEnds ?? []) { // stubs stand in for Game in journal.test.ts
145
+ lines.push(`${BULLET}{bold}Loose end:{/bold} ${le.site}'s cameras kept your face from "${le.jobName}"${le.homecomingsLeft <= 1 ? ' -- the desk reviews the feeds after your next job' : ''}${hint(` ("jack in", then "hop ${le.site}" -- erase it before the desk finds it.)`)}`);
146
+ }
133
147
  return lines.join('\n') + hint(`\n("jobs" shows the board.)`);
134
148
  }
135
149
  }
@@ -170,6 +170,9 @@ export class SheetCommand extends Command {
170
170
  row('Needs', `${a.ateThisCycle ? 'fed' : 'HUNGRY'}, ${a.sleptThisCycle ? 'slept' : 'SHORT ON SLEEP'}${a.ateThisCycle && a.sleptThisCycle ? '' : ' -- edge will NOT refill'}`),
171
171
  row('Rep', `Cred ${a.streetCred} / Notoriety ${a.notoriety} / Awareness ${a.publicAwareness}${hint(`${a.socialRep > 0 ? ` -- +${a.socialRep} dice haggle/persuade` : ''}${a.notoriety > 0 ? `; threats +${Math.min(3, a.notoriety)}` : ''}${a.publicAwareness > 0 ? `; cons -${Math.min(2, a.publicAwareness)}` : ''}`)}`),
172
172
  ...(a.notorietyReasons.length > 0 ? [row('Stains', a.notorietyReasons.join('; '))] : []),
173
+ // LOOSE ENDS (2026-09-14): footage a site still holds -- erasable
174
+ // from the Matrix until the desk reviews it (ILooseEnd).
175
+ ...((this.game?.looseEnds.length ?? 0) > 0 ? [row('Loose ends', this.game.looseEnds.map(le => `${le.site} has your face from "${le.jobName}"`).join('; ') + hint(' ("journal" for how to fix it)'))] : []),
173
176
  ...(a.initiationGrade > 0 ? [row(a.isTechnomancer() ? 'Submersion' : 'Initiation', `grade ${a.initiationGrade} -- ${catalogMetamagics().filter(m => a.hasMetamagic(m.key)).map(m => m.name).join(', ')}`)] : []),
174
177
  ...(a.lockedFatigue > 0 ? [row('Fatigue', `${a.lockedFatigue} stun LOCKED until the need is met`)] : []),
175
178
  ...(a.woundModifier < 0 ? [row('Wounds', `${a.woundModifier} dice on everything`)] : []),
@@ -185,6 +185,13 @@ export class TakeCommand extends Command {
185
185
  let tookAnything = false;
186
186
  const taken = [];
187
187
  for (const item of items) {
188
+ // The sweep skips a file the objective says to ERASE -- see the
189
+ // single-take refusal below for why a copy is not a fix.
190
+ const wcAll = this.scene.getWinCondition?.();
191
+ if (item.plane === 'matrix' && wcAll?.type === 'erase' && wcAll.item.toLowerCase() === item.name.toLowerCase()) {
192
+ lines.push(` • left the {bold}{underline}${item.name}{/underline}{/bold} -- copying it changes nothing; "erase" it`);
193
+ continue;
194
+ }
188
195
  try {
189
196
  this.actor.addInventory(item);
190
197
  // THE SWEEP DOWNLOADS TOO. Same rule as the single take below,
@@ -316,6 +323,15 @@ export class TakeCommand extends Command {
316
323
  if (reach === 'enter') {
317
324
  return `The ${item.name} is INSIDE the host -- you're on the outside grid, reading its shell (p.246). ${(room.hostMarksBy.get(this.actor.name) ?? 0) > 0 || room.hostCracked ? `"enter" first.` : `Get a mark on it and "enter" -- or "hack" your way in.`}`;
318
325
  }
326
+ // A LOOSE END IS ERASED, NOT CARRIED (win-condition-seed.ts 'erase',
327
+ // 2026-09-14): pulling the footage down to a chip leaves the desk's
328
+ // copy exactly where it was, and would let a runner believe the
329
+ // night was dealt with. The objective's own verb is "erase" --
330
+ // said before the seal, because it is the refusal that teaches.
331
+ const wc = this.scene.getWinCondition?.();
332
+ if (wc?.type === 'erase' && wc.item.toLowerCase() === item.name.toLowerCase()) {
333
+ return `Copying ${item.name} changes nothing -- the desk keeps its own. ${hint(`"erase ${item.name}" is what makes it go away (Edit File, p.239).`)}`;
334
+ }
319
335
  // Same split as look.ts: the host's seal does not reach a box that
320
336
  // is not on the network. The air gap IS the server's security.
321
337
  if (room.hasNode && !room.offlineServer && !room.hostCracked) {
@@ -459,5 +459,17 @@
459
459
  // the host fight opens only on a sighting and closes when nobody in
460
460
  // view is left. New verb: "hide" (p.240). Matrix Search inside a host
461
461
  // is reachable again.
462
- export const ENGINE_VERSION = '1.54.0';
462
+ // 1.55.0 (2026-09-14): LOOSE ENDS. A run that ends with a camera's footage
463
+ // still live on an uncracked host leaves an ILooseEnd on the save
464
+ // (save.game.looseEnds, additive; a hosted run carries it on
465
+ // pendingHomecoming.footage). From the hub a Matrix-capable runner
466
+ // jacks in, "hop"s onto the site's grid (Hack on the Fly v. 4 dice,
467
+ // p.240), is dropped into a one-host cleanup scene, cracks and
468
+ // searches the host and ERASES the file with the new Edit File verb
469
+ // ("edit delete" / "erase", p.239, a Data Processing action GOD does
470
+ // not count); jacking out -- by any door -- hands the hub back. No
471
+ // taxi, no pay. Left one full homecoming, the desk reviews the feeds:
472
+ // +1 Public Awareness. New SaveReason 'cleanup'; new win condition
473
+ // type 'erase'. The site reads Game.footageLeft() off the live handle.
474
+ export const ENGINE_VERSION = '1.55.0';
463
475
  //# sourceMappingURL=engine-version.js.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * THE LOOSE-END CLEANUP SEED (2026-09-14). Deterministic and hand-built
3
+ * -- no LLM, no AI key, instant -- rebuilt from the record every time
4
+ * the runner hops onto the site's grid (utilities/grids.ts
5
+ * looseEndGrid, Game.enterCleanup), and thrown away when they jack out.
6
+ *
7
+ * ONE ROOM: the site's host, standing over the room the cameras are
8
+ * slaved to. It is never reachable in the meat (`reachable: false`, the
9
+ * fixer's-hideout convention) because nobody is going there -- the
10
+ * persona is dropped onto the site's grid and the host hangs overhead,
11
+ * reachable by name from anywhere on it (utilities/grid-reach.ts). The
12
+ * body's own room is added by the caller, so the body has somewhere to
13
+ * be; it is not part of this seed.
14
+ *
15
+ * ONE FILE: the footage, a matrix-plane Document in the host's room --
16
+ * the shape Host.files() reads (models/host.ts) and the data run's
17
+ * paydata already takes (factories/repro-scene.ts). The objective is to
18
+ * ERASE it (Edit File, p.239; win-condition-seed.ts 'erase'), not to
19
+ * take it: copying it changes nothing about what the desk keeps.
20
+ *
21
+ * NO CLIENT, NO PAYOUT, NO NPCS: nobody hired the runner for this. Ice
22
+ * is the host's to launch at runtime (utilities/host-combat.ts); the
23
+ * generator's rule that no seed writes matrix-plane NPCs holds here too.
24
+ */
25
+ export const FOOTAGE_FILE_PREFIX = 'Security Footage';
26
+ export function footageFileName(le) {
27
+ return `${FOOTAGE_FILE_PREFIX} -- ${le.site}`;
28
+ }
29
+ export function cleanupSceneName(le) {
30
+ return `Loose end: ${le.site}`;
31
+ }
32
+ export function buildCleanupSeed(le, playerName) {
33
+ const file = footageFileName(le);
34
+ const when = le.capturedAt.slice(0, 10);
35
+ const rooms = le.rooms.length > 0 ? le.rooms.join(', ') : le.site;
36
+ return {
37
+ name: cleanupSceneName(le),
38
+ story: `${le.site}'s cameras kept your face from "${le.jobName}" -- and the footage is sitting on the site's own host, waiting for a security desk that has not got round to it yet. You are on ${le.site}'s grid. The host hangs overhead. Nobody is paying for this one; nobody else is coming to fix it.`,
39
+ currency: { type: 'nuyen', authorityRoomTags: [] },
40
+ residenceTiers: [],
41
+ rooms: [
42
+ {
43
+ name: le.host.room,
44
+ description: `The room ${le.site}'s cameras report to. You will never stand in it -- its host is what you are here for.`,
45
+ roomType: 'Office',
46
+ isStartRoom: true,
47
+ reachable: false,
48
+ hasNode: true,
49
+ hostRating: le.host.rating,
50
+ hostGrid: 'local',
51
+ hostPurpose: le.host.purpose ?? `${le.site}'s security feeds and door logs`,
52
+ hostSculpt: le.host.sculpt,
53
+ watched: true,
54
+ exits: [],
55
+ },
56
+ ],
57
+ npcs: [],
58
+ items: [
59
+ {
60
+ name: file,
61
+ description: `Camera footage from ${rooms}, ${when}: a runner's face, in full, from the night of "${le.jobName}". Yours.`,
62
+ details: `Feeds from ${rooms}. Not yet reviewed by the desk.`,
63
+ size: 'Tiny',
64
+ category: 'Document',
65
+ shape: 'Rectangle',
66
+ color: 'Gray',
67
+ texture: 'Plain',
68
+ rating: 'Common',
69
+ weight: 0.1,
70
+ plane: 'matrix',
71
+ room: le.host.room,
72
+ },
73
+ ],
74
+ puzzles: [],
75
+ winCondition: {
76
+ type: 'erase',
77
+ item: file,
78
+ completionMessage: `The file shreds under your hand and the feeds loop on an empty hallway. ${le.site} never kept your face.`,
79
+ },
80
+ cleanup: { looseEndId: le.id },
81
+ player: {
82
+ name: playerName,
83
+ startLocation: le.host.room,
84
+ lifestyleChoices: [],
85
+ startingItems: [],
86
+ },
87
+ };
88
+ }
89
+ //# sourceMappingURL=cleanup-seed.js.map
@@ -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';
@@ -523,6 +525,192 @@ export default class Game {
523
525
  this._runHeat += billed;
524
526
  Logger.getInstance().write(`Heat: +${billed}${billed !== amount ? ` (${amount} doubled on camera)` : ''} (${why}) -> ${this._runHeat}.`);
525
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
+ }
526
714
  /** The HUD's read on the district (bands, never the number). */
527
715
  heatBand() {
528
716
  if (this._runHeat <= 0)
@@ -2749,6 +2937,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2749
2937
  CommandFactory.registerCommand('os', OverwatchCommand);
2750
2938
  // Hide (SR5 p.240): drop off a host that has spotted you.
2751
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);
2752
2945
  // MAtrix Recognition Keys (canon p.235-236).
2753
2946
  CommandFactory.registerCommand('mark', MarkCommand);
2754
2947
  // Camera feeds you own (surveillance).
@@ -3134,7 +3327,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3134
3327
  const where = this._resumedAt && this._resumedAt !== this._homeRoom
3135
3328
  ? `You're back at the ${this._resumedAt.name}${this.player.atSpot ? `, by the ${this.player.atSpot}` : ''} -- right where you left off.`
3136
3329
  : 'The cot remembers your shape.';
3137
- 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}`;
3138
3334
  }
3139
3335
  // Nudge the player toward "call" up front -- the fixer convention means
3140
3336
  // the story deliberately doesn't narrate a completed briefing, so
@@ -3258,6 +3454,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3258
3454
  applyHubOverlay(this.scene, save.hub, this.player, this._homeRoom);
3259
3455
  this._tier = save.game.tier;
3260
3456
  this._pendingHomecoming = save.game.pendingHomecoming;
3457
+ this._looseEnds = (save.game.looseEnds ?? []).map(l => ({ ...l }));
3261
3458
  this.contacts = new Map(save.game.contacts);
3262
3459
  // Roles are DERIVED, not earned -- re-derive them all on every
3263
3460
  // resume so old saves self-heal (pre-role saves read all "Street
@@ -3575,6 +3772,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3575
3772
  })(),
3576
3773
  crew: this.crew.map(m => ({ ...m, gear: [...m.gear] })),
3577
3774
  pendingHomecoming: this._pendingHomecoming,
3775
+ looseEnds: this._looseEnds.length > 0 ? this._looseEnds.map(l => ({ ...l })) : undefined,
3578
3776
  commandRefusals: { ...this.commandRefusals },
3579
3777
  fencedCategories: [...this.fencedCategories],
3580
3778
  rumorIndex: this._rumorIndex,
@@ -3670,6 +3868,13 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3670
3868
  // will carry a runner away from a job gone sideways (see call.ts) --
3671
3869
  // walking away just means the payout never landed. Classic sessions
3672
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
+ }
3673
3878
  if (this._hubScene && this.scene !== this._hubScene) {
3674
3879
  return this.returnToHub();
3675
3880
  }
@@ -3880,6 +4085,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3880
4085
  // saying canon does not do that, and spending the consequence
3881
4086
  // somewhere the books actually put it.
3882
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;
3883
4091
  const awarenessLine = loudRun && this.player.addPublicAwareness(`left "${this.currentJobName ?? 'a job'}" screaming`)
3884
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})`
3885
4093
  : '';
@@ -3975,6 +4183,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3975
4183
  const allyLine = crewLines.length > 0 ? `\n${crewLines.join('\n')}` : '';
3976
4184
  const docwagonLine = this.tickDocwagonHomecoming();
3977
4185
  const deliveryLine = this.deliverProcurements();
4186
+ // Loose ends: last night's expire, tonight's is filed.
4187
+ const looseEndLines = this.settleLooseEnds(footage);
3978
4188
  // The night catches up on the ride (SINs & getting caught): a hot
3979
4189
  // run means the wire between there and home. Settled AFTER the
3980
4190
  // payout (the fee comes out of tonight's take) and BEFORE the save
@@ -3987,7 +4197,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3987
4197
  const fateLine = wrappedRun
3988
4198
  ? `The run's wrapped and the street swallows you back up.`
3989
4199
  : `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
3990
- 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')}` : ''}`;
3991
4201
  }
3992
4202
  /** The rent, the slide, the tab, and the High-lifestyle shower --
3993
4203
  * homecoming's money half, for the solo ride home and the hosted
@@ -4121,6 +4331,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4121
4331
  this.rollHireCandidates();
4122
4332
  const docwagonLine = this.tickDocwagonHomecoming();
4123
4333
  const deliveryLine = this.deliverProcurements();
4334
+ const looseEndLines = this.settleLooseEnds(owed.footage);
4124
4335
  Logger.getInstance().write(`Hosted homecoming applied (${owed.outcome}, ended ${owed.endedAt}): rent ${this.rentDebt} owed, lifestyle ${this.lifestyleTier}.`);
4125
4336
  this.requestSave('homecoming');
4126
4337
  const fateLine = owed.outcome === 'wrapped'
@@ -4128,7 +4339,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4128
4339
  : owed.outcome === 'interrupted'
4129
4340
  ? `The run was lost to the static -- the street doesn't ask.`
4130
4341
  : `The run's behind you -- unfinished, unpaid, already someone else's problem. The street doesn't ask.`;
4131
- 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}`;
4132
4343
  }
4133
4344
  /**
4134
4345
  * KNIGHT ERRANT CHECKPOINT (SINs & getting caught, ruling 2026-08-24;
@@ -6281,7 +6492,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6281
6492
  // players" / "crew street"); the bare verb still answers quietly.
6282
6493
  { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
6283
6494
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
6284
- { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['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']] },
6285
6496
  // The Emerged get their own shelf (player request: "there MUST be
6286
6497
  // resonance" -- matrix is the place, Resonance is the talent).
6287
6498
  // `sustain` shelves here and not under magic: the command is
@@ -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
  }
@@ -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;
@@ -267,6 +267,11 @@ export function leaveMatrix(scene, actor, opts) {
267
267
  actor.bodyRoom = undefined;
268
268
  lines.push(`You're back in your body in ${body.name}.`);
269
269
  }
270
+ // A LOOSE-END CLEANUP ENDS HERE, whichever door the persona left by --
271
+ // a clean jack-out, a torn link-lock, a bricked deck, Scramble, GOD.
272
+ // Every exit is one of this function's callers, so this is the one
273
+ // place the hub takes the scene back (Game.onPersonaLeftMatrix).
274
+ lines.push(...(scene.ownerGame?.onPersonaLeftMatrix?.(actor) ?? []));
270
275
  actor.performAction('jacks out', 'stirring awake', { quiet: scene.isHumanControlled?.(actor) ?? false });
271
276
  scene.addWorldEvent(`${actor.name} jacked out of the Matrix.`);
272
277
  scene.updateStatus();
@@ -97,6 +97,40 @@ export function camerasLive(rooms, room) {
97
97
  return true;
98
98
  return !hosts.some(r => r.hostCracked);
99
99
  }
100
+ /**
101
+ * WHAT THE CAMERAS STILL HOLD WHEN THE RUN ENDS (loose ends, 2026-09-14).
102
+ *
103
+ * `camSeen` is the set of rooms whose cameras were called out during the
104
+ * run (Game._camSeenRooms -- "a camera's dead eye caught ALL of it").
105
+ * The line promised the footage was "already somewhere else", and this
106
+ * is where: the host those cameras are slaved to. Footage survives the
107
+ * run when the room's feeds are STILL live at the end -- its master host
108
+ * never cracked, its cluster never looped or bricked -- and there is a
109
+ * host anywhere to hold it (standalone recorders keep nothing a decker
110
+ * could reach, so they leave no loose end). Grouped under one master
111
+ * (the one with the most rooms, then by name): one run, one record.
112
+ */
113
+ export function liveFootage(rooms, camSeen) {
114
+ const byHost = new Map();
115
+ for (const name of camSeen) {
116
+ const room = Object.values(rooms).find(r => r.name === name);
117
+ if (!room || !camerasLive(rooms, room))
118
+ continue;
119
+ const master = cameraMasterHost(rooms, room);
120
+ if (!master)
121
+ continue;
122
+ const list = byHost.get(master) ?? [];
123
+ list.push(room);
124
+ byHost.set(master, list);
125
+ }
126
+ let best;
127
+ for (const [host, seen] of byHost) {
128
+ if (!best || seen.length > best.rooms.length || (seen.length === best.rooms.length && host.name.localeCompare(best.host.name) < 0)) {
129
+ best = { host, rooms: seen };
130
+ }
131
+ }
132
+ return best;
133
+ }
100
134
  /** Can THIS persona watch the room's feeds remotely? Its own host
101
135
  * cracked or marked; a hostless watched room answers to whoever owns
102
136
  * any of the site's hosts. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.185.0",
3
+ "version": "5.186.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",