@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +89 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +276 -43
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +15 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +24 -9
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +10 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +8 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +7 -6
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +31 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +7 -3
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +8 -6
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +87 -18
- package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +102 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-geometry.js +277 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +32 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-glyphs.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-rulers.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +30 -4
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +22 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +51 -11
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +24 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ways-out.js +34 -2
- 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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
717
|
-
|
|
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
|
-
/**
|
|
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
|