@maka/maka-cli 5.383.0 → 5.384.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 +206 -41
  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 +22 -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 +253 -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.383.0",
3
+ "version": "5.384.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.",
@@ -6,9 +6,11 @@ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
6
6
  import { describeFledTarget } from '../utilities/pursuit.js';
7
7
  import { shellsInRoom, resolveBodyAttack, crossPlaneRefusal } from '../utilities/planes.js';
8
8
  import { heldBy, holding } from '../utilities/grapple.js';
9
- import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_FLOOR, inMeleeReach, actorCell, standingRoomNear, spotForCell } from '../utilities/spots.js';
9
+ import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_FLOOR, inMeleeReach, actorCell, standingRoomNear, spotForCell, ADJACENT_METERS } from '../utilities/spots.js';
10
10
  import { distanceMeters } from '../utilities/room-grid.js';
11
- import { resolveBarrierStrike, resolveCameraStrike } from '../utilities/barrier-combat.js';
11
+ import { wallsBetween } from '../utilities/district-plane.js';
12
+ import { formatCoord, parseCoord } from '../utilities/barrier-geometry.js';
13
+ import { resolveBarrierStrike, resolveCameraStrike, resolveSpellBarrierStrike } from '../utilities/barrier-combat.js';
12
14
  import { isWatched, cameraWithinReach, cameraOutOfReach } from '../utilities/surveillance.js';
13
15
  import { matchesCameraName } from '../utilities/grid-names.js';
14
16
  import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
@@ -48,7 +50,7 @@ export class AttackCommand extends Command {
48
50
  async readyForStrike() {
49
51
  return {};
50
52
  }
51
- static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197).';
53
+ static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197). "attack barrier [x,y]" breaks a face of a barrier spell.';
52
54
  async execute(args = []) {
53
55
  if (!args || args.length === 0) {
54
56
  return 'Who do you want to attack? Try: `attack <name>`';
@@ -159,6 +161,36 @@ export class AttackCommand extends Command {
159
161
  return this.scene.isHumanControlled(this.actor) ? '' : lines.join('\n');
160
162
  }
161
163
  }
164
+ // A BARRIER SPELL (2026-10-05; p.197-198, p.294, p.316): "attack
165
+ // barrier" hits the nearest standing face you can see, "attack
166
+ // barrier 3,5" the face holding that cell. A Physical Barrier is
167
+ // matter to the meat; a Mana Barrier is only there for the astral.
168
+ const spellWall = this.spellBarrierTarget(targetName);
169
+ if (spellWall) {
170
+ if ('refusal' in spellWall)
171
+ return spellWall.refusal;
172
+ const held = this.actor.getCarriedWeapon();
173
+ if (this.scene.encounterFor?.(this.actor)) {
174
+ if (held && !this.actor.weaponDrawn) {
175
+ return `The ${held.name} is holstered -- "draw" it first (Ready Weapon, a Simple Action, SR5 p.165).`;
176
+ }
177
+ const bill = billAction(this.scene, this.actor, held?.isFirearm() ? 'simple' : 'complex', 'Attack Barrier', { attack: true });
178
+ if (bill)
179
+ return bill;
180
+ }
181
+ const drawn = this.scene.encounterFor?.(this.actor) ? null : this.actor.drawWeapon();
182
+ const strike = resolveSpellBarrierStrike(this.scene, this.logger, this.actor, spellWall.barrier, spellWall.face, room, {
183
+ heat: (amount, why) => this.game?.addHeat(amount, why),
184
+ });
185
+ const lines = [...(drawn ? [`${this.actor.name} draws ${drawn.name}.`] : []), ...strike.world];
186
+ this.actor.performAction('attacks', `${spellWall.barrier.caster}'s barrier`);
187
+ this.scene.updateStatus();
188
+ if (strike.opened)
189
+ this.scene.updateExits?.(room);
190
+ const exchange = new CombatExchange(this.scene, this.logger, this.actor);
191
+ await exchange.announce(lines, strike.meta);
192
+ return this.scene.isHumanControlled(this.actor) ? '' : lines.join('\n');
193
+ }
162
194
  // A BARRIER (2026-09-03; SR5 p.197-198): "attack <door>" shoots it
163
195
  // apart -- unopposed, AP against its Armor, Structure as its track.
164
196
  // Named the way pick/breach name it: the device's name, or the exit
@@ -569,5 +601,59 @@ export class AttackCommand extends Command {
569
601
  }
570
602
  return null;
571
603
  }
