@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.
Files changed (22) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +17 -11
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +14 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +7 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +6 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +6 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +8 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +10 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +69 -23
  10. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +6 -4
  11. package/bundle/typescript/src/commands/game/sideQuest/game.js +47 -39
  12. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +39 -4
  13. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +6 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +44 -17
  15. package/bundle/typescript/src/commands/game/sideQuest/utilities/alarmed-staff.js +3 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +91 -23
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +107 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic-brain.js +84 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/ic.js +84 -51
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +5 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +5 -0
  22. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.180.0",
3
+ "version": "5.182.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -10,6 +10,7 @@ import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_
10
10
  import { distanceMeters } from '../utilities/room-grid.js';
11
11
  import { resolveBarrierStrike } from '../utilities/barrier-combat.js';
12
12
  import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
13
+ import { arenaOf, inArena } from '../utilities/combat-turn.js';
13
14
  import { spendMovementMeters } from '../utilities/movement-cost.js';
14
15
  /**
15
16
  * Shadowrun-style dice-pool combat, one full exchange per command:
@@ -224,11 +225,12 @@ export class AttackCommand extends Command {
224
225
  return `Click -- the ${weaponItem.name} is dry. "reload" (needs ammunition), or go in swinging with something else.`;
225
226
  }
226
227
  // THE COMBAT TURN (utilities/combat-turn.ts): on the physical planes
227
- // a fight is a turn/pass loop and this attack is ONE action in the
228
- // actor's own Action Phase. The Matrix and the astral keep the older
229
- // exchange below -- the Matrix has its own initiative structure
230
- // (utilities/ic.ts) and astral combat has not been swept yet.
231
- if (this.actor.plane === 'meat' || this.actor.plane === 'drone') {
228
+ // and the Matrix a fight is a turn/pass loop and this attack is ONE
229
+ // action in the actor's own Action Phase. The Matrix joined it on
230
+ // 2026-09-13 (utilities/host-combat.ts): a Data Spike used to roll a
231
+ // fresh two-actor initiative per command and print "FREEZES" every
232
+ // time. Only the astral still rides the older exchange below.
233
+ if (this.actor.plane === 'meat' || this.actor.plane === 'drone' || this.actor.plane === 'matrix') {
232
234
  return await this.executeInEncounter(target, room, weaponItem ?? undefined, ambush);
233
235
  }
234
236
  const report = [];
@@ -340,8 +342,9 @@ export class AttackCommand extends Command {
340
342
  const actor = this.actor;
341
343
  const human = this.scene.isHumanControlled(actor);
342
344
  let enc = this.scene.encounterFor?.(actor);
345
+ const arena = arenaOf(actor);
343
346
  if (!enc) {
344
- enc = await this.scene.startEncounter(room, { aggressor: actor, target, ambush });
347
+ enc = await this.scene.startEncounter(arena, { aggressor: actor, target, ambush });
345
348
  if (enc.ended) {
346
349
  return human ? `` : `The fight was over before ${actor.name} got to act.`;
347
350
  }
@@ -356,7 +359,7 @@ export class AttackCommand extends Command {
356
359
  ? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${this.actionVerb() === 'attacks' ? 'attack' : 'move'} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
357
360
  : `${actor.name} squares up -- initiative rolled; ${who} first.`;
358
361
  }
359
- if (target.isIncapacitated() || target.currentLocation !== room) {
362
+ if (target.isIncapacitated() || !inArena(arena, target)) {
360
363
  return human ? `${target.name} is no longer standing in front of you.` : `${target.name} is no longer a target.`;
361
364
  }
362
365
  }
@@ -372,7 +375,10 @@ export class AttackCommand extends Command {
372
375
  // shoot or swing the blade instead? READY WEAPON (p.165) is its own
373
376
  // Simple Action -- "draw" first (2jbprbSYqF7EPXrkR).
374
377
  const activeWeapon = this.usesEquippedWeapon() && actor.weaponDrawn ? weaponItem : undefined;
375
- const isMelee = !activeWeapon || !activeWeapon.isFirearm();
378
+ // A DATA SPIKE (p.239) is neither a swing nor a shot: no ground to
379
+ // close, no recoil, a Complex Action. The grid sees no distance.
380
+ const onGrid = actor.plane === 'matrix';
381
+ const isMelee = !onGrid && (!activeWeapon || !activeWeapon.isFirearm());
376
382
  const report = [];
377
383
  // MELEE REACH: ground costs (p.161-162).
378
384
  if (isMelee) {
@@ -428,8 +434,8 @@ export class AttackCommand extends Command {
428
434
  }
429
435
  }
430
436
  // THE ACTION.
431
- const label = isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
432
- const bill = billAction(this.scene, actor, isMelee ? 'complex' : 'simple', label, { attack: true });
437
+ const label = onGrid ? 'Data Spike' : isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
438
+ const bill = billAction(this.scene, actor, onGrid || isMelee ? 'complex' : 'simple', label, { attack: true });
433
439
  if (bill)
434
440
  return bill;
435
441
  const exchange = new CombatExchange(this.scene, this.logger, actor);
@@ -442,7 +448,7 @@ export class AttackCommand extends Command {
442
448
  target.combatOpponent = target.combatOpponent ?? actor;
443
449
  target.lastExchangeAt = now;
444
450
  actor.surrendered = false;
445
- if (!isMelee) {
451
+ if (!isMelee && !onGrid) {
446
452
  actor.firedThisPhase = true;
447
453
  actor.recoilRoundsFired += 1;
448
454
  }
@@ -3,6 +3,7 @@ import { movePersonaWith } from '../utilities/matrix-roster.js';
3
3
  import { resolveHostInReach, hostsInReach, gridVicinity, hostOver } from '../utilities/grid-reach.js';
4
4
  import { hint } from '../utilities/hints.js';
5
5
  import { hostLabel } from '../utilities/grid-names.js';
6
+ import { billAction } from '../utilities/action-cost.js';
6
7
  /**
7
8
  * ENTER/EXIT HOST (SR5 p.239, Complex Action).
8
9
  *
@@ -67,6 +68,12 @@ export class EnterHostCommand extends Command {
67
68
  hint(`A host opens to anyone holding a MARK on it (p.239) -- "mark ${target.name}" softens it, or "hack" takes one by force.`),
68
69
  ].join('\n');
69
70
  }
71
+ // A Complex Action (p.239) -- billed only inside a Combat Turn.
72
+ const bill = billAction(this.scene, actor, 'complex', 'Enter Host');
73
+ if (bill)
74
+ return bill;
75
+ // Crossing the wall leaves whatever fight was on this side of it.
76
+ this.scene.encounterFor?.(actor)?.leave(actor);
70
77
  // THE PERSONA CROSSES, THE BODY STAYS (increment 2): position becomes
71
78
  // the host, remembering the grid it came from (p.239: exit returns
72
79
  // you there). currentLocation is never written -- it is the body's.
@@ -130,6 +137,13 @@ export class ExitHostCommand extends Command {
130
137
  const by = [...actor.linkLockedBy].join(', ');
131
138
  return `{red-fg}You reach for the door and it isn't there. LINK-LOCKED by ${by}: the connection is held open and Enter/Exit Host is closed to you (p.229). "jack out" still works -- it just costs you dumpshock.{/red-fg}`;
132
139
  }
140
+ // A Complex Action (p.239), and the way out of the host's fight
141
+ // (utilities/host-combat.ts): a persona back on the grid is beyond
142
+ // the ice, which "does not operate out on the grid".
143
+ const bill = billAction(this.scene, actor, 'complex', 'Exit Host');
144
+ if (bill)
145
+ return bill;
146
+ this.scene.encounterFor?.(actor)?.leave(actor);
133
147
  const host = actor.insideHost;
134
148
  const from = actor.matrixPosition?.kind === 'host' ? actor.matrixPosition.from : undefined;
135
149
  actor.matrixPosition = { kind: 'grid', grid: from };
@@ -12,6 +12,7 @@ import { matchesCameraName, hostLabel } from '../utilities/grid-names.js';
12
12
  import { TapCommand } from './tap.js';
13
13
  import { sameHostSide } from '../models/player.js';
14
14
  import { MAX_MARKS } from '../utilities/marks.js';
15
+ import { billAction } from '../utilities/action-cost.js';
15
16
  import { declarationPenalty, parseMarkDeclaration, hostDefensePool, wanDefensePool, freeMatrixPerceptionHits, bruteForceMatrixDv, overwatchFromDefense, GO_BIG_QUALITY, deviceDefensePool } from '../utilities/matrix-intrusion.js';
16
17
  /**
17
18
  * MARKS A PAN INTRUSION MUST ALREADY HOLD before it can command the
@@ -74,6 +75,12 @@ export class HackCommand extends BypassCommand {
74
75
  // here so a declaration never lands in a target name, the same way
75
76
  // the mode words are.
76
77
  const { marks: declaredMarks, rest } = parseMarkDeclaration(afterMode);
78
+ // BRUTE FORCE and HACK ON THE FLY are Complex Actions (p.238, p.240)
79
+ // -- billed only inside a Combat Turn, where one intrusion is the
80
+ // whole Action Phase.
81
+ const bill = billAction(this.scene, this.actor, 'complex', mode === 'sleaze' ? 'Hack on the Fly' : 'Brute Force');
82
+ if (bill)
83
+ return bill;
77
84
  // PAN warfare: "hack <name>" cracks a meat actor's personal area
78
85
  // network -- from inside the Matrix, or right here in AR with a
79
86
  // working deck in hand (which is also how enemy deckers get YOU).
@@ -3,6 +3,7 @@ import { gridVicinity } from '../utilities/grid-reach.js';
3
3
  import { enterMatrix, leaveMatrix } from '../utilities/planes.js';
4
4
  import { hint } from '../utilities/hints.js';
5
5
  import { rollPool, formatRoll } from '../utilities/dice.js';
6
+ import { billAction } from '../utilities/action-cost.js';
6
7
  /**
7
8
  * The Matrix door, both directions: "jack in [hot|cold]" and "jack out".
8
9
  * Needs a working CYBERDECK carried (a commlink is calls and AR only) --
@@ -97,6 +98,11 @@ export class JackCommand extends Command {
97
98
  if (actor.inExchange) {
98
99
  return `The ice has your persona locked in the exchange -- survive it first, then jack out in the lull.`;
99
100
  }
101
+ // JACK OUT is a Simple Action (p.240) -- billed inside a Combat
102
+ // Turn; leaving the Matrix then leaves the fight (planes.ts).
103
+ const bill = billAction(this.scene, actor, 'simple', 'Jack Out');
104
+ if (bill)
105
+ return bill;
100
106
  // LINK-LOCKED: leaving stops being a decision and becomes a test
101
107
  // (p.229/p.240). Everything below is the Jack Out action.
102
108
  if (actor.linkLockedBy.size > 0) {
@@ -17,6 +17,7 @@ import { BULLET } from '../utilities/log-style.js';
17
17
  import { spotsActive, whoIsWhereLine, highlightSpots, highlightActors, canReach, sealedRouteTo } from '../utilities/spots.js';
18
18
  import { rollPool, formatRoll } from '../utilities/dice.js';
19
19
  import { findCatalogItem, describeEffect } from '../utilities/catalog.js';
20
+ import { billAction } from '../utilities/action-cost.js';
20
21
  export class LookCommand extends Command {
21
22
  static verb = 'look';
22
23
  static description = 'Examine your surroundings';
@@ -301,6 +302,11 @@ export class LookCommand extends Command {
301
302
  * match the grid map: ● host, ◆ persona, ▣ PAN, ◇ data.
302
303
  */
