@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
@@ -0,0 +1,277 @@
1
+ import { METERS_PER_CELL, METERS_PER_LEVEL, key } from './room-grid.js';
2
+ import { Direction } from '../types/shared/direction-enum.js';
3
+ export const FACE_LABEL = {
4
+ wall: 'the wall', n: 'the north face', s: 'the south face', e: 'the east face', w: 'the west face', top: 'the crown',
5
+ };
6
+ /** "3,4" or "3,4,1" (negative allowed: the plane's edges are open). */
7
+ export function parseCoord(token, defaultZ) {
8
+ const m = /^(-?\d+),(-?\d+)(?:,(-?\d+))?$/.exec(token.trim());
9
+ if (!m)
10
+ return undefined;
11
+ return { x: parseInt(m[1], 10), y: parseInt(m[2], 10), z: m[3] !== undefined ? parseInt(m[3], 10) : defaultZ };
12
+ }
13
+ export function isCoordToken(token) {
14
+ return token !== undefined && /^-?\d+,-?\d+(?:,-?\d+)?$/.test(token.trim());
15
+ }
16
+ export const formatCoord = (c) => `${c.x},${c.y},${c.z}`;
17
+ /** Force x 2 metres of wall, in 2 m cells. */
18
+ export function maxWallCells(force) {
19
+ return Math.max(1, Math.floor((force * 2) / METERS_PER_CELL));
20
+ }
21
+ /** Force metres of wall, in whole 3 m levels -- never less than one. */
22
+ export function maxWallLevels(force) {
23
+ return Math.max(1, Math.floor(force / METERS_PER_LEVEL));
24
+ }
25
+ /**
26
+ * THE LEAST FORCE THAT RAISES THIS WALL -- what a cast with no Force
27
+ * named is sized to (user 2026-10-05: "the force is auto calculated if
28
+ * omitted"). Length needs Force cells (Force x 2 m); every level past
29
+ * the first needs another 3 m of height, i.e. Force 3 a level.
30
+ */
31
+ export function forceForWall(a, b) {
32
+ const length = Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)) + 1;
33
+ const levels = Math.abs(a.z - b.z) + 1;
34
+ return Math.max(1, Math.ceil(length * METERS_PER_CELL / 2), levels > 1 ? levels * METERS_PER_LEVEL : 1);
35
+ }
36
+ /** The directional wall is three cells across: Force 3. */
37
+ export const DIRECTIONAL_WALL_FORCE = 3;
38
+ /** A dome with no Force named rises one full 3 m storey: Force 3. */
39
+ export const DOME_FORCE = METERS_PER_LEVEL;
40
+ /**
41
+ * A WALL BETWEEN TWO POINTS, ends included. Straight along an axis only:
42
+ * a diagonal run of cells leaves corner gaps an 8-way walker slips
43
+ * through, so it would not be a wall.
44
+ *
45
+ * Height: the two points' z values give the span (both default to the
46
+ * caster's level, which is one level of wall -- 3 m, the user's "default
47
+ * z to 1 unit up").
48
+ */
49
+ export function planWall(a, b, force) {
50
+ const fail = (error) => ({ shape: 'wall', faces: [], error });
51
+ if (a.x !== b.x && a.y !== b.y) {
52
+ return fail(`A barrier wall runs straight along one axis -- ${a.x},${a.y} to ${b.x},${b.y} is a diagonal. Keep x the same (a north-south wall) or y the same (an east-west one).`);
53
+ }
54
+ const length = Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)) + 1;
55
+ const most = maxWallCells(force);
56
+ if (length > most) {
57
+ return fail(`At Force ${force} the wall runs ${force * 2} m -- ${most} cell${most === 1 ? '' : 's'} (SR5 p.294). ${length} cells needs Force ${Math.ceil(length * METERS_PER_CELL / 2)}.`);
58
+ }
59
+ const zLo = Math.min(a.z, b.z);
60
+ const zHi = Math.max(a.z, b.z);
61
+ const levels = zHi - zLo + 1;
62
+ const tallest = maxWallLevels(force);
63
+ if (levels > tallest) {
64
+ return fail(`At Force ${force} the wall stands ${force} m -- ${tallest} level${tallest === 1 ? '' : 's'} of ${METERS_PER_LEVEL} m (SR5 p.294). ${levels} levels needs Force ${levels * METERS_PER_LEVEL}.`);
65
+ }
66
+ const cells = [];
67
+ const sx = Math.sign(b.x - a.x);
68
+ const sy = Math.sign(b.y - a.y);
69
+ for (let z = zLo; z <= zHi; z++) {
70
+ for (let i = 0; i < length; i++)
71
+ cells.push({ x: a.x + sx * i, y: a.y + sy * i, z });
72
+ }
73
+ return { shape: 'wall', faces: [{ id: 'wall', cells }] };
74
+ }
75
+ const LATERAL = {
76
+ [Direction.NORTH]: [0, -1], [Direction.SOUTH]: [0, 1],
77
+ [Direction.EAST]: [1, 0], [Direction.WEST]: [-1, 0],
78
+ };
79
+ /**
80
+ * "cast barrier east 3": a 1x3 wall square across the way east, three
81
+ * cells out (user 2026-10-05). No distance puts it in the very next cell.
82
+ * Three cells is 6 m, which is Force 3's wall; below that it is as long
83
+ * as the Force allows and the note says so.
84
+ */
85
+ export function planDirectionalWall(from, direction, distance, force) {
86
+ const step = LATERAL[direction];
87
+ if (!step) {
88
+ return { shape: 'wall', faces: [], error: `A barrier stands across a way, not over or under you -- north, south, east or west. For a roof, raise a dome ("cast barrier" around yourself).` };
89
+ }
90
+ if (!Number.isInteger(distance) || distance < 1) {
91
+ return { shape: 'wall', faces: [], error: `The distance is in cells out from you, 1 or more -- "cast barrier ${direction} 3".` };
92
+ }
93
+ const width = Math.min(3, maxWallCells(force));
94
+ const across = [step[1] === 0 ? 0 : 1, step[0] === 0 ? 0 : 1];
95
+ const base = { x: from.x + step[0] * distance, y: from.y + step[1] * distance, z: from.z };
96
+ const offsets = width === 3 ? [-1, 0, 1] : width === 2 ? [0, 1] : [0];
97
+ const cells = offsets.map(o => ({ x: base.x + across[0] * o, y: base.y + across[1] * o, z: base.z }));
98
+ const note = width < 3 ? `At Force ${force} the wall runs only ${force * 2} m -- ${width} cell${width === 1 ? '' : 's'}, not three.` : undefined;
99
+ return { shape: 'wall', faces: [{ id: 'wall', cells }], note };
100
+ }
101
+ /**
102
+ * A DOME OF RADIUS FORCE METRES around a centre cell (p.294: "a dome
103
+ * with a radius and height equal to the spell's normal radius" -- the
104
+ * normal radius of an area spell is Force metres, p.281). Canon dome,
105
+ * not a sphere (user ruling 2026-10-05).
106
+ *
107
+ * Each level k up is a horizontal slice of the hemisphere, radius
108
+ * sqrt(R^2 - (3k)^2). The SHELL on a level is every cell 8-adjacent to
109
+ * that slice's interior and not in it -- which is a 4-connected ring, so
110
+ * the 8-way walk in findPath cannot slip out across a corner. The centre
111
+ * is always interior: the one the dome is raised around is inside it.
112
+ *
113
+ * The highest level of a dome that rises past its first is a filled cap
114
+ * -- the 'top' face. A one-level dome's roof is its height, which the
115
+ * engine cannot draw shorter than a storey; it has no crown to shoot.
116
+ */
117
+ export function planDome(center, force) {
118
+ const R = force;
119
+ const levels = [];
120
+ for (let k = 0; k * METERS_PER_LEVEL < R; k++)
121
+ levels.push(k);
122
+ if (levels.length === 0)
123
+ levels.push(0);
124
+ const faces = { wall: [], n: [], s: [], e: [], w: [], top: [] };
125
+ const reach = Math.ceil(R / METERS_PER_CELL) + 1;
126
+ for (const k of levels) {
127
+ const rk = Math.sqrt(Math.max(0, R * R - (k * METERS_PER_LEVEL) ** 2));
128
+ const inside = (dx, dy) => (dx === 0 && dy === 0) || Math.hypot(dx * METERS_PER_CELL, dy * METERS_PER_CELL) < rk;
129
+ const z = center.z + k;
130
+ const capped = levels.length > 1 && k === levels[levels.length - 1];
131
+ for (let dx = -reach; dx <= reach; dx++) {
132
+ for (let dy = -reach; dy <= reach; dy++) {
133
+ const c = { x: center.x + dx, y: center.y + dy, z };
134
+ if (inside(dx, dy)) {
135
+ if (capped)
136
+ faces.top.push(c);
137
+ continue;
138
+ }
139
+ let rim = false;
140
+ for (let ox = -1; ox <= 1 && !rim; ox++) {
141
+ for (let oy = -1; oy <= 1 && !rim; oy++) {
142
+ if ((ox || oy) && inside(dx + ox, dy + oy))
143
+ rim = true;
144
+ }
145
+ }
146
+ if (!rim)
147
+ continue;
148
+ if (capped) {
149
+ faces.top.push(c);
150
+ continue;
151
+ }
152
+ // North is -y. A corner (|dx| === |dy|) goes to the east/west face.
153
+ const id = Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'e' : 'w') : (dy < 0 ? 'n' : 's');
154
+ faces[id].push(c);
155
+ }
156
+ }
157
+ }
158
+ const order = ['n', 'e', 's', 'w', 'top'];
159
+ return { shape: 'dome', faces: order.filter(id => faces[id].length > 0).map(id => ({ id, cells: faces[id] })) };
160
+ }
161
+ /**
162
+ * WHAT THE BARRIER CANNOT STAND IN. Physical: walls, void, furniture and
163
+ * bodies -- matter is already there. Mana: only earth, a ward's edge, and
164
+ * another mana barrier -- the astral is unimpeded by everything else
165
+ * (p.313). The caller supplies the test; this drops the cells and counts
166
+ * them so the cast can say "the weave stops at the wall".
167
+ */
168
+ export function clipFaces(faces, open) {
169
+ let dropped = 0;
170
+ const kept = faces.map(f => {
171
+ const cells = f.cells.filter(c => {
172
+ const ok = open(c);
173
+ if (!ok)
174
+ dropped++;
175
+ return ok;
176
+ });
177
+ return { id: f.id, cells };
178
+ }).filter(f => f.cells.length > 0);
179
+ return { faces: kept, dropped };
180
+ }
181
+ export function buildBarrier(kind, caster, force, structure, roomName, shape, faces) {
182
+ return {
183
+ kind, caster, force, structure, armor: structure, shape, roomName,
184
+ faces: faces.map(f => ({ id: f.id, cells: f.cells, damage: 0, breached: false })),
185
+ };
186
+ }
187
+ /** The standing face holding this plane cell, if any. */
188
+ export function faceAt(barrier, c) {
189
+ const k = key(c);
190
+ return barrier.faces.find(f => !f.breached && f.cells.some(fc => key(fc) === k));
191
+ }
192
+ export function standingCells(barrier) {
193
+ return barrier.faces.filter(f => !f.breached).flatMap(f => f.cells);
194
+ }
195
+ /**
196
+ * One line for the spells list and the HUD: shape, place, rating, and
197
+ * every face's track -- a face is a testable claim, so it is printed.
198
+ */
199
+ /** Where it stands, in the rulers' numbers: "3,4,0 to 3,6,0". */
200
+ export function barrierSpan(barrier) {
201
+ const cells = barrier.faces.flatMap(f => f.cells);
202
+ if (cells.length === 0)
203
+ return 'nowhere';
204
+ const xs = cells.map(c => c.x), ys = cells.map(c => c.y), zs = cells.map(c => c.z);
205
+ return `${Math.min(...xs)},${Math.min(...ys)},${Math.min(...zs)} to ${Math.max(...xs)},${Math.max(...ys)},${Math.max(...zs)}`;
206
+ }
207
+ export function describeBarrier(barrier) {
208
+ const span = barrierSpan(barrier);
209
+ return `a ${barrier.shape} over ${span} in ${barrier.roomName}, Force ${barrier.force}, ${barrierStats(barrier)}`;
210
+ }
211
+ /**
212
+ * Armor and every face's damage track, in one breath: "Armor 3,
213
+ * Structure 3 [N 0/3, E 1/3, S BREACHED, W 0/3]". The HUD's sustained
214
+ * line and the spells list both print it -- a barrier you hold is a
215
+ * thing being shot at, and the track is what tells you which side gives.
216
+ */
217
+ export function barrierStats(barrier) {
218
+ const tracks = barrier.faces
219
+ .map(f => `${f.id === 'wall' ? 'wall' : f.id.toUpperCase()} ${f.breached ? 'BREACHED' : `${f.damage}/${barrier.structure}`}`)
220
+ .join(', ');
221
+ return `Armor ${barrier.armor}, Structure ${barrier.structure} [${tracks}]`;
222
+ }
223
+ export function allBreached(barrier) {
224
+ return barrier.faces.every(f => f.breached);
225
+ }
226
+ // ---------------------------------------------------------------------------
227
+ // THE DAMAGE TRACK (SR5 p.197-198, p.294, p.316; RAG-checked 2026-10-05)
228
+ // ---------------------------------------------------------------------------
229
+ /**
230
+ * Boxes on one face. Reaching Structure breaches it -- that side of the
231
+ * dome, or the whole wall, stops blocking. Returns whether this blow
232
+ * breached it.
233
+ */
234
+ export function damageFace(barrier, face, boxes) {
235
+ if (face.breached || boxes <= 0)
236
+ return false;
237
+ face.damage += boxes;
238
+ if (face.damage >= barrier.structure) {
239
+ face.breached = true;
240
+ return true;
241
+ }
242
+ return false;
243
+ }
244
+ /**
245
+ * Sustained, it comes back whole: Physical regains all Structure at the
246
+ * START of each Combat Turn (p.294), Mana at the END (p.316). A breached
247
+ * face re-forms with it.
248
+ */
249
+ export function regenerate(barrier) {
250
+ let changed = false;
251
+ for (const f of barrier.faces) {
252
+ if (f.damage > 0 || f.breached)
253
+ changed = true;
254
+ f.damage = 0;
255
+ f.breached = false;
256
+ }
257
+ return changed;
258
+ }
259
+ /**
260
+ * The PENETRATION rule (p.198): a firearm or a pointed blade does the
261
+ * barrier 1 box, and the rest goes on to whoever is behind it -- 2 boxes
262
+ * for 3 rounds, 3 for 6, 4 for 10.
263
+ */
264
+ export function penetrationBoxes(rounds) {
265
+ return rounds >= 10 ? 4 : rounds >= 6 ? 3 : rounds >= 3 ? 2 : 1;
266
+ }
267
+ /**
268
+ * Does the blow get through the barrier at all? p.197: "If the weapon's
269
+ * modified Damage Value does not exceed the barrier's Armor rating
270
+ * (modified by the weapon's AP) ... the attack automatically fails."
271
+ * The text, not the p.198 example that lets an equal DV through (user
272
+ * ruling 2026-10-05).
273
+ */
274
+ export function pierces(dv, armor, ap) {
275
+ return dv > Math.max(0, armor + ap);
276
+ }
277
+ //# sourceMappingURL=barrier-geometry.js.map
@@ -1,3 +1,4 @@
1
+ import { allBreached, damageFace, penetrationBoxes, pierces } from './barrier-geometry.js';
1
2
  import { recoilCompensation, recoilPenalty } from './recoil.js';