604
+ /**
605
+ * "barrier", "the force wall", "mana barrier 3,5" -> the standing face
606
+ * this actor would hit, or a refusal that says why not, or undefined
607
+ * when the words are not about a barrier spell at all (the door route
608
+ * and the "who?" answer come next).
609
+ */
610
+ spellBarrierTarget(words) {
611
+ const m = /^(?:the\s+)?(?:(mana|force|physical)\s+)?(?:barrier|wall)(?:\s+(?:at\s+)?(-?\d+,-?\d+(?:,-?\d+)?))?$/i.exec(words.trim());
612
+ if (!m)
613
+ return undefined;
614
+ const kind = this.actor.plane === 'astral' ? 'mana' : 'physical';
615
+ const all = this.scene.spellBarriers?.(kind) ?? [];
616
+ if (all.length === 0) {
617
+ // A door called "the wall" is still the door route's to answer.
618
+ if (m[1]?.toLowerCase() === 'mana' && kind === 'physical' && (this.scene.spellBarriers?.('mana').length ?? 0) > 0) {
619
+ return { refusal: `A mana barrier is woven mana -- your ${this.actor.getCarriedWeapon()?.name ?? 'fists'} would pass through it as if it were not there. Only the astral can touch it.` };
620
+ }
621
+ return undefined;
622
+ }
623
+ const plane = this.scene.ensurePlane?.();
624
+ const from = this.scene.worldCellOf?.(this.actor);
625
+ if (!plane || !from)
626
+ return { refusal: `You can't place yourself well enough to aim at a barrier from here.` };
627
+ const melee = !this.actor.getCarriedWeapon()?.isFirearm();
628
+ const reachable = (c) => wallsBetween(plane, from, c) === 0 && (!melee || distanceMeters(from, c) <= ADJACENT_METERS);
629
+ if (m[2]) {
630
+ const at = parseCoord(m[2], from.z);
631
+ const hit = this.scene.barrierAt(at, kind);
632
+ if (!hit)
633
+ return { refusal: `There's no standing barrier at ${formatCoord(at)}.` };
634
+ if (!reachable(at)) {
635
+ return { refusal: melee ? `${formatCoord(at)} is out of arm's reach -- close in, or bring a gun.` : `You can't see ${formatCoord(at)} from here.` };
636
+ }
637
+ return hit;
638
+ }
639
+ let best;
640
+ for (const barrier of all) {
641
+ for (const face of barrier.faces) {
642
+ if (face.breached)
643
+ continue;
644
+ for (const c of face.cells) {
645
+ if (!reachable(c))
646
+ continue;
647
+ const d = distanceMeters(from, c);
648
+ if (!best || d < best.d)
649
+ best = { barrier, face, d };
650
+ }
651
+ }
652
+ }
653
+ if (!best) {
654
+ return { refusal: melee ? `No barrier within arm's reach -- close in, or bring a gun.` : `No barrier face in sight from here.` };
655
+ }
656
+ return { barrier: best.barrier, face: best.face };
657
+ }
572
658
  }
573
659
  //# sourceMappingURL=attack.js.map
@@ -6,7 +6,10 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
6
6
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
7
7
  import { describeFledTarget } from '../utilities/pursuit.js';
8
8
  import { crossPlaneRefusal } from '../utilities/planes.js';
9
- import { spotOf, ensureAtSpot } from '../utilities/spots.js';
9
+ import { spotOf, ensureAtSpot, furnitureCells, standingOccupants } from '../utilities/spots.js';
10
+ import { key as gridKey } from '../utilities/room-grid.js';
11
+ import { isSolid, isVoid, roomAtCell, wallsBetween } from '../utilities/district-plane.js';
12
+ import { buildBarrier, clipFaces, formatCoord, isCoordToken, parseCoord, planDirectionalWall, planDome, planWall } from '../utilities/barrier-geometry.js';
10
13
  import { NPC } from '../models/npc.js';
11
14
  import { spellFor, catalogSpells } from '../utilities/catalog.js';
12
15
  import { getAspect } from '../aspects.js';
