@maka/maka-cli 5.382.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 (38) 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/command-registry.js +9 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +10 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +15 -7
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/follow.js +1 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +25 -9
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +3 -3
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +10 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +9 -3
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +4 -4
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +7 -6
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +8 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +37 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/game.js +12 -4
  17. package/bundle/typescript/src/commands/game/sideQuest/headless.js +3 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +3 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +12 -6
  20. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +4 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +87 -18
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +4 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +102 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-geometry.js +253 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +32 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +25 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +26 -4
  29. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-glyphs.js +15 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-rulers.js +58 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +30 -4
  32. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-authorization.js +27 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +38 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +22 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +51 -11
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +24 -2
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/ways-out.js +34 -2
  38. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { stillWatching } from './surveillance.js';
2
2
  import { rollPool, formatRoll } from './dice.js';
3
3
  import { applyDeviceOpened } from './devices.js';
4
+ import { FACE_LABEL, allBreached, damageFace, penetrationBoxes, pierces } from './barrier-geometry.js';
4
5
  export function resolveBarrierStrike(scene, logger, attacker, device, room, opts = {}) {
5
6
  const world = [];
6
7
  const meta = [];
@@ -148,4 +149,105 @@ export function resolveCameraStrike(scene, logger, attacker, camera, room, opts
148
149
  return result;
149
150
  }
150
151
  }