2
3
  import { Category } from '../types/shared/item-enum.js';
3
4
  import { crashIC } from './ic-actors.js';
@@ -716,7 +717,37 @@ export class CombatExchange {
716
717
  world.push(`${header} -- ${defender.name} evades.`);
717
718
  return { world: [...world, ...dryWarning], meta };
718
719
  }
719
- const incoming = strike.damageBase + netHits;
720
+ let incoming = strike.damageBase + netHits;
721
+ // SHOOTING THROUGH A BARRIER SPELL (p.197-198): a round fired at
722
+ // someone behind a standing Physical Barrier meets the barrier first.
723
+ // A DV that does not EXCEED its AP-modified Armor stops dead (p.197's
724
+ // text); one that does is a penetration weapon's: the barrier takes
725
+ // 1 box and the target takes the rest. It is translucent (p.294,
726
+ // Light Fog), so there is no cover in it -- only the wall to get past.
727
+ if (strike.weaponItem?.isFirearm()) {
728
+ const from = this.scene.worldCellOf?.(attacker);
729
+ const to = this.scene.worldCellOf?.(defender);
730
+ const wall = from && to ? this.scene.barrierOnLine?.(from, to, 'physical') : undefined;
731
+ if (wall) {
732
+ const ap = attacker.getWeaponProfile().armorPen ?? 0;
733
+ const armor = Math.max(0, wall.barrier.armor + ap);
734
+ if (!pierces(incoming, wall.barrier.armor, ap)) {
735
+ meta.push(` Barrier: DV ${incoming} against ${wall.barrier.caster}'s barrier, Armor ${armor} -- stopped dead (p.197)`);
736
+ world.push(`${header} -- the round flattens against ${wall.barrier.caster}'s shimmer-wall and never reaches ${defender.name}.`);
737
+ return { world: [...world, ...dryWarning], meta };
738
+ }
739
+ const boxes = penetrationBoxes(1);
740
+ const fell = damageFace(wall.barrier, wall.face, boxes);
741
+ incoming -= boxes;
742
+ meta.push(` Barrier: through ${wall.barrier.caster}'s barrier (Armor ${armor}) -- it takes ${boxes} box (${wall.face.breached ? 'BREACHED' : `${wall.face.damage}/${wall.barrier.structure}`}), DV ${incoming} goes on (p.198)`);
743
+ if (fell && allBreached(wall.barrier)) {
744
+ const caster = this.scene.getPlayers().find(p => p.name === wall.barrier.caster);
745
+ if (caster)
746
+ caster.sustainedBarrier = undefined;
747
+ world.push(` ${wall.barrier.caster}'s barrier gives way entirely -- the spell is over (p.294).`);
748
+ }
749
+ }
750
+ }
720
751
  // p.168 (RAG-checked 2026-09-03): a Physical hit whose modified DV is
