@maka/maka-cli 5.385.0 → 5.388.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 +402 -124
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +20 -20
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +26 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-geometry.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +26 -6
- 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.388.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
|
-
import { spotOf, ensureAtSpot, furnitureCells, standingOccupants } from '../utilities/spots.js';
|
|
10
|
-
import { key as gridKey, METERS_PER_CELL } from '../utilities/room-grid.js';
|
|
9
|
+
import { spotOf, ensureAtSpot, furnitureCells, standingOccupants, visibleActorsIn, concealedFrom } from '../utilities/spots.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 { DIRECTIONAL_WALL_FORCE, DOME_FORCE, forceForWall, 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, BARRIER_FOG, 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';
|
|
@@ -91,14 +91,21 @@ const COMBAT_SPELL_FLAVOR = {
|
|
|
91
91
|
* inherited from BypassCommand -- now gated on actually having magic gear,
|
|
92
92
|
* instead of being a free-floating coin flip anyone could attempt.
|
|
93
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* BLIND FIRE (p.178): "the same as the Total Darkness modifier", -6 -- the
|
|
96
|
+
* number p.197 gives for a target completely hidden from the attacker.
|
|
97
|
+
*/
|
|
98
|
+
const BLIND_FIRE = -6;
|
|
94
99
|
export class CastCommand extends BypassCommand {
|
|
95
100
|
static verb = 'cast';
|
|
96
101
|
/** Touches another actor: authorized per-act for AI-driven actors
|
|
97
102
|
* (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
|
|
98
103
|
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>".';
|
|
104
|
+
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
105
|
deviceKind = 'ward';
|
|
101
106
|
verbName = 'cast';
|
|
107
|
+
/** The bolt in flight is at someone the caster cannot see (castCombatSpell). */
|
|
108
|
+
unseenTarget = false;
|
|
102
109
|
noDeviceMessage = "There's nothing here to cast at.";
|
|
103
110
|
/** Backlash drain when a ward-bypass fails -- resisted with Willpower,
|
|
104
111
|
* like any drain. The nastiest drain in the game: forcing a hostile
|
|
@@ -335,7 +342,10 @@ export class CastCommand extends BypassCommand {
|
|
|
335
342
|
if (!def)
|
|
336
343
|
return `You don't know "${spellName}".`;
|
|
337
344
|
switch (def.cast) {
|
|
338
|
-
|
|
345
|
+
// An AREA combat spell is aimed at a point (castAreaSpell); a bolt at a person.
|
|
346
|
+
case 'combat': return def.area
|
|
347
|
+
? this.castAreaSpell(spellName, force, overcast, rest)
|
|
348
|
+
: this.castCombatSpell(spellName, force, overcast, rest);
|
|
339
349
|
case 'heal': return this.castHeal(force, overcast, rest);
|
|
340
350
|
case 'armor': return this.castArmor(force, overcast, rest);
|
|
341
351
|
case 'sustained-utility': return this.castSustainedUtility(spellName, force, overcast, rest);
|
|
@@ -479,6 +489,101 @@ export class CastCommand extends BypassCommand {
|
|
|
479
489
|
}
|
|
480
490
|
return [];
|
|
481
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* ONE COMBAT SPELL'S NUMBERS AGAINST ONE TARGET, canon (SR5 p.283,
|
|
494
|
+
* RAG-checked 2026-10-05; user ruling: "fix all of it now").
|
|
495
|
+
*
|
|
496
|
+
* - DIRECT (soak 'willpower' / 'body': manabolt, stunball, powerball):
|
|
497
|
+
* the target opposes the casting test with Willpower (mana) or Body
|
|
498
|
+
* (physical), plus Magic Resistance (p.76). The damage is the NET
|
|
499
|
+
* HITS, and nothing soaks it -- "the target does not get to resist
|
|
500
|
+
* the damage, only the Spellcasting test".
|
|
501
|
+
* - INDIRECT (soak 'armor': fireball, clout, blast): dodged like a
|
|
502
|
+
* bullet with Reaction + Intuition; DV is Force + net hits, AP is
|
|
503
|
+
* -Force, resisted with Body + Armor.
|
|
504
|
+
*
|
|
505
|
+
* This replaced F-2 + net hits soaked by Willpower/Body for the direct
|
|
506
|
+
* family and AP floor(F/3) for the indirect one.
|
|
507
|
+
*
|
|
508
|
+
* A MANA BARRIER between caster and target (p.316) adds its Force to
|
|
509
|
+
* the defence (direct) or the resistance (indirect) pool.
|
|
510
|
+
*/
|
|
511
|
+
spellStrikeProfile(spell, spellKey, force, overcast, target, extraResist = 0) {
|
|
512
|
+
const label = (ap) => `with a Force ${force} ${spellKey}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}${ap !== 0 ? ` (AP -${ap})` : ''}`;
|
|
513
|
+
const weave = this.manaBarrierBetween(target);
|
|
514
|
+
const through = (weave ?? 0) + extraResist;
|
|
515
|
+
if (spell.soak !== 'armor') {
|
|
516
|
+
const resist = (spell.soak === 'willpower' ? target.willpower : target.body) + target.qualityRating('magic-resistance');
|
|
517
|
+
return {
|
|
518
|
+
label: label(0) + (weave ? ` -- through a Force ${weave} mana barrier` : ''),
|
|
519
|
+
pool: this.actor.getMagicPool() + this.actor.consumeEdgeBoost(),
|
|
520
|
+
damageBase: 0,
|
|
521
|
+
soakPool: 0,
|
|
522
|
+
defensePool: resist + through,
|
|
523
|
+
dealStun: spell.stun,
|
|
524
|
+
isSpell: true,
|
|
525
|
+
limit: force,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const armor = target.plane === 'meat' ? target.getArmorValue() : 0;
|
|
529
|
+
const modifiedArmor = Math.max(0, armor - force);
|
|
530
|
+
const blind = this.unseenTarget ? BLIND_FIRE : 0;
|
|
531
|
+
return {
|
|
532
|
+
label: label(force) + (weave ? ` -- through a Force ${weave} mana barrier` : '') + (blind ? ` -- thrown BLIND (${blind}, p.178)` : ''),
|
|
533
|
+
pool: Math.max(0, this.actor.getMagicPool() + this.actor.consumeEdgeBoost() + blind),
|
|
534
|
+
damageBase: force,
|
|
535
|
+
soakPool: Math.max(0, target.getSoakPool() - armor + modifiedArmor) + through,
|
|
536
|
+
modifiedArmor: target.plane === 'meat' ? modifiedArmor : undefined,
|
|
537
|
+
dealStun: spell.stun,
|
|
538
|
+
isSpell: true,
|
|
539
|
+
limit: force,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/** The Force of a MANA BARRIER (not the caster's own) standing between
|
|
543
|
+
* the caster and someone -- what a spell has to get through (p.316). */
|
|
544
|
+
manaBarrierBetween(target) {
|
|
545
|
+
const from = this.scene.worldCellOf?.(this.actor);
|
|
546
|
+
const to = this.scene.worldCellOf?.(target);
|
|
547
|
+
if (!from || !to)
|
|
548
|
+
return undefined;
|
|
549
|
+
const hit = this.scene.barrierOnLine?.(from, to, 'mana');
|
|
550
|
+
return hit && hit.barrier.caster !== this.actor.name ? hit.barrier.force : undefined;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* AN INDIRECT SPELL IS A THING THAT FLIES (p.283: "a clear line of
|
|
554
|
+
* fire"), and a Physical Barrier stops it: the bolt hits the wall
|
|
555
|
+
* instead. Direct spells pass -- the barrier "does not impede
|
|
556
|
+
* spellcasting" save for indirect ones (p.294). The face takes the
|
|
557
|
+
* spell's DV (Force + hits, combat spells unchanged against barriers,
|
|
558
|
+
* p.197) if it beats the Armor.
|
|
559
|
+
*/
|
|
560
|
+
indirectWallBetween(from, to) {
|
|
561
|
+
if (!from || !to)
|
|
562
|
+
return undefined;
|
|
563
|
+
return this.scene.barrierOnLine?.(from, to, 'physical');
|
|
564
|
+
}
|
|
565
|
+
strikeWall(wall, force, spellKey, rolledHits) {
|
|
566
|
+
const { barrier, face } = wall;
|
|
567
|
+
// A barrier does not dodge (p.197): the casting test is unopposed
|
|
568
|
+
// and its hits add to the DV.
|
|
569
|
+
const hits = rolledHits ?? rollPool(this.actor.getMagicPool(), force).hits;
|
|
570
|
+
const dv = force + hits;
|
|
571
|
+
if (!pierces(dv, barrier.armor, 0)) {
|
|
572
|
+
return [` The ${spellKey} breaks against ${barrier.caster}'s shimmer-wall -- DV ${dv} against Armor ${barrier.armor} (p.197); nothing gets past it.`];
|
|
573
|
+
}
|
|
574
|
+
const soak = rollPool(barrier.structure + barrier.armor);
|
|
575
|
+
const dealt = Math.max(0, dv - soak.hits);
|
|
576
|
+
const breached = damageFace(barrier, face, dealt);
|
|
577
|
+
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}`}).`];
|
|
578
|
+
if (breached && allBreached(barrier)) {
|
|
579
|
+
const caster = this.scene.getPlayers().find(p => p.name === barrier.caster);
|
|
580
|
+
if (caster)
|
|
581
|
+
caster.sustainedBarrier = undefined;
|
|
582
|
+
out.push(` The barrier comes down entirely -- ${barrier.caster}'s spell is over (p.294).`);
|
|
583
|
+
}
|
|
584
|
+
this.scene.updateExits?.(this.actor.currentLocation);
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
482
587
|
/**
|
|
483
588
|
* The shared combat-spell engine (manabolt/stunbolt/powerbolt/
|
|
484
589
|
* fireball): one exchange flow, parameterized by what SOAKS the hit
|
|
@@ -526,6 +631,16 @@ export class CastCommand extends BypassCommand {
|
|
|
526
631
|
if (target.isIncapacitated()) {
|
|
527
632
|
return `${target.name} is already down. The fight is over.`;
|
|
528
633
|
}
|
|
634
|
+
// LINE OF SIGHT (p.281, p.283). Seeing them is the one question
|
|
635
|
+
// visibleActorsIn answers for the map and `look`: on a plane you
|
|
636
|
+
// perceive and not hidden in cover. A DIRECT spell needs it -- it
|
|
637
|
+
// reaches through your own sight, with nothing to aim but that. An
|
|
638
|
+
// INDIRECT one only needs "a clear line of fire": you can throw it
|
|
639
|
+
// blind, at the Blind Fire penalty (p.178; -6, p.197).
|
|
640
|
+
this.unseenTarget = !visibleActorsIn(room, this.actor).includes(target);
|
|
641
|
+
if (this.unseenTarget && spellFor(spellKey)?.soak !== 'armor') {
|
|
642
|
+
return `You can't see ${target.name} -- a ${spellKey} reaches through your own eyes, and they have nothing to fix on (LOS, p.281).${hint(' (An indirect spell can be thrown blind, at -6.)')}`;
|
|
643
|
+
}
|
|
529
644
|
// One exchange at a time per combatant, same as attack.ts: the player
|
|
530
645
|
// is refused cleanly, an NPC caster waits out the exchange and re-runs
|
|
531
646
|
// (see CombatExchange.waitForLull and the rationale in attack.ts).
|
|
@@ -609,55 +724,28 @@ export class CastCommand extends BypassCommand {
|
|
|
609
724
|
if (defender.isIncapacitated())
|
|
610
725
|
break;
|
|
611
726
|
if (striker === this.actor) {
|
|
612
|
-
// ---- The spell
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const damageBase = spell.soak === 'armor' ? force : Math.max(1, force - 2);
|
|
630
|
-
const strike = exchange.resolveStrike(this.actor, target, {
|
|
631
|
-
label: `with a Force ${force} ${spellKey}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}${armorEaten > 0 ? ` (AP -${armorEaten})` : ''}`,
|
|
632
|
-
// An armed edge boost (see edge.ts) supercharges the bolt.
|
|
633
|
-
pool: this.actor.getMagicPool() + this.actor.consumeEdgeBoost(),
|
|
634
|
-
damageBase,
|
|
635
|
-
soakPool,
|
|
636
|
-
dealStun: spell.stun,
|
|
637
|
-
// Lets an Awakened defender's counterspelling answer it.
|
|
638
|
-
isSpell: true,
|
|
639
|
-
// Spellcasting + Magic [FORCE] (p.281): the Force you chose
|
|
640
|
-
// is the ceiling on the hits it can buy. Overcasting for
|
|
641
|
-
// reach costs drain AND raises this cap, which is the whole
|
|
642
|
-
// shape of the Force dial -- without it, a Force 1 bolt
|
|
643
|
-
// could land as hard as a Force 8 one on a lucky roll.
|
|
644
|
-
limit: force,
|
|
645
|
-
});
|
|
646
|
-
report.push('', ...strike.world, ...strike.meta);
|
|
647
|
-
await exchange.announce(strike.world, strike.meta);
|
|
727
|
+
// ---- The spell, canon (spellStrikeProfile, p.283): direct is
|
|
728
|
+
// opposed by Willpower/Body and does its net hits; indirect is
|
|
729
|
+
// dodged, Force + net hits at AP -Force, soaked by Body + Armor.
|
|
730
|
+
// An indirect bolt meets a Physical Barrier first. ----
|
|
731
|
+
const wall = spell.soak === 'armor'
|
|
732
|
+
? this.indirectWallBetween(this.scene.worldCellOf?.(this.actor), this.scene.worldCellOf?.(target))
|
|
733
|
+
: undefined;
|
|
734
|
+
if (wall) {
|
|
735
|
+
const hit = this.strikeWall(wall, force, spellKey);
|
|
736
|
+
report.push('', ...hit);
|
|
737
|
+
await exchange.announce(hit);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const strike = exchange.resolveStrike(this.actor, target, this.spellStrikeProfile(spell, spellKey, force, overcast, target));
|
|
741
|
+
report.push('', ...strike.world, ...strike.meta);
|
|
742
|
+
await exchange.announce(strike.world, strike.meta);
|
|
743
|
+
}
|
|
648
744
|
if (target.isDown()) {
|
|
649
745
|
report.push(...await exchange.resolveDeath(target, this.actor, {
|
|
650
746
|
fallLine: `${target.name} ${flavor.fall} Their gear scatters across ${room.name}.`,
|
|
651
747
|
}));
|
|
652
748
|
}
|
|
653
|
-
// AREA (p.284): the blast doesn't stop at the person you aimed
|
|
654
|
-
// at. Everyone else hostile in the room resists the same
|
|
655
|
-
// damage with the same soak -- no defense roll, there's nothing
|
|
656
|
-
// to dodge -- and anyone it drops falls here too.
|
|
657
|
-
if (spell.area) {
|
|
658
|
-
const splash = await this.splashArea(exchange, spellKey, spell, force, target, damageBase, spellAp);
|
|
659
|
-
report.push(...splash);
|
|
660
|
-
}
|
|
661
749
|
// Drain comes due the moment the bolt flies, kill or not.
|
|
662
750
|
const drain = this.applyDrain(spell, force, overcast);
|
|
663
751
|
report.push('', ...drain);
|
|
@@ -769,27 +857,21 @@ export class CastCommand extends BypassCommand {
|
|
|
769
857
|
target.combatOpponent = target.combatOpponent ?? actor;
|
|
770
858
|
target.lastExchangeAt = now;
|
|
771
859
|
actor.surrendered = false;
|
|
772
|
-
// The spell's own numbers -- identical to the exchange path
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
?
|
|
776
|
-
:
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
dealStun: spell.stun,
|
|
788
|
-
isSpell: true,
|
|
789
|
-
limit: force,
|
|
790
|
-
});
|
|
791
|
-
report.push('', ...strike.world, ...strike.meta);
|
|
792
|
-
await exchange.announce(strike.world, strike.meta);
|
|
860
|
+
// The spell's own numbers -- identical to the exchange path above
|
|
861
|
+
// (spellStrikeProfile), and an indirect bolt meets a barrier first.
|
|
862
|
+
const wall = spell.soak === 'armor'
|
|
863
|
+
? this.indirectWallBetween(this.scene.worldCellOf?.(actor), this.scene.worldCellOf?.(target))
|
|
864
|
+
: undefined;
|
|
865
|
+
if (wall) {
|
|
866
|
+
const hit = this.strikeWall(wall, force, spellKey);
|
|
867
|
+
report.push('', ...hit);
|
|
868
|
+
await exchange.announce(hit);
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
const strike = exchange.resolveStrike(actor, target, this.spellStrikeProfile(spell, spellKey, force, overcast, target));
|
|
872
|
+
report.push('', ...strike.world, ...strike.meta);
|
|
873
|
+
await exchange.announce(strike.world, strike.meta);
|
|
874
|
+
}
|
|
793
875
|
enc.syncWounds();
|
|
794
876
|
if (target.isDown()) {
|
|
795
877
|
report.push(...await exchange.resolveDeath(target, actor, {
|
|
@@ -804,10 +886,6 @@ export class CastCommand extends BypassCommand {
|
|
|
804
886
|
fallLine: `${target.name} sags and drops -- out cold, breathing. Their gear scatters across ${room.name}.`,
|
|
805
887
|
})));
|
|
806
888
|
}
|
|
807
|
-
if (spell.area) {
|
|
808
|
-
report.push(...await this.splashArea(exchange, spellKey, spell, force, target, damageBase, spellAp));
|
|
809
|
-
enc.syncWounds();
|
|
810
|
-
}
|
|
811
889
|
const drain = this.applyDrain(spell, force, overcast);
|
|
812
890
|
report.push('', ...drain);
|
|
813
891
|
await exchange.announce(drain);
|
|
@@ -818,61 +896,261 @@ export class CastCommand extends BypassCommand {
|
|
|
818
896
|
return human ? phaseHint(this.scene, actor).trim() : report.join('\n');
|
|
819
897
|
}
|
|
820
898
|
/**
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
899
|
+
* AN AREA COMBAT SPELL, CAST AT A POINT (SR5 p.281-284; RAG-checked
|
|
900
|
+
* 2026-10-05; user spec: "cast at a coordinate").
|
|
901
|
+
*
|
|
902
|
+
* cast <ball> [F] at <x,y[,z]> the point, in the map rulers' numbers
|
|
903
|
+
* cast <ball> [F] at <someone> the cell they stand in
|
|
904
|
+
*
|
|
905
|
+
* "Area spells can be cast on a specific target or a point in space that
|
|
906
|
+
* you can see ... a sphere centered on the target with a radius in
|
|
907
|
+
* meters equal to the Force" (p.281). "All targets in the area of effect
|
|
908
|
+
* that you can see, friend and foe alike, are valid targets" -- the
|
|
909
|
+
* caster included when they stand in it (user ruling 2026-10-05). This
|
|
910
|
+
* replaced "everyone hostile in the room, allies spared".
|
|
911
|
+
*
|
|
912
|
+
* - DIRECT balls (manaball, stunball, powerball): ONE casting test,
|
|
913
|
+
* opposed by each target's Willpower/Body (+ counterspelling, Magic
|
|
914
|
+
* Resistance, a mana barrier's Force); each takes their own net hits.
|
|
915
|
+
* Only targets the caster can SEE are touched (p.281).
|
|
916
|
+
* - INDIRECT balls (fireball, ball lightning, toxic wave, blast) fly
|
|
917
|
+
* and go boom like a grenade (p.283, p.181): Spellcasting + Magic
|
|
918
|
+
* [Force] (3). Beat the threshold and it lands where you aimed, its
|
|
919
|
+
* net hits on the DV; miss and it SCATTERS 2D6 m, less a metre per
|
|
920
|
+
* hit. DV is Force (+ net hits), AP -Force, soaked with Body + Armor
|
|
921
|
+
* by everyone the blast reaches -- no dodge. A Physical Barrier in
|
|
922
|
+
* its path is where it goes off.
|
|
923
|
+
*
|
|
924
|
+
* The area is measured on the district PLANE, so it does not stop at a
|
|
925
|
+
* room's edge; walls stop the blast and the caster's sight.
|
|
826
926
|
*/
|
|
827
|
-
async
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
this.
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
927
|
+
async castAreaSpell(spellKey, force, overcast, rest) {
|
|
928
|
+
const actor = this.actor;
|
|
929
|
+
const spell = spellFor(spellKey);
|
|
930
|
+
const flavor = COMBAT_SPELL_FLAVOR[spellKey] ?? { act: `cast ${spellKey} at`, fall: 'goes down.' };
|
|
931
|
+
const room = actor.currentLocation;
|
|
932
|
+
const words = (rest[0]?.toLowerCase() === 'at' ? rest.slice(1) : rest).filter(w => w.trim().length > 0);
|
|
933
|
+
const plane = this.scene.ensurePlane?.();
|
|
934
|
+
const from = this.scene.worldCellOf?.(actor);
|
|
935
|
+
if (!plane || !from)
|
|
936
|
+
return `There's nowhere here to aim a ${spellKey}.`;
|
|
937
|
+
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)}.`;
|
|
938
|
+
if (words.length === 0)
|
|
939
|
+
return `Cast it where? ${usage}`;
|
|
940
|
+
// WHERE IT IS AIMED.
|
|
941
|
+
let aim;
|
|
942
|
+
let aimedAt;
|
|
943
|
+
if (isCoordToken(words[0]) && words.length === 1) {
|
|
944
|
+
aim = parseCoord(words[0], from.z);
|
|
945
|
+
aimedAt = formatCoord(aim);
|
|
946
|
+
if (!this.inSight(plane, from, aim))
|
|
947
|
+
return `You can't see ${aimedAt} from here -- an area spell is cast at a point you can see (p.281).`;
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
const selfWord = words.length === 1 && /^(me|self|myself)$/i.test(words[0]);
|
|
951
|
+
const visible = this.scene.getActorsInRoom(room).filter(a => a === actor || (a.plane === actor.plane && actor.canPerceive(a)));
|
|
952
|
+
const picked = selfWord ? actor.name : fuzzyPickName(words.join(' '), visible.map(a => a.name));
|
|
953
|
+
const target = picked ? visible.find(a => a.name === picked) : undefined;
|
|
954
|
+
if (!target) {
|
|
955
|
+
const cross = crossPlaneRefusal(this.scene, actor, room, words.join(' '), 'spell');
|
|
956
|
+
if (cross)
|
|
957
|
+
return cross;
|
|
958
|
+
return `You don't see "${words.join(' ')}" here. ${usage}`;
|
|
855
959
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
960
|
+
aim = this.scene.worldCellOf?.(target);
|
|
961
|
+
aimedAt = target === actor ? 'yourself' : target.name;
|
|
962
|
+
if (!aim)
|
|
963
|
+
return `${target.name} isn't standing anywhere a ${spellKey} could find.`;
|
|
964
|
+
if (target !== actor && !this.inSight(plane, from, aim))
|
|
965
|
+
return `You can't see ${target.name} clearly enough to aim at them (p.281).`;
|
|
966
|
+
}
|
|
967
|
+
// WHO COULD BE CAUGHT: everyone on the caster's plane with a cell.
|
|
968
|
+
const sameSide = (a) => a.plane === actor.plane || (actor.plane === 'meat' && a.plane === 'drone') || (actor.plane === 'drone' && a.plane === 'meat');
|
|
969
|
+
const everyone = this.scene.allActors.filter(a => sameSide(a) && !a.isIncapacitated());
|
|
970
|
+
// THE COMBAT TURN: a Complex Action and the phase's attack (p.167).
|
|
971
|
+
const physical = actor.plane === 'meat' || actor.plane === 'drone';
|
|
972
|
+
const human = this.scene.isHumanControlled(actor);
|
|
973
|
+
if (physical) {
|
|
974
|
+
let enc = this.scene.encounterFor?.(actor);
|
|
975
|
+
const opener = everyone.find(a => a !== actor && a.currentLocation === room && a instanceof NPC);
|
|
976
|
+
if (!enc && opener) {
|
|
977
|
+
enc = await this.scene.startEncounter(room, { aggressor: actor, target: opener, ambush: false });
|
|
978
|
+
if (enc.ended)
|
|
979
|
+
return human ? '' : `The fight was over before ${actor.name} could cast.`;
|
|
980
|
+
if (actor.isIncapacitated())
|
|
981
|
+
return human ? `You're down before you can cast.` : `${actor.name} went down before casting.`;
|
|
982
|
+
if (!enc.isPhaseOf(actor)) {
|
|
983
|
+
const who = enc.phaseActor?.name ?? 'the others';
|
|
984
|
+
return human
|
|
985
|
+
? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${spellKey} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
|
|
986
|
+
: `${actor.name} gathers mana -- initiative rolled; ${who} first.`;
|
|
987
|
+
}
|
|
866
988
|
}
|
|
867
|
-
else if (
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
989
|
+
else if (enc && !enc.isPhaseOf(actor)) {
|
|
990
|
+
return notYourPhase(enc, actor);
|
|
991
|
+
}
|
|
992
|
+
const bill = billAction(this.scene, actor, 'complex', `Cast ${spellKey}`, { attack: true });
|
|
993
|
+
if (bill)
|
|
994
|
+
return bill;
|
|
995
|
+
}
|
|
996
|
+
const exchange = new CombatExchange(this.scene, this.logger, actor);
|
|
997
|
+
actor.performAction(`casts ${spellKey} at`, aimedAt);
|
|
998
|
+
this.scene.addWorldEvent(`${actor.name} ${flavor.act} ${aimedAt} in ${room.name}.`);
|
|
999
|
+
const lines = [];
|
|
1000
|
+
const meta = [];
|
|
1001
|
+
// Seen through a Physical Barrier, the point is in Light Fog: -1 on
|
|
1002
|
+
// the casting test (p.294, p.175). An indirect ball meets the barrier
|
|
1003
|
+
// itself, below, so only a direct one pays this.
|
|
1004
|
+
const fog = spell.soak !== 'armor' && this.scene.barrierOnLine?.(from, aim, 'physical') ? BARRIER_FOG : 0;
|
|
1005
|
+
const pool = Math.max(0, actor.getMagicPool() + actor.consumeEdgeBoost() + fog);
|
|
1006
|
+
const roll = rollPool(pool, force);
|
|
1007
|
+
const header = `${actor.name} casts ${spellKey} at Force ${force}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}, at ${aimedAt}`;
|
|
1008
|
+
const hurt = [];
|
|
1009
|
+
if (spell.soak !== 'armor') {
|
|
1010
|
+
// ---- DIRECT: one test, opposed by each target it can see. ----
|
|
1011
|
+
meta.push(`${header}: casting ${formatRoll(roll)}${fog ? ` (${fog} through a force barrier, Light Fog p.175)` : ''}`);
|
|
1012
|
+
lines.push(`${header} -- ${force} m of ${spell.type === 'mana' ? 'raw mana' : 'force'} around ${aimedAt}.`);
|
|
1013
|
+
const caught = everyone.filter(a => {
|
|
1014
|
+
const c = this.scene.worldCellOf?.(a);
|
|
1015
|
+
if (!c || distanceMeters(aim, c) > force)
|
|
1016
|
+
return false;
|
|
1017
|
+
// "...that you can see" (p.281): your own body counts; others must
|
|
1018
|
+
// be perceived and in plain sight of you.
|
|
1019
|
+
// Hidden in cover is not seen either (visibleActorsIn's rule).
|
|
1020
|
+
if (a === actor)
|
|
1021
|
+
return true;
|
|
1022
|
+
if (!actor.canPerceive(a) || !this.inSight(plane, from, c))
|
|
1023
|
+
return false;
|
|
1024
|
+
return a.currentLocation !== room || !concealedFrom(room, actor, a);
|
|
1025
|
+
});
|
|
1026
|
+
if (caught.length === 0)
|
|
1027
|
+
lines.push(` The spell washes over empty ground.`);
|
|
1028
|
+
for (const victim of caught) {
|
|
1029
|
+
// Mana touches only the living and the magical (p.284).
|
|
1030
|
+
if (spell.type === 'mana' && victim.plane === 'drone') {
|
|
1031
|
+
lines.push(` ${victim.name}'s frame has no aura for mana to bite -- untouched.`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const weave = victim === actor ? undefined : this.manaBarrierBetween(victim);
|
|
1035
|
+
const counter = typeof victim.isCaster === 'function' && victim.isCaster() ? victim.skillRating('counterspelling') : 0;
|
|
1036
|
+
const resist = (spell.soak === 'willpower' ? victim.willpower : victim.body)
|
|
1037
|
+
+ victim.qualityRating('magic-resistance') + counter + (weave ?? 0);
|
|
1038
|
+
const defense = rollPool(resist);
|
|
1039
|
+
const net = roll.hits - defense.hits;
|
|
1040
|
+
meta.push(` ${victim.name}: resists ${formatRoll(defense)}${weave ? ` (+${weave} mana barrier)` : ''}${counter ? ` (+${counter} counterspelling)` : ''} -- ${Math.max(0, net)} net`);
|
|
1041
|
+
if (net <= 0) {
|
|
1042
|
+
lines.push(` ${victim.name} holds against it.`);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
lines.push(...this.landAreaDamage(victim, net, !!spell.stun));
|
|
1046
|
+
hurt.push(victim);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
// ---- INDIRECT: it flies, and goes off like a grenade. ----
|
|
1051
|
+
const hits = roll.hits;
|
|
1052
|
+
let at = aim;
|
|
1053
|
+
let dv = force;
|
|
1054
|
+
if (hits >= 3) {
|
|
1055
|
+
dv = force + (hits - 3);
|
|
1056
|
+
meta.push(`${header}: Spellcasting + Magic [${force}] (3) ${formatRoll(roll)} -- on target, +${hits - 3} DV (p.283)`);
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
const metres = Math.max(0, rollD6Total(2) - hits);
|
|
1060
|
+
const angle = Math.random() * Math.PI * 2;
|
|
1061
|
+
at = {
|
|
1062
|
+
x: aim.x + Math.round(Math.cos(angle) * metres / METERS_PER_CELL),
|
|
1063
|
+
y: aim.y + Math.round(Math.sin(angle) * metres / METERS_PER_CELL),
|
|
1064
|
+
z: aim.z,
|
|
1065
|
+
};
|
|
1066
|
+
meta.push(`${header}: Spellcasting + Magic [${force}] (3) ${formatRoll(roll)} -- misses the threshold, scatters ${metres} m (p.181)`);
|
|
1067
|
+
}
|
|
1068
|
+
// What it meets on the way: a wall stops it short; so does a barrier.
|
|
1069
|
+
let blocked = false;
|
|
1070
|
+
const path = line3D(from, at);
|
|
1071
|
+
for (let i = 1; i < path.length; i++) {
|
|
1072
|
+
const c = path[i];
|
|
1073
|
+
const wall = this.scene.barrierAt(c, 'physical');
|
|
1074
|
+
if (wall) {
|
|
1075
|
+
lines.push(`${header} -- it bursts against ${wall.barrier.caster}'s barrier at ${formatCoord(c)}.`);
|
|
1076
|
+
lines.push(...this.strikeWall(wall, force, spellKey, dv - force));
|
|
1077
|
+
at = path[i - 1];
|
|
1078
|
+
blocked = true;
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
if (isSolid(plane, c)) {
|
|
1082
|
+
at = path[i - 1];
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (!blocked)
|
|
1087
|
+
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.`);
|
|
1088
|
+
const weave = this.scene.barrierOnLine?.(from, at, 'mana');
|
|
1089
|
+
const through = weave && weave.barrier.caster !== actor.name ? weave.barrier.force : 0;
|
|
1090
|
+
const caught = everyone.filter(a => {
|
|
1091
|
+
const c = this.scene.worldCellOf?.(a);
|
|
1092
|
+
if (!c || distanceMeters(at, c) > force)
|
|
1093
|
+
return false;
|
|
1094
|
+
// The blast reaches what nothing solid shields.
|
|
1095
|
+
return wallsBetween(plane, at, c) === 0 && !this.scene.barrierOnLine?.(at, c, 'physical');
|
|
1096
|
+
});
|
|
1097
|
+
if (caught.length === 0)
|
|
1098
|
+
lines.push(` The blast catches nobody.`);
|
|
1099
|
+
for (const victim of caught) {
|
|
1100
|
+
const armor = victim.plane === 'meat' ? victim.getArmorValue() : 0;
|
|
1101
|
+
const modified = Math.max(0, armor - force);
|
|
1102
|
+
const soak = rollPool(Math.max(0, victim.getSoakPool() - armor + modified) + through);
|
|
1103
|
+
const dealt = Math.max(0, dv - soak.hits);
|
|
1104
|
+
// p.168: a Physical hit under the modified Armor turns to Stun.
|
|
1105
|
+
const stun = !!spell.stun || (victim.plane === 'meat' && dv < modified);
|
|
1106
|
+
meta.push(` ${victim.name}: soak ${formatRoll(soak)} v. DV ${dv} (Armor ${armor} -> ${modified})${through ? ` (+${through} mana barrier)` : ''} -- ${dealt}`);
|
|
1107
|
+
if (dealt === 0) {
|
|
1108
|
+
lines.push(` ${victim.name} rides it out.`);
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
lines.push(...this.landAreaDamage(victim, dealt, stun));
|
|
1112
|
+
hurt.push(victim);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
await exchange.announce(lines, meta);
|
|
1116
|
+
const report = [...lines, ...(human ? [] : meta)];
|
|
1117
|
+
for (const victim of hurt) {
|
|
1118
|
+
if (victim.isDown()) {
|
|
1119
|
+
report.push(...await exchange.resolveDeath(victim, actor, {
|
|
1120
|
+
fallLine: `${victim.name} ${flavor.fall} Their gear scatters across ${victim.currentLocation.name}.`,
|
|
871
1121
|
}));
|
|
872
1122
|
}
|
|
1123
|
+
else if (victim.isUnconscious()) {
|
|
1124
|
+
report.push(...(this.scene.isHumanControlled(victim)
|
|
1125
|
+
? await exchange.resolvePlayerKnockout(actor, victim)
|
|
1126
|
+
: await exchange.resolveDeath(victim, actor, {
|
|
1127
|
+
fatal: false,
|
|
1128
|
+
fallLine: `${victim.name} drops where the spell found them -- out cold, breathing.`,
|
|
1129
|
+
})));
|
|
1130
|
+
}
|
|
873
1131
|
}
|
|
874
|
-
|
|
875
|
-
|
|
1132
|
+
this.scene.encounterFor?.(actor)?.syncWounds();
|
|
1133
|
+
if (!actor.isIncapacitated()) {
|
|
1134
|
+
const drain = this.applyDrain(spell, force, overcast);
|
|
1135
|
+
report.push('', ...drain);
|
|
1136
|
+
await exchange.announce(drain);
|
|
1137
|
+
report.push(...await this.drainCollapse(exchange));
|
|
1138
|
+
}
|
|
1139
|
+
this.scene.encounterFor?.(actor)?.syncWounds();
|
|
1140
|
+
this.scene.updateStatus();
|
|
1141
|
+
return human ? (physical ? phaseHint(this.scene, actor).trim() : '') : report.join('\n');
|
|
1142
|
+
}
|
|
1143
|
+
/** A box count landing on one body caught in an area spell. */
|
|
1144
|
+
landAreaDamage(victim, boxes, stun) {
|
|
1145
|
+
if (stun) {
|
|
1146
|
+
const { stun: s, overflow } = victim.takeStun(boxes);
|
|
1147
|
+
this.logger.write(`${this.actor.name}'s area spell dealt ${boxes} STUN to ${victim.name}.`);
|
|
1148
|
+
return [` ${victim.name} is caught -- ${s} stun: ${victim.stunSummary()}`,
|
|
1149
|
+
...(overflow > 0 ? [` It spills past consciousness -- ${overflow} box${overflow === 1 ? '' : 'es'} physical: ${victim.conditionSummary()}`] : [])];
|
|
1150
|
+
}
|
|
1151
|
+
victim.takeDamage(boxes);
|
|
1152
|
+
this.logger.write(`${this.actor.name}'s area spell dealt ${boxes} to ${victim.name}.`);
|
|
1153
|
+
return [` ${victim.name} is caught -- ${boxes} box${boxes === 1 ? '' : 'es'}: ${victim.conditionSummary()}`];
|
|
876
1154
|
}
|
|
877
1155
|
async castHeal(force, overcast, rest = []) {
|
|
878
1156
|
const { target, error } = this.resolveAllyTarget(rest);
|
|
@@ -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>]',
|
|
@@ -3364,5 +3364,30 @@
|
|
|
3364
3364
|
// are refused), and "me"/"self" name the caster.
|
|
3365
3365
|
// The HUD's sustained line and `spells` show the barrier's Armor,
|
|
3366
3366
|
// Structure and every face's damage track.
|
|
3367
|
-
|
|
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
|
+
// 1.199.0 (2026-10-05): SPELLS NEED SIGHT (p.281, p.283, p.178). A
|
|
3384
|
+
// direct bolt at someone the caster cannot see -- off their plane or
|
|
3385
|
+
// hidden in cover (visibleActorsIn) -- is refused before any drain; an
|
|
3386
|
+
// indirect one flies blind at -6 (Blind Fire). Direct area spells pass
|
|
3387
|
+
// over anyone hidden in cover inside them.
|
|
3388
|
+
// 1.200.0 (2026-10-05): A PHYSICAL BARRIER IS LIGHT FOG (p.294): a spell
|
|
3389
|
+
// or ranged attack whose line crosses a standing face is at -1, the
|
|
3390
|
+
// Light Fog row of the Environmental Modifiers table (p.175; confirmed
|
|
3391
|
+
// against the rules index and SR5 references online). Melee is exempt.
|
|
3392
|
+
export const ENGINE_VERSION = '1.200.0';
|
|
3368
3393
|
//# sourceMappingURL=engine-version.js.map
|
|
@@ -35,6 +35,11 @@ export function forceForWall(a, b) {
|
|
|
35
35
|
}
|
|
36
36
|
/** The directional wall is three cells across: Force 3. */
|
|
37
37
|
export const DIRECTIONAL_WALL_FORCE = 3;
|
|
38
|
+
/**
|
|
39
|
+
* Seeing through a Physical Barrier: "equivalent to Light Fog" (p.294),
|
|
40
|
+
* the Light Fog row of the Environmental Modifiers table -- -1 (p.175).
|
|
41
|
+
*/
|
|
42
|
+
export const BARRIER_FOG = -1;
|
|
38
43
|
/** A dome with no Force named rises one full 3 m storey: Force 3. */
|
|
39
44
|
export const DOME_FORCE = METERS_PER_LEVEL;
|
|
40
45
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allBreached, damageFace, penetrationBoxes, pierces } from './barrier-geometry.js';
|
|
1
|
+
import { allBreached, damageFace, penetrationBoxes, pierces, BARRIER_FOG } from './barrier-geometry.js';
|
|
2
2
|
import { recoilCompensation, recoilPenalty } from './recoil.js';
|
|
3
3
|
import { Category } from '../types/shared/item-enum.js';
|
|
4
4
|
import { crashIC } from './ic-actors.js';
|
|
@@ -648,6 +648,20 @@ export class CombatExchange {
|
|
|
648
648
|
const spatial = isMelee ? { meters: 0 } : this.spatialContext(attacker, defender);
|
|
649
649
|
const weaponClass = strike.weaponItem?.isFirearm() ? classifyWeapon(strike.weaponItem) : undefined;
|
|
650
650
|
const range = weaponClass ? rangePenalty(spatial.meters, weaponClass) : 0;
|
|
651
|
+
// THROUGH A PHYSICAL BARRIER (p.294: it is "equivalent to Light Fog"
|
|
652
|
+
// and impedes spellcasting only by visibility): the Light Fog row of
|
|
653
|
+
// the Environmental Modifiers table, -1 (p.175), on a ranged attack or
|
|
654
|
+
// a spell whose line crosses a standing face. Melee never reaches
|
|
655
|
+
// through one. Only the worst condition counts (p.173), and this is
|
|
656
|
+
// the only environmental condition the engine models, so it stands
|
|
657
|
+
// alone.
|
|
658
|
+
let fog = 0;
|
|
659
|
+
if (!isMelee) {
|
|
660
|
+
const a = this.scene.worldCellOf?.(attacker);
|
|
661
|
+
const d = this.scene.worldCellOf?.(defender);
|
|
662
|
+
if (a && d && this.scene.barrierOnLine?.(a, d, 'physical'))
|
|
663
|
+
fog = BARRIER_FOG;
|
|
664
|
+
}
|
|
651
665
|
const rangeMod = range === 'out-of-range' ? -6 : range;
|
|
652
666
|
// p.190 grants the bonus to a defender who "uses a Take Cover
|
|
653
667
|
// action" -- so the dice follow the ACTION, not the address.
|
|
@@ -692,17 +706,23 @@ export class CombatExchange {
|
|
|
692
706
|
// charging, a running defender, surprise, Full Defense and the
|
|
693
707
|
// dodge/block/parry interrupts -- see turnModifiers below.
|
|
694
708
|
const turn = this.turnModifiers(attacker, defender, isMelee, strike, meta);
|
|
695
|
-
const attack = rollPool(strike.pool + directed + rangeMod + attackerFloor + targetDown + turn.attack + recoil, attackLimit);
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
709
|
+
const attack = rollPool(strike.pool + directed + rangeMod + attackerFloor + targetDown + turn.attack + recoil + fog, attackLimit);
|
|
710
|
+
// A DIRECT SPELL IS NOT DODGED (p.283): the target opposes the
|
|
711
|
+
// casting test with Body or Willpower, and only counterspelling (and
|
|
712
|
+
// a teammate's inspiration) helps -- not cover, posture, surprise or
|
|
713
|
+
// a dodge. The profile carries that pool when it applies.
|
|
714
|
+
const defense = strike.defensePool !== undefined
|
|
715
|
+
? rollPool(Math.max(0, strike.defensePool) + inspired + counterspell)
|
|
716
|
+
: turn.noDefense
|
|
717
|
+
? rollPool(0)
|
|
718
|
+
: rollPool(defender.getDefensePool() + inspired + counterspell + coverDice + proneDefence + turn.defense);
|
|
699
719
|
const rangeNote = weaponClass && spatial.meters > 0
|
|
700
720
|
? ` (${rangeMod !== 0 ? `${rangeMod} range` : `${rangeBracketName(spatial.meters, weaponClass)} range`} -- ~${Math.round(spatial.meters)} m)`
|
|
701
721
|
: '';
|
|
702
722
|
const recoilNote = recoil < 0 && strike.weaponItem
|
|
703
723
|
? ` (${recoil} recoil -- round ${attacker.recoilRoundsFired} against RC ${recoilCompensation(attacker, strike.weaponItem)})`
|
|
704
724
|
: '';
|
|
705
|
-
meta.push(` Attack: ${formatRoll(attack)}${directed > 0 ? ` (+${directed} directed)` : ''}${rangeNote}${recoilNote}`);
|
|
725
|
+
meta.push(` Attack: ${formatRoll(attack)}${directed > 0 ? ` (+${directed} directed)` : ''}${rangeNote}${recoilNote}${fog ? ` (${fog} through a force barrier, Light Fog p.175)` : ''}`);
|
|
706
726
|
const coverNote = coverDice > 0 ? ` (+${coverDice} cover -- the ${spotOf(defender)})` : '';
|
|
707
727
|
meta.push(` Defense: ${formatRoll(defense)}${inspired > 0 ? ` (+${inspired} inspired)` : ''}${counterspell > 0 ? ` (+${counterspell} counterspell${counterspellSource ? ` -- ${counterspellSource}'s cover` : ''})` : ''}${coverNote}`);
|
|
708
728
|
const dryWarning = strike.weaponItem?.isFirearm() && strike.weaponItem.ammo === 0
|
|
@@ -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.388.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.",
|