303
304
  gridLook(room) {
305
+ // MATRIX PERCEPTION is a Complex Action (p.241) -- billed only inside
306
+ // a Combat Turn, where a look at the room costs the phase.
307
+ const bill = billAction(this.scene, this.actor, 'complex', 'Matrix Perception');
308
+ if (bill)
309
+ return bill;
304
310
  // NO MEAT PROSE AT ALL (player ruling 2026-08-26: "looking in the
305
311
  // matrix shouldn't show the meatworld"). The room's own description
306
312
  // used to open this view "for orientation" -- but that description
@@ -5,7 +5,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
5
5
  import { perceptionTest } from '../utilities/perception.js';
6
6
  import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
7
7
  import { placeName } from '../utilities/log-style.js';
8
- import { billAction } from '../utilities/action-cost.js';
8
+ import { billAction, encounterOf } from '../utilities/action-cost.js';
9
9
  /**
10
10
  * Barrier rendering (2026-08-24 playtest: "I don't see THE THING I'm
11
11
  * supposed to act on -- just a description... and I still see the
@@ -137,6 +137,13 @@ export class SearchCommand extends Command {
137
137
  static description = 'Search for items in your area';
138
138
  async execute(_args) {
139
139
  const room = this.actor.currentLocation;
140
+ // A MATRIX SEARCH IS MEASURED IN MINUTES (p.241: base time one
141
+ // minute inside a host), not in Action Phases -- there is no cost to
142
+ // bill inside a Combat Turn, so it is refused there rather than
143
+ // priced by invention.
144
+ if (this.actor.plane === 'matrix' && encounterOf(this.scene, this.actor)) {
145
+ return `Not with the ice on you -- a Matrix Search takes minutes (p.241), and a Combat Turn is three seconds. Crash the ice or get out first.`;
146
+ }
140
147
  // OBSERVE IN DETAIL (SR5 p.165): the Perception Test below is the
141
148
  // Simple Action the book names, inside a Combat Turn.
142
149
  const bill = billAction(this.scene, this.actor, 'simple', 'Observe in Detail');
@@ -440,5 +440,14 @@
440
440
  // 1.52.0 (2026-09-12): THE EMBEDDED HUB SEED IS GONE FROM SAVES. The one
441
441
  // release of dual-writing is over: a save carries hubSeedRef only, and
442
442
  // the site (sheet fallback) reads hubSeed on older docs alone.
443
- 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
@@ -267,10 +267,13 @@ take ground. Build it accordingly:
267
267
  Matrix presence, whose files open only to a runner whose BODY is in that room). Pick one
268
268
  and commit to it -- the offline server is the harder, more interesting shape when the
269
269
  fiction wants a vault you must walk into.
270
- - THE OPPOSITION IS ICE, not muscle. Every NPC besides the client is "matrix"-plane and
271
- stationed in the host. No guards patrolling meat corridors, no gunfight in the lobby: a
272
- data run that ends in a firefight is a smash-and-grab wearing the wrong name. A site can
273
- still be WATCHED (cameras) -- surveillance is pressure without being a gun.
270
+ - THE OPPOSITION IS THE HOST'S OWN ICE, not muscle. Give the vault host a "hostRating" of
271
+ 5-8: the host launches its own intrusion countermeasures (Patrol, Probe, Killer...) at
272
+ that rating, so DO NOT write an NPC for the ice -- no "matrix"-plane npcs[] at all. The
273
+ NPCs are the client and the people who work the site. No guards patrolling meat
274
+ corridors, no gunfight in the lobby: a data run that ends in a firefight is a
275
+ smash-and-grab wearing the wrong name. A site can still be WATCHED (cameras) --
276
+ surveillance is pressure without being a gun.
274
277
  - Keep it SHORT and tight. One site, one host, one file.
275
278
  - It pays LESS than a full run of the same tier -- roughly half. The runner is trading pay
276
279
  for a job that never puts a gun in their face, and the fixer prices it that way. Say so
@@ -407,8 +410,9 @@ ${JSON_ONLY}
407
410
  // its description reads.
408
411
  "npcs": [{
409
412
  "name": string, "concept": string, "startLocation": string,
410
- "plane"?: "meat"|"matrix"|"astral", // "matrix" = ICE guarding a hasNode room;
411
- // "astral" = a spirit. Default meat.
413
+ "plane"?: "meat"|"astral", // "astral" = a spirit. Default meat. NEVER "matrix": a host's
414
+ // ice is not a person -- the host launches its own programs
415
+ // at its "hostRating", and an NPC written as ice is dropped.
412
416
  "hostile"?: boolean // OPPOSITION: this one KNOWS the runner does not belong and will
413
417
  // act on it -- a posted guard, a patrolling enforcer, a gang
414
418
  // holding the room. Default false, and LEAVE IT FALSE unless the
@@ -418,9 +422,6 @@ ${JSON_ONLY}
418
422
  // building, and marking them turns an ordinary scene into a
419
423
  // brawl. If in doubt, leave it out -- a guard who stays calm is
420
424
  // a missed beat, a hostile bartender is a broken scene.
421
- // ICE NEEDS NO FLAG: a "matrix" NPC is intrusion countermeasures
422
- // and is treated as opposition automatically. Write it only to
423
- // say FALSE, for the rare tame construct.
424
425
  }],
425
426
  "devices": [{
426
427
  "name": string, "concept": string, // what it physically IS (a maglock, a keypad, a
@@ -520,9 +521,10 @@ Hard structural rules (validated mechanically -- a violation is rejected):
520
521
  skill can still take the shortcut -- pick the lock, hack the panel -- but the found way
521
522
  must exist for a runner who has neither.
522
523
  - The winCondition item must not be heldBy winCondition.toNpc. If a matrix-plane item is the
523
- winCondition item, its room has hasNode true AND a "matrix"-plane NPC stationed there. If
524
- the runner can jack in, a Datachip/Document winCondition item MUST be that matrix-plane
525
- item -- data for a hacker lives on a host, never on a desk.
524
+ winCondition item, its room has hasNode true AND a "hostRating" of at least 4 -- the host
525
+ defends its own files with its own ice; never write an NPC for that. If the runner can
526
+ jack in, a Datachip/Document winCondition item MUST be that matrix-plane item -- data for
527
+ a hacker lives on a host, never on a desk.
526
528
  - ${isContinuation
527
529
  ? 'ZERO "starting"-role items -- the runner arrives with everything they own. 4-6'
528
530
  : '3-5 "starting"-role items (basics the runner doesn\'t already own -- see the dossier), 4-6'}
@@ -652,8 +654,8 @@ For EVERY npc in the skeleton, expand its "concept":
652
654
  "hacking"?, "cybercombat"?, "electronic-warfare"?, "hardware"?, "locksmith"?, "computer"?,
653
655
  "negotiation"?, "con"?, "intimidation"?, "etiquette"?, "leadership"?, "performance"?, "disguise"?,
654
656
  "medicine"?, "assensing"?, "counterspelling"?, "conjuring"?, "gunnery"? } }
655
- "magic" 3-6 ONLY for a genuinely Awakened concept; "plane":"matrix" ice fights with
656
- logic 4-7; give combat blocks to anyone the story expects in a firefight. "skills" is
657
+ "magic" 3-6 ONLY for a genuinely Awakened concept; give combat blocks to anyone the story
658
+ expects in a firefight. "skills" is
657
659
  optional flavor for defined concepts (a sharpshooter's "firearms": 6).
658
660
 
659
661
  ${JSON_ONLY}
@@ -886,6 +888,14 @@ const GENERIC_LOCK_WORDS = new Set([
886
888
  'lock', 'maglock', 'door', 'gate', 'grate', 'hatch', 'safe', 'vault', 'cage', 'shutter', 'padlock', 'cabinet', 'locker',
887
889
  'keycard', 'card', 'passkey', 'access', 'security', 'back', 'front', 'side', 'room', 'office', 'main', 'inner', 'outer',
888
890
  ]);
891
+ /**
892
+ * THE LEAST A VAULT HOST RATES. A host launches its own IC at its rating
893
+ * (p.247, one per Combat Turn up to the rating), so the rating is the
894
+ * whole defence of the paydata inside it; below this the run is a free
895
+ * walk. Four is the floor: a rating-3 default host fields three thin
896
+ * programs and no Killer.
897
+ */
898
+ export const VAULT_HOST_RATING = 4;
889
899
  export function validateSkeleton(skeleton, player, crew) {
890
900
  const fail = (msg) => { throw new Error(`Skeleton invalid: ${msg}`); };
891
901
  if (!Array.isArray(skeleton?.rooms) || skeleton.rooms.length === 0)
@@ -1107,11 +1117,18 @@ export function validateSkeleton(skeleton, player, crew) {
1107
1117
  // Worded to route to 'skeleton' (no "items[", no "Win condition item").
1108
1118
  const winItem = skeleton.items.find(i => i.name === skeleton.winCondition.item);
1109
1119
  if (winItem && isDataShaped(winItem.category) && winItem.plane !== 'matrix' && partyCanDeck(player)) {
1110
- fail(`the winCondition item "${winItem.name}" is a ${winItem.category} on the meat plane, and this runner can jack in -- for a decker or technomancer, data is PAYDATA: give it "plane": "matrix", place it in a hasNode room (not heldBy anyone), and station a "matrix"-plane NPC (ice) there. A chip lying on a desk is no job for a hacker.`);
1120
+ fail(`the winCondition item "${winItem.name}" is a ${winItem.category} on the meat plane, and this runner can jack in -- for a decker or technomancer, data is PAYDATA: give it "plane": "matrix", place it in a hasNode room (not heldBy anyone) with a hostRating of ${VAULT_HOST_RATING} or more. A chip lying on a desk is no job for a hacker.`);
1111
1121
  }
1112
1122
  // The other half of the same rule, for ANY matrix-plane objective: the
1113
- // schema says its room has hasNode and an ice NPC stationed there, and
1123
+ // schema says its room has hasNode and a rating that fields ice, and
1114
1124
  // until now nothing at the skeleton checked it.
1125
+ //
1126
+ // THE HOST IS ITS OWN GUARD (player ruling 2026-09-13, "mute all
1127
+ // ice"): a vault used to demand a "matrix"-plane NPC -- a person
1128
+ // wearing an ice costume, with dialogue lines and a model behind it,
1129
+ // which is how four programs ended up bargaining over a chip in Vex's
1130
+ // log. Canon has none of that: the host launches its own IC at its
1131
+ // rating (p.247), so what a vault needs is a rating worth the name.
1115
1132
  if (winItem?.plane === 'matrix') {
1116
1133
  if (!winItem.room) {
1117
1134
  fail(`the winCondition item "${winItem.name}" is matrix-plane paydata but has no "room" -- data lives on a host, never in a pocket. Place it in a hasNode room.`);
@@ -1120,9 +1137,17 @@ export function validateSkeleton(skeleton, player, crew) {
1120
1137
  if (!vault?.hasNode) {
1121
1138
  fail(`the winCondition item "${winItem.name}" is matrix-plane paydata in "${winItem.room}", which has no "hasNode": true -- a matrix file can only exist on a host. Mark that room hasNode.`);
1122
1139
  }
1123
- const ice = skeleton.npcs.some(n => n.plane === 'matrix' && n.startLocation === winItem.room);
1124
- if (!ice) {
1125
- fail(`the winCondition item "${winItem.name}" is matrix-plane paydata in hasNode room "${winItem.room}" with no ice guarding it -- station a "matrix"-plane NPC there. An undefended vault is a free win.`);
1140
+ else if ((vault.hostRating ?? 0) < VAULT_HOST_RATING) {
1141
+ fail(`the winCondition item "${winItem.name}" is matrix-plane paydata in hasNode room "${winItem.room}" whose hostRating is ${vault.hostRating ?? 'unset'} -- a vault defends itself with its own ice, so give that room a hostRating of ${VAULT_HOST_RATING} or more. An undefended vault is a free win.`);
1142
+ }
1143
+ }
1144
+ // NO NPC IS ICE. The same ruling from the other side: a model that
1145
+ // still writes a "matrix"-plane person is writing a voice for a
1146
+ // program. normalizeSkeleton drops them with a note; this holds the
1147
+ // line for anything that reaches the validator by another road.
1148
+ for (const npc of skeleton.npcs) {
1149
+ if (npc.plane === 'matrix') {
1150
+ fail(`npc "${npc.name}" is "matrix"-plane -- ice is not a person. Remove it; the host over its room fields its own countermeasures at its hostRating.`);
1126
1151
  }
1127
1152
  }
1128
1153
  // EVERY HOST HAS A PURPOSE AND HOLDS SOMETHING (N4erz3f63MiCZLkDE,
@@ -1153,10 +1178,9 @@ export function validateSkeleton(skeleton, player, crew) {
1153
1178
  if (!holds) {
1154
1179
  fail(`room "${room.name}" is a host (${room.hostPurpose}) with NOTHING inside it -- no "matrix"-plane item placed in that room. A host holds what its business protects: put at least one matrix-plane item there (the ledger, the client list, the camera archive), or drop hasNode.`);
1155
1180
  }
1156
- const guarded = skeleton.npcs.some(n => n.plane === 'matrix' && n.startLocation === room.name);
1157
- if (!guarded) {
1158
- fail(`room "${room.name}" is a host holding matrix-plane files with no ice on it -- station a "matrix"-plane NPC in that room. A host worth entering is a host that defends itself.`);
1159
- }
1181
+ // "Ice stationed on it" used to be the third requirement. It is
1182
+ // now the host's own rating (see the vault rule above): a host
1183
+ // defends itself, and a person written as its ice is refused.
1160
1184
  }
1161
1185
  // Lock-and-key reachability. CRITICAL: edges are BIDIRECTIONAL -- the
1162
1186
  // engine mirrors every declared exit into a shared two-way Door
@@ -1568,6 +1592,28 @@ export function normalizeSkeleton(skeleton) {
1568
1592
  delete room.nodeAccess;
1569
1593
  notes.push(`dropped the host over "${room.name}" -- it held no matrix-plane file, and a host with nothing in it should not be there.`);
1570
1594
  }
1595
+ // ICE IS NOT A PERSON (player ruling 2026-09-13, "mute all ice"). The
1596
+ // prompt no longer asks for a "matrix"-plane NPC and validateSkeleton
1597
+ // refuses one; a model that writes one anyway is writing a voice for
1598
+ // a program, and the cheap repair is to drop it and let the host's
1599
+ // own rating do the guarding. The vault host gets that rating here
1600
+ // when the model forgot it -- clamp, don't burn a regeneration.
1601
+ const constructs = (skeleton.npcs ?? []).filter(n => n.plane === 'matrix');
1602
+ if (constructs.length > 0) {
1603
+ skeleton.npcs = skeleton.npcs.filter(n => n.plane !== 'matrix');
1604
+ for (const n of constructs) {
1605
+ notes.push(`normalized: dropped npc "${n.name}" -- it was "matrix"-plane ice, and a host fields its own countermeasures at its hostRating rather than a person in a costume.`);
1606
+ }
1607
+ }
1608
+ const winItem = (skeleton.items ?? []).find(i => i.name === skeleton.winCondition?.item);
1609
+ if (winItem?.plane === 'matrix' && winItem.room) {
1610
+ const vault = (skeleton.rooms ?? []).find(r => r.name === winItem.room);
1611
+ if (vault?.hasNode && (vault.hostRating ?? 0) < VAULT_HOST_RATING) {
1612
+ const was = vault.hostRating === undefined ? 'no hostRating' : `hostRating ${vault.hostRating}`;
1613
+ vault.hostRating = VAULT_HOST_RATING;
1614
+ notes.push(`normalized: the vault host over "${vault.name}" had ${was}; raised it to ${VAULT_HOST_RATING} so it fields ice worth the paydata.`);
1615
+ }
1616
+ }
1571
1617
  // NOBODY MINDING THE STORE (playtest 2026-08-25: "the flooded
1572
1618
  // undermarket could use some details -- it's just blank"). It was the
1573
1619
  // district's only commerce room with neither a vendor nor stock: a
@@ -8,7 +8,7 @@ import { clampDeviceKind } from '../utilities/affordances.js';
8
8
  import { GenerationCapture } from '../utilities/generation-capture.js';
9
9
  import { fetchCanonContext } from '../utilities/canon-lore.js';
10
10
  import { SceneSynthesizer } from './scene-factory.js';
11
- import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildDevicesPrompt, buildItemsPrompt, pickRunType, parseJsonReply, validateSkeleton, normalizeSkeleton, assembleScene, classifyRepair, } from './scene-chunks.js';
11
+ import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildDevicesPrompt, buildItemsPrompt, pickRunType, parseJsonReply, validateSkeleton, normalizeSkeleton, assembleScene, classifyRepair, VAULT_HOST_RATING, } from './scene-chunks.js';
12
12
  // The fixer name convention and the player dossier live with the prompt
13
13
  // builders now (see scene-chunks.ts); re-exported so the existing
14
14
  // import sites (call.ts, game.ts) keep working unchanged.
@@ -751,9 +751,11 @@ export class SceneSeedGenerator {
751
751
  if (!room?.hasNode) {
752
752
  throw new Error(`Matrix-plane win condition item "${wc.item}" is in room "${item.room}", which has no "hasNode": true -- matrix files can only exist on a host. Mark that room hasNode.`);
753
753
  }
754
- const iceGuard = (candidate.npcs ?? []).some(n => n.plane === 'matrix' && n.player.startLocation === item.room);
755
- if (!iceGuard) {
756
- throw new Error(`Matrix-plane win condition item "${wc.item}" sits in hasNode room "${item.room}" with NO ice guarding it -- add an npc with "plane": "matrix", "startLocation": "${item.room}", and a combat block (logic 4-7). An undefended data vault makes the run a free win.`);
754
+ // THE HOST GUARDS ITSELF (2026-09-13): this used to demand an ice
755
+ // NPC; a host's countermeasures are its own programs at its rating
756
+ // (p.247), and normalizeSkeleton raises a vault to the floor.
757
+ if ((room.hostRating ?? 0) < VAULT_HOST_RATING) {
758
+ throw new Error(`Matrix-plane win condition item "${wc.item}" sits in hasNode room "${item.room}" whose hostRating is ${room.hostRating ?? 'unset'} -- a vault defends itself with its own ice; give that room a hostRating of ${VAULT_HOST_RATING} or more. An undefended data vault makes the run a free win.`);
757
759
  }
758
760
  }
759
761
  /**
@@ -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';
@@ -2482,7 +2483,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2482
2483
  isParty: a => a !== player && this.scene.isHumanControlled(a),
2483
2484
  // Whose Action Phase is live in the viewer's room
2484
2485
  // (PYK6zCKhQcjRFwLSQ); undefined between phases and out of a fight.
2485
- phaseActor: () => this.scene.encounterIn(player.currentLocation)?.phaseActor?.name,
2486
+ // The viewer's own fight first -- a persona's is inside its host,
2487
+ // not in the room its body sits in.
2488
+ phaseActor: () => (this.scene.encounterFor(player) ?? this.scene.encounterIn(player.currentLocation))?.phaseActor?.name,
2486
2489
  }))
2487
2490
  .catch(() => undefined);
2488
2491
  };
@@ -6044,7 +6047,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6044
6047
  if (this.scene.isHumanControlled(actor))
6045
6048
  this.scene.beat += 1;
6046
6049
  const result = await command.execute(args);
6047
- const wrapped = await this.withHostileContact(actor, this.withEphemeralTurn(actor, this.withAlarmTurn(actor, this.withICTurn(actor, result))));
6050
+ const hosted = await this.withHostTurn(actor, result);
6051
+ const wrapped = await this.withHostileContact(actor, this.withEphemeralTurn(actor, this.withAlarmTurn(actor, hosted)));
6048
6052
  // The IC turn above may have filled a track -- settle it now, not
6049
6053
  // on the next keystroke.
6050
6054
  const settled = await settleUnresolvedHarm(this.scene, actor);
@@ -6178,19 +6182,43 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6178
6182
  const lines = await hostileContactBeat(this.scene, actor, this.scene.beat);
6179
6183
  return lines.length === 0 ? result : `${result}\n\n${lines.join('\n')}`;
6180
6184
  }
6181
- withICTurn(actor, result) {
6185
+ /**
6186
+ * THE HOST'S BEAT after every command a persona types inside one
6187
+ * (p.246-247: the ice acts on what is in the host).
6188
+ *
6189
+ * Before the host has made you, this is ambient Patrol on its clock
6190
+ * (utilities/ic.ts ambientPatrol) -- the per-command tick of that
6191
+ * clock is an engine mapping and its own comment says so. Once the
6192
+ * host is hunting, the fight is a Combat Turn in the host's arena
6193
+ * (utilities/host-combat.ts): the first beat opens it, and after
6194
+ * that the programs act in their OWN Action Phases, not on every
6195
+ * keystroke. The old shape -- every running program taking a swing
6196
+ * per player command, with its dice and lines appended to whatever
6197
+ * the command printed -- is gone; Vex's log had four programs and
6198
+ * twelve lines answering "look".
6199
+ */
6200
+ async withHostTurn(actor, result) {
6182
6201
  if (result === null)
6183
6202
  return null;
6184
- // The host's turn happens INSIDE it (p.246-247): the ice acts on what
6185
- // is in the host, and the persona's position says whether it is.
6186
6203
  const host = actor.hostInside;
6187
6204
  if (!host || actor.plane !== 'matrix')
6188
6205
  return result;
6189
- const turn = runHostTurn(host.over, actor, this.scene);
6206
+ if (hostIsHunting(host.over, actor)) {
6207
+ if (this.scene.encounterInHost(host)?.has(actor))
6208
+ return result;
6209
+ // THE COMMAND'S OWN REPLY PRINTS FIRST. The encounter announces
6210
+ // itself while it opens (the aggressor, Surprise, the order), so
6211
+ // a result returned afterwards would land under all of that,
6212
+ // out of order -- the same trick hostile-contact.ts uses.
6213
+ if (result.length > 0 && this.scene.isHumanControlled(actor)) {
6214
+ Logger.getInstance().log(result, { actor: actor.name });
6215
+ }
6216
+ await hostContactBeat(this.scene, actor);
6217
+ return '';
6218
+ }
6219
+ const turn = ambientPatrol(host.over, actor);
6190
6220
  if (turn.lines.length === 0 && turn.meta.length === 0)
6191
6221
  return result;
6192
- // Dice to the Mechanics pane, and only for a human -- the same gate
6193
- // every other roll in this engine uses.
6194
6222
  if (this.scene.isHumanControlled(actor)) {
6195
6223
  const logger = Logger.getInstance();
6196
6224
  for (const m of turn.meta)
@@ -6198,36 +6226,16 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6198
6226
  }
6199
6227
  for (const w of turn.world)
6200
6228
  this.scene.addWorldEvent(w);
6201
- // A deck bricked by IC owes the same forced dump a convergence does.
6202
- // ic.ts reports it rather than performing it -- leaveMatrix needs the
6203
- // Scene and that module deliberately takes none, exactly as hack.ts
6204
- // splits the convergence hammer from the dump that follows it.
6205
- if (turn.bricked) {
6206
- turn.lines.push(...leaveMatrix(this.scene, actor, {
6207
- forced: true,
6208
- reason: `The host's ice burns your deck out from under you.`,
6209
- }));
6210
- }
6211
- else if (turn.scrambled) {
6212
- // SCRAMBLE IC (p.248): "you reboot immediately, taking dumpshock if
6213
- // you were in VR." A forced exit through the same door, which is
6214
- // what makes it a reboot rather than a graceful drop -- and the
6215
- // reboot is also what hands the reducers' damage back.
6216
- //
6217
- // `else if` on purpose: a deck already bricked this turn has been
6218
- // dumped, and dumping a persona twice would bill dumpshock twice.
6219
- turn.lines.push(...leaveMatrix(this.scene, actor, {
6220
- forced: true,
6221
- reason: `Scramble IC rips the connection out at the root --`,
6222
- }));
6223
- }
6224
- if (turn.traced) {
6225
- // TRACK IC (p.249): the location goes to the authorities. Routed
6226
- // into the heat the game already keeps for being made in the meat
6227
- // world, rather than a second parallel notion of "they know".
6228
- this.addHeat(4, `Track IC reported ${actor.name}'s location`);
6229
- }
6230
6229
  const body = turn.lines.join('\n');
6230
+ // Made on this very sweep: the fight opens on the same beat, after
6231
+ // the sighting has been read.
6232
+ if (hostIsHunting(host.over, actor)) {
6233
+ if (this.scene.isHumanControlled(actor)) {
6234
+ Logger.getInstance().log(result.length > 0 ? `${result}\n${body}` : body, { actor: actor.name });
6235
+ }
6236
+ await hostContactBeat(this.scene, actor);
6237
+ return '';
6238
+ }
6231
6239
  return result.length > 0 ? `${result}\n${body}` : body;
6232
6240
  }
6233
6241
  /**