721
752
  // LESS THAN the modified Armor rating turns to Stun -- the plate holds
722
753
  // and the round still knocks the wind out of you. Strictly less-than.
@@ -1,3 +1,4 @@
1
+ import { regenerate } from './barrier-geometry.js';
1
2
  import { rollInitiativeScore, formatInitiative, INITIATIVE_PASS_DROP, rollPool, formatRoll } from './dice.js';
2
3
  import { ActionBudget } from './action-budget.js';
3
4
  import { runNpcActionPhase } from './npc-combat-brain.js';
@@ -772,8 +773,13 @@ export class CombatEncounter {
772
773
  await this.end('burnout');
773
774
  return;
774
775
  }
776
+ // BARRIER SPELLS COME BACK WHOLE while sustained: a Mana Barrier at
777
+ // the END of every Combat Turn (p.316), a Physical Barrier at the
778
+ // START of the next (p.294) -- one boundary, two sides of it.
779
+ this.regenerateBarriers('mana');
775
780
  this.turn += 1;
776
781
  this.pass = 1;
782
+ this.regenerateBarriers('physical');
777
783
  // The host launches at the START of the turn (p.247), before the
778
784
  // roll, so a fresh program rolls initiative with everyone else.
779
785
  await this.onNewTurn?.(this);
