@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.184.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.",
@@ -148,8 +148,11 @@ export class DisableCommand extends Command {
148
148
  const host = t.kind === 'device' ? t.host : t.kind === 'camera' ? t.master : undefined;
149
149
  if (host) {
150
150
  host.hostAlert = true;
151
- if (made)
151
+ if (made) {
152
152
  host.hostMarksOn.set(actor.name, Math.min(MAX_MARKS, (host.hostMarksOn.get(actor.name) ?? 0) + 1));
153
+ // Made is seen (Host.spotted) -- the ice hunts this persona now.
154
+ host.host?.spotted.add(actor.name);
155
+ }
153
156
  }
154
157
  if (t.kind === 'gun' && made) {
155
158
  actor.panMarksBy.set(t.owner.name, Math.min(MAX_MARKS, (actor.panMarksBy.get(t.owner.name) ?? 0) + 1));
@@ -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
@@ -197,6 +197,9 @@ export class HackCommand extends BypassCommand {
197
197
  // CAUGHT from a user who merely holds a key, so the sentence
198
198
  // has to be true.
199
199
  room.hostMarksOn.set(this.actor.name, Math.min(MAX_MARKS, (room.hostMarksOn.get(this.actor.name) ?? 0) + 1));
200
+ // Being made is being SEEN (Host.spotted): the ice hunts this
201
+ // persona from here, not merely the intrusion.
202
+ room.host?.spotted.add(this.actor.name);
200
203
  this.actor.noteConditionChanged();
201
204
  this.actor.sneaking = false;
202
205
  this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} MADE ${this.actor.name} mid-intrusion -- alarm flagged, ice hunting.`);
@@ -0,0 +1,110 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { rollPool, formatRoll } from '../utilities/dice.js';
4
+ import { accrueOverwatch } from '../utilities/overwatch.js';
5
+ import { leaveMatrix } from '../utilities/planes.js';
6
+ import { billAction, encounterOf } from '../utilities/action-cost.js';
7
+ import { isSpotted } from '../utilities/ic.js';
8
+ import { MAX_MARKS } from '../utilities/marks.js';
9
+ import { hostLabel } from '../utilities/grid-names.js';
10
+ /**
11
+ * HIDE (SR5 p.240, RAG-checked 2026-09-14): a Complex Action, no marks
12
+ * required, Electronic Warfare + Intuition [Sleaze] v. Intuition + Data
13
+ * Processing. "If you succeed, the target loses track of you and must
14
+ * perform a new Matrix Perception action to find you." And the one hard
15
+ * limit the book puts on it: "You can't hide from an icon that has a
16
+ * mark on you, so you'll need to clear those before you can try this."
17
+ *
18
+ * WHY IT EXISTS HERE (the "constantly in combat" report, 2026-09-14):
19
+ * a host that has SPOTTED a persona (Host.spotted) hunts it with every
20
+ * program it can launch, and nothing but leaving ended that. Canon's
21
+ * answer is this action -- drop off the host's scope, and its ice has
22
+ * nothing to swing at until Patrol finds you again (which, on an alerted
23
+ * host, it tries every action: Data Trails p.86, ic.ts ambientPatrol).
24
+ *
25
+ * THE DEFENDER IS THE HOST, and a host has no Intuition: its rating
26
+ * stands in, the same engine ruling matrix-intrusion.ts hostDefensePool
27
+ * already makes for the intrusion roll (Host Rating + Firewall). Here it
28
+ * is Host Rating + Data Processing, because the book names Data
29
+ * Processing for this test. The host and its IC share spotting (p.247),
30
+ * so hiding from the host hides you from every program at once.
31
+ *
32
+ * A SLEAZE ACTION: GOD bills the defender's hits (p.231-232), and a
33
+ * failure is a blown Sleaze -- the target gets one free mark on you and
34
+ * spots you (p.236). Failing to hide from a host that could see you
35
+ * makes it hold you tighter; that is the book's own tension, not ours.
36
+ */
37
+ export class HideCommand extends Command {
38
+ static verb = 'hide';
39
+ static description = 'Hide (Complex Action, SR5 p.240): Electronic Warfare + Intuition [Sleaze] v. the host\'s Rating + Data Processing. Win and the host loses track of your persona -- its ice has nothing to swing at until Patrol finds you again. Impossible while the host holds a mark on you. A Sleaze action: GOD counts it, and a miss hands the host a mark.';
40
+ async execute(_args = []) {
41
+ const actor = this.actor;
42
+ if (actor.plane !== 'matrix') {
43
+ return `Hide is a Matrix action -- it takes a persona to lose. ${hint(`"jack in" first; in the meat, "sneak".`)}`;
44
+ }
45
+ const host = actor.hostInside;
46
+ if (!host) {
47
+ return `Nothing out here has you. Hide is for shaking an icon that has SPOTTED you -- a host's ice, once it's made you inside.`;
48
+ }
49
+ const room = host.over;
50
+ if (!isSpotted(room, actor)) {
51
+ return `${hostLabel(room, { capital: true })} hasn't found you -- there is nothing to hide from yet.${actor.runningSilent ? '' : hint(` ("silent" is what keeps it that way: a loud icon is spotted with no test, p.235.)`)}`;
52
+ }
53
+ const held = room.hostMarksOn.get(actor.name) ?? 0;
54
+ if (held > 0) {
55
+ return `You can't hide from an icon that has a MARK on you (p.240) -- ${hostLabel(room)} holds ${held} on your persona. Those come off with a reboot, not a dodge.${hint(` ("jack out" reboots; "exit" leaves the host.)`)}`;
56
+ }
57
+ // Marker IC eats Sleaze; a bracket of 0 permits no hits at all.
58
+ const burned = actor.burnedAttributeRefusal('sleaze');
59
+ if (burned)
60
+ return burned;
61
+ const bill = billAction(this.scene, actor, 'complex', 'Hide');
62
+ if (bill)
63
+ return bill;
64
+ const ew = actor.skillRating('electronic-warfare');
65
+ const pool = Math.max(1, actor.intuition + (ew > 0 ? ew : -1)
66
+ + actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty);
67
+ const limit = actor.matrixAttribute('sleaze');
68
+ const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
69
+ const defence = rollPool(room.hostRating + room.hostAttributes().dataProcessing);
70
+ if (this.scene.isHumanControlled(actor)) {
71
+ this.logger.meta(`Hide -- Electronic Warfare + Intuition [Sleaze]: ${formatRoll(attempt)}`);
72
+ this.logger.meta(` v. ${hostLabel(room)} (Host Rating + Data Processing, rating standing in for Intuition): ${formatRoll(defence)}`);
73
+ }
74
+ actor.performAction('tries to hide', `persona folding into ${hostLabel(room)}'s traffic`, { quiet: this.scene.isHumanControlled(actor) });
75
+ const god = accrueOverwatch(this.scene, actor, defence.hits, 'hide (sleaze)');
76
+ if (god.converged) {
77
+ return [`The hide never lands --`, ...god.lines, ...leaveMatrix(this.scene, actor, { forced: true, reason: `GOD force-reboots your persona --` })].join('\n');
78
+ }
79
+ if (attempt.hits <= defence.hits) {
80
+ // p.236: a blown Sleaze action is NOTICED -- one free mark, and it
81
+ // certainly still sees you.
82
+ room.hostMarksOn.set(actor.name, Math.min(MAX_MARKS, held + 1));
83
+ room.host?.spotted.add(actor.name);
84
+ actor.noteConditionChanged();
85
+ this.logger.write(`${actor.name} failed to hide in ${room.name}: ${attempt.hits} v ${defence.hits} -- host mark ${held + 1}.`);
86
+ this.scene.updateStatus();
87
+ return [
88
+ `{red-fg}You fold into the traffic and the host folds with you -- it never lost you, and now it holds a MARK on your persona.{/red-fg}`,
89
+ ` No hiding from an icon that has a mark on you (p.240). The ice stays on you.`,
90
+ ...god.lines,
91
+ ].join('\n');
92
+ }
93
+ room.host?.spotted.delete(actor.name);
94
+ room.host?.justHid.add(actor.name);
95
+ actor.noteConditionChanged();
96
+ // Out of the host's sight is out of its fight: nothing may swing at
97
+ // an icon it cannot see. The encounter closes on its own when nobody
98
+ // in view is left (host-combat.ts hostKeepsFighting).
99
+ encounterOf(this.scene, actor)?.leave(actor);
100
+ this.logger.write(`${actor.name} hid from ${room.name}'s host: ${attempt.hits} v ${defence.hits}.`);
101
+ this.scene.addWorldEvent(`${host.name}'s ice lost ${actor.name} in the traffic.`);
102
+ this.scene.updateStatus();
103
+ return [
104
+ `{lightblue-fg}You drop into the host's own traffic and become one more packet in a million. ${hostLabel(room, { capital: true })} loses you.{/lightblue-fg}`,
105
+ ` Its ice has nothing to swing at -- but the host is still awake, and Patrol is looking every action now.${hint(` ("silent" keeps a sweep from seeing you for free; "search" is possible while it looks.)`)}`,
106
+ ...god.lines,
107
+ ].join('\n');
108
+ }
109
+ }
110
+ //# sourceMappingURL=hide.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
  }
