@maka/maka-cli 5.185.0 → 5.187.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +132 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +27 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +23 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js +17 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +13 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/cleanup-seed.js +89 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +116 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.js +217 -6
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +22 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +31 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js +22 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +34 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.187.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
|
|
@@ -556,6 +556,31 @@ export class HackCommand extends BypassCommand {
|
|
|
556
556
|
this.logger.meta(`${defense.label} (DR ${device.rating}): ${formatRoll(defenseRoll)} -- net ${netHits >= 0 ? '+' : ''}${netHits}`);
|
|
557
557
|
}
|
|
558
558
|
const godLines = this.godBill(overwatchFromDefense(defenseRoll.hits), `device intrusion (${mode})`);
|
|
559
|
+
// WHY THE KEYS YOU ALREADY HOLD DID NOT OPEN THIS (u8u2koi3THQM9dzSz:
|
|
560
|
+
// "I _OWN_ the host with 3 marks, I should be able to hack the
|
|
561
|
+
// maglock, or simply disable it").
|
|
562
|
+
//
|
|
563
|
+
// Checked against the books rather than the report's own reading:
|
|
564
|
+
// marks share ONE WAY. "If you get a mark on a slave you also get a
|
|
565
|
+
// mark on the master ... This doesn't work both ways" (p.233). Three
|
|
566
|
+
// marks on the host are three marks on the HOST; the lock hanging off
|
|
567
|
+
// it is still its own icon with its own monitor, and Control Device
|
|
568
|
+
// (p.238) counts marks on the thing being controlled. So the engine
|
|
569
|
+
// was right to make them roll -- and said nothing at all about why,
|
|
570
|
+
// which is the part that earned the report. A rule the player cannot
|
|
571
|
+
// see is indistinguishable from a bug.
|
|
572
|
+
//
|
|
573
|
+
// The line also names the move that DOES cash those marks in: inside
|
|
574
|
+
// the host you are directly connected to everything on its WAN, and
|
|
575
|
+
// a directly-connected slave "cannot use its master's ratings to
|
|
576
|
+
// defend itself" (p.233) -- which is exactly the pool that just beat
|
|
577
|
+
// them. Only said when they actually hold host marks and are still
|
|
578
|
+
// outside; from in here it would be telling them to walk through the
|
|
579
|
+
// door they are standing in.
|
|
580
|
+
const hostMarksHeld = viaWan && host ? host.hostMarksBy.get(actor.name) ?? 0 : 0;
|
|
581
|
+
const wanNote = hostMarksHeld > 0
|
|
582
|
+
? hint(` (Your ${hostMarksHeld} mark${hostMarksHeld === 1 ? '' : 's'} on ${hostLabel(host)} buy nothing against the lock itself -- marks run slave-to-master, never master-to-slave (p.233). "enter ${hostLabel(host)}": from inside its WAN you are directly connected, and ${device.name} has to defend on DR ${device.rating} alone.)`)
|
|
583
|
+
: '';
|
|
559
584
|
if (netHits <= 0) {
|
|
560
585
|
if (mode === 'sleaze') {
|
|
561
586
|
// A blown Sleaze is NOTICED (p.236) -- and a slaved device that
|
|
@@ -565,14 +590,14 @@ export class HackCommand extends BypassCommand {
|
|
|
565
590
|
host.hostAlert = true;
|
|
566
591
|
this.scene.addWorldEvent(`${device.name} flags a bad handshake -- ${hostLabel(host, { capital: true })} is awake.`);
|
|
567
592
|
this.scene.updateStatus();
|
|
568
|
-
return [`${device.name} refuses the key and SAYS SO -- the node it answers to knows something is in here
|
|
593
|
+
return [`${device.name} refuses the key and SAYS SO -- the node it answers to knows something is in here.${wanNote}`, ...godLines].join('\n');
|
|
569
594
|
}
|
|
570
595
|
// A loose device has no host to wake; its owner still gets the
|
|
571
596
|
// mark on you a blown Sleaze hands over (p.240).
|
|
572
597
|
this.scene.updateStatus();
|
|
573
598
|
return [`${device.name} refuses the key and SAYS SO -- whoever owns it has your icon on file now.`, ...godLines].join('\n');
|
|
574
599
|
}
|
|
575
|
-
return [`${device.name} holds against the smash -- its firewall never even flexes. Nobody upstairs noticed
|
|
600
|
+
return [`${device.name} holds against the smash -- its firewall never even flexes. Nobody upstairs noticed.${wanNote}`, ...godLines].join('\n');
|
|
576
601
|
}
|
|
577
602
|
const now = Math.min(MAX_MARKS, held + declared);
|
|
578
603
|
device.marksBy.set(actor.name, now);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
@@ -733,6 +733,15 @@ For EVERY item in the skeleton, expand it into a full item ("name" and "category
|
|
|
733
733
|
Clue requirements (mechanically validated -- the exact phrase must appear in "details"):
|
|
734
734
|
${clueSpecs || '- (no clue items in this scene)'}
|
|
735
735
|
|
|
736
|
+
THE ONLY PRIZE IN THIS WORLD is "${skeleton.winCondition?.item ?? ''}" -- the item
|
|
737
|
+
"winCondition" is for. No other item may present itself as the thing the client wants: not
|
|
738
|
+
"it's what ${skeleton.winCondition?.toNpc || skeleton.client || 'the Johnson'} is looking
|
|
739
|
+
for", not "this is what the job is about", not a second set of goods that reads like the
|
|
740
|
+
objective wearing a different name. The engine stars exactly one item and pays out on
|
|
741
|
+
exactly one; a second item that CLAIMS the role sends the runner home with the wrong thing
|
|
742
|
+
in their pack, believing they blew the job they just finished. Loot is loot -- say what it
|
|
743
|
+
IS, never what it is worth to the client.
|
|
744
|
+
|
|
736
745
|
THE ONLY CODES IN THIS WORLD are the clue phrases listed just above. No other item may
|
|
737
746
|
carry a code-shaped string -- no serial numbers, no model numbers, no unit or locker or
|
|
738
747
|
apartment numbers, no "access code" on a scrap of paper that isn't a listed clue. A player
|
|
@@ -2046,6 +2055,112 @@ export function ensureCredentialKindAgrees(details) {
|
|
|
2046
2055
|
}
|
|
2047
2056
|
return notes;
|
|
2048
2057
|
}
|
|
2058
|
+
/**
|
|
2059
|
+
* ONE PRIZE, AND THE PROSE HAS TO AGREE (aSpARWJXXieMAL4eM, 2026-09-14:
|
|
2060
|
+
* "the item marked with the star as the mission objective was 'The
|
|
2061
|
+
* Scout's Contact Sheet'. But the mission was clearly about camera
|
|
2062
|
+
* data ... When I returned to the Hub, I had 'Developed Negatives',
|
|
2063
|
+
* which state it's what Mr. Leger is looking for. It's like the two
|
|
2064
|
+
* items got mis labeld as to what was important.").
|
|
2065
|
+
*
|
|
2066
|
+
* Nothing was mislabelled in the STATE. winCondition.item was the
|
|
2067
|
+
* contact sheet, the star followed it (planes.ts objectiveMark), the
|
|
2068
|
+
* completion check accepted it, and the payout landed. What went wrong
|
|
2069
|
+
* is that the items pass wrote a SECOND item's flavour as though that
|
|
2070
|
+
* one were the job -- so the run had one prize by the engine's reckoning
|
|
2071
|
+
* and a different one by its own prose, and the player believed the
|
|
2072
|
+
* prose. That is worse than a wrong star: they finished the job
|
|
2073
|
+
* convinced they had failed it.
|
|
2074
|
+
*
|
|
2075
|
+
* Same family as payoutSummary and heatSummary in npc.ts -- the model
|
|
2076
|
+
* inventing a fact the engine already owns -- and the same answer:
|
|
2077
|
+
* state the fact in the prompt (buildItemsPrompt, "THE ONLY PRIZE IN
|
|
2078
|
+
* THIS WORLD"), and enforce it here so a draft that ignores the rule
|
|
2079
|
+
* costs nothing.
|
|
2080
|
+
*
|
|
2081
|
+
* SURGERY, narrowly, and this file's own preference is against it:
|
|
2082
|
+
* ensureCredentialKindAgrees corrects "by ADDING a plain sentence rather
|
|
2083
|
+
* than rewriting generated prose". That works when the generated line is
|
|
2084
|
+
* merely incomplete. It cannot work here, because the line is FALSE --
|
|
2085
|
+
* appending "but the client doesn't want this" under "this is what the
|
|
2086
|
+
* client wants" leaves prose that argues with itself, which is exactly
|
|
2087
|
+
* the confusion the report describes. So the false sentence comes out,
|
|
2088
|
+
* whole, and everything around it is left byte-identical.
|
|
2089
|
+
*
|
|
2090
|
+
* THE BAR IS THREE THINGS IN ONE SENTENCE, because two is not enough to
|
|
2091
|
+
* tell a claim from colour: someone the job is ABOUT (the client by
|
|
2092
|
+
* name, or a generic for them), a verb of WANTING, and a reference back
|
|
2093
|
+
* to THIS item. "Ledger drinks alone and wants nothing from anybody"
|
|
2094
|
+
* keeps all three words apart and survives; "it's what Mr. Ledger is
|
|
2095
|
+
* looking for" does not.
|
|
2096
|
+
*/
|
|
2097
|
+
const PRIZE_WANTING = /\b(look(?:s|ing|ed)?\s+for|after\s+(?:this|it|these|them)|want(?:s|ed|ing)?|need(?:s|ed|ing)?|ask(?:s|ed|ing)?\s+for|pay(?:s|ing)?\s+for|hir(?:ed|ing)\s+\w+\s+for|sent\s+\w+\s+for|came\s+for|is\s+the\s+(?:job|prize|objective|target))\b/i;
|
|
2098
|
+
const PRIZE_SELF = /\b(this|these|those|it|its|it's|the\s+one)\b/i;
|
|
2099
|
+
const PRIZE_GENERIC_CLIENT = /\b(the\s+client|the\s+johnson|mr\.?\s+johnson|your\s+employer|the\s+employer|the\s+buyer|the\s+job|the\s+run|the\s+contract)\b/i;
|
|
2100
|
+
/**
|
|
2101
|
+
* Sentence-ish split that keeps the terminator with its sentence.
|
|
2102
|
+
*
|
|
2103
|
+
* IT DOES HAVE TO HANDLE "Mr." -- this comment first said it didn't,
|
|
2104
|
+
* and the first test written against it failed. Every client in this
|
|
2105
|
+
* game is "a Mr. Johnson" (canon p.27-28), so the one sentence this
|
|
2106
|
+
* function exists to read -- "It's what Mr. Ledger is looking for." --
|
|
2107
|
+
* is precisely the one a naive split cuts in half, into two fragments
|
|
2108
|
+
* neither of which claims anything. The titles below are the ones a
|
|
2109
|
+
* Shadowrun scene actually writes; a single capital initial ("J.
|
|
2110
|
+
* Ledger") goes with them.
|
|
2111
|
+
*/
|
|
2112
|
+
const SENTENCE_BREAK = /(?<!\b(?:Mr|Mrs|Ms|Dr|Sr|Jr|St|Prof|Rev|Inc|Ltd|Co|vs|No|[A-Z])\.)(?<=[.!?])\s+/;
|
|
2113
|
+
function prizeSentences(text) {
|
|
2114
|
+
return text.split(SENTENCE_BREAK).filter(s => s.length > 0);
|
|
2115
|
+
}
|
|
2116
|
+
function claimsThePrize(sentence, clientNames) {
|
|
2117
|
+
if (!PRIZE_WANTING.test(sentence) || !PRIZE_SELF.test(sentence))
|
|
2118
|
+
return false;
|
|
2119
|
+
const named = clientNames.some(n => n.length > 0 && sentence.toLowerCase().includes(n.toLowerCase()));
|
|
2120
|
+
return named || PRIZE_GENERIC_CLIENT.test(sentence);
|
|
2121
|
+
}
|
|
2122
|
+
export function ensureOnePrize(skeleton, details) {
|
|
2123
|
+
const notes = [];
|
|
2124
|
+
const prize = (skeleton.winCondition?.item ?? '').trim().toLowerCase();
|
|
2125
|
+
if (prize.length === 0)
|
|
2126
|
+
return notes;
|
|
2127
|
+
// The people a claim can be made on behalf of: the client who holds
|
|
2128
|
+
// the meet, and whoever the goods are owed to (usually the same
|
|
2129
|
+
// person, sometimes not -- a courier job delivers to a third party).
|
|
2130
|
+
// Last names alone are how these lines actually read ("Ledger takes
|
|
2131
|
+
// the sleeve"), so each name contributes its words too.
|
|
2132
|
+
const clientNames = [skeleton.client ?? '', skeleton.winCondition?.toNpc ?? '']
|
|
2133
|
+
.flatMap(n => [n, ...n.split(/\s+/)])
|
|
2134
|
+
.map(n => n.replace(/[^\w\s'-]/g, '').trim())
|
|
2135
|
+
.filter(n => n.length >= 4);
|
|
2136
|
+
for (const item of details.items ?? []) {
|
|
2137
|
+
if ((item.name ?? '').trim().toLowerCase() === prize)
|
|
2138
|
+
continue;
|
|
2139
|
+
for (const field of ['description', 'details']) {
|
|
2140
|
+
const text = (item[field] ?? '').trim();
|
|
2141
|
+
if (text.length === 0)
|
|
2142
|
+
continue;
|
|
2143
|
+
const kept = prizeSentences(text).filter(s => !claimsThePrize(s, clientNames));
|
|
2144
|
+
if (kept.length === prizeSentences(text).length)
|
|
2145
|
+
continue;
|
|
2146
|
+
const rebuilt = kept.join(' ').trim();
|
|
2147
|
+
// A description is always-visible flavour and cannot be blank --
|
|
2148
|
+
// an item with no line at all reads as a generator failure. A
|
|
2149
|
+
// "details" that empties out simply goes away, which is what an
|
|
2150
|
+
// item with nothing more to say should do.
|
|
2151
|
+
if (field === 'description') {
|
|
2152
|
+
item.description = rebuilt.length > 0
|
|
2153
|
+
? rebuilt
|
|
2154
|
+
: `Someone's property once, and worth carrying if nobody's looking.`;
|
|
2155
|
+
}
|
|
2156
|
+
else {
|
|
2157
|
+
item.details = rebuilt.length > 0 ? rebuilt : undefined;
|
|
2158
|
+
}
|
|
2159
|
+
notes.push(`normalized: item "${item.name}" claimed in its ${field} to be what the job is for; "${skeleton.winCondition.item}" is, so the claim was cut.`);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
return notes;
|
|
2163
|
+
}
|
|
2049
2164
|
/**
|
|
2050
2165
|
* RESOLVE A NAME ONE LIST WROTE AGAINST THE LIST THAT OWNS IT.
|
|
2051
2166
|
*
|
|
@@ -2110,6 +2225,7 @@ export function assembleScene(skeleton, details, playerName) {
|
|
|
2110
2225
|
// The answer has to be legible before anything is assembled from it.
|
|
2111
2226
|
ensureClueDetails(skeleton, details);
|
|
2112
2227
|
ensureCredentialKindAgrees(details);
|
|
2228
|
+
ensureOnePrize(skeleton, details);
|
|
2113
2229
|
const roomDetail = new Map(details.rooms.map(r => [r.name, r]));
|
|
2114
2230
|
const npcDetail = new Map(details.npcs.map(n => [n.name, n]));
|
|
2115
2231
|
const deviceDetail = new Map(details.devices.map(d => [d.name, d]));
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -458,6 +458,28 @@ export function splitSpokenLines(lines) {
|
|
|
458
458
|
rebuilt.push(`[COMMAND] say ${bracketed[1]}`);
|
|
459
459
|
continue;
|
|
460
460
|
}
|
|
461
|
+
// ...AND SO IS ONE FILED UNDER A TAG THE MODEL INVENTED
|
|
462
|
+
// (zQTWGJFWDXoLtiJX6, 2026-09-13: Whisper's whole sales line
|
|
463
|
+
// arrived as `[TALK] say Depends what you're buying or
|
|
464
|
+
// confessing. The credstick's hot and priced like it...` and
|
|
465
|
+
// printed into the world log verbatim, marker and all -- the
|
|
466
|
+
// reporter first read it as his OWN typing echoed back).
|
|
467
|
+
//
|
|
468
|
+
// Third sighting of one root: the model reaches for the bracket
|
|
469
|
+
// convention and gets the WORD inside it wrong. [say] was the
|
|
470
|
+
// second; this is the general case, and generalising beats
|
|
471
|
+
// adding [TALK] to a list that would need [DIALOGUE], [ACTION]
|
|
472
|
+
// and whatever the next model invents.
|
|
473
|
+
//
|
|
474
|
+
// The bound that holds every repair in this function holds here
|
|
475
|
+
// too: the VERB after the bracket must be `say`. `[go] north`
|
|
476
|
+
// stays exactly what it was -- only `say` carries free prose, so
|
|
477
|
+
// only `say` can be promoted without risking a silent act.
|
|
478
|
+
const tagged = t.match(/^\[\s*[A-Za-z][A-Za-z ._-]*\s*\]\s*say\s+(\S.*)$/i);
|
|
479
|
+
if (tagged) {
|
|
480
|
+
rebuilt.push(`[COMMAND] say ${tagged[1]}`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
461
483
|
// ...AND A LINE THAT IS NOTHING BUT SPEECH MARKS IS SPEECH
|
|
462
484
|
// (playtest 2026-08-27, reported twice in one session: 'the
|
|
463
485
|
// "Take a seat..." comment isn't showing as he said it. It's in
|
|
@@ -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
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hostConcealedFrom } from './jackpoint.js';
|
|
2
2
|
import { matchesHostName, hostLabel } from './grid-names.js';
|
|
3
|
+
import { fuzzyPickName } from './fuzzy-match.js';
|
|
3
4
|
/**
|
|
4
5
|
* THE GRID HAS NO STREETS (user canon check 2026-09-02; SR5 pp.217-218,
|
|
5
6
|
* p.246). Jacked in and outside a host, a persona is on its grid: "a
|
|
@@ -44,6 +45,28 @@ export function hostOver(room) {
|
|
|
44
45
|
* anywhere on the grid. Bare "host" (or nothing) means the one over the
|
|
45
46
|
* persona's vicinity, else nothing -- with several overhead, a bare word
|
|
46
47
|
* cannot pick.
|
|
48
|
+
*
|
|
49
|
+
* THE ALIAS LIST IS EXACT, AND EXACT IS NOT ENOUGH (qHwKnqEGrJFn3tmF7).
|
|
50
|
+
* grid-names.ts made the printer and the resolver agree, which fixed
|
|
51
|
+
* the lie -- but it left the player typing the label back BYTE FOR
|
|
52
|
+
* BYTE. A run whose room was called "The Grinning Idol -- Back Room"
|
|
53
|
+
* refused `mark Back Room host` and accepted only
|
|
54
|
+
* `mark The Grinning Idol -- Back Room host`; the reporter's word for
|
|
55
|
+
* it was "ridiculous", and they are right. A name you can read off the
|
|
56
|
+
* screen but not shorten is a name you have to transcribe.
|
|
57
|
+
*
|
|
58
|
+
* So: aliases first, unchanged -- an exact hit still resolves exactly,
|
|
59
|
+
* and the vicinity still wins ties. Only when nothing answers does the
|
|
60
|
+
* shared fuzzy matcher get a turn, over the names the map actually
|
|
61
|
+
* printed. It is the same resolver items and NPCs go through, with the
|
|
62
|
+
* same discipline: an AMBIGUOUS guess misses rather than picking a host
|
|
63
|
+
* at random, which matters more here than anywhere -- cracking the
|
|
64
|
+
* wrong node spends the action, wakes the wrong ice and cannot be
|
|
65
|
+
* taken back.
|
|
66
|
+
*
|
|
67
|
+
* The trailing "host"/"node" comes off first: it is part of the LABEL
|
|
68
|
+
* ("the Back Room host"), never part of the room's name, and leaving it
|
|
69
|
+
* on drags every score down by a word.
|
|
47
70
|
*/
|
|
48
71
|
export function resolveHostInReach(rooms, actor, query) {
|
|
49
72
|
const q = query.trim().toLowerCase();
|
|
@@ -56,7 +79,14 @@ export function resolveHostInReach(rooms, actor, query) {
|
|
|
56
79
|
const near = hostOver(gridVicinity(actor));
|
|
57
80
|
if (near && matchesHostName(q, near))
|
|
58
81
|
return near;
|
|
59
|
-
|
|
82
|
+
const exact = hosts.find(r => matchesHostName(q, r));
|
|
83
|
+
if (exact)
|
|
84
|
+
return exact;
|
|
85
|
+
const bare = q.replace(/\s*\b(hosts?|nodes?)\s*$/, '').trim();
|
|
86
|
+
if (bare.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const picked = fuzzyPickName(bare, hosts.map(r => r.name));
|
|
89
|
+
return picked ? hosts.find(r => r.name === picked) : undefined;
|
|
60
90
|
}
|
|
61
91
|
/** Noise between the persona and a room's icons: 0 for the vicinity (and inside a host), -DISTRICT_NOISE dice elsewhere. */
|
|
62
92
|
export function noiseFor(actor, room) {
|
|
@@ -57,11 +57,25 @@ export function hasAccess(lifestyleTier, district, target, provider = DEFAULT_GR
|
|
|
57
57
|
export function hopDefenceDice(target) {
|
|
58
58
|
return target.kind === 'public' ? 0 : target.kind === 'local' ? 4 : 6;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* THE GRID A LOOSE END HANGS ON (Game.enterCleanup, 2026-09-14): the
|
|
62
|
+
* site whose cameras kept the runner's face runs its own local grid, and
|
|
63
|
+
* "hop" lists it while the record is open. Nobody's lifestyle buys
|
|
64
|
+
* access to it, so the hop is always the illegal one (p.240, Hack on the
|
|
65
|
+
* Fly v. 4 dice -- a local grid) -- exactly the rule hop.ts already
|
|
66
|
+
* implements, not a new gate. `kind: 'local'` is the same metroplex:
|
|
67
|
+
* canon lets you reach a local grid only from inside its service area
|
|
68
|
+
* (p.220-221), and the site is across town.
|
|
69
|
+
*/
|
|
70
|
+
export function looseEndGrid(le) {
|
|
71
|
+
return { key: `local:loose-end-${slug(le.id)}`, kind: 'local', name: `the ${le.site.trim()} site grid`, userPenalty: 0 };
|
|
72
|
+
}
|
|
60
73
|
/**
|
|
61
74
|
* A grid by what a player types: "public", "local", the district's name,
|
|
62
|
-
* a Big Ten name, or "global <corp>"
|
|
75
|
+
* a Big Ten name, or "global <corp>" -- or one of `extras` (a loose end's
|
|
76
|
+
* site grid) by its site or full name. Undefined when nothing answers.
|
|
63
77
|
*/
|
|
64
|
-
export function resolveGridName(query, district) {
|
|
78
|
+
export function resolveGridName(query, district, extras = []) {
|
|
65
79
|
const q = query.trim().toLowerCase().replace(/^the\s+/, '').replace(/\s+grid$/, '');
|
|
66
80
|
if (q === '')
|
|
67
81
|
return undefined;
|
|
@@ -69,6 +83,12 @@ export function resolveGridName(query, district) {
|
|
|
69
83
|
return PUBLIC_GRID;
|
|
70
84
|
if (q === 'local' || slug(q) === district.key.slice('local:'.length))
|
|
71
85
|
return district;
|
|
86
|
+
const extra = extras.find(g => {
|
|
87
|
+
const name = g.name.toLowerCase().replace(/^the\s+/, '').replace(/\s+grid$/, '');
|
|
88
|
+
return slug(name) === slug(q) || slug(name.replace(/\s+site$/, '')) === slug(q);
|
|
89
|
+
});
|
|
90
|
+
if (extra)
|
|
91
|
+
return extra;
|
|
72
92
|
const corpWord = q.replace(/^global\s+/, '');
|
|
73
93
|
const corp = BIG_TEN.find(c => c.toLowerCase() === corpWord || slug(c) === slug(corpWord));
|
|
74
94
|
return corp ? globalGrid(corp) : undefined;
|
|
@@ -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.
|
|
3
|
+
"version": "5.187.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.",
|