@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
@@ -2,10 +2,12 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { planeTintItemName, emphasizeFind, objectiveMark, fileReach } from '../utilities/planes.js';
4
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
5
- import { perceptionTest } from '../utilities/perception.js';
5
+ import { perceptionTest, matrixSearchTest, matrixSearchSeconds, MATRIX_SEARCH } from '../utilities/perception.js';
6
+ import { gridVicinity } from '../utilities/grid-reach.js';
7
+ import { hostLabel } from '../utilities/grid-names.js';
6
8
  import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
7
9
  import { placeName } from '../utilities/log-style.js';
8
- import { billAction } from '../utilities/action-cost.js';
10
+ import { billAction, encounterOf } from '../utilities/action-cost.js';
9
11
  /**
10
12
  * Barrier rendering (2026-08-24 playtest: "I don't see THE THING I'm
11
13
  * supposed to act on -- just a description... and I still see the
@@ -135,8 +137,84 @@ ${coach}`;
135
137
  export class SearchCommand extends Command {
136
138
  static verb = 'search';
137
139
  static description = 'Search for items in your area';
140
+ /**
141
+ * MATRIX SEARCH (SR5 p.241, player ruling 2026-09-13: "a true matrix
142
+ * search, not perception, revealing what is inside a host if it's
143
+ * cracked open").
144
+ *
145
+ * A Special action: Computer + Intuition [Data Processing] against a
146
+ * threshold from the Matrix Search Table, and it takes TIME -- one
147
+ * minute inside a host whatever the information, net hits over the
148
+ * threshold dividing it, a miss spending it all, a Browse program
149
+ * halving it (utilities/perception.ts). The time is real: it moves
150
+ * the persona's Overwatch clock back, so a slow search inside a host
151
+ * is what GOD bills for (utilities/overwatch.ts billTime, +2d6 per
152
+ * 15 minutes).
153
+ *
154
+ * WHAT IT FINDS. Inside a host, the host's archive: from then on the
155
+ * interior lists its files for this persona (Host.searchedBy,
156
+ * grid-view.ts). A CRACKED host spills them without the roll -- the
157
+ * seal is broken, there is nothing to find. Out on the grid it is the
158
+ * public-knowledge row: the loose data icons in reach.
159
+ */
160
+ matrixSearch() {
161
+ const actor = this.actor;
162
+ const host = actor.hostInside;
163
+ const where = host ? host.over : gridVicinity(actor);
164
+ const row = host ? MATRIX_SEARCH.inHost : MATRIX_SEARCH.public;
165
+ const browse = actor.activeDeck?.hasProgram('browse') ?? false;
166
+ const { pool, limit } = matrixSearchTest(actor);
167
+ const roll = rollPool(pool, limit);
168
+ const seconds = matrixSearchSeconds(row.seconds, roll.hits, row.threshold, browse);
169
+ const isPlayer = this.scene.isHumanControlled(actor);
170
+ if (isPlayer) {
171
+ this.logger.meta(`Matrix Search (Computer + Intuition [Data Processing], threshold ${row.threshold}${browse ? ', Browse' : ''}): ${formatRoll(roll)} -- ${seconds}s`);
172
+ }
173
+ // THE MINUTES ARE GOD'S (p.231-232): Overwatch bills time on the
174
+ // grid, and a search is time. Winding the last tick back by the
175
+ // seconds spent is what makes the next illegal action pay for them.
176
+ if (actor.overwatchLastTick > 0)
177
+ actor.overwatchLastTick -= seconds * 1000;
178
+ actor.performAction('runs a Matrix Search', host ? `inside ${hostLabel(where)}` : `across ${where.name}'s grid`);
179
+ const files = where.offlineServer ? [] : where.inventory.getAllItems().filter(i => i.plane === 'matrix');
180
+ const clock = `(${seconds}s of Matrix time${browse ? ', Browse cutting it' : ''})`;
181
+ if (roll.hits < row.threshold) {
182
+ return host
183
+ ? `You dig through ${hostLabel(where)}'s archive and the index keeps folding away from you -- nothing this pass. ${clock}${hint(` Try again; the host is not going anywhere.`)}`
184
+ : `You sweep ${where.name}'s grid for loose data and turn up nothing this pass. ${clock}`;
185
+ }
186
+ if (host) {
187
+ host.searchedBy.add(actor.name);
188
+ where.searchedBy.add(actor.name);
189
+ if (files.length === 0) {
190
+ return `The archive is indexed and EMPTY -- ${hostLabel(where)} keeps nothing worth the trip. ${clock}`;
191
+ }
192
+ const list = files.map(i => `${objectiveMark(this.scene, i.name)}${emphasizeFind(planeTintItemName(i, actor.plane))}`).join(', ');
193
+ const seal = where.hostCracked
194
+ ? hint(` ("take" them -- the seal is already broken.)`)
195
+ : hint(` (sealed in the host's data vault: "hack" the node to break the seal, then "take".)`);
196
+ return `The index gives up ${hostLabel(where)}'s archive: ${list}. ${clock}${seal}`;
197
+ }
198
+ where.searchedBy.add(actor.name);
199
+ const loose = where.hasNode ? [] : files;
200
+ if (loose.length === 0) {
201
+ const sealedElsewhere = files.length > 0 && where.hasNode;
202
+ return `Nothing loose on ${where.name}'s grid. ${clock}${sealedElsewhere ? hint(` (${hostLabel(where)} keeps its files INSIDE -- "enter" it and search from in there.)`) : ''}`;
203
+ }
204
+ const list = loose.map(i => `${objectiveMark(this.scene, i.name)}${emphasizeFind(planeTintItemName(i, actor.plane))}`).join(', ');
205
+ return `Loose on ${where.name}'s grid: ${list}. ${clock}${hint(` ("take" rides them out on your deck.)`)}`;
206
+ }
138
207
  async execute(_args) {
139
208
  const room = this.actor.currentLocation;
209
+ // A MATRIX SEARCH IS MEASURED IN MINUTES (p.241: base time one
210
+ // minute inside a host), not in Action Phases -- there is no cost to
211
+ // bill inside a Combat Turn, so it is refused there rather than
212
+ // priced by invention.
213
+ if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
214
+ return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice or get out first.`;
215
+ }
216
+ if (this.actor.plane === 'matrix')
217
+ return this.matrixSearch();
140
218
  // OBSERVE IN DETAIL (SR5 p.165): the Perception Test below is the
141
219
  // Simple Action the book names, inside a Combat Turn.
142
220
  const bill = billAction(this.scene, this.actor, 'simple', 'Observe in Detail');
@@ -148,17 +226,14 @@ export class SearchCommand extends Command {
148
226
  // room stays uncased ("take all" stays gated). Barriers (puzzles) are
149
227
  // in plain sight and show regardless -- you can't fail to notice the
150
228
  // maglock you're staring at.
151
- // On the GRID a search is Matrix Search (canon: Computer, Logic) --
152
- // best-of with perception so pre-computer deckers lose nothing.
153
- // A loaded Browse program is purpose-built for Matrix Search: +2.
154
- const browse = this.actor.plane === 'matrix' && this.actor.activeDeck?.hasProgram('browse') ? 2 : 0;
155
229
  // ACTIVE: this is the deliberate hunt, so it carries p.135-136's +3
156
230
  // for "perceiver is specifically looking for it" -- the modifier
157
231
  // that makes `search` mechanically different from `look` rather
158
232
  // than only differently named (FNgptvRhbA5yMunbB). The pool and the
159
233
  // bracket both live in utilities/perception.ts; look.ts rolls the
160
- // same test without the +3.
161
- const { pool, limit } = perceptionTest(this.actor, { active: true, browse });
234
+ // same test without the +3. The grid never comes through here: a
235
+ // persona's search is matrixSearch() above.
236
+ const { pool, limit } = perceptionTest(this.actor, { active: true });
162
237
  const roll = rollPool(pool, limit);
163
238
  // Roll anatomy to the Mechanics ticker for the player; an NPC's
164
239
  // return string is its AI history, so IT keeps the roll inline
@@ -227,28 +302,7 @@ export class SearchCommand extends Command {
227
302
  if (spotted.length > 0) {
228
303
  description += `\n{yellow-fg}You pick ${spotted.map(a => a.name).join(', ')} out of the shadows -- ${spotted.length === 1 ? 'they were' : 'they were'} there the whole time.{/yellow-fg}`;
229
304
  }
230
- // Data is LOOT -- say so while the persona is standing on it, with the
231
- // vault state attached: sealed files need the host cracked first. A
232
- // real session's decker found the paydata jacked-in, didn't realize it
233
- // was takeable from the grid, and went hunting for it in the meat.
234
- if (this.actor.plane === 'matrix' && found.some(i => i.plane === 'matrix')) {
235
- description += room.hasNode && !room.offlineServer && !room.hostCracked
236
- ? `\n{cyan-fg}(the files sit sealed in the host's data vault -- "hack" the node to unseal them){/cyan-fg}`
237
- : hint(`\n{cyan-fg}(data files are loot -- "take" them right here on the grid; they ride out on your deck){/cyan-fg}`);
238
- }
239
- // A persona sweeping the grid OUTSIDE a host: the files exist and
240
- // this sweep cannot reach them. Say why, rather than reading empty
241
- // -- a silent nothing is what made the old air-gap case feel broken.
242
- if (this.actor.plane === 'matrix' && !filesInReach
243
- && room.inventory.getAllItems().some(i => i.plane === 'matrix')) {
244
- if (room.offlineServer) {
245
- description += `\n{cyan-fg}There is a ${room.offlineServer} in this room and it is NOT on the Matrix -- nothing you can do from out here touches it.${hint(` Walk in and cable into it in the flesh.`)}{/cyan-fg}`;
246
- }
247
- else {
248
- const marked = (room.hostMarksBy.get(this.actor.name) ?? 0) > 0 || room.hostCracked;
249
- description += `\n{cyan-fg}This sweep only covers the open grid. Whatever the host holds is INSIDE it, behind its wall (p.246).${hint(marked ? ` "enter" and search again.` : ` A mark is the door -- "mark" the host, then "enter".`)}{/cyan-fg}`;
250
- }
251
- }
305
+ // The grid never reaches here: a persona's search is matrixSearch().
252
306
  // The mirror hint: a meat-side search can't see matrix files, but it
253
307
  // shouldn't pretend the room is empty when the good stuff is one
254
308
  // "jack in" away.
@@ -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
- export const ENGINE_VERSION = '1.52.0';
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 { runHostTurn } from './utilities/ic.js';
45
- import { leaveMatrix, endPersona } from './utilities/planes.js';
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';
@@ -108,6 +109,7 @@ import { EndCallCommand } from './commands/end-call.js';
108
109
  import { fuzzyPickName, guessVerb } from './utilities/fuzzy-match.js';
109
110
  import { PickCommand } from './commands/pick.js';
110
111
  import { HackCommand } from './commands/hack.js';
112
+ import { DisableCommand, BrickCommand } from './commands/disable.js';
111
113
  import { CastCommand } from './commands/cast.js';
112
114
  import { StanceCommand } from './commands/stance.js';
113
115
  import { FollowCommand, UnfollowCommand } from './commands/follow.js';
@@ -2482,7 +2484,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2482
2484
  isParty: a => a !== player && this.scene.isHumanControlled(a),
2483
2485
  // Whose Action Phase is live in the viewer's room
2484
2486
  // (PYK6zCKhQcjRFwLSQ); undefined between phases and out of a fight.
2485
- phaseActor: () => this.scene.encounterIn(player.currentLocation)?.phaseActor?.name,
2487
+ // The viewer's own fight first -- a persona's is inside its host,
2488
+ // not in the room its body sits in.
2489
+ phaseActor: () => (this.scene.encounterFor(player) ?? this.scene.encounterIn(player.currentLocation))?.phaseActor?.name,
2486
2490
  }))
