@maka/maka-cli 5.390.0 → 5.392.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/assense.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +60 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/breach.js +5 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/bypass-command.js +8 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +4 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +16 -150
- package/bundle/typescript/src/commands/game/sideQuest/commands/crack.js +76 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +42 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/disarm.js +64 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +23 -46
- package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +23 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +15 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +55 -17
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +15 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/leap.js +23 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +40 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +16 -15
- package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +13 -3
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/authored-run.js +632 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/catalog-item-seed.js +78 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/job-order.js +7 -44
- package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +8 -10
- package/bundle/typescript/src/commands/game/sideQuest/factories/room-factory.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +30 -52
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +112 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +123 -3
- package/bundle/typescript/src/commands/game/sideQuest/factories/seed-migration.js +52 -4
- package/bundle/typescript/src/commands/game/sideQuest/game.js +17 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/camera.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/device.js +14 -21
- package/bundle/typescript/src/commands/game/sideQuest/models/host.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +62 -9
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +11 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +141 -3
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/affordances.js +18 -11
- package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +4 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +7 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +9 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/district-plane.js +109 -27
- package/bundle/typescript/src/commands/game/sideQuest/utilities/door-watch.js +70 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/falling.js +27 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/file-guard.js +120 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-names.js +10 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +12 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +23 -5
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +29 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ledge-jump.js +86 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +2 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +29 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/owner-guard.js +77 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/path-run.js +4 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +66 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +8 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spider.js +50 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +20 -6
- package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +193 -8
- package/bundle/typescript/src/commands/game/sideQuest/utilities/use-target.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/wards.js +116 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.392.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.",
|
|
@@ -121,7 +121,7 @@ export class AssenseCommand extends Command {
|
|
|
121
121
|
const room = actor.currentLocation;
|
|
122
122
|
const lines = [header];
|
|
123
123
|
lines.push(room.warded
|
|
124
|
-
? ` • This place is WARDED -- an astral barrier seals it against
|
|
124
|
+
? ` • This place is WARDED -- an astral barrier of Force ${room.wardForce} seals it against astral forms (bodies walk through it unhindered).`
|
|
125
125
|
: ` • No wards here -- the astral is open ground.`);
|
|
126
126
|
if (room.hasNode) {
|
|
127
127
|
lines.push(` • The room's tech hums with dataflow -- a live Matrix node (nothing astral, but a decker would care).`);
|
|
@@ -11,11 +11,13 @@ import { distanceMeters } from '../utilities/room-grid.js';
|
|
|
11
11
|
import { wallsBetween } from '../utilities/district-plane.js';
|
|
12
12
|
import { formatCoord, parseCoord } from '../utilities/barrier-geometry.js';
|
|
13
13
|
import { resolveBarrierStrike, resolveCameraStrike, resolveSpellBarrierStrike } from '../utilities/barrier-combat.js';
|
|
14
|
+
import { resolveWardStrike } from '../utilities/wards.js';
|
|
14
15
|
import { isWatched, cameraWithinReach, cameraOutOfReach } from '../utilities/surveillance.js';
|
|
15
16
|
import { matchesCameraName } from '../utilities/grid-names.js';
|
|
16
17
|
import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
|
|
17
18
|
import { arenaOf, inArena } from '../utilities/combat-turn.js';
|
|
18
19
|
import { spendMovementMeters } from '../utilities/movement-cost.js';
|
|
20
|
+
import { ownerWitnesses } from '../utilities/owner-guard.js';
|
|
19
21
|
/**
|
|
20
22
|
* Shadowrun-style dice-pool combat, one full exchange per command:
|
|
21
23
|
*
|
|
@@ -50,7 +52,7 @@ export class AttackCommand extends Command {
|
|
|
50
52
|
async readyForStrike() {
|
|
51
53
|
return {};
|
|
52
54
|
}
|
|
53
|
-
static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197). "attack barrier [x,y]" breaks a face of a barrier spell.';
|
|
55
|
+
static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197). "attack barrier [x,y]" breaks a face of a barrier spell; from the astral, "attack ward" tears at a ward (Armor and Structure = its Force, p.316).';
|
|
54
56
|
async execute(args = []) {
|
|
55
57
|
if (!args || args.length === 0) {
|
|
56
58
|
return 'Who do you want to attack? Try: `attack <name>`';
|
|
@@ -155,12 +157,36 @@ export class AttackCommand extends Command {
|
|
|
155
157
|
});
|
|
156
158
|
const lines = [...(drawn ? [`${this.actor.name} draws ${drawn.name}.`] : []), ...strike.world];
|
|
157
159
|
this.actor.performAction('attacks', `the ${lens.name}`);
|
|
160
|
+
// Their camera, smashed in front of them (owner-guard.ts).
|
|
161
|
+
const seenLens = ownerWitnesses(this.scene, room, this.actor, `the ${lens.name}`);
|
|
162
|
+
if (seenLens)
|
|
163
|
+
lines.push(seenLens);
|
|
158
164
|
this.scene.updateStatus();
|
|
159
165
|
const exchange = new CombatExchange(this.scene, this.logger, this.actor);
|
|
160
166
|
await exchange.announce(lines, strike.meta);
|
|
161
167
|
return this.scene.isHumanControlled(this.actor) ? '' : lines.join('\n');
|
|
162
168
|
}
|
|
163
169
|
}
|
|
170
|
+
// A WARD (p.316): "attack ward" tears at the ward over the room you
|
|
171
|
+
// are in, or a warded room next to it ("attack ward over <room>").
|
|
172
|
+
// Astral only: to a bullet a ward is not there at all.
|
|
173
|
+
const wardWords = /^(?:the\s+)?ward(?:\s+(?:over|on|of|at)\s+(.+))?$/i.exec(targetName.trim());
|
|
174
|
+
if (wardWords) {
|
|
175
|
+
const picked = this.wardTarget(wardWords[1]);
|
|
176
|
+
if (typeof picked === 'string')
|
|
177
|
+
return picked;
|
|
178
|
+
if (this.scene.encounterFor?.(this.actor)) {
|
|
179
|
+
const bill = billAction(this.scene, this.actor, 'complex', 'Attack Ward', { attack: true });
|
|
180
|
+
if (bill)
|
|
181
|
+
return bill;
|
|
182
|
+
}
|
|
183
|
+
const strike = resolveWardStrike(this.scene, this.logger, this.actor, picked);
|
|
184
|
+
this.actor.performAction('attacks', `the ward over ${picked.name}`);
|
|
185
|
+
this.scene.updateStatus();
|
|
186
|
+
const exchange = new CombatExchange(this.scene, this.logger, this.actor);
|
|
187
|
+
await exchange.announce(strike.world, strike.meta);
|
|
188
|
+
return this.scene.isHumanControlled(this.actor) ? '' : strike.world.join('\n');
|
|
189
|
+
}
|
|
164
190
|
// A BARRIER SPELL (2026-10-05; p.197-198, p.294, p.316): "attack
|
|
165
191
|
// barrier" hits the nearest standing face you can see, "attack
|
|
166
192
|
// barrier 3,5" the face holding that cell. A Physical Barrier is
|
|
@@ -235,6 +261,10 @@ export class AttackCommand extends Command {
|
|
|
235
261
|
});
|
|
236
262
|
const lines = [...(reach.line ? [reach.line] : []), ...(drawn ? [`${this.actor.name} draws ${drawn.name}.`] : []), ...strike.world];
|
|
237
263
|
this.actor.performAction('attacks', device.name);
|
|
264
|
+
// Their lock, hit in front of them (owner-guard.ts).
|
|
265
|
+
const seenDevice = ownerWitnesses(this.scene, room, this.actor, device.name);
|
|
266
|
+
if (seenDevice)
|
|
267
|
+
lines.push(seenDevice);
|
|
238
268
|
this.scene.updateStatus();
|
|
239
269
|
const exchange = new CombatExchange(this.scene, this.logger, this.actor);
|
|
240
270
|
await exchange.announce(lines, strike.meta);
|
|
@@ -601,6 +631,35 @@ export class AttackCommand extends Command {
|
|
|
601
631
|
}
|
|
602
632
|
return null;
|
|
603
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* THE WARD THIS FORM CAN REACH: the one over the room it is in (sealed
|
|
636
|
+
* in), else a warded room through one of its ways (kept out). Named
|
|
637
|
+
* when there is more than one. A refusal string when there is none, or
|
|
638
|
+
* when the attacker is not on the astral.
|
|
639
|
+
*/
|
|
640
|
+
wardTarget(named) {
|
|
641
|
+
if (this.actor.plane !== 'astral') {
|
|
642
|
+
return `A ward is woven mana on the astral -- ${this.actor.getCarriedWeapon()?.name ?? 'your fists'} would pass through it as if it were not there. Only an astral form can touch it (p.315).`;
|
|
643
|
+
}
|
|
644
|
+
const here = this.actor.currentLocation;
|
|
645
|
+
const near = here.warded ? [here] : [];
|
|
646
|
+
for (const exit of here.exits.values()) {
|
|
647
|
+
const other = typeof exit.getOtherSide === 'function'
|
|
648
|
+
? exit.getOtherSide(here) : undefined;
|
|
649
|
+
if (other?.warded && !near.includes(other))
|
|
650
|
+
near.push(other);
|
|
651
|
+
}
|
|
652
|
+
if (named) {
|
|
653
|
+
const pick = fuzzyPickName(named, near.map(r => r.name));
|
|
654
|
+
const ward = pick ? near.find(r => r.name === pick) : undefined;
|
|
655
|
+
return ward ?? `No ward over "${named}" within reach.${near.length > 0 ? ` In reach: ${near.map(r => r.name).join(', ')}.` : ''}`;
|
|
656
|
+
}
|
|
657
|
+
if (near.length === 0)
|
|
658
|
+
return `No ward within reach -- the astral here is open ground.`;
|
|
659
|
+
if (near.length > 1 && !here.warded)
|
|
660
|
+
return `Which ward? ${near.map(r => `"attack ward over ${r.name}"`).join(', ')}.`;
|
|
661
|
+
return near[0];
|
|
662
|
+
}
|
|
604
663
|
/**
|
|
605
664
|
* "barrier", "the force wall", "mana barrier 3,5" -> the standing face
|
|
606
665
|
* this actor would hit, or a refusal that says why not, or undefined
|
|
@@ -41,7 +41,7 @@ import { AFFORDANCE_FLAVOR } from '../utilities/affordances.js';
|
|
|
41
41
|
*/
|
|
42
42
|
export class BluffCommand extends Command {
|
|
43
43
|
static verb = 'bluff';
|
|
44
|
-
static description = 'Talk a PERSON round -- Charisma + con. Locks
|
|
44
|
+
static description = 'Talk a PERSON round -- Charisma + con. Locks and terminals have no opinion to change; work those with "pick" or "hack".';
|
|
45
45
|
async execute(args = []) {
|
|
46
46
|
// Fast-talk needs a face and a voice on the scene -- your meat ones.
|
|
47
47
|
if (this.actor.plane === 'matrix') {
|
|
@@ -2,6 +2,7 @@ import { BypassCommand } from './bypass-command.js';
|
|
|
2
2
|
import { Door } from '../models/door.js';
|
|
3
3
|
import { ensureAtExit } from '../utilities/spots.js';
|
|
4
4
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
5
|
+
import { ownerWitnesses } from '../utilities/owner-guard.js';
|
|
5
6
|
/**
|
|
6
7
|
* The FORCEFUL route past a physical barrier (player ruling: "solve" was
|
|
7
8
|
* too broad -- a maglock wants finesse OR muscle, and muscle deserves its
|
|
@@ -119,7 +120,8 @@ export class BreachCommand extends BypassCommand {
|
|
|
119
120
|
const where = String(direction).toLowerCase();
|
|
120
121
|
if (!gave) {
|
|
121
122
|
this.actor.performAction('throws a shoulder at', `the ${where} door, and it holds`);
|
|
122
|
-
|
|
123
|
+
const seen = ownerWitnesses(this.scene, room, this.actor, `the ${where} door`);
|
|
124
|
+
return `${crossNote}${this.failureFlavor}${seen ? `\n ${seen}` : ''}`;
|
|
123
125
|
}
|
|
124
126
|
door.isLocked = false;
|
|
125
127
|
this.actor.sneaking = false;
|
|
@@ -128,7 +130,8 @@ export class BreachCommand extends BypassCommand {
|
|
|
128
130
|
this.scene.raiseAlarm(`${this.actor.name} breached the ${where} door in ${room.name} -- the crash carried`);
|
|
129
131
|
this.scene.addWorldEvent(`${this.actor.name} breached the ${where} door in ${room.name} -- the crash carried.`);
|
|
130
132
|
this.game?.addHeat(2, `breached the ${where} door`);
|
|
131
|
-
|
|
133
|
+
const seen = ownerWitnesses(this.scene, room, this.actor, `the ${where} door`);
|
|
134
|
+
return `${crossNote}${this.successFlavor}\n The crash CARRIES -- anyone nearby knows exactly where you are now.${seen ? `\n ${seen}` : ''}`;
|
|
132
135
|
}
|
|
133
136
|
async determineOutcome() {
|
|
134
137
|
// Raw muscle -- Strength + Body, 2 hits to give. No skill governs
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from './command.js';
|
|
2
2
|
import { ensureAtSpot, ensureAtExit } from '../utilities/spots.js';
|
|
3
3
|
import { applyDeviceOpened } from '../utilities/devices.js';
|
|
4
|
+
import { ownerWitnesses } from '../utilities/owner-guard.js';
|
|
4
5
|
/**
|
|
5
6
|
* Shared implementation for pick/breach/hack/cast/dispel -- FORCING a
|
|
6
7
|
* device rather than opening it the way it was meant to open.
|
|
@@ -257,6 +258,11 @@ export class BypassCommand extends Command {
|
|
|
257
258
|
}
|
|
258
259
|
this.logger.write(`${this.actor.name} attempted to ${this.verbName} "${device.name}": ${result.success ? 'success' : 'failure'}${result.alarmed ? ' (alarmed)' : ''}`);
|
|
259
260
|
this.actor.performAction(this.verbName, result.message);
|
|
261
|
+
// THE OWNER WATCHING (owner-guard.ts): picking, breaching or forcing
|
|
262
|
+
// their lock in front of them turns them. A key or a keycard is the
|
|
263
|
+
// legitimate way through and is not a hostile act.
|
|
264
|
+
const seen = this.verbName === 'use' ? undefined : ownerWitnesses(this.scene, room, this.actor, device.name);
|
|
265
|
+
const ownerLine = seen ? `\n ${seen}` : '';
|
|
260
266
|
if (result.alarmed) {
|
|
261
267
|
this.actor.sneaking = false;
|
|
262
268
|
const teeth = await this.onForceFailure();
|
|
@@ -268,9 +274,9 @@ export class BypassCommand extends Command {
|
|
|
268
274
|
// promising a permanence that no longer exists.
|
|
269
275
|
` It holds. You can try again, but the noise has already carried.`,
|
|
270
276
|
...teeth,
|
|
271
|
-
].join('\n');
|
|
277
|
+
].join('\n') + ownerLine;
|
|
272
278
|
}
|
|
273
|
-
return crossNote + [result.message, ...consequence].join('\n');
|
|
279
|
+
return crossNote + [result.message, ...consequence].join('\n') + ownerLine;
|
|
274
280
|
}
|
|
275
281
|
}
|
|
276
282
|
//# sourceMappingURL=bypass-command.js.map
|
|
@@ -9,7 +9,7 @@ import { isOutdoorsPlace } from '../utilities/outdoors.js';
|
|
|
9
9
|
import { readEarshot } from '../utilities/earshot.js';
|
|
10
10
|
import { firstReachable } from '../utilities/spots.js';
|
|
11
11
|
import { meetLocationFor } from '../utilities/meet-location.js';
|
|
12
|
-
import { partyCanDeck
|
|
12
|
+
import { partyCanDeck } from '../factories/scene-chunks.js';
|
|
13
13
|
import { requirementLine } from '../factories/job-order.js';
|
|
14
14
|
import { readJobAsk, fixerMenuLine, fixerPickupLine, fixerPickupNote, commissionFromFixer } from '../utilities/fixer-menu.js';
|
|
15
15
|
import { RecallCommand } from './companions.js';
|
|
@@ -543,7 +543,9 @@ export class CallCommand extends Command {
|
|
|
543
543
|
// against the caller, not the crew -- a hired seat's gear is
|
|
544
544
|
// not knowable from the dossier, so the line says "put one on
|
|
545
545
|
// the crew" and lets the player judge whether they already did.
|
|
546
|
-
|
|
546
|
+
// A 'mage' an older pending job still carries is dropped: it was
|
|
547
|
+
// the door-ward requirement, and a ward stops no body any more.
|
|
548
|
+
const lacks = (pending.requires ?? []).filter((r) => r === 'decker' && !partyCanDeck(this.actor));
|
|
547
549
|
const needsLine = requirementLine(lacks);
|
|
548
550
|
// WETWORK IS SAID OUT LOUD (user ruling 2026-09-24; SR5 p.336:
|
|
549
551
|
// "one or more runners refuse 'wetwork'"). The flag is
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sustainBurden } from '../utilities/sustaining.js';
|
|
2
|
-
import {
|
|
2
|
+
import { Command } from './command.js';
|
|
3
3
|
import { hint } from '../utilities/hints.js';
|
|
4
4
|
import { CombatExchange } from '../utilities/combat-exchange.js';
|
|
5
5
|
import { rollPool, formatRoll, rollD6Total } from '../utilities/dice.js';
|
|
@@ -87,125 +87,29 @@ const COMBAT_SPELL_FLAVOR = {
|
|
|
87
87
|
* Gear-driven spellcasting (see Player's magic section): you know a spell
|
|
88
88
|
* if you carry its SpellFormula (or any Grimoire), and the best Focus on
|
|
89
89
|
* you scales the casting pool. "cast <spell> [at <target>]" casts; a bare
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
90
|
+
* A WARD IS NOT CAST AT (user 2026-10-06): it is an astral barrier over a
|
|
91
|
+
* room (utilities/wards.ts), pressed through or attacked from the astral --
|
|
92
|
+
* never a door this verb unweaves.
|
|
93
93
|
*/
|
|
94
94
|
/**
|
|
95
95
|
* BLIND FIRE (p.178): "the same as the Total Darkness modifier", -6 -- the
|
|
96
96
|
* number p.197 gives for a target completely hidden from the attacker.
|
|
97
97
|
*/
|
|
98
98
|
const BLIND_FIRE = -6;
|
|
99
|
-
export class CastCommand extends
|
|
99
|
+
export class CastCommand extends Command {
|
|
100
100
|
static verb = 'cast';
|
|
101
101
|
/** Touches another actor: authorized per-act for AI-driven actors
|
|
102
102
|
* (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
|
|
103
103
|
static npcPolicy = 'authorized';
|
|
104
|
-
static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know. Barriers take map coordinates: "cast barrier 3 3,4 3,6" (a wall), "cast barrier 3 5,5" (a dome), "cast barrier east 2". Area spells too: "cast fireball 4 at 12,7" (a point) or "cast fireball 4 at <someone>" -- Force m around it, friend and foe.
|
|
105
|
-
deviceKind = 'ward';
|
|
106
|
-
verbName = 'cast';
|
|
104
|
+
static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know. Barriers take map coordinates: "cast barrier 3 3,4 3,6" (a wall), "cast barrier 3 5,5" (a dome), "cast barrier east 2". Area spells too: "cast fireball 4 at 12,7" (a point) or "cast fireball 4 at <someone>" -- Force m around it, friend and foe.';
|
|
107
105
|
/** The bolt in flight is at someone the caster cannot see (castCombatSpell). */
|
|
108
106
|
unseenTarget = false;
|
|
109
|
-
noDeviceMessage = "There's nothing here to cast at.";
|
|
110
|
-
/** Backlash drain when a ward-bypass fails -- resisted with Willpower,
|
|
111
|
-
* like any drain. The nastiest drain in the game: forcing a hostile
|
|
112
|
-
* ward is harder on the caster than any spell of their own. */
|
|
113
|
-
static BACKLASH_DRAIN = 4;
|
|
114
|
-
/**
|
|
115
|
-
* The ward-bypass outcome is a real SPELLCASTING test now, not the
|
|
116
|
-
* legacy coin flip (a real session died to a 50/50 that ignored Magic 6
|
|
117
|
-
* + spellcasting entirely): the full casting pool -- Magic +
|
|
118
|
-
* spellcasting (or the book-caster's base), best focus, wounds,
|
|
119
|
-
* sustaining -- needs 2 hits to unweave the barrier, same threshold as
|
|
120
|
-
* pick/hack/bluff.
|
|
121
|
-
*/
|
|
122
|
-
async determineOutcome() {
|
|
123
|
-
const pool = Math.max(1, this.actor.getMagicPool());
|
|
124
|
-
const roll = rollPool(pool);
|
|
125
|
-
if (this.scene.isHumanControlled(this.actor)) {
|
|
126
|
-
this.logger.meta(`Spellwork: ${formatRoll(roll)} (2+ to unweave)`);
|
|
127
|
-
}
|
|
128
|
-
return roll.hits >= 2;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* A failed unweaving isn't quiet OR free: the ward's backlash is drain
|
|
132
|
-
* (BACKLASH_DRAIN, resisted with Willpower like any drain), landing on
|
|
133
|
-
* the real stun track with overflow into physical. Enough of it still
|
|
134
|
-
* knocks out or kills -- but through dice and the condition monitors,
|
|
135
|
-
* the same death any spell drain earns, not by fiat.
|
|
136
|
-
*/
|
|
137
|
-
async onForceFailure() {
|
|
138
|
-
const actor = this.actor;
|
|
139
|
-
// Centering (metamagic, p.325): +grade dice on every drain resist --
|
|
140
|
-
// the chant that keeps the backlash outside the skull.
|
|
141
|
-
const resist = rollPool(actor.willpower + (actor.hasMetamagic('centering') ? actor.initiationGrade : 0));
|
|
142
|
-
const taken = Math.max(0, CastCommand.BACKLASH_DRAIN - resist.hits);
|
|
143
|
-
const lines = [
|
|
144
|
-
` The barrier's backlash arcs up your own weave:`,
|
|
145
|
-
];
|
|
146
|
-
// Roll anatomy to the ticker for the player; an NPC keeps it in the
|
|
147
|
-
// returned lines that feed its own AI history (readability pass).
|
|
148
|
-
const backlashRoll = `Backlash (${CastCommand.BACKLASH_DRAIN}): resist ${formatRoll(resist)}`;
|
|
149
|
-
if (this.scene.isHumanControlled(actor))
|
|
150
|
-
this.logger.meta(backlashRoll);
|
|
151
|
-
else
|
|
152
|
-
lines.push(` ${backlashRoll}`);
|
|
153
|
-
if (taken === 0) {
|
|
154
|
-
lines.push(` You ground it out through clenched teeth -- shaken, but standing.`);
|
|
155
|
-
return lines;
|
|
156
|
-
}
|
|
157
|
-
const { stun, overflow } = actor.takeStun(taken);
|
|
158
|
-
this.logger.write(`${actor.name} took ${taken} ward backlash (stun ${actor.stunTaken}/${actor.maxStunBoxes}, overflow ${overflow}).`);
|
|
159
|
-
lines.push(` It burns through you -- ${stun} stun: ${actor.stunSummary()}`);
|
|
160
|
-
if (overflow > 0) {
|
|
161
|
-
lines.push(` More than your mind can hold -- ${overflow} box${overflow === 1 ? '' : 'es'} spill into your body: ${actor.conditionSummary()}`);
|
|
162
|
-
}
|
|
163
|
-
// Backlash can still finish you -- through the tracks, same flows as
|
|
164
|
-
// spell drain (cast.ts drainCollapse) and the stim crash (use.ts).
|
|
165
|
-
if (actor.isIncapacitated()) {
|
|
166
|
-
const human = this.scene.isHumanControlled(actor);
|
|
167
|
-
const dead = actor.isDown();
|
|
168
|
-
if (human) {
|
|
169
|
-
if (dead) {
|
|
170
|
-
// A runner past the track goes through the one door every
|
|
171
|
-
// runner death takes (CombatExchange.resolveDeath): down and
|
|
172
|
-
// bleeding short of Body overflow (p.170), and at death's door
|
|
173
|
-
// -- DocWagon, Not Dead Yet, the banner -- past it. This called
|
|
174
|
-
// endGame straight off a full track, and only for the primary.
|
|
175
|
-
const exchange = new CombatExchange(this.scene, this.logger, actor);
|
|
176
|
-
lines.push(...await exchange.resolveDeath(actor, actor, {
|
|
177
|
-
fallLine: `The weave snaps back through every nerve at once. The last thing you feel is the barrier holding.`,
|
|
178
|
-
gameOverLine: `💀 GAME OVER: the ward's backlash burned ${actor.name} out from the inside.`,
|
|
179
|
-
cause: `a ward's backlash`,
|
|
180
|
-
}));
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
// A stun KO defers to the room (RAW p.379/p.207, see
|
|
184
|
-
// Scene.beginPlayerKnockout): a ward has no legs -- alone, the
|
|
185
|
-
// player simply comes to at its foot; with hostiles about, the
|
|
186
|
-
// street still gets its say.
|
|
187
|
-
this.scene.addWorldEvent(`${actor.name} was knocked cold by a ward's backlash in ${actor.currentLocation.name}.`);
|
|
188
|
-
lines.push('', `The world grays out -- the ward stands over you, humming, as everything goes dark.`);
|
|
189
|
-
this.scene.beginPlayerKnockout([], actor);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
this.scene.addWorldEvent(dead
|
|
194
|
-
? `${actor.name} was killed by a ward's backlash in ${actor.currentLocation.name}.`
|
|
195
|
-
: `${actor.name} was knocked cold by a ward's backlash in ${actor.currentLocation.name}.`);
|
|
196
|
-
if (dead)
|
|
197
|
-
this.scene.removeActor(actor.name);
|
|
198
|
-
lines.push('', `${actor.name} drops at the barrier's foot, ${dead ? 'dead before they land' : 'out cold'}.`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return lines;
|
|
202
|
-
}
|
|
203
107
|
async execute(args = []) {
|
|
204
108
|
// Incantations carry -- casting ends a sneak (see sneak.ts).
|
|
205
109
|
this.actor.sneaking = false;
|
|
206
110
|
// "cast increase reflexes ..." -- the natural phrasing for the spell
|
|
207
111
|
// whose key is 'reflexes'; strip the qualifier before resolution or
|
|
208
|
-
// the whole phrase
|
|
112
|
+
// the whole phrase reads as no spell at all.
|
|
209
113
|
let words = args ?? [];
|
|
210
114
|
if (words[0]?.toLowerCase() === 'increase' && words[1]?.toLowerCase().startsWith('reflex')) {
|
|
211
115
|
words = words.slice(1);
|
|
@@ -228,18 +132,17 @@ export class CastCommand extends BypassCommand {
|
|
|
228
132
|
if (!this.actor.isCaster()) {
|
|
229
133
|
return `Casting takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
|
|
230
134
|
}
|
|
231
|
-
// "CAST <DIRECTION>" IS GONE (user ruling 2026-10-05)
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// anything else is not a spell this caster knows.
|
|
135
|
+
// "CAST <DIRECTION>" IS GONE (user ruling 2026-10-05), and a ward is
|
|
136
|
+
// not a door any more (2026-10-06): a direction gets the barrier
|
|
137
|
+
// spell it might mean; anything else is not a spell this caster knows.
|
|
235
138
|
const first = words[0]?.toLowerCase();
|
|
236
139
|
if (!first) {
|
|
237
140
|
return `Cast what? "spells" lists what you know -- "cast <spell> [force] ...".`;
|
|
238
141
|
}
|
|
239
142
|
if (this.isValidDirection(first)) {
|
|
240
|
-
return `To
|
|
143
|
+
return `To wall the way off, "cast barrier ${first} [cells]".`;
|
|
241
144
|
}
|
|
242
|
-
return `"${words.join(' ')}" isn't a spell you know. "spells" lists them
|
|
145
|
+
return `"${words.join(' ')}" isn't a spell you know. "spells" lists them.`;
|
|
243
146
|
}
|
|
244
147
|
/**
|
|
245
148
|
* ONE REFUSAL FOR THE ASPECT, wherever it is reached from -- the
|
|
@@ -254,8 +157,7 @@ export class CastCommand extends BypassCommand {
|
|
|
254
157
|
/**
|
|
255
158
|
* Lenient spell-name matching: the exact key, or a unique fragment of at
|
|
256
159
|
* least 3 characters ("bolt"/"mana" -> manabolt). Anything that doesn't
|
|
257
|
-
* resolve
|
|
258
|
-
* direction or barrier name ("cast north", "cast maglock") needs.
|
|
160
|
+
* resolve is answered as not a spell this caster knows.
|
|
259
161
|
*/
|
|
260
162
|
resolveSpellName(arg) {
|
|
261
163
|
const wanted = arg?.toLowerCase();
|
|
@@ -291,7 +193,7 @@ export class CastCommand extends BypassCommand {
|
|
|
291
193
|
}
|
|
292
194
|
// NOT A SHOPPING PROBLEM (EL9pWnQe3w5PieogT, found reading this
|
|
293
195
|
// item's own bench). The aspect refusal above only guards the
|
|
294
|
-
//
|
|
196
|
+
// non-spell path -- the one a spell NAME never reaches -- so a
|
|
295
197
|
// named spell fell through to the line below and told a Conjurer to
|
|
296
198
|
// go and buy a formula or a grimoire. p.69 says neither will ever
|
|
297
199
|
// work for them: the formula stands in for the SKILL, and the skill
|
|
@@ -381,31 +283,6 @@ export class CastCommand extends BypassCommand {
|
|
|
381
283
|
}
|
|
382
284
|
return { target };
|
|
383
285
|
}
|
|
384
|
-
/**
|
|
385
|
-
* Does this string name a WARD in the room? Checked only AFTER actor
|
|
386
|
-
* matching fails, so a person's name always wins. Matches the exit it
|
|
387
|
-
* holds, its own name, a noun from its description, or -- with
|
|
388
|
-
* exactly one ward here -- a generic barrier word.
|
|
389
|
-
*/
|
|
390
|
-
findSpellBarrier(rawInput) {
|
|
391
|
-
const raw = rawInput.trim().toLowerCase();
|
|
392
|
-
const room = this.actor.currentLocation;
|
|
393
|
-
const targets = (room?.openableDevices() ?? []).filter(d => d.kind === 'ward');
|
|
394
|
-
if (targets.length === 0)
|
|
395
|
-
return undefined;
|
|
396
|
-
if (this.isValidDirection(raw))
|
|
397
|
-
return targets.find(d => d.opensExit === raw);
|
|
398
|
-
if (raw.length < 3)
|
|
399
|
-
return undefined;
|
|
400
|
-
const named = targets.filter(d => d.name.toLowerCase().includes(raw)
|
|
401
|
-
|| d.description.toLowerCase().includes(raw));
|
|
402
|
-
if (named.length === 1)
|
|
403
|
-
return named[0];
|
|
404
|
-
if (targets.length === 1 && /^(the )?(lock|door|ward|barrier|seal|gate|hatch|glyph|field|sigil|way)$/.test(raw)) {
|
|
405
|
-
return targets[0];
|
|
406
|
-
}
|
|
407
|
-
return undefined;
|
|
408
|
-
}
|
|
409
286
|
/** How the caster's pool reads in narration: focus-assisted, raw talent, or book-learned. */
|
|
410
287
|
castingLabel() {
|
|
411
288
|
const focus = this.actor.getBestFocus();
|
|
@@ -604,17 +481,6 @@ export class CastCommand extends BypassCommand {
|
|
|
604
481
|
const pickedName = fuzzyPickName(targetName, candidates.map(a => a.name));
|
|
605
482
|
const target = pickedName ? candidates.find(a => a.name === pickedName) : undefined;
|
|
606
483
|
if (!target) {
|
|
607
|
-
// A WARDED BARRIER is a legal target (a real session tried "cast
|
|
608
|
-
// manabolt at hatch", "at lock", "at down" and bounced on all
|
|
609
|
-
// three): when the words name no person but DO name a spell-keyed
|
|
610
|
-
// barrier here -- by direction, title, or a noun from its own
|
|
611
|
-
// description -- the cast routes to the ward test. The specific
|
|
612
|
-
// spell doesn't matter: "show us your spark" wants spellwork, and
|
|
613
|
-
// the bypass rolls the full casting pool.
|
|
614
|
-
const barrier = this.findSpellBarrier(targetName);
|
|
615
|
-
if (barrier) {
|
|
616
|
-
return super.execute([barrier.opensExit ?? barrier.name]);
|
|
617
|
-
}
|
|
618
484
|
// Visible on another plane beats "no one" -- an astral caster CAN
|
|
619
485
|
// see the meat target they're aiming at; name the actual rule
|
|
620
486
|
// (see planes.crossPlaneRefusal).
|
|
@@ -1273,8 +1139,8 @@ export class CastCommand extends BypassCommand {
|
|
|
1273
1139
|
* of each per caster; casting again replaces the standing one, with a
|
|
1274
1140
|
* fresh roll and fresh drain. -2 sustaining like every held weave.
|
|
1275
1141
|
*
|
|
1276
|
-
* Bare "cast north" no
|
|
1277
|
-
*
|
|
1142
|
+
* Bare "cast north" reaches no ward: a ward is a room's astral barrier
|
|
1143
|
+
* now (utilities/wards.ts), not a door (user rulings 2026-10-05/06).
|
|
1278
1144
|
*/
|
|
1279
1145
|
async castBarrier(spellKey, force, overcast, rest,
|
|
1280
1146
|
/** Set when no Force was named: the talent a sized cast may reach. */
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Command } from './command.js';
|
|
2
|
+
import { gridActionModifier } from '../utilities/grids.js';
|
|
3
|
+
import { hint } from '../utilities/hints.js';
|
|
4
|
+
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
5
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
6
|
+
import { overwatchFromDefense } from '../utilities/matrix-intrusion.js';
|
|
7
|
+
import { accrueOverwatch } from '../utilities/overwatch.js';
|
|
8
|
+
import { leaveMatrix } from '../utilities/planes.js';
|
|
9
|
+
import { hostLabel } from '../utilities/grid-names.js';
|
|
10
|
+
import { pickHostFile } from '../utilities/file-guard.js';
|
|
11
|
+
import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
|
|
12
|
+
/**
|
|
13
|
+
* CRACK FILE (SR5 p.238, RAG-checked 2026-10-06): "removes the protection
|
|
14
|
+
* from a file, making it readable". Complex Action, Marks Required 1,
|
|
15
|
+
* Hacking + Logic [Attack] v. Protection Rating x 2. An Attack action
|
|
16
|
+
* (p.244), so GOD bills the defence hits (p.231-232). No penalty on a
|
|
17
|
+
* miss beyond that bill, and -- not being on p.242's trigger list -- it
|
|
18
|
+
* never sets off a data bomb.
|
|
19
|
+
*
|
|
20
|
+
* The mark is the host's, the stand-in edit-file.ts already uses for a
|
|
21
|
+
* mark on a file inside it.
|
|
22
|
+
*/
|
|
23
|
+
export class CrackCommand extends Command {
|
|
24
|
+
static verb = 'crack';
|
|
25
|
+
static description = 'Crack File (SR5 p.238, Complex Action): "crack <file>" -- Hacking + Logic [Attack] v. the file\'s Protection x 2, from inside the host with a mark on it. An Attack action: GOD counts the defence hits. A cracked file can be read, copied and erased.';
|
|
26
|
+
async execute(args = []) {
|
|
27
|
+
const actor = this.actor;
|
|
28
|
+
const found = pickHostFile(actor, args.join(' ').trim(), 'crack');
|
|
29
|
+
if (typeof found === 'string')
|
|
30
|
+
return found;
|
|
31
|
+
const { host, file } = found;
|
|
32
|
+
const room = host.over;
|
|
33
|
+
if (!file.protection) {
|
|
34
|
+
return `${file.name} carries no protection -- nothing to crack.${file.dataBomb && file.bombSeenBy.has(actor.name) ? hint(` (The bomb on it is another matter: "disarm ${file.name}".)`) : ''}`;
|
|
35
|
+
}
|
|
36
|
+
if ((room.hostMarksBy.get(actor.name) ?? 0) < 1) {
|
|
37
|
+
return `Crack File needs a MARK (p.238) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
|
|
38
|
+
}
|
|
39
|
+
const burned = actor.burnedAttributeRefusal('attack');
|
|
40
|
+
if (burned)
|
|
41
|
+
return burned;
|
|
42
|
+
const bill = billAction(this.scene, actor, 'complex', 'Crack File');
|
|
43
|
+
if (bill)
|
|
44
|
+
return bill;
|
|
45
|
+
const hacking = actor.skillRating('hacking');
|
|
46
|
+
const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, actor.currentGrid);
|
|
47
|
+
const pool = Math.max(1, actor.logic + (hacking > 0 ? hacking : -1) + actor.bonus('hacking')
|
|
48
|
+
+ actor.matrixActionPenalty + grid.total + actor.woundModifier - actor.sustainingPenalty);
|
|
49
|
+
const attempt = rollPool(pool, actor.matrixAttribute('attack'), { gremlins: actor.deckGremlins });
|
|
50
|
+
const resist = rollPool(file.protection * 2);
|
|
51
|
+
if (showsMechanics(this.scene, actor)) {
|
|
52
|
+
this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Crack File -- Hacking + Logic [Attack]: ${formatRoll(attempt)}`);
|
|
53
|
+
this.logger.meta(` v. ${file.name}'s Protection ${file.protection} x 2: ${formatRoll(resist)}`);
|
|
54
|
+
}
|
|
55
|
+
actor.performAction('cracks a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
|
|
56
|
+
const god = this.godBill(overwatchFromDefense(resist.hits), 'Crack File (attack)');
|
|
57
|
+
const tail = god.length > 0 ? `\n${god.join('\n')}` : '';
|
|
58
|
+
if (attempt.hits <= resist.hits) {
|
|
59
|
+
this.logger.write(`${actor.name} failed to crack "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
|
|
60
|
+
return `The encryption holds -- ${file.name} stays a sealed block of noise.${hint(` (Try again; each attempt is a Complex Action.)`)}${tail}`;
|
|
61
|
+
}
|
|
62
|
+
file.protection = 0;
|
|
63
|
+
this.logger.write(`${actor.name} cracked "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
|
|
64
|
+
this.scene.updateStatus();
|
|
65
|
+
return `{light-blue-fg}The protection on ${file.name} unravels -- the file reads clean now.{/light-blue-fg}${tail}`;
|
|
66
|
+
}
|
|
67
|
+
/** GOD's bill, and the forced dump when it converges (erase-mark.ts). */
|
|
68
|
+
godBill(defenseHits, why) {
|
|
69
|
+
const bill = accrueOverwatch(this.scene, this.actor, defenseHits, why);
|
|
70
|
+
if (bill.converged && this.actor.plane === 'matrix') {
|
|
71
|
+
bill.lines.push(...leaveMatrix(this.scene, this.actor, { forced: true, reason: `GOD force-reboots your persona --` }));
|
|
72
|
+
}
|
|
73
|
+
return bill.lines;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=crack.js.map
|
|
@@ -4,7 +4,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
|
4
4
|
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
5
5
|
import { hostsInReach, hostOver, gridVicinity, roomsInReach } from '../utilities/grid-reach.js';
|
|
6
6
|
import { hostLabel, matchesCameraName } from '../utilities/grid-names.js';
|
|
7
|
-
import { isWatched,
|
|
7
|
+
import { isWatched, lensMaster, stillWatching } from '../utilities/surveillance.js';
|
|
8
8
|
import { deviceDefensePool, wanDefensePool } from '../utilities/matrix-intrusion.js';
|
|
9
9
|
import { accrueOverwatch } from '../utilities/overwatch.js';
|
|
10
10
|
import { leaveMatrix, leaveDrone } from '../utilities/planes.js';
|
|
@@ -17,6 +17,7 @@ import { MAX_MARKS } from '../utilities/marks.js';
|
|
|
17
17
|
import { deviceMaster, wanDevices } from '../utilities/wan.js';
|
|
18
18
|
import { sameHostSide } from '../models/player.js';
|
|
19
19
|
import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
|
|
20
|
+
import { ownerInformed } from '../utilities/owner-guard.js';
|
|
20
21
|
/** The marks Control Device wants for a Simple device action (p.238). */
|
|
21
22
|
export const MARKS_TO_CONTROL = 2;
|
|
22
23
|
export class DisableCommand extends Command {
|
|
@@ -68,8 +69,37 @@ export class DisableCommand extends Command {
|
|
|
68
69
|
// 1. A CAMERA of the room the persona is over / inside -- by its own
|
|
69
70
|
// name ("north camera"), or "camera"/"cameras" for the first lens
|
|
70
71
|
// still recording. One lens per action (p.238-239).
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
// INSIDE A HOST, ITS CAMERA WAN IS EVERY LENS SLAVED TO IT, wherever
|
|
73
|
+
// the lens hangs (surveillance.ts lensMaster; user 2026-10-06: a
|
|
74
|
+
// camera on the strip outside a shop is the shop's) -- and not a lens
|
|
75
|
+
// on its own floor that an author slaved to some other host.
|
|
76
|
+
const inside = actor.insideHost;
|
|
77
|
+
if (inside) {
|
|
78
|
+
const slaved = [];
|
|
79
|
+
for (const r of [inside, ...Object.values(rooms).filter(r => r !== inside)]) {
|
|
80
|
+
if (!isWatched(r))
|
|
81
|
+
continue;
|
|
82
|
+
for (const c of r.ensureCameras())
|
|
83
|
+
if (lensMaster(rooms, r, c) === inside)
|
|
84
|
+
slaved.push({ room: r, camera: c });
|
|
85
|
+
}
|
|
86
|
+
const namedHit = slaved.find(s => s.room.cameraNamed(named) === s.camera);
|
|
87
|
+
if (namedHit || matchesCameraName(named)) {
|
|
88
|
+
if (slaved.length === 0)
|
|
89
|
+
return `No cameras answer to ${hostLabel(inside)} -- nothing to loop.`;
|
|
90
|
+
const pick = namedHit ?? slaved.find(s => s.camera.working) ?? slaved.find(s => !s.camera.destroyed && !s.camera.bricked);
|
|
91
|
+
if (!pick)
|
|
92
|
+
return `Every lens slaved to ${hostLabel(inside)} is already dead -- smashed or bricked, no icon left to order.`;
|
|
93
|
+
if (pick.camera.destroyed)
|
|
94
|
+
return `The ${pick.camera.name} is smashed -- glass and a dangling cable. No icon left to reach.`;
|
|
95
|
+
// The WAN order loops the picked lens's room, one room per order,
|
|
96
|
+
// so the report below can say which room went blind.
|
|
97
|
+
const wan = namedHit ? undefined : slaved.filter(s => s.room === pick.room && s.camera.working).map(s => s.camera);
|
|
98
|
+
return { kind: 'camera', room: pick.room, camera: pick.camera, master: inside, ...(wan && wan.length > 1 ? { wan } : {}) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const camRoom = inside ?? (actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation);
|
|
102
|
+
const namedLens = !inside && isWatched(camRoom) ? camRoom.cameraNamed(named) : undefined;
|
|
73
103
|
if (namedLens || matchesCameraName(named)) {
|
|
74
104
|
if (!isWatched(camRoom))
|
|
75
105
|
return `No cameras on ${camRoom.name} -- nothing to loop.`;
|
|
@@ -80,8 +110,7 @@ export class DisableCommand extends Command {
|
|
|
80
110
|
// A lens shot off the wall is not on the grid at all.
|
|
81
111
|
if (camera.destroyed)
|
|
82
112
|
return `The ${camera.name} is smashed -- glass and a dangling cable. No icon left to reach.`;
|
|
83
|
-
|
|
84
|
-
return { kind: 'camera', room: camRoom, camera, master: cameraMasterHost(rooms, camRoom), ...(wan && wan.length > 1 ? { wan } : {}) };
|
|
113
|
+
return { kind: 'camera', room: camRoom, camera, master: lensMaster(rooms, camRoom, camera) };
|
|
85
114
|
}
|
|
86
115
|
// 2. A DEVICE ICON: inside the host, its WAN; from the grid, a slaved
|
|
87
116
|
// device you hold a host mark for, or a loose wireless one where
|
|
@@ -92,7 +121,6 @@ export class DisableCommand extends Command {
|
|
|
92
121
|
const matching = (entries, inHost = false) => entries.filter(({ device: d }) => d.rating > 0 && d.hasIconFor(actor.name, inHost) && (d.name.toLowerCase().includes(raw)
|
|
93
122
|
|| (d.opensExit ?? '').toLowerCase() === raw
|
|
94
123
|
|| d.description.toLowerCase().includes(raw)));
|
|
95
|
-
const inside = actor.insideHost;
|
|
96
124
|
if (inside) {
|
|
97
125
|
const matches = matching(wanDevices(rooms, inside), true);
|
|
98
126
|
if (matches.length === 1)
|
|
@@ -252,6 +280,14 @@ export class DisableCommand extends Command {
|
|
|
252
280
|
host.host?.spotted.add(actor.name);
|
|
253
281
|
}
|
|
254
282
|
}
|
|
283
|
+
// THE PLACE'S OWNER is told too (owner-guard.ts): a blown Control
|
|
284
|
+
// Device hands them the mark; a landed Data Spike raises the alarm.
|
|
285
|
+
if (t.kind === 'device' || t.kind === 'camera') {
|
|
286
|
+
const what = t.kind === 'device' ? t.device.name : `The ${t.room.name} cameras`;
|
|
287
|
+
const told = ownerInformed(this.scene, t.room, actor, made ? 'device-sleaze' : 'attack-landed', what);
|
|
288
|
+
if (told)
|
|
289
|
+
this.logger.logWithColor(told, 'yellow');
|
|
290
|
+
}
|
|
255
291
|
if ((t.kind === 'gun' || t.kind === 'implant') && made) {
|
|
256
292
|
actor.panMarksBy.set(t.owner.name, Math.min(MAX_MARKS, (actor.panMarksBy.get(t.owner.name) ?? 0) + 1));
|
|
257
293
|
t.owner.performAction('notices a Matrix probe', 'their link flashing an intrusion warning');
|