@maka/maka-cli 5.181.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 +20 -11
- package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +331 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +17 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +13 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +84 -30
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +10 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +51 -40
- 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/player.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +44 -17
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +91 -23
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +30 -4
- package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +107 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-brain.js +84 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +84 -51
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +5 -0
- 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/planes.js +5 -0
- 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.",
|
|
@@ -10,6 +10,7 @@ import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_
|
|
|
10
10
|
import { distanceMeters } from '../utilities/room-grid.js';
|
|
11
11
|
import { resolveBarrierStrike } from '../utilities/barrier-combat.js';
|
|
12
12
|
import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
|
|
13
|
+
import { arenaOf, inArena } from '../utilities/combat-turn.js';
|
|
13
14
|
import { spendMovementMeters } from '../utilities/movement-cost.js';
|
|
14
15
|
/**
|
|
15
16
|
* Shadowrun-style dice-pool combat, one full exchange per command:
|
|
@@ -217,6 +218,9 @@ export class AttackCommand extends Command {
|
|
|
217
218
|
// holstered dry/jammed piece is not what's being attacked with, so it
|
|
218
219
|
// must not block a bare-fisted attack the player never asked to fail.
|
|
219
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
|
+
}
|
|
220
224
|
if (this.usesEquippedWeapon() && this.actor.weaponDrawn && weaponItem?.isFirearm() && weaponItem.jammed) {
|
|
221
225
|
return `The ${weaponItem.name}'s smartlink is LOCKED -- someone cracked your PAN. "reboot" to clear it, or go in swinging.`;
|
|
222
226
|
}
|
|
@@ -224,11 +228,12 @@ export class AttackCommand extends Command {
|
|
|
224
228
|
return `Click -- the ${weaponItem.name} is dry. "reload" (needs ammunition), or go in swinging with something else.`;
|
|
225
229
|
}
|
|
226
230
|
// THE COMBAT TURN (utilities/combat-turn.ts): on the physical planes
|
|
227
|
-
// a fight is a turn/pass loop and this attack is ONE
|
|
228
|
-
// actor's own Action Phase. The Matrix
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
231
|
+
// and the Matrix a fight is a turn/pass loop and this attack is ONE
|
|
232
|
+
// action in the actor's own Action Phase. The Matrix joined it on
|
|
233
|
+
// 2026-09-13 (utilities/host-combat.ts): a Data Spike used to roll a
|
|
234
|
+
// fresh two-actor initiative per command and print "FREEZES" every
|
|
235
|
+
// time. Only the astral still rides the older exchange below.
|
|
236
|
+
if (this.actor.plane === 'meat' || this.actor.plane === 'drone' || this.actor.plane === 'matrix') {
|
|
232
237
|
return await this.executeInEncounter(target, room, weaponItem ?? undefined, ambush);
|
|
233
238
|
}
|
|
234
239
|
const report = [];
|
|
@@ -340,8 +345,9 @@ export class AttackCommand extends Command {
|
|
|
340
345
|
const actor = this.actor;
|
|
341
346
|
const human = this.scene.isHumanControlled(actor);
|
|
342
347
|
let enc = this.scene.encounterFor?.(actor);
|
|
348
|
+
const arena = arenaOf(actor);
|
|
343
349
|
if (!enc) {
|
|
344
|
-
enc = await this.scene.startEncounter(
|
|
350
|
+
enc = await this.scene.startEncounter(arena, { aggressor: actor, target, ambush });
|
|
345
351
|
if (enc.ended) {
|
|
346
352
|
return human ? `` : `The fight was over before ${actor.name} got to act.`;
|
|
347
353
|
}
|
|
@@ -356,7 +362,7 @@ export class AttackCommand extends Command {
|
|
|
356
362
|
? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${this.actionVerb() === 'attacks' ? 'attack' : 'move'} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
|
|
357
363
|
: `${actor.name} squares up -- initiative rolled; ${who} first.`;
|
|
358
364
|
}
|
|
359
|
-
if (target.isIncapacitated() || target
|
|
365
|
+
if (target.isIncapacitated() || !inArena(arena, target)) {
|
|
360
366
|
return human ? `${target.name} is no longer standing in front of you.` : `${target.name} is no longer a target.`;
|
|
361
367
|
}
|
|
362
368
|
}
|
|
@@ -372,7 +378,10 @@ export class AttackCommand extends Command {
|
|
|
372
378
|
// shoot or swing the blade instead? READY WEAPON (p.165) is its own
|
|
373
379
|
// Simple Action -- "draw" first (2jbprbSYqF7EPXrkR).
|
|
374
380
|
const activeWeapon = this.usesEquippedWeapon() && actor.weaponDrawn ? weaponItem : undefined;
|
|
375
|
-
|
|
381
|
+
// A DATA SPIKE (p.239) is neither a swing nor a shot: no ground to
|
|
382
|
+
// close, no recoil, a Complex Action. The grid sees no distance.
|
|
383
|
+
const onGrid = actor.plane === 'matrix';
|
|
384
|
+
const isMelee = !onGrid && (!activeWeapon || !activeWeapon.isFirearm());
|
|
376
385
|
const report = [];
|
|
377
386
|
// MELEE REACH: ground costs (p.161-162).
|
|
378
387
|
if (isMelee) {
|
|
@@ -428,8 +437,8 @@ export class AttackCommand extends Command {
|
|
|
428
437
|
}
|
|
429
438
|
}
|
|
430
439
|
// THE ACTION.
|
|
431
|
-
const label = isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
|
|
432
|
-
const bill = billAction(this.scene, actor, isMelee ? 'complex' : 'simple', label, { attack: true });
|
|
440
|
+
const label = onGrid ? 'Data Spike' : isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
|
|
441
|
+
const bill = billAction(this.scene, actor, onGrid || isMelee ? 'complex' : 'simple', label, { attack: true });
|
|
433
442
|
if (bill)
|
|
434
443
|
return bill;
|
|
435
444
|
const exchange = new CombatExchange(this.scene, this.logger, actor);
|
|
@@ -442,7 +451,7 @@ export class AttackCommand extends Command {
|
|
|
442
451
|
target.combatOpponent = target.combatOpponent ?? actor;
|
|
443
452
|
target.lastExchangeAt = now;
|
|
444
453
|
actor.surrendered = false;
|
|
445
|
-
if (!isMelee) {
|
|
454
|
+
if (!isMelee && !onGrid) {
|
|
446
455
|
actor.firedThisPhase = true;
|
|
447
456
|
actor.recoilRoundsFired += 1;
|
|
448
457
|
}
|
|
@@ -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
|
|
@@ -3,6 +3,7 @@ import { movePersonaWith } from '../utilities/matrix-roster.js';
|
|
|
3
3
|
import { resolveHostInReach, hostsInReach, gridVicinity, hostOver } from '../utilities/grid-reach.js';
|
|
4
4
|
import { hint } from '../utilities/hints.js';
|
|
5
5
|
import { hostLabel } from '../utilities/grid-names.js';
|
|
6
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
6
7
|
/**
|
|
7
8
|
* ENTER/EXIT HOST (SR5 p.239, Complex Action).
|
|
8
9
|
*
|
|
@@ -67,6 +68,12 @@ export class EnterHostCommand extends Command {
|
|
|
67
68
|
hint(`A host opens to anyone holding a MARK on it (p.239) -- "mark ${target.name}" softens it, or "hack" takes one by force.`),
|
|
68
69
|
].join('\n');
|
|
69
70
|
}
|
|
71
|
+
// A Complex Action (p.239) -- billed only inside a Combat Turn.
|
|
72
|
+
const bill = billAction(this.scene, actor, 'complex', 'Enter Host');
|
|
73
|
+
if (bill)
|
|
74
|
+
return bill;
|
|
75
|
+
// Crossing the wall leaves whatever fight was on this side of it.
|
|
76
|
+
this.scene.encounterFor?.(actor)?.leave(actor);
|
|
70
77
|
// THE PERSONA CROSSES, THE BODY STAYS (increment 2): position becomes
|
|
71
78
|
// the host, remembering the grid it came from (p.239: exit returns
|
|
72
79
|
// you there). currentLocation is never written -- it is the body's.
|
|
@@ -130,6 +137,13 @@ export class ExitHostCommand extends Command {
|
|
|
130
137
|
const by = [...actor.linkLockedBy].join(', ');
|
|
131
138
|
return `{red-fg}You reach for the door and it isn't there. LINK-LOCKED by ${by}: the connection is held open and Enter/Exit Host is closed to you (p.229). "jack out" still works -- it just costs you dumpshock.{/red-fg}`;
|
|
132
139
|
}
|
|
140
|
+
// A Complex Action (p.239), and the way out of the host's fight
|
|
141
|
+
// (utilities/host-combat.ts): a persona back on the grid is beyond
|
|
142
|
+
// the ice, which "does not operate out on the grid".
|
|
143
|
+
const bill = billAction(this.scene, actor, 'complex', 'Exit Host');
|
|
144
|
+
if (bill)
|
|
145
|
+
return bill;
|
|
146
|
+
this.scene.encounterFor?.(actor)?.leave(actor);
|
|
133
147
|
const host = actor.insideHost;
|
|
134
148
|
const from = actor.matrixPosition?.kind === 'host' ? actor.matrixPosition.from : undefined;
|
|
135
149
|
actor.matrixPosition = { kind: 'grid', grid: from };
|
|
@@ -12,6 +12,7 @@ import { matchesCameraName, hostLabel } from '../utilities/grid-names.js';
|
|
|
12
12
|
import { TapCommand } from './tap.js';
|
|
13
13
|
import { sameHostSide } from '../models/player.js';
|
|
14
14
|
import { MAX_MARKS } from '../utilities/marks.js';
|
|
15
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
15
16
|
import { declarationPenalty, parseMarkDeclaration, hostDefensePool, wanDefensePool, freeMatrixPerceptionHits, bruteForceMatrixDv, overwatchFromDefense, GO_BIG_QUALITY, deviceDefensePool } from '../utilities/matrix-intrusion.js';
|
|
16
17
|
/**
|
|
17
18
|
* MARKS A PAN INTRUSION MUST ALREADY HOLD before it can command the
|
|
@@ -74,6 +75,12 @@ export class HackCommand extends BypassCommand {
|
|
|
74
75
|
// here so a declaration never lands in a target name, the same way
|
|
75
76
|
// the mode words are.
|
|
76
77
|
const { marks: declaredMarks, rest } = parseMarkDeclaration(afterMode);
|
|
78
|
+
// BRUTE FORCE and HACK ON THE FLY are Complex Actions (p.238, p.240)
|
|
79
|
+
// -- billed only inside a Combat Turn, where one intrusion is the
|
|
80
|
+
// whole Action Phase.
|
|
81
|
+
const bill = billAction(this.scene, this.actor, 'complex', mode === 'sleaze' ? 'Hack on the Fly' : 'Brute Force');
|
|
82
|
+
if (bill)
|
|
83
|
+
return bill;
|
|
77
84
|
// PAN warfare: "hack <name>" cracks a meat actor's personal area
|
|
78
85
|
// network -- from inside the Matrix, or right here in AR with a
|
|
79
86
|
// working deck in hand (which is also how enemy deckers get YOU).
|
|
@@ -601,16 +608,16 @@ export class HackCommand extends BypassCommand {
|
|
|
601
608
|
if (perception > 0)
|
|
602
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}`);
|
|
603
610
|
}
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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.)`)}`);
|
|
614
621
|
}
|
|
615
622
|
this.scene.updateStatus();
|
|
616
623
|
return [...lines, ...godLines].join('\n');
|
|
@@ -3,6 +3,7 @@ import { gridVicinity } from '../utilities/grid-reach.js';
|
|
|
3
3
|
import { enterMatrix, leaveMatrix } from '../utilities/planes.js';
|
|
4
4
|
import { hint } from '../utilities/hints.js';
|
|
5
5
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
6
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
6
7
|
/**
|
|
7
8
|
* The Matrix door, both directions: "jack in [hot|cold]" and "jack out".
|
|
8
9
|
* Needs a working CYBERDECK carried (a commlink is calls and AR only) --
|
|
@@ -97,6 +98,11 @@ export class JackCommand extends Command {
|
|
|
97
98
|
if (actor.inExchange) {
|
|
98
99
|
return `The ice has your persona locked in the exchange -- survive it first, then jack out in the lull.`;
|
|
99
100
|
}
|
|
101
|
+
// JACK OUT is a Simple Action (p.240) -- billed inside a Combat
|
|
102
|
+
// Turn; leaving the Matrix then leaves the fight (planes.ts).
|
|
103
|
+
const bill = billAction(this.scene, actor, 'simple', 'Jack Out');
|
|
104
|
+
if (bill)
|
|
105
|
+
return bill;
|
|
100
106
|
// LINK-LOCKED: leaving stops being a decision and becomes a test
|
|
101
107
|
// (p.229/p.240). Everything below is the Jack Out action.
|
|
102
108
|
if (actor.linkLockedBy.size > 0) {
|
|
@@ -17,6 +17,7 @@ import { BULLET } from '../utilities/log-style.js';
|
|
|
17
17
|
import { spotsActive, whoIsWhereLine, highlightSpots, highlightActors, canReach, sealedRouteTo } from '../utilities/spots.js';
|
|
18
18
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
19
19
|
import { findCatalogItem, describeEffect } from '../utilities/catalog.js';
|
|
20
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
20
21
|
export class LookCommand extends Command {
|
|
21
22
|
static verb = 'look';
|
|
22
23
|
static description = 'Examine your surroundings';
|
|
@@ -301,6 +302,11 @@ export class LookCommand extends Command {
|
|
|
301
302
|
* match the grid map: ● host, ◆ persona, ▣ PAN, ◇ data.
|
|
302
303
|
*/
|
|
303
304
|
gridLook(room) {
|
|
305
|
+
// MATRIX PERCEPTION is a Complex Action (p.241) -- billed only inside
|
|
306
|
+
// a Combat Turn, where a look at the room costs the phase.
|
|
307
|
+
const bill = billAction(this.scene, this.actor, 'complex', 'Matrix Perception');
|
|
308
|
+
if (bill)
|
|
309
|
+
return bill;
|
|
304
310
|
// NO MEAT PROSE AT ALL (player ruling 2026-08-26: "looking in the
|
|
305
311
|
// matrix shouldn't show the meatworld"). The room's own description
|
|
306
312
|
// used to open this view "for orientation" -- but that description
|
|
@@ -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'));
|