@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.
Files changed (28) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +20 -11
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +331 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +14 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +17 -10
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +6 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +6 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +13 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +84 -30
  10. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +10 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/game.js +51 -40
  12. package/bundle/typescript/src/commands/game/sideQuest/models/device.js +40 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +4 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +6 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +6 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +6 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +44 -17
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +91 -23
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +30 -4
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +107 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-brain.js +84 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +84 -51
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +5 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/perception.js +50 -21
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +6 -2
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/surveillance.js +4 -0
  28. package/package.json +1 -1
@@ -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
- constructor(scene, logger, room) {
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.room.name} -- ${opts.aggressor.name} opened${opts.target ? ` on ${opts.target.name}` : ''}. In it: ${this.participants.map(p => p.actor.name).join(', ')}.`);
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 room, on their feet, still a participant. */
272
+ /** Present in the arena, on their feet, still a participant. */
237
273
  present(p) {
238
- return p.actor.currentLocation === this.room && !p.actor.isIncapacitated();
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) && defender.currentLocation === this.room)
305
+ if (!this.has(defender) && inArena(this.arena, defender))
265
306
  this.join(defender);
266
- if (!this.has(attacker) && attacker.currentLocation === this.room)
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.room.getActors()) {
404
- if (this.scene.isHumanControlled(a))
405
- a.noteConditionChanged();
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
- const budgetLine = `Your Action Phase -- Combat Turn ${this.turn}, pass ${this.pass}, Initiative ${p.score}. ${this.budget.describe()}; ${this.movementLine(p.actor)}. "end turn" when you're done.`;
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 ${this.room.name} burns out -- nobody can land the finishing blow.`
684
+ ? `The fight in ${where} burns out -- nobody can land the finishing blow.`
625
685
  : reason === 'left'
626
- ? `The fight in ${this.room.name} is over -- nobody left to fight.`
627
- : `The fight in ${this.room.name} is over.`;
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 room to see it. */
694
+ /** A human is present in the arena to see it. */
635
695
  witnessed() {
636
- return this.scene.getPlayers().some(p => p.currentLocation === this.room);
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
- const scope = { room: this.room.name };
642
- for (const m of meta)
643
- this.logger.meta(m, undefined, scope);
644
- if (lines.length > 0)
645
- this.logger.log(`\n${lines.join('\n')}`, scope);
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
- return `{cyan-fg}⏱ YOUR PHASE{/cyan-fg} T${this.turn}/P${this.pass} Init ${p.score} · ${this.budget.describe()} · ${player.movementLeftMeters} m · "end turn"`;
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' : ''}`;
@@ -16,6 +16,25 @@ import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
16
16
  * ▣ PAN, ▲ dangerous device, ◇ data. (There is no air-gapped-host glyph
17
17
  * any more -- see hhSWzLSXECFtfoAGa and canReachHost.)
18
18
  */
19
+ /**
20
+ * A DEVICE ICON, AND WHAT WORKS IT (commands/disable.ts, 2026-09-13).
21
+ * Control Device (p.238) needs 2 marks on the device -- "disable" -- and
22
+ * a Data Spike (p.239) needs none -- "brick". A bricked lock stays
23
+ * locked (p.228), so its line says so rather than offering a way in.
24
+ */
25
+ export function deviceIconLine(device, actor) {
26
+ const dr = `{bold}[DR ${device.rating}]{/bold}`;
27
+ if (device.isOpen())
28
+ return `▤ ${device.name} ${dr} -- its icon standing open, nothing left to hold.`;
29
+ if (device.isBricked) {
30
+ return `▤ ${device.name} ${dr} -- BRICKED, dead electronics${device.kind === 'lock' ? '; the lock stays locked (p.228)' : ''}.`;
31
+ }
32
+ const marks = device.marksBy.get(actor.name) ?? 0;
33
+ const way = marks >= 2
34
+ ? `"disable ${device.name}" (Control Device, ${marks} marks held)`
35
+ : `"hack ${device.name}" for marks (${marks}/2 to command it), or "brick ${device.name}"`;
36
+ return `▤ ${device.name} ${dr} -- a device icon shaped like the thing itself, shut. (${way})`;
37
+ }
19
38
  export function gridIcons(scene, actor, room) {
20
39
  // INSIDE A HOST IS A DIFFERENT PLACE (SR5 p.246, and Deditri's
21
40
  // eJANPpm7qCaCAJBJw). "The virtual space inside a host is separate
@@ -202,9 +221,7 @@ export function gridIcons(scene, actor, room) {
202
221
  for (const device of room.devices) {
203
222
  if (!device.broadcastsAro())
204
223
  continue;
205
- icons.push(device.isOpen()
206
- ? `▤ ${device.name} {bold}[DR ${device.rating}]{/bold} -- its icon standing open, nothing left to hold.`
207
- : `▤ ${device.name} {bold}[DR ${device.rating}]{/bold} -- a device icon shaped like the thing itself, shut. ("hack ${device.name}")`);
224
+ icons.push(deviceIconLine(device, actor));
208
225
  }
209
226
  // The cameras, as a THING (surveillance.ts): live for security, looped,
210
227
  // or yours to ride.
@@ -306,7 +323,16 @@ function hostInteriorIcons(scene, actor, room) {
306
323
  const data = room.offlineServer
307
324
  ? []
308
325
  : room.inventory.getAllItems().filter(i => i.plane === 'matrix');
309
- for (const item of data) {
326
+ // THE ARCHIVE IS NOT A GLANCE (p.241, player ruling 2026-09-13): a
327
+ // persona sees the host's files after a Matrix Search turns them up
328
+ // (commands/search.ts, Host.searchedBy), or when the host is cracked
329
+ // and they spill open. Until then a look says only that there is an
330
+ // archive to search.
331
+ const found = room.hostCracked || (room.host?.searchedBy.has(actor.name) ?? false);
332
+ if (data.length > 0 && !found) {
333
+ icons.push(`◇ The host's archive -- files in here somewhere, and a glance does not read an archive. ("search" runs a Matrix Search, p.241)`);
334
+ }
335
+ for (const item of found ? data : []) {
310
336
  icons.push(room.hostCracked
311
337
  ? `◇ ${item.name} [data] -- unsealed, sitting open in the host's archive. ("take" it)`
312
338
  : `◇ ${item.name} [data] -- SEALED in the host's archive. ("hack" the host to break the seal)`);
@@ -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