@@ -803,6 +809,25 @@ export class CombatEncounter {
803
809
  }
804
810
  await this.announce([`Combat Turn ${this.turn} -- ${this.orderLine()}.`], metaLines);
805
811
  }
812
+ /** The barriers this fight can see: cast from its room, or held by
813
+ * someone in it. A fight across town does not mend this one's wall. */
814
+ regenerateBarriers(kind) {
815
+ const here = this.room?.name;
816
+ const names = new Set(this.participants.map(p => p.actor.name));
817
+ let mended = false;
818
+ for (const barrier of this.scene.spellBarriers?.(kind) ?? []) {
819
+ if (barrier.roomName !== here && !names.has(barrier.caster))
820
+ continue;
821
+ if (!regenerate(barrier))
822
+ continue;
823
+ mended = true;
824
+ this.logger.log(`{cyan-fg}${barrier.caster}'s ${kind === 'mana' ? 'mana barrier' : 'barrier'} knits itself whole again -- Structure ${barrier.structure} (${kind === 'mana' ? 'p.316' : 'p.294'}).{/cyan-fg}`, { room: barrier.roomName });
825
+ }
826
+ if (mended) {
827
+ this.scene.updateStatus?.();
828
+ this.scene.updateExits?.(this.room);
829
+ }
830
+ }
806
831
  // ------------------------------------------------- score adjustments ----