@@ -93,7 +96,7 @@ export class CastCommand extends BypassCommand {
93
96
  /** Touches another actor: authorized per-act for AI-driven actors
94
97
  * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
95
98
  static npcPolicy = 'authorized';
96
- static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know. Bare "cast" tries to unweave a warded barrier.';
99
+ static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know. Barriers take map coordinates: "cast barrier 3 3,4 3,6" (a wall), "cast barrier 3 5,5" (a dome), "cast barrier east 2". A ward is "dispel <direction>".';
97
100
  deviceKind = 'ward';
98
101
  verbName = 'cast';
99
102
  noDeviceMessage = "There's nothing here to cast at.";
@@ -205,16 +208,11 @@ export class CastCommand extends BypassCommand {
205
208
  return this.castSpell(spellName, words.slice(1));
206
209
  }
207
210
  // An AMBIGUOUS fragment ("cast stun ...", now that stunbolt and
208
- // stunball both exist) used to fall silently into the ward-bypass
209
- // path and answer with something about barriers. Name the choices
210
- // instead -- but only for someone who can actually cast, so "cast
211
- // north" still routes to the bypass.
211
+ // stunball both exist) names the choices rather than guessing.
212
212
  const near = this.spellsMatching(words?.[0]);
213
213
  if (near.length > 1 && this.actor.isCaster()) {
214
214
  return `Which one? ${near.join(', ')}.`;
215
215
  }
216
- // Ward-bypass path (BypassCommand). Now that casting is a real, gated
217
- // ability, throwing magic at a ward requires the talent or the gear.
218
216
  // ASPECTED TO ANOTHER GROUP (SR5 p.69): no Sorcery at all -- not even
219
217
  // through a formula or focus, which stand in for the SKILL, not the gift.
220
218
  if (this.actor.isAspected && !this.actor.canWorkSorcery()) {
@@ -223,7 +221,18 @@ export class CastCommand extends BypassCommand {
223
221
  if (!this.actor.isCaster()) {
224
222
  return `Casting takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
225
223
  }
226
- return super.execute(args);
224
+ // "CAST <DIRECTION>" IS GONE (user ruling 2026-10-05): it used to
225
+ // unweave a ward on that exit, which is DISPEL's job and now only
226
+ // dispel's. A direction gets pointed at both verbs it might mean;
227
+ // anything else is not a spell this caster knows.
228
+ const first = words[0]?.toLowerCase();
229
+ if (!first) {
230
+ return `Cast what? "spells" lists what you know -- "cast <spell> [force] ...".`;
231
+ }
232
+ if (this.isValidDirection(first)) {
233
+ return `To unweave a ward that way, "dispel ${first}". To wall the way off, "cast barrier ${first} [cells]".`;
234
+ }
235
+ return `"${words.join(' ')}" isn't a spell you know. "spells" lists them; a ward is "dispel <it>".`;
227
236
  }
228
237
  /**
229
238
  * ONE REFUSAL FOR THE ASPECT, wherever it is reached from -- the
@@ -964,16 +973,31 @@ export class CastCommand extends BypassCommand {
964
973
  return this.finishSpell(lines);
965
974
  }
966
975
  /**
967
- * PHYSICAL BARRIER (ruling 2026-08-24): "cast barrier <direction>"
968
- * seals that exit of the CURRENT room while sustained -- go.ts (and
969
- * spot-crossing toward its doorway) refuses everyone, fleeing NPCs
970
- * included, the caster too. Same toggle shape as armor: casting
971
- * barrier again (any direction) releases the standing one first.
972
- * One barrier per caster; -2 sustaining like every held weave.
976
+ * THE BARRIER SPELLS, CAST AT THE DISTRICT PLANE (SR5 p.294; user
977
+ * spec 2026-10-05). Coordinates are plane cells -- the numbers on the
978
+ * map rulers -- and the shape follows from how many points you give:
979
+ *
980
+ * cast barrier [F] <x,y[,z]> <x,y[,z]> a wall between two points
981
+ * cast barrier [F] <x,y[,z]> a dome around one point
982
+ * cast barrier [F] <direction> [n] a 1x3 wall n cells that way
983
+ * cast barrier [F] at <someone> a dome around them
984
+ * cast barrier [F] a dome around yourself
985
+ * cast barrier off let it go (free)
986
+ *
987
+ * A wall is Force x 2 m long and Force m high; a dome is Force m in
988
+ * radius (barrier-geometry.ts). A Physical Barrier's Armor and
989
+ * Structure are its HITS; a Mana Barrier's are its Force (p.316). One
990
+ * of each per caster; casting again replaces the standing one, with a
991
+ * fresh roll and fresh drain. -2 sustaining like every held weave.
992
+ *
993
+ * Bare "cast north" no longer reaches a ward from here -- that is
994
+ * "dispel north" (user ruling 2026-10-05).
973
995
  */
974
996
  async castBarrier(spellKey, force, overcast, rest) {
975
997
  const actor = this.actor;
976
998
  const mana = spellKey === 'manabarrier';
999
+ const kind = mana ? 'mana' : 'physical';
1000
+ const label = mana ? 'mana barrier' : 'barrier';
977
1001
  if (actor.plane !== 'meat' && actor.plane !== 'astral') {
978
1002
  return `Barriers are woven where the world is -- not from the grid.`;
979
1003
  }
@@ -981,54 +1005,195 @@ export class CastCommand extends BypassCommand {
981
1005
  return `A physical barrier needs physical hands -- come back to the meat.`;
982
1006
  }
983
1007
  const standing = mana ? actor.sustainedManaBarrier : actor.sustainedBarrier;
984
- if (standing) {
1008
+ const words = rest.map(w => w.trim()).filter(w => w.length > 0);
1009
+ if (words.length === 1 && /^(off|release|drop|end)$/i.test(words[0])) {
1010
+ if (!standing)
1011
+ return `You aren't holding a ${label}.`;
985
1012
  if (mana)
986
1013
  actor.sustainedManaBarrier = undefined;
987
1014
  else
988
1015
  actor.sustainedBarrier = undefined;
989
1016
  this.scene.updateStatus();
990
- actor.performAction(`releases ${mana ? 'mana barrier' : 'barrier'} spell`, `${standing.direction} in ${standing.roomName}`);
991
- if (rest.length === 0) {
992
- return this.finishSpell([`You let the ${mana ? 'mana barrier' : 'barrier'} go -- the ${mana ? 'weave' : 'air'} ${standing.roomName === actor.currentLocation.name ? `across the way ${standing.direction}` : `in ${standing.roomName}`} ${mana ? 'unravels' : 'unhardens'}, and the sustaining burden lifts.`]);
993
- }
994
- // Fall through: a direction was named -- re-seat the wall there.
1017
+ this.scene.updateExits?.(actor.currentLocation);
1018
+ actor.performAction(`releases ${label} spell`, `in ${standing.roomName}`);
1019
+ return this.finishSpell([`You let the ${label} go -- the ${mana ? 'weave unravels' : 'air unhardens'}, and the sustaining burden lifts.`]);
995
1020
  }
996
1021
  const room = actor.currentLocation;
997
- const wanted = rest.join(' ').trim().toLowerCase();
998
- if (!wanted) {
999
- return `Seal which way? "cast ${spellKey} <direction>" -- exits here: ${[...room.exits.keys()].join(', ') || 'none'}.`;
1022
+ const plane = this.scene.ensurePlane?.();
1023
+ const from = this.scene.worldCellOf?.(actor);
1024
+ if (!plane || !from) {
1025
+ return `There's no ground here to raise a ${label} on.`;
1000
1026
  }
1001
- const direction = [...room.exits.keys()].find(d => d.toLowerCase() === wanted || d.toLowerCase().startsWith(wanted));
1002
- if (!direction) {
1003
- return `There's no way ${wanted} from here to seal. Exits: ${[...room.exits.keys()].join(', ') || 'none'}.`;
1027
+ // WHAT SHAPE, AND WHERE.
1028
+ let plan;
1029
+ let around;
1030
+ let center;
1031
+ const usage = `"cast ${spellKey} [force] <x,y> <x,y>" (a wall), "cast ${spellKey} [force] <x,y>" (a dome), "cast ${spellKey} [force] <direction> [cells]", or "cast ${spellKey} at <someone>". The map rulers give x and y; you're at ${formatCoord(from)}.`;
1032
+ const unseen = (what) => `You can't see ${what} from here -- a barrier is cast at what you can see (LOS, p.294).`;
1033
+ if (words.length === 0) {
1034
+ center = from;
1035
+ around = 'you';
1036
+ plan = planDome(from, force);
1004
1037
  }
1005
- // Non-null: castSpell resolved the row and returned early if it
1006
- // could not, so every routine below is unreachable for a spell the
1007
- // catalog does not hold. The Record this replaced was typed as
1008
- // always-present and hid that; spellFor is honest about it.
1038
+ else if (isCoordToken(words[0])) {
1039
+ if (words.length > 2 || (words.length === 2 && !isCoordToken(words[1])))
1040
+ return `Two points at most. ${usage}`;
1041
+ const a = parseCoord(words[0], from.z);
1042
+ if (words.length === 2) {
1043
+ const b = parseCoord(words[1], from.z);
1044
+ plan = planWall(a, b, force);
1045
+ if (plan.error)
1046
+ return plan.error;
1047
+ const hidden = [a, b].find(p => !this.inSight(plane, from, p));
1048
+ if (hidden)
1049
+ return unseen(formatCoord(hidden));
1050
+ }
1051
+ else {
1052
+ center = a;
1053
+ plan = planDome(a, force);
1054
+ if (!this.inSight(plane, from, a))
1055
+ return unseen(formatCoord(a));
1056
+ }
1057
+ }
1058
+ else if (this.isValidDirection(words[0].toLowerCase())) {
1059
+ if (words.length > 2 || (words.length === 2 && !/^\d+$/.test(words[1]))) {
1060
+ return `"cast ${spellKey} ${words[0]} [cells]" -- the distance is a whole number of cells.`;
1061
+ }
1062
+ const distance = words.length === 2 ? parseInt(words[1], 10) : 1;
1063
+ plan = planDirectionalWall(from, words[0].toLowerCase(), distance, force);
1064
+ if (plan.error)
1065
+ return plan.error;
1066
+ const run = plan.faces[0].cells;
1067
+ const mid = run[Math.floor(run.length / 2)];
1068
+ if (!this.inSight(plane, from, mid))
1069
+ return unseen(`${distance} cell${distance === 1 ? '' : 's'} ${words[0]}`);
1070
+ }
1071
+ else {
1072
+ const lead = words[0].toLowerCase();
1073
+ const nameWords = lead === 'at' || lead === 'on' || lead === 'around' ? words.slice(1) : words;
1074
+ if (nameWords.length === 0)
1075
+ return usage;
1076
+ const candidates = this.scene.getActorsInRoom(room).filter(a => a === actor || ((a.plane === 'meat' || a.plane === actor.plane) && actor.canPerceive(a)));
1077
+ const picked = fuzzyPickName(nameWords.join(' '), candidates.map(a => a.name));
1078
+ const target = picked ? candidates.find(a => a.name === picked) : undefined;
1079
+ if (!target)
1080
+ return `You don't see "${nameWords.join(' ')}" here. ${usage}`;
1081
+ const at = this.scene.worldCellOf?.(target);
1082
+ if (!at)
1083
+ return `${target.name} isn't standing anywhere a dome could go round.`;
1084
+ if (target !== actor && !this.inSight(plane, from, at))
1085
+ return unseen(target.name);
1086
+ center = at;
1087
+ around = target === actor ? 'you' : target.name;
1088
+ plan = planDome(at, force);
1089
+ }
1090
+ if (plan.error)
1091
+ return plan.error;
1092
+ // CASTING IS A COMPLEX ACTION in a Combat Turn (p.167).
1093
+ const bill = billAction(this.scene, actor, 'complex', `Cast ${label}`);
1094
+ if (bill)
1095
+ return bill;
1096
+ // The standing one goes first: its cells must not clip the new one.
1097
+ if (standing) {
1098
+ if (mana)
1099
+ actor.sustainedManaBarrier = undefined;
1100
+ else
1101
+ actor.sustainedBarrier = undefined;
1102
+ }
1103
+ // WHAT IT CANNOT STAND IN (barrier-geometry clipFaces).
1104
+ const clipped = clipFaces(plan.faces, c => this.barrierCellOpen(plane, c, kind, center));
1105
+ if (clipped.faces.length === 0) {
1106
+ return `There's no room for a ${label} there -- every cell of it is ${mana ? 'earth or another weave' : 'wall, furniture or somebody'}.`;
1107
+ }
1108
+ // THE CASTING TEST: Magic + Spellcasting [Force] (p.281). Physical
1109
+ // Barrier: Armor and Structure 1 per hit (p.294). Mana Barrier: the
1110
+ // barrier's Armor and Structure are its Force (p.316).
1111
+ const roll = rollPool(actor.getMagicPool(), force);
1112
+ if (this.scene.isHumanControlled(actor))
1113
+ this.logger.meta(`${mana ? 'Mana Barrier' : 'Physical Barrier'} casting: ${formatRoll(roll)}`);
1009
1114
  const spell = spellFor(spellKey);
1010
- const seat = { roomName: room.name, direction, force };
1115
+ if (!mana && roll.hits === 0) {
1116
+ actor.performAction(`casts ${label}`, 'and nothing holds');
1117
+ const lines = [
1118
+ `${actor.name} casts barrier at Force ${force}, ${this.castingLabel()}:`,
1119
+ ` The air shivers and lets go -- no hits, no wall.`,
1120
+ ...this.applyDrain(spell, force, overcast),
1121
+ ];
1122
+ lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
1123
+ return this.finishSpell(lines);
1124
+ }
1125
+ const rating = mana ? force : roll.hits;
1126
+ const barrier = buildBarrier(kind, actor.name, force, rating, room.name, plan.shape, clipped.faces);
1011
1127
  if (mana)
1012
- actor.sustainedManaBarrier = seat;
1128
+ actor.sustainedManaBarrier = barrier;
1013
1129
  else
1014
- actor.sustainedBarrier = seat;
1015
- actor.performAction(`casts ${mana ? 'mana barrier' : 'barrier'}`, `sealing ${direction}`);
1130
+ actor.sustainedBarrier = barrier;
1131
+ const cells = barrier.faces.reduce((n, f) => n + f.cells.length, 0);
1132
+ const first = barrier.faces[0].cells;
1133
+ const where = plan.shape === 'dome'
1134
+ ? `a dome of ${force} m radius around ${around ?? formatCoord(center)}`
1135
+ : `a wall from ${formatCoord(first[0])} to ${formatCoord(first[first.length - 1])}`;
1136
+ actor.performAction(`casts ${label}`, where);
1016
1137
  this.scene.addWorldEvent(mana
1017
- ? `${actor.name} wove a mana barrier across the way ${direction} in ${room.name}.`
1018
- : `${actor.name} raised a shimmering force barrier across the way ${direction} in ${room.name}.`);
1138
+ ? `${actor.name} wove a mana barrier in ${room.name}.`
1139
+ : `${actor.name} raised a shimmering force barrier in ${room.name}.`);
1019
1140
  this.scene.updateStatus();
1141
+ // The panels draw it (room-view / map-view): repaint them now.
1142
+ this.scene.updateExits?.(room);
1020
1143
  const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === spellKey), ' to everything else you do');
1144
+ const faces = barrier.faces.length > 1 ? ` ${barrier.faces.length} faces, each with its own damage track.` : '';
1021
1145
  const lines = [
1022
- `${actor.name} casts ${mana ? 'mana barrier' : 'barrier'} at Force ${force}, ${this.castingLabel()}:`,
1146
+ `${actor.name} casts ${label} at Force ${force}, ${this.castingLabel()}:`,
1147
+ ...(this.scene.isHumanControlled(actor) ? [] : [` Casting: ${formatRoll(roll)}`]),
1023
1148
  mana
1024
- ? ` A lattice of woven mana settles across the way ${direction} -- invisible to meat eyes, Force ${force}. SPIRITS and astral forms stop at it; the living walk through without noticing. ${burden.charAt(0).toUpperCase()}${burden.slice(1)}.`
1025
- : ` The air itself hardens across the way ${direction} -- a faint heat-shimmer wall, Force ${force}. NOBODY passes while you hold it (you included), and ${burden}.`,
1026
- hint(` (Cast ${spellKey} again to release it; "dispel ${mana ? 'mana barrier' : 'barrier'}" unweaves it from either side.)`),
1149
+ ? ` A lattice of woven mana settles into ${where} -- ${cells} cell${cells === 1 ? '' : 's'}, invisible to meat eyes. Armor and Structure ${rating}.${faces} SPIRITS and astral forms stop at it; the living walk through without noticing, and so do you. ${burden.charAt(0).toUpperCase()}${burden.slice(1)}.`
1150
+ : ` The air itself hardens into ${where} -- ${cells} cell${cells === 1 ? '' : 's'} of heat-shimmer. Armor and Structure ${rating} (${roll.hits} hit${roll.hits === 1 ? '' : 's'}).${faces} NOTHING physical passes while you hold it (you included), and ${burden}.`,
1151
+ ...(plan.note ? [` ${plan.note}`] : []),
1152
+ ...(clipped.dropped > 0 ? [` ${clipped.dropped} cell${clipped.dropped === 1 ? '' : 's'} of it ${mana ? 'stop at earth or another weave' : 'meet wall, furniture or a body and stop there'}.`] : []),
1153
+ hint(` ("cast ${spellKey} off" lets it go; "dispel ${mana ? 'mana barrier' : 'barrier'}" unweaves it from either side.)`),
1027
1154
  ...this.applyDrain(spell, force, overcast),
1028
1155
  ];
1029
1156
  lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
1030
1157
  return this.finishSpell(lines);
1031
1158
  }
1159
+ /** LINE OF SIGHT for an area spell's point (p.281): no wall between. */
1160
+ inSight(plane, from, to) {
1161
+ return wallsBetween(plane, from, to) === 0;
1162
+ }
1163
+ /**
1164
+ * CAN A BARRIER CELL STAND HERE. Physical: not in a wall, not in the
1165
+ * void, not in furniture or a body -- matter is already there. Mana:
1166
+ * the astral is "unimpeded by material objects" (p.313), so only earth,
1167
+ * a warded room you are not inside, and another mana barrier stop it.
1168
+ * The one a dome is raised around is inside it, never in its way.
1169
+ */
1170
+ barrierCellOpen(plane, c, kind, center) {
1171
+ if (center && c.x === center.x && c.y === center.y && c.z === center.z)
1172
+ return true;
1173
+ if (isVoid(plane, c))
1174
+ return false;
1175
+ const ownerKey = roomAtCell(plane, c);
1176
+ const owner = ownerKey ? Object.values(this.scene.getRooms()).find(r => r.name.trim().toLowerCase() === ownerKey) : undefined;
1177
+ if (kind === 'mana') {
1178
+ if (owner?.warded && owner !== this.actor.currentLocation)
1179
+ return false;
1180
+ return !this.scene.barrierAt(c, 'mana');
1181
+ }
1182
+ if (isSolid(plane, c))
1183
+ return false;
1184
+ if (this.scene.barrierAt(c, 'physical'))
1185
+ return false;
1186
+ const place = ownerKey ? plane.placements.get(ownerKey) : undefined;
1187
+ if (!owner || !place)
1188
+ return true;
1189
+ const local = { x: c.x - place.origin.x, y: c.y - place.origin.y, z: c.z - place.origin.z };
1190
+ const lk = gridKey(local);
1191
+ if (furnitureCells(owner).has(lk))
1192
+ return false;
1193
+ if (standingOccupants(owner).has(lk))
1194
+ return false;
1195
+ return true;
1196
+ }
1032
1197
  /**
1033
1198
  * The sustained utility pair (invisibility / increase reflexes), same
1034
1199
  * toggle shape as the armor spell: the BEARER wears the effect, the
@@ -3,6 +3,7 @@ import { BypassCommand } from './bypass-command.js';
3
3
  import { rollPool, formatRoll } from '../utilities/dice.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
5
  import { billAction } from '../utilities/action-cost.js';
6
+ import { roomAtCell } from '../utilities/district-plane.js';
6
7
  /**
7
8
  * The magical barrier's OWN verb (player ruling: "solve" was too broad --
8
9
  * a ward wants "dispel", not a generic answer verb), and the second half
@@ -110,16 +111,23 @@ export class DispelCommand extends CastCommand {
110
111
  return `More than one weave of ${named.label} here -- name whose: "dispel ${named.words[0]} on <name>".`;
111
112
  }
112
113
  // A barrier's WEAVE is here even when its weaver isn't: "dispel
113
- // [mana] barrier" from either side of the wall reaches the caster
114
- // wherever they stand (the spell seals one of THIS room's exits).
114
+ // [mana] barrier" reaches the caster wherever they stand, for any
115
+ // barrier with a cell in this room or cast from it -- or across one
116
+ // of its doorways, from either side.
115
117
  if (!target && (named?.key === 'barrier' || named?.key === 'manabarrier')) {
116
- for (const direction of room.exits.keys()) {
117
- const seal = this.scene.barrierSealing(room, direction, named.key === 'manabarrier' ? 'mana' : 'physical');
118
- if (seal) {
119
- target = this.scene.getPlayers().find(p => p.name === seal.caster);
118
+ const kind = named.key === 'manabarrier' ? 'mana' : 'physical';
119
+ const plane = this.scene.ensurePlane?.();
120
+ const here = room.name.trim().toLowerCase();
121
+ const standing = this.scene.spellBarriers?.(kind).find(b => b.roomName === room.name
122
+ || (plane && b.faces.some(f => f.cells.some(c => roomAtCell(plane, c) === here))));
123
+ let caster = standing?.caster;
124
+ for (const direction of caster ? [] : room.exits.keys()) {
125
+ caster = this.scene.barrierSealing(room, direction, kind)?.caster;
126
+ if (caster)
120
127
  break;
121
- }
122
128
  }
129
+ if (caster)
130
+ target = this.scene.getPlayers().find(p => p.name === caster);
123
131
  }
124
132
  if (!target)
125
133
  return undefined;
@@ -5,6 +5,7 @@ import { resolveHostInReach } from '../utilities/grid-reach.js';
5
5
  import { planeChangeHint, astralMovesLeft, astralCordWarning, astralCordParts } from '../utilities/planes.js';
6
6
  import { CombatExchange } from '../utilities/combat-exchange.js';
7
7
  import { rollPool, formatRoll } from '../utilities/dice.js';
8
+ import { astralLimit } from '../utilities/limits.js';
8
9
  import { walkOut } from '../utilities/movement-cost.js';
9
10
  import { frameOf, droneImmobile, droneImmobileLine } from '../utilities/drone-prose.js';
10
11
  import { entrySpotFor, arrivalAt, ensureAtExit, spotsActive, highlightSpots, highlightActors, doorwayBlocker, doorwayFurniture, yieldDoorway, lockedRouteHint, lockSeenFromOutside, spotForCell, waysOutOf, actorCell } from '../utilities/spots.js';
@@ -636,11 +637,12 @@ export class GoCommand extends Command {
636
637
  // back corner, and nothing said so). Walking out means walking to
637
638
  // it first: free out of combat, refused mid-exchange -- the same
638
639
  // rule pick/unlock already enforce on the same doors.
639
- // SUSTAINED BARRIER SPELL (ruling 2026-08-24): a held force wall
640
- // seals this exit for EVERY meat-plane actor -- players, fleeing
641
- // NPCs, the caster too. Checked before the walk-to-the-doorway so
642
- // nobody strolls up to a wall they can see shimmering from here.
643
- // Astral and Matrix forms pass: the spell hardens AIR.
640
+ // SUSTAINED BARRIER SPELL: a held force wall standing over this
641
+ // doorway (its cells, barrier-geometry.ts) seals it for EVERY
642
+ // meat-plane actor -- players, fleeing NPCs, the caster too. Checked
643
+ // before the walk-to-the-doorway so nobody strolls up to a wall they
644
+ // can see shimmering from here. Astral and Matrix forms pass: the
645
+ // spell hardens AIR.
644
646
  if (this.actor.plane === 'meat') {
645
647
  const seal = this.scene.barrierSealing(currentRoom, direction);
646
648
  if (seal) {
@@ -649,12 +651,25 @@ export class GoCommand extends Command {
649
651
  }
650
652
  }
651
653
  // The MANA BARRIER twin (canon p.294): spirits and astral forms
652
- // stop at the weave; the living walk through it without noticing.
654
+ // stop at the weave; the living walk through it without noticing,
655
+ // and so does its creator (p.315).
653
656
  if (this.actor.plane === 'astral') {
654
- const seal = this.scene.barrierSealing(currentRoom, direction, 'mana');
657
+ const seal = this.scene.barrierSealing(currentRoom, direction, 'mana', this.actor.name);
655
658
  if (seal) {
656
- this.actor.performAction('is stopped by a mana barrier', `${direction} from ${currentRoom.name}`);
657
- return `A lattice of woven mana seals the way ${direction} -- Force ${seal.force}, ${seal.caster}'s working. The astral does not pass.`;
659
+ // PRESSING THROUGH (p.316, "Getting Around Mana Barriers"):
660
+ // Magic + Charisma [Astral] v. the barrier's Force x 2. Any net
661
+ // hits and you are through; otherwise the weave holds you.
662
+ const press = rollPool(this.actor.magic + this.actor.charisma, astralLimit(this.actor));
663
+ const hold = rollPool(seal.force * 2);
664
+ const net = press.hits - hold.hits;
665
+ if (this.scene.isHumanControlled(this.actor)) {
666
+ this.logger.meta(`Press through the mana barrier (p.316): Magic + Charisma ${formatRoll(press)} v. Force x 2 ${formatRoll(hold)}`);
667
+ }
668
+ if (net <= 0) {
669
+ this.actor.performAction('is stopped by a mana barrier', `${direction} from ${currentRoom.name}`);
670
+ return `A lattice of woven mana seals the way ${direction} -- Force ${seal.force}, ${seal.caster}'s working. You press against it and it presses back; the astral does not pass.`;
671
+ }
672
+ this.actor.performAction('presses through a mana barrier', `${direction} from ${currentRoom.name}`);
658
673
  }
659
674
  }
660
675
  let crossNote = '';
@@ -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.