@maka/maka-cli 5.185.0 → 5.186.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/game.js +217 -6
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +43 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grids.js +22 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/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.186.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|
|
@@ -110,6 +110,12 @@ export class DownloadCommand extends Command {
|
|
|
110
110
|
const fromSprite = carried.find(c => c.item.name === picked);
|
|
111
111
|
const fromFar = !fromSprite && !loose.some(i => i.name === picked) ? far.find(f => f.item.name === picked) : undefined;
|
|
112
112
|
const item = fromSprite ? fromSprite.item : fromFar ? fromFar.item : loose.find(i => i.name === picked);
|
|
113
|
+
// A LOOSE END IS ERASED, NOT DOWNLOADED (take.ts has the same gate):
|
|
114
|
+
// the desk keeps its copy whatever comes down to your chip.
|
|
115
|
+
const wc = this.scene.getWinCondition?.();
|
|
116
|
+
if (wc?.type === 'erase' && wc.item.toLowerCase() === item.name.toLowerCase()) {
|
|
117
|
+
return `Pulling ${item.name} down changes nothing -- the desk keeps its own. ${hint(`"erase ${item.name}" is what makes it go away (Edit File, p.239).`)}`;
|
|
118
|
+
}
|
|
113
119
|
// THE CONVERSION, identical to take.ts's. Same two fields, because a
|
|
114
120
|
// file that came down by one verb and a file that came down by the
|
|
115
121
|
// other must be the same object afterwards -- a chip you can `give`.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Command } from './command.js';
|
|
2
|
+
import { hint } from '../utilities/hints.js';
|
|
3
|
+
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
4
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
5
|
+
import { hostDefensePool } from '../utilities/matrix-intrusion.js';
|
|
6
|
+
import { hostLabel } from '../utilities/grid-names.js';
|
|
7
|
+
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
8
|
+
/**
|
|
9
|
+
* EDIT FILE (SR5 p.239, RAG-checked 2026-09-14): a Complex Action, Marks
|
|
10
|
+
* Required 1, Computer + Logic [Data Processing] v. Intuition + Firewall.
|
|
11
|
+
* "Edit File allows you to create, change, copy, delete, or protect any
|
|
12
|
+
* kind of file. The defender against this test is either the host
|
|
13
|
+
* holding the file or the owner of the file." It is a DATA PROCESSING
|
|
14
|
+
* action -- not Attack, not Sleaze -- so it is legal and GOD does not
|
|
15
|
+
* count it (p.231, and the worked example at p.231-232: "since that's a
|
|
16
|
+
* Data Processing action, he doesn't need to worry about his OS going up
|
|
17
|
+
* again"). A failed edit is not a blown Sleaze either: nothing upstairs
|
|
18
|
+
* notices a checksum that held.
|
|
19
|
+
*
|
|
20
|
+
* WHAT SHIPS (loose ends, 2026-09-14): DELETE. The cleanup's objective
|
|
21
|
+
* is a file the site keeps -- camera footage with the runner's face --
|
|
22
|
+
* and copying it (the engine's `take` / `download`, which already
|
|
23
|
+
* converts a matrix file to a chip) changes nothing about what the desk
|
|
24
|
+
* still holds. Create, change and protect are named here as not built;
|
|
25
|
+
* Crack File and the Data Bomb family wait on a per-file protection
|
|
26
|
+
* rating (models/item.ts) and are their own item.
|
|
27
|
+
*
|
|
28
|
+
* TWO ENGINE RULINGS, both labelled. Canon is silent on how a file INSIDE
|
|
29
|
+
* a host is marked (only archive files carry marks, p.247): here the
|
|
30
|
+
* persona must be inside the host and hold a mark on it -- the host is
|
|
31
|
+
* the file's keeper and the door was the mark. And a host has no
|
|
32
|
+
* Intuition to defend with: Host Rating + Firewall stands in, exactly
|
|
33
|
+
* the hostDefensePool ruling the intrusion roll already makes.
|
|
34
|
+
*
|
|
35
|
+
* FOUND FIRST: the archive is listed for a persona whose Matrix Search
|
|
36
|
+
* turned it up (Host.searchedBy, commands/search.ts) or once the host is
|
|
37
|
+
* cracked. You cannot delete what you have not seen.
|
|
38
|
+
*/
|
|
39
|
+
export class EditFileCommand extends Command {
|
|
40
|
+
static verb = 'edit';
|
|
41
|
+
static description = 'Edit File (Complex Action, SR5 p.239): "edit delete <file>" -- Computer + Logic [Data Processing] v. the host\'s Rating + Firewall, from inside the host with a mark on it, once a search has turned the file up. A Data Processing action: legal, and GOD does not count it. "erase <file>" is the same delete.';
|
|
42
|
+
/** The operation this verb form performs; the delete forms fix it. */
|
|
43
|
+
operation(args) {
|
|
44
|
+
const first = (args[0] ?? '').toLowerCase();
|
|
45
|
+
if (/^(delete|erase|wipe|remove)$/.test(first))
|
|
46
|
+
return { op: 'delete', rest: args.slice(1) };
|
|
47
|
+
if (/^(create|change|copy|protect)$/.test(first))
|
|
48
|
+
return { op: 'other', word: first, rest: args.slice(1) };
|
|
49
|
+
return { op: 'other', rest: args };
|
|
50
|
+
}
|
|
51
|
+
async execute(args = []) {
|
|
52
|
+
const actor = this.actor;
|
|
53
|
+
const { op, word, rest } = this.operation(args);
|
|
54
|
+
if (op !== 'delete') {
|
|
55
|
+
const named = word ? `"${word}"` : 'that';
|
|
56
|
+
return `Edit File (p.239) can create, change, copy, delete or protect a file -- and this engine builds DELETE: "edit delete <file>" (or "erase <file>"). ${word === 'copy' ? 'Copying is "take" or "download" -- the file comes down to a chip.' : `${named} isn't built yet.`}`;
|
|
57
|
+
}
|
|
58
|
+
if (actor.plane !== 'matrix') {
|
|
59
|
+
return `Files are edited by a persona. ${hint(`"jack in" first.`)}`;
|
|
60
|
+
}
|
|
61
|
+
const host = actor.hostInside;
|
|
62
|
+
if (!host) {
|
|
63
|
+
return `You edit a host's files from INSIDE it (p.246). ${hint(`"enter" the host that keeps it.`)}`;
|
|
64
|
+
}
|
|
65
|
+
const room = host.over;
|
|
66
|
+
const files = host.files();
|
|
67
|
+
const query = rest.join(' ').trim();
|
|
68
|
+
if (!query) {
|
|
69
|
+
return files.length === 0
|
|
70
|
+
? `${hostLabel(room, { capital: true })} keeps nothing here to edit.`
|
|
71
|
+
: `Edit which file? ${hint(`("edit delete <file>". "search" lists the archive.)`)}`;
|
|
72
|
+
}
|
|
73
|
+
const found = host.searchedBy.has(actor.name) || room.hostCracked;
|
|
74
|
+
if (!found) {
|
|
75
|
+
return `You haven't found ${hostLabel(room)}'s archive yet -- there is nothing on your display to edit. ${hint(`"search" runs a Matrix Search inside the host (p.241).`)}`;
|
|
76
|
+
}
|
|
77
|
+
const picked = fuzzyPickName(query, files.map(f => f.name));
|
|
78
|
+
const file = picked ? files.find(f => f.name === picked) : undefined;
|
|
79
|
+
if (!file) {
|
|
80
|
+
return `No file called "${query}" in ${hostLabel(room)}'s archive.${files.length > 0 ? hint(` (It holds: ${files.map(f => f.name).join(', ')}.)`) : ''}`;
|
|
81
|
+
}
|
|
82
|
+
const marks = room.hostMarksBy.get(actor.name) ?? 0;
|
|
83
|
+
if (marks < 1) {
|
|
84
|
+
return `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
|
|
85
|
+
}
|
|
86
|
+
// Binder IC eats Data Processing; a bracket of 0 permits no hits.
|
|
87
|
+
const burned = actor.burnedAttributeRefusal('dataProcessing');
|
|
88
|
+
if (burned)
|
|
89
|
+
return burned;
|
|
90
|
+
const bill = billAction(this.scene, actor, 'complex', 'Edit File');
|
|
91
|
+
if (bill)
|
|
92
|
+
return bill;
|
|
93
|
+
const computer = actor.skillRating('computer');
|
|
94
|
+
// Inside a host no grid penalty applies (p.246); silence still taxes
|
|
95
|
+
// every Matrix action (p.235-236, Player.matrixActionPenalty).
|
|
96
|
+
const pool = Math.max(1, actor.logic + (computer > 0 ? computer : -1)
|
|
97
|
+
+ actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty);
|
|
98
|
+
const limit = actor.matrixAttribute('dataProcessing');
|
|
99
|
+
const defence = hostDefensePool(host);
|
|
100
|
+
const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
|
|
101
|
+
const resist = rollPool(defence.pool);
|
|
102
|
+
if (this.scene.isHumanControlled(actor)) {
|
|
103
|
+
this.logger.meta(`Edit File (delete) -- Computer + Logic [Data Processing]: ${formatRoll(attempt)}`);
|
|
104
|
+
this.logger.meta(` v. ${hostLabel(room)} (${defence.label}, rating standing in for Intuition): ${formatRoll(resist)}`);
|
|
105
|
+
}
|
|
106
|
+
actor.performAction('edits a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
|
|
107
|
+
if (attempt.hits <= resist.hits) {
|
|
108
|
+
this.logger.write(`${actor.name} failed to delete "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
|
|
109
|
+
return `The file's checksum holds -- your edit rolls off it and the archive keeps ${file.name}. Nothing upstairs noticed: a failed edit is not a failed sleaze.${hint(` (Try again; each attempt is a Complex Action.)`)}`;
|
|
110
|
+
}
|
|
111
|
+
room.inventory.removeItem(file.name);
|
|
112
|
+
room.plainSightItems.delete(file.name.toLowerCase());
|
|
113
|
+
this.logger.write(`${actor.name} deleted "${file.name}" from ${room.name}'s host: ${attempt.hits} v ${resist.hits}.`);
|
|
114
|
+
this.scene.addWorldEvent(`${file.name} was erased from ${host.name}'s archive.`);
|
|
115
|
+
const banner = this.scene.checkErased?.(file.name, actor.name);
|
|
116
|
+
this.scene.updateStatus();
|
|
117
|
+
return [
|
|
118
|
+
`{lightblue-fg}${file.name} comes apart under your hand -- sectors zeroed, index entry gone, the host's own checksum rewritten to agree. ${hostLabel(room, { capital: true })} no longer has it.{/lightblue-fg}`,
|
|
119
|
+
...(banner ? [banner] : []),
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** "erase <file>" / "wipe <file>": the delete form under its own name. */
|
|
124
|
+
export class EraseCommand extends EditFileCommand {
|
|
125
|
+
static verb = 'erase';
|
|
126
|
+
static description = 'Erase a file inside a host -- Edit File\'s delete (SR5 p.239): Computer + Logic [Data Processing] v. the host\'s Rating + Firewall, with a mark on the host, once "search" has turned the file up. Legal: GOD does not count it.';
|
|
127
|
+
operation(args) {
|
|
128
|
+
const first = (args[0] ?? '').toLowerCase();
|
|
129
|
+
return { op: 'delete', rest: /^(delete|erase|wipe|remove)$/.test(first) ? args.slice(1) : args };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=edit-file.js.map
|
|
@@ -26,34 +26,52 @@ export class HopCommand extends Command {
|
|
|
26
26
|
if (actor.hostInside) {
|
|
27
27
|
return `You're inside ${actor.hostInside.name}'s host -- there is no grid under you to hop from. ${hint(`"exit" first.`)}`;
|
|
28
28
|
}
|
|
29
|
-
const district = this.scene.localGrid;
|
|
30
29
|
const game = this.game ?? undefined;
|
|
30
|
+
// A LOOSE-END CLEANUP HAS ONE DOOR (Game.enterCleanup, 2026-09-14):
|
|
31
|
+
// the persona hopped onto the site's grid and the only way off it is
|
|
32
|
+
// "jack out" -- hopping elsewhere would leave a scene with nobody in
|
|
33
|
+
// it and a body with no way back.
|
|
34
|
+
if (game?.inCleanup) {
|
|
35
|
+
return `Not from here -- this is the site's grid, and the only way off it is "jack out".`;
|
|
36
|
+
}
|
|
37
|
+
const district = this.scene.localGrid;
|
|
31
38
|
const tier = game?.lifestyleTier ?? 'Street';
|
|
32
39
|
const provider = game?.gridProvider ?? DEFAULT_GRID_PROVIDER;
|
|
33
40
|
const query = args.join(' ').trim();
|
|
34
41
|
const reachable = gridsWithAccess(tier, district, provider);
|
|
42
|
+
// LOOSE ENDS (Game.looseEndGrids): a site that kept your face runs a
|
|
43
|
+
// grid of its own, listed while the footage is still up there. No
|
|
44
|
+
// subscription buys it, so it is always the Hack on the Fly hop.
|
|
45
|
+
// Optional call: test stubs stand in for Game here (hop.test.ts).
|
|
46
|
+
const looseEnds = game?.looseEndGrids?.() ?? [];
|
|
47
|
+
const extras = looseEnds.map(e => e.grid);
|
|
35
48
|
if (!query) {
|
|
36
49
|
const here = actor.currentGrid?.name ?? 'an unnamed grid';
|
|
37
50
|
return [
|
|
38
51
|
`You're on ${here}.`,
|
|
39
52
|
` Grids you can hop to freely: ${reachable.map(g => g.name).join(', ')}.`,
|
|
40
|
-
|
|
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
|
|
@@ -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
|
}
|
|
@@ -104,6 +104,19 @@ export class Scene extends AbstractScene {
|
|
|
104
104
|
this.addActor(this.player);
|
|
105
105
|
this.game = game;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* ADOPT A PLAYER WHOSE BODY DOES NOT MOVE (Game.enterCleanup, 2026-09-14).
|
|
109
|
+
* initialize() seats the player at the seed's start room, which for a
|
|
110
|
+
* run is the arrival. A loose-end cleanup is entered from the Matrix:
|
|
111
|
+
* the body stays slumped wherever it jacked in (that room is added to
|
|
112
|
+
* this scene by the caller), so the seat must not change -- the
|
|
113
|
+
* currentLocation setter would clear the body's spot and fire a
|
|
114
|
+
* "move" save beat for a move that never happened.
|
|
115
|
+
*/
|
|
116
|
+
adopt(game) {
|
|
117
|
+
this.addActor(this.player);
|
|
118
|
+
this.game = game;
|
|
119
|
+
}
|
|
107
120
|
updateExits(room) {
|
|
108
121
|
this.game.updateExits(room);
|
|
109
122
|
}
|
|
@@ -979,6 +992,36 @@ export class Scene extends AbstractScene {
|
|
|
979
992
|
this.logger.logWithColor(banner, 'green');
|
|
980
993
|
return banner;
|
|
981
994
|
}
|
|
995
|
+
/**
|
|
996
|
+
* THE ERASE OBJECTIVE (loose ends, 2026-09-14): met when the named file
|
|
997
|
+
* is DELETED by Edit File (commands/edit-file.ts) -- a state change on
|
|
998
|
+
* something you leave behind, which is why it is not a possession
|
|
999
|
+
* check. No payout ever: nobody hired the runner for this. Karma is a
|
|
1000
|
+
* flat 1 -- a loose end tied off is worth a point (p.375's "survived"
|
|
1001
|
+
* floor), and there is no stake to scale it by. Returns null when this
|
|
1002
|
+
* is not the objective or it is already met.
|
|
1003
|
+
*/
|
|
1004
|
+
checkErased(itemName, byName) {
|
|
1005
|
+
const condition = this._winCondition;
|
|
1006
|
+
if (!condition || this._completed || condition.type !== 'erase')
|
|
1007
|
+
return null;
|
|
1008
|
+
if (condition.item.toLowerCase() !== itemName.toLowerCase())
|
|
1009
|
+
return null;
|
|
1010
|
+
this._completed = true;
|
|
1011
|
+
this.addWorldEvent(`${byName} erased ${itemName} -- the loose end is tied off.`);
|
|
1012
|
+
this.logger.write(`Scene erase objective met: "${byName}" erased "${itemName}".`);
|
|
1013
|
+
const karmaEarned = 1;
|
|
1014
|
+
for (const p of this.players) {
|
|
1015
|
+
if (this.deadPlayers.has(p.name))
|
|
1016
|
+
continue;
|
|
1017
|
+
p.karma += karmaEarned;
|
|
1018
|
+
p.careerKarma += karmaEarned;
|
|
1019
|
+
}
|
|
1020
|
+
const karmaNote = `\nKarma earned: ${karmaEarned} (${this.player.karma} banked${hint(` -- "advance" at your hideout to train`)}).`;
|
|
1021
|
+
const banner = `${condition.completionMessage}${karmaNote}\n\nLOOSE END TIED OFF\n${hint(`"jack out" when you're done here -- the grid closes behind you.`)}`;
|
|
1022
|
+
this.logger.logWithColor(banner, 'green');
|
|
1023
|
+
return banner;
|
|
1024
|
+
}
|
|
982
1025
|
/**
|
|
983
1026
|
* Money actually landing in the runner's hands: accumulates on ONE
|
|
984
1027
|
* certified credstick across jobs (minting a same-named stick per job
|
|
@@ -57,11 +57,25 @@ export function hasAccess(lifestyleTier, district, target, provider = DEFAULT_GR
|
|
|
57
57
|
export function hopDefenceDice(target) {
|
|
58
58
|
return target.kind === 'public' ? 0 : target.kind === 'local' ? 4 : 6;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* THE GRID A LOOSE END HANGS ON (Game.enterCleanup, 2026-09-14): the
|
|
62
|
+
* site whose cameras kept the runner's face runs its own local grid, and
|
|
63
|
+
* "hop" lists it while the record is open. Nobody's lifestyle buys
|
|
64
|
+
* access to it, so the hop is always the illegal one (p.240, Hack on the
|
|
65
|
+
* Fly v. 4 dice -- a local grid) -- exactly the rule hop.ts already
|
|
66
|
+
* implements, not a new gate. `kind: 'local'` is the same metroplex:
|
|
67
|
+
* canon lets you reach a local grid only from inside its service area
|
|
68
|
+
* (p.220-221), and the site is across town.
|
|
69
|
+
*/
|
|
70
|
+
export function looseEndGrid(le) {
|
|
71
|
+
return { key: `local:loose-end-${slug(le.id)}`, kind: 'local', name: `the ${le.site.trim()} site grid`, userPenalty: 0 };
|
|
72
|
+
}
|
|
60
73
|
/**
|
|
61
74
|
* A grid by what a player types: "public", "local", the district's name,
|
|
62
|
-
* a Big Ten name, or "global <corp>"
|
|
75
|
+
* a Big Ten name, or "global <corp>" -- or one of `extras` (a loose end's
|
|
76
|
+
* site grid) by its site or full name. Undefined when nothing answers.
|
|
63
77
|
*/
|
|
64
|
-
export function resolveGridName(query, district) {
|
|
78
|
+
export function resolveGridName(query, district, extras = []) {
|
|
65
79
|
const q = query.trim().toLowerCase().replace(/^the\s+/, '').replace(/\s+grid$/, '');
|
|
66
80
|
if (q === '')
|
|
67
81
|
return undefined;
|
|
@@ -69,6 +83,12 @@ export function resolveGridName(query, district) {
|
|
|
69
83
|
return PUBLIC_GRID;
|
|
70
84
|
if (q === 'local' || slug(q) === district.key.slice('local:'.length))
|
|
71
85
|
return district;
|
|
86
|
+
const extra = extras.find(g => {
|
|
87
|
+
const name = g.name.toLowerCase().replace(/^the\s+/, '').replace(/\s+grid$/, '');
|
|
88
|
+
return slug(name) === slug(q) || slug(name.replace(/\s+site$/, '')) === slug(q);
|
|
89
|
+
});
|
|
90
|
+
if (extra)
|
|
91
|
+
return extra;
|
|
72
92
|
const corpWord = q.replace(/^global\s+/, '');
|
|
73
93
|
const corp = BIG_TEN.find(c => c.toLowerCase() === corpWord || slug(c) === slug(corpWord));
|
|
74
94
|
return corp ? globalGrid(corp) : undefined;
|
|
@@ -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.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.",
|