807
832
  /**
808
833
  * p.160: wound modifiers hit the Initiative Score the moment the wound
@@ -113,7 +113,7 @@ function roomGridFor(player) {
113
113
  if (!room || player.plane === 'matrix')
114
114
  return undefined;
115
115
  try {
116
- return roomGridWire(room);
116
+ return roomGridWire(room, player);
117
117
  }
118
118
  catch {
119
119
  return undefined;
@@ -129,6 +129,17 @@ export const FLOOR_GLYPH = '·';
129
129
  * stack of crates. The room panel draws cover as the same shade one
130
130
  * zoom in (room-view.ts), so the two agree. */
131
131
  export const TERRAIN_GLYPH = '▒';
132
+ /**
133
+ * A BARRIER SPELL'S CELL (barrier-geometry.ts, 2026-10-05). Plain ASCII
134
+ * on purpose -- one column on every terminal, no ambiguous-width risk --
135
+ * and coloured by kind: cyan for a Physical Barrier's heat-shimmer,
136
+ * magenta for a Mana Barrier, because magenta is the astral's colour on
137
+ * this map (overlay-fill.ts) and a mana barrier is only drawn for eyes
138
+ * that can see it.
139
+ */
140
+ export const BARRIER_GLYPH = '#';
141
+ export const BARRIER_COLOR = 'cyan';
142
+ export const MANA_BARRIER_COLOR = 'magenta';
132
143
  export const VOID_GLYPH = ' ';
