@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.
- 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 +206 -41
- package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +9 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +15 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/follow.js +1 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +25 -9
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +10 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +9 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +4 -4
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +7 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +8 -2
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +37 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +12 -4
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +12 -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 +253 -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/earshot.js +26 -4
- 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/npc-authorization.js +27 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +38 -0
- 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
|
@@ -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
|
-
|
|
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
|
|
@@ -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 (
|
|
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 (
|
|
211
|
+
if (!standsSomewhere(speaker))
|
|
190
212
|
return true;
|
|
191
213
|
// The listener is unplaced: they are passing through, not standing here.
|
|
192
|
-
if (
|
|
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
|