2487
2491
  .catch(() => undefined);
2488
2492
  };
@@ -2593,6 +2597,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2593
2597
  CommandFactory.registerCommand('end-call', EndCallCommand);
2594
2598
  CommandFactory.registerCommand('pick', PickCommand);
2595
2599
  CommandFactory.registerCommand('hack', HackCommand);
2600
+ CommandFactory.registerCommand('disable', DisableCommand);
2601
+ CommandFactory.registerCommand('brick', BrickCommand);
2596
2602
  CommandFactory.registerCommand('cast', CastCommand);
2597
2603
  CommandFactory.registerCommand('stance', StanceCommand);
2598
2604
  CommandFactory.registerCommand('surrender', SurrenderCommand);
@@ -6044,7 +6050,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6044
6050
  if (this.scene.isHumanControlled(actor))
6045
6051
  this.scene.beat += 1;
6046
6052
  const result = await command.execute(args);
6047
- const wrapped = await this.withHostileContact(actor, this.withEphemeralTurn(actor, this.withAlarmTurn(actor, this.withICTurn(actor, result))));
6053
+ const hosted = await this.withHostTurn(actor, result);
6054
+ const wrapped = await this.withHostileContact(actor, this.withEphemeralTurn(actor, this.withAlarmTurn(actor, hosted)));
6048
6055
  // The IC turn above may have filled a track -- settle it now, not
