@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.383.0",
3
+ "version": "5.385.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, METERS_PER_CELL } from '../utilities/room-grid.js';
11
+ import { isSolid, isVoid, roomAtCell, wallsBetween } from '../utilities/district-plane.js';
12
+ import { DIRECTIONAL_WALL_FORCE, DOME_FORCE, forceForWall, 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
@@ -298,6 +307,7 @@ export class CastCommand extends BypassCommand {
298
307
  const spellDef = spellFor(spellName);
299
308
  const safeForce = this.actor.magic > 0 ? this.actor.magic : 4;
300
309
  let force = Math.max(1, -(spellDef?.drainOffset ?? -3));
310
+ let forceGiven = true;
301
311
  if (rest[0]?.toLowerCase() === 'force' && /^\d+$/.test(rest[1] ?? '')) {
302
312
  force = parseInt(rest[1], 10);
303
313
  rest = rest.slice(2);
@@ -306,6 +316,9 @@ export class CastCommand extends BypassCommand {
306
316
  force = parseInt(rest[0], 10);
307
317
  rest = rest.slice(1);
308
318
  }
319
+ else {
320
+ forceGiven = false;
321
+ }
309
322
  if (force < 1) {
310
323
  return `Force below 1 isn't a spell, it's a wish.`;
311
324
  }
@@ -326,7 +339,7 @@ export class CastCommand extends BypassCommand {
326
339
  case 'heal': return this.castHeal(force, overcast, rest);
327
340
  case 'armor': return this.castArmor(force, overcast, rest);
328
341
  case 'sustained-utility': return this.castSustainedUtility(spellName, force, overcast, rest);
329
- case 'barrier': return this.castBarrier(spellName, force, overcast, rest);
342
+ case 'barrier': return this.castBarrier(spellName, force, overcast, rest, forceGiven ? undefined : safeForce);
330
343
  }
331
344
  }
332
345
  /**
@@ -964,16 +977,34 @@ export class CastCommand extends BypassCommand {
964
977
  return this.finishSpell(lines);
965
978
  }
966
979
  /**
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.
980
+ * THE BARRIER SPELLS, CAST AT THE DISTRICT PLANE (SR5 p.294; user
981
+ * spec 2026-10-05). Coordinates are plane cells -- the numbers on the
982
+ * map rulers -- and the shape follows from how many points you give:
983
+ *
984
+ * cast barrier [F] <x,y[,z]> <x,y[,z]> a wall between two points
985
+ * cast barrier [F] <x,y[,z]> a dome around one point
986
+ * cast barrier [F] <direction> [n] a 1x3 wall n cells that way
987
+ * cast barrier [F] at <someone> [size] a dome around them ("me"/"self": you);
988
+ * size = radius in cells = Force / 2
989
+ * cast barrier [F] a dome around yourself
990
+ * cast barrier off let it go (free)
991
+ *
992
+ * A wall is Force x 2 m long and Force m high; a dome is Force m in
993
+ * radius (barrier-geometry.ts). A Physical Barrier's Armor and
994
+ * Structure are its HITS; a Mana Barrier's are its Force (p.316). One
995
+ * of each per caster; casting again replaces the standing one, with a
996
+ * fresh roll and fresh drain. -2 sustaining like every held weave.
997
+ *
998
+ * Bare "cast north" no longer reaches a ward from here -- that is
999
+ * "dispel north" (user ruling 2026-10-05).
973
1000
  */
974
- async castBarrier(spellKey, force, overcast, rest) {
1001
+ async castBarrier(spellKey, force, overcast, rest,
1002
+ /** Set when no Force was named: the talent a sized cast may reach. */
1003
+ autoUpTo) {
975
1004
  const actor = this.actor;
976
1005
  const mana = spellKey === 'manabarrier';
1006
+ const kind = mana ? 'mana' : 'physical';
1007
+ const label = mana ? 'mana barrier' : 'barrier';
977
1008
  if (actor.plane !== 'meat' && actor.plane !== 'astral') {
978
1009
  return `Barriers are woven where the world is -- not from the grid.`;
979
1010
  }
@@ -981,54 +1012,256 @@ export class CastCommand extends BypassCommand {
981
1012
  return `A physical barrier needs physical hands -- come back to the meat.`;
982
1013
  }
983
1014
  const standing = mana ? actor.sustainedManaBarrier : actor.sustainedBarrier;
984
- if (standing) {
1015
+ const words = rest.map(w => w.trim()).filter(w => w.length > 0);
1016
+ if (words.length === 1 && /^(off|release|drop|end)$/i.test(words[0])) {
1017
+ if (!standing)
1018
+ return `You aren't holding a ${label}.`;
985
1019
  if (mana)
986
1020
  actor.sustainedManaBarrier = undefined;
987
1021
  else
988
1022
  actor.sustainedBarrier = undefined;
989
1023
  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.
1024
+ this.scene.updateExits?.(actor.currentLocation);
1025
+ actor.performAction(`releases ${label} spell`, `in ${standing.roomName}`);
1026
+ return this.finishSpell([`You let the ${label} go -- the ${mana ? 'weave unravels' : 'air unhardens'}, and the sustaining burden lifts.`]);
995
1027
  }
996
1028
  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'}.`;
1029
+ const plane = this.scene.ensurePlane?.();
1030
+ const from = this.scene.worldCellOf?.(actor);
1031
+ if (!plane || !from) {
1032
+ return `There's no ground here to raise a ${label} on.`;
1000
1033
  }
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'}.`;
1034
+ // WHAT SHAPE, AND WHERE.
1035
+ let plan;
1036
+ let around;
1037
+ let center;
1038
+ 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|me> [radius in cells]". Leave the Force off and it is sized for you. The map rulers give x and y; you're at ${formatCoord(from)}.`;
1039
+ const unseen = (what) => `You can't see ${what} from here -- a barrier is cast at what you can see (LOS, p.294).`;
1040
+ if (words.length === 0) {
1041
+ center = from;
1042
+ around = 'you';
1043
+ force = this.domeForce(force, autoUpTo);
1044
+ plan = planDome(from, force);
1004
1045
  }
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.
1046
+ else if (isCoordToken(words[0])) {
1047
+ if (words.length > 2 || (words.length === 2 && !isCoordToken(words[1])))
1048
+ return `Two points at most. ${usage}`;
1049
+ const a = parseCoord(words[0], from.z);
1050
+ if (words.length === 2) {
1051
+ const b = parseCoord(words[1], from.z);
1052
+ const sized = this.sizeForce(spellKey, force, forceForWall(a, b), autoUpTo);
1053
+ if (typeof sized === 'string')
1054
+ return sized;
1055
+ force = sized;
1056
+ plan = planWall(a, b, force);
1057
+ if (plan.error)
1058
+ return plan.error;
1059
+ const hidden = [a, b].find(p => !this.inSight(plane, from, p));
1060
+ if (hidden)
1061
+ return unseen(formatCoord(hidden));
1062
+ }
1063
+ else {
1064
+ center = a;
1065
+ force = this.domeForce(force, autoUpTo);
1066
+ plan = planDome(a, force);
1067
+ if (!this.inSight(plane, from, a))
1068
+ return unseen(formatCoord(a));
1069
+ }
1070
+ }
1071
+ else if (this.isValidDirection(words[0].toLowerCase())) {
1072
+ if (words.length > 2 || (words.length === 2 && !/^\d+$/.test(words[1]))) {
1073
+ return `"cast ${spellKey} ${words[0]} [cells]" -- the distance is a whole number of cells.`;
1074
+ }
1075
+ const distance = words.length === 2 ? parseInt(words[1], 10) : 1;
1076
+ const sized = this.sizeForce(spellKey, force, DIRECTIONAL_WALL_FORCE, autoUpTo);
1077
+ if (typeof sized === 'string')
1078
+ return sized;
1079
+ force = sized;
1080
+ plan = planDirectionalWall(from, words[0].toLowerCase(), distance, force);
1081
+ if (plan.error)
1082
+ return plan.error;
1083
+ const run = plan.faces[0].cells;
1084
+ const mid = run[Math.floor(run.length / 2)];
1085
+ if (!this.inSight(plane, from, mid))
1086
+ return unseen(`${distance} cell${distance === 1 ? '' : 's'} ${words[0]}`);
1087
+ }
1088
+ else {
1089
+ const lead = words[0].toLowerCase();
1090
+ let nameWords = lead === 'at' || lead === 'on' || lead === 'around' ? words.slice(1) : words;
1091
+ // "at <someone> [size]" (user 2026-10-05): a trailing number is the
1092
+ // dome's RADIUS in map cells. p.294 makes the radius the Force in
1093
+ // metres, so the size IS a Force -- two cells is Force 4.
1094
+ let size;
1095
+ if (nameWords.length > 1 && /^\d+$/.test(nameWords[nameWords.length - 1])) {
1096
+ size = parseInt(nameWords[nameWords.length - 1], 10);
1097
+ nameWords = nameWords.slice(0, -1);
1098
+ }
1099
+ if (nameWords.length === 0)
1100
+ return usage;
1101
+ // "me" / "self" / "myself" are the caster (user 2026-10-05).
1102
+ const selfWord = nameWords.length === 1 && /^(me|self|myself|yourself)$/i.test(nameWords[0]);
1103
+ const candidates = this.scene.getActorsInRoom(room).filter(a => a === actor || ((a.plane === 'meat' || a.plane === actor.plane) && actor.canPerceive(a)));
1104
+ const picked = selfWord ? actor.name : fuzzyPickName(nameWords.join(' '), candidates.map(a => a.name));
1105
+ const target = selfWord ? actor : picked ? candidates.find(a => a.name === picked) : undefined;
1106
+ if (!target)
1107
+ return `You don't see "${nameWords.join(' ')}" here. ${usage}`;
1108
+ const at = this.scene.worldCellOf?.(target);
1109
+ if (!at)
1110
+ return `${target.name} isn't standing anywhere a dome could go round.`;
1111
+ if (target !== actor && !this.inSight(plane, from, at))
1112
+ return unseen(target.name);
1113
+ center = at;
1114
+ around = target === actor ? 'you' : target.name;
1115
+ if (size !== undefined) {
1116
+ if (size < 1)
1117
+ return `A dome's size is its radius in cells, 1 or more.`;
1118
+ const needed = Math.ceil(size * METERS_PER_CELL);
1119
+ if (autoUpTo === undefined && needed !== force) {
1120
+ return `A dome's radius IS its Force in metres (p.294): ${size} cell${size === 1 ? '' : 's'} is Force ${needed}, not ${force}. Name one or the other.`;
1121
+ }
1122
+ const sized = this.sizeForce(spellKey, force, needed, autoUpTo, 'dome');
1123
+ if (typeof sized === 'string')
1124
+ return sized;
1125
+ force = sized;
1126
+ }
1127
+ else {
1128
+ force = this.domeForce(force, autoUpTo);
1129
+ }
1130
+ plan = planDome(at, force);
1131
+ }
1132
+ if (plan.error)
1133
+ return plan.error;
1134
+ // CASTING IS A COMPLEX ACTION in a Combat Turn (p.167).
1135
+ const bill = billAction(this.scene, actor, 'complex', `Cast ${label}`);
1136
+ if (bill)
1137
+ return bill;
1138
+ // The standing one goes first: its cells must not clip the new one.
1139
+ if (standing) {
1140
+ if (mana)
1141
+ actor.sustainedManaBarrier = undefined;
1142
+ else
1143
+ actor.sustainedBarrier = undefined;
1144
+ }
1145
+ // WHAT IT CANNOT STAND IN (barrier-geometry clipFaces).
1146
+ const clipped = clipFaces(plan.faces, c => this.barrierCellOpen(plane, c, kind, center));
1147
+ if (clipped.faces.length === 0) {
1148
+ return `There's no room for a ${label} there -- every cell of it is ${mana ? 'earth or another weave' : 'wall, furniture or somebody'}.`;
1149
+ }
1150
+ // THE CASTING TEST: Magic + Spellcasting [Force] (p.281). Physical
1151
+ // Barrier: Armor and Structure 1 per hit (p.294). Mana Barrier: the
1152
+ // barrier's Armor and Structure are its Force (p.316).
1153
+ const roll = rollPool(actor.getMagicPool(), force);
1154
+ if (this.scene.isHumanControlled(actor))
1155
+ this.logger.meta(`${mana ? 'Mana Barrier' : 'Physical Barrier'} casting: ${formatRoll(roll)}`);
1009
1156
  const spell = spellFor(spellKey);
1010
- const seat = { roomName: room.name, direction, force };
1157
+ if (!mana && roll.hits === 0) {
1158
+ actor.performAction(`casts ${label}`, 'and nothing holds');
1159
+ const lines = [
1160
+ `${actor.name} casts barrier at Force ${force}, ${this.castingLabel()}:`,
1161
+ ` The air shivers and lets go -- no hits, no wall.`,
1162
+ ...this.applyDrain(spell, force, overcast),
1163
+ ];
1164
+ lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
1165
+ return this.finishSpell(lines);
1166
+ }
1167
+ const rating = mana ? force : roll.hits;
1168
+ const barrier = buildBarrier(kind, actor.name, force, rating, room.name, plan.shape, clipped.faces);
1011
1169
  if (mana)
1012
- actor.sustainedManaBarrier = seat;
1170
+ actor.sustainedManaBarrier = barrier;
1013
1171
  else
1014
- actor.sustainedBarrier = seat;
1015
- actor.performAction(`casts ${mana ? 'mana barrier' : 'barrier'}`, `sealing ${direction}`);
1172
+ actor.sustainedBarrier = barrier;
1173
+ const cells = barrier.faces.reduce((n, f) => n + f.cells.length, 0);
1174
+ const first = barrier.faces[0].cells;
1175
+ const where = plan.shape === 'dome'
1176
+ ? `a dome of ${force} m radius around ${around ?? formatCoord(center)}`
1177
+ : `a wall from ${formatCoord(first[0])} to ${formatCoord(first[first.length - 1])}`;
1178
+ actor.performAction(`casts ${label}`, where);
1016
1179
  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}.`);
1180
+ ? `${actor.name} wove a mana barrier in ${room.name}.`
1181
+ : `${actor.name} raised a shimmering force barrier in ${room.name}.`);
1019
1182
  this.scene.updateStatus();
1183
+ // The panels draw it (room-view / map-view): repaint them now.
1184
+ this.scene.updateExits?.(room);
1020
1185
  const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === spellKey), ' to everything else you do');
1186
+ const faces = barrier.faces.length > 1 ? ` ${barrier.faces.length} faces, each with its own damage track.` : '';
1021
1187
  const lines = [
1022
- `${actor.name} casts ${mana ? 'mana barrier' : 'barrier'} at Force ${force}, ${this.castingLabel()}:`,
1188
+ `${actor.name} casts ${label} at Force ${force}, ${this.castingLabel()}:`,
1189
+ ...(this.scene.isHumanControlled(actor) ? [] : [` Casting: ${formatRoll(roll)}`]),
1023
1190
  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.)`),
1191
+ ? ` 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)}.`
1192
+ : ` 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}.`,
1193
+ ...(plan.note ? [` ${plan.note}`] : []),
1194
+ ...(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'}.`] : []),
1195
+ hint(` ("cast ${spellKey} off" lets it go; "dispel ${mana ? 'mana barrier' : 'barrier'}" unweaves it from either side.)`),
1027
1196
  ...this.applyDrain(spell, force, overcast),
1028
1197
  ];
1029
1198
  lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
1030
1199
  return this.finishSpell(lines);
1031
1200
  }
1201
+ /**
1202
+ * NO FORCE NAMED: SIZE IT TO THE SHAPE (user 2026-10-05). A wall gets
1203
+ * the least Force that raises it -- never less than the default, and
1204
+ * never past your talent: overcasting turns the drain PHYSICAL (p.281),
1205
+ * and that is a choice to make out loud, not one made for you.
1206
+ */
1207
+ sizeForce(spellKey, force, needed, autoUpTo, what = 'wall') {
1208
+ // A wall only ever grows to fit; a sized dome IS its size.
1209
+ if (autoUpTo === undefined || (what === 'wall' && needed <= force))
1210
+ return force;
1211
+ if (needed > autoUpTo) {
1212
+ return `That ${what} needs Force ${needed}, past your talent (${autoUpTo}) -- name the Force to overcast it on purpose ("cast ${spellKey} ${needed} ..."; the drain lands PHYSICAL, p.281).`;
1213
+ }
1214
+ return needed;
1215
+ }
1216
+ /**
1217
+ * A DOME WITH NO FORCE NAMED stands one storey: Force 3, a dome 3 m
1218
+ * high (p.294: height = radius = Force m). Anything lower would not
1219
+ * cover the person it is raised around. Never past your talent -- a
1220
+ * caster with less Magic gets the tallest dome they can hold safely.
1221
+ */
1222
+ domeForce(force, autoUpTo) {
1223
+ if (autoUpTo === undefined)
1224
+ return force;
1225
+ return Math.max(force, Math.min(DOME_FORCE, autoUpTo));
1226
+ }
1227
+ /** LINE OF SIGHT for an area spell's point (p.281): no wall between. */
1228
+ inSight(plane, from, to) {
1229
+ return wallsBetween(plane, from, to) === 0;
1230
+ }
1231
+ /**
1232
+ * CAN A BARRIER CELL STAND HERE. Physical: not in a wall, not in the
1233
+ * void, not in furniture or a body -- matter is already there. Mana:
1234
+ * the astral is "unimpeded by material objects" (p.313), so only earth,
1235
+ * a warded room you are not inside, and another mana barrier stop it.
1236
+ * The one a dome is raised around is inside it, never in its way.
1237
+ */
1238
+ barrierCellOpen(plane, c, kind, center) {
1239
+ if (center && c.x === center.x && c.y === center.y && c.z === center.z)
1240
+ return true;
1241
+ if (isVoid(plane, c))
1242
+ return false;
1243
+ const ownerKey = roomAtCell(plane, c);
1244
+ const owner = ownerKey ? Object.values(this.scene.getRooms()).find(r => r.name.trim().toLowerCase() === ownerKey) : undefined;
1245
+ if (kind === 'mana') {
1246
+ if (owner?.warded && owner !== this.actor.currentLocation)
1247
+ return false;
1248
+ return !this.scene.barrierAt(c, 'mana');
1249
+ }
1250
+ if (isSolid(plane, c))
1251
+ return false;
1252
+ if (this.scene.barrierAt(c, 'physical'))
1253
+ return false;
1254
+ const place = ownerKey ? plane.placements.get(ownerKey) : undefined;
1255
+ if (!owner || !place)
1256
+ return true;
1257
+ const local = { x: c.x - place.origin.x, y: c.y - place.origin.y, z: c.z - place.origin.z };
1258
+ const lk = gridKey(local);
1259
+ if (furnitureCells(owner).has(lk))
1260
+ return false;
1261
+ if (standingOccupants(owner).has(lk))
1262
+ return false;
1263
+ return true;
1264
+ }
1032
1265
  /**
1033
1266
  * The sustained utility pair (invisibility / increase reflexes), same
1034
1267
  * 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 = '';