@maka/maka-cli 5.181.0 → 5.182.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 +17 -11
- package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +7 -0
- 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/search.js +8 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +10 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +47 -39
- package/bundle/typescript/src/commands/game/sideQuest/models/player.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/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/planes.js +5 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.182.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:
|
|
@@ -224,11 +225,12 @@ export class AttackCommand extends Command {
|
|
|
224
225
|
return `Click -- the ${weaponItem.name} is dry. "reload" (needs ammunition), or go in swinging with something else.`;
|
|
225
226
|
}
|
|
226
227
|
// 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
|
-
|
|
228
|
+
// and the Matrix a fight is a turn/pass loop and this attack is ONE
|
|
229
|
+
// action in the actor's own Action Phase. The Matrix joined it on
|
|
230
|
+
// 2026-09-13 (utilities/host-combat.ts): a Data Spike used to roll a
|
|
231
|
+
// fresh two-actor initiative per command and print "FREEZES" every
|
|
232
|
+
// time. Only the astral still rides the older exchange below.
|
|
233
|
+
if (this.actor.plane === 'meat' || this.actor.plane === 'drone' || this.actor.plane === 'matrix') {
|
|
232
234
|
return await this.executeInEncounter(target, room, weaponItem ?? undefined, ambush);
|
|
233
235
|
}
|
|
234
236
|
const report = [];
|
|
@@ -340,8 +342,9 @@ export class AttackCommand extends Command {
|
|
|
340
342
|
const actor = this.actor;
|
|
341
343
|
const human = this.scene.isHumanControlled(actor);
|
|
342
344
|
let enc = this.scene.encounterFor?.(actor);
|
|
345
|
+
const arena = arenaOf(actor);
|
|
343
346
|
if (!enc) {
|
|
344
|
-
enc = await this.scene.startEncounter(
|
|
347
|
+
enc = await this.scene.startEncounter(arena, { aggressor: actor, target, ambush });
|
|
345
348
|
if (enc.ended) {
|
|
346
349
|
return human ? `` : `The fight was over before ${actor.name} got to act.`;
|
|
347
350
|
}
|
|
@@ -356,7 +359,7 @@ export class AttackCommand extends Command {
|
|
|
356
359
|
? `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
360
|
: `${actor.name} squares up -- initiative rolled; ${who} first.`;
|
|
358
361
|
}
|
|
359
|
-
if (target.isIncapacitated() || target
|
|
362
|
+
if (target.isIncapacitated() || !inArena(arena, target)) {
|
|
360
363
|
return human ? `${target.name} is no longer standing in front of you.` : `${target.name} is no longer a target.`;
|
|
361
364
|
}
|
|
362
365
|
}
|
|
@@ -372,7 +375,10 @@ export class AttackCommand extends Command {
|
|
|
372
375
|
// shoot or swing the blade instead? READY WEAPON (p.165) is its own
|
|
373
376
|
// Simple Action -- "draw" first (2jbprbSYqF7EPXrkR).
|
|
374
377
|
const activeWeapon = this.usesEquippedWeapon() && actor.weaponDrawn ? weaponItem : undefined;
|
|
375
|
-
|
|
378
|
+
// A DATA SPIKE (p.239) is neither a swing nor a shot: no ground to
|
|
379
|
+
// close, no recoil, a Complex Action. The grid sees no distance.
|
|
380
|
+
const onGrid = actor.plane === 'matrix';
|
|
381
|
+
const isMelee = !onGrid && (!activeWeapon || !activeWeapon.isFirearm());
|
|
376
382
|
const report = [];
|
|
377
383
|
// MELEE REACH: ground costs (p.161-162).
|
|
378
384
|
if (isMelee) {
|
|
@@ -428,8 +434,8 @@ export class AttackCommand extends Command {
|
|
|
428
434
|
}
|
|
429
435
|
}
|
|
430
436
|
// 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 });
|
|
437
|
+
const label = onGrid ? 'Data Spike' : isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
|
|
438
|
+
const bill = billAction(this.scene, actor, onGrid || isMelee ? 'complex' : 'simple', label, { attack: true });
|
|
433
439
|
if (bill)
|
|
434
440
|
return bill;
|
|
435
441
|
const exchange = new CombatExchange(this.scene, this.logger, actor);
|
|
@@ -442,7 +448,7 @@ export class AttackCommand extends Command {
|
|
|
442
448
|
target.combatOpponent = target.combatOpponent ?? actor;
|
|
443
449
|
target.lastExchangeAt = now;
|
|
444
450
|
actor.surrendered = false;
|
|
445
|
-
if (!isMelee) {
|
|
451
|
+
if (!isMelee && !onGrid) {
|
|
446
452
|
actor.firedThisPhase = true;
|
|
447
453
|
actor.recoilRoundsFired += 1;
|
|
448
454
|
}
|
|
@@ -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).
|
|
@@ -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
|
|
@@ -5,7 +5,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
|
5
5
|
import { perceptionTest } from '../utilities/perception.js';
|
|
6
6
|
import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
|
|
7
7
|
import { placeName } from '../utilities/log-style.js';
|
|
8
|
-
import { billAction } from '../utilities/action-cost.js';
|
|
8
|
+
import { billAction, encounterOf } from '../utilities/action-cost.js';
|
|
9
9
|
/**
|
|
10
10
|
* Barrier rendering (2026-08-24 playtest: "I don't see THE THING I'm
|
|
11
11
|
* supposed to act on -- just a description... and I still see the
|
|
@@ -137,6 +137,13 @@ export class SearchCommand extends Command {
|
|
|
137
137
|
static description = 'Search for items in your area';
|
|
138
138
|
async execute(_args) {
|
|
139
139
|
const room = this.actor.currentLocation;
|
|
140
|
+
// A MATRIX SEARCH IS MEASURED IN MINUTES (p.241: base time one
|
|
141
|
+
// minute inside a host), not in Action Phases -- there is no cost to
|
|
142
|
+
// bill inside a Combat Turn, so it is refused there rather than
|
|
143
|
+
// priced by invention.
|
|
144
|
+
if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
|
|
145
|
+
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
|
+
}
|
|
140
147
|
// OBSERVE IN DETAIL (SR5 p.165): the Perception Test below is the
|
|
141
148
|
// Simple Action the book names, inside a Combat Turn.
|
|
142
149
|
const bill = billAction(this.scene, this.actor, 'simple', 'Observe in Detail');
|
|
@@ -440,5 +440,14 @@
|
|
|
440
440
|
// 1.52.0 (2026-09-12): THE EMBEDDED HUB SEED IS GONE FROM SAVES. The one
|
|
441
441
|
// release of dual-writing is over: a save carries hubSeedRef only, and
|
|
442
442
|
// the site (sheet fallback) reads hubSeed on older docs alone.
|
|
443
|
-
|
|
443
|
+
// 1.53.0 (2026-09-13): THE MATRIX JOINS THE COMBAT TURN. A host fight is a
|
|
444
|
+
// CombatEncounter in a host arena (encounters are keyed by arena: room,
|
|
445
|
+
// host, or the open grid); the host launches one IC per Combat Turn at
|
|
446
|
+
// the turn's start, each program is a participant with 4D6 whose phase
|
|
447
|
+
// the ice brain plays, a Data Spike is the phase's Complex attack, and
|
|
448
|
+
// hack/enter/exit/look/jack out bill their canon action cost inside a
|
|
449
|
+
// Combat Turn. IC no longer act once per player command, never speak,
|
|
450
|
+
// and a failed IC attack damages the program (p.247). Ice NPCs are no
|
|
451
|
+
// longer generated; the vault host's rating is its guard.
|
|
452
|
+
export const ENGINE_VERSION = '1.53.0';
|
|
444
453
|
//# sourceMappingURL=engine-version.js.map
|
|
@@ -41,8 +41,9 @@ import { getPref } from './utilities/prefs.js';
|
|
|
41
41
|
import { rollPool, formatRoll } from './utilities/dice.js';
|
|
42
42
|
import { Direction } from './types/shared/direction-enum.js';
|
|
43
43
|
import { Logger } from './utilities/logger.js';
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
44
|
+
import { hostIsHunting, ambientPatrol } from './utilities/ic.js';
|
|
45
|
+
import { hostContactBeat } from './utilities/host-combat.js';
|
|
46
|
+
import { endPersona } from './utilities/planes.js';
|
|
46
47
|
import { CommandQueue } from './utilities/command-queue.js';
|
|
47
48
|
import { currentSession } from './utilities/session-context.js';
|
|
48
49
|
import { PlayersCommand } from './commands/players.js';
|
|
@@ -2482,7 +2483,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
2482
2483
|
isParty: a => a !== player && this.scene.isHumanControlled(a),
|
|
2483
2484
|
// Whose Action Phase is live in the viewer's room
|
|
2484
2485
|
// (PYK6zCKhQcjRFwLSQ); undefined between phases and out of a fight.
|
|
2485
|
-
|
|
2486
|
+
// The viewer's own fight first -- a persona's is inside its host,
|
|
2487
|
+
// not in the room its body sits in.
|
|
2488
|
+
phaseActor: () => (this.scene.encounterFor(player) ?? this.scene.encounterIn(player.currentLocation))?.phaseActor?.name,
|
|
2486
2489
|
}))
|
|
2487
2490
|
.catch(() => undefined);
|
|
2488
2491
|
};
|
|
@@ -6044,7 +6047,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
6044
6047
|
if (this.scene.isHumanControlled(actor))
|
|
6045
6048
|
this.scene.beat += 1;
|
|
6046
6049
|
const result = await command.execute(args);
|
|
6047
|
-
const
|
|
6050
|
+
const hosted = await this.withHostTurn(actor, result);
|
|
6051
|
+
const wrapped = await this.withHostileContact(actor, this.withEphemeralTurn(actor, this.withAlarmTurn(actor, hosted)));
|
|
6048
6052
|
// The IC turn above may have filled a track -- settle it now, not
|
|
6049
6053
|
// on the next keystroke.
|
|
6050
6054
|
const settled = await settleUnresolvedHarm(this.scene, actor);
|
|
@@ -6178,19 +6182,43 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
6178
6182
|
const lines = await hostileContactBeat(this.scene, actor, this.scene.beat);
|
|
6179
6183
|
return lines.length === 0 ? result : `${result}\n\n${lines.join('\n')}`;
|
|
6180
6184
|
}
|
|
6181
|
-
|
|
6185
|
+
/**
|
|
6186
|
+
* THE HOST'S BEAT after every command a persona types inside one
|
|
6187
|
+
* (p.246-247: the ice acts on what is in the host).
|
|
6188
|
+
*
|
|
6189
|
+
* Before the host has made you, this is ambient Patrol on its clock
|
|
6190
|
+
* (utilities/ic.ts ambientPatrol) -- the per-command tick of that
|
|
6191
|
+
* clock is an engine mapping and its own comment says so. Once the
|
|
6192
|
+
* host is hunting, the fight is a Combat Turn in the host's arena
|
|
6193
|
+
* (utilities/host-combat.ts): the first beat opens it, and after
|
|
6194
|
+
* that the programs act in their OWN Action Phases, not on every
|
|
6195
|
+
* keystroke. The old shape -- every running program taking a swing
|
|
6196
|
+
* per player command, with its dice and lines appended to whatever
|
|
6197
|
+
* the command printed -- is gone; Vex's log had four programs and
|
|
6198
|
+
* twelve lines answering "look".
|
|
6199
|
+
*/
|
|
6200
|
+
async withHostTurn(actor, result) {
|
|
6182
6201
|
if (result === null)
|
|
6183
6202
|
return null;
|
|
6184
|
-
// The host's turn happens INSIDE it (p.246-247): the ice acts on what
|
|
6185
|
-
// is in the host, and the persona's position says whether it is.
|
|
6186
6203
|
const host = actor.hostInside;
|
|
6187
6204
|
if (!host || actor.plane !== 'matrix')
|
|
6188
6205
|
return result;
|
|
6189
|
-
|
|
6206
|
+
if (hostIsHunting(host.over, actor)) {
|
|
6207
|
+
if (this.scene.encounterInHost(host)?.has(actor))
|
|
6208
|
+
return result;
|
|
6209
|
+
// THE COMMAND'S OWN REPLY PRINTS FIRST. The encounter announces
|
|
6210
|
+
// itself while it opens (the aggressor, Surprise, the order), so
|
|
6211
|
+
// a result returned afterwards would land under all of that,
|
|
6212
|
+
// out of order -- the same trick hostile-contact.ts uses.
|
|
6213
|
+
if (result.length > 0 && this.scene.isHumanControlled(actor)) {
|
|
6214
|
+
Logger.getInstance().log(result, { actor: actor.name });
|
|
6215
|
+
}
|
|
6216
|
+
await hostContactBeat(this.scene, actor);
|
|
6217
|
+
return '';
|
|
6218
|
+
}
|
|
6219
|
+
const turn = ambientPatrol(host.over, actor);
|
|
6190
6220
|
if (turn.lines.length === 0 && turn.meta.length === 0)
|
|
6191
6221
|
return result;
|
|
6192
|
-
// Dice to the Mechanics pane, and only for a human -- the same gate
|
|
6193
|
-
// every other roll in this engine uses.
|
|
6194
6222
|
if (this.scene.isHumanControlled(actor)) {
|
|
6195
6223
|
const logger = Logger.getInstance();
|
|
6196
6224
|
for (const m of turn.meta)
|
|
@@ -6198,36 +6226,16 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
6198
6226
|
}
|
|
6199
6227
|
for (const w of turn.world)
|
|
6200
6228
|
this.scene.addWorldEvent(w);
|
|
6201
|
-
// A deck bricked by IC owes the same forced dump a convergence does.
|
|
6202
|
-
// ic.ts reports it rather than performing it -- leaveMatrix needs the
|
|
6203
|
-
// Scene and that module deliberately takes none, exactly as hack.ts
|
|
6204
|
-
// splits the convergence hammer from the dump that follows it.
|
|
6205
|
-
if (turn.bricked) {
|
|
6206
|
-
turn.lines.push(...leaveMatrix(this.scene, actor, {
|
|
6207
|
-
forced: true,
|
|
6208
|
-
reason: `The host's ice burns your deck out from under you.`,
|
|
6209
|
-
}));
|
|
6210
|
-
}
|
|
6211
|
-
else if (turn.scrambled) {
|
|
6212
|
-
// SCRAMBLE IC (p.248): "you reboot immediately, taking dumpshock if
|
|
6213
|
-
// you were in VR." A forced exit through the same door, which is
|
|
6214
|
-
// what makes it a reboot rather than a graceful drop -- and the
|
|
6215
|
-
// reboot is also what hands the reducers' damage back.
|
|
6216
|
-
//
|
|
6217
|
-
// `else if` on purpose: a deck already bricked this turn has been
|
|
6218
|
-
// dumped, and dumping a persona twice would bill dumpshock twice.
|
|
6219
|
-
turn.lines.push(...leaveMatrix(this.scene, actor, {
|
|
6220
|
-
forced: true,
|
|
6221
|
-
reason: `Scramble IC rips the connection out at the root --`,
|
|
6222
|
-
}));
|
|
6223
|
-
}
|
|
6224
|
-
if (turn.traced) {
|
|
6225
|
-
// TRACK IC (p.249): the location goes to the authorities. Routed
|
|
6226
|
-
// into the heat the game already keeps for being made in the meat
|
|
6227
|
-
// world, rather than a second parallel notion of "they know".
|
|
6228
|
-
this.addHeat(4, `Track IC reported ${actor.name}'s location`);
|
|
6229
|
-
}
|
|
6230
6229
|
const body = turn.lines.join('\n');
|
|
6230
|
+
// Made on this very sweep: the fight opens on the same beat, after
|
|
6231
|
+
// the sighting has been read.
|
|
6232
|
+
if (hostIsHunting(host.over, actor)) {
|
|
6233
|
+
if (this.scene.isHumanControlled(actor)) {
|
|
6234
|
+
Logger.getInstance().log(result.length > 0 ? `${result}\n${body}` : body, { actor: actor.name });
|
|
6235
|
+
}
|
|
6236
|
+
await hostContactBeat(this.scene, actor);
|
|
6237
|
+
return '';
|
|
6238
|
+
}
|
|
6231
6239
|
return result.length > 0 ? `${result}\n${body}` : body;
|
|
6232
6240
|
}
|
|
6233
6241
|
/**
|
|
@@ -3554,6 +3554,12 @@ export class Player extends AbstractPlayer {
|
|
|
3554
3554
|
if (this.plane === 'drone')
|
|
3555
3555
|
return this.simMode === 'hot' ? 4 : 3;
|
|
3556
3556
|
if (this.plane === 'matrix') {
|
|
3557
|
+
// A HOST'S PROGRAM "should be treated as if it is in hot-sim, so
|
|
3558
|
+
// it gets a total of 4D6 Initiative Dice" (p.247). It has no
|
|
3559
|
+
// simMode of its own -- that field routes a visitor's damage into
|
|
3560
|
+
// a deck -- so the rule is read off the host it runs for.
|
|
3561
|
+
if (this.icHost)
|
|
3562
|
+
return 4;
|
|
3557
3563
|
if (this.simMode !== 'hot')
|
|
3558
3564
|
return 3;
|
|
3559
3565
|
// Overclocking echo (p.258): "an additional +1D6 while you're in
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Item, NPC } from './_index.js';
|
|
1
|
+
import { Room, Item, NPC } from './_index.js';
|
|
2
2
|
import { capitalCase } from 'change-case';
|
|
3
3
|
import { localGrid as makeLocalGrid } from '../utilities/grids.js';
|
|
4
4
|
import { Logger } from '../utilities/logger.js';
|
|
@@ -6,7 +6,7 @@ import { hint } from '../utilities/hints.js';
|
|
|
6
6
|
import { Category, Size, Rating } from '../types/shared/item-enum.js';
|
|
7
7
|
import { AbstractScene } from '../types/shared/abstracts.js';
|
|
8
8
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
9
|
-
import { CombatEncounter } from '../utilities/combat-turn.js';
|
|
9
|
+
import { CombatEncounter, arenaKey, arenaOf, inArena } from '../utilities/combat-turn.js';
|
|
10
10
|
export class Scene extends AbstractScene {
|
|
11
11
|
story;
|
|
12
12
|
// SYMMETRIC PLAYERS: the scene holds a LIST of human players --
|
|
@@ -136,13 +136,27 @@ export class Scene extends AbstractScene {
|
|
|
136
136
|
// the fight in the office are two initiative orders, as they would be
|
|
137
137
|
// at a table. An encounter holds the participants, their scores and
|
|
138
138
|
// whose Action Phase is live; the scene only owns the map.
|
|
139
|
+
//
|
|
140
|
+
// KEYED BY ARENA (utilities/combat-turn.ts, 2026-09-13): a room for
|
|
141
|
+
// the physical planes, a host for the personas inside it, the open
|
|
142
|
+
// grid for the rest. A persona's currentLocation is its body's room,
|
|
143
|
+
// so a room key could never find a decker's fight.
|
|
139
144
|
encounters = new Map();
|
|
140
|
-
|
|
145
|
+
encounterAt(arena) {
|
|
146
|
+
const enc = this.encounters.get(arenaKey(arena));
|
|
147
|
+
return enc && !enc.ended ? enc : undefined;
|
|
148
|
+
}
|
|
149
|
+
/** The live fight in a room (the physical arena), if any. */
|
|
141
150
|
encounterIn(room) {
|
|
142
151
|
if (!room)
|
|
143
152
|
return undefined;
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
return this.encounterAt({ kind: 'room', room });
|
|
154
|
+
}
|
|
155
|
+
/** The live fight inside a host, if any. */
|
|
156
|
+
encounterInHost(host) {
|
|
157
|
+
if (!host)
|
|
158
|
+
return undefined;
|
|
159
|
+
return this.encounterAt({ kind: 'host', host });
|
|
146
160
|
}
|
|
147
161
|
/**
|
|
148
162
|
* THE BEAT CLOCK: which human command the world is on. Game bumps it
|
|
@@ -155,7 +169,7 @@ export class Scene extends AbstractScene {
|
|
|
155
169
|
beat = 0;
|
|
156
170
|
/** The fight this actor is a participant in, if any. */
|
|
157
171
|
encounterFor(actor) {
|
|
158
|
-
const enc = this.
|
|
172
|
+
const enc = this.encounterAt(arenaOf(actor));
|
|
159
173
|
return enc?.has(actor) ? enc : undefined;
|
|
160
174
|
}
|
|
161
175
|
/**
|
|
@@ -165,11 +179,15 @@ export class Scene extends AbstractScene {
|
|
|
165
179
|
* either. A bystander is scenery; a bartender is not opposition until
|
|
166
180
|
* someone makes them so (noteAttack pulls them in then).
|
|
167
181
|
*/
|
|
168
|
-
encounterMembers(
|
|
182
|
+
encounterMembers(where, aggressor, target) {
|
|
183
|
+
const arena = where instanceof Room ? { kind: 'room', room: where } : where;
|
|
169
184
|
const out = new Set([aggressor]);
|
|
170
185
|
if (target)
|
|
171
186
|
out.add(target);
|
|
172
|
-
|
|
187
|
+
// The physical arena's roster is the room's; a host's or the grid's
|
|
188
|
+
// is every persona on that side of the wall (matrix-roster.ts).
|
|
189
|
+
const roster = arena.kind === 'room' ? arena.room.getActors() : this.allActors.filter(a => inArena(arena, a));
|
|
190
|
+
for (const a of roster) {
|
|
173
191
|
if (a === aggressor || a === target)
|
|
174
192
|
continue;
|
|
175
193
|
if (!a.sharesCombatPlane(aggressor) || a.isIncapacitated())
|
|
@@ -190,8 +208,9 @@ export class Scene extends AbstractScene {
|
|
|
190
208
|
* Resolves once the first pass has run up to a human's Action Phase
|
|
191
209
|
* (or the fight has already ended).
|
|
192
210
|
*/
|
|
193
|
-
async startEncounter(
|
|
194
|
-
const
|
|
211
|
+
async startEncounter(where, opts) {
|
|
212
|
+
const arena = where instanceof Room ? { kind: 'room', room: where } : where;
|
|
213
|
+
const existing = this.encounterAt(arena);
|
|
195
214
|
if (existing) {
|
|
196
215
|
if (opts.target)
|
|
197
216
|
existing.noteAttack(opts.aggressor, opts.target);
|
|
@@ -199,15 +218,21 @@ export class Scene extends AbstractScene {
|
|
|
199
218
|
existing.join(opts.aggressor);
|
|
200
219
|
return existing;
|
|
201
220
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
221
|
+
// The room under the arena: the fight's own for the physical planes,
|
|
222
|
+
// the host's room for a host, the aggressor's body for the grid.
|
|
223
|
+
const room = arena.kind === 'room' ? arena.room : arena.kind === 'host' ? arena.host.over : opts.aggressor.currentLocation;
|
|
224
|
+
const enc = new CombatEncounter(this, this.logger, room, arena);
|
|
225
|
+
enc.onNewTurn = opts.onNewTurn;
|
|
226
|
+
enc.keepAlive = opts.keepAlive;
|
|
227
|
+
this.encounters.set(arenaKey(arena), enc);
|
|
228
|
+
await enc.start(this.encounterMembers(arena, opts.aggressor, opts.target), opts);
|
|
205
229
|
return enc;
|
|
206
230
|
}
|
|
207
231
|
/** The encounter is over (called by the encounter itself). */
|
|
208
232
|
endEncounter(enc) {
|
|
209
|
-
|
|
210
|
-
|
|
233
|
+
const key = arenaKey(enc.arena);
|
|
234
|
+
if (this.encounters.get(key) === enc)
|
|
235
|
+
this.encounters.delete(key);
|
|
211
236
|
}
|
|
212
237
|
/**
|
|
213
238
|
* Someone walks into a room where a fight is on (p.160 entering
|
|
@@ -215,7 +240,7 @@ export class Scene extends AbstractScene {
|
|
|
215
240
|
* out until they act.
|
|
216
241
|
*/
|
|
217
242
|
admitToEncounter(actor) {
|
|
218
|
-
const enc = this.
|
|
243
|
+
const enc = this.encounterAt(arenaOf(actor));
|
|
219
244
|
if (!enc || enc.has(actor) || actor.isIncapacitated())
|
|
220
245
|
return undefined;
|
|
221
246
|
const npc = actor;
|
|
@@ -599,7 +624,9 @@ export class Scene extends AbstractScene {
|
|
|
599
624
|
// "still going" until the human explicitly ended their turn
|
|
600
625
|
// (5aqFRn2xPtRYDvNxR). Mirrors leave()'s own direct stillHostile()
|
|
601
626
|
// check for the same "nobody left to fight" shape.
|
|
602
|
-
|
|
627
|
+
// The actor's own arena first: a crashed IC's fight is inside its
|
|
628
|
+
// host, not in the room under it.
|
|
629
|
+
const enc = this.encounterFor(actor) ?? (room ? this.encounterIn(room) : undefined);
|
|
603
630
|
if (enc && !enc.ended) {
|
|
604
631
|
if (enc.phaseActor === actor) {
|
|
605
632
|
void enc.dropPhaseActor(actor);
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { rollInitiativeScore, formatInitiative, INITIATIVE_PASS_DROP, rollPool, formatRoll } from './dice.js';
|
|
2
2
|
import { ActionBudget } from './action-budget.js';
|
|
3
3
|
import { runNpcActionPhase } from './npc-combat-brain.js';
|
|
4
|
+
export function arenaKey(a) {
|
|
5
|
+
return a.kind === 'room' ? `room:${a.room.name}` : a.kind === 'host' ? `host:${a.host.name}` : 'grid:open';
|
|
6
|
+
}
|
|
7
|
+
/** The arena this actor would fight in right now. */
|
|
8
|
+
export function arenaOf(actor) {
|
|
9
|
+
if (actor.plane === 'matrix') {
|
|
10
|
+
const host = actor.hostInside;
|
|
11
|
+
return host ? { kind: 'host', host } : { kind: 'grid' };
|
|
12
|
+
}
|
|
13
|
+
return { kind: 'room', room: actor.currentLocation };
|
|
14
|
+
}
|
|
15
|
+
/** Standing in the arena: the body in the room, or the persona on
|
|
16
|
+
* that side of the host wall. */
|
|
17
|
+
export function inArena(arena, actor) {
|
|
18
|
+
if (arena.kind === 'room')
|
|
19
|
+
return actor.plane !== 'matrix' && actor.currentLocation === arena.room;
|
|
20
|
+
if (actor.plane !== 'matrix')
|
|
21
|
+
return false;
|
|
22
|
+
return arena.kind === 'host' ? actor.hostInside === arena.host : actor.hostInside === undefined;
|
|
23
|
+
}
|
|
24
|
+
export function arenaName(arena) {
|
|
25
|
+
return arena.kind === 'room' ? arena.room.name : arena.kind === 'host' ? `the ${arena.host.name} host` : 'the open grid';
|
|
26
|
+
}
|
|
4
27
|
/**
|
|
5
28
|
* THE COMBAT TURN (SR5 p.158-161, RAG-checked 2026-09-06).
|
|
6
29
|
*
|
|
@@ -96,10 +119,23 @@ export class CombatEncounter {
|
|
|
96
119
|
* autopilot was retired 2026-09-07: a phase you commit with "end
|
|
97
120
|
* turn" is yours to play). */
|
|
98
121
|
onHumanPhase;
|
|
99
|
-
|
|
122
|
+
/** A fresh Combat Turn is about to be rolled (p.159 step 5) -- the
|
|
123
|
+
* seam a host uses to launch one IC "at the beginning of each Combat
|
|
124
|
+
* Turn" (p.247), before the roll so the new program rolls with
|
|
125
|
+
* everyone. */
|
|
126
|
+
onNewTurn;
|
|
127
|
+
/** The fight goes on even with nobody paired against anybody: a host
|
|
128
|
+
* whose ice is all crashed still gets its next turn to relaunch
|
|
129
|
+
* (p.355-356). Consulted by stillHostile(). */
|
|
130
|
+
keepAlive;
|
|
131
|
+
/** Where this fight is. `room` stays for the physical arena and for
|
|
132
|
+
* world events; a host arena's room is the room under the host. */
|
|
133
|
+
arena;
|
|
134
|
+
constructor(scene, logger, room, arena) {
|
|
100
135
|
this.scene = scene;
|
|
101
136
|
this.logger = logger;
|
|
102
137
|
this.room = room;
|
|
138
|
+
this.arena = arena ?? { kind: 'room', room };
|
|
103
139
|
}
|
|
104
140
|
// ------------------------------------------------------------ setup ----
|
|
105
141
|
/**
|
|
@@ -153,7 +189,7 @@ export class CombatEncounter {
|
|
|
153
189
|
metaLines.push(` ${p.actor.name}: ${formatInitiative(p.roll)}${p.score !== p.roll.score ? ` -> ${p.score}` : ''}`);
|
|
154
190
|
}
|
|
155
191
|
worldLines.push(`Combat Turn ${this.turn} -- ${this.orderLine()}.`);
|
|
156
|
-
this.scene.addWorldEvent(`A fight broke out in ${this.
|
|
192
|
+
this.scene.addWorldEvent(`A fight broke out in ${arenaName(this.arena)} -- ${opts.aggressor.name} opened${opts.target ? ` on ${opts.target.name}` : ''}. In it: ${this.participants.map(p => p.actor.name).join(', ')}.`);
|
|
157
193
|
await this.announce(worldLines, metaLines);
|
|
158
194
|
await this.run();
|
|
159
195
|
}
|
|
@@ -233,9 +269,14 @@ export class CombatEncounter {
|
|
|
233
269
|
fullDefenseActive(actor) {
|
|
234
270
|
return this.participantOf(actor)?.fullDefenseTurn === this.turn;
|
|
235
271
|
}
|
|
236
|
-
/** Present in the
|
|
272
|
+
/** Present in the arena, on their feet, still a participant. */
|
|
237
273
|
present(p) {
|
|
238
|
-
return p.actor
|
|
274
|
+
return inArena(this.arena, p.actor) && !p.actor.isIncapacitated() && !p.actor.deathClaimed;
|
|
275
|
+
}
|
|
276
|
+
/** A program the host runs (utilities/ic-actors.ts) -- its phase is
|
|
277
|
+
* played by the ice brain and narrated by its own lines. */
|
|
278
|
+
isConstruct(actor) {
|
|
279
|
+
return actor.icKind !== undefined;
|
|
239
280
|
}
|
|
240
281
|
isNpc(actor) {
|
|
241
282
|
return !this.scene.isHumanControlled(actor);
|
|
@@ -261,9 +302,9 @@ export class CombatEncounter {
|
|
|
261
302
|
}
|
|
262
303
|
/** An attack renews (or creates) the hostility between two actors. */
|
|
263
304
|
noteAttack(attacker, defender) {
|
|
264
|
-
if (!this.has(defender) &&
|
|
305
|
+
if (!this.has(defender) && inArena(this.arena, defender))
|
|
265
306
|
this.join(defender);
|
|
266
|
-
if (!this.has(attacker) &&
|
|
307
|
+
if (!this.has(attacker) && inArena(this.arena, attacker))
|
|
267
308
|
this.join(attacker);
|
|
268
309
|
this.notePair(attacker, defender);
|
|
269
310
|
}
|
|
@@ -273,6 +314,8 @@ export class CombatEncounter {
|
|
|
273
314
|
* by an attack -- hands up ends a fight unless somebody keeps shooting.
|
|
274
315
|
*/
|
|
275
316
|
stillHostile() {
|
|
317
|
+
if (this.keepAlive?.())
|
|
318
|
+
return true;
|
|
276
319
|
for (const pair of this.pairs.values()) {
|
|
277
320
|
const a = this.participants.find(p => p.actor.name === pair.a);
|
|
278
321
|
const b = this.participants.find(p => p.actor.name === pair.b);
|
|
@@ -400,10 +443,15 @@ export class CombatEncounter {
|
|
|
400
443
|
* phase that opens and closes in one tick is one beat per watcher.
|
|
401
444
|
*/
|
|
402
445
|
notePhaseChanged() {
|
|
403
|
-
for (const a of this.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
446
|
+
for (const a of this.watchers())
|
|
447
|
+
a.noteConditionChanged();
|
|
448
|
+
}
|
|
449
|
+
/** The humans who can see this fight: bodies in the room, or the
|
|
450
|
+
* human personas on this side of the host wall. */
|
|
451
|
+
watchers() {
|
|
452
|
+
if (this.arena.kind === 'room')
|
|
453
|
+
return this.room.getActors().filter(a => this.scene.isHumanControlled(a));
|
|
454
|
+
return this.scene.getPlayers().filter(p => inArena(this.arena, p));
|
|
407
455
|
}
|
|
408
456
|
async beginPhase(p) {
|
|
409
457
|
this.phaseActor = p.actor;
|
|
@@ -424,12 +472,18 @@ export class CombatEncounter {
|
|
|
424
472
|
this.budget.spend('free', 'Run (still running)');
|
|
425
473
|
const human = this.scene.isHumanControlled(p.actor);
|
|
426
474
|
if (human) {
|
|
427
|
-
|
|
475
|
+
// A persona has no metres to spend: the grid sees no distance.
|
|
476
|
+
const ground = this.arena.kind === 'room' ? `; ${this.movementLine(p.actor)}` : '';
|
|
477
|
+
const budgetLine = `Your Action Phase -- Combat Turn ${this.turn}, pass ${this.pass}, Initiative ${p.score}. ${this.budget.describe()}${ground}. "end turn" when you're done.`;
|
|
428
478
|
this.logger.log(`\n{cyan-fg}${budgetLine}{/cyan-fg}`, { actor: p.actor.name });
|
|
429
479
|
this.scene.updateStatus();
|
|
430
480
|
this.onHumanPhase?.(p.actor, this);
|
|
431
481
|
return;
|
|
432
482
|
}
|
|
483
|
+
// Ice narrates its own action (utilities/ic-brain.ts); a second
|
|
484
|
+
// "acts" line per program per pass was half the wall of text.
|
|
485
|
+
if (this.isConstruct(p.actor))
|
|
486
|
+
return;
|
|
433
487
|
await this.announce([`${p.actor.name} acts (Initiative ${p.score}).`], []);
|
|
434
488
|
}
|
|
435
489
|
movementLine(actor) {
|
|
@@ -543,6 +597,11 @@ export class CombatEncounter {
|
|
|
543
597
|
}
|
|
544
598
|
this.turn += 1;
|
|
545
599
|
this.pass = 1;
|
|
600
|
+
// The host launches at the START of the turn (p.247), before the
|
|
601
|
+
// roll, so a fresh program rolls initiative with everyone else.
|
|
602
|
+
await this.onNewTurn?.(this);
|
|
603
|
+
if (this.ended)
|
|
604
|
+
return;
|
|
546
605
|
const metaLines = [`Combat Turn ${this.turn} -- new Initiative (p.159):`];
|
|
547
606
|
for (const p of this.participants) {
|
|
548
607
|
p.roll = rollInitiativeScore(p.actor.getInitiativeAttribute(), p.actor.getInitiativeDice());
|
|
@@ -590,7 +649,7 @@ export class CombatEncounter {
|
|
|
590
649
|
: `${actor.name} has only ${p.score} Initiative left -- ${label} costs ${cost} (SR5 p.168).`;
|
|
591
650
|
}
|
|
592
651
|
p.score -= cost;
|
|
593
|
-
this.logger.meta(` ${actor.name}: ${label} (-${cost} Initiative -> ${p.score})`, undefined, { room: this.room.name });
|
|
652
|
+
this.logger.meta(` ${actor.name}: ${label} (-${cost} Initiative -> ${p.score})`, undefined, this.arena.kind === 'room' ? { room: this.room.name } : { actor: actor.name });
|
|
594
653
|
return undefined;
|
|
595
654
|
}
|
|
596
655
|
/** Full Defense (p.191): -10, Willpower to every defense this turn. */
|
|
@@ -620,29 +679,37 @@ export class CombatEncounter {
|
|
|
620
679
|
p.actor.firedThisPhase = false;
|
|
621
680
|
}
|
|
622
681
|
this.scene.endEncounter(this);
|
|
682
|
+
const where = arenaName(this.arena);
|
|
623
683
|
const line = reason === 'burnout'
|
|
624
|
-
? `The fight in ${
|
|
684
|
+
? `The fight in ${where} burns out -- nobody can land the finishing blow.`
|
|
625
685
|
: reason === 'left'
|
|
626
|
-
? `The fight in ${
|
|
627
|
-
: `The fight in ${
|
|
686
|
+
? `The fight in ${where} is over -- nobody left to fight.`
|
|
687
|
+
: `The fight in ${where} is over.`;
|
|
628
688
|
await this.announce([line], []);
|
|
629
689
|
this.scene.updateStatus();
|
|
630
690
|
// Spirits whose services ran out depart once the dust settles.
|
|
631
691
|
this.scene.ownerGame?.settleSpiritServices?.();
|
|
632
692
|
}
|
|
633
693
|
// ------------------------------------------------------------ output ----
|
|
634
|
-
/** A human is present in the
|
|
694
|
+
/** A human is present in the arena to see it. */
|
|
635
695
|
witnessed() {
|
|
636
|
-
return this.
|
|
696
|
+
return this.watchers().length > 0;
|
|
637
697
|
}
|
|
638
698
|
async announce(lines, meta) {
|
|
639
699
|
if ((lines.length === 0 && meta.length === 0) || !this.witnessed())
|
|
640
700
|
return;
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
this.
|
|
701
|
+
// A room scope resolves by BODY room (headless.ts), which is not
|
|
702
|
+
// where a persona stands -- so a host or grid fight addresses each
|
|
703
|
+
// watching persona by name instead.
|
|
704
|
+
const scopes = this.arena.kind === 'room'
|
|
705
|
+
? [{ room: this.room.name }]
|
|
706
|
+
: this.watchers().map(p => ({ actor: p.name }));
|
|
707
|
+
for (const scope of scopes) {
|
|
708
|
+
for (const m of meta)
|
|
709
|
+
this.logger.meta(m, undefined, scope);
|
|
710
|
+
if (lines.length > 0)
|
|
711
|
+
this.logger.log(`\n${lines.join('\n')}`, scope);
|
|
712
|
+
}
|
|
646
713
|
this.scene.updateStatus();
|
|
647
714
|
const beat = process.env.MAKA_NO_BEATS === '1' ? 0 : CombatEncounter.BEAT_MS;
|
|
648
715
|
if (beat > 0)
|
|
@@ -674,7 +741,8 @@ export class CombatEncounter {
|
|
|
674
741
|
if (!p)
|
|
675
742
|
return undefined;
|
|
676
743
|
if (this.phaseActor === player && this.budget) {
|
|
677
|
-
|
|
744
|
+
const ground = this.arena.kind === 'room' ? ` · ${player.movementLeftMeters} m` : '';
|
|
745
|
+
return `{cyan-fg}⏱ YOUR PHASE{/cyan-fg} T${this.turn}/P${this.pass} Init ${p.score} · ${this.budget.describe()}${ground} · "end turn"`;
|
|
678
746
|
}
|
|
679
747
|
const who = this.phaseActor ? `${this.phaseActor.name} acting` : 'resolving';
|
|
680
748
|
return `⏱ COMBAT T${this.turn}/P${this.pass} Init ${p.score} · ${who}${p.fullDefenseTurn === this.turn ? ' · FULL DEFENSE' : ''}`;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { hostIsHunting, hostLaunchStep } from './ic.js';
|
|
2
|
+
import { personasInside } from './matrix-roster.js';
|
|
3
|
+
/**
|
|
4
|
+
* THE HOST OWNS ITS FIGHT (2026-09-13, the Matrix joins the Combat Turn).
|
|
5
|
+
*
|
|
6
|
+
* Before this the host's ice acted once per PLAYER COMMAND (game.ts
|
|
7
|
+
* withICTurn -> ic.ts runICTurn): type "look" inside an alerted host and
|
|
8
|
+
* every running program took a swing, four programs and twelve lines a
|
|
9
|
+
* keystroke, with no initiative and no turn to end -- an engine mapping
|
|
10
|
+
* that its own comment called "not a rule". Cybercombat, meanwhile, ran
|
|
11
|
+
* on the two-actor exchange: every `attack Patrol IC` rolled a fresh
|
|
12
|
+
* initiative and printed "FREEZES".
|
|
13
|
+
*
|
|
14
|
+
* Canon has one structure for both (SR5 p.247, p.229-230, RAG-checked
|
|
15
|
+
* 2026-09-13): Combat Turns and Initiative Passes shared with the meat
|
|
16
|
+
* world; a persona's initiative is Data Processing + Intuition with
|
|
17
|
+
* 3D6/4D6; each IC program has its own persona, condition monitor and
|
|
18
|
+
* Initiative Score, treated as hot-sim (4D6); the host launches ONE IC
|
|
19
|
+
* per Combat Turn at the beginning of the turn, up to its rating
|
|
20
|
+
* running; IC attacks are Complex Actions. So the host's fight is a
|
|
21
|
+
* CombatEncounter in a host ARENA (combat-turn.ts): the host launches
|
|
22
|
+
* on the encounter's newTurn hook, each program is a participant whose
|
|
23
|
+
* Action Phase the ice brain plays (ic-brain.ts), and the persona spends
|
|
24
|
+
* its own budget and types "end turn" like anywhere else.
|
|
25
|
+
*
|
|
26
|
+
* `ic.ts` stays scene-free and dice-only; this file is the seam that
|
|
27
|
+
* holds the Scene and the encounter.
|
|
28
|
+
*/
|
|
29
|
+
const empty = () => ({ lines: [], meta: [], world: [] });
|
|
30
|
+
/** Every program the host has running as an actor, seated in the fight. */
|
|
31
|
+
function seatRunningIce(enc, host) {
|
|
32
|
+
for (const ice of host.iceActors.values()) {
|
|
33
|
+
if (!enc.has(ice) && !ice.isIncapacitated())
|
|
34
|
+
enc.join(ice);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Report one host step to the persona's watchers. */
|
|
38
|
+
async function tell(enc, scene, turn) {
|
|
39
|
+
for (const w of turn.world)
|
|
40
|
+
scene.addWorldEvent(w);
|
|
41
|
+
await enc.announce(turn.lines, turn.meta);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* THE START OF THE HOST'S COMBAT TURN (p.247): crashed programs leave,
|
|
45
|
+
* one program launches, and the newcomer rolls initiative with everyone
|
|
46
|
+
* else -- the encounter calls this from newTurn() before the roll.
|
|
47
|
+
*/
|
|
48
|
+
export async function hostTurnStart(scene, host, enc) {
|
|
49
|
+
const out = empty();
|
|
50
|
+
hostLaunchStep(host.over, scene, out);
|
|
51
|
+
seatRunningIce(enc, host);
|
|
52
|
+
await tell(enc, scene, out);
|
|
53
|
+
}
|
|
54
|
+
/** The fight goes on while the host is alert and someone human is still
|
|
55
|
+
* inside it -- even with every program crashed, the host gets its next
|
|
56
|
+
* turn to relaunch (p.355-356). */
|
|
57
|
+
export function hostKeepsFighting(scene, host) {
|
|
58
|
+
return host.alert && !host.sanctioned
|
|
59
|
+
&& personasInside(scene, host).some(p => scene.isHumanControlled(p) && !p.isIncapacitated());
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* OPEN THE HOST'S FIGHT on an intruder it has made. The aggressor is a
|
|
63
|
+
* running program -- Patrol, which "is already walking the host", if
|
|
64
|
+
* nothing else is -- so the initiative block reads like a fight and not
|
|
65
|
+
* like a system message.
|
|
66
|
+
*/
|
|
67
|
+
export async function openHostEncounter(scene, host, intruder) {
|
|
68
|
+
const out = empty();
|
|
69
|
+
let aggressor = [...host.iceActors.values()].find(ice => !ice.isIncapacitated());
|
|
70
|
+
if (!aggressor) {
|
|
71
|
+
aggressor = hostLaunchStep(host.over, scene, out);
|
|
72
|
+
}
|
|
73
|
+
if (!aggressor)
|
|
74
|
+
return undefined;
|
|
75
|
+
for (const w of out.world)
|
|
76
|
+
scene.addWorldEvent(w);
|
|
77
|
+
const enc = await scene.startEncounter({ kind: 'host', host }, {
|
|
78
|
+
aggressor, target: intruder, ambush: false,
|
|
79
|
+
onNewTurn: e => hostTurnStart(scene, host, e),
|
|
80
|
+
keepAlive: () => hostKeepsFighting(scene, host),
|
|
81
|
+
});
|
|
82
|
+
seatRunningIce(enc, host);
|
|
83
|
+
if (out.lines.length > 0)
|
|
84
|
+
await enc.announce(out.lines, out.meta);
|
|
85
|
+
return enc;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The beat after every command a persona types inside a host (game.ts
|
|
89
|
+
* withHostTurn): an alerted host that is not yet fighting this persona
|
|
90
|
+
* opens on it. Returns true when a fight was opened this beat.
|
|
91
|
+
*/
|
|
92
|
+
export async function hostContactBeat(scene, player) {
|
|
93
|
+
const host = player.hostInside;
|
|
94
|
+
if (!host || player.plane !== 'matrix')
|
|
95
|
+
return false;
|
|
96
|
+
if (!hostIsHunting(host.over, player))
|
|
97
|
+
return false;
|
|
98
|
+
const live = scene.encounterInHost(host);
|
|
99
|
+
if (live) {
|
|
100
|
+
if (!live.has(player))
|
|
101
|
+
live.join(player);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const enc = await openHostEncounter(scene, host, player);
|
|
105
|
+
return enc !== undefined;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=host-combat.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { IC_TYPES, icActs } from './ic.js';
|
|
2
|
+
import { leaveMatrix } from './planes.js';
|
|
3
|
+
import { Logger } from './logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* A PROGRAM'S ACTION PHASE (SR5 p.247): one Complex Action, by rule.
|
|
6
|
+
*
|
|
7
|
+
* The meat brain (npc-combat-brain.ts) plays verbs -- draw, close, fire.
|
|
8
|
+
* Ice has no verbs and no mind (models/npc.ts isConstruct): its whole
|
|
9
|
+
* repertoire is the one test its type makes, and utilities/ic.ts already
|
|
10
|
+
* rolls that with the right dice. So the phase is: pick the persona the
|
|
11
|
+
* host is after, run the program's act once, spend the Complex, and
|
|
12
|
+
* route what happened to the same places game.ts used to -- the lines to
|
|
13
|
+
* the watchers, the world events to the table, a bricked or scrambled
|
|
14
|
+
* persona to the forced dump, a trace to the heat ledger.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here narrates a second "X acts" line: the program's own act
|
|
17
|
+
* line is the narration (combat-turn.ts beginPhase skips it for ice).
|
|
18
|
+
*/
|
|
19
|
+
export async function runIcActionPhase(enc, ice) {
|
|
20
|
+
const logger = Logger.getInstance();
|
|
21
|
+
const host = ice.icHost;
|
|
22
|
+
const type = IC_TYPES.find(t => t.name === ice.icKind);
|
|
23
|
+
if (!host || !type || ice.isIncapacitated())
|
|
24
|
+
return;
|
|
25
|
+
const budget = enc.budgetOf(ice);
|
|
26
|
+
if (!budget)
|
|
27
|
+
return;
|
|
28
|
+
const target = pickTarget(enc, ice);
|
|
29
|
+
if (!target) {
|
|
30
|
+
logger.write(`Ice: ${ice.name} has nobody in the host to hunt -- phase forfeited.`);
|
|
31
|
+
budget.spend('complex', `${type.name} (idle)`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
ice.combatOpponent = target;
|
|
35
|
+
ice.lastExchangeAt = Date.now();
|
|
36
|
+
const out = { lines: [], meta: [], world: [] };
|
|
37
|
+
icActs(host.over, target, type, out, ice);
|
|
38
|
+
budget.spend('complex', type.name);
|
|
39
|
+
// The forced dumps ic.ts reports rather than performs -- it takes no
|
|
40
|
+
// Scene. Reasons verbatim from the old game.ts withICTurn.
|
|
41
|
+
if (out.bricked) {
|
|
42
|
+
out.lines.push(...leaveMatrix(enc.scene, target, {
|
|
43
|
+
forced: true,
|
|
44
|
+
reason: `The host's ice burns your deck out from under you.`,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
else if (out.scrambled) {
|
|
48
|
+
out.lines.push(...leaveMatrix(enc.scene, target, {
|
|
49
|
+
forced: true,
|
|
50
|
+
reason: `Scramble IC rips the connection out at the root --`,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
if (out.traced) {
|
|
54
|
+
// TRACK IC (p.249): the location goes to the authorities -- routed
|
|
55
|
+
// into the heat the game already keeps for being made in the meat.
|
|
56
|
+
enc.scene.ownerGame?.addHeat?.(4, `Track IC reported ${target.name}'s location`);
|
|
57
|
+
}
|
|
58
|
+
for (const w of out.world)
|
|
59
|
+
enc.scene.addWorldEvent(w);
|
|
60
|
+
await enc.announce(out.lines, out.meta);
|
|
61
|
+
enc.syncWounds();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Who the program goes for: the persona the host holds the most marks
|
|
65
|
+
* on (Probe's work, and what Killer's DV and the mark-gated programs
|
|
66
|
+
* read), then whoever it last engaged, then the first enemy standing.
|
|
67
|
+
*/
|
|
68
|
+
function pickTarget(enc, ice) {
|
|
69
|
+
const enemies = enc.enemiesOf(ice).filter(e => e.plane === 'matrix');
|
|
70
|
+
if (enemies.length === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
const marksOn = ice.icHost?.marksOn;
|
|
73
|
+
let best = enemies[0];
|
|
74
|
+
let bestMarks = -1;
|
|
75
|
+
for (const e of enemies) {
|
|
76
|
+
const m = marksOn?.get(e.name) ?? 0;
|
|
77
|
+
if (m > bestMarks || (m === bestMarks && e === ice.combatOpponent)) {
|
|
78
|
+
best = e;
|
|
79
|
+
bestMarks = m;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return best;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=ic-brain.js.map
|
|
@@ -297,37 +297,37 @@ export function runHostTurn(room, actor, scene) {
|
|
|
297
297
|
return ambientPatrol(room, actor);
|
|
298
298
|
}
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
300
|
+
* THE START OF A HOST'S COMBAT TURN (p.247): "a host can launch one IC
|
|
301
|
+
* program per Combat Turn, at the beginning of each Combat Turn", up to
|
|
302
|
+
* its rating running, never two of a kind. Crashed programs leave the
|
|
303
|
+
* list first and come back the very next turn (p.355-356, "delayed,
|
|
304
|
+
* never destroyed") -- the relaunch takes the turn's one launch.
|
|
301
305
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* ALERTED HOST counts as one Combat Turn. That is the closest structural
|
|
307
|
-
* analogue available; it is not something the book says, and it is not
|
|
308
|
-
* dressed up as a citation.
|
|
306
|
+
* Launches only; nothing acts here. In play the encounter's newTurn
|
|
307
|
+
* hook calls this (utilities/host-combat.ts) and each program then acts
|
|
308
|
+
* in its own Action Phase (utilities/ic-brain.ts). Returns the actor a
|
|
309
|
+
* launch produced, so the caller can seat it in the fight.
|
|
309
310
|
*/
|
|
310
|
-
export function
|
|
311
|
-
if (!hostIsHunting(room, actor))
|
|
312
|
-
return empty();
|
|
313
|
-
const out = empty();
|
|
311
|
+
export function hostLaunchStep(room, scene, out) {
|
|
314
312
|
const host = room.host;
|
|
315
|
-
|
|
316
|
-
// filled since the last turn is off the list now, and the host
|
|
317
|
-
// relaunches it the very next turn -- "delayed, never destroyed".
|
|
313
|
+
let launched;
|
|
318
314
|
if (host) {
|
|
319
|
-
//
|
|
320
|
-
//
|
|
315
|
+
// Crashes first, then the relaunch: a program whose monitor filled
|
|
316
|
+
// during the last turn is off the list at the start of this one and
|
|
317
|
+
// comes straight back -- "reactivated the very next turn" -- taking
|
|
318
|
+
// the turn's one launch.
|
|
319
|
+
for (const kind of crashedIC(host)) {
|
|
320
|
+
crashIC(scene, host, kind);
|
|
321
|
+
out.lines.push(` {red-fg}${kind} shears apart -- crashed. The host is already recompiling it.{/red-fg}`);
|
|
322
|
+
}
|
|
321
323
|
if (host.relaunchQueue.length > 0 && room.runningIC.length < room.hostRating) {
|
|
322
324
|
const kind = host.relaunchQueue.shift();
|
|
323
325
|
room.runningIC.push(kind);
|
|
324
326
|
if (scene)
|
|
325
|
-
spawnIC(scene, host, kind);
|
|
327
|
+
launched = spawnIC(scene, host, kind);
|
|
326
328
|
out.lines.push(` {red-fg}The host relaunches ${kind}.{/red-fg}`);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
crashIC(scene, host, kind);
|
|
330
|
-
out.lines.push(` {red-fg}${kind} shears apart -- crashed. The host is already recompiling it.{/red-fg}`);
|
|
329
|
+
// ONE launch per turn (p.247): the relaunch was it.
|
|
330
|
+
return launched;
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
333
|
if (room.runningIC.length < room.hostRating) {
|
|
@@ -335,26 +335,49 @@ export function runICTurn(room, actor, scene) {
|
|
|
335
335
|
if (next) {
|
|
336
336
|
room.runningIC.push(next.name);
|
|
337
337
|
if (host && scene)
|
|
338
|
-
spawnIC(scene, host, next.name);
|
|
338
|
+
launched = spawnIC(scene, host, next.name) ?? launched;
|
|
339
339
|
out.lines.push(next.kind === 'patrol'
|
|
340
340
|
? ` {red-fg}${next.name} is already walking the host -- it never had to be launched.{/red-fg}`
|
|
341
341
|
: ` {red-fg}The host launches ${next.name}.{/red-fg}`);
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
return launched;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* ONE PROGRAM'S ACTION -- an IC attack is a Complex Action (p.247), which
|
|
348
|
+
* is one program's whole Action Phase. `ice` is the program's own actor
|
|
349
|
+
* when the scene has one: it is what a failed attack damages.
|
|
350
|
+
*/
|
|
351
|
+
export function icActs(room, actor, type, out, ice) {
|
|
352
|
+
if (type.kind === 'patrol')
|
|
353
|
+
patrolActs(room, actor, out);
|
|
354
|
+
else if (type.kind === 'probe')
|
|
355
|
+
probeActs(room, actor, type, out, ice);
|
|
356
|
+
else if (type.kind === 'damage')
|
|
357
|
+
damageActs(room, actor, type, out, ice);
|
|
358
|
+
else
|
|
359
|
+
reducerOrConditionalActs(room, actor, type, out, ice);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* ONE WHOLE COMBAT TURN of the host's ice, as a single call: the launch
|
|
363
|
+
* step, then every running program acts once. This is the shape the
|
|
364
|
+
* tests drive and the shape the engine used to run after EVERY player
|
|
365
|
+
* command ("one action inside an alerted host = one Combat Turn" -- an
|
|
366
|
+
* engine mapping that was never a rule, and that had four programs
|
|
367
|
+
* hitting a persona for typing "look"). In play the Combat Turn owns the
|
|
368
|
+
* pacing now (utilities/host-combat.ts): the launch at newTurn, each
|
|
369
|
+
* program in its own phase.
|
|
370
|
+
*/
|
|
371
|
+
export function runICTurn(room, actor, scene) {
|
|
372
|
+
if (!hostIsHunting(room, actor))
|
|
373
|
+
return empty();
|
|
374
|
+
const out = empty();
|
|
375
|
+
hostLaunchStep(room, scene, out);
|
|
346
376
|
for (const name of room.runningIC) {
|
|
347
377
|
const type = IC_TYPES.find(t => t.name === name);
|
|
348
378
|
if (!type)
|
|
349
379
|
continue;
|
|
350
|
-
|
|
351
|
-
patrolActs(room, actor, out);
|
|
352
|
-
else if (type.kind === 'probe')
|
|
353
|
-
probeActs(room, actor, out);
|
|
354
|
-
else if (type.kind === 'damage')
|
|
355
|
-
damageActs(room, actor, type, out);
|
|
356
|
-
else
|
|
357
|
-
reducerOrConditionalActs(room, actor, type, out);
|
|
380
|
+
icActs(room, actor, type, out, room.host?.iceActors.get(name));
|
|
358
381
|
}
|
|
359
382
|
return out;
|
|
360
383
|
}
|
|
@@ -363,13 +386,29 @@ export function runICTurn(room, actor, scene) {
|
|
|
363
386
|
* v. Intuition + Firewall (p.248). Returns the net hits; 0 or less is a
|
|
364
387
|
* miss. Shared so the four reducers, Track and Scramble cannot drift
|
|
365
388
|
* from Killer's version of the same roll.
|
|
389
|
+
*
|
|
390
|
+
* A FAILED ATTACK ACTION HURTS THE ATTACKER (p.247 "as with all Attack
|
|
391
|
+
* actions, a failed attack causes damage to the IC"; the amount is the
|
|
392
|
+
* general rule, Data Trails p.181: one box of Matrix damage per net hit
|
|
393
|
+
* the defender got, unresisted). Patrol makes no Attack action and is
|
|
394
|
+
* never here (hurtByFailure false). Applied to the program's own actor,
|
|
395
|
+
* which is its condition monitor (ic-actors.ts); a program crashed this
|
|
396
|
+
* way leaves at the next launch step like any other.
|
|
366
397
|
*/
|
|
367
|
-
function icAttack(room, actor, type, out) {
|
|
398
|
+
function icAttack(room, actor, type, out, ice) {
|
|
368
399
|
const { pool, limit } = icAttackPool(room);
|
|
369
400
|
const attack = rollPool(pool, limit);
|
|
370
401
|
const defence = rollPool(icDefencePool(actor));
|
|
371
402
|
const net = attack.hits - defence.hits;
|
|
372
403
|
out.meta.push(`${type.name} -- Host Rating x2 [Attack ${limit}]: ${formatRoll(attack)} v. Intuition + Firewall: ${formatRoll(defence)} (net ${net})`);
|
|
404
|
+
if (net < 0 && type.hurtByFailure && ice && !ice.isDown()) {
|
|
405
|
+
const rebound = -net;
|
|
406
|
+
ice.takeDamage(rebound);
|
|
407
|
+
out.meta.push(`${type.name} -- failed Attack action: ${rebound} box${rebound === 1 ? '' : 'es'} back on the program, unresisted (p.247)`);
|
|
408
|
+
out.lines.push(ice.isDown()
|
|
409
|
+
? ` {red-fg}${ice.name}'s own attack code rejects and tears it apart -- ${rebound} box${rebound === 1 ? '' : 'es'}, and its monitor fills.{/red-fg}`
|
|
410
|
+
: ` ${ice.name} eats its own rejected code -- ${rebound} box${rebound === 1 ? '' : 'es'} (${ice.conditionSummary()}).`);
|
|
411
|
+
}
|
|
373
412
|
return net;
|
|
374
413
|
}
|
|
375
414
|
/**
|
|
@@ -389,8 +428,8 @@ function icAttack(room, actor, type, out) {
|
|
|
389
428
|
* against the next IC, and Jammer really does lower the ceiling on your
|
|
390
429
|
* own Brute Force.
|
|
391
430
|
*/
|
|
392
|
-
function reducerOrConditionalActs(room, actor, type, out) {
|
|
393
|
-
const net = icAttack(room, actor, type, out);
|
|
431
|
+
function reducerOrConditionalActs(room, actor, type, out, ice) {
|
|
432
|
+
const net = icAttack(room, actor, type, out, ice);
|
|
394
433
|
if (net <= 0) {
|
|
395
434
|
out.lines.push(` ${type.name} probes at your icon and finds no purchase.`);
|
|
396
435
|
return;
|
|
@@ -467,17 +506,13 @@ function applyMatrixDamage(actor, dealt, type, out) {
|
|
|
467
506
|
* Probe IC spends its turns building. That coupling is canon's own, and
|
|
468
507
|
* it is why Probe is worth a slot on a host that also fields a Killer.
|
|
469
508
|
*
|
|
470
|
-
* A FAILED IC ATTACK
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* attack ice, and neither exists: there is no cybercombat path against
|
|
474
|
-
* IC in this engine at all. Half of it -- ice that hurts itself while
|
|
475
|
-
* the player still cannot swing back -- would be worse than neither
|
|
476
|
-
* half. It lands with the verb.
|
|
509
|
+
* A FAILED IC ATTACK DAMAGES THE IC (p.247) -- see icAttack, where it
|
|
510
|
+
* lands for every attacking program at once. The player's side of it,
|
|
511
|
+
* cybercombat against ice, is `attack <program>` inside the host.
|
|
477
512
|
*/
|
|
478
|
-
function damageActs(room, actor, type, out) {
|
|
513
|
+
function damageActs(room, actor, type, out, ice) {
|
|
479
514
|
const { limit } = icAttackPool(room);
|
|
480
|
-
const net = icAttack(room, actor, type, out);
|
|
515
|
+
const net = icAttack(room, actor, type, out, ice);
|
|
481
516
|
if (net <= 0) {
|
|
482
517
|
out.lines.push(` ${type.name} lunges at your icon and closes on nothing.`);
|
|
483
518
|
return;
|
|
@@ -608,12 +643,10 @@ function patrolActs(room, actor, out) {
|
|
|
608
643
|
* read in Phase 4. The engine previously modelled the host's side as the
|
|
609
644
|
* single boolean `hostAlert`, which cannot answer "how many".
|
|
610
645
|
*/
|
|
611
|
-
function probeActs(room, actor, out) {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
const net = attack.hits - defence.hits;
|
|
616
|
-
out.meta.push(`Probe IC -- Host Rating x2 [Attack ${limit}]: ${formatRoll(attack)} v. Intuition + Firewall: ${formatRoll(defence)} (net ${net})`);
|
|
646
|
+
function probeActs(room, actor, type, out, ice) {
|
|
647
|
+
// The same opposed roll as every attacking program (icAttack), so a
|
|
648
|
+
// failed probe rebounds on the program like any failed Attack action.
|
|
649
|
+
const net = icAttack(room, actor, type, out, ice);
|
|
617
650
|
const held = room.hostMarksOn.get(actor.name) ?? 0;
|
|
618
651
|
if (net <= 0) {
|
|
619
652
|
out.lines.push(` Probe IC tests your edges and finds nothing to hold onto.`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Category } from '../types/shared/item-enum.js';
|
|
2
2
|
import { inMeleeReach, isInCover, coverAvailableFor, actorDistanceMeters } from './spots.js';
|
|
3
3
|
import { Logger } from './logger.js';
|
|
4
|
+
import { runIcActionPhase } from './ic-brain.js';
|
|
4
5
|
/**
|
|
5
6
|
* AN NPC'S ACTION PHASE, PLAYED BY RULE (SR5 p.163-167).
|
|
6
7
|
*
|
|
@@ -25,6 +26,10 @@ import { Logger } from './logger.js';
|
|
|
25
26
|
* Nothing here rolls a die of its own: the verbs do.
|
|
26
27
|
*/
|
|
27
28
|
export async function runNpcActionPhase(enc, npc) {
|
|
29
|
+
// A HOST'S PROGRAM HAS ITS OWN BRAIN (utilities/ic-brain.ts): no
|
|
30
|
+
// weapon to draw, no ground to close -- one test, by type.
|
|
31
|
+
if (npc.icKind !== undefined)
|
|
32
|
+
return runIcActionPhase(enc, npc);
|
|
28
33
|
const logger = Logger.getInstance();
|
|
29
34
|
if (npc.isIncapacitated() || npc.surrendered)
|
|
30
35
|
return;
|
|
@@ -209,6 +209,11 @@ export const DUMPSHOCK_DV = 6;
|
|
|
209
209
|
export function leaveMatrix(scene, actor, opts) {
|
|
210
210
|
const lines = [];
|
|
211
211
|
const body = actor.bodyRoom;
|
|
212
|
+
// OUT OF THE MATRIX IS OUT OF THE FIGHT (utilities/host-combat.ts): a
|
|
213
|
+
// persona leaving its host arena -- jack out, link-lock broken, deck
|
|
214
|
+
// bricked, Scramble, convergence -- is no longer a participant. Before
|
|
215
|
+
// endPersona clears the position the arena is read off.
|
|
216
|
+
scene.encounterFor?.(actor)?.leave(actor);
|
|
212
217
|
if (opts?.forced) {
|
|
213
218
|
lines.push(...applyDumpshock(actor, opts.reason ?? `The connection is severed from the other side.`));
|
|
214
219
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.182.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.",
|