@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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.384.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|
|
@@ -6,9 +6,11 @@ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
|
6
6
|
import { describeFledTarget } from '../utilities/pursuit.js';
|
|
7
7
|
import { shellsInRoom, resolveBodyAttack, crossPlaneRefusal } from '../utilities/planes.js';
|
|
8
8
|
import { heldBy, holding } from '../utilities/grapple.js';
|
|
9
|
-
import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_FLOOR, inMeleeReach, actorCell, standingRoomNear, spotForCell } from '../utilities/spots.js';
|
|
9
|
+
import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters, OPEN_FLOOR, inMeleeReach, actorCell, standingRoomNear, spotForCell, ADJACENT_METERS } from '../utilities/spots.js';
|
|
10
10
|
import { distanceMeters } from '../utilities/room-grid.js';
|
|
11
|
-
import {
|
|
11
|
+
import { wallsBetween } from '../utilities/district-plane.js';
|
|
12
|
+
import { formatCoord, parseCoord } from '../utilities/barrier-geometry.js';
|
|
13
|
+
import { resolveBarrierStrike, resolveCameraStrike, resolveSpellBarrierStrike } from '../utilities/barrier-combat.js';
|
|
12
14
|
import { isWatched, cameraWithinReach, cameraOutOfReach } from '../utilities/surveillance.js';
|
|
13
15
|
import { matchesCameraName } from '../utilities/grid-names.js';
|
|
14
16
|
import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
|
|
@@ -48,7 +50,7 @@ export class AttackCommand extends Command {
|
|
|
48
50
|
async readyForStrike() {
|
|
49
51
|
return {};
|
|
50
52
|
}
|
|
51
|
-
static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197).';
|
|
53
|
+
static description = 'Attack someone in the room -- dice-pool combat with your equipped weapon. They WILL hit back. "attack <door>" shoots a barrier apart instead (unopposed; its Armor and Structure decide, p.197). "attack barrier [x,y]" breaks a face of a barrier spell.';
|
|
52
54
|
async execute(args = []) {
|
|
53
55
|
if (!args || args.length === 0) {
|
|
54
56
|
return 'Who do you want to attack? Try: `attack <name>`';
|
|
@@ -159,6 +161,36 @@ export class AttackCommand extends Command {
|
|
|
159
161
|
return this.scene.isHumanControlled(this.actor) ? '' : lines.join('\n');
|
|
160
162
|
}
|
|
161
163
|
}
|
|
164
|
+
// A BARRIER SPELL (2026-10-05; p.197-198, p.294, p.316): "attack
|
|
165
|
+
// barrier" hits the nearest standing face you can see, "attack
|
|
166
|
+
// barrier 3,5" the face holding that cell. A Physical Barrier is
|
|
167
|
+
// matter to the meat; a Mana Barrier is only there for the astral.
|
|
168
|
+
const spellWall = this.spellBarrierTarget(targetName);
|
|
169
|
+
if (spellWall) {
|
|
170
|
+
if ('refusal' in spellWall)
|
|
171
|
+
return spellWall.refusal;
|
|
172
|
+
const held = this.actor.getCarriedWeapon();
|
|
173
|
+
if (this.scene.encounterFor?.(this.actor)) {
|
|
174
|
+
if (held && !this.actor.weaponDrawn) {
|
|
175
|
+
return `The ${held.name} is holstered -- "draw" it first (Ready Weapon, a Simple Action, SR5 p.165).`;
|
|
176
|
+
}
|
|
177
|
+
const bill = billAction(this.scene, this.actor, held?.isFirearm() ? 'simple' : 'complex', 'Attack Barrier', { attack: true });
|
|
178
|
+
if (bill)
|
|
179
|
+
return bill;
|
|
180
|
+
}
|
|
181
|
+
const drawn = this.scene.encounterFor?.(this.actor) ? null : this.actor.drawWeapon();
|
|
182
|
+
const strike = resolveSpellBarrierStrike(this.scene, this.logger, this.actor, spellWall.barrier, spellWall.face, room, {
|
|
183
|
+
heat: (amount, why) => this.game?.addHeat(amount, why),
|
|
184
|
+
});
|
|
185
|
+
const lines = [...(drawn ? [`${this.actor.name} draws ${drawn.name}.`] : []), ...strike.world];
|
|
186
|
+
this.actor.performAction('attacks', `${spellWall.barrier.caster}'s barrier`);
|
|
187
|
+
this.scene.updateStatus();
|
|
188
|
+
if (strike.opened)
|
|
189
|
+
this.scene.updateExits?.(room);
|
|
190
|
+
const exchange = new CombatExchange(this.scene, this.logger, this.actor);
|
|
191
|
+
await exchange.announce(lines, strike.meta);
|
|
192
|
+
return this.scene.isHumanControlled(this.actor) ? '' : lines.join('\n');
|
|
193
|
+
}
|
|
162
194
|
// A BARRIER (2026-09-03; SR5 p.197-198): "attack <door>" shoots it
|
|
163
195
|
// apart -- unopposed, AP against its Armor, Structure as its track.
|
|
164
196
|
// Named the way pick/breach name it: the device's name, or the exit
|
|
@@ -569,5 +601,59 @@ export class AttackCommand extends Command {
|
|
|
569
601
|
}
|
|
570
602
|
return null;
|
|
571
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* "barrier", "the force wall", "mana barrier 3,5" -> the standing face
|
|
606
|
+
* this actor would hit, or a refusal that says why not, or undefined
|
|
607
|
+
* when the words are not about a barrier spell at all (the door route
|
|
608
|
+
* and the "who?" answer come next).
|
|
609
|
+
*/
|
|
610
|
+
spellBarrierTarget(words) {
|
|
611
|
+
const m = /^(?:the\s+)?(?:(mana|force|physical)\s+)?(?:barrier|wall)(?:\s+(?:at\s+)?(-?\d+,-?\d+(?:,-?\d+)?))?$/i.exec(words.trim());
|
|
612
|
+
if (!m)
|
|
613
|
+
return undefined;
|
|
614
|
+
const kind = this.actor.plane === 'astral' ? 'mana' : 'physical';
|
|
615
|
+
const all = this.scene.spellBarriers?.(kind) ?? [];
|
|
616
|
+
if (all.length === 0) {
|
|
617
|
+
// A door called "the wall" is still the door route's to answer.
|
|
618
|
+
if (m[1]?.toLowerCase() === 'mana' && kind === 'physical' && (this.scene.spellBarriers?.('mana').length ?? 0) > 0) {
|
|
619
|
+
return { refusal: `A mana barrier is woven mana -- your ${this.actor.getCarriedWeapon()?.name ?? 'fists'} would pass through it as if it were not there. Only the astral can touch it.` };
|
|
620
|
+
}
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
const plane = this.scene.ensurePlane?.();
|
|
624
|
+
const from = this.scene.worldCellOf?.(this.actor);
|
|
625
|
+
if (!plane || !from)
|
|
626
|
+
return { refusal: `You can't place yourself well enough to aim at a barrier from here.` };
|
|
627
|
+
const melee = !this.actor.getCarriedWeapon()?.isFirearm();
|
|
628
|
+
const reachable = (c) => wallsBetween(plane, from, c) === 0 && (!melee || distanceMeters(from, c) <= ADJACENT_METERS);
|
|
629
|
+
if (m[2]) {
|
|
630
|
+
const at = parseCoord(m[2], from.z);
|
|
631
|
+
const hit = this.scene.barrierAt(at, kind);
|
|
632
|
+
if (!hit)
|
|
633
|
+
return { refusal: `There's no standing barrier at ${formatCoord(at)}.` };
|
|
634
|
+
if (!reachable(at)) {
|
|
635
|
+
return { refusal: melee ? `${formatCoord(at)} is out of arm's reach -- close in, or bring a gun.` : `You can't see ${formatCoord(at)} from here.` };
|
|
636
|
+
}
|
|
637
|
+
return hit;
|
|
638
|
+
}
|
|
639
|
+
let best;
|
|
640
|
+
for (const barrier of all) {
|
|
641
|
+
for (const face of barrier.faces) {
|
|
642
|
+
if (face.breached)
|
|
643
|
+
continue;
|
|
644
|
+
for (const c of face.cells) {
|
|
645
|
+
if (!reachable(c))
|
|
646
|
+
continue;
|
|
647
|
+
const d = distanceMeters(from, c);
|
|
648
|
+
if (!best || d < best.d)
|
|
649
|
+
best = { barrier, face, d };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (!best) {
|
|
654
|
+
return { refusal: melee ? `No barrier within arm's reach -- close in, or bring a gun.` : `No barrier face in sight from here.` };
|
|
655
|
+
}
|
|
656
|
+
return { barrier: best.barrier, face: best.face };
|
|
657
|
+
}
|
|
572
658
|
}
|
|
573
659
|
//# sourceMappingURL=attack.js.map
|
|
@@ -6,7 +6,10 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
|
6
6
|
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
7
7
|
import { describeFledTarget } from '../utilities/pursuit.js';
|
|
8
8
|
import { crossPlaneRefusal } from '../utilities/planes.js';
|
|
9
|
-
import { spotOf, ensureAtSpot } from '../utilities/spots.js';
|
|
9
|
+
import { spotOf, ensureAtSpot, furnitureCells, standingOccupants } from '../utilities/spots.js';
|
|
10
|
+
import { key as gridKey } from '../utilities/room-grid.js';
|
|
11
|
+
import { isSolid, isVoid, roomAtCell, wallsBetween } from '../utilities/district-plane.js';
|
|
12
|
+
import { buildBarrier, clipFaces, formatCoord, isCoordToken, parseCoord, planDirectionalWall, planDome, planWall } from '../utilities/barrier-geometry.js';
|
|
10
13
|
import { NPC } from '../models/npc.js';
|
|
11
14
|
import { spellFor, catalogSpells } from '../utilities/catalog.js';
|
|
12
15
|
import { getAspect } from '../aspects.js';
|
|
@@ -93,7 +96,7 @@ export class CastCommand extends BypassCommand {
|
|
|
93
96
|
/** Touches another actor: authorized per-act for AI-driven actors
|
|
94
97
|
* (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
|
|
95
98
|
static npcPolicy = 'authorized';
|
|
96
|
-
static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know.
|
|
99
|
+
static description = 'Cast a spell you know, at a FORCE you choose: "cast manabolt 8 at <target>" (default Force = your Magic; drain scales with it, and OVERCASTING past your talent makes the drain PHYSICAL). "spells" lists what you know. Barriers take map coordinates: "cast barrier 3 3,4 3,6" (a wall), "cast barrier 3 5,5" (a dome), "cast barrier east 2". A ward is "dispel <direction>".';
|
|
97
100
|
deviceKind = 'ward';
|
|
98
101
|
verbName = 'cast';
|
|
99
102
|
noDeviceMessage = "There's nothing here to cast at.";
|
|
@@ -205,16 +208,11 @@ export class CastCommand extends BypassCommand {
|
|
|
205
208
|
return this.castSpell(spellName, words.slice(1));
|
|
206
209
|
}
|
|
207
210
|
// An AMBIGUOUS fragment ("cast stun ...", now that stunbolt and
|
|
208
|
-
// stunball both exist)
|
|
209
|
-
// path and answer with something about barriers. Name the choices
|
|
210
|
-
// instead -- but only for someone who can actually cast, so "cast
|
|
211
|
-
// north" still routes to the bypass.
|
|
211
|
+
// stunball both exist) names the choices rather than guessing.
|
|
212
212
|
const near = this.spellsMatching(words?.[0]);
|
|
213
213
|
if (near.length > 1 && this.actor.isCaster()) {
|
|
214
214
|
return `Which one? ${near.join(', ')}.`;
|
|
215
215
|
}
|
|
216
|
-
// Ward-bypass path (BypassCommand). Now that casting is a real, gated
|
|
217
|
-
// ability, throwing magic at a ward requires the talent or the gear.
|
|
218
216
|
// ASPECTED TO ANOTHER GROUP (SR5 p.69): no Sorcery at all -- not even
|
|
219
217
|
// through a formula or focus, which stand in for the SKILL, not the gift.
|
|
220
218
|
if (this.actor.isAspected && !this.actor.canWorkSorcery()) {
|
|
@@ -223,7 +221,18 @@ export class CastCommand extends BypassCommand {
|
|
|
223
221
|
if (!this.actor.isCaster()) {
|
|
224
222
|
return `Casting takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
|
|
225
223
|
}
|
|
226
|
-
|
|
224
|
+
// "CAST <DIRECTION>" IS GONE (user ruling 2026-10-05): it used to
|
|
225
|
+
// unweave a ward on that exit, which is DISPEL's job and now only
|
|
226
|
+
// dispel's. A direction gets pointed at both verbs it might mean;
|
|
227
|
+
// anything else is not a spell this caster knows.
|
|
228
|
+
const first = words[0]?.toLowerCase();
|
|
229
|
+
if (!first) {
|
|
230
|
+
return `Cast what? "spells" lists what you know -- "cast <spell> [force] ...".`;
|
|
231
|
+
}
|
|
232
|
+
if (this.isValidDirection(first)) {
|
|
233
|
+
return `To unweave a ward that way, "dispel ${first}". To wall the way off, "cast barrier ${first} [cells]".`;
|
|
234
|
+
}
|
|
235
|
+
return `"${words.join(' ')}" isn't a spell you know. "spells" lists them; a ward is "dispel <it>".`;
|
|
227
236
|
}
|
|
228
237
|
/**
|
|
229
238
|
* ONE REFUSAL FOR THE ASPECT, wherever it is reached from -- the
|
|
@@ -964,16 +973,31 @@ export class CastCommand extends BypassCommand {
|
|
|
964
973
|
return this.finishSpell(lines);
|
|
965
974
|
}
|
|
966
975
|
/**
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
* barrier
|
|
972
|
-
*
|
|
976
|
+
* THE BARRIER SPELLS, CAST AT THE DISTRICT PLANE (SR5 p.294; user
|
|
977
|
+
* spec 2026-10-05). Coordinates are plane cells -- the numbers on the
|
|
978
|
+
* map rulers -- and the shape follows from how many points you give:
|
|
979
|
+
*
|
|
980
|
+
* cast barrier [F] <x,y[,z]> <x,y[,z]> a wall between two points
|
|
981
|
+
* cast barrier [F] <x,y[,z]> a dome around one point
|
|
982
|
+
* cast barrier [F] <direction> [n] a 1x3 wall n cells that way
|
|
983
|
+
* cast barrier [F] at <someone> a dome around them
|
|
984
|
+
* cast barrier [F] a dome around yourself
|
|
985
|
+
* cast barrier off let it go (free)
|
|
986
|
+
*
|
|
987
|
+
* A wall is Force x 2 m long and Force m high; a dome is Force m in
|
|
988
|
+
* radius (barrier-geometry.ts). A Physical Barrier's Armor and
|
|
989
|
+
* Structure are its HITS; a Mana Barrier's are its Force (p.316). One
|
|
990
|
+
* of each per caster; casting again replaces the standing one, with a
|
|
991
|
+
* fresh roll and fresh drain. -2 sustaining like every held weave.
|
|
992
|
+
*
|
|
993
|
+
* Bare "cast north" no longer reaches a ward from here -- that is
|
|
994
|
+
* "dispel north" (user ruling 2026-10-05).
|
|
973
995
|
*/
|
|
974
996
|
async castBarrier(spellKey, force, overcast, rest) {
|
|
975
997
|
const actor = this.actor;
|
|
976
998
|
const mana = spellKey === 'manabarrier';
|
|
999
|
+
const kind = mana ? 'mana' : 'physical';
|
|
1000
|
+
const label = mana ? 'mana barrier' : 'barrier';
|
|
977
1001
|
if (actor.plane !== 'meat' && actor.plane !== 'astral') {
|
|
978
1002
|
return `Barriers are woven where the world is -- not from the grid.`;
|
|
979
1003
|
}
|
|
@@ -981,54 +1005,195 @@ export class CastCommand extends BypassCommand {
|
|
|
981
1005
|
return `A physical barrier needs physical hands -- come back to the meat.`;
|
|
982
1006
|
}
|
|
983
1007
|
const standing = mana ? actor.sustainedManaBarrier : actor.sustainedBarrier;
|
|
984
|
-
|
|
1008
|
+
const words = rest.map(w => w.trim()).filter(w => w.length > 0);
|
|
1009
|
+
if (words.length === 1 && /^(off|release|drop|end)$/i.test(words[0])) {
|
|
1010
|
+
if (!standing)
|
|
1011
|
+
return `You aren't holding a ${label}.`;
|
|
985
1012
|
if (mana)
|
|
986
1013
|
actor.sustainedManaBarrier = undefined;
|
|
987
1014
|
else
|
|
988
1015
|
actor.sustainedBarrier = undefined;
|
|
989
1016
|
this.scene.updateStatus();
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
994
|
-
// Fall through: a direction was named -- re-seat the wall there.
|
|
1017
|
+
this.scene.updateExits?.(actor.currentLocation);
|
|
1018
|
+
actor.performAction(`releases ${label} spell`, `in ${standing.roomName}`);
|
|
1019
|
+
return this.finishSpell([`You let the ${label} go -- the ${mana ? 'weave unravels' : 'air unhardens'}, and the sustaining burden lifts.`]);
|
|
995
1020
|
}
|
|
996
1021
|
const room = actor.currentLocation;
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1022
|
+
const plane = this.scene.ensurePlane?.();
|
|
1023
|
+
const from = this.scene.worldCellOf?.(actor);
|
|
1024
|
+
if (!plane || !from) {
|
|
1025
|
+
return `There's no ground here to raise a ${label} on.`;
|
|
1000
1026
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1027
|
+
// WHAT SHAPE, AND WHERE.
|
|
1028
|
+
let plan;
|
|
1029
|
+
let around;
|
|
1030
|
+
let center;
|
|
1031
|
+
const usage = `"cast ${spellKey} [force] <x,y> <x,y>" (a wall), "cast ${spellKey} [force] <x,y>" (a dome), "cast ${spellKey} [force] <direction> [cells]", or "cast ${spellKey} at <someone>". The map rulers give x and y; you're at ${formatCoord(from)}.`;
|
|
1032
|
+
const unseen = (what) => `You can't see ${what} from here -- a barrier is cast at what you can see (LOS, p.294).`;
|
|
1033
|
+
if (words.length === 0) {
|
|
1034
|
+
center = from;
|
|
1035
|
+
around = 'you';
|
|
1036
|
+
plan = planDome(from, force);
|
|
1004
1037
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1038
|
+
else if (isCoordToken(words[0])) {
|
|
1039
|
+
if (words.length > 2 || (words.length === 2 && !isCoordToken(words[1])))
|
|
1040
|
+
return `Two points at most. ${usage}`;
|
|
1041
|
+
const a = parseCoord(words[0], from.z);
|
|
1042
|
+
if (words.length === 2) {
|
|
1043
|
+
const b = parseCoord(words[1], from.z);
|
|
1044
|
+
plan = planWall(a, b, force);
|
|
1045
|
+
if (plan.error)
|
|
1046
|
+
return plan.error;
|
|
1047
|
+
const hidden = [a, b].find(p => !this.inSight(plane, from, p));
|
|
1048
|
+
if (hidden)
|
|
1049
|
+
return unseen(formatCoord(hidden));
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
center = a;
|
|
1053
|
+
plan = planDome(a, force);
|
|
1054
|
+
if (!this.inSight(plane, from, a))
|
|
1055
|
+
return unseen(formatCoord(a));
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
else if (this.isValidDirection(words[0].toLowerCase())) {
|
|
1059
|
+
if (words.length > 2 || (words.length === 2 && !/^\d+$/.test(words[1]))) {
|
|
1060
|
+
return `"cast ${spellKey} ${words[0]} [cells]" -- the distance is a whole number of cells.`;
|
|
1061
|
+
}
|
|
1062
|
+
const distance = words.length === 2 ? parseInt(words[1], 10) : 1;
|
|
1063
|
+
plan = planDirectionalWall(from, words[0].toLowerCase(), distance, force);
|
|
1064
|
+
if (plan.error)
|
|
1065
|
+
return plan.error;
|
|
1066
|
+
const run = plan.faces[0].cells;
|
|
1067
|
+
const mid = run[Math.floor(run.length / 2)];
|
|
1068
|
+
if (!this.inSight(plane, from, mid))
|
|
1069
|
+
return unseen(`${distance} cell${distance === 1 ? '' : 's'} ${words[0]}`);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
const lead = words[0].toLowerCase();
|
|
1073
|
+
const nameWords = lead === 'at' || lead === 'on' || lead === 'around' ? words.slice(1) : words;
|
|
1074
|
+
if (nameWords.length === 0)
|
|
1075
|
+
return usage;
|
|
1076
|
+
const candidates = this.scene.getActorsInRoom(room).filter(a => a === actor || ((a.plane === 'meat' || a.plane === actor.plane) && actor.canPerceive(a)));
|
|
1077
|
+
const picked = fuzzyPickName(nameWords.join(' '), candidates.map(a => a.name));
|
|
1078
|
+
const target = picked ? candidates.find(a => a.name === picked) : undefined;
|
|
1079
|
+
if (!target)
|
|
1080
|
+
return `You don't see "${nameWords.join(' ')}" here. ${usage}`;
|
|
1081
|
+
const at = this.scene.worldCellOf?.(target);
|
|
1082
|
+
if (!at)
|
|
1083
|
+
return `${target.name} isn't standing anywhere a dome could go round.`;
|
|
1084
|
+
if (target !== actor && !this.inSight(plane, from, at))
|
|
1085
|
+
return unseen(target.name);
|
|
1086
|
+
center = at;
|
|
1087
|
+
around = target === actor ? 'you' : target.name;
|
|
1088
|
+
plan = planDome(at, force);
|
|
1089
|
+
}
|
|
1090
|
+
if (plan.error)
|
|
1091
|
+
return plan.error;
|
|
1092
|
+
// CASTING IS A COMPLEX ACTION in a Combat Turn (p.167).
|
|
1093
|
+
const bill = billAction(this.scene, actor, 'complex', `Cast ${label}`);
|
|
1094
|
+
if (bill)
|
|
1095
|
+
return bill;
|
|
1096
|
+
// The standing one goes first: its cells must not clip the new one.
|
|
1097
|
+
if (standing) {
|
|
1098
|
+
if (mana)
|
|
1099
|
+
actor.sustainedManaBarrier = undefined;
|
|
1100
|
+
else
|
|
1101
|
+
actor.sustainedBarrier = undefined;
|
|
1102
|
+
}
|
|
1103
|
+
// WHAT IT CANNOT STAND IN (barrier-geometry clipFaces).
|
|
1104
|
+
const clipped = clipFaces(plan.faces, c => this.barrierCellOpen(plane, c, kind, center));
|
|
1105
|
+
if (clipped.faces.length === 0) {
|
|
1106
|
+
return `There's no room for a ${label} there -- every cell of it is ${mana ? 'earth or another weave' : 'wall, furniture or somebody'}.`;
|
|
1107
|
+
}
|
|
1108
|
+
// THE CASTING TEST: Magic + Spellcasting [Force] (p.281). Physical
|
|
1109
|
+
// Barrier: Armor and Structure 1 per hit (p.294). Mana Barrier: the
|
|
1110
|
+
// barrier's Armor and Structure are its Force (p.316).
|
|
1111
|
+
const roll = rollPool(actor.getMagicPool(), force);
|
|
1112
|
+
if (this.scene.isHumanControlled(actor))
|
|
1113
|
+
this.logger.meta(`${mana ? 'Mana Barrier' : 'Physical Barrier'} casting: ${formatRoll(roll)}`);
|
|
1009
1114
|
const spell = spellFor(spellKey);
|
|
1010
|
-
|
|
1115
|
+
if (!mana && roll.hits === 0) {
|
|
1116
|
+
actor.performAction(`casts ${label}`, 'and nothing holds');
|
|
1117
|
+
const lines = [
|
|
1118
|
+
`${actor.name} casts barrier at Force ${force}, ${this.castingLabel()}:`,
|
|
1119
|
+
` The air shivers and lets go -- no hits, no wall.`,
|
|
1120
|
+
...this.applyDrain(spell, force, overcast),
|
|
1121
|
+
];
|
|
1122
|
+
lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
|
|
1123
|
+
return this.finishSpell(lines);
|
|
1124
|
+
}
|
|
1125
|
+
const rating = mana ? force : roll.hits;
|
|
1126
|
+
const barrier = buildBarrier(kind, actor.name, force, rating, room.name, plan.shape, clipped.faces);
|
|
1011
1127
|
if (mana)
|
|
1012
|
-
actor.sustainedManaBarrier =
|
|
1128
|
+
actor.sustainedManaBarrier = barrier;
|
|
1013
1129
|
else
|
|
1014
|
-
actor.sustainedBarrier =
|
|
1015
|
-
|
|
1130
|
+
actor.sustainedBarrier = barrier;
|
|
1131
|
+
const cells = barrier.faces.reduce((n, f) => n + f.cells.length, 0);
|
|
1132
|
+
const first = barrier.faces[0].cells;
|
|
1133
|
+
const where = plan.shape === 'dome'
|
|
1134
|
+
? `a dome of ${force} m radius around ${around ?? formatCoord(center)}`
|
|
1135
|
+
: `a wall from ${formatCoord(first[0])} to ${formatCoord(first[first.length - 1])}`;
|
|
1136
|
+
actor.performAction(`casts ${label}`, where);
|
|
1016
1137
|
this.scene.addWorldEvent(mana
|
|
1017
|
-
? `${actor.name} wove a mana barrier
|
|
1018
|
-
: `${actor.name} raised a shimmering force barrier
|
|
1138
|
+
? `${actor.name} wove a mana barrier in ${room.name}.`
|
|
1139
|
+
: `${actor.name} raised a shimmering force barrier in ${room.name}.`);
|
|
1019
1140
|
this.scene.updateStatus();
|
|
1141
|
+
// The panels draw it (room-view / map-view): repaint them now.
|
|
1142
|
+
this.scene.updateExits?.(room);
|
|
1020
1143
|
const burden = sustainBurden(actor.sustainedLedger().find(w => w.key === spellKey), ' to everything else you do');
|
|
1144
|
+
const faces = barrier.faces.length > 1 ? ` ${barrier.faces.length} faces, each with its own damage track.` : '';
|
|
1021
1145
|
const lines = [
|
|
1022
|
-
`${actor.name} casts ${
|
|
1146
|
+
`${actor.name} casts ${label} at Force ${force}, ${this.castingLabel()}:`,
|
|
1147
|
+
...(this.scene.isHumanControlled(actor) ? [] : [` Casting: ${formatRoll(roll)}`]),
|
|
1023
1148
|
mana
|
|
1024
|
-
? ` A lattice of woven mana settles
|
|
1025
|
-
: ` The air itself hardens
|
|
1026
|
-
|
|
1149
|
+
? ` A lattice of woven mana settles into ${where} -- ${cells} cell${cells === 1 ? '' : 's'}, invisible to meat eyes. Armor and Structure ${rating}.${faces} SPIRITS and astral forms stop at it; the living walk through without noticing, and so do you. ${burden.charAt(0).toUpperCase()}${burden.slice(1)}.`
|
|
1150
|
+
: ` The air itself hardens into ${where} -- ${cells} cell${cells === 1 ? '' : 's'} of heat-shimmer. Armor and Structure ${rating} (${roll.hits} hit${roll.hits === 1 ? '' : 's'}).${faces} NOTHING physical passes while you hold it (you included), and ${burden}.`,
|
|
1151
|
+
...(plan.note ? [` ${plan.note}`] : []),
|
|
1152
|
+
...(clipped.dropped > 0 ? [` ${clipped.dropped} cell${clipped.dropped === 1 ? '' : 's'} of it ${mana ? 'stop at earth or another weave' : 'meet wall, furniture or a body and stop there'}.`] : []),
|
|
1153
|
+
hint(` ("cast ${spellKey} off" lets it go; "dispel ${mana ? 'mana barrier' : 'barrier'}" unweaves it from either side.)`),
|
|
1027
1154
|
...this.applyDrain(spell, force, overcast),
|
|
1028
1155
|
];
|
|
1029
1156
|
lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
|
|
1030
1157
|
return this.finishSpell(lines);
|
|
1031
1158
|
}
|
|
1159
|
+
/** LINE OF SIGHT for an area spell's point (p.281): no wall between. */
|
|
1160
|
+
inSight(plane, from, to) {
|
|
1161
|
+
return wallsBetween(plane, from, to) === 0;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* CAN A BARRIER CELL STAND HERE. Physical: not in a wall, not in the
|
|
1165
|
+
* void, not in furniture or a body -- matter is already there. Mana:
|
|
1166
|
+
* the astral is "unimpeded by material objects" (p.313), so only earth,
|
|
1167
|
+
* a warded room you are not inside, and another mana barrier stop it.
|
|
1168
|
+
* The one a dome is raised around is inside it, never in its way.
|
|
1169
|
+
*/
|
|
1170
|
+
barrierCellOpen(plane, c, kind, center) {
|
|
1171
|
+
if (center && c.x === center.x && c.y === center.y && c.z === center.z)
|
|
1172
|
+
return true;
|
|
1173
|
+
if (isVoid(plane, c))
|
|
1174
|
+
return false;
|
|
1175
|
+
const ownerKey = roomAtCell(plane, c);
|
|
1176
|
+
const owner = ownerKey ? Object.values(this.scene.getRooms()).find(r => r.name.trim().toLowerCase() === ownerKey) : undefined;
|
|
1177
|
+
if (kind === 'mana') {
|
|
1178
|
+
if (owner?.warded && owner !== this.actor.currentLocation)
|
|
1179
|
+
return false;
|
|
1180
|
+
return !this.scene.barrierAt(c, 'mana');
|
|
1181
|
+
}
|
|
1182
|
+
if (isSolid(plane, c))
|
|
1183
|
+
return false;
|
|
1184
|
+
if (this.scene.barrierAt(c, 'physical'))
|
|
1185
|
+
return false;
|
|
1186
|
+
const place = ownerKey ? plane.placements.get(ownerKey) : undefined;
|
|
1187
|
+
if (!owner || !place)
|
|
1188
|
+
return true;
|
|
1189
|
+
const local = { x: c.x - place.origin.x, y: c.y - place.origin.y, z: c.z - place.origin.z };
|
|
1190
|
+
const lk = gridKey(local);
|
|
1191
|
+
if (furnitureCells(owner).has(lk))
|
|
1192
|
+
return false;
|
|
1193
|
+
if (standingOccupants(owner).has(lk))
|
|
1194
|
+
return false;
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1032
1197
|
/**
|
|
1033
1198
|
* The sustained utility pair (invisibility / increase reflexes), same
|
|
1034
1199
|
* toggle shape as the armor spell: the BEARER wears the effect, the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/commands/command-registry.ts
|
|
2
2
|
import { CommandFactory } from '../factories/command-factory.js';
|
|
3
3
|
import { Logger } from '../utilities/logger.js';
|
|
4
|
-
import { authorizeNpcAction } from '../utilities/npc-authorization.js';
|
|
4
|
+
import { authorizeNpcAction, clientHoldsTheMeet } from '../utilities/npc-authorization.js';
|
|
5
5
|
export class CommandRegistry {
|
|
6
6
|
scene;
|
|
7
7
|
constructor(scene) {
|
|
@@ -74,6 +74,14 @@ export class CommandRegistry {
|
|
|
74
74
|
return { args, refusal: verdict.reason ?? `That is not yours to do.` };
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
// The meet's Johnson keeps his seat (npc-authorization.ts).
|
|
78
|
+
if (declaring.isMovement() && !this.scene.isHumanControlled(actor)) {
|
|
79
|
+
const held = clientHoldsTheMeet(this.scene, actor);
|
|
80
|
+
if (held) {
|
|
81
|
+
Logger.getInstance().write(`Refused "${commandString.trim()}" from ${actor.name}: the client keeps his seat.`);
|
|
82
|
+
return { args, refusal: held };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
77
85
|
return { command, args };
|
|
78
86
|
}
|
|
79
87
|
}
|
|
@@ -46,6 +46,16 @@ export class Command {
|
|
|
46
46
|
// whether or not it also remembered to say so here.
|
|
47
47
|
return this.humanOnly ? 'human-only' : this.npcPolicy;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* THIS VERB MOVES THE ACTOR'S OWN BODY -- across the room (move, run,
|
|
51
|
+
* sprint, fly), out of it (go) or after someone (follow). Read by
|
|
52
|
+
* CommandRegistry.parse for AI-driven actors only: a Johnson holding a
|
|
53
|
+
* meet keeps his seat (npc-authorization.ts clientHoldsTheMeet).
|
|
54
|
+
*/
|
|
55
|
+
static movesActor = false;
|
|
56
|
+
static isMovement() {
|
|
57
|
+
return this.movesActor;
|
|
58
|
+
}
|
|
49
59
|
static verb;
|
|
50
60
|
static description; // Make this static
|
|
51
61
|
actor;
|
|
@@ -3,6 +3,7 @@ import { BypassCommand } from './bypass-command.js';
|
|
|
3
3
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
4
4
|
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
5
5
|
import { billAction } from '../utilities/action-cost.js';
|
|
6
|
+
import { roomAtCell } from '../utilities/district-plane.js';
|
|
6
7
|
/**
|
|
7
8
|
* The magical barrier's OWN verb (player ruling: "solve" was too broad --
|
|
8
9
|
* a ward wants "dispel", not a generic answer verb), and the second half
|
|
@@ -110,16 +111,23 @@ export class DispelCommand extends CastCommand {
|
|
|
110
111
|
return `More than one weave of ${named.label} here -- name whose: "dispel ${named.words[0]} on <name>".`;
|
|
111
112
|
}
|
|
112
113
|
// A barrier's WEAVE is here even when its weaver isn't: "dispel
|
|
113
|
-
// [mana] barrier"
|
|
114
|
-
//
|
|
114
|
+
// [mana] barrier" reaches the caster wherever they stand, for any
|
|
115
|
+
// barrier with a cell in this room or cast from it -- or across one
|
|
116
|
+
// of its doorways, from either side.
|
|
115
117
|
if (!target && (named?.key === 'barrier' || named?.key === 'manabarrier')) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
const kind = named.key === 'manabarrier' ? 'mana' : 'physical';
|
|
119
|
+
const plane = this.scene.ensurePlane?.();
|
|
120
|
+
const here = room.name.trim().toLowerCase();
|
|
121
|
+
const standing = this.scene.spellBarriers?.(kind).find(b => b.roomName === room.name
|
|
122
|
+
|| (plane && b.faces.some(f => f.cells.some(c => roomAtCell(plane, c) === here))));
|
|
123
|
+
let caster = standing?.caster;
|
|
124
|
+
for (const direction of caster ? [] : room.exits.keys()) {
|
|
125
|
+
caster = this.scene.barrierSealing(room, direction, kind)?.caster;
|
|
126
|
+
if (caster)
|
|
120
127
|
break;
|
|
121
|
-
}
|
|
122
128
|
}
|
|
129
|
+
if (caster)
|
|
130
|
+
target = this.scene.getPlayers().find(p => p.name === caster);
|
|
123
131
|
}
|
|
124
132
|
if (!target)
|
|
125
133
|
return undefined;
|
|
@@ -124,6 +124,7 @@ export function dragFollowers(scene, mover, origin, logger) {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
export class FollowCommand extends Command {
|
|
127
|
+
static movesActor = true;
|
|
127
128
|
static verb = 'follow';
|
|
128
129
|
static description = 'Tail another runner: "follow <name>" and their room changes take you with them. Bare "follow" (or "unfollow") peels off. It breaks on its own if you get caught in a fight, fall behind, or they leave the run. Same room, same plane, real players only -- NPC companions already heel by nature ("order" sends them).';
|
|
129
130
|
/** The scene's other humans -- the only legal targets. */
|
|
@@ -5,6 +5,7 @@ import { resolveHostInReach } from '../utilities/grid-reach.js';
|
|
|
5
5
|
import { planeChangeHint, astralMovesLeft, astralCordWarning, astralCordParts } from '../utilities/planes.js';
|
|
6
6
|
import { CombatExchange } from '../utilities/combat-exchange.js';
|
|
7
7
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
8
|
+
import { astralLimit } from '../utilities/limits.js';
|
|
8
9
|
import { walkOut } from '../utilities/movement-cost.js';
|
|
9
10
|
import { frameOf, droneImmobile, droneImmobileLine } from '../utilities/drone-prose.js';
|
|
10
11
|
import { entrySpotFor, arrivalAt, ensureAtExit, spotsActive, highlightSpots, highlightActors, doorwayBlocker, doorwayFurniture, yieldDoorway, lockedRouteHint, lockSeenFromOutside, spotForCell, waysOutOf, actorCell } from '../utilities/spots.js';
|
|
@@ -96,6 +97,7 @@ export class GoCommand extends Command {
|
|
|
96
97
|
return false;
|
|
97
98
|
return GoCommand.HEELS_ON[c.kind](plane);
|
|
98
99
|
}
|
|
100
|
+
static movesActor = true;
|
|
99
101
|
static verb = 'go';
|
|
100
102
|
static description = 'Move in the specified direction ("go back" retraces your last move). Fleeing someone you just fought hands them one free parting shot. Jacked in, the grid sees no distance: "go <any room>" jumps your persona straight there ("map" lights the icons). Inside a host there is no distance to cross at all -- "exit" first.';
|
|
101
103
|
/** See MoveCommand.execute: going through a door is a walk too, and
|
|
@@ -635,11 +637,12 @@ export class GoCommand extends Command {
|
|
|
635
637
|
// back corner, and nothing said so). Walking out means walking to
|
|
636
638
|
// it first: free out of combat, refused mid-exchange -- the same
|
|
637
639
|
// rule pick/unlock already enforce on the same doors.
|
|
638
|
-
// SUSTAINED BARRIER SPELL
|
|
639
|
-
//
|
|
640
|
-
// NPCs, the caster too. Checked
|
|
641
|
-
// nobody strolls up to a wall they
|
|
642
|
-
// Astral and Matrix forms pass: the
|
|
640
|
+
// SUSTAINED BARRIER SPELL: a held force wall standing over this
|
|
641
|
+
// doorway (its cells, barrier-geometry.ts) seals it for EVERY
|
|
642
|
+
// meat-plane actor -- players, fleeing NPCs, the caster too. Checked
|
|
643
|
+
// before the walk-to-the-doorway so nobody strolls up to a wall they
|
|
644
|
+
// can see shimmering from here. Astral and Matrix forms pass: the
|
|
645
|
+
// spell hardens AIR.
|
|
643
646
|
if (this.actor.plane === 'meat') {
|
|
644
647
|
const seal = this.scene.barrierSealing(currentRoom, direction);
|
|
645
648
|
if (seal) {
|
|
@@ -648,12 +651,25 @@ export class GoCommand extends Command {
|
|
|
648
651
|
}
|
|
649
652
|
}
|
|
650
653
|
// The MANA BARRIER twin (canon p.294): spirits and astral forms
|
|
651
|
-
// stop at the weave; the living walk through it without noticing
|
|
654
|
+
// stop at the weave; the living walk through it without noticing,
|
|
655
|
+
// and so does its creator (p.315).
|
|
652
656
|
if (this.actor.plane === 'astral') {
|
|
653
|
-
const seal = this.scene.barrierSealing(currentRoom, direction, 'mana');
|
|
657
|
+
const seal = this.scene.barrierSealing(currentRoom, direction, 'mana', this.actor.name);
|
|
654
658
|
if (seal) {
|
|
655
|
-
|
|
656
|
-
|
|
659
|
+
// PRESSING THROUGH (p.316, "Getting Around Mana Barriers"):
|
|
660
|
+
// Magic + Charisma [Astral] v. the barrier's Force x 2. Any net
|
|
661
|
+
// hits and you are through; otherwise the weave holds you.
|
|
662
|
+
const press = rollPool(this.actor.magic + this.actor.charisma, astralLimit(this.actor));
|
|
663
|
+
const hold = rollPool(seal.force * 2);
|
|
664
|
+
const net = press.hits - hold.hits;
|
|
665
|
+
if (this.scene.isHumanControlled(this.actor)) {
|
|
666
|
+
this.logger.meta(`Press through the mana barrier (p.316): Magic + Charisma ${formatRoll(press)} v. Force x 2 ${formatRoll(hold)}`);
|
|
667
|
+
}
|
|
668
|
+
if (net <= 0) {
|
|
669
|
+
this.actor.performAction('is stopped by a mana barrier', `${direction} from ${currentRoom.name}`);
|
|
670
|
+
return `A lattice of woven mana seals the way ${direction} -- Force ${seal.force}, ${seal.caster}'s working. You press against it and it presses back; the astral does not pass.`;
|
|
671
|
+
}
|
|
672
|
+
this.actor.performAction('presses through a mana barrier', `${direction} from ${currentRoom.name}`);
|
|
657
673
|
}
|
|
658
674
|
}
|
|
659
675
|
let crossNote = '';
|