@@ -211,7 +211,7 @@ export class SearchCommand extends Command {
211
211
  // bill inside a Combat Turn, so it is refused there rather than
212
212
  // priced by invention.
213
213
  if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
214
- return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice or get out first.`;
214
+ return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice, "hide" from it (p.240), or get out first.`;
215
215
  }
216
216
  if (this.actor.plane === 'matrix')
217
217
  return this.matrixSearch();
@@ -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) {
@@ -117,6 +117,12 @@ export class TapCommand extends Command {
117
117
  // p.236: a blown Sleaze action is NOTICED. The camera screams to
118
118
  // its master, which is the same alarm hack.ts raises.
119
119
  host.hostAlert = true;
120
+ // The line below promises "it holds a mark on your persona now"
121
+ // (p.236, a blown Sleaze's free mark) -- and a mark is a sighting
122
+ // (Host.spotted). Both written, so the sentence is true and the
123
+ // ice hunts THIS persona rather than the intrusion in general.
124
+ host.hostMarksOn.set(actor.name, Math.min(3, (host.hostMarksOn.get(actor.name) ?? 0) + 1));
125
+ host.host?.spotted.add(actor.name);
120
126
  actor.sneaking = false;
121
127
  this.scene.addWorldEvent(`Something forced the cameras in ${room.name} -- ${hostLabel(host)} flagged an intrusion and its ice is hunting.`);
122
128
  this.scene.updateStatus();
@@ -449,5 +449,27 @@
449
449
  // Combat Turn. IC no longer act once per player command, never speak,
450
450
  // and a failed IC attack damages the program (p.247). Ice NPCs are no
451
451
  // longer generated; the vault host's rating is its guard.
452
- export const ENGINE_VERSION = '1.53.0';
452
+ // 1.54.0 (2026-09-14): ALERT IS NOT SPOTTED. A host's ice hunts only the
453
+ // personas the host has SEEN (Host.spotted, shared by all its IC,
454
+ // p.247): a blown Sleaze's free mark, a Patrol sighting, Probe's grip.
455
+ // A successful Attack (Brute Force, the default stance) alerts the host
456
+ // without spotting you (p.236); an alerted host's Patrol then runs
457
+ // Matrix Perception every action (DT p.86) -- a loud icon is seen with
458
+ // no test, a silent one gets its opposed roll, tie to the hider -- and
459
+ // the host fight opens only on a sighting and closes when nobody in
460
+ // view is left. New verb: "hide" (p.240). Matrix Search inside a host
461
+ // is reachable again.
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';
453
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