@maka/maka-cli 5.383.0 → 5.385.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 +89 -3
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +276 -43
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +15 -7
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +24 -9
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +10 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +8 -3
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +7 -6
  9. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +31 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/game.js +7 -3
  11. package/bundle/typescript/src/commands/game/sideQuest/headless.js +3 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +8 -6
  13. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +4 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +87 -18
  15. package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +4 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +102 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-geometry.js +277 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +32 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +25 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-glyphs.js +15 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-rulers.js +58 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +30 -4
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +22 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +51 -11
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +24 -2
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/ways-out.js +34 -2
  28. package/package.json +1 -1
@@ -12,7 +12,7 @@ import { getPref, setPref } from '../utilities/prefs.js';
12
12
  import { roomMetresPerDot } from '../utilities/room-view.js';
13
13
  import { roomRasterRows } from '../utilities/room-grid.js';
14
14
  import { PLANE_METRES_PER_DOT, fogEye, fogName, fogOf } from '../utilities/map-view.js';
15
- import { PLAYER_GLYPH, OTHER_PLAYER_GLYPH, CREW_GLYPH, NEUTRAL_GLYPH, HOSTILE_GLYPH, DOWNED_GLYPH, DEAD_GLYPH, RIGGED_GLYPH, SPOT_GLYPH, CAMERA_GLYPH, CAMERA_COLOR, CAMERA_DEAD_COLOR, WALL_GLYPH, DOOR_GLYPH, FLOOR_GLYPH, FRONTIER_GLYPH, BLOCKED_GLYPH, UP_GLYPH, DOWN_GLYPH, } from '../utilities/map-glyphs.js';
15
+ import { PLAYER_GLYPH, OTHER_PLAYER_GLYPH, CREW_GLYPH, NEUTRAL_GLYPH, HOSTILE_GLYPH, DOWNED_GLYPH, DEAD_GLYPH, RIGGED_GLYPH, SPOT_GLYPH, CAMERA_GLYPH, CAMERA_COLOR, CAMERA_DEAD_COLOR, BARRIER_GLYPH, BARRIER_COLOR, MANA_BARRIER_COLOR, WALL_GLYPH, DOOR_GLYPH, FLOOR_GLYPH, FRONTIER_GLYPH, BLOCKED_GLYPH, UP_GLYPH, DOWN_GLYPH, } from '../utilities/map-glyphs.js';
16
16
  /**
17
17
  * The on-demand WAYS-ON list. Registered as both "map" and "exits";
18
18
  * since 2026-08-25 the drawn maps are permanently docked beside the log
@@ -41,7 +41,7 @@ export class MapCommand extends Command {
41
41
  if (sub === 'legend' || sub === 'key') {
42
42
  // Asked of the panel rather than guessed -- see Game.roomGridWidth.
43
43
  const availW = this.game?.roomGridWidth() ?? 19;
44
- const metres = roomMetresPerDot(room, availW, roomRasterRows());
44
+ const metres = roomMetresPerDot(room, availW, roomRasterRows(), room.planeFrame?.()?.origin);
45
45
  const out = [];
46
46
  out.push(`{bold}THE MAP KEY{/bold}`);
47
47
  out.push('');
@@ -81,6 +81,14 @@ export class MapCommand extends Command {
81
81
  out.push(` {${CAMERA_DEAD_COLOR}-fg}${CAMERA_GLYPH}{/${CAMERA_DEAD_COLOR}-fg} a smashed one`);
82
82
  out.push(` On the elevation bar, ${CAMERA_GLYPH} marks the height a camera hangs at.`);
83
83
  out.push('');
84
+ out.push(`{bold}Barriers{/bold}`);
85
+ out.push(` {${BARRIER_COLOR}-fg}${BARRIER_GLYPH}{/${BARRIER_COLOR}-fg} a force barrier -- nothing physical passes; "attack barrier" breaks a face`);
86
+ out.push(` {${MANA_BARRIER_COLOR}-fg}${BARRIER_GLYPH}{/${MANA_BARRIER_COLOR}-fg} a mana barrier, if you can see the astral -- it stops spirits and astral forms`);
87
+ out.push('');
88
+ out.push(`{bold}Coordinates{/bold}`);
89
+ out.push(` The numbers along the top and left edges are district x and y -- 2 m a cell, north is up.`);
90
+ out.push(` Your own x,y,z is under the Floor Plan. "cast barrier <x,y> <x,y>" raises a wall between two of them.`);
91
+ out.push('');
84
92
  out.push(`{bold}Scale{/bold}`);
85
93
  out.push(` Floor Plan: one dot is {white-fg}${metres} m{/white-fg} here.`);
86
94
  out.push(` Outside, the map runs at {white-fg}${PLANE_METRES_PER_DOT} m{/white-fg} a dot on the ground and scrolls with you;`);
@@ -1201,7 +1201,10 @@ export class MoveCommand extends Command {
1201
1201
  // world simply stops. Naming it a wall would send the
1202
1202
  // player looking for a door.
1203
1203
  : blockedBy === 'edge' ? `the ground ends there`
1204
- : `there's a wall`;
1204
+ // A barrier spell, said as one: it is drawn, and it is
1205
+ // somebody's working (barrier-geometry.ts).
1206
+ : blockedBy === 'barrier' ? `a shimmer-wall of hardened air stands there${blocker ? ` -- ${blocker}'s barrier` : ''}`
1207
+ : `there's a wall`;
1205
1208
  const refusal = `You can't step ${direction} -- ${why}.${this.doorHint(room, cell, blockedBy)}`;
1206
1209
  // SAY IT ONCE (player screenshot 2026-09-21: twelve identical
1207
1210
  // "You can't step south -- there's a wall." filling the log).
@@ -1280,7 +1283,8 @@ export class MoveCommand extends Command {
1280
1283
  ? `Pulled up short after ${meters} m -- ${blockedBy === 'someone' ? 'someone is standing there'
1281
1284
  : blockedBy === 'furniture' ? 'furniture blocks the way'
1282
1285
  : blockedBy === 'terrain' ? 'the way is blocked'
1283
- : blockedBy === 'edge' ? 'the ground ends there' : 'the wall'}.`
1286
+ : blockedBy === 'edge' ? 'the ground ends there'
1287
+ : blockedBy === 'barrier' ? 'a force barrier stands there' : 'the wall'}.`
1284
1288
  : '';
1285
1289
  const arrived = actor.atSpot !== wasAt && actor.atSpot !== OPEN_FLOOR
1286
1290
  ? `You're at the ${actor.atSpot} now.`
@@ -1725,7 +1729,8 @@ export class MoveCommand extends Command {
1725
1729
  return driftRefusal(drift, direction);
1726
1730
  const stopped = drift.blockedBy === undefined ? ''
1727
1731
  : drift.blockedBy === 'ward' ? ` A ward stops you${drift.ward ? ` at ${drift.ward}` : ''}.`
1728
- : ' Then solid earth.';
1732
+ : drift.blockedBy === 'barrier' ? ` A mana barrier stops you${drift.ward ? ` -- ${drift.ward}'s working` : ''}.`
1733
+ : ' Then solid earth.';
1729
1734
  // Silent on a plain drift, like a meat step: the map moved, which
1730
1735
  // is the answer. Speak when you arrived over somewhere named, or
1731
1736
  // when something out here stopped you.
@@ -4,6 +4,7 @@ import { Category } from '../types/shared/item-enum.js';
4
4
  import { hint } from '../utilities/hints.js';
5
5
  import { getAspect } from '../aspects.js';
6
6
  import { spellCreationCap } from '../spells.js';
7
+ import { describeBarrier } from '../utilities/barrier-geometry.js';
7
8
  /**
8
9
  * The spellbook: how a player actually discovers what they can cast and
9
10
  * why. Especially load-bearing for INNATE casting -- an Awakened character
@@ -118,10 +119,10 @@ export class SpellsCommand extends Command {
118
119
  lines.push(`Sustaining on ${w.bearer}: ${w.key === 'reflexes' ? 'increase reflexes' : w.key} (${sustainBurden(w, ' to everything else')}).${hint(` Cast ${w.key} on ${w.bearer} again to release.`)}`);
119
120
  }
120
121
  if (actor.sustainedBarrier) {
121
- lines.push(`Sustained: force barrier sealing ${actor.sustainedBarrier.direction} in ${actor.sustainedBarrier.roomName}, Force ${actor.sustainedBarrier.force} (${burden('barrier')}).${hint(' Cast barrier again to release.')}`);
122
+ lines.push(`Sustained: force barrier -- ${describeBarrier(actor.sustainedBarrier)} (${burden('barrier')}).${hint(' "cast barrier off" releases it.')}`);
122
123
  }
123
124
  if (actor.sustainedManaBarrier) {
124
- lines.push(`Sustained: mana barrier sealing ${actor.sustainedManaBarrier.direction} in ${actor.sustainedManaBarrier.roomName}, Force ${actor.sustainedManaBarrier.force} (${burden('manabarrier')}).${hint(' Cast manabarrier again to release.')}`);
125
+ lines.push(`Sustained: mana barrier -- ${describeBarrier(actor.sustainedManaBarrier)} (${burden('manabarrier')}).${hint(' "cast manabarrier off" releases it.')}`);
125
126
  }
126
127
  if (actor.sustainedReflexes) {
127
128
  lines.push(`Sustained: increase reflexes, +2 initiative (${burden('reflexes')}).${hint(' Cast it again to release.')}`);
@@ -176,10 +177,10 @@ export class SpellsCommand extends Command {
176
177
  line: 'sustained: light slides around the bearer, +3 on sneak contests at -2 dice. Drain F-4.' },
177
178
  { key: 'reflexes', cast: 'increase reflexes', how: 'cast increase reflexes [on <ally>]',
178
179
  line: 'sustained: +2 initiative for the bearer at -2 dice. Drain F-3.' },
179
- { key: 'barrier', cast: 'barrier', how: 'cast barrier <direction>; cast barrier again to release',
180
- line: 'sustained: hardened air seals ONE exit of your room; nobody meat-side passes (you included) at -2 dice to you. Drain F-3.' },
181
- { key: 'manabarrier', cast: 'manabarrier', how: 'cast manabarrier <direction>',
182
- line: 'sustained: woven mana seals ONE exit against SPIRITS and astral forms; the living pass freely. Drain F-2.' },
180
+ { key: 'barrier', cast: 'barrier', how: 'cast barrier [F] <x,y> <x,y> (wall) | <x,y> (dome) | <direction> [cells] | at <someone|me> [radius] | (bare: a dome round you); Force optional -- sized to fit; cast barrier off',
181
+ line: 'sustained: hardened air -- a wall Force x 2 m long and Force m high, or a Force-metre dome. Armor/Structure = hits, a track per face; nobody meat-side passes (you included) at -2 dice to you. Drain F-1.' },
182
+ { key: 'manabarrier', cast: 'manabarrier', how: 'cast manabarrier [F] <x,y> <x,y> | <x,y> | <direction> [cells] | at <someone|me> [radius]; cast manabarrier off',
183
+ line: 'sustained: woven mana, same shapes -- stops SPIRITS and astral forms; the living pass freely, and so do you. Armor/Structure = Force. Drain F-2.' },
183
184
  ];
184
185
  for (const entry of book) {
185
186
  if (!actor.knowsSpell(entry.key))
@@ -3334,5 +3334,35 @@
3334
3334
  // (nfrBW27eRRxstJ8uC).
3335
3335
  // - The HUD's holstered line drops its `-- "brandish"` tip
3336
3336
  // (MkxamPxmsSyonR3wC).
3337
- export const ENGINE_VERSION = '1.195.0';
3337
+ // 1.196.0 (2026-10-05): BARRIER SPELLS ON THE DISTRICT PLANE (SR5 p.294,
3338
+ // p.197-198, p.315-316; utilities/barrier-geometry.ts).
3339
+ // - A barrier is cells, not a tag on an exit: `cast barrier [F] <x,y[,z]>
3340
+ // [<x,y[,z]>]` (a wall between two points, or a Force-metre dome round
3341
+ // one), `cast barrier [F] <dir> [cells]` (1x3 across the way), `cast
3342
+ // barrier [F] at <someone>`, bare `cast barrier` (a dome round you),
3343
+ // `cast barrier off`. Plane coordinates; LOS-gated; clipped by matter
3344
+ // (physical) or by earth/wards/other weaves (mana).
3345
+ // - Physical Barrier rolls for real: Armor and Structure = hits [Force].
3346
+ // Mana Barrier: Force. One damage track per FACE (a wall has one; a
3347
+ // dome N/E/S/W, plus a crown past one level). `attack barrier [x,y]`
3348
+ // breaks a face (p.197 text: DV must EXCEED Armor; guns do 1 box);
3349
+ // a gun fired through one loses a box to it. Both regenerate at the
3350
+ // Combat Turn boundary (mana at the end, physical at the start).
3351
+ // - Movement: findPath/stepFrom route round a physical barrier; astral
3352
+ // `move` now stops at a mana barrier (it drifted through); astral `go`
3353
+ // presses through on Magic + Charisma [Astral] v. Force x 2.
3354
+ // - Bare `cast <direction>` no longer unweaves a ward -- `dispel <dir>`.
3355
+ // - Wire: the room grid carries `barriers` (room-local cells; a mana
3356
+ // barrier only for eyes that can see it). Docks draw x/y rulers in
3357
+ // plane coordinates and the caption gives your x,y,z.
3358
+ // 1.197.0 (2026-10-05): BARRIERS SIZE THEMSELVES. With no Force named, a
3359
+ // wall is cast at the least Force that raises it (a cell of length per
3360
+ // Force, Force 3 a level), the directional wall at Force 3, and a dome
3361
+ // at Force 3 (one storey) -- never past the caster's talent; an
3362
+ // overcast must be named. `cast barrier [F] at <someone> [size]` takes
3363
+ // a radius in cells (size x 2 = Force; a Force and size that disagree
3364
+ // are refused), and "me"/"self" name the caster.
3365
+ // The HUD's sustained line and `spells` show the barrier's Armor,
3366
+ // Structure and every face's damage track.
3367
+ export const ENGINE_VERSION = '1.197.0';
3338
3368
  //# sourceMappingURL=engine-version.js.map
@@ -149,6 +149,7 @@ import { ForceCommand } from './commands/force.js';
149
149
  import { CompleteCommand } from './commands/complete.js';
150
150
  import { DisableCommand, BrickCommand } from './commands/disable.js';
151
151
  import { CastCommand } from './commands/cast.js';
152
+ import { barrierSpan, barrierStats } from './utilities/barrier-geometry.js';
152
153
  import { FollowCommand, UnfollowCommand } from './commands/follow.js';
153
154
  import { BringCommand } from './commands/bring.js';
154
155
  import { SurrenderCommand } from './commands/surrender.js';
@@ -9477,10 +9478,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
9477
9478
  // the one that strands the team -- and both of them seal an exit
9478
9479
  // that the caster cannot pass either.
9479
9480
  if (player.sustainedBarrier) {
9480
- heldSpells.push(`physical barrier (${player.sustainedBarrier.roomName} ${player.sustainedBarrier.direction})`);
9481
+ heldSpells.push(`physical barrier (${player.sustainedBarrier.shape} ${barrierSpan(player.sustainedBarrier)}, ${player.sustainedBarrier.roomName}; ${barrierStats(player.sustainedBarrier)})`);
9481
9482
  }
9482
9483
  if (player.sustainedManaBarrier) {
9483
- heldSpells.push(`mana barrier (${player.sustainedManaBarrier.roomName} ${player.sustainedManaBarrier.direction})`);
9484
+ heldSpells.push(`mana barrier (${player.sustainedManaBarrier.shape} ${barrierSpan(player.sustainedManaBarrier)}, ${player.sustainedManaBarrier.roomName}; ${barrierStats(player.sustainedManaBarrier)})`);
9484
9485
  }
9485
9486
  // What this character holds on OTHERS -- the caster's burden, named
9486
9487
  // by bearer so the mage knows whose armor is costing them two dice.
@@ -9721,6 +9722,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
9721
9722
  // Whose eyes, and what counts as hostile -- so the district draws
9722
9723
  // the same people, with the same marks, the room panel does.
9723
9724
  viewer: this.player,
9725
+ rulers: true,
9724
9726
  isOtherPlayer: a => this.scene.isHumanControlled(a),
9725
9727
  isTableAlly: crewOfTable(this.scene.getPlayers()),
9726
9728
  isHostile: a => hostileByStance(a) || enemyInFight(this.scene, this.player, a),
@@ -9753,6 +9755,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
9753
9755
  const roomLines = renderRoomLayoutCompact(here, this.player, {
9754
9756
  isTableAlly: crewOfTable(this.scene.getPlayers()),
9755
9757
  boxWidth,
9758
+ // Rulers in PLANE coordinates -- the numbers "cast barrier x,y" takes.
9759
+ origin: here.planeFrame?.()?.origin,
9756
9760
  isOtherPlayer: a => this.scene.isHumanControlled(a),
9757
9761
  // Red is the stance flag OR whoever the Combat Turn says is
9758
9762
  // fighting you (room-view.ts hostileByStance).
@@ -9767,7 +9771,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
9767
9771
  // of its own. The GLYPHS moved to `map legend`, where there is
9768
9772
  // room to say what they mean instead of abbreviating them into
9769
9773
  // a strip nobody could read.
9770
- roomLines.push(rightAlign(dockScale(roomMetresPerDot(here, this.roomGridWidth() ?? Math.max(6, (boxWidth ?? 27) - 8), roomRasterRows())), boxWidth));
9774
+ roomLines.push(rightAlign(dockScale(roomMetresPerDot(here, this.roomGridWidth() ?? Math.max(6, (boxWidth ?? 27) - 8), roomRasterRows(), here.planeFrame?.()?.origin)), boxWidth));
9771
9775
  roomLines.push(caption(roomCaption(here, this.player), boxWidth));
9772
9776
  }
9773
9777
  this.setRoomOverlay(roomLines);
@@ -564,6 +564,7 @@ function wireHeadless(game, ctx, logger) {
564
564
  arEyes: p.hasMatrixEyes(),
565
565
  at: game.scene.worldCellOf(p),
566
566
  viewer: p,
567
+ rulers: true,
567
568
  isOtherPlayer: (a) => game.scene.isHumanControlled(a),
568
569
  isTableAlly: crewOfTable(game.scene.getPlayers()),
569
570
  isHostile: (a) => hostileByStance(a) || enemyInFight(game.scene, p, a),
@@ -651,6 +652,7 @@ function wireHeadless(game, ctx, logger) {
651
652
  try {
652
653
  const roomLines = renderRoomLayoutCompact(here, p, {
653
654
  isOtherPlayer: a => game.scene.isHumanControlled(a),
655
+ origin: here.planeFrame?.()?.origin,
654
656
  isHostile: a => hostileByStance(a) || enemyInFight(game.scene, p, a),
655
657
  // The leader's crew are the TABLE's crew -- without this
656
658
  // every non-leader saw them as neutral bystanders.
@@ -671,7 +673,7 @@ function wireHeadless(game, ctx, logger) {
671
673
  // here anyway rather than asserted, since a decoration must
672
674
  // never take down the room push (same reasoning as the
673
675
  // try/catch this sits inside).
674
- const grid = roomGridWire(here);
676
+ const grid = roomGridWire(here, p);
675
677
  const data = grid ? { grid } : undefined;
676
678
  logger.system(roomLines.join('\n'), { actor: p.name }, 'room', data);
677
679
  }
@@ -5244,10 +5244,11 @@ export class Player extends AbstractPlayer {
5244
5244
  }
5245
5245
  }
5246
5246
  }
5247
- /** The sustained PHYSICAL BARRIER spell (ruling 2026-08-24): seals
5248
- * one exit of one room while held -- go.ts and move refuse passage
5249
- * for everyone (the caster included; walls don't play favorites).
5250
- * Session-only, like every sustain. */
5247
+ /** The sustained PHYSICAL BARRIER spell: cells on the district plane
5248
+ * (utilities/barrier-geometry.ts, 2026-10-05 -- it used to seal one
5249
+ * exit). Nothing physical crosses a standing face, the caster
5250
+ * included; walls don't play favorites. Session-only, like every
5251
+ * sustain. */
5251
5252
  _sustainedBarrier;