6049
6056
  // on the next keystroke.
6050
6057
  const settled = await settleUnresolvedHarm(this.scene, actor);
@@ -6178,19 +6185,43 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6178
6185
  const lines = await hostileContactBeat(this.scene, actor, this.scene.beat);
6179
6186
  return lines.length === 0 ? result : `${result}\n\n${lines.join('\n')}`;
6180
6187
  }
6181
- withICTurn(actor, result) {
6188
+ /**
6189
+ * THE HOST'S BEAT after every command a persona types inside one
6190
+ * (p.246-247: the ice acts on what is in the host).
6191
+ *
6192
+ * Before the host has made you, this is ambient Patrol on its clock
6193
+ * (utilities/ic.ts ambientPatrol) -- the per-command tick of that
6194
+ * clock is an engine mapping and its own comment says so. Once the
6195
+ * host is hunting, the fight is a Combat Turn in the host's arena
6196
+ * (utilities/host-combat.ts): the first beat opens it, and after
6197
+ * that the programs act in their OWN Action Phases, not on every
6198
+ * keystroke. The old shape -- every running program taking a swing
6199
+ * per player command, with its dice and lines appended to whatever
6200
+ * the command printed -- is gone; Vex's log had four programs and
6201
+ * twelve lines answering "look".
6202
+ */
6203
+ async withHostTurn(actor, result) {
6182
6204
  if (result === null)
6183
6205
  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
6206
  const host = actor.hostInside;
6187
6207
  if (!host || actor.plane !== 'matrix')
6188
6208
  return result;
6189
- const turn = runHostTurn(host.over, actor, this.scene);
6209
+ if (hostIsHunting(host.over, actor)) {
6210
+ if (this.scene.encounterInHost(host)?.has(actor))
6211
+ return result;
6212
+ // THE COMMAND'S OWN REPLY PRINTS FIRST. The encounter announces
6213
+ // itself while it opens (the aggressor, Surprise, the order), so
6214
+ // a result returned afterwards would land under all of that,
6215
+ // out of order -- the same trick hostile-contact.ts uses.
6216
+ if (result.length > 0 && this.scene.isHumanControlled(actor)) {
6217
+ Logger.getInstance().log(result, { actor: actor.name });
6218
+ }
6219
+ await hostContactBeat(this.scene, actor);
6220
+ return '';
6221
+ }
6222
+ const turn = ambientPatrol(host.over, actor);
6190
6223
  if (turn.lines.length === 0 && turn.meta.length === 0)
6191
6224
  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
6225
  if (this.scene.isHumanControlled(actor)) {
6195
6226
  const logger = Logger.getInstance();
6196
6227
  for (const m of turn.meta)
@@ -6198,36 +6229,16 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6198
6229
  }
6199
6230
  for (const w of turn.world)
6200
6231
  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
6232
  const body = turn.lines.join('\n');
6233
+ // Made on this very sweep: the fight opens on the same beat, after
6234
+ // the sighting has been read.
6235
+ if (hostIsHunting(host.over, actor)) {
6236
+ if (this.scene.isHumanControlled(actor)) {
6237
+ Logger.getInstance().log(result.length > 0 ? `${result}\n${body}` : body, { actor: actor.name });
6238
+ }
6239
+ await hostContactBeat(this.scene, actor);
6240
+ return '';
6241
+ }
6231
6242
  return result.length > 0 ? `${result}\n${body}` : body;
6232
6243
  }
6233
6244
  /**
@@ -6267,7 +6278,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6267
6278
  // players" / "crew street"); the bare verb still answers quietly.
6268
6279
  { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
6269
6280
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
6270
- { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6281
+ { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6271
6282
  // The Emerged get their own shelf (player request: "there MUST be
6272
6283
  // resonance" -- matrix is the place, Resonance is the talent).
6273
6284
  // `sustain` shelves here and not under magic: the command is
@@ -126,6 +126,16 @@ export class Device {
126
126
  structure;
127
127
  armor;
128
128
  _barrierDamage = 0;
129
+ /**
130
+ * THE MATRIX CONDITION MONITOR (p.228): 8 + (Device Rating / 2)
131
+ * boxes, resisted with Device Rating + Firewall, filled by a Data
132
+ * Spike (commands/disable.ts, "brick"). Full = BRICKED: "the device
133
+ * ceases functioning" -- a camera records nothing, a smartgun sparks
134
+ * and dies -- but "a lock stays locked" (p.228): bricking is never a
135
+ * way through a maglock. Repaired on the bench (rest.ts), never by a
136
+ * reboot. A ward is not electronics and has no monitor at all.
137
+ */
138
+ _matrixDamage = 0;
129
139
  /**
130
140
  * THE LOCKSMITH EXTENDED TEST IN PROGRESS (p.48, p.359-360): picking a
131
141
  * maglock by hand accumulates hits toward Rating x 2 across attempts,
@@ -207,6 +217,33 @@ export class Device {
207
217
  * exact fault this codebase keeps having to close (see the gun in
208
218
  * aroTags, AKfemBJJWJP99nx8K).
209
219
  */
220
+ // ---- The Matrix condition monitor (commands/disable.ts) ----
221
+ /** p.228: 8 + (Device Rating / 2), rounded up as the deck's is. 0 for
222
+ * a thing with no rating (a ward): nothing there to brick. */
223
+ get matrixMonitor() {
224
+ return this.rating > 0 ? 8 + Math.ceil(this.rating / 2) : 0;
225
+ }
226
+ get matrixDamage() {
227
+ return this._matrixDamage;
228
+ }
229
+ /** Returns boxes actually taken. */
230
+ takeMatrixDamage(boxes) {
231
+ if (this.matrixMonitor <= 0)
232
+ return 0;
233
+ const taken = Math.max(0, Math.floor(boxes));
234
+ this._matrixDamage = Math.min(this.matrixMonitor, this._matrixDamage + taken);
235
+ return taken;
236
+ }
237
+ get isBricked() {
238
+ return this.matrixMonitor > 0 && this._matrixDamage >= this.matrixMonitor;
239
+ }
240
+ /** Bench work (rest.ts): the whole monitor, at once. */
241
+ repairMatrix() {
242
+ this._matrixDamage = 0;
243
+ }
244
+ matrixSummary() {
245
+ return this.matrixMonitor > 0 ? `${this._matrixDamage}/${this.matrixMonitor} boxes` : '';
246
+ }
210
247
  broadcastsAro() {
211
248
  return this.wireless && this.rating > 0;
212
249
  }
@@ -328,11 +365,13 @@ export class Device {
328
365
  const text = hint(AFFORDANCE_FLAVOR[this.kind].hint);
329
366
  return text.length > 0 ? text : undefined;
330
367
  }
331
- /** Persistence-only: one flag in, nothing out. */
368
+ /** Persistence-only: flags in, nothing out. */
332
369
  restoreState(state) {
333
370
  this.open = state.open;
334
371
  if (state.damage !== undefined)
335
372
  this._barrierDamage = Math.max(0, Math.min(this.structure, Math.floor(state.damage)));
373
+ if (state.bricked !== undefined)
374
+ this._matrixDamage = state.bricked ? this.matrixMonitor : 0;
336
375
  }
337
376
  }