133
144
  /**
134
145
  * SOMEONE OUT THAT WAY -- a body inside the 100 m band but outside the
@@ -234,6 +245,10 @@ export const GLYPH_RANK = {
234
245
  * only the way up TO the camera, and the camera is what the cell is
235
246
  * about -- and below every body. */
236
247
  camera: 72,
248
+ /** A barrier spell standing in the cell: above a doorway it seals and
249
+ * the floor it walls off, below every body (a dome's centre holds
250
+ * whoever it was raised around). */
251
+ barrier: 75,
237
252
  vertical: 70,
238
253
  exit: 65,
239
254
  /**
@@ -0,0 +1,58 @@
1
+ /**
2
+ * AXIS RULERS IN DISTRICT-PLANE COORDINATES (user 2026-10-05: "the map
3
+ * may need to be adjusted to show x,y numbers on the axes" -- so that
4
+ * "cast barrier 3 3,4 3,6" can be read straight off the screen).
5
+ *
6
+ * ONE COORDINATE SYSTEM EVERYWHERE (user ruling 2026-10-05): the numbers
7
+ * are plane cells -- a room's origin plus its local cell -- on the room
8
+ * panel, the full `map`, and the district viewport alike, so a wall
9
+ * drawn across a doorway reads the same from either side of it.
10
+ *
11
+ * Labels live in the MARGINS and are plain ASCII digits: one map dot is
12
+ * exactly one terminal column (map-view.ts:65-94), so a number written
13
+ * over the cells would shear the row. Dim, because a ruler is reference,
14
+ * not news.
15
+ */
16
+ export const RULER_COLOR = 'gray';
17
+ const dim = (s) => (s.trim().length === 0 ? s : `{${RULER_COLOR}-fg}${s}{/${RULER_COLOR}-fg}`);
18
+ /**
19
+ * The x ruler over a run of columns. Column i covers plane x from
20
+ * `start + i*scale` to `start + (i+1)*scale - 1`; a column is labelled
21
+ * with the multiple of `every * scale` it contains, when the label fits
22
+ * without running into the previous one.
23
+ */
24
+ export function xRulerLine(start, columns, scale = 1, every = 5) {
25
+ const step = every * scale;
26
+ const out = new Array(columns).fill(' ');
27
+ let free = 0;
28
+ for (let i = 0; i < columns; i++) {
29
+ const lo = start + i * scale;
30
+ const hi = lo + scale - 1;
31
+ const mult = Math.ceil(lo / step) * step;
32
+ if (mult > hi || i < free)
33
+ continue;
34
+ const label = String(mult);
35
+ if (i + label.length > columns)
36
+ break;
37
+ for (let j = 0; j < label.length; j++)
38
+ out[i + j] = label[j];
39
+ free = i + label.length + 1;
40
+ }
41
+ return dim(out.join('').trimEnd());
42
+ }
43
+ /** The y label for a row (plane y of its first cell), right-aligned. */
44
+ export function yRulerLabel(y, width) {
45
+ return dim(String(y).padStart(width));
46
+ }
47
+ /** How wide the y gutter must be to hold every row label. */
48
+ export function yGutterWidth(start, rows, scale = 1) {
49
+ let w = 1;
50
+ for (let i = 0; i < rows; i++)
51
+ w = Math.max(w, String(start + i * scale).length);
52
+ return w;
53
+ }
54
+ /** "@ 12,7,0" -- where you are, in the rulers' numbers. */
55
+ export function whereLabel(c) {
56
+ return c ? dim(`@ ${c.x},${c.y},${c.z}`) : '';
57
+ }
58
+ //# sourceMappingURL=map-rulers.js.map
@@ -1,7 +1,8 @@
1
1
  import { capitalCase } from 'change-case';