152
+ /**
153
+ * HITTING A BARRIER SPELL (SR5 p.197-198 with p.294 / p.316; user
154
+ * rulings 2026-10-05). Same unopposed shape as a door, against ONE FACE:
155
+ *
156
+ * - The attack test is unopposed; its hits add to the DV.
157
+ * - p.197's text, not p.198's example: a DV that does not EXCEED the
158
+ * AP-modified Armor does nothing at all.
159
+ * - A gun is a PENETRATION weapon (p.198): the barrier takes 1 box,
160
+ * unresisted, and the rest of the shot is spent on the far side.
161
+ * It takes a lot of bullets to bring down a wall, which is the point.
162
+ * - Anything else resists with Structure + Armor; what gets through
163
+ * fills the face's track. Barriers ignore Stun.
164
+ * - A face whose track reaches Structure is breached: it stops
165
+ * blocking. Every face breached and the spell is over (p.294).
166
+ * - A mana barrier's caster knows at once it is under attack (p.316).
167
+ *
168
+ * The caller has already chosen the cell (and so the face) and checked
169
+ * the plane: a Physical Barrier is hit from the meat, a Mana Barrier
170
+ * only from the astral -- bullets go through a weave as if it were not
171
+ * there, because to them it is not.
172
+ */
173
+ export function resolveSpellBarrierStrike(scene, logger, attacker, barrier, face, room, opts = {}) {
174
+ const world = [];
175
+ const meta = [];
176
+ const done = (opened = false, collapsed = false) => ({ world, meta, opened, collapsed });
177
+ const what = `${barrier.caster}'s ${barrier.kind === 'mana' ? 'mana barrier' : 'barrier'} (${FACE_LABEL[face.id]})`;
178
+ const weaponItem = attacker.weaponDrawn ? attacker.getCarriedWeapon() : null;
179
+ const profile = attacker.getWeaponProfile();
180
+ if (weaponItem?.isFirearm() && (weaponItem.ammo <= 0 || weaponItem.jammed)) {
181
+ world.push(`${weaponItem.name} is ${weaponItem.jammed ? 'locked up' : 'dry'} -- nothing to put into ${what}.`);
182
+ return done();
183
+ }
184
+ const stun = weaponItem ? weaponItem.isStunWeapon() : !(attacker.hasAug('bone-lacing') || attacker.adept || attacker.hasKillingHands());
185
+ if (stun && attacker.plane === 'meat') {
186
+ world.push(`${weaponItem ? weaponItem.name : 'Your fists'} deal${weaponItem ? 's' : ''} Stun, and ${what} has nothing to stun -- barriers ignore it (p.198).`);
187
+ return done();
188
+ }
189
+ const firearm = attacker.plane === 'meat' && !!weaponItem?.isFirearm();
190
+ if (firearm)
191
+ weaponItem.fireRound();
192
+ attacker.sneaking = false;
193
+ const roll = rollPool(attacker.getAttackPool(), attacker.getAttackLimit(weaponItem));
194
+ meta.push(`Attack on ${what} (unopposed, p.197): ${formatRoll(roll)}`);
195
+ if (roll.criticalGlitch) {
196
+ world.push(`${attacker.name} goes for ${what} -- and it goes catastrophically wrong.`);
197
+ return noise(done());
198
+ }
199
+ const dv = profile.damage + roll.hits;
200
+ const ap = profile.armorPen ?? 0;
201
+ const modifiedArmor = Math.max(0, barrier.armor + ap);
202
+ const how = profile.name === 'bare fists' ? 'with bare fists' : `with ${profile.name}`;
203
+ meta.push(` DV ${profile.damage} + ${roll.hits} hit${roll.hits === 1 ? '' : 's'} = ${dv} v. Armor ${barrier.armor}${ap !== 0 ? ` (AP ${ap} -> ${modifiedArmor})` : ''}`);
204
+ if (!pierces(dv, barrier.armor, ap)) {
205
+ world.push(`${attacker.name} hits ${what} ${how} -- the shimmer swallows it. Too weak to break through (DV ${dv} against Armor ${modifiedArmor}, p.197).`);
206
+ return noise(done());
207
+ }
208
+ let dealt;
209
+ if (firearm) {
210
+ dealt = penetrationBoxes(1);
211
+ meta.push(` Penetration weapon (p.198): ${dealt} box to the barrier, unresisted -- the rest of the round is spent beyond it`);
212
+ }
213
+ else {
214
+ const soak = rollPool(barrier.structure + modifiedArmor);
215
+ dealt = Math.max(0, dv - soak.hits);
216
+ meta.push(` Resist: Structure ${barrier.structure} + Armor ${modifiedArmor}: ${formatRoll(soak)} -- ${dealt} box${dealt === 1 ? '' : 'es'} through`);
217
+ }
218
+ const breached = damageFace(barrier, face, dealt);
219
+ logger.write(`${attacker.name} dealt ${dealt} boxes to ${what} (${face.damage}/${barrier.structure}${face.breached ? ', breached' : ''}).`);
220
+ if (barrier.kind === 'mana') {
221
+ const caster = scene.getPlayers().find(p => p.name === barrier.caster);
222
+ if (caster && caster !== attacker)
223
+ logger.log(`Something is hammering your mana barrier in ${barrier.roomName} -- ${FACE_LABEL[face.id]}, ${face.breached ? 'breached' : `${face.damage}/${barrier.structure}`} (p.316).`, { actor: caster.name });
224
+ }
225
+ if (!breached) {
226
+ world.push(`${attacker.name} hits ${what} ${how} -- ${dealt > 0 ? `${dealt} box${dealt === 1 ? '' : 'es'}` : 'it holds, unmarked'}. (${face.damage}/${barrier.structure})`);
227
+ return noise(done());
228
+ }
229
+ world.push(`${attacker.name} breaks ${what} ${how} -- that face ${barrier.kind === 'mana' ? 'frays to nothing' : 'shatters like heat-haze'}, and the way through it is open.`);
230
+ if (allBreached(barrier)) {
231
+ const caster = scene.getPlayers().find(p => p.name === barrier.caster);
232
+ if (caster) {
233
+ if (barrier.kind === 'mana')
234
+ caster.sustainedManaBarrier = undefined;
235
+ else
236
+ caster.sustainedBarrier = undefined;
237
+ }
238
+ world.push(` The whole ${barrier.kind === 'mana' ? 'weave' : 'barrier'} collapses -- ${barrier.caster}'s spell is over (p.294).`);
239
+ scene.addWorldEvent(`${attacker.name} brought down ${barrier.caster}'s ${barrier.kind === 'mana' ? 'mana barrier' : 'force barrier'} in ${room.name}.`);
240
+ scene.updateStatus();
241
+ return noise(done(true, true));
242
+ }
243
+ scene.updateStatus();
244
+ return noise(done(true));
245
+ function noise(result) {
246
+ if (firearm) {
247
+ scene.addWorldEvent(`${attacker.name} opened fire on a force barrier in ${room.name} -- the shots carried.`);
248
+ opts.heat?.(1, 'shot at a force barrier');
249
+ }
250
+ return result;
251
+ }
252
+ }
151
253
  //# sourceMappingURL=barrier-combat.js.map