5252
5253
  get sustainedBarrier() { return this._sustainedBarrier; }
5253
5254
  set sustainedBarrier(v) {
@@ -5259,8 +5260,9 @@ export class Player extends AbstractPlayer {
5259
5260
  this.noteConditionChanged();
5260
5261
  }
5261
5262
  /** The MANA BARRIER twin (canon p.294, RAG-checked): blocks SPIRITS
5262
- * and astral forms at the sealed exit; living mundanes and physical
5263
- * objects pass freely. Same lifecycle as sustainedBarrier. */
5263
+ * and astral forms at its cells; living mundanes and physical
5264
+ * objects pass freely, and so does its creator (p.315). Same
5265
+ * lifecycle as sustainedBarrier. */
5264
5266
  _sustainedManaBarrier;
5265
5267
  get sustainedManaBarrier() { return this._sustainedManaBarrier; }
5266
5268
  set sustainedManaBarrier(v) {
@@ -16,7 +16,7 @@ import { dwellingDims } from '../utilities/dwellings.js';
16
16
  import { spotsActive, isPerchSpot, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
17
17
  import { canHearSpeech, canHearShout } from '../utilities/earshot.js';
18
18
  import { freshCrowd, clampHeadcount, deriveOccupancy } from '../utilities/crowd.js';
19
- import { synthesizeGrid, attachExitCell, detachExitCell, hashFnv1a, mulberry32, } from '../utilities/room-grid.js';
19
+ import { synthesizeGrid, attachExitCell, detachExitCell, hashFnv1a, mulberry32, setCellBlocker, } from '../utilities/room-grid.js';
20
20
  /**
21
21
  * A CAMERA'S DEVICE RATING when the scene does not say (p.356, which
22
22
  * lists "Camera" at 1 -- the same table that gives the door lock the 2
@@ -560,6 +560,9 @@ export class Room extends AbstractRoom {
560
560
  levels: this.levels,
561
561
  terrainHints: this.terrainHints,
562
562
  });
563
+ // A BARRIER SPELL IS ASKED, NOT STORED (room-grid.ts CELL_BLOCKERS):
564
+ // its cells come and go while the grid lives, and the scene owns them.
565
+ setCellBlocker(this._grid, c => this.scene?.physicalBarrierAtLocal?.(this, c) ?? false);
563
566
  return this._grid;
564
567
  }
565
568
  /**
@@ -8,6 +8,12 @@ import { Direction } from '../types/shared/direction-enum.js';
8
8
  import { Door } from './door.js';
9
9
  import { dwellingDims } from '../utilities/dwellings.js';
10
10
  import { actorCell } from '../utilities/spots.js';
11
+ import { line3D } from '../utilities/room-grid.js';
12
+ import { faceAt } from '../utilities/barrier-geometry.js';
13
+ /** One step out through a doorway, per exit direction (north is -y). */
14
+ const BARRIER_STEP = {
15
+ north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0],
16
+ };
11
17
  import { packDistrict, worldCell, buildAuthoredPlane, partyLayout, planeFaults } from '../utilities/district-plane.js';
12
18
  import { Category, Size, Rating } from '../types/shared/item-enum.js';
13
19
  import { AbstractScene } from '../types/shared/abstracts.js';
@@ -1222,30 +1228,93 @@ export class Scene extends AbstractScene {
1222
1228
  return this.characterLocations.get(character.name);
1223
1229
  }
1224
1230
  /**
1225
- * A sustained PHYSICAL BARRIER spell (Player.sustainedBarrier)
1226
- * sealing this room's exit in `direction` -- checked from BOTH sides
1227
- * of the doorway (the wall is in the frame, not painted on one face).
1228
- * go.ts refuses traversal for every meat-plane actor while it holds.
1231
+ * EVERY BARRIER SPELL STANDING IN THIS SCENE (barrier-geometry.ts).
1232
+ * They live on the casters -- one of each kind apiece -- so this is a
1233
+ * walk of the players, the same place the old exit seal read them.
1229
1234
  */
1230
- barrierSealing(room, direction, kind = 'physical') {
1235
+ spellBarriers(kind) {
1236
+ const out = [];
1237
+ for (const p of this.getPlayers()) {
1238
+ if (p.sustainedBarrier && (!kind || kind === 'physical'))
1239
+ out.push(p.sustainedBarrier);
1240
+ if (p.sustainedManaBarrier && (!kind || kind === 'mana'))
1241
+ out.push(p.sustainedManaBarrier);
1242
+ }
1243
+ return out;
1244
+ }
1245
+ /**
1246
+ * The standing barrier face on this PLANE cell, if any. `passer` is
1247
+ * who is asking to cross: a mana barrier "does not affect its
1248
+ * creator, who can pass through at will" (p.315), so its own weaver
1249
+ * gets undefined. A physical wall plays no favourites.
1250
+ */
1251
+ barrierAt(c, kind, passer) {
1252
+ for (const barrier of this.spellBarriers(kind)) {
1253
+ if (kind === 'mana' && passer !== undefined && barrier.caster === passer)
1254
+ continue;
1255
+ const face = faceAt(barrier, c);
1256
+ if (face)
1257
+ return { barrier, face };
1258
+ }
1259
+ return undefined;
1260
+ }
1261
+ /**
1262
+ * The first standing barrier face a straight line crosses between two
1263
+ * PLANE cells, endpoints excluded -- what a shot at someone behind a
1264
+ * wall has to go through first (p.197-198).
1265
+ */
1266
+ barrierOnLine(a, b, kind) {
1267
+ if (this.spellBarriers(kind).length === 0)
1268
+ return undefined;
1269
+ for (const c of line3D(a, b).slice(1, -1)) {
1270
+ const hit = this.barrierAt(c, kind);
1271
+ if (hit)
1272
+ return hit;
1273
+ }
1274
+ return undefined;
1275
+ }
1276
+ /**
1277
+ * A ROOM-LOCAL cell under a physical barrier -- what Room.ensureGrid
1278
+ * registers with the grid, so findPath, stepFrom and seating all
1279
+ * route round the wall without knowing spells exist.
1280
+ */
1281
+ physicalBarrierAtLocal(room, local) {
1282
+ if (this.spellBarriers('physical').length === 0)
1283
+ return false;
1284
+ const c = worldCell(this.ensurePlane(), room.name, local);
1285
+ return !!c && !!this.barrierAt(c, 'physical');
1286
+ }
1287
+ /**
1288
+ * IS THIS EXIT WALLED OFF BY A BARRIER SPELL. Since 2026-10-05 a
1289
+ * barrier is cells, not a tag on an exit, so the question is whether a
1290
+ * standing face covers the doorway: the exit's own cell or the gap in
1291
+ * the wall beyond it -- from either side, because the far room's
1292
+ * doorway is the same hole.
1293
+ */
1294
+ barrierSealing(room, direction, kind = 'physical', passer) {
1295
+ if (this.spellBarriers(kind).length === 0)
1296
+ return undefined;
1297
+ const plane = this.ensurePlane();
1298
+ const cellsOf = (r, dir) => {
1299
+ const local = r.ensureGrid()?.exitCells.get(dir.toLowerCase());
1300
+ const at = local && worldCell(plane, r.name, local);
1301
+ if (!at)
1302
+ return [];
1303
+ const step = BARRIER_STEP[dir.toLowerCase()];
1304
+ return step ? [at, { x: at.x + step[0], y: at.y + step[1], z: at.z }] : [at];
1305
+ };
1231
1306
  const exit = room.exits.get(direction);
1232
1307
  // getOtherSide (the Room), not getOtherSideToName (a "To X" display
1233
1308
  // string -- a probe caught the far-side check silently never
1234
1309
  // matching on it).
1235
- const destName = !exit ? undefined
1236
- : typeof exit === 'string' ? exit
1237
- : exit.getOtherSide?.(room)?.name;
1310
+ const far = !exit || typeof exit === 'string' ? undefined
1311
+ : exit.getOtherSide?.(room);
1238
1312
  const opposite = room.getOppositeDirection(direction);
1239
- for (const p of this.getPlayers()) {
1240
- const b = kind === 'physical' ? p.sustainedBarrier : p.sustainedManaBarrier;
1241
- if (!b)
1242
- continue;
1243
- if (b.roomName === room.name && b.direction.toLowerCase() === direction.toLowerCase()) {
1244
- return { caster: p.name, force: b.force };
1245
- }
1246
- if (destName && b.roomName === destName && opposite && b.direction.toLowerCase() === String(opposite).toLowerCase()) {
1247
- return { caster: p.name, force: b.force };
1248
- }
1313
+ const cells = [...cellsOf(room, direction), ...(far && opposite ? cellsOf(far, String(opposite)) : [])];
1314
+ for (const c of cells) {
1315
+ const hit = this.barrierAt(c, kind, passer);
1316
+ if (hit)
1317
+ return { caster: hit.barrier.caster, force: hit.barrier.force };
1249
1318
  }
1250
1319
  return undefined;
1251
1320
  }
@@ -75,7 +75,7 @@ export function driftAstral(scene, actor, rooms, direction, count, untilOut = fa
75
75
  // the room it is trying to leave is large.
76
76
  const limit = untilOut ? 50 : count;
77
77
  while (taken < limit) {
78
- const step = astralStepFrom(plane, cell, direction, 1, byKey);
78
+ const step = astralStepFrom(plane, cell, direction, 1, byKey, c => scene.barrierAt?.(c, 'mana', actor.name)?.barrier.caster);
79
79
  if (step.taken === 0) {
80
80
  blockedBy = step.blockedBy;
81
81
  ward = step.ward;
@@ -193,6 +193,9 @@ export function driftRefusal(d, direction) {
193
193
  * nearest outdoor room and simply goes. The only things that stop a
194
194
  * form are the two canon gives: solid earth and a ward.
195
195
  */
196
+ if (d.blockedBy === 'barrier') {
197
+ return `A mana barrier hangs across the way ${direction}${d.ward ? ` -- ${d.ward}'s working` : ''}: a hazy, solid sheet of woven mana. The astral does not pass (p.315-316).`;
198
+ }
196
199
  if (d.blockedBy === 'ward') {
197
200
  return `A lattice of woven mana stands across the way ${direction}${d.ward ? ` -- ${d.ward}'s` : ''}. The astral does not pass.`;
198
201
  }
@@ -1,6 +1,7 @@
1
1
  import { stillWatching } from './surveillance.js';
2
2
  import { rollPool, formatRoll } from './dice.js';
3
3
  import { applyDeviceOpened } from './devices.js';
4
+ import { FACE_LABEL, allBreached, damageFace, penetrationBoxes, pierces } from './barrier-geometry.js';
4
5
  export function resolveBarrierStrike(scene, logger, attacker, device, room, opts = {}) {
5
6
  const world = [];
6
7
  const meta = [];
@@ -148,4 +149,105 @@ export function resolveCameraStrike(scene, logger, attacker, camera, room, opts
148
149
  return result;
149
150
  }
150
151
  }
152
+ /**
153
+ * HITTING A BARRIER SPELL (SR5 p.197-198 with p.294 / p.316; user
154
+ * rulings 2026-10-05). Same unopposed shape as a door, against ONE FACE:
155
+ *
156
+ * - The attack test is unopposed; its hits add to the DV.
157
+ * - p.197's text, not p.198's example: a DV that does not EXCEED the
158
+ * AP-modified Armor does nothing at all.
159
+ * - A gun is a PENETRATION weapon (p.198): the barrier takes 1 box,
160
+ * unresisted, and the rest of the shot is spent on the far side.
161
+ * It takes a lot of bullets to bring down a wall, which is the point.
162
+ * - Anything else resists with Structure + Armor; what gets through
163
+ * fills the face's track. Barriers ignore Stun.
164
+ * - A face whose track reaches Structure is breached: it stops
165
+ * blocking. Every face breached and the spell is over (p.294).
166
+ * - A mana barrier's caster knows at once it is under attack (p.316).
167
+ *
168
+ * The caller has already chosen the cell (and so the face) and checked
169
+ * the plane: a Physical Barrier is hit from the meat, a Mana Barrier
170
+ * only from the astral -- bullets go through a weave as if it were not
171
+ * there, because to them it is not.
172
+ */
173
+ export function resolveSpellBarrierStrike(scene, logger, attacker, barrier, face, room, opts = {}) {
174
+ const world = [];
175
+ const meta = [];
176
+ const done = (opened = false, collapsed = false) => ({ world, meta, opened, collapsed });
177
+ const what = `${barrier.caster}'s ${barrier.kind === 'mana' ? 'mana barrier' : 'barrier'} (${FACE_LABEL[face.id]})`;
178
+ const weaponItem = attacker.weaponDrawn ? attacker.getCarriedWeapon() : null;
179
+ const profile = attacker.getWeaponProfile();
180
+ if (weaponItem?.isFirearm() && (weaponItem.ammo <= 0 || weaponItem.jammed)) {
181
+ world.push(`${weaponItem.name} is ${weaponItem.jammed ? 'locked up' : 'dry'} -- nothing to put into ${what}.`);
182
+ return done();
183
+ }
184
+ const stun = weaponItem ? weaponItem.isStunWeapon() : !(attacker.hasAug('bone-lacing') || attacker.adept || attacker.hasKillingHands());
185
+ if (stun && attacker.plane === 'meat') {
186
+ world.push(`${weaponItem ? weaponItem.name : 'Your fists'} deal${weaponItem ? 's' : ''} Stun, and ${what} has nothing to stun -- barriers ignore it (p.198).`);
187
+ return done();
188
+ }
189
+ const firearm = attacker.plane === 'meat' && !!weaponItem?.isFirearm();
190
+ if (firearm)
191
+ weaponItem.fireRound();
192
+ attacker.sneaking = false;
193
+ const roll = rollPool(attacker.getAttackPool(), attacker.getAttackLimit(weaponItem));
194
+ meta.push(`Attack on ${what} (unopposed, p.197): ${formatRoll(roll)}`);
195
+ if (roll.criticalGlitch) {
196
+ world.push(`${attacker.name} goes for ${what} -- and it goes catastrophically wrong.`);
197
+ return noise(done());
198
+ }
199
+ const dv = profile.damage + roll.hits;
200
+ const ap = profile.armorPen ?? 0;
201
+ const modifiedArmor = Math.max(0, barrier.armor + ap);
202
+ const how = profile.name === 'bare fists' ? 'with bare fists' : `with ${profile.name}`;
203
+ meta.push(` DV ${profile.damage} + ${roll.hits} hit${roll.hits === 1 ? '' : 's'} = ${dv} v. Armor ${barrier.armor}${ap !== 0 ? ` (AP ${ap} -> ${modifiedArmor})` : ''}`);
204
+ if (!pierces(dv, barrier.armor, ap)) {
205
+ world.push(`${attacker.name} hits ${what} ${how} -- the shimmer swallows it. Too weak to break through (DV ${dv} against Armor ${modifiedArmor}, p.197).`);
206
+ return noise(done());
207
+ }
208
+ let dealt;
209
+ if (firearm) {
210
+ dealt = penetrationBoxes(1);
211
+ meta.push(` Penetration weapon (p.198): ${dealt} box to the barrier, unresisted -- the rest of the round is spent beyond it`);
212
+ }
213
+ else {
214
+ const soak = rollPool(barrier.structure + modifiedArmor);
215
+ dealt = Math.max(0, dv - soak.hits);
216
+ meta.push(` Resist: Structure ${barrier.structure} + Armor ${modifiedArmor}: ${formatRoll(soak)} -- ${dealt} box${dealt === 1 ? '' : 'es'} through`);
217
+ }
218
+ const breached = damageFace(barrier, face, dealt);
219
+ logger.write(`${attacker.name} dealt ${dealt} boxes to ${what} (${face.damage}/${barrier.structure}${face.breached ? ', breached' : ''}).`);
220
+ if (barrier.kind === 'mana') {
221
+ const caster = scene.getPlayers().find(p => p.name === barrier.caster);
222
+ if (caster && caster !== attacker)
223
+ logger.log(`Something is hammering your mana barrier in ${barrier.roomName} -- ${FACE_LABEL[face.id]}, ${face.breached ? 'breached' : `${face.damage}/${barrier.structure}`} (p.316).`, { actor: caster.name });
224
+ }
225
+ if (!breached) {
226
+ world.push(`${attacker.name} hits ${what} ${how} -- ${dealt > 0 ? `${dealt} box${dealt === 1 ? '' : 'es'}` : 'it holds, unmarked'}. (${face.damage}/${barrier.structure})`);
227
+ return noise(done());
228
+ }
229
+ world.push(`${attacker.name} breaks ${what} ${how} -- that face ${barrier.kind === 'mana' ? 'frays to nothing' : 'shatters like heat-haze'}, and the way through it is open.`);
230
+ if (allBreached(barrier)) {
231
+ const caster = scene.getPlayers().find(p => p.name === barrier.caster);
232
+ if (caster) {
233
+ if (barrier.kind === 'mana')
234
+ caster.sustainedManaBarrier = undefined;
235
+ else
236
+ caster.sustainedBarrier = undefined;
237
+ }
238
+ world.push(` The whole ${barrier.kind === 'mana' ? 'weave' : 'barrier'} collapses -- ${barrier.caster}'s spell is over (p.294).`);
239
+ scene.addWorldEvent(`${attacker.name} brought down ${barrier.caster}'s ${barrier.kind === 'mana' ? 'mana barrier' : 'force barrier'} in ${room.name}.`);
240
+ scene.updateStatus();
241
+ return noise(done(true, true));
242
+ }
243
+ scene.updateStatus();
244
+ return noise(done(true));
245
+ function noise(result) {
246
+ if (firearm) {
247
+ scene.addWorldEvent(`${attacker.name} opened fire on a force barrier in ${room.name} -- the shots carried.`);
248
+ opts.heat?.(1, 'shot at a force barrier');
249
+ }
250
+ return result;
251
+ }
252
+ }
151
253
  //# sourceMappingURL=barrier-combat.js.map