338
377
  //# sourceMappingURL=device.js.map
@@ -41,6 +41,10 @@ export class Host {
41
41
  marksBy = new Map();
42
42
  /** player name -> marks the host holds on that player. */
43
43
  marksOn = new Map();
44
+ /** Personas whose Matrix Search (p.241, commands/search.ts) turned up
45
+ * this host's files: the archive is listed for them from then on. A
46
+ * cracked host lists it for everyone. Session-only. */
47
+ searchedBy = new Set();
44
48
  /** Walls up: the host holds a mark on an intruder and its ice is hunting. */
45
49
  alert = false;
46
50
  /** IC running now, by kind name. Names, not actors -- until increment 5. */
@@ -43,6 +43,12 @@ export class Item extends AbstractItem {
43
43
  // A jammed firearm can't fire (fists until cleared); "reboot" clears
44
44
  // every jammed device you carry, in the lull.
45
45
  jammed = false;
46
+ // BRICKED (p.228, commands/disable.ts "brick"): a Data Spike filled the
47
+ // device's Matrix condition monitor. Dead electronics -- a smartgun
48
+ // "sparks, crackles, and smokes" and will not fire -- until bench
49
+ // work (rest.ts). A reboot does NOT clear it; that is the whole
50
+ // difference from `jammed`.
51
+ bricked = false;
46
52
  _transferable;
47
53
  // New currency-related fields
48
54
  _currencyAmount = 0;
@@ -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
@@ -469,6 +469,12 @@ export class Room extends AbstractRoom {
469
469
  warded = false;
470
470
  // See IRoomConstructorConfig.watched (utilities/surveillance.ts).
471
471
  watched = false;
472
+ /** The cluster is LOOPED by Control Device (commands/disable.ts): the
473
+ * house sees empty hallways until the run ends. Session-only. */
474
+ camerasLooped = false;
475
+ /** The cluster is BRICKED by a Data Spike (p.228): dead lenses, until
476
+ * somebody's bench work nobody in this run is doing. Session-only. */
477
+ camerasBricked = false;
472
478
  /**
473
479
  * THE HOST STANDING OVER THIS ROOM, as an object (models/host.ts) --
474
480
  * increment 1 of the Matrix position model. Every host field this class
@@ -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
- /** The live fight in a room, if any. */
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
- const enc = this.encounters.get(room);
145
- return enc && !enc.ended ? enc : undefined;
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.encounterIn(actor.currentLocation);
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(room, aggressor, target) {
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
- for (const a of room.getActors()) {
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(room, opts) {
194
- const existing = this.encounterIn(room);
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
- const enc = new CombatEncounter(this, this.logger, room);
203
- this.encounters.set(room, enc);
204
- await enc.start(this.encounterMembers(room, opts.aggressor, opts.target), opts);
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
- if (this.encounters.get(enc.room) === enc)
210
- this.encounters.delete(enc.room);
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.encounterIn(actor.currentLocation);
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
- const enc = room ? this.encounterIn(room) : undefined;
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);