@@ -0,0 +1,253 @@
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
+ * A WALL BETWEEN TWO POINTS, ends included. Straight along an axis only:
27
+ * a diagonal run of cells leaves corner gaps an 8-way walker slips
28
+ * through, so it would not be a wall.
29
+ *
30
+ * Height: the two points' z values give the span (both default to the
31
+ * caster's level, which is one level of wall -- 3 m, the user's "default
32
+ * z to 1 unit up").
33
+ */
34
+ export function planWall(a, b, force) {
35
+ const fail = (error) => ({ shape: 'wall', faces: [], error });
36
+ if (a.x !== b.x && a.y !== b.y) {
37
+ 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).`);
38
+ }
39
+ const length = Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)) + 1;
40
+ const most = maxWallCells(force);
41
+ if (length > most) {
42
+ 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)}.`);
43
+ }
44
+ const zLo = Math.min(a.z, b.z);
45
+ const zHi = Math.max(a.z, b.z);
46
+ const levels = zHi - zLo + 1;
47
+ const tallest = maxWallLevels(force);
48
+ if (levels > tallest) {
49
+ 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}.`);
50
+ }
51
+ const cells = [];
52
+ const sx = Math.sign(b.x - a.x);
53
+ const sy = Math.sign(b.y - a.y);
54
+ for (let z = zLo; z <= zHi; z++) {
55
+ for (let i = 0; i < length; i++)
56
+ cells.push({ x: a.x + sx * i, y: a.y + sy * i, z });
57
+ }
58
+ return { shape: 'wall', faces: [{ id: 'wall', cells }] };
59
+ }
60
+ const LATERAL = {
61
+ [Direction.NORTH]: [0, -1], [Direction.SOUTH]: [0, 1],
62
+ [Direction.EAST]: [1, 0], [Direction.WEST]: [-1, 0],
63
+ };
64
+ /**
65
+ * "cast barrier east 3": a 1x3 wall square across the way east, three
66
+ * cells out (user 2026-10-05). No distance puts it in the very next cell.
67
+ * Three cells is 6 m, which is Force 3's wall; below that it is as long
68
+ * as the Force allows and the note says so.
69
+ */
70
+ export function planDirectionalWall(from, direction, distance, force) {
71
+ const step = LATERAL[direction];
72
+ if (!step) {
73
+ 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).` };
74
+ }
75
+ if (!Number.isInteger(distance) || distance < 1) {
76
+ return { shape: 'wall', faces: [], error: `The distance is in cells out from you, 1 or more -- "cast barrier ${direction} 3".` };
77
+ }
78
+ const width = Math.min(3, maxWallCells(force));
79
+ const across = [step[1] === 0 ? 0 : 1, step[0] === 0 ? 0 : 1];
80
+ const base = { x: from.x + step[0] * distance, y: from.y + step[1] * distance, z: from.z };
81
+ const offsets = width === 3 ? [-1, 0, 1] : width === 2 ? [0, 1] : [0];
82
+ const cells = offsets.map(o => ({ x: base.x + across[0] * o, y: base.y + across[1] * o, z: base.z }));
83
+ const note = width < 3 ? `At Force ${force} the wall runs only ${force * 2} m -- ${width} cell${width === 1 ? '' : 's'}, not three.` : undefined;
84
+ return { shape: 'wall', faces: [{ id: 'wall', cells }], note };
85
+ }
86
+ /**
87
+ * A DOME OF RADIUS FORCE METRES around a centre cell (p.294: "a dome
88
+ * with a radius and height equal to the spell's normal radius" -- the
89
+ * normal radius of an area spell is Force metres, p.281). Canon dome,
90
+ * not a sphere (user ruling 2026-10-05).
91
+ *
92
+ * Each level k up is a horizontal slice of the hemisphere, radius
93
+ * sqrt(R^2 - (3k)^2). The SHELL on a level is every cell 8-adjacent to
94
+ * that slice's interior and not in it -- which is a 4-connected ring, so
95
+ * the 8-way walk in findPath cannot slip out across a corner. The centre
96
+ * is always interior: the one the dome is raised around is inside it.
97
+ *
98
+ * The highest level of a dome that rises past its first is a filled cap
99
+ * -- the 'top' face. A one-level dome's roof is its height, which the
100
+ * engine cannot draw shorter than a storey; it has no crown to shoot.
101
+ */
102
+ export function planDome(center, force) {
103
+ const R = force;
104
+ const levels = [];
105
+ for (let k = 0; k * METERS_PER_LEVEL < R; k++)
106
+ levels.push(k);
107
+ if (levels.length === 0)
108
+ levels.push(0);
109
+ const faces = { wall: [], n: [], s: [], e: [], w: [], top: [] };
110
+ const reach = Math.ceil(R / METERS_PER_CELL) + 1;
111
+ for (const k of levels) {
112
+ const rk = Math.sqrt(Math.max(0, R * R - (k * METERS_PER_LEVEL) ** 2));
113
+ const inside = (dx, dy) => (dx === 0 && dy === 0) || Math.hypot(dx * METERS_PER_CELL, dy * METERS_PER_CELL) < rk;
114
+ const z = center.z + k;
115
+ const capped = levels.length > 1 && k === levels[levels.length - 1];
116
+ for (let dx = -reach; dx <= reach; dx++) {
117
+ for (let dy = -reach; dy <= reach; dy++) {
118
+ const c = { x: center.x + dx, y: center.y + dy, z };
119
+ if (inside(dx, dy)) {
120
+ if (capped)
121
+ faces.top.push(c);
122
+ continue;
123
+ }
124
+ let rim = false;
125
+ for (let ox = -1; ox <= 1 && !rim; ox++) {
126
+ for (let oy = -1; oy <= 1 && !rim; oy++) {
127
+ if ((ox || oy) && inside(dx + ox, dy + oy))
128
+ rim = true;
129
+ }
130
+ }
131
+ if (!rim)
132
+ continue;
133
+ if (capped) {
134
+ faces.top.push(c);
135
+ continue;
136
+ }
137
+ // North is -y. A corner (|dx| === |dy|) goes to the east/west face.
138
+ const id = Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'e' : 'w') : (dy < 0 ? 'n' : 's');
139
+ faces[id].push(c);
140
+ }
141
+ }
142
+ }
143
+ const order = ['n', 'e', 's', 'w', 'top'];
144
+ return { shape: 'dome', faces: order.filter(id => faces[id].length > 0).map(id => ({ id, cells: faces[id] })) };
145
+ }
146
+ /**
147
+ * WHAT THE BARRIER CANNOT STAND IN. Physical: walls, void, furniture and
148
+ * bodies -- matter is already there. Mana: only earth, a ward's edge, and
149
+ * another mana barrier -- the astral is unimpeded by everything else
150
+ * (p.313). The caller supplies the test; this drops the cells and counts
151
+ * them so the cast can say "the weave stops at the wall".
152
+ */
153
+ export function clipFaces(faces, open) {
154
+ let dropped = 0;
155
+ const kept = faces.map(f => {
156
+ const cells = f.cells.filter(c => {
157
+ const ok = open(c);
158
+ if (!ok)
159
+ dropped++;
160
+ return ok;
161
+ });
162
+ return { id: f.id, cells };
163
+ }).filter(f => f.cells.length > 0);
164
+ return { faces: kept, dropped };
165
+ }
166
+ export function buildBarrier(kind, caster, force, structure, roomName, shape, faces) {
167
+ return {
168
+ kind, caster, force, structure, armor: structure, shape, roomName,
169
+ faces: faces.map(f => ({ id: f.id, cells: f.cells, damage: 0, breached: false })),
170
+ };
171
+ }
172
+ /** The standing face holding this plane cell, if any. */
173
+ export function faceAt(barrier, c) {
174
+ const k = key(c);
175
+ return barrier.faces.find(f => !f.breached && f.cells.some(fc => key(fc) === k));
176
+ }
177
+ export function standingCells(barrier) {
178
+ return barrier.faces.filter(f => !f.breached).flatMap(f => f.cells);
179
+ }
180
+ /**
181
+ * One line for the spells list and the HUD: shape, place, rating, and
182
+ * every face's track -- a face is a testable claim, so it is printed.
183
+ */
184
+ /** Where it stands, in the rulers' numbers: "3,4,0 to 3,6,0". */
185
+ export function barrierSpan(barrier) {
186
+ const cells = barrier.faces.flatMap(f => f.cells);
187
+ if (cells.length === 0)
188
+ return 'nowhere';
189
+ const xs = cells.map(c => c.x), ys = cells.map(c => c.y), zs = cells.map(c => c.z);
190
+ return `${Math.min(...xs)},${Math.min(...ys)},${Math.min(...zs)} to ${Math.max(...xs)},${Math.max(...ys)},${Math.max(...zs)}`;
191
+ }
192
+ export function describeBarrier(barrier) {
193
+ const span = barrierSpan(barrier);
194
+ const tracks = barrier.faces
195
+ .map(f => `${f.id === 'wall' ? 'wall' : f.id.toUpperCase()} ${f.breached ? 'BREACHED' : `${f.damage}/${barrier.structure}`}`)
196
+ .join(', ');
197
+ return `a ${barrier.shape} over ${span} in ${barrier.roomName}, Force ${barrier.force}, Armor/Structure ${barrier.structure} [${tracks}]`;
198
+ }
199
+ export function allBreached(barrier) {
200
+ return barrier.faces.every(f => f.breached);
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // THE DAMAGE TRACK (SR5 p.197-198, p.294, p.316; RAG-checked 2026-10-05)
204
+ // ---------------------------------------------------------------------------
205
+ /**
206
+ * Boxes on one face. Reaching Structure breaches it -- that side of the
207
+ * dome, or the whole wall, stops blocking. Returns whether this blow
208
+ * breached it.
209
+ */
210
+ export function damageFace(barrier, face, boxes) {
211
+ if (face.breached || boxes <= 0)
212
+ return false;
213
+ face.damage += boxes;
214
+ if (face.damage >= barrier.structure) {
215
+ face.breached = true;
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+ /**
221
+ * Sustained, it comes back whole: Physical regains all Structure at the
222
+ * START of each Combat Turn (p.294), Mana at the END (p.316). A breached
223
+ * face re-forms with it.
224
+ */
225
+ export function regenerate(barrier) {
226
+ let changed = false;
227
+ for (const f of barrier.faces) {
228
+ if (f.damage > 0 || f.breached)
229
+ changed = true;
230
+ f.damage = 0;
231
+ f.breached = false;
232
+ }
233
+ return changed;
234
+ }
235
+ /**
236
+ * The PENETRATION rule (p.198): a firearm or a pointed blade does the
237
+ * barrier 1 box, and the rest goes on to whoever is behind it -- 2 boxes
238
+ * for 3 rounds, 3 for 6, 4 for 10.
239
+ */
240
+ export function penetrationBoxes(rounds) {
241
+ return rounds >= 10 ? 4 : rounds >= 6 ? 3 : rounds >= 3 ? 2 : 1;
242
+ }
243
+ /**
244
+ * Does the blow get through the barrier at all? p.197: "If the weapon's
245
+ * modified Damage Value does not exceed the barrier's Armor rating
246
+ * (modified by the weapon's AP) ... the attack automatically fails."
247
+ * The text, not the p.198 example that lets an equal DV through (user
248
+ * ruling 2026-10-05).
249
+ */
250
+ export function pierces(dv, armor, ap) {
251
+ return dv > Math.max(0, armor + ap);
252
+ }
253
+ //# 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;
@@ -1,4 +1,4 @@
1
- import { sameSpot, spotOf, spotsActive, isPerchSpot, OPEN_FLOOR, CONVERSATION_M, actorDistanceMeters } from './spots.js';
1
+ import { sameSpot, spotOf, spotsActive, isPerchSpot, OPEN_FLOOR, CONVERSATION_M, actorDistanceMeters, onTheFloor } from './spots.js';
2
2
  /**
3
3
  * WHO CAN OVERHEAR A MURMUR (playtest, Deditri: "a private place for
4
4
  * calls"). A call is audible to the room, and that is correct and
@@ -123,6 +123,28 @@ export function hearingRangeMeters(listener) {
123
123
  const bonus = listener.augBonus('hearing') + listener.qualityBonus('perception');
124
124
  return Math.max(SPEECH_METERS, SPEECH_METERS + bonus);
125
125
  }
126
+ /**
127
+ * HAS A PLACE IN THE ROOM: a named spot, OR an exact cell on the floor.
128
+ *
129
+ * THE SECOND HALF IS THE FIX (xurFSiqNtRNbXA88A: "I'm getting 'Nobody's
130
+ * close enough to catch that', but I'm right next to Mr. Vellum"). The
131
+ * runner stepped into Vellum's square; the excuse-me rule (spots.ts
132
+ * shoulderAside) moved him one cell over and, as it and yieldDoorway both
133
+ * do on purpose, left him on that EXACT cell with no spot name. This
134
+ * module read "no spot name" as "circulating", and a circulating listener
135
+ * is passing through and hears no murmur -- so a Johnson drawn one square
136
+ * from the runner was deaf to everything said to him for the rest of the
137
+ * meet. An exact cell is the most precise position the engine has
138
+ * (seatingIn seats it first), and distance is measured from it.
139
+ *
140
+ * A DRIFT SEAT IS STILL NOT A PLACE: a circulating runner's derived seat
141
+ * is pinned to atCell only so it is drawn in one spot (Player.
142
+ * pinDerivedSeat), and placedOnCell says no to it -- the Kessler rule
143
+ * below stands (speech-range.test.ts).
144
+ */
145
+ function standsSomewhere(actor) {
146
+ return spotOf(actor) !== undefined || (onTheFloor(actor) && actor.placedOnCell);
147
+ }
126
148
  /**
127
149
  * CAN THIS LISTENER HEAR ORDINARY SPEECH FROM THERE -- the whole of the
128
150
  * `say` rule, in one place so the verb, the NPC fan-out and any future
@@ -176,7 +198,7 @@ export function canHearShout(speaker, listener) {
176
198
  const room = speaker.currentLocation;
177
199
  if (!spotsActive(room))
178
200
  return true;
179
- if (spotOf(speaker) === undefined || spotOf(listener) === undefined)
201
+ if (!standsSomewhere(speaker) || !standsSomewhere(listener))
180
202
  return true;
181
203
  const reach = SHOUT_METERS + (hearingRangeMeters(listener) - SPEECH_METERS);
182
204
  return actorDistanceMeters(room, speaker, listener) <= reach;
@@ -186,10 +208,10 @@ export function canHearSpeech(speaker, listener) {
186
208
  if (!spotsActive(room))
187
209
  return true;
188
210
  // The speaker is unplaced: they are moving among everyone.
189
- if (spotOf(speaker) === undefined)
211
+ if (!standsSomewhere(speaker))
190
212
  return true;
191
213
  // The listener is unplaced: they are passing through, not standing here.
192
- if (spotOf(listener) === undefined)
214
+ if (!standsSomewhere(listener))
193
215
  return false;
194
216
  return actorDistanceMeters(room, speaker, listener) <= hearingRangeMeters(listener);
195
217
  }
@@ -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