2
2
  import { Door } from '../models/door.js';
3
3
  import { Direction } from '../types/shared/direction-enum.js';
4
- import { FRONTIER_GLYPH, BLOCKED_GLYPH, UP_GLYPH, DOWN_GLYPH, PLAYER_GLYPH, WALL_GLYPH, DOOR_GLYPH, FLOOR_GLYPH, VOID_GLYPH, TERRAIN_GLYPH, OUTSIDE_GROUND_COLOR, GLYPH_RANK, OFF_EDGE_GLYPH, } from './map-glyphs.js';
4
+ import { FRONTIER_GLYPH, BLOCKED_GLYPH, UP_GLYPH, DOWN_GLYPH, PLAYER_GLYPH, WALL_GLYPH, DOOR_GLYPH, FLOOR_GLYPH, VOID_GLYPH, TERRAIN_GLYPH, OUTSIDE_GROUND_COLOR, GLYPH_RANK, OFF_EDGE_GLYPH, BARRIER_GLYPH, BARRIER_COLOR, MANA_BARRIER_COLOR, } from './map-glyphs.js';
5
+ import { xRulerLine, yRulerLabel } from './map-rulers.js';
5
6
  import { roomAtCell, isVoid, wallsBetween, districtBearing, isOpenAir } from './district-plane.js';
6
7
  import { distanceMeters, METERS_PER_LEVEL } from './room-grid.js';
7
8
  import { isOutdoorsPlace } from './outdoors.js';
@@ -713,8 +714,13 @@ function renderPlaneViewport(plane, rooms, here, eyes, viewW, viewH) {
713
714
  */
714
715
  const MARGIN = 2;
715
716
  const cam = eyes?.at;
716
- const wantW = Math.max(1, viewW);
717
- const wantH = Math.max(1, viewH);
717
+ // The rulers come out of the panel, not on top of it: a fixed gutter
718
+ // (three characters -- the plane is 60 rows deep -- and a space) and
719
+ // one row for the x labels.
720
+ const RULER_GUTTER = 3;
721
+ const rulers = eyes?.rulers === true;
722
+ const wantW = Math.max(1, viewW - (rulers ? RULER_GUTTER + 1 : 0));
723
+ const wantH = Math.max(1, viewH - (rulers ? 1 : 0));
718
724
  /**
719
725
  * ONE CELL, ONE DOT -- 2 m, the same as the room panel.
720
726
  *
@@ -1068,6 +1074,21 @@ function renderPlaneViewport(plane, rooms, here, eyes, viewW, viewH) {
1068
1074
  labels.set(at2, ch);
1069
1075
  });
1070
1076
  }
1077
+ // BARRIER SPELLS (barrier-geometry.ts): a Physical Barrier for every
1078
+ // eye, a Mana Barrier only for its weaver or an eye on the astral.
1079
+ const spellCells = new Map();
1080
+ const seer = eyes?.viewer;
1081
+ const seesAstral = !!seer && (seer.plane === 'astral' || seer.astralPerceiving);
1082
+ for (const b of here.scene?.spellBarriers?.() ?? []) {
1083
+ if (b.kind === 'mana' && !seesAstral && b.caster !== seer?.name)
1084
+ continue;
1085
+ for (const f of b.faces) {
1086
+ if (f.breached)
1087
+ continue;
1088
+ for (const c of f.cells)
1089
+ spellCells.set(`${c.x},${c.y},${c.z}`, b.kind);
1090
+ }
1091
+ }
1071
1092
  const out = [];
1072
1093
  let openStyle = '';
1073
1094
  for (let ry = 0; ry < rows; ry++) {
@@ -1195,6 +1216,9 @@ function renderPlaneViewport(plane, rooms, here, eyes, viewW, viewH) {
1195
1216
  * not internal detail. The floorplan is what the Room panel
1196
1217
  * is for, one zoom in.
1197
1218
  */
1219
+ const spell = spellCells.get(key);
1220
+ if (spell)
1221
+ take(BARRIER_GLYPH, GLYPH_RANK.barrier, spell === 'mana' ? MANA_BARRIER_COLOR : BARRIER_COLOR);
1198
1222
  const owner = roomAtCell(plane, { x, y, z });
1199
1223
  const indoors = owner !== undefined && !plane.placements.get(owner).outdoors;
1200
1224
  const ring = plane.walls.has(key) || (zoomedOut && plane.doorways.has(key) && wallOwner.has(key));
@@ -1300,8 +1324,10 @@ function renderPlaneViewport(plane, rooms, here, eyes, viewW, viewH) {
1300
1324
  openStyle = '';
1301
1325
  // NOT trimmed: a filled row is the point, and trailing ground is
1302
1326
  // still ground.
1303
- out.push(line);
1327
+ out.push(rulers ? `${yRulerLabel(y0 + Math.floor((ry * spanY) / rows), RULER_GUTTER)} ${line}` : line);
1304
1328
  }
1329
+ if (rulers)
1330
+ out.unshift(`${' '.repeat(RULER_GUTTER + 1)}${xRulerLine(x0, cols, cellsPerDot)}`);
1305
1331
  return out;
1306
1332
  }
