@maka/maka-cli 5.180.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/factories/scene-chunks.js +69 -23
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +6 -4
- package/bundle/typescript/src/commands/game/sideQuest/game.js +47 -39
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +39 -4
- 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/alarmed-staff.js +3 -0
- 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
|
@@ -935,6 +935,29 @@ description text.`;
|
|
|
935
935
|
combatPhaseLocked() {
|
|
936
936
|
return !!this._scene?.encounterFor?.(this);
|
|
937
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* ICE HAS NO VOICE AND NO MIND OF ITS OWN (player ruling 2026-09-13,
|
|
940
|
+
* from Vex's Borrowed Time log: "Patrol IC: That chip belongs to
|
|
941
|
+
* Corvid. Put it back on the desk, nice and slow" -- four programs
|
|
942
|
+
* bargaining over a chip in four voices, and Killer IC "assembling
|
|
943
|
+
* in the back room" after the persona had already jacked out).
|
|
944
|
+
*
|
|
945
|
+
* SR5 p.247: IC is "mercilessly efficient, not very bright" -- it
|
|
946
|
+
* finds, disables, destroys and repels, and nothing in the books has
|
|
947
|
+
* it speak. A spawned program (icKind) and a seeded matrix-plane
|
|
948
|
+
* construct are the same thing to the engine: driven by dice
|
|
949
|
+
* (utilities/ic.ts), never by the model. No speech, no reflection,
|
|
950
|
+
* no exposition, and no listening either -- there is nothing for a
|
|
951
|
+
* stimulus to wake.
|
|
952
|
+
*/
|
|
953
|
+
isConstruct() {
|
|
954
|
+
return this.icKind !== undefined || this.plane === 'matrix';
|
|
955
|
+
}
|
|
956
|
+
/** Every AI entry point asks this: a construct never runs the model,
|
|
957
|
+
* and anyone in a Combat Turn yields to the turn loop. */
|
|
958
|
+
aiSuspended() {
|
|
959
|
+
return this.isConstruct() || this.combatPhaseLocked();
|
|
960
|
+
}
|
|
938
961
|
/**
|
|
939
962
|
* One verb, executed now, no reflection -- the brain's way of acting
|
|
940
963
|
* inside an Action Phase. Returns the command's own result so the
|
|
@@ -963,6 +986,10 @@ description text.`;
|
|
|
963
986
|
this.logger.write(`${this.name} is gone from the world -- dropping queued action "${commandString}".`);
|
|
964
987
|
return;
|
|
965
988
|
}
|
|
989
|
+
if (this.isConstruct()) {
|
|
990
|
+
this.logger.write(`${this.name} is ice -- the host's dice act for it; dropping "${commandString}".`);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
966
993
|
if (this.combatPhaseLocked()) {
|
|
967
994
|
this.logger.write(`${this.name} is in a Combat Turn -- the turn loop acts for them; dropping "${commandString}".`);
|
|
968
995
|
return;
|
|
@@ -995,6 +1022,10 @@ description text.`;
|
|
|
995
1022
|
// removed shell's AI.
|
|
996
1023
|
if (this.deathClaimed)
|
|
997
1024
|
return;
|
|
1025
|
+
// NOR DOES ICE (isConstruct): a program has no conversation to
|
|
1026
|
+
// hold and no history worth keeping.
|
|
1027
|
+
if (this.isConstruct())
|
|
1028
|
+
return;
|
|
998
1029
|
// NEITHER DO THE UNCONSCIOUS. A knocked-out actor now STAYS in the
|
|
999
1030
|
// scene so they can be brought round (player ruling 2026-08-25) --
|
|
1000
1031
|
// which means every AI entry point has to check, or the body on the
|
|
@@ -1113,6 +1144,10 @@ description text.`;
|
|
|
1113
1144
|
// Same cross-plane blindness as hear() above.
|
|
1114
1145
|
if (!this.canPerceive(actor))
|
|
1115
1146
|
return;
|
|
1147
|
+
// Ice watches with the host's dice, not with a model (see
|
|
1148
|
+
// isConstruct): what it notices, utilities/ic.ts rolls for.
|
|
1149
|
+
if (this.isConstruct())
|
|
1150
|
+
return;
|
|
1116
1151
|
// The unconscious see nothing (see hear()): a KO'd actor stays in
|
|
1117
1152
|
// the scene now, and must not react to what happens over it.
|
|
1118
1153
|
if (this.isIncapacitated())
|
|
@@ -1428,8 +1463,8 @@ description text.`;
|
|
|
1428
1463
|
this.logger.write(`${this.name} is gone from the world -- reflect chain ends.`);
|
|
1429
1464
|
return;
|
|
1430
1465
|
}
|
|
1431
|
-
if (this.
|
|
1432
|
-
this.logger.write(`${this.name} is in a Combat Turn -- reflect chain yields to the turn loop.`);
|
|
1466
|
+
if (this.aiSuspended()) {
|
|
1467
|
+
this.logger.write(`${this.name} ${this.isConstruct() ? 'is ice -- no reflect chain' : 'is in a Combat Turn -- reflect chain yields to the turn loop'}.`);
|
|
1433
1468
|
return;
|
|
1434
1469
|
}
|
|
1435
1470
|
// THE GRIND BREAKER. Record what just happened, then refuse to keep
|
|
@@ -1609,8 +1644,8 @@ ${worldEventsSummary}
|
|
|
1609
1644
|
: [];
|
|
1610
1645
|
}
|
|
1611
1646
|
async respondTo(actor, message) {
|
|
1612
|
-
if (this.
|
|
1613
|
-
this.logger.write(`${this.name} is in a Combat Turn -- no AI reaction to "${message.slice(0, 40)}".`);
|
|
1647
|
+
if (this.aiSuspended()) {
|
|
1648
|
+
this.logger.write(`${this.name} ${this.isConstruct() ? 'is ice' : 'is in a Combat Turn'} -- no AI reaction to "${message.slice(0, 40)}".`);
|
|
1614
1649
|
return;
|
|
1615
1650
|
}
|
|
1616
1651
|
this.logger.write('Attempting to respond');
|
|
@@ -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);
|
|
@@ -59,6 +59,9 @@ export function alarmedStaff(scene, player) {
|
|
|
59
59
|
.filter((a) => a instanceof NPC)
|
|
60
60
|
.filter(npc => !isEphemeral(npc))
|
|
61
61
|
.filter(npc => npc.allyOf === undefined)
|
|
62
|
+
// Ice is not staff: a program answers to the host's dice
|
|
63
|
+
// (utilities/ic.ts), never to an alarm beat (NPC.isConstruct).
|
|
64
|
+
.filter(npc => !npc.isConstruct())
|
|
62
65
|
.filter(npc => !npc.isIncapacitated());
|
|
63
66
|
}
|
|
64
67
|
/**
|
|
@@ -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
|