@maka/maka-cli 5.384.0 → 5.386.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/cast.js +449 -130
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +22 -22
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +26 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-geometry.js +25 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +9 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +18 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.386.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.",
|
|
@@ -2,14 +2,14 @@ import { sustainBurden } from '../utilities/sustaining.js';
|
|
|
2
2
|
import { BypassCommand } from './bypass-command.js';
|
|
3
3
|
import { hint } from '../utilities/hints.js';
|
|
4
4
|
import { CombatExchange } from '../utilities/combat-exchange.js';
|
|
5
|
-
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
5
|
+
import { rollPool, formatRoll, rollD6Total } 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
9
|
import { spotOf, ensureAtSpot, furnitureCells, standingOccupants } from '../utilities/spots.js';
|
|
10
|
-
import { key as gridKey } from '../utilities/room-grid.js';
|
|
10
|
+
import { key as gridKey, METERS_PER_CELL, distanceMeters, line3D } from '../utilities/room-grid.js';
|
|
11
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';
|
|
12
|
+
import { DIRECTIONAL_WALL_FORCE, DOME_FORCE, forceForWall, buildBarrier, clipFaces, formatCoord, isCoordToken, parseCoord, planDirectionalWall, planDome, planWall, FACE_LABEL, allBreached, damageFace, pierces } from '../utilities/barrier-geometry.js';
|
|
13
13
|
import { NPC } from '../models/npc.js';
|
|
14
14
|
import { spellFor, catalogSpells } from '../utilities/catalog.js';
|
|
15
15
|
import { getAspect } from '../aspects.js';
|
|
@@ -96,7 +96,7 @@ export class CastCommand extends BypassCommand {
|
|
|
96
96
|
/** Touches another actor: authorized per-act for AI-driven actors
|
|
97
97
|
* (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
|
|
98
98
|
static npcPolicy = 'authorized';
|
|
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>".';
|
|
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". Area spells too: "cast fireball 4 at 12,7" (a point) or "cast fireball 4 at <someone>" -- Force m around it, friend and foe. A ward is "dispel <direction>".';
|
|
100
100
|
deviceKind = 'ward';
|
|
101
101
|
verbName = 'cast';
|
|
102
102
|
noDeviceMessage = "There's nothing here to cast at.";
|
|
@@ -307,6 +307,7 @@ export class CastCommand extends BypassCommand {
|
|
|
307
307
|
const spellDef = spellFor(spellName);
|
|
308
308
|
const safeForce = this.actor.magic > 0 ? this.actor.magic : 4;
|
|
309
309
|
let force = Math.max(1, -(spellDef?.drainOffset ?? -3));
|
|
310
|
+
let forceGiven = true;
|
|
310
311
|
if (rest[0]?.toLowerCase() === 'force' && /^\d+$/.test(rest[1] ?? '')) {
|
|
311
312
|
force = parseInt(rest[1], 10);
|
|
312
313
|
rest = rest.slice(2);
|
|
@@ -315,6 +316,9 @@ export class CastCommand extends BypassCommand {
|
|
|
315
316
|
force = parseInt(rest[0], 10);
|
|
316
317
|
rest = rest.slice(1);
|
|
317
318
|
}
|
|
319
|
+
else {
|
|
320
|
+
forceGiven = false;
|
|
321
|
+
}
|
|
318
322
|
if (force < 1) {
|
|
319
323
|
return `Force below 1 isn't a spell, it's a wish.`;
|
|
320
324
|
}
|
|
@@ -331,11 +335,14 @@ export class CastCommand extends BypassCommand {
|
|
|
331
335
|
if (!def)
|
|
332
336
|
return `You don't know "${spellName}".`;
|
|
333
337
|
switch (def.cast) {
|
|
334
|
-
|
|
338
|
+
// An AREA combat spell is aimed at a point (castAreaSpell); a bolt at a person.
|
|
339
|
+
case 'combat': return def.area
|
|
340
|
+
? this.castAreaSpell(spellName, force, overcast, rest)
|
|
341
|
+
: this.castCombatSpell(spellName, force, overcast, rest);
|
|
335
342
|
case 'heal': return this.castHeal(force, overcast, rest);
|
|
336
343
|
case 'armor': return this.castArmor(force, overcast, rest);
|
|
337
344
|
case 'sustained-utility': return this.castSustainedUtility(spellName, force, overcast, rest);
|
|
338
|
-
case 'barrier': return this.castBarrier(spellName, force, overcast, rest);
|
|
345
|
+
case 'barrier': return this.castBarrier(spellName, force, overcast, rest, forceGiven ? undefined : safeForce);
|
|
339
346
|
}
|
|
340
347
|
}
|
|
341
348
|
/**
|
|
@@ -475,6 +482,100 @@ export class CastCommand extends BypassCommand {
|
|
|
475
482
|
}
|
|
476
483
|
return [];
|
|
477
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* ONE COMBAT SPELL'S NUMBERS AGAINST ONE TARGET, canon (SR5 p.283,
|
|
487
|
+
* RAG-checked 2026-10-05; user ruling: "fix all of it now").
|
|
488
|
+
*
|
|
489
|
+
* - DIRECT (soak 'willpower' / 'body': manabolt, stunball, powerball):
|
|
490
|
+
* the target opposes the casting test with Willpower (mana) or Body
|
|
491
|
+
* (physical), plus Magic Resistance (p.76). The damage is the NET
|
|
492
|
+
* HITS, and nothing soaks it -- "the target does not get to resist
|
|
493
|
+
* the damage, only the Spellcasting test".
|
|
494
|
+
* - INDIRECT (soak 'armor': fireball, clout, blast): dodged like a
|
|
495
|
+
* bullet with Reaction + Intuition; DV is Force + net hits, AP is
|
|
496
|
+
* -Force, resisted with Body + Armor.
|
|
497
|
+
*
|
|
498
|
+
* This replaced F-2 + net hits soaked by Willpower/Body for the direct
|
|
499
|
+
* family and AP floor(F/3) for the indirect one.
|
|
500
|
+
*
|
|
501
|
+
* A MANA BARRIER between caster and target (p.316) adds its Force to
|
|
502
|
+
* the defence (direct) or the resistance (indirect) pool.
|
|
503
|
+
*/
|
|
504
|
+
spellStrikeProfile(spell, spellKey, force, overcast, target, extraResist = 0) {
|
|
505
|
+
const label = (ap) => `with a Force ${force} ${spellKey}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}${ap !== 0 ? ` (AP -${ap})` : ''}`;
|
|
506
|
+
const weave = this.manaBarrierBetween(target);
|
|
507
|
+
const through = (weave ?? 0) + extraResist;
|
|
508
|
+
if (spell.soak !== 'armor') {
|
|
509
|
+
const resist = (spell.soak === 'willpower' ? target.willpower : target.body) + target.qualityRating('magic-resistance');
|
|
510
|
+
return {
|
|
511
|
+
label: label(0) + (weave ? ` -- through a Force ${weave} mana barrier` : ''),
|
|
512
|
+
pool: this.actor.getMagicPool() + this.actor.consumeEdgeBoost(),
|
|
513
|
+
damageBase: 0,
|
|
514
|
+
soakPool: 0,
|
|
515
|
+
defensePool: resist + through,
|
|
516
|
+
dealStun: spell.stun,
|
|
517
|
+
isSpell: true,
|
|
518
|
+
limit: force,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const armor = target.plane === 'meat' ? target.getArmorValue() : 0;
|
|
522
|
+
const modifiedArmor = Math.max(0, armor - force);
|
|
523
|
+
return {
|
|
524
|
+
label: label(force) + (weave ? ` -- through a Force ${weave} mana barrier` : ''),
|
|
525
|
+
pool: this.actor.getMagicPool() + this.actor.consumeEdgeBoost(),
|
|
526
|
+
damageBase: force,
|
|
527
|
+
soakPool: Math.max(0, target.getSoakPool() - armor + modifiedArmor) + through,
|
|
528
|
+
modifiedArmor: target.plane === 'meat' ? modifiedArmor : undefined,
|
|
529
|
+
dealStun: spell.stun,
|
|
530
|
+
isSpell: true,
|
|
531
|
+
limit: force,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/** The Force of a MANA BARRIER (not the caster's own) standing between
|
|
535
|
+
* the caster and someone -- what a spell has to get through (p.316). */
|
|
536
|
+
manaBarrierBetween(target) {
|
|
537
|
+
const from = this.scene.worldCellOf?.(this.actor);
|
|
538
|
+
const to = this.scene.worldCellOf?.(target);
|
|
539
|
+
if (!from || !to)
|
|
540
|
+
return undefined;
|
|
541
|
+
const hit = this.scene.barrierOnLine?.(from, to, 'mana');
|
|
542
|
+
return hit && hit.barrier.caster !== this.actor.name ? hit.barrier.force : undefined;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* AN INDIRECT SPELL IS A THING THAT FLIES (p.283: "a clear line of
|
|
546
|
+
* fire"), and a Physical Barrier stops it: the bolt hits the wall
|
|
547
|
+
* instead. Direct spells pass -- the barrier "does not impede
|
|
548
|
+
* spellcasting" save for indirect ones (p.294). The face takes the
|
|
549
|
+
* spell's DV (Force + hits, combat spells unchanged against barriers,
|
|
550
|
+
* p.197) if it beats the Armor.
|
|
551
|
+
*/
|
|
552
|
+
indirectWallBetween(from, to) {
|
|
553
|
+
if (!from || !to)
|
|
554
|
+
return undefined;
|
|
555
|
+
return this.scene.barrierOnLine?.(from, to, 'physical');
|
|
556
|
+
}
|
|
557
|
+
strikeWall(wall, force, spellKey, rolledHits) {
|
|
558
|
+
const { barrier, face } = wall;
|
|
559
|
+
// A barrier does not dodge (p.197): the casting test is unopposed
|
|
560
|
+
// and its hits add to the DV.
|
|
561
|
+
const hits = rolledHits ?? rollPool(this.actor.getMagicPool(), force).hits;
|
|
562
|
+
const dv = force + hits;
|
|
563
|
+
if (!pierces(dv, barrier.armor, 0)) {
|
|
564
|
+
return [` The ${spellKey} breaks against ${barrier.caster}'s shimmer-wall -- DV ${dv} against Armor ${barrier.armor} (p.197); nothing gets past it.`];
|
|
565
|
+
}
|
|
566
|
+
const soak = rollPool(barrier.structure + barrier.armor);
|
|
567
|
+
const dealt = Math.max(0, dv - soak.hits);
|
|
568
|
+
const breached = damageFace(barrier, face, dealt);
|
|
569
|
+
const out = [` The ${spellKey} slams into ${barrier.caster}'s barrier instead -- ${dealt} box${dealt === 1 ? '' : 'es'} to ${FACE_LABEL[face.id]} (${face.breached ? 'BREACHED' : `${face.damage}/${barrier.structure}`}).`];
|
|
570
|
+
if (breached && allBreached(barrier)) {
|
|
571
|
+
const caster = this.scene.getPlayers().find(p => p.name === barrier.caster);
|
|
572
|
+
if (caster)
|
|
573
|
+
caster.sustainedBarrier = undefined;
|
|
574
|
+
out.push(` The barrier comes down entirely -- ${barrier.caster}'s spell is over (p.294).`);
|
|
575
|
+
}
|
|
576
|
+
this.scene.updateExits?.(this.actor.currentLocation);
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
478
579
|
/**
|
|
479
580
|
* The shared combat-spell engine (manabolt/stunbolt/powerbolt/
|
|
480
581
|
* fireball): one exchange flow, parameterized by what SOAKS the hit
|
|
@@ -605,55 +706,28 @@ export class CastCommand extends BypassCommand {
|
|
|
605
706
|
if (defender.isIncapacitated())
|
|
606
707
|
break;
|
|
607
708
|
if (striker === this.actor) {
|
|
608
|
-
// ---- The spell
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const damageBase = spell.soak === 'armor' ? force : Math.max(1, force - 2);
|
|
626
|
-
const strike = exchange.resolveStrike(this.actor, target, {
|
|
627
|
-
label: `with a Force ${force} ${spellKey}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}${armorEaten > 0 ? ` (AP -${armorEaten})` : ''}`,
|
|
628
|
-
// An armed edge boost (see edge.ts) supercharges the bolt.
|
|
629
|
-
pool: this.actor.getMagicPool() + this.actor.consumeEdgeBoost(),
|
|
630
|
-
damageBase,
|
|
631
|
-
soakPool,
|
|
632
|
-
dealStun: spell.stun,
|
|
633
|
-
// Lets an Awakened defender's counterspelling answer it.
|
|
634
|
-
isSpell: true,
|
|
635
|
-
// Spellcasting + Magic [FORCE] (p.281): the Force you chose
|
|
636
|
-
// is the ceiling on the hits it can buy. Overcasting for
|
|
637
|
-
// reach costs drain AND raises this cap, which is the whole
|
|
638
|
-
// shape of the Force dial -- without it, a Force 1 bolt
|
|
639
|
-
// could land as hard as a Force 8 one on a lucky roll.
|
|
640
|
-
limit: force,
|
|
641
|
-
});
|
|
642
|
-
report.push('', ...strike.world, ...strike.meta);
|
|
643
|
-
await exchange.announce(strike.world, strike.meta);
|
|
709
|
+
// ---- The spell, canon (spellStrikeProfile, p.283): direct is
|
|
710
|
+
// opposed by Willpower/Body and does its net hits; indirect is
|
|
711
|
+
// dodged, Force + net hits at AP -Force, soaked by Body + Armor.
|
|
712
|
+
// An indirect bolt meets a Physical Barrier first. ----
|
|
713
|
+
const wall = spell.soak === 'armor'
|
|
714
|
+
? this.indirectWallBetween(this.scene.worldCellOf?.(this.actor), this.scene.worldCellOf?.(target))
|
|
715
|
+
: undefined;
|
|
716
|
+
if (wall) {
|
|
717
|
+
const hit = this.strikeWall(wall, force, spellKey);
|
|
718
|
+
report.push('', ...hit);
|
|
719
|
+
await exchange.announce(hit);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
const strike = exchange.resolveStrike(this.actor, target, this.spellStrikeProfile(spell, spellKey, force, overcast, target));
|
|
723
|
+
report.push('', ...strike.world, ...strike.meta);
|
|
724
|
+
await exchange.announce(strike.world, strike.meta);
|
|
725
|
+
}
|
|
644
726
|
if (target.isDown()) {
|
|
645
727
|
report.push(...await exchange.resolveDeath(target, this.actor, {
|
|
646
728
|
fallLine: `${target.name} ${flavor.fall} Their gear scatters across ${room.name}.`,
|
|
647
729
|
}));
|
|
648
730
|
}
|
|
649
|
-
// AREA (p.284): the blast doesn't stop at the person you aimed
|
|
650
|
-
// at. Everyone else hostile in the room resists the same
|
|
651
|
-
// damage with the same soak -- no defense roll, there's nothing
|
|
652
|
-
// to dodge -- and anyone it drops falls here too.
|
|
653
|
-
if (spell.area) {
|
|
654
|
-
const splash = await this.splashArea(exchange, spellKey, spell, force, target, damageBase, spellAp);
|
|
655
|
-
report.push(...splash);
|
|
656
|
-
}
|
|
657
731
|
// Drain comes due the moment the bolt flies, kill or not.
|
|
658
732
|
const drain = this.applyDrain(spell, force, overcast);
|
|
659
733
|
report.push('', ...drain);
|
|
@@ -765,27 +839,21 @@ export class CastCommand extends BypassCommand {
|
|
|
765
839
|
target.combatOpponent = target.combatOpponent ?? actor;
|
|
766
840
|
target.lastExchangeAt = now;
|
|
767
841
|
actor.surrendered = false;
|
|
768
|
-
// The spell's own numbers -- identical to the exchange path
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
?
|
|
772
|
-
:
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
dealStun: spell.stun,
|
|
784
|
-
isSpell: true,
|
|
785
|
-
limit: force,
|
|
786
|
-
});
|
|
787
|
-
report.push('', ...strike.world, ...strike.meta);
|
|
788
|
-
await exchange.announce(strike.world, strike.meta);
|
|
842
|
+
// The spell's own numbers -- identical to the exchange path above
|
|
843
|
+
// (spellStrikeProfile), and an indirect bolt meets a barrier first.
|
|
844
|
+
const wall = spell.soak === 'armor'
|
|
845
|
+
? this.indirectWallBetween(this.scene.worldCellOf?.(actor), this.scene.worldCellOf?.(target))
|
|
846
|
+
: undefined;
|
|
847
|
+
if (wall) {
|
|
848
|
+
const hit = this.strikeWall(wall, force, spellKey);
|
|
849
|
+
report.push('', ...hit);
|
|
850
|
+
await exchange.announce(hit);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
const strike = exchange.resolveStrike(actor, target, this.spellStrikeProfile(spell, spellKey, force, overcast, target));
|
|
854
|
+
report.push('', ...strike.world, ...strike.meta);
|
|
855
|
+
await exchange.announce(strike.world, strike.meta);
|
|
856
|
+
}
|
|
789
857
|
enc.syncWounds();
|
|
790
858
|
if (target.isDown()) {
|
|
791
859
|
report.push(...await exchange.resolveDeath(target, actor, {
|
|
@@ -800,10 +868,6 @@ export class CastCommand extends BypassCommand {
|
|
|
800
868
|
fallLine: `${target.name} sags and drops -- out cold, breathing. Their gear scatters across ${room.name}.`,
|
|
801
869
|
})));
|
|
802
870
|
}
|
|
803
|
-
if (spell.area) {
|
|
804
|
-
report.push(...await this.splashArea(exchange, spellKey, spell, force, target, damageBase, spellAp));
|
|
805
|
-
enc.syncWounds();
|
|
806
|
-
}
|
|
807
871
|
const drain = this.applyDrain(spell, force, overcast);
|
|
808
872
|
report.push('', ...drain);
|
|
809
873
|
await exchange.announce(drain);
|
|
@@ -814,61 +878,252 @@ export class CastCommand extends BypassCommand {
|
|
|
814
878
|
return human ? phaseHint(this.scene, actor).trim() : report.join('\n');
|
|
815
879
|
}
|
|
816
880
|
/**
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
881
|
+
* AN AREA COMBAT SPELL, CAST AT A POINT (SR5 p.281-284; RAG-checked
|
|
882
|
+
* 2026-10-05; user spec: "cast at a coordinate").
|
|
883
|
+
*
|
|
884
|
+
* cast <ball> [F] at <x,y[,z]> the point, in the map rulers' numbers
|
|
885
|
+
* cast <ball> [F] at <someone> the cell they stand in
|
|
886
|
+
*
|
|
887
|
+
* "Area spells can be cast on a specific target or a point in space that
|
|
888
|
+
* you can see ... a sphere centered on the target with a radius in
|
|
889
|
+
* meters equal to the Force" (p.281). "All targets in the area of effect
|
|
890
|
+
* that you can see, friend and foe alike, are valid targets" -- the
|
|
891
|
+
* caster included when they stand in it (user ruling 2026-10-05). This
|
|
892
|
+
* replaced "everyone hostile in the room, allies spared".
|
|
893
|
+
*
|
|
894
|
+
* - DIRECT balls (manaball, stunball, powerball): ONE casting test,
|
|
895
|
+
* opposed by each target's Willpower/Body (+ counterspelling, Magic
|
|
896
|
+
* Resistance, a mana barrier's Force); each takes their own net hits.
|
|
897
|
+
* Only targets the caster can SEE are touched (p.281).
|
|
898
|
+
* - INDIRECT balls (fireball, ball lightning, toxic wave, blast) fly
|
|
899
|
+
* and go boom like a grenade (p.283, p.181): Spellcasting + Magic
|
|
900
|
+
* [Force] (3). Beat the threshold and it lands where you aimed, its
|
|
901
|
+
* net hits on the DV; miss and it SCATTERS 2D6 m, less a metre per
|
|
902
|
+
* hit. DV is Force (+ net hits), AP -Force, soaked with Body + Armor
|
|
903
|
+
* by everyone the blast reaches -- no dodge. A Physical Barrier in
|
|
904
|
+
* its path is where it goes off.
|
|
905
|
+
*
|
|
906
|
+
* The area is measured on the district PLANE, so it does not stop at a
|
|
907
|
+
* room's edge; walls stop the blast and the caster's sight.
|
|
822
908
|
*/
|
|
823
|
-
async
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
const
|
|
847
|
-
this.
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
909
|
+
async castAreaSpell(spellKey, force, overcast, rest) {
|
|
910
|
+
const actor = this.actor;
|
|
911
|
+
const spell = spellFor(spellKey);
|
|
912
|
+
const flavor = COMBAT_SPELL_FLAVOR[spellKey] ?? { act: `cast ${spellKey} at`, fall: 'goes down.' };
|
|
913
|
+
const room = actor.currentLocation;
|
|
914
|
+
const words = (rest[0]?.toLowerCase() === 'at' ? rest.slice(1) : rest).filter(w => w.trim().length > 0);
|
|
915
|
+
const plane = this.scene.ensurePlane?.();
|
|
916
|
+
const from = this.scene.worldCellOf?.(actor);
|
|
917
|
+
if (!plane || !from)
|
|
918
|
+
return `There's nowhere here to aim a ${spellKey}.`;
|
|
919
|
+
const usage = `"cast ${spellKey} [force] at <x,y>" or "cast ${spellKey} [force] at <someone>" -- a ${force} m radius around that point. You're at ${formatCoord(from)}.`;
|
|
920
|
+
if (words.length === 0)
|
|
921
|
+
return `Cast it where? ${usage}`;
|
|
922
|
+
// WHERE IT IS AIMED.
|
|
923
|
+
let aim;
|
|
924
|
+
let aimedAt;
|
|
925
|
+
if (isCoordToken(words[0]) && words.length === 1) {
|
|
926
|
+
aim = parseCoord(words[0], from.z);
|
|
927
|
+
aimedAt = formatCoord(aim);
|
|
928
|
+
if (!this.inSight(plane, from, aim))
|
|
929
|
+
return `You can't see ${aimedAt} from here -- an area spell is cast at a point you can see (p.281).`;
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
const selfWord = words.length === 1 && /^(me|self|myself)$/i.test(words[0]);
|
|
933
|
+
const visible = this.scene.getActorsInRoom(room).filter(a => a === actor || (a.plane === actor.plane && actor.canPerceive(a)));
|
|
934
|
+
const picked = selfWord ? actor.name : fuzzyPickName(words.join(' '), visible.map(a => a.name));
|
|
935
|
+
const target = picked ? visible.find(a => a.name === picked) : undefined;
|
|
936
|
+
if (!target) {
|
|
937
|
+
const cross = crossPlaneRefusal(this.scene, actor, room, words.join(' '), 'spell');
|
|
938
|
+
if (cross)
|
|
939
|
+
return cross;
|
|
940
|
+
return `You don't see "${words.join(' ')}" here. ${usage}`;
|
|
851
941
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
942
|
+
aim = this.scene.worldCellOf?.(target);
|
|
943
|
+
aimedAt = target === actor ? 'yourself' : target.name;
|
|
944
|
+
if (!aim)
|
|
945
|
+
return `${target.name} isn't standing anywhere a ${spellKey} could find.`;
|
|
946
|
+
if (target !== actor && !this.inSight(plane, from, aim))
|
|
947
|
+
return `You can't see ${target.name} clearly enough to aim at them (p.281).`;
|
|
948
|
+
}
|
|
949
|
+
// WHO COULD BE CAUGHT: everyone on the caster's plane with a cell.
|
|
950
|
+
const sameSide = (a) => a.plane === actor.plane || (actor.plane === 'meat' && a.plane === 'drone') || (actor.plane === 'drone' && a.plane === 'meat');
|
|
951
|
+
const everyone = this.scene.allActors.filter(a => sameSide(a) && !a.isIncapacitated());
|
|
952
|
+
// THE COMBAT TURN: a Complex Action and the phase's attack (p.167).
|
|
953
|
+
const physical = actor.plane === 'meat' || actor.plane === 'drone';
|
|
954
|
+
const human = this.scene.isHumanControlled(actor);
|
|
955
|
+
if (physical) {
|
|
956
|
+
let enc = this.scene.encounterFor?.(actor);
|
|
957
|
+
const opener = everyone.find(a => a !== actor && a.currentLocation === room && a instanceof NPC);
|
|
958
|
+
if (!enc && opener) {
|
|
959
|
+
enc = await this.scene.startEncounter(room, { aggressor: actor, target: opener, ambush: false });
|
|
960
|
+
if (enc.ended)
|
|
961
|
+
return human ? '' : `The fight was over before ${actor.name} could cast.`;
|
|
962
|
+
if (actor.isIncapacitated())
|
|
963
|
+
return human ? `You're down before you can cast.` : `${actor.name} went down before casting.`;
|
|
964
|
+
if (!enc.isPhaseOf(actor)) {
|
|
965
|
+
const who = enc.phaseActor?.name ?? 'the others';
|
|
966
|
+
return human
|
|
967
|
+
? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${spellKey} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
|
|
968
|
+
: `${actor.name} gathers mana -- initiative rolled; ${who} first.`;
|
|
969
|
+
}
|
|
862
970
|
}
|
|
863
|
-
else if (
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
971
|
+
else if (enc && !enc.isPhaseOf(actor)) {
|
|
972
|
+
return notYourPhase(enc, actor);
|
|
973
|
+
}
|
|
974
|
+
const bill = billAction(this.scene, actor, 'complex', `Cast ${spellKey}`, { attack: true });
|
|
975
|
+
if (bill)
|
|
976
|
+
return bill;
|
|
977
|
+
}
|
|
978
|
+
const exchange = new CombatExchange(this.scene, this.logger, actor);
|
|
979
|
+
actor.performAction(`casts ${spellKey} at`, aimedAt);
|
|
980
|
+
this.scene.addWorldEvent(`${actor.name} ${flavor.act} ${aimedAt} in ${room.name}.`);
|
|
981
|
+
const lines = [];
|
|
982
|
+
const meta = [];
|
|
983
|
+
const pool = actor.getMagicPool() + actor.consumeEdgeBoost();
|
|
984
|
+
const roll = rollPool(pool, force);
|
|
985
|
+
const header = `${actor.name} casts ${spellKey} at Force ${force}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}, at ${aimedAt}`;
|
|
986
|
+
const hurt = [];
|
|
987
|
+
if (spell.soak !== 'armor') {
|
|
988
|
+
// ---- DIRECT: one test, opposed by each target it can see. ----
|
|
989
|
+
meta.push(`${header}: casting ${formatRoll(roll)}`);
|
|
990
|
+
lines.push(`${header} -- ${force} m of ${spell.type === 'mana' ? 'raw mana' : 'force'} around ${aimedAt}.`);
|
|
991
|
+
const caught = everyone.filter(a => {
|
|
992
|
+
const c = this.scene.worldCellOf?.(a);
|
|
993
|
+
if (!c || distanceMeters(aim, c) > force)
|
|
994
|
+
return false;
|
|
995
|
+
// "...that you can see" (p.281): your own body counts; others must
|
|
996
|
+
// be perceived and in plain sight of you.
|
|
997
|
+
return a === actor || (actor.canPerceive(a) && this.inSight(plane, from, c));
|
|
998
|
+
});
|
|
999
|
+
if (caught.length === 0)
|
|
1000
|
+
lines.push(` The spell washes over empty ground.`);
|
|
1001
|
+
for (const victim of caught) {
|
|
1002
|
+
// Mana touches only the living and the magical (p.284).
|
|
1003
|
+
if (spell.type === 'mana' && victim.plane === 'drone') {
|
|
1004
|
+
lines.push(` ${victim.name}'s frame has no aura for mana to bite -- untouched.`);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
const weave = victim === actor ? undefined : this.manaBarrierBetween(victim);
|
|
1008
|
+
const counter = typeof victim.isCaster === 'function' && victim.isCaster() ? victim.skillRating('counterspelling') : 0;
|
|
1009
|
+
const resist = (spell.soak === 'willpower' ? victim.willpower : victim.body)
|
|
1010
|
+
+ victim.qualityRating('magic-resistance') + counter + (weave ?? 0);
|
|
1011
|
+
const defense = rollPool(resist);
|
|
1012
|
+
const net = roll.hits - defense.hits;
|
|
1013
|
+
meta.push(` ${victim.name}: resists ${formatRoll(defense)}${weave ? ` (+${weave} mana barrier)` : ''}${counter ? ` (+${counter} counterspelling)` : ''} -- ${Math.max(0, net)} net`);
|
|
1014
|
+
if (net <= 0) {
|
|
1015
|
+
lines.push(` ${victim.name} holds against it.`);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
lines.push(...this.landAreaDamage(victim, net, !!spell.stun));
|
|
1019
|
+
hurt.push(victim);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
// ---- INDIRECT: it flies, and goes off like a grenade. ----
|
|
1024
|
+
const hits = roll.hits;
|
|
1025
|
+
let at = aim;
|
|
1026
|
+
let dv = force;
|
|
1027
|
+
if (hits >= 3) {
|
|
1028
|
+
dv = force + (hits - 3);
|
|
1029
|
+
meta.push(`${header}: Spellcasting + Magic [${force}] (3) ${formatRoll(roll)} -- on target, +${hits - 3} DV (p.283)`);
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
const metres = Math.max(0, rollD6Total(2) - hits);
|
|
1033
|
+
const angle = Math.random() * Math.PI * 2;
|
|
1034
|
+
at = {
|
|
1035
|
+
x: aim.x + Math.round(Math.cos(angle) * metres / METERS_PER_CELL),
|
|
1036
|
+
y: aim.y + Math.round(Math.sin(angle) * metres / METERS_PER_CELL),
|
|
1037
|
+
z: aim.z,
|
|
1038
|
+
};
|
|
1039
|
+
meta.push(`${header}: Spellcasting + Magic [${force}] (3) ${formatRoll(roll)} -- misses the threshold, scatters ${metres} m (p.181)`);
|
|
1040
|
+
}
|
|
1041
|
+
// What it meets on the way: a wall stops it short; so does a barrier.
|
|
1042
|
+
let blocked = false;
|
|
1043
|
+
const path = line3D(from, at);
|
|
1044
|
+
for (let i = 1; i < path.length; i++) {
|
|
1045
|
+
const c = path[i];
|
|
1046
|
+
const wall = this.scene.barrierAt(c, 'physical');
|
|
1047
|
+
if (wall) {
|
|
1048
|
+
lines.push(`${header} -- it bursts against ${wall.barrier.caster}'s barrier at ${formatCoord(c)}.`);
|
|
1049
|
+
lines.push(...this.strikeWall(wall, force, spellKey, dv - force));
|
|
1050
|
+
at = path[i - 1];
|
|
1051
|
+
blocked = true;
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
if (isSolid(plane, c)) {
|
|
1055
|
+
at = path[i - 1];
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!blocked)
|
|
1060
|
+
lines.push(`${header} -- it bursts at ${formatCoord(at)}${at.x !== aim.x || at.y !== aim.y ? `, wide of the mark` : ''}: DV ${dv}, AP -${force}, ${force} m.`);
|
|
1061
|
+
const weave = this.scene.barrierOnLine?.(from, at, 'mana');
|
|
1062
|
+
const through = weave && weave.barrier.caster !== actor.name ? weave.barrier.force : 0;
|
|
1063
|
+
const caught = everyone.filter(a => {
|
|
1064
|
+
const c = this.scene.worldCellOf?.(a);
|
|
1065
|
+
if (!c || distanceMeters(at, c) > force)
|
|
1066
|
+
return false;
|
|
1067
|
+
// The blast reaches what nothing solid shields.
|
|
1068
|
+
return wallsBetween(plane, at, c) === 0 && !this.scene.barrierOnLine?.(at, c, 'physical');
|
|
1069
|
+
});
|
|
1070
|
+
if (caught.length === 0)
|
|
1071
|
+
lines.push(` The blast catches nobody.`);
|
|
1072
|
+
for (const victim of caught) {
|
|
1073
|
+
const armor = victim.plane === 'meat' ? victim.getArmorValue() : 0;
|
|
1074
|
+
const modified = Math.max(0, armor - force);
|
|
1075
|
+
const soak = rollPool(Math.max(0, victim.getSoakPool() - armor + modified) + through);
|
|
1076
|
+
const dealt = Math.max(0, dv - soak.hits);
|
|
1077
|
+
// p.168: a Physical hit under the modified Armor turns to Stun.
|
|
1078
|
+
const stun = !!spell.stun || (victim.plane === 'meat' && dv < modified);
|
|
1079
|
+
meta.push(` ${victim.name}: soak ${formatRoll(soak)} v. DV ${dv} (Armor ${armor} -> ${modified})${through ? ` (+${through} mana barrier)` : ''} -- ${dealt}`);
|
|
1080
|
+
if (dealt === 0) {
|
|
1081
|
+
lines.push(` ${victim.name} rides it out.`);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
lines.push(...this.landAreaDamage(victim, dealt, stun));
|
|
1085
|
+
hurt.push(victim);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
await exchange.announce(lines, meta);
|
|
1089
|
+
const report = [...lines, ...(human ? [] : meta)];
|
|
1090
|
+
for (const victim of hurt) {
|
|
1091
|
+
if (victim.isDown()) {
|
|
1092
|
+
report.push(...await exchange.resolveDeath(victim, actor, {
|
|
1093
|
+
fallLine: `${victim.name} ${flavor.fall} Their gear scatters across ${victim.currentLocation.name}.`,
|
|
867
1094
|
}));
|
|
868
1095
|
}
|
|
1096
|
+
else if (victim.isUnconscious()) {
|
|
1097
|
+
report.push(...(this.scene.isHumanControlled(victim)
|
|
1098
|
+
? await exchange.resolvePlayerKnockout(actor, victim)
|
|
1099
|
+
: await exchange.resolveDeath(victim, actor, {
|
|
1100
|
+
fatal: false,
|
|
1101
|
+
fallLine: `${victim.name} drops where the spell found them -- out cold, breathing.`,
|
|
1102
|
+
})));
|
|
1103
|
+
}
|
|
869
1104
|
}
|
|
870
|
-
|
|
871
|
-
|
|
1105
|
+
this.scene.encounterFor?.(actor)?.syncWounds();
|
|
1106
|
+
if (!actor.isIncapacitated()) {
|
|
1107
|
+
const drain = this.applyDrain(spell, force, overcast);
|
|
1108
|
+
report.push('', ...drain);
|
|
1109
|
+
await exchange.announce(drain);
|
|
1110
|
+
report.push(...await this.drainCollapse(exchange));
|
|
1111
|
+
}
|
|
1112
|
+
this.scene.encounterFor?.(actor)?.syncWounds();
|
|
1113
|
+
this.scene.updateStatus();
|
|
1114
|
+
return human ? (physical ? phaseHint(this.scene, actor).trim() : '') : report.join('\n');
|
|
1115
|
+
}
|
|
1116
|
+
/** A box count landing on one body caught in an area spell. */
|
|
1117
|
+
landAreaDamage(victim, boxes, stun) {
|
|
1118
|
+
if (stun) {
|
|
1119
|
+
const { stun: s, overflow } = victim.takeStun(boxes);
|
|
1120
|
+
this.logger.write(`${this.actor.name}'s area spell dealt ${boxes} STUN to ${victim.name}.`);
|
|
1121
|
+
return [` ${victim.name} is caught -- ${s} stun: ${victim.stunSummary()}`,
|
|
1122
|
+
...(overflow > 0 ? [` It spills past consciousness -- ${overflow} box${overflow === 1 ? '' : 'es'} physical: ${victim.conditionSummary()}`] : [])];
|
|
1123
|
+
}
|
|
1124
|
+
victim.takeDamage(boxes);
|
|
1125
|
+
this.logger.write(`${this.actor.name}'s area spell dealt ${boxes} to ${victim.name}.`);
|
|
1126
|
+
return [` ${victim.name} is caught -- ${boxes} box${boxes === 1 ? '' : 'es'}: ${victim.conditionSummary()}`];
|
|
872
1127
|
}
|
|
873
1128
|
async castHeal(force, overcast, rest = []) {
|
|
874
1129
|
const { target, error } = this.resolveAllyTarget(rest);
|
|
@@ -980,7 +1235,8 @@ export class CastCommand extends BypassCommand {
|
|
|
980
1235
|
* cast barrier [F] <x,y[,z]> <x,y[,z]> a wall between two points
|
|
981
1236
|
* cast barrier [F] <x,y[,z]> a dome around one point
|
|
982
1237
|
* cast barrier [F] <direction> [n] a 1x3 wall n cells that way
|
|
983
|
-
* cast barrier [F] at <someone>
|
|
1238
|
+
* cast barrier [F] at <someone> [size] a dome around them ("me"/"self": you);
|
|
1239
|
+
* size = radius in cells = Force / 2
|
|
984
1240
|
* cast barrier [F] a dome around yourself
|
|
985
1241
|
* cast barrier off let it go (free)
|
|
986
1242
|
*
|
|
@@ -993,7 +1249,9 @@ export class CastCommand extends BypassCommand {
|
|
|
993
1249
|
* Bare "cast north" no longer reaches a ward from here -- that is
|
|
994
1250
|
* "dispel north" (user ruling 2026-10-05).
|
|
995
1251
|
*/
|
|
996
|
-
async castBarrier(spellKey, force, overcast, rest
|
|
1252
|
+
async castBarrier(spellKey, force, overcast, rest,
|
|
1253
|
+
/** Set when no Force was named: the talent a sized cast may reach. */
|
|
1254
|
+
autoUpTo) {
|
|
997
1255
|
const actor = this.actor;
|
|
998
1256
|
const mana = spellKey === 'manabarrier';
|
|
999
1257
|
const kind = mana ? 'mana' : 'physical';
|
|
@@ -1028,11 +1286,12 @@ export class CastCommand extends BypassCommand {
|
|
|
1028
1286
|
let plan;
|
|
1029
1287
|
let around;
|
|
1030
1288
|
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)}.`;
|
|
1289
|
+
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|me> [radius in cells]". Leave the Force off and it is sized for you. The map rulers give x and y; you're at ${formatCoord(from)}.`;
|
|
1032
1290
|
const unseen = (what) => `You can't see ${what} from here -- a barrier is cast at what you can see (LOS, p.294).`;
|
|
1033
1291
|
if (words.length === 0) {
|
|
1034
1292
|
center = from;
|
|
1035
1293
|
around = 'you';
|
|
1294
|
+
force = this.domeForce(force, autoUpTo);
|
|
1036
1295
|
plan = planDome(from, force);
|
|
1037
1296
|
}
|
|
1038
1297
|
else if (isCoordToken(words[0])) {
|
|
@@ -1041,6 +1300,10 @@ export class CastCommand extends BypassCommand {
|
|
|
1041
1300
|
const a = parseCoord(words[0], from.z);
|
|
1042
1301
|
if (words.length === 2) {
|
|
1043
1302
|
const b = parseCoord(words[1], from.z);
|
|
1303
|
+
const sized = this.sizeForce(spellKey, force, forceForWall(a, b), autoUpTo);
|
|
1304
|
+
if (typeof sized === 'string')
|
|
1305
|
+
return sized;
|
|
1306
|
+
force = sized;
|
|
1044
1307
|
plan = planWall(a, b, force);
|
|
1045
1308
|
if (plan.error)
|
|
1046
1309
|
return plan.error;
|
|
@@ -1050,6 +1313,7 @@ export class CastCommand extends BypassCommand {
|
|
|
1050
1313
|
}
|
|
1051
1314
|
else {
|
|
1052
1315
|
center = a;
|
|
1316
|
+
force = this.domeForce(force, autoUpTo);
|
|
1053
1317
|
plan = planDome(a, force);
|
|
1054
1318
|
if (!this.inSight(plane, from, a))
|
|
1055
1319
|
return unseen(formatCoord(a));
|
|
@@ -1060,6 +1324,10 @@ export class CastCommand extends BypassCommand {
|
|
|
1060
1324
|
return `"cast ${spellKey} ${words[0]} [cells]" -- the distance is a whole number of cells.`;
|
|
1061
1325
|
}
|
|
1062
1326
|
const distance = words.length === 2 ? parseInt(words[1], 10) : 1;
|
|
1327
|
+
const sized = this.sizeForce(spellKey, force, DIRECTIONAL_WALL_FORCE, autoUpTo);
|
|
1328
|
+
if (typeof sized === 'string')
|
|
1329
|
+
return sized;
|
|
1330
|
+
force = sized;
|
|
1063
1331
|
plan = planDirectionalWall(from, words[0].toLowerCase(), distance, force);
|
|
1064
1332
|
if (plan.error)
|
|
1065
1333
|
return plan.error;
|
|
@@ -1070,12 +1338,22 @@ export class CastCommand extends BypassCommand {
|
|
|
1070
1338
|
}
|
|
1071
1339
|
else {
|
|
1072
1340
|
const lead = words[0].toLowerCase();
|
|
1073
|
-
|
|
1341
|
+
let nameWords = lead === 'at' || lead === 'on' || lead === 'around' ? words.slice(1) : words;
|
|
1342
|
+
// "at <someone> [size]" (user 2026-10-05): a trailing number is the
|
|
1343
|
+
// dome's RADIUS in map cells. p.294 makes the radius the Force in
|
|
1344
|
+
// metres, so the size IS a Force -- two cells is Force 4.
|
|
1345
|
+
let size;
|
|
1346
|
+
if (nameWords.length > 1 && /^\d+$/.test(nameWords[nameWords.length - 1])) {
|
|
1347
|
+
size = parseInt(nameWords[nameWords.length - 1], 10);
|
|
1348
|
+
nameWords = nameWords.slice(0, -1);
|
|
1349
|
+
}
|
|
1074
1350
|
if (nameWords.length === 0)
|
|
1075
1351
|
return usage;
|
|
1352
|
+
// "me" / "self" / "myself" are the caster (user 2026-10-05).
|
|
1353
|
+
const selfWord = nameWords.length === 1 && /^(me|self|myself|yourself)$/i.test(nameWords[0]);
|
|
1076
1354
|
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;
|
|
1355
|
+
const picked = selfWord ? actor.name : fuzzyPickName(nameWords.join(' '), candidates.map(a => a.name));
|
|
1356
|
+
const target = selfWord ? actor : picked ? candidates.find(a => a.name === picked) : undefined;
|
|
1079
1357
|
if (!target)
|
|
1080
1358
|
return `You don't see "${nameWords.join(' ')}" here. ${usage}`;
|
|
1081
1359
|
const at = this.scene.worldCellOf?.(target);
|
|
@@ -1085,6 +1363,21 @@ export class CastCommand extends BypassCommand {
|
|
|
1085
1363
|
return unseen(target.name);
|
|
1086
1364
|
center = at;
|
|
1087
1365
|
around = target === actor ? 'you' : target.name;
|
|
1366
|
+
if (size !== undefined) {
|
|
1367
|
+
if (size < 1)
|
|
1368
|
+
return `A dome's size is its radius in cells, 1 or more.`;
|
|
1369
|
+
const needed = Math.ceil(size * METERS_PER_CELL);
|
|
1370
|
+
if (autoUpTo === undefined && needed !== force) {
|
|
1371
|
+
return `A dome's radius IS its Force in metres (p.294): ${size} cell${size === 1 ? '' : 's'} is Force ${needed}, not ${force}. Name one or the other.`;
|
|
1372
|
+
}
|
|
1373
|
+
const sized = this.sizeForce(spellKey, force, needed, autoUpTo, 'dome');
|
|
1374
|
+
if (typeof sized === 'string')
|
|
1375
|
+
return sized;
|
|
1376
|
+
force = sized;
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
force = this.domeForce(force, autoUpTo);
|
|
1380
|
+
}
|
|
1088
1381
|
plan = planDome(at, force);
|
|
1089
1382
|
}
|
|
1090
1383
|
if (plan.error)
|
|
@@ -1156,6 +1449,32 @@ export class CastCommand extends BypassCommand {
|
|
|
1156
1449
|
lines.push(...await this.drainCollapse(new CombatExchange(this.scene, this.logger, this.actor)));
|
|
1157
1450
|
return this.finishSpell(lines);
|
|
1158
1451
|
}
|
|
1452
|
+
/**
|
|
1453
|
+
* NO FORCE NAMED: SIZE IT TO THE SHAPE (user 2026-10-05). A wall gets
|
|
1454
|
+
* the least Force that raises it -- never less than the default, and
|
|
1455
|
+
* never past your talent: overcasting turns the drain PHYSICAL (p.281),
|
|
1456
|
+
* and that is a choice to make out loud, not one made for you.
|
|
1457
|
+
*/
|
|
1458
|
+
sizeForce(spellKey, force, needed, autoUpTo, what = 'wall') {
|
|
1459
|
+
// A wall only ever grows to fit; a sized dome IS its size.
|
|
1460
|
+
if (autoUpTo === undefined || (what === 'wall' && needed <= force))
|
|
1461
|
+
return force;
|
|
1462
|
+
if (needed > autoUpTo) {
|
|
1463
|
+
return `That ${what} needs Force ${needed}, past your talent (${autoUpTo}) -- name the Force to overcast it on purpose ("cast ${spellKey} ${needed} ..."; the drain lands PHYSICAL, p.281).`;
|
|
1464
|
+
}
|
|
1465
|
+
return needed;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* A DOME WITH NO FORCE NAMED stands one storey: Force 3, a dome 3 m
|
|
1469
|
+
* high (p.294: height = radius = Force m). Anything lower would not
|
|
1470
|
+
* cover the person it is raised around. Never past your talent -- a
|
|
1471
|
+
* caster with less Magic gets the tallest dome they can hold safely.
|
|
1472
|
+
*/
|
|
1473
|
+
domeForce(force, autoUpTo) {
|
|
1474
|
+
if (autoUpTo === undefined)
|
|
1475
|
+
return force;
|
|
1476
|
+
return Math.max(force, Math.min(DOME_FORCE, autoUpTo));
|
|
1477
|
+
}
|
|
1159
1478
|
/** LINE OF SIGHT for an area spell's point (p.281): no wall between. */
|
|
1160
1479
|
inSight(plane, from, to) {
|
|
1161
1480
|
return wallsBetween(plane, from, to) === 0;
|
|
@@ -140,35 +140,35 @@ export class SpellsCommand extends Command {
|
|
|
140
140
|
// health and manipulation work.
|
|
141
141
|
const book = [
|
|
142
142
|
{ key: 'manabolt', cast: 'manabolt', how: 'cast manabolt [force] at <target>',
|
|
143
|
-
line: 'direct mana:
|
|
143
|
+
line: 'direct mana: they oppose your casting test with WILLPOWER; every net hit is a box, and nothing soaks it. Drain F-3.' },
|
|
144
144
|
{ key: 'stunbolt', cast: 'stunbolt', how: 'cast stunbolt at <target>',
|
|
145
|
-
line: 'direct mana, merciful:
|
|
145
|
+
line: 'direct mana, merciful: as manabolt (Willpower opposes, net hits are boxes), but STUN. Drain F-3.' },
|
|
146
146
|
{ key: 'powerbolt', cast: 'powerbolt', how: 'cast powerbolt at <target>',
|
|
147
|
-
line: 'direct physical:
|
|
148
|
-
{ key: 'fireball', cast: 'fireball', how: 'cast fireball at <
|
|
149
|
-
line: 'indirect elemental:
|
|
147
|
+
line: 'direct physical: they oppose your casting test with BODY; net hits are boxes, armor means nothing. Drain F-3.' },
|
|
148
|
+
{ key: 'fireball', cast: 'fireball', how: 'cast fireball [force] at <x,y | someone>',
|
|
149
|
+
line: 'AREA indirect elemental: Force m of fire. Thrown like a grenade (threshold 3, or it scatters 2D6 m); DV Force + net hits, AP -Force, soaked with Body + armor -- everyone in it, you too. Drain F-1.' },
|
|
150
150
|
{ key: 'lightning-bolt', cast: 'lightning-bolt', how: 'cast lightning-bolt at <target>',
|
|
151
|
-
line: 'indirect elemental:
|
|
151
|
+
line: 'indirect elemental: dodged like a bullet (Reaction + Intuition); DV Force + net hits, AP -Force, Body + armor soaks. Drain F-3.' },
|
|
152
152
|
{ key: 'acid-stream', cast: 'acid-stream', how: 'cast acid-stream at <target>',
|
|
153
|
-
line: 'indirect elemental:
|
|
153
|
+
line: 'indirect elemental: dodged like a bullet; DV Force + net hits, AP -Force -- acid eats armor. Drain F-3.' },
|
|
154
154
|
{ key: 'flamethrower', cast: 'flamethrower', how: 'cast flamethrower at <target>',
|
|
155
|
-
line: 'indirect elemental: a
|
|
155
|
+
line: 'indirect elemental: a sheet of flame, dodged like a bullet; DV Force + net hits, AP -Force. Drain F-3.' },
|
|
156
156
|
{ key: 'clout', cast: 'clout', how: 'cast clout at <target>',
|
|
157
|
-
line: 'indirect force: an invisible fist,
|
|
157
|
+
line: 'indirect force: an invisible fist, dodged like a bullet; DV Force + net hits, AP -Force, STUN. Drain F-3.' },
|
|
158
158
|
{ key: 'death-touch', cast: 'death-touch', how: 'cast death-touch at <target>',
|
|
159
|
-
line: 'direct mana at TOUCH range -- you close first,
|
|
159
|
+
line: 'direct mana at TOUCH range -- you close first; Willpower opposes, net hits are boxes. Drain F-6.' },
|
|
160
160
|
{ key: 'knockout', cast: 'knockout', how: 'cast knockout at <target>',
|
|
161
|
-
line: 'direct mana at TOUCH range, STUN
|
|
161
|
+
line: 'direct mana at TOUCH range, STUN: the quiet way to take one alive. Willpower opposes. Drain F-6.' },
|
|
162
162
|
{ key: 'shatter', cast: 'shatter', how: 'cast shatter at <target>',
|
|
163
|
-
line: 'direct physical at TOUCH range --
|
|
164
|
-
{ key: 'manaball', cast: 'manaball', how: 'cast manaball at <
|
|
165
|
-
line: 'AREA direct mana:
|
|
166
|
-
{ key: 'stunball', cast: 'stunball', how: 'cast stunball at <
|
|
167
|
-
line: 'AREA direct mana, STUN: the
|
|
168
|
-
{ key: 'powerball', cast: 'powerball', how: 'cast powerball at <
|
|
169
|
-
line: 'AREA direct physical:
|
|
170
|
-
{ key: 'ball-lightning', cast: 'ball-lightning', how: 'cast ball-lightning at <
|
|
171
|
-
line: 'AREA indirect elemental:
|
|
163
|
+
line: 'direct physical at TOUCH range -- Body opposes, net hits are boxes, bone gives. Drain F-6.' },
|
|
164
|
+
{ key: 'manaball', cast: 'manaball', how: 'cast manaball [force] at <x,y | someone>',
|
|
165
|
+
line: 'AREA direct mana: Force m around the point; everyone in it you can SEE, friend or foe (you too), opposes with Willpower and takes their own net hits. Drain F.' },
|
|
166
|
+
{ key: 'stunball', cast: 'stunball', how: 'cast stunball [force] at <x,y | someone>',
|
|
167
|
+
line: 'AREA direct mana, STUN: as manaball, but the knockout. Drain F.' },
|
|
168
|
+
{ key: 'powerball', cast: 'powerball', how: 'cast powerball [force] at <x,y | someone>',
|
|
169
|
+
line: 'AREA direct physical: as manaball, Body opposes, armor does not help. Drain F.' },
|
|
170
|
+
{ key: 'ball-lightning', cast: 'ball-lightning', how: 'cast ball-lightning [force] at <x,y | someone>',
|
|
171
|
+
line: 'AREA indirect elemental: as fireball, in lightning. Drain F-1.' },
|
|
172
172
|
{ key: 'heal', cast: 'heal', how: 'cast heal [on <ally>]',
|
|
173
173
|
line: "knit physical wounds closed, yours or an ally's (touch range); Force caps the boxes. Drain F-4." },
|
|
174
174
|
{ key: 'armor', cast: 'armor', how: 'cast armor [on <ally>]',
|
|
@@ -177,9 +177,9 @@ export class SpellsCommand extends Command {
|
|
|
177
177
|
line: 'sustained: light slides around the bearer, +3 on sneak contests at -2 dice. Drain F-4.' },
|
|
178
178
|
{ key: 'reflexes', cast: 'increase reflexes', how: 'cast increase reflexes [on <ally>]',
|
|
179
179
|
line: 'sustained: +2 initiative for the bearer at -2 dice. Drain F-3.' },
|
|
180
|
-
{ key: 'barrier', cast: 'barrier', how: 'cast barrier [F] <x,y> <x,y> (wall) | <x,y> (dome) | <direction> [cells] | at <someone> | (bare: a dome round you); cast barrier off',
|
|
180
|
+
{ key: 'barrier', cast: 'barrier', how: 'cast barrier [F] <x,y> <x,y> (wall) | <x,y> (dome) | <direction> [cells] | at <someone|me> [radius] | (bare: a dome round you); Force optional -- sized to fit; cast barrier off',
|
|
181
181
|
line: 'sustained: hardened air -- a wall Force x 2 m long and Force m high, or a Force-metre dome. Armor/Structure = hits, a track per face; nobody meat-side passes (you included) at -2 dice to you. Drain F-1.' },
|
|
182
|
-
{ key: 'manabarrier', cast: 'manabarrier', how: 'cast manabarrier [F] <x,y> <x,y> | <x,y> | <direction> [cells] | at <someone
|
|
182
|
+
{ key: 'manabarrier', cast: 'manabarrier', how: 'cast manabarrier [F] <x,y> <x,y> | <x,y> | <direction> [cells] | at <someone|me> [radius]; cast manabarrier off',
|
|
183
183
|
line: 'sustained: woven mana, same shapes -- stops SPIRITS and astral forms; the living pass freely, and so do you. Armor/Structure = Force. Drain F-2.' },
|
|
184
184
|
];
|
|
185
185
|
for (const entry of book) {
|
|
@@ -3355,5 +3355,30 @@
|
|
|
3355
3355
|
// - Wire: the room grid carries `barriers` (room-local cells; a mana
|
|
3356
3356
|
// barrier only for eyes that can see it). Docks draw x/y rulers in
|
|
3357
3357
|
// plane coordinates and the caption gives your x,y,z.
|
|
3358
|
-
|
|
3358
|
+
// 1.197.0 (2026-10-05): BARRIERS SIZE THEMSELVES. With no Force named, a
|
|
3359
|
+
// wall is cast at the least Force that raises it (a cell of length per
|
|
3360
|
+
// Force, Force 3 a level), the directional wall at Force 3, and a dome
|
|
3361
|
+
// at Force 3 (one storey) -- never past the caster's talent; an
|
|
3362
|
+
// overcast must be named. `cast barrier [F] at <someone> [size]` takes
|
|
3363
|
+
// a radius in cells (size x 2 = Force; a Force and size that disagree
|
|
3364
|
+
// are refused), and "me"/"self" name the caster.
|
|
3365
|
+
// The HUD's sustained line and `spells` show the barrier's Armor,
|
|
3366
|
+
// Structure and every face's damage track.
|
|
3367
|
+
// 1.198.0 (2026-10-05): COMBAT SPELLS, CANON (SR5 p.281-284, p.316).
|
|
3368
|
+
// - Direct spells (manabolt, stunbolt, powerbolt, the touch three, the
|
|
3369
|
+
// balls) are opposed by Willpower/Body (+ Magic Resistance, counter-
|
|
3370
|
+
// spelling) and do their NET HITS, unsoaked -- not F-2 + net soaked.
|
|
3371
|
+
// - Indirect spells are dodged (Reaction + Intuition): DV Force + net
|
|
3372
|
+
// hits, AP -Force, Body + Armor soaks -- not AP floor(F/3).
|
|
3373
|
+
// - Area spells are aimed at a point: `cast <ball> [F] at <x,y[,z]>` or
|
|
3374
|
+
// `at <someone>`; everyone within Force metres on the district plane
|
|
3375
|
+
// is caught, friend and foe, the caster too (user ruling). Direct balls
|
|
3376
|
+
// touch only who the caster can see, each opposing one casting test.
|
|
3377
|
+
// Indirect balls fly like a grenade: threshold 3 (hits are capped at
|
|
3378
|
+
// Force), net hits on the DV, else scatter 2D6 m less a metre a hit;
|
|
3379
|
+
// walls shield. Replaces "the room splashes, allies spared".
|
|
3380
|
+
// - A mana barrier between adds its Force to defence/soak; a physical
|
|
3381
|
+
// barrier stops an indirect spell, which hits the face instead.
|
|
3382
|
+
// - Fireball drains F - 1 (catalog v36).
|
|
3383
|
+
export const ENGINE_VERSION = '1.198.0';
|
|
3359
3384
|
//# sourceMappingURL=engine-version.js.map
|
|
@@ -149,7 +149,7 @@ import { ForceCommand } from './commands/force.js';
|
|
|
149
149
|
import { CompleteCommand } from './commands/complete.js';
|
|
150
150
|
import { DisableCommand, BrickCommand } from './commands/disable.js';
|
|
151
151
|
import { CastCommand } from './commands/cast.js';
|
|
152
|
-
import { barrierSpan } from './utilities/barrier-geometry.js';
|
|
152
|
+
import { barrierSpan, barrierStats } from './utilities/barrier-geometry.js';
|
|
153
153
|
import { FollowCommand, UnfollowCommand } from './commands/follow.js';
|
|
154
154
|
import { BringCommand } from './commands/bring.js';
|
|
155
155
|
import { SurrenderCommand } from './commands/surrender.js';
|
|
@@ -9478,10 +9478,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
9478
9478
|
// the one that strands the team -- and both of them seal an exit
|
|
9479
9479
|
// that the caster cannot pass either.
|
|
9480
9480
|
if (player.sustainedBarrier) {
|
|
9481
|
-
heldSpells.push(`physical barrier (${player.sustainedBarrier.shape} ${barrierSpan(player.sustainedBarrier)}, ${player.sustainedBarrier.roomName})`);
|
|
9481
|
+
heldSpells.push(`physical barrier (${player.sustainedBarrier.shape} ${barrierSpan(player.sustainedBarrier)}, ${player.sustainedBarrier.roomName}; ${barrierStats(player.sustainedBarrier)})`);
|
|
9482
9482
|
}
|
|
9483
9483
|
if (player.sustainedManaBarrier) {
|
|
9484
|
-
heldSpells.push(`mana barrier (${player.sustainedManaBarrier.shape} ${barrierSpan(player.sustainedManaBarrier)}, ${player.sustainedManaBarrier.roomName})`);
|
|
9484
|
+
heldSpells.push(`mana barrier (${player.sustainedManaBarrier.shape} ${barrierSpan(player.sustainedManaBarrier)}, ${player.sustainedManaBarrier.roomName}; ${barrierStats(player.sustainedManaBarrier)})`);
|
|
9485
9485
|
}
|
|
9486
9486
|
// What this character holds on OTHERS -- the caster's burden, named
|
|
9487
9487
|
// by bearer so the mage knows whose armor is costing them two dice.
|
|
@@ -22,6 +22,21 @@ export function maxWallCells(force) {
|
|
|
22
22
|
export function maxWallLevels(force) {
|
|
23
23
|
return Math.max(1, Math.floor(force / METERS_PER_LEVEL));
|
|
24
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;
|
|
25
40
|
/**
|
|
26
41
|
* A WALL BETWEEN TWO POINTS, ends included. Straight along an axis only:
|
|
27
42
|
* a diagonal run of cells leaves corner gaps an 8-way walker slips
|
|
@@ -191,10 +206,19 @@ export function barrierSpan(barrier) {
|
|
|
191
206
|
}
|
|
192
207
|
export function describeBarrier(barrier) {
|
|
193
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) {
|
|
194
218
|
const tracks = barrier.faces
|
|
195
219
|
.map(f => `${f.id === 'wall' ? 'wall' : f.id.toUpperCase()} ${f.breached ? 'BREACHED' : `${f.damage}/${barrier.structure}`}`)
|
|
196
220
|
.join(', ');
|
|
197
|
-
return `
|
|
221
|
+
return `Armor ${barrier.armor}, Structure ${barrier.structure} [${tracks}]`;
|
|
198
222
|
}
|
|
199
223
|
export function allBreached(barrier) {
|
|
200
224
|
return barrier.faces.every(f => f.breached);
|
|
@@ -693,9 +693,15 @@ export class CombatExchange {
|
|
|
693
693
|
// dodge/block/parry interrupts -- see turnModifiers below.
|
|
694
694
|
const turn = this.turnModifiers(attacker, defender, isMelee, strike, meta);
|
|
695
695
|
const attack = rollPool(strike.pool + directed + rangeMod + attackerFloor + targetDown + turn.attack + recoil, attackLimit);
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
696
|
+
// A DIRECT SPELL IS NOT DODGED (p.283): the target opposes the
|
|
697
|
+
// casting test with Body or Willpower, and only counterspelling (and
|
|
698
|
+
// a teammate's inspiration) helps -- not cover, posture, surprise or
|
|
699
|
+
// a dodge. The profile carries that pool when it applies.
|
|
700
|
+
const defense = strike.defensePool !== undefined
|
|
701
|
+
? rollPool(Math.max(0, strike.defensePool) + inspired + counterspell)
|
|
702
|
+
: turn.noDefense
|
|
703
|
+
? rollPool(0)
|
|
704
|
+
: rollPool(defender.getDefensePool() + inspired + counterspell + coverDice + proneDefence + turn.defense);
|
|
699
705
|
const rangeNote = weaponClass && spatial.meters > 0
|
|
700
706
|
? ` (${rangeMod !== 0 ? `${rangeMod} range` : `${rangeBracketName(spatial.meters, weaponClass)} range`} -- ~${Math.round(spatial.meters)} m)`
|
|
701
707
|
: '';
|
|
@@ -46,6 +46,24 @@ let scriptedInitiative;
|
|
|
46
46
|
export function scriptInitiative(totals) {
|
|
47
47
|
scriptedInitiative = totals ? [...totals] : undefined;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* A SUM OF D6s, not a hit count -- scatter distance (p.181: 2D6 metres)
|
|
51
|
+
* and anything else the book measures in pips. Scriptable like the
|
|
52
|
+
* other seams: each scripted entry is the TOTAL the next call shows.
|
|
53
|
+
*/
|
|
54
|
+
let scriptedTotals;
|
|
55
|
+
export function scriptTotals(totals) {
|
|
56
|
+
scriptedTotals = totals ? [...totals] : undefined;
|
|
57
|
+
}
|
|
58
|
+
export function rollD6Total(count) {
|
|
59
|
+
const scripted = scriptedTotals && scriptedTotals.length > 0 ? scriptedTotals.shift() : undefined;
|
|
60
|
+
if (scripted !== undefined)
|
|
61
|
+
return scripted;
|
|
62
|
+
let total = 0;
|
|
63
|
+
for (let i = 0; i < count; i++)
|
|
64
|
+
total += 1 + Math.floor(Math.random() * 6);
|
|
65
|
+
return total;
|
|
66
|
+
}
|
|
49
67
|
export function rollPool(pool, limit, opts = {}) {
|
|
50
68
|
const size = Math.max(0, Math.floor(pool));
|
|
51
69
|
const scripted = scriptedHits && scriptedHits.length > 0 ? scriptedHits.shift() : undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.386.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.",
|