1307
1333
  /**
@@ -248,8 +248,23 @@ function cellExists(grid, c, levelCells) {
248
248
  export function cellOnGrid(grid, c) {
249
249
  return cellExists(grid, c, LEVEL_CELLS.get(grid) ?? new Map());
250
250
  }
251
- /** Whether `c` sits inside an uncleared BLOCKING terrain region. */
251
+ /**
252
+ * SOMETHING ELSE THAT SEALS A CELL, asked live: a sustained Physical
253
+ * Barrier (Scene.physicalBarrierAtLocal, registered by Room.ensureGrid).
254
+ * A spell comes and goes many times over a grid's life, so it is a
255
+ * question the grid asks rather than terrain baked into it -- and a
256
+ * rebuilt grid cannot lose a wall that was never stored on it. Keyed by
257
+ * grid identity like LEVEL_CELLS: per room, never module-wide (several
258
+ * scenes share this process on the site).
259
+ */
260
+ const CELL_BLOCKERS = new WeakMap();
261
+ export function setCellBlocker(grid, blocked) {
262
+ CELL_BLOCKERS.set(grid, blocked);
263
+ }
264
+ /** Whether `c` sits inside an uncleared BLOCKING terrain region, or under a standing barrier spell. */
252
265
  function isBlocked(grid, c) {
266
+ if (CELL_BLOCKERS.get(grid)?.(c))
267
+ return true;
253
268
  const k = key(c);
254
269
  for (const region of grid.terrain.values()) {
255
270
  if (!region.blocking || region.cleared)
@@ -482,6 +497,9 @@ export function pathClearing(grid, from, to, region) {
482
497
  const trial = { ...grid, terrain: new Map(grid.terrain) };
483
498
  trial.terrain.set(region.id, { ...region, cleared: true });
484
499
  LEVEL_CELLS.set(trial, LEVEL_CELLS.get(grid) ?? new Map());
500
+ const blocker = CELL_BLOCKERS.get(grid);
501
+ if (blocker)
502
+ CELL_BLOCKERS.set(trial, blocker);
485
503
  return findPath(trial, from, to);
486
504
  }
487
505
  /**
@@ -503,6 +521,9 @@ export function pathWithRegionsCleared(grid, from, to, regionIds) {
503
521
  trial.terrain.set(id, { ...region, cleared: true });
504
522
  }
505
523
  LEVEL_CELLS.set(trial, LEVEL_CELLS.get(grid) ?? new Map());
524
+ const blocker = CELL_BLOCKERS.get(grid);
525
+ if (blocker)
526
+ CELL_BLOCKERS.set(trial, blocker);
506
527
  return findPath(trial, from, to);
507
528
  }
508
529
  /** SR5 p.172 (RAG-verified): DV = meters fallen, only beyond 3m; AP-4