@maka/maka-cli 5.182.0 → 5.183.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/attack.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +331 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +10 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +76 -29
- package/bundle/typescript/src/commands/game/sideQuest/game.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/device.js +40 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/host.js +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +30 -4
- package/bundle/typescript/src/commands/game/sideQuest/utilities/perception.js +50 -21
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +6 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +4 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.183.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.",
|
|
@@ -218,6 +218,9 @@ export class AttackCommand extends Command {
|
|
|
218
218
|
// holstered dry/jammed piece is not what's being attacked with, so it
|
|
219
219
|
// must not block a bare-fisted attack the player never asked to fail.
|
|
220
220
|
const weaponItem = this.actor.equipment.rightHand?.weapon ?? this.actor.equipment.leftHand?.weapon;
|
|
221
|
+
if (this.usesEquippedWeapon() && this.actor.weaponDrawn && weaponItem?.isFirearm() && weaponItem.bricked) {
|
|
222
|
+
return `The ${weaponItem.name} is BRICKED -- its electronics burned out under a Data Spike (p.228). Bench work while you rest brings it back; for now, go in swinging.`;
|
|
223
|
+
}
|
|
221
224
|
if (this.usesEquippedWeapon() && this.actor.weaponDrawn && weaponItem?.isFirearm() && weaponItem.jammed) {
|
|
222
225
|
return `The ${weaponItem.name}'s smartlink is LOCKED -- someone cracked your PAN. "reboot" to clear it, or go in swinging.`;
|
|
223
226
|
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { Command } from './command.js';
|
|
2
|
+
import { hint } from '../utilities/hints.js';
|
|
3
|
+
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
4
|
+
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
5
|
+
import { hostsInReach, hostOver, gridVicinity, roomsInReach } from '../utilities/grid-reach.js';
|
|
6
|
+
import { hostLabel, matchesCameraName } from '../utilities/grid-names.js';
|
|
7
|
+
import { isWatched, cameraMasterHost } from '../utilities/surveillance.js';
|
|
8
|
+
import { deviceDefensePool, wanDefensePool, hostDefensePool } from '../utilities/matrix-intrusion.js';
|
|
9
|
+
import { accrueOverwatch } from '../utilities/overwatch.js';
|
|
10
|
+
import { leaveMatrix } from '../utilities/planes.js';
|
|
11
|
+
import { applyDeviceOpened } from '../utilities/devices.js';
|
|
12
|
+
import { gridActionModifier, gridNote } from '../utilities/grids.js';
|
|
13
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
14
|
+
import { MAX_MARKS } from '../utilities/marks.js';
|
|
15
|
+
import { sameHostSide } from '../models/player.js';
|
|
16
|
+
/** The marks Control Device wants for a Simple device action (p.238). */
|
|
17
|
+
export const MARKS_TO_CONTROL = 2;
|
|
18
|
+
export class DisableCommand extends Command {
|
|
19
|
+
static verb = 'disable';
|
|
20
|
+
static description = 'Control Device (SR5 p.238): with 2 marks on a device, order it -- a maglock unlocks, a camera cluster loops, a smartgun\'s clip drops. Electronic Warfare + Intuition [Sleaze] v. Device Rating + Firewall. "disable loud <icon>" is a Data Spike instead (see "brick").';
|
|
21
|
+
/** The Data Spike route: `brick` always, `disable loud` by the word. */
|
|
22
|
+
way(args) {
|
|
23
|
+
const rest = [...args];
|
|
24
|
+
if (rest[0]?.toLowerCase() === 'loud' || rest[0]?.toLowerCase() === 'brick') {
|
|
25
|
+
rest.shift();
|
|
26
|
+
return { way: 'spike', rest };
|
|
27
|
+
}
|
|
28
|
+
if (rest[0]?.toLowerCase() === 'quiet')
|
|
29
|
+
rest.shift();
|
|
30
|
+
return { way: 'control', rest };
|
|
31
|
+
}
|
|
32
|
+
async execute(args = []) {
|
|
33
|
+
const actor = this.actor;
|
|
34
|
+
const { way, rest } = this.way(args);
|
|
35
|
+
const named = rest.join(' ').trim();
|
|
36
|
+
if (!named) {
|
|
37
|
+
return way === 'spike'
|
|
38
|
+
? `Brick what? Name the icon: "brick <device>", "brick cameras", "brick <person>" for their gun.`
|
|
39
|
+
: `Disable what? Name the icon: "disable <device>", "disable cameras", "disable <person>" for their gun.${hint(` (Control Device wants ${MARKS_TO_CONTROL} marks on it -- "hack <it>" first.)`)}`;
|
|
40
|
+
}
|
|
41
|
+
// REACH: riding the Matrix, a living persona, or AR with a working
|
|
42
|
+
// deck in hand -- the same gate a PAN intrusion uses (hack.ts).
|
|
43
|
+
if (actor.plane === 'astral')
|
|
44
|
+
return `The Matrix doesn't answer to spirits.`;
|
|
45
|
+
if (actor.plane !== 'matrix' && !actor.isTechnomancer() && !actor.getCyberdeck()) {
|
|
46
|
+
return `You'd need to be jacked in, or have a working cyberdeck in hand, to reach a device's icon.`;
|
|
47
|
+
}
|
|
48
|
+
const target = this.resolve(named);
|
|
49
|
+
if (typeof target === 'string')
|
|
50
|
+
return target;
|
|
51
|
+
const bill = billAction(this.scene, actor, 'complex', way === 'spike' ? 'Data Spike' : 'Control Device', { attack: way === 'spike' });
|
|
52
|
+
if (bill)
|
|
53
|
+
return bill;
|
|
54
|
+
return way === 'spike' ? this.dataSpike(target) : this.controlDevice(target);
|
|
55
|
+
}
|
|
56
|
+
// ------------------------------------------------------------ targets --
|
|
57
|
+
resolve(named) {
|
|
58
|
+
const actor = this.actor;
|
|
59
|
+
const raw = named.toLowerCase();
|
|
60
|
+
const rooms = this.rooms ?? this.scene.getRooms();
|
|
61
|
+
// 1. THE CAMERA CLUSTER of the room the persona is over / inside.
|
|
62
|
+
if (matchesCameraName(named)) {
|
|
63
|
+
const room = actor.insideHost ?? (actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation);
|
|
64
|
+
if (!isWatched(room))
|
|
65
|
+
return `No cameras on ${room.name} -- nothing to loop.`;
|
|
66
|
+
return { kind: 'camera', room, master: cameraMasterHost(rooms, room) };
|
|
67
|
+
}
|
|
68
|
+
// 2. A DEVICE ICON: inside the host, its WAN; from the grid, a slaved
|
|
69
|
+
// device you hold a host mark for, or a loose wireless one where
|
|
70
|
+
// the body stands. Mirrors hack.ts tryDeviceHack.
|
|
71
|
+
const matching = (room) => room.openableDevices().filter(d => d.rating > 0).filter(d => d.name.toLowerCase().includes(raw)
|
|
72
|
+
|| (d.opensExit ?? '').toLowerCase() === raw
|
|
73
|
+
|| d.description.toLowerCase().includes(raw));
|
|
74
|
+
const inside = actor.insideHost;
|
|
75
|
+
if (inside) {
|
|
76
|
+
const matches = matching(inside);
|
|
77
|
+
if (matches.length === 1)
|
|
78
|
+
return { kind: 'device', device: matches[0], room: inside, host: inside, viaWan: false };
|
|
79
|
+
if (matches.length > 1)
|
|
80
|
+
return `More than one thing in here answers to "${named}": ${matches.map(d => d.name).join(', ')}.`;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const found = hostsInReach(rooms, actor).map(r => ({ room: r, matches: matching(r) })).filter(x => x.matches.length === 1);
|
|
84
|
+
if (found.length === 1) {
|
|
85
|
+
const host = found[0].room;
|
|
86
|
+
const device = found[0].matches[0];
|
|
87
|
+
if ((host.hostMarksBy.get(actor.name) ?? 0) < 1) {
|
|
88
|
+
return `${device.name} hangs off ${hostLabel(host)}'s WAN -- slaved, and a host's devices answer nobody who holds no mark on the host itself (p.233).${hint(` ("hack ${hostLabel(host)}" for a mark first.)`)}`;
|
|
89
|
+
}
|
|
90
|
+
return { kind: 'device', device, room: host, host, viaWan: true };
|
|
91
|
+
}
|
|
92
|
+
const here = actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation;
|
|
93
|
+
const loose = hostOver(here) ? [] : matching(here).filter(d => d.wireless);
|
|
94
|
+
if (loose.length === 1)
|
|
95
|
+
return { kind: 'device', device: loose[0], room: here, viaWan: false };
|
|
96
|
+
}
|
|
97
|
+
// 3. A PERSON'S GUN, broken out of their PAN (p.219). Same reach as
|
|
98
|
+
// a PAN intrusion: bodies on this side of the host wall.
|
|
99
|
+
const reach = (actor.plane === 'matrix' && !actor.insideHost)
|
|
100
|
+
? roomsInReach(rooms, actor)
|
|
101
|
+
: [{ room: actor.currentLocation, noise: 0 }];
|
|
102
|
+
const bodies = reach.flatMap(({ room: r }) => this.scene.getActorsInRoom(r)
|
|
103
|
+
.filter(a => a !== actor && a.plane === 'meat' && !a.isIncapacitated() && sameHostSide(actor, a)));
|
|
104
|
+
const picked = fuzzyPickName(named, bodies.map(a => a.name));
|
|
105
|
+
const owner = picked ? bodies.find(a => a.name === picked) : undefined;
|
|
106
|
+
if (owner) {
|
|
107
|
+
const gun = owner.equipment.rightHand?.weapon ?? owner.equipment.leftHand?.weapon;
|
|
108
|
+
if (!gun || !gun.isFirearm())
|
|
109
|
+
return `${owner.name} has no firearm on the grid to work -- nothing wireless in their hands.`;
|
|
110
|
+
return { kind: 'gun', owner, gun };
|
|
111
|
+
}
|
|
112
|
+
return `Nothing on the grid answers to "${named}".${hint(` ("look" lists the icons in reach.)`)}`;
|
|
113
|
+
}
|
|
114
|
+
marksOn(t) {
|
|
115
|
+
const me = this.actor.name;
|
|
116
|
+
if (t.kind === 'device')
|
|
117
|
+
return t.device.marksBy.get(me) ?? 0;
|
|
118
|
+
if (t.kind === 'camera')
|
|
119
|
+
return t.master?.hostMarksBy.get(me) ?? 0;
|
|
120
|
+
return t.owner.panMarksBy.get(me) ?? 0;
|
|
121
|
+
}
|
|
122
|
+
nameOf(t) {
|
|
123
|
+
return t.kind === 'device' ? t.device.name : t.kind === 'camera' ? `the camera cluster on ${t.room.name}` : `${t.owner.name}'s ${t.gun.name}`;
|
|
124
|
+
}
|
|
125
|
+
/** What defends: DR + Firewall for an unattended device (the master's
|
|
126
|
+
* where higher, seen from the grid), the host for its cameras, the
|
|
127
|
+
* owner's Intuition + Firewall for a gun on their PAN. */
|
|
128
|
+
defence(t) {
|
|
129
|
+
if (t.kind === 'device') {
|
|
130
|
+
return t.viaWan && t.host?.host ? wanDefensePool(t.device, t.host.host) : deviceDefensePool(t.device);
|
|
131
|
+
}
|
|
132
|
+
if (t.kind === 'camera') {
|
|
133
|
+
return t.master?.host ? hostDefensePool(t.master.host) : { label: 'Device Rating + Firewall', pool: 4 };
|
|
134
|
+
}
|
|
135
|
+
return { label: 'Intuition + Firewall', pool: t.owner.getPanDefensePool('sleaze') };
|
|
136
|
+
}
|
|
137
|
+
godBill(defenseHits, why) {
|
|
138
|
+
const bill = accrueOverwatch(this.scene, this.actor, defenseHits, why);
|
|
139
|
+
if (bill.converged && this.actor.plane === 'matrix') {
|
|
140
|
+
bill.lines.push(...leaveMatrix(this.scene, this.actor, { forced: true, reason: `GOD force-reboots your persona --` }));
|
|
141
|
+
}
|
|
142
|
+
return bill.lines;
|
|
143
|
+
}
|
|
144
|
+
/** The owner of the thing learns something went wrong. */
|
|
145
|
+
wakeOwner(t, made) {
|
|
146
|
+
const actor = this.actor;
|
|
147
|
+
actor.sneaking = false;
|
|
148
|
+
const host = t.kind === 'device' ? t.host : t.kind === 'camera' ? t.master : undefined;
|
|
149
|
+
if (host) {
|
|
150
|
+
host.hostAlert = true;
|
|
151
|
+
if (made)
|
|
152
|
+
host.hostMarksOn.set(actor.name, Math.min(MAX_MARKS, (host.hostMarksOn.get(actor.name) ?? 0) + 1));
|
|
153
|
+
}
|
|
154
|
+
if (t.kind === 'gun' && made) {
|
|
155
|
+
actor.panMarksBy.set(t.owner.name, Math.min(MAX_MARKS, (actor.panMarksBy.get(t.owner.name) ?? 0) + 1));
|
|
156
|
+
t.owner.performAction('notices a Matrix probe', 'their link flashing an intrusion warning');
|
|
157
|
+
}
|
|
158
|
+
this.scene.updateStatus();
|
|
159
|
+
}
|
|
160
|
+
// ------------------------------------------------------ Control Device --
|
|
161
|
+
controlDevice(t) {
|
|
162
|
+
const actor = this.actor;
|
|
163
|
+
const name = this.nameOf(t);
|
|
164
|
+
const marks = this.marksOn(t);
|
|
165
|
+
if (marks < MARKS_TO_CONTROL) {
|
|
166
|
+
const how = t.kind === 'device' ? `"hack ${t.device.name}"` : t.kind === 'camera' ? `"hack ${t.master ? hostLabel(t.master) : 'the host'}"` : `"hack ${t.owner.name}"`;
|
|
167
|
+
return `${name} takes orders from ${MARKS_TO_CONTROL} marks and you hold ${marks} (Control Device, p.238: a Simple device action wants two).${hint(` (${how} places them; or "brick" it -- no marks, but loud, and a bricked lock stays locked.)`)}`;
|
|
168
|
+
}
|
|
169
|
+
const burned = actor.burnedAttributeRefusal('sleaze');
|
|
170
|
+
if (burned)
|
|
171
|
+
return burned;
|
|
172
|
+
// ELECTRONIC WARFARE + INTUITION [SLEAZE] v. Intuition + Firewall
|
|
173
|
+
// (p.238), the no-test device action's test. Unskilled at -1 (p.130).
|
|
174
|
+
const ew = actor.skillRating('electronic-warfare');
|
|
175
|
+
const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, t.kind === 'device' ? t.host?.host?.grid : t.kind === 'camera' ? t.master?.host?.grid : undefined);
|
|
176
|
+
const pool = Math.max(1, actor.intuition + (ew > 0 ? ew : -1) + actor.bonus('hacking') + actor.bonus('matrix-sleaze')
|
|
177
|
+
+ actor.matrixActionPenalty + actor.woundModifier - actor.sustainingPenalty + grid.total);
|
|
178
|
+
const limit = actor.matrixAttribute('sleaze');
|
|
179
|
+
const roll = rollPool(pool, limit, { gremlins: actor.deckGremlins });
|
|
180
|
+
const defence = this.defence(t);
|
|
181
|
+
const defenceRoll = rollPool(defence.pool);
|
|
182
|
+
const net = roll.hits - defenceRoll.hits;
|
|
183
|
+
actor.performAction('takes control of a device', name);
|
|
184
|
+
if (this.scene.isHumanControlled(actor)) {
|
|
185
|
+
this.logger.meta(`Control Device (Electronic Warfare + Intuition [Sleaze])${gridNote(grid)}: ${formatRoll(roll)}`);
|
|
186
|
+
this.logger.meta(`${defence.label}: ${formatRoll(defenceRoll)} -- net ${net >= 0 ? '+' : ''}${net}`);
|
|
187
|
+
}
|
|
188
|
+
const godLines = this.godBill(defenceRoll.hits, 'Control Device (sleaze)');
|
|
189
|
+
if (net <= 0) {
|
|
190
|
+
// A blown Sleaze is noticed (p.236): the owner marks you and the
|
|
191
|
+
// host it hangs off is awake.
|
|
192
|
+
this.wakeOwner(t, true);
|
|
193
|
+
const who = t.kind === 'gun' ? `${t.owner.name}'s firewall holds a MARK on your persona now` : `whoever owns it has your icon on file now`;
|
|
194
|
+
return [`${name} refuses the order and SAYS SO -- ${who}.`, ...godLines].join('\n');
|
|
195
|
+
}
|
|
196
|
+
const lines = [];
|
|
197
|
+
if (t.kind === 'device') {
|
|
198
|
+
// THE ORDER: it opens. Same aftermath as a key or a forced door
|
|
199
|
+
// (utilities/devices.ts) -- the exit unseals in the meat.
|
|
200
|
+
const opened = t.device.force(true);
|
|
201
|
+
lines.push(`${t.device.name} takes the order as its owner's: ${opened.message}`);
|
|
202
|
+
lines.push(...applyDeviceOpened(this.scene, actor, t.room, t.device, 'disable'));
|
|
203
|
+
}
|
|
204
|
+
else if (t.kind === 'camera') {
|
|
205
|
+
t.room.camerasLooped = true;
|
|
206
|
+
lines.push(`The cluster on ${t.room.name} takes the order: every lens loops on an empty hallway. Security sees NOTHING there now.${hint(` ("snoop ${t.room.name}" rides the real feed.)`)}`);
|
|
207
|
+
this.scene.addWorldEvent(`An unseen hand looped the cameras on ${t.room.name}.`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
t.gun.jammed = true;
|
|
211
|
+
lines.push(`${t.owner.name}'s ${t.gun.name} takes the order: the smartlink seizes and the clip drops -- LOCKED until they reboot.`);
|
|
212
|
+
t.owner.performAction('suffers a PAN crack', `their ${t.gun.name} locking up in their hand`);
|
|
213
|
+
this.scene.addWorldEvent(`An unseen hand seized ${t.owner.name}'s ${t.gun.name}.`);
|
|
214
|
+
}
|
|
215
|
+
this.scene.updateStatus();
|
|
216
|
+
return [...lines, ...godLines].join('\n');
|
|
217
|
+
}
|
|
218
|
+
// ----------------------------------------------------------- Data Spike --
|
|
219
|
+
dataSpike(t) {
|
|
220
|
+
const actor = this.actor;
|
|
221
|
+
const name = this.nameOf(t);
|
|
222
|
+
const burned = actor.burnedAttributeRefusal('attack');
|
|
223
|
+
if (burned)
|
|
224
|
+
return burned;
|
|
225
|
+
if (t.kind === 'device' && t.device.isBricked)
|
|
226
|
+
return `${t.device.name} is already bricked -- dead boards, nothing left to spike.`;
|
|
227
|
+
if (t.kind === 'camera' && t.room.camerasBricked)
|
|
228
|
+
return `The cluster on ${t.room.name} is already dead glass.`;
|
|
229
|
+
if (t.kind === 'gun' && t.gun.bricked)
|
|
230
|
+
return `${t.owner.name}'s ${t.gun.name} is already bricked.`;
|
|
231
|
+
// CYBERCOMBAT + LOGIC [ATTACK] v. Intuition + Firewall (p.239).
|
|
232
|
+
const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, t.kind === 'device' ? t.host?.host?.grid : t.kind === 'camera' ? t.master?.host?.grid : undefined);
|
|
233
|
+
const pool = Math.max(1, actor.getAttackPool() + actor.matrixActionPenalty + actor.bonus('matrix-attack') + grid.total);
|
|
234
|
+
const limit = actor.matrixAttribute('attack');
|
|
235
|
+
const roll = rollPool(pool, limit, { gremlins: actor.deckGremlins });
|
|
236
|
+
const defence = this.defence(t);
|
|
237
|
+
const defenceRoll = rollPool(defence.pool);
|
|
238
|
+
const net = roll.hits - defenceRoll.hits;
|
|
239
|
+
actor.performAction('throws a Data Spike', name);
|
|
240
|
+
if (this.scene.isHumanControlled(actor)) {
|
|
241
|
+
this.logger.meta(`Data Spike (Cybercombat + Logic [Attack ${limit}])${gridNote(grid)}: ${formatRoll(roll)}`);
|
|
242
|
+
this.logger.meta(`${defence.label}: ${formatRoll(defenceRoll)} -- net ${net >= 0 ? '+' : ''}${net}`);
|
|
243
|
+
}
|
|
244
|
+
const godLines = this.godBill(defenceRoll.hits, 'Data Spike (attack)');
|
|
245
|
+
if (net <= 0) {
|
|
246
|
+
// A failed Attack action is never noticed (p.236), but the
|
|
247
|
+
// target's software rejects the code and sends it back: one
|
|
248
|
+
// unresistable box per net hit the defender got (Data Trails p.181).
|
|
249
|
+
const rebound = Math.max(0, -net);
|
|
250
|
+
const lines = [`${name} shrugs the spike off${rebound > 0 ? ' -- and sends it back' : ''}.`];
|
|
251
|
+
if (rebound > 0) {
|
|
252
|
+
if (actor.isTechnomancer()) {
|
|
253
|
+
actor.takeStun(rebound);
|
|
254
|
+
lines.push(` Rejected code burns back through your living persona -- ${rebound} stun: ${actor.stunSummary()}`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
actor.takeMatrixDamage(rebound);
|
|
258
|
+
lines.push(` Rejected code burns back into your deck -- ${rebound} box${rebound === 1 ? '' : 'es'}, unresisted: ${actor.matrixSummary()}`);
|
|
259
|
+
if (actor.matrixCrashed)
|
|
260
|
+
lines.push(...leaveMatrix(this.scene, actor, { forced: true, reason: `Your own spike bricks the deck under you.` }));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
lines.push(` Nobody upstairs noticed -- a spike that doesn't land leaves no trace.`);
|
|
264
|
+
return [...lines, ...godLines].join('\n');
|
|
265
|
+
}
|
|
266
|
+
// DV = Attack + net hits, +2 per mark held (p.239); resisted with
|
|
267
|
+
// Device Rating + Firewall (p.228).
|
|
268
|
+
const marks = this.marksOn(t);
|
|
269
|
+
const dv = limit + net + 2 * marks;
|
|
270
|
+
const soak = rollPool(this.defence(t).pool);
|
|
271
|
+
const dealt = Math.max(0, dv - soak.hits);
|
|
272
|
+
if (this.scene.isHumanControlled(actor)) {
|
|
273
|
+
this.logger.meta(`Data Spike -- ${dv} DV (Attack ${limit} +${net} net${marks > 0 ? ` +${2 * marks} for ${marks} mark${marks === 1 ? '' : 's'}` : ''}) v. ${defence.label}: ${formatRoll(soak)} -> ${dealt}`);
|
|
274
|
+
}
|
|
275
|
+
// An Attack action is never subtle: the owner knows.
|
|
276
|
+
this.wakeOwner(t, false);
|
|
277
|
+
const lines = [];
|
|
278
|
+
if (dealt <= 0) {
|
|
279
|
+
lines.push(`${name} takes the spike and its firewall holds -- barely. It knows it was hit.`);
|
|
280
|
+
return [...lines, ...godLines].join('\n');
|
|
281
|
+
}
|
|
282
|
+
if (t.kind === 'device') {
|
|
283
|
+
t.device.takeMatrixDamage(dealt);
|
|
284
|
+
lines.push(`The spike lands on ${t.device.name} -- ${dealt} box${dealt === 1 ? '' : 'es'} (${t.device.matrixSummary()}).`);
|
|
285
|
+
if (t.device.isBricked) {
|
|
286
|
+
lines.push(t.device.kind === 'lock'
|
|
287
|
+
? ` ${t.device.name} BRICKS -- sparks, a pop, a smell of hot plastic. And the lock STAYS LOCKED (p.228): a dead maglock holds like a bolt.${hint(` (Marks and "disable" would have opened it; now it is "pick" or "breach".)`)}`
|
|
288
|
+
: ` ${t.device.name} BRICKS -- sparks, a pop, dead electronics. Whatever it was doing, it has stopped.`);
|
|
289
|
+
this.scene.addWorldEvent(`${actor.name} bricked ${t.device.name} in ${t.room.name} with a Data Spike.`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else if (t.kind === 'camera') {
|
|
293
|
+
// The cluster has no Device of its own to hold a monitor; a hit
|
|
294
|
+
// that lands past the firewall kills it. Engine ruling.
|
|
295
|
+
t.room.camerasBricked = true;
|
|
296
|
+
lines.push(`The spike lands on the cluster -- ${dealt} box${dealt === 1 ? '' : 'es'} -- and every lens on ${t.room.name} goes to static and stays there. Dead glass.`);
|
|
297
|
+
this.scene.addWorldEvent(`${actor.name} bricked the cameras on ${t.room.name} with a Data Spike.`);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
// A gun is a Rating 2 device with a monitor of 9 (p.228, the
|
|
301
|
+
// book's own smartgun example): a spike that lands past its
|
|
302
|
+
// firewall for that much bricks it outright.
|
|
303
|
+
const monitor = 8 + 1;
|
|
304
|
+
if (dealt >= monitor) {
|
|
305
|
+
t.gun.bricked = true;
|
|
306
|
+
lines.push(`The spike lands on ${t.owner.name}'s ${t.gun.name} -- it sparks, crackles and smokes in their hand. BRICKED: it will not fire again until somebody's bench work.`);
|
|
307
|
+
t.owner.performAction('suffers a PAN crack', `their ${t.gun.name} smoking in their hand`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
t.gun.jammed = true;
|
|
311
|
+
lines.push(`The spike lands on ${t.owner.name}'s ${t.gun.name} -- ${dealt} box${dealt === 1 ? '' : 'es'}: the smartlink seizes and LOCKS. It boots again on a reboot; another spike like that bricks it.`);
|
|
312
|
+
t.owner.performAction('suffers a PAN crack', `their ${t.gun.name} locking up in their hand`);
|
|
313
|
+
}
|
|
314
|
+
this.scene.addWorldEvent(`${actor.name} spiked ${t.owner.name}'s ${t.gun.name}.`);
|
|
315
|
+
}
|
|
316
|
+
this.scene.updateStatus();
|
|
317
|
+
return [...lines, ...godLines].join('\n');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** `brick <icon>`: the Data Spike, by its street name. */
|
|
321
|
+
export class BrickCommand extends DisableCommand {
|
|
322
|
+
static verb = 'brick';
|
|
323
|
+
static description = 'Data Spike (SR5 p.239): Cybercombat + Logic [Attack] v. Device Rating + Firewall, no marks needed, never subtle. Fill a device\'s Matrix condition monitor (8 + DR/2, p.228) and it BRICKS -- a camera goes dead, a smartgun sparks and dies; a lock stays locked. A miss burns your own deck.';
|
|
324
|
+
way(args) {
|
|
325
|
+
const rest = [...args];
|
|
326
|
+
if (rest[0]?.toLowerCase() === 'loud')
|
|
327
|
+
rest.shift();
|
|
328
|
+
return { way: 'spike', rest };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
//# sourceMappingURL=disable.js.map
|
|
@@ -608,16 +608,16 @@ export class HackCommand extends BypassCommand {
|
|
|
608
608
|
if (perception > 0)
|
|
609
609
|
lines.push(` {cyan-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what it guards (p.240).{/cyan-fg}`);
|
|
610
610
|
}
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
611
|
+
// TWO MARKS COMMAND IT (p.238 Control Device: 1 mark for a Free
|
|
612
|
+
// action, 2 for a Simple, 3 for a Complex -- and unlocking a maglock
|
|
613
|
+
// is the book's own example of the no-test device action, a Simple
|
|
614
|
+
// "Use Simple Device", p.165). The verb is `disable`
|
|
615
|
+
// (commands/disable.ts): the key is turned from in here, the door
|
|
616
|
+
// swings out there. One mark short says so.
|
|
617
|
+
if (!device.isOpen()) {
|
|
618
|
+
lines.push(now >= 2
|
|
619
|
+
? ` That is enough to command it: ${hint(`"disable ${device.name}" (Control Device, p.238) ${device.opensExit ? `opens the ${device.opensExit} way` : `works it`} from right here.`)}`
|
|
620
|
+
: ` One more mark and it takes orders.${hint(` ("hack ${device.name}" again, then "disable ${device.name}" -- Control Device wants 2 marks for a Simple device action, p.238.)`)}`);
|
|
621
621
|
}
|
|
622
622
|
this.scene.updateStatus();
|
|
623
623
|
return [...lines, ...godLines].join('\n');
|
|
@@ -163,6 +163,19 @@ export class RestCommand extends Command {
|
|
|
163
163
|
lines.push(` Bench work on the ${deck.name}: ${formatRoll(repairRoll)}`);
|
|
164
164
|
lines.push(` ${repaired} box${repaired === 1 ? '' : 'es'} repaired: ${deck.deckSummary()}${wasBricked && !deck.isBricked ? ' -- it boots again.' : ''}`);
|
|
165
165
|
}
|
|
166
|
+
// BRICKED GEAR (p.228 Repairing Matrix Damage): an hour with a
|
|
167
|
+
// toolkit and Hardware + Logic -- the rest is the hour, and every
|
|
168
|
+
// hit either clears boxes or halves the time, so by the end of a
|
|
169
|
+
// night's rest a bricked smartgun boots again. A reboot never does
|
|
170
|
+
// this; that is the difference between bricked and jammed.
|
|
171
|
+
const brickedGear = [
|
|
172
|
+
...actor.inventory.getAllItems(),
|
|
173
|
+
actor.equipment.rightHand?.weapon, actor.equipment.leftHand?.weapon,
|
|
174
|
+
].filter((i) => !!i && i.bricked);
|
|
175
|
+
for (const gear of new Set(brickedGear)) {
|
|
176
|
+
gear.bricked = false;
|
|
177
|
+
lines.push(` Bench work on the ${gear.name}: the burnt boards come out, and it powers up again.`);
|
|
178
|
+
}
|
|
166
179
|
for (const drone of damagedDrones) {
|
|
167
180
|
const wasWrecked = drone.isWrecked;
|
|
168
181
|
const repairRoll = rollPool(actor.logic + actor.skillRating('hardware') + actor.bonus('piloting'));
|
|
@@ -2,7 +2,9 @@ import { Command } from './command.js';
|
|
|
2
2
|
import { hint } from '../utilities/hints.js';
|
|
3
3
|
import { planeTintItemName, emphasizeFind, objectiveMark, fileReach } from '../utilities/planes.js';
|
|
4
4
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
5
|
-
import { perceptionTest } from '../utilities/perception.js';
|
|
5
|
+
import { perceptionTest, matrixSearchTest, matrixSearchSeconds, MATRIX_SEARCH } from '../utilities/perception.js';
|
|
6
|
+
import { gridVicinity } from '../utilities/grid-reach.js';
|
|
7
|
+
import { hostLabel } from '../utilities/grid-names.js';
|
|
6
8
|
import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
|
|
7
9
|
import { placeName } from '../utilities/log-style.js';
|
|
8
10
|
import { billAction, encounterOf } from '../utilities/action-cost.js';
|
|
@@ -135,6 +137,73 @@ ${coach}`;
|
|
|
135
137
|
export class SearchCommand extends Command {
|
|
136
138
|
static verb = 'search';
|
|
137
139
|
static description = 'Search for items in your area';
|
|
140
|
+
/**
|
|
141
|
+
* MATRIX SEARCH (SR5 p.241, player ruling 2026-09-13: "a true matrix
|
|
142
|
+
* search, not perception, revealing what is inside a host if it's
|
|
143
|
+
* cracked open").
|
|
144
|
+
*
|
|
145
|
+
* A Special action: Computer + Intuition [Data Processing] against a
|
|
146
|
+
* threshold from the Matrix Search Table, and it takes TIME -- one
|
|
147
|
+
* minute inside a host whatever the information, net hits over the
|
|
148
|
+
* threshold dividing it, a miss spending it all, a Browse program
|
|
149
|
+
* halving it (utilities/perception.ts). The time is real: it moves
|
|
150
|
+
* the persona's Overwatch clock back, so a slow search inside a host
|
|
151
|
+
* is what GOD bills for (utilities/overwatch.ts billTime, +2d6 per
|
|
152
|
+
* 15 minutes).
|
|
153
|
+
*
|
|
154
|
+
* WHAT IT FINDS. Inside a host, the host's archive: from then on the
|
|
155
|
+
* interior lists its files for this persona (Host.searchedBy,
|
|
156
|
+
* grid-view.ts). A CRACKED host spills them without the roll -- the
|
|
157
|
+
* seal is broken, there is nothing to find. Out on the grid it is the
|
|
158
|
+
* public-knowledge row: the loose data icons in reach.
|
|
159
|
+
*/
|
|
160
|
+
matrixSearch() {
|
|
161
|
+
const actor = this.actor;
|
|
162
|
+
const host = actor.hostInside;
|
|
163
|
+
const where = host ? host.over : gridVicinity(actor);
|
|
164
|
+
const row = host ? MATRIX_SEARCH.inHost : MATRIX_SEARCH.public;
|
|
165
|
+
const browse = actor.activeDeck?.hasProgram('browse') ?? false;
|
|
166
|
+
const { pool, limit } = matrixSearchTest(actor);
|
|
167
|
+
const roll = rollPool(pool, limit);
|
|
168
|
+
const seconds = matrixSearchSeconds(row.seconds, roll.hits, row.threshold, browse);
|
|
169
|
+
const isPlayer = this.scene.isHumanControlled(actor);
|
|
170
|
+
if (isPlayer) {
|
|
171
|
+
this.logger.meta(`Matrix Search (Computer + Intuition [Data Processing], threshold ${row.threshold}${browse ? ', Browse' : ''}): ${formatRoll(roll)} -- ${seconds}s`);
|
|
172
|
+
}
|
|
173
|
+
// THE MINUTES ARE GOD'S (p.231-232): Overwatch bills time on the
|
|
174
|
+
// grid, and a search is time. Winding the last tick back by the
|
|
175
|
+
// seconds spent is what makes the next illegal action pay for them.
|
|
176
|
+
if (actor.overwatchLastTick > 0)
|
|
177
|
+
actor.overwatchLastTick -= seconds * 1000;
|
|
178
|
+
actor.performAction('runs a Matrix Search', host ? `inside ${hostLabel(where)}` : `across ${where.name}'s grid`);
|
|
179
|
+
const files = where.offlineServer ? [] : where.inventory.getAllItems().filter(i => i.plane === 'matrix');
|
|
180
|
+
const clock = `(${seconds}s of Matrix time${browse ? ', Browse cutting it' : ''})`;
|
|
181
|
+
if (roll.hits < row.threshold) {
|
|
182
|
+
return host
|
|
183
|
+
? `You dig through ${hostLabel(where)}'s archive and the index keeps folding away from you -- nothing this pass. ${clock}${hint(` Try again; the host is not going anywhere.`)}`
|
|
184
|
+
: `You sweep ${where.name}'s grid for loose data and turn up nothing this pass. ${clock}`;
|
|
185
|
+
}
|
|
186
|
+
if (host) {
|
|
187
|
+
host.searchedBy.add(actor.name);
|
|
188
|
+
where.searchedBy.add(actor.name);
|
|
189
|
+
if (files.length === 0) {
|
|
190
|
+
return `The archive is indexed and EMPTY -- ${hostLabel(where)} keeps nothing worth the trip. ${clock}`;
|
|
191
|
+
}
|
|
192
|
+
const list = files.map(i => `${objectiveMark(this.scene, i.name)}${emphasizeFind(planeTintItemName(i, actor.plane))}`).join(', ');
|
|
193
|
+
const seal = where.hostCracked
|
|
194
|
+
? hint(` ("take" them -- the seal is already broken.)`)
|
|
195
|
+
: hint(` (sealed in the host's data vault: "hack" the node to break the seal, then "take".)`);
|
|
196
|
+
return `The index gives up ${hostLabel(where)}'s archive: ${list}. ${clock}${seal}`;
|
|
197
|
+
}
|
|
198
|
+
where.searchedBy.add(actor.name);
|
|
199
|
+
const loose = where.hasNode ? [] : files;
|
|
200
|
+
if (loose.length === 0) {
|
|
201
|
+
const sealedElsewhere = files.length > 0 && where.hasNode;
|
|
202
|
+
return `Nothing loose on ${where.name}'s grid. ${clock}${sealedElsewhere ? hint(` (${hostLabel(where)} keeps its files INSIDE -- "enter" it and search from in there.)`) : ''}`;
|
|
203
|
+
}
|
|
204
|
+
const list = loose.map(i => `${objectiveMark(this.scene, i.name)}${emphasizeFind(planeTintItemName(i, actor.plane))}`).join(', ');
|
|
205
|
+
return `Loose on ${where.name}'s grid: ${list}. ${clock}${hint(` ("take" rides them out on your deck.)`)}`;
|
|
206
|
+
}
|
|
138
207
|
async execute(_args) {
|
|
139
208
|
const room = this.actor.currentLocation;
|
|
140
209
|
// A MATRIX SEARCH IS MEASURED IN MINUTES (p.241: base time one
|
|
@@ -144,6 +213,8 @@ export class SearchCommand extends Command {
|
|
|
144
213
|
if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
|
|
145
214
|
return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice or get out first.`;
|
|
146
215
|
}
|
|
216
|
+
if (this.actor.plane === 'matrix')
|
|
217
|
+
return this.matrixSearch();
|
|
147
218
|
// OBSERVE IN DETAIL (SR5 p.165): the Perception Test below is the
|
|
148
219
|
// Simple Action the book names, inside a Combat Turn.
|
|
149
220
|
const bill = billAction(this.scene, this.actor, 'simple', 'Observe in Detail');
|
|
@@ -155,17 +226,14 @@ export class SearchCommand extends Command {
|
|
|
155
226
|
// room stays uncased ("take all" stays gated). Barriers (puzzles) are
|
|
156
227
|
// in plain sight and show regardless -- you can't fail to notice the
|
|
157
228
|
// maglock you're staring at.
|
|
158
|
-
// On the GRID a search is Matrix Search (canon: Computer, Logic) --
|
|
159
|
-
// best-of with perception so pre-computer deckers lose nothing.
|
|
160
|
-
// A loaded Browse program is purpose-built for Matrix Search: +2.
|
|
161
|
-
const browse = this.actor.plane === 'matrix' && this.actor.activeDeck?.hasProgram('browse') ? 2 : 0;
|
|
162
229
|
// ACTIVE: this is the deliberate hunt, so it carries p.135-136's +3
|
|
163
230
|
// for "perceiver is specifically looking for it" -- the modifier
|
|
164
231
|
// that makes `search` mechanically different from `look` rather
|
|
165
232
|
// than only differently named (FNgptvRhbA5yMunbB). The pool and the
|
|
166
233
|
// bracket both live in utilities/perception.ts; look.ts rolls the
|
|
167
|
-
// same test without the +3.
|
|
168
|
-
|
|
234
|
+
// same test without the +3. The grid never comes through here: a
|
|
235
|
+
// persona's search is matrixSearch() above.
|
|
236
|
+
const { pool, limit } = perceptionTest(this.actor, { active: true });
|
|
169
237
|
const roll = rollPool(pool, limit);
|
|
170
238
|
// Roll anatomy to the Mechanics ticker for the player; an NPC's
|
|
171
239
|
// return string is its AI history, so IT keeps the roll inline
|
|
@@ -234,28 +302,7 @@ export class SearchCommand extends Command {
|
|
|
234
302
|
if (spotted.length > 0) {
|
|
235
303
|
description += `\n{yellow-fg}You pick ${spotted.map(a => a.name).join(', ')} out of the shadows -- ${spotted.length === 1 ? 'they were' : 'they were'} there the whole time.{/yellow-fg}`;
|
|
236
304
|
}
|
|
237
|
-
//
|
|
238
|
-
// vault state attached: sealed files need the host cracked first. A
|
|
239
|
-
// real session's decker found the paydata jacked-in, didn't realize it
|
|
240
|
-
// was takeable from the grid, and went hunting for it in the meat.
|
|
241
|
-
if (this.actor.plane === 'matrix' && found.some(i => i.plane === 'matrix')) {
|
|
242
|
-
description += room.hasNode && !room.offlineServer && !room.hostCracked
|
|
243
|
-
? `\n{cyan-fg}(the files sit sealed in the host's data vault -- "hack" the node to unseal them){/cyan-fg}`
|
|
244
|
-
: hint(`\n{cyan-fg}(data files are loot -- "take" them right here on the grid; they ride out on your deck){/cyan-fg}`);
|
|
245
|
-
}
|
|
246
|
-
// A persona sweeping the grid OUTSIDE a host: the files exist and
|
|
247
|
-
// this sweep cannot reach them. Say why, rather than reading empty
|
|
248
|
-
// -- a silent nothing is what made the old air-gap case feel broken.
|
|
249
|
-
if (this.actor.plane === 'matrix' && !filesInReach
|
|
250
|
-
&& room.inventory.getAllItems().some(i => i.plane === 'matrix')) {
|
|
251
|
-
if (room.offlineServer) {
|
|
252
|
-
description += `\n{cyan-fg}There is a ${room.offlineServer} in this room and it is NOT on the Matrix -- nothing you can do from out here touches it.${hint(` Walk in and cable into it in the flesh.`)}{/cyan-fg}`;
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
const marked = (room.hostMarksBy.get(this.actor.name) ?? 0) > 0 || room.hostCracked;
|
|
256
|
-
description += `\n{cyan-fg}This sweep only covers the open grid. Whatever the host holds is INSIDE it, behind its wall (p.246).${hint(marked ? ` "enter" and search again.` : ` A mark is the door -- "mark" the host, then "enter".`)}{/cyan-fg}`;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
305
|
+
// The grid never reaches here: a persona's search is matrixSearch().
|
|
259
306
|
// The mirror hint: a meat-side search can't see matrix files, but it
|
|
260
307
|
// shouldn't pretend the room is empty when the good stuff is one
|
|
261
308
|
// "jack in" away.
|
|
@@ -109,6 +109,7 @@ import { EndCallCommand } from './commands/end-call.js';
|
|
|
109
109
|
import { fuzzyPickName, guessVerb } from './utilities/fuzzy-match.js';
|
|
110
110
|
import { PickCommand } from './commands/pick.js';
|
|
111
111
|
import { HackCommand } from './commands/hack.js';
|
|
112
|
+
import { DisableCommand, BrickCommand } from './commands/disable.js';
|
|
112
113
|
import { CastCommand } from './commands/cast.js';
|
|
113
114
|
import { StanceCommand } from './commands/stance.js';
|
|
114
115
|
import { FollowCommand, UnfollowCommand } from './commands/follow.js';
|
|
@@ -2596,6 +2597,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
2596
2597
|
CommandFactory.registerCommand('end-call', EndCallCommand);
|
|
2597
2598
|
CommandFactory.registerCommand('pick', PickCommand);
|
|
2598
2599
|
CommandFactory.registerCommand('hack', HackCommand);
|
|
2600
|
+
CommandFactory.registerCommand('disable', DisableCommand);
|
|
2601
|
+
CommandFactory.registerCommand('brick', BrickCommand);
|
|
2599
2602
|
CommandFactory.registerCommand('cast', CastCommand);
|
|
2600
2603
|
CommandFactory.registerCommand('stance', StanceCommand);
|
|
2601
2604
|
CommandFactory.registerCommand('surrender', SurrenderCommand);
|
|
@@ -6275,7 +6278,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
6275
6278
|
// players" / "crew street"); the bare verb still answers quietly.
|
|
6276
6279
|
{ title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
|
|
6277
6280
|
{ title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
|
|
6278
|
-
{ title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
|
|
6281
|
+
{ title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
|
|
6279
6282
|
// The Emerged get their own shelf (player request: "there MUST be
|
|
6280
6283
|
// resonance" -- matrix is the place, Resonance is the talent).
|
|
6281
6284
|
// `sustain` shelves here and not under magic: the command is
|
|
@@ -126,6 +126,16 @@ export class Device {
|
|
|
126
126
|
structure;
|
|
127
127
|
armor;
|
|
128
128
|
_barrierDamage = 0;
|
|
129
|
+
/**
|
|
130
|
+
* THE MATRIX CONDITION MONITOR (p.228): 8 + (Device Rating / 2)
|
|
131
|
+
* boxes, resisted with Device Rating + Firewall, filled by a Data
|
|
132
|
+
* Spike (commands/disable.ts, "brick"). Full = BRICKED: "the device
|
|
133
|
+
* ceases functioning" -- a camera records nothing, a smartgun sparks
|
|
134
|
+
* and dies -- but "a lock stays locked" (p.228): bricking is never a
|
|
135
|
+
* way through a maglock. Repaired on the bench (rest.ts), never by a
|
|
136
|
+
* reboot. A ward is not electronics and has no monitor at all.
|
|
137
|
+
*/
|
|
138
|
+
_matrixDamage = 0;
|
|
129
139
|
/**
|
|
130
140
|
* THE LOCKSMITH EXTENDED TEST IN PROGRESS (p.48, p.359-360): picking a
|
|
131
141
|
* maglock by hand accumulates hits toward Rating x 2 across attempts,
|
|
@@ -207,6 +217,33 @@ export class Device {
|
|
|
207
217
|
* exact fault this codebase keeps having to close (see the gun in
|
|
208
218
|
* aroTags, AKfemBJJWJP99nx8K).
|
|
209
219
|
*/
|
|
220
|
+
// ---- The Matrix condition monitor (commands/disable.ts) ----
|
|
221
|
+
/** p.228: 8 + (Device Rating / 2), rounded up as the deck's is. 0 for
|
|
222
|
+
* a thing with no rating (a ward): nothing there to brick. */
|
|
223
|
+
get matrixMonitor() {
|
|
224
|
+
return this.rating > 0 ? 8 + Math.ceil(this.rating / 2) : 0;
|
|
225
|
+
}
|
|
226
|
+
get matrixDamage() {
|
|
227
|
+
return this._matrixDamage;
|
|
228
|
+
}
|
|
229
|
+
/** Returns boxes actually taken. */
|
|
230
|
+
takeMatrixDamage(boxes) {
|
|
231
|
+
if (this.matrixMonitor <= 0)
|
|
232
|
+
return 0;
|
|
233
|
+
const taken = Math.max(0, Math.floor(boxes));
|
|
234
|
+
this._matrixDamage = Math.min(this.matrixMonitor, this._matrixDamage + taken);
|
|
235
|
+
return taken;
|
|
236
|
+
}
|
|
237
|
+
get isBricked() {
|
|
238
|
+
return this.matrixMonitor > 0 && this._matrixDamage >= this.matrixMonitor;
|
|
239
|
+
}
|
|
240
|
+
/** Bench work (rest.ts): the whole monitor, at once. */
|
|
241
|
+
repairMatrix() {
|
|
242
|
+
this._matrixDamage = 0;
|
|
243
|
+
}
|
|
244
|
+
matrixSummary() {
|
|
245
|
+
return this.matrixMonitor > 0 ? `${this._matrixDamage}/${this.matrixMonitor} boxes` : '';
|
|
246
|
+
}
|
|
210
247
|
broadcastsAro() {
|
|
211
248
|
return this.wireless && this.rating > 0;
|
|
212
249
|
}
|
|
@@ -328,11 +365,13 @@ export class Device {
|
|
|
328
365
|
const text = hint(AFFORDANCE_FLAVOR[this.kind].hint);
|
|
329
366
|
return text.length > 0 ? text : undefined;
|
|
330
367
|
}
|
|
331
|
-
/** Persistence-only:
|
|
368
|
+
/** Persistence-only: flags in, nothing out. */
|
|
332
369
|
restoreState(state) {
|
|
333
370
|
this.open = state.open;
|
|
334
371
|
if (state.damage !== undefined)
|
|
335
372
|
this._barrierDamage = Math.max(0, Math.min(this.structure, Math.floor(state.damage)));
|
|
373
|
+
if (state.bricked !== undefined)
|
|
374
|
+
this._matrixDamage = state.bricked ? this.matrixMonitor : 0;
|
|
336
375
|
}
|
|
337
376
|
}
|
|
338
377
|
//# sourceMappingURL=device.js.map
|
|
@@ -41,6 +41,10 @@ export class Host {
|
|
|
41
41
|
marksBy = new Map();
|
|
42
42
|
/** player name -> marks the host holds on that player. */
|
|
43
43
|
marksOn = new Map();
|
|
44
|
+
/** Personas whose Matrix Search (p.241, commands/search.ts) turned up
|
|
45
|
+
* this host's files: the archive is listed for them from then on. A
|
|
46
|
+
* cracked host lists it for everyone. Session-only. */
|
|
47
|
+
searchedBy = new Set();
|
|
44
48
|
/** Walls up: the host holds a mark on an intruder and its ice is hunting. */
|
|
45
49
|
alert = false;
|
|
46
50
|
/** IC running now, by kind name. Names, not actors -- until increment 5. */
|
|
@@ -43,6 +43,12 @@ export class Item extends AbstractItem {
|
|
|
43
43
|
// A jammed firearm can't fire (fists until cleared); "reboot" clears
|
|
44
44
|
// every jammed device you carry, in the lull.
|
|
45
45
|
jammed = false;
|
|
46
|
+
// BRICKED (p.228, commands/disable.ts "brick"): a Data Spike filled the
|
|
47
|
+
// device's Matrix condition monitor. Dead electronics -- a smartgun
|
|
48
|
+
// "sparks, crackles, and smokes" and will not fire -- until bench
|
|
49
|
+
// work (rest.ts). A reboot does NOT clear it; that is the whole
|
|
50
|
+
// difference from `jammed`.
|
|
51
|
+
bricked = false;
|
|
46
52
|
_transferable;
|
|
47
53
|
// New currency-related fields
|
|
48
54
|
_currencyAmount = 0;
|
|
@@ -469,6 +469,12 @@ export class Room extends AbstractRoom {
|
|
|
469
469
|
warded = false;
|
|
470
470
|
// See IRoomConstructorConfig.watched (utilities/surveillance.ts).
|
|
471
471
|
watched = false;
|
|
472
|
+
/** The cluster is LOOPED by Control Device (commands/disable.ts): the
|
|
473
|
+
* house sees empty hallways until the run ends. Session-only. */
|
|
474
|
+
camerasLooped = false;
|
|
475
|
+
/** The cluster is BRICKED by a Data Spike (p.228): dead lenses, until
|
|
476
|
+
* somebody's bench work nobody in this run is doing. Session-only. */
|
|
477
|
+
camerasBricked = false;
|
|
472
478
|
/**
|
|
473
479
|
* THE HOST STANDING OVER THIS ROOM, as an object (models/host.ts) --
|
|
474
480
|
* increment 1 of the Matrix position model. Every host field this class
|
|
@@ -16,6 +16,25 @@ import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
|
|
|
16
16
|
* ▣ PAN, ▲ dangerous device, ◇ data. (There is no air-gapped-host glyph
|
|
17
17
|
* any more -- see hhSWzLSXECFtfoAGa and canReachHost.)
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* A DEVICE ICON, AND WHAT WORKS IT (commands/disable.ts, 2026-09-13).
|
|
21
|
+
* Control Device (p.238) needs 2 marks on the device -- "disable" -- and
|
|
22
|
+
* a Data Spike (p.239) needs none -- "brick". A bricked lock stays
|
|
23
|
+
* locked (p.228), so its line says so rather than offering a way in.
|
|
24
|
+
*/
|
|
25
|
+
export function deviceIconLine(device, actor) {
|
|
26
|
+
const dr = `{bold}[DR ${device.rating}]{/bold}`;
|
|
27
|
+
if (device.isOpen())
|
|
28
|
+
return `▤ ${device.name} ${dr} -- its icon standing open, nothing left to hold.`;
|
|
29
|
+
if (device.isBricked) {
|
|
30
|
+
return `▤ ${device.name} ${dr} -- BRICKED, dead electronics${device.kind === 'lock' ? '; the lock stays locked (p.228)' : ''}.`;
|
|
31
|
+
}
|
|
32
|
+
const marks = device.marksBy.get(actor.name) ?? 0;
|
|
33
|
+
const way = marks >= 2
|
|
34
|
+
? `"disable ${device.name}" (Control Device, ${marks} marks held)`
|
|
35
|
+
: `"hack ${device.name}" for marks (${marks}/2 to command it), or "brick ${device.name}"`;
|
|
36
|
+
return `▤ ${device.name} ${dr} -- a device icon shaped like the thing itself, shut. (${way})`;
|
|
37
|
+
}
|
|
19
38
|
export function gridIcons(scene, actor, room) {
|
|
20
39
|
// INSIDE A HOST IS A DIFFERENT PLACE (SR5 p.246, and Deditri's
|
|
21
40
|
// eJANPpm7qCaCAJBJw). "The virtual space inside a host is separate
|
|
@@ -202,9 +221,7 @@ export function gridIcons(scene, actor, room) {
|
|
|
202
221
|
for (const device of room.devices) {
|
|
203
222
|
if (!device.broadcastsAro())
|
|
204
223
|
continue;
|
|
205
|
-
icons.push(device
|
|
206
|
-
? `▤ ${device.name} {bold}[DR ${device.rating}]{/bold} -- its icon standing open, nothing left to hold.`
|
|
207
|
-
: `▤ ${device.name} {bold}[DR ${device.rating}]{/bold} -- a device icon shaped like the thing itself, shut. ("hack ${device.name}")`);
|
|
224
|
+
icons.push(deviceIconLine(device, actor));
|
|
208
225
|
}
|
|
209
226
|
// The cameras, as a THING (surveillance.ts): live for security, looped,
|
|
210
227
|
// or yours to ride.
|
|
@@ -306,7 +323,16 @@ function hostInteriorIcons(scene, actor, room) {
|
|
|
306
323
|
const data = room.offlineServer
|
|
307
324
|
? []
|
|
308
325
|
: room.inventory.getAllItems().filter(i => i.plane === 'matrix');
|
|
309
|
-
|
|
326
|
+
// THE ARCHIVE IS NOT A GLANCE (p.241, player ruling 2026-09-13): a
|
|
327
|
+
// persona sees the host's files after a Matrix Search turns them up
|
|
328
|
+
// (commands/search.ts, Host.searchedBy), or when the host is cracked
|
|
329
|
+
// and they spill open. Until then a look says only that there is an
|
|
330
|
+
// archive to search.
|
|
331
|
+
const found = room.hostCracked || (room.host?.searchedBy.has(actor.name) ?? false);
|
|
332
|
+
if (data.length > 0 && !found) {
|
|
333
|
+
icons.push(`◇ The host's archive -- files in here somewhere, and a glance does not read an archive. ("search" runs a Matrix Search, p.241)`);
|
|
334
|
+
}
|
|
335
|
+
for (const item of found ? data : []) {
|
|
310
336
|
icons.push(room.hostCracked
|
|
311
337
|
? `◇ ${item.name} [data] -- unsealed, sitting open in the host's archive. ("take" it)`
|
|
312
338
|
: `◇ ${item.name} [data] -- SEALED in the host's archive. ("hack" the host to break the seal)`);
|
|
@@ -39,36 +39,65 @@ import { mentalLimit } from './limits.js';
|
|
|
39
39
|
* an active search mechanically different from a passive glance. */
|
|
40
40
|
export const ACTIVELY_LOOKING = 3;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
42
|
+
* THE MEAT PERCEPTION TEST. The Matrix used to ride through here as a
|
|
43
|
+
* "different test with a different bracket" -- Logic + the better of
|
|
44
|
+
* Perception and Computer [Data Processing] -- which was neither Matrix
|
|
45
|
+
* Perception nor Matrix Search. It is matrixSearchTest now, below; the
|
|
46
|
+
* grid never comes through this door.
|
|
47
|
+
*/
|
|
48
|
+
export function perceptionTest(actor, opts = {}) {
|
|
49
|
+
const skill = actor.skillRating('perception');
|
|
50
|
+
// Unskilled defaults at -1 (p.130), exactly as both callers already did.
|
|
51
|
+
const pool = Math.max(1, actor.intuition
|
|
52
|
+
+ (skill > 0 ? skill : -1)
|
|
53
|
+
+ (opts.active ? ACTIVELY_LOOKING : 0)
|
|
54
|
+
+ actor.bonus('perception')
|
|
55
|
+
+ actor.woundModifier
|
|
56
|
+
- actor.sustainingPenalty);
|
|
57
|
+
return { pool, limit: mentalLimit(actor) };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* MATRIX SEARCH (SR5 p.241, RAG-checked 2026-09-13): a Special action,
|
|
61
|
+
* "Computer + Intuition [Data Processing]" -- INTUITION, not Logic; the
|
|
62
|
+
* old grid branch above rolled Logic and called it canon. The threshold
|
|
63
|
+
* and the base time come from what is being looked for (the Matrix
|
|
64
|
+
* Search Table: public 1 / 1 minute, limited 3 / 30 minutes; inside a
|
|
65
|
+
* host the base time is one minute whatever the information), net hits
|
|
66
|
+
* over the threshold divide the time, and a failure still spends all
|
|
67
|
+
* of it. A Browse program "cuts the base time in half" (Splintered
|
|
68
|
+
* State p.54) -- time, not dice. Running silent costs its -2 like every
|
|
69
|
+
* Matrix action (p.235-236).
|
|
48
70
|
*
|
|
49
71
|
* Data Processing reads 0 for an actor with no deck at all; that is a
|
|
50
72
|
* caller's missing gate rather than a real limit, so it degrades to
|
|
51
73
|
* unlimited here instead of handing rollPool a zero it would reject.
|
|
52
74
|
*/
|
|
53
|
-
export function
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
? Math.max(actor.skillRating('perception'), actor.skillRating('computer'))
|
|
57
|
-
: actor.skillRating('perception');
|
|
58
|
-
const attribute = onGrid ? actor.logic : actor.intuition;
|
|
59
|
-
// Unskilled defaults at -1 (p.130), exactly as both callers already did.
|
|
60
|
-
const pool = Math.max(1, attribute
|
|
75
|
+
export function matrixSearchTest(actor) {
|
|
76
|
+
const skill = actor.skillRating('computer');
|
|
77
|
+
const pool = Math.max(1, actor.intuition
|
|
61
78
|
+ (skill > 0 ? skill : -1)
|
|
62
|
-
+ (opts.browse ?? 0)
|
|
63
|
-
+ (opts.active ? ACTIVELY_LOOKING : 0)
|
|
64
79
|
+ actor.bonus('perception')
|
|
80
|
+
+ (typeof actor.matrixActionPenalty === 'number' ? actor.matrixActionPenalty : 0)
|
|
65
81
|
+ actor.woundModifier
|
|
66
82
|
- actor.sustainingPenalty);
|
|
67
|
-
const dataProcessing =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
const dataProcessing = actor.matrixAttribute('dataProcessing');
|
|
84
|
+
return { pool, limit: dataProcessing > 0 ? dataProcessing : undefined };
|
|
85
|
+
}
|
|
86
|
+
/** The Matrix Search Table (p.241-242): threshold and base time in
|
|
87
|
+
* seconds. Inside a host the base time is one minute regardless. */
|
|
88
|
+
export const MATRIX_SEARCH = {
|
|
89
|
+
public: { threshold: 1, seconds: 60 },
|
|
90
|
+
inHost: { threshold: 3, seconds: 60 },
|
|
91
|
+
};
|
|
92
|
+
/** How long a search takes once rolled: the base time divided by the
|
|
93
|
+
* net hits over the threshold, never under one Combat Turn (3s); a
|
|
94
|
+
* failure spends the whole base time. Browse halves the base. */
|
|
95
|
+
export function matrixSearchSeconds(baseSeconds, hits, threshold, browse) {
|
|
96
|
+
const base = browse ? Math.ceil(baseSeconds / 2) : baseSeconds;
|
|
97
|
+
if (hits < threshold)
|
|
98
|
+
return base;
|
|
99
|
+
const net = hits - threshold;
|
|
100
|
+
return net > 0 ? Math.max(3, Math.ceil(base / net)) : base;
|
|
72
101
|
}
|
|
73
102
|
/**
|
|
74
103
|
* PERCEPTION THRESHOLDS (p.136). Named because the numbers appear in
|
|
@@ -164,6 +164,8 @@ export function serializeItem(item) {
|
|
|
164
164
|
out.loadedAmmo = item.loadedAmmo;
|
|
165
165
|
if (item.jammed)
|
|
166
166
|
out.jammed = true;
|
|
167
|
+
if (item.bricked)
|
|
168
|
+
out.bricked = true;
|
|
167
169
|
if (item.deckDamage > 0)
|
|
168
170
|
out.deckDamage = item.deckDamage;
|
|
169
171
|
if (item.droneDamage > 0)
|
|
@@ -219,6 +221,8 @@ export function restoreItem(json) {
|
|
|
219
221
|
item.loadedAmmo = json.loadedAmmo;
|
|
220
222
|
if (json.jammed)
|
|
221
223
|
item.jammed = true;
|
|
224
|
+
if (json.bricked)
|
|
225
|
+
item.bricked = true;
|
|
222
226
|
if (json.deckDamage)
|
|
223
227
|
item.takeDeckDamage(json.deckDamage);
|
|
224
228
|
if (json.droneDamage)
|
|
@@ -663,7 +667,7 @@ export function captureHubOverlay(scene, hubSeed, player, homeRoom) {
|
|
|
663
667
|
.map(n => n.player.name)
|
|
664
668
|
.filter(name => !scene.getActor(name));
|
|
665
669
|
const devices = [...collectDevices(scene).entries()]
|
|
666
|
-
.map(([name, d]) => ({ name, open: d.isOpen(), ...(d.barrierDamage > 0 ? { damage: d.barrierDamage } : {}) }));
|
|
670
|
+
.map(([name, d]) => ({ name, open: d.isOpen(), ...(d.barrierDamage > 0 ? { damage: d.barrierDamage } : {}), ...(d.isBricked ? { bricked: true } : {}) }));
|
|
667
671
|
// THE TOMBSTONE (see ISavedHub.puzzles): always [], never read here.
|
|
668
672
|
// Omitting it crashes v5.64.0's unguarded for-of on resume, and
|
|
669
673
|
// saves move between builds via the cloud mirror.
|
|
@@ -824,7 +828,7 @@ export function applyHubOverlay(scene, saved, player, homeRoom) {
|
|
|
824
828
|
for (const d of saved.devices ?? []) {
|
|
825
829
|
const live = liveDevices.get(d.name);
|
|
826
830
|
if (live)
|
|
827
|
-
live.restoreState({ open: d.open, damage: d.damage });
|
|
831
|
+
live.restoreState({ open: d.open, damage: d.damage, bricked: d.bricked });
|
|
828
832
|
}
|
|
829
833
|
// A SAVE WRITTEN BEFORE DEVICES EXISTED has no `devices` key at all,
|
|
830
834
|
// so every device would read shut -- while the door it holds stays
|
|
@@ -85,6 +85,10 @@ export function directConnectionBlocker(actor, room) {
|
|
|
85
85
|
export function camerasLive(rooms, room) {
|
|
86
86
|
if (!isWatched(room))
|
|
87
87
|
return false;
|
|
88
|
+
// Looped by Control Device or bricked by a Data Spike (commands/
|
|
89
|
+
// disable.ts): the house sees nothing from this cluster either way.
|
|
90
|
+
if (room.camerasLooped || room.camerasBricked)
|
|
91
|
+
return false;
|
|
88
92
|
if (room.hasNode)
|
|
89
93
|
return !room.hostCracked;
|
|
90
94
|
const hosts = Object.values(rooms).filter(r => r.hasNode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.183.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.",
|