@maka/maka-cli 5.385.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.385.0",
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, METERS_PER_CELL } 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 { 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, 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.";
@@ -335,7 +335,10 @@ export class CastCommand extends BypassCommand {
335
335
  if (!def)
336
336
  return `You don't know "${spellName}".`;
337
337
  switch (def.cast) {
338
- case 'combat': return this.castCombatSpell(spellName, force, overcast, rest);
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);
339
342
  case 'heal': return this.castHeal(force, overcast, rest);
340
343
  case 'armor': return this.castArmor(force, overcast, rest);
341
344
  case 'sustained-utility': return this.castSustainedUtility(spellName, force, overcast, rest);
@@ -479,6 +482,100 @@ export class CastCommand extends BypassCommand {
479
482
  }
480
483
  return [];
481
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
+ }
482
579
  /**
483
580
  * The shared combat-spell engine (manabolt/stunbolt/powerbolt/
484
581
  * fireball): one exchange flow, parameterized by what SOAKS the hit
@@ -609,55 +706,28 @@ export class CastCommand extends BypassCommand {
609
706
  if (defender.isIncapacitated())
610
707
  break;
611
708
  if (striker === this.actor) {
612
- // ---- The spell: casting pool vs defense, soaked per its type
613
- // (direct mana = Willpower; direct physical = Body; indirect
614
- // elemental = the full soak pool minus AP floor(F/3)). The
615
- // FORCE dial sets the damage: bolts hit at F-2, the elemental
616
- // hits at full F for letting armor matter. ----
617
- const spellAp = spell.soak === 'armor' ? Math.floor(force / 3) : 0;
618
- const armorEaten = spellAp > 0
619
- ? Math.min(spellAp, target.plane === 'meat' ? target.getArmorValue() : 0)
620
- : 0;
621
- // MAGIC RESISTANCE (p.76): +1 die per rating on the Spell
622
- // Resistance test -- the Willpower or Body roll a mana or
623
- // physical spell is resisted with. Not on the armor soak of an
624
- // elemental effect, which is damage resistance, not spell.
625
- const magicResistance = spell.soak === 'armor' ? 0 : target.qualityRating('magic-resistance');
626
- const soakPool = (spell.soak === 'willpower' ? target.willpower
627
- : spell.soak === 'body' ? target.body
628
- : Math.max(0, target.getSoakPool() - armorEaten)) + magicResistance;
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);
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
+ }
648
726
  if (target.isDown()) {
649
727
  report.push(...await exchange.resolveDeath(target, this.actor, {
650
728
  fallLine: `${target.name} ${flavor.fall} Their gear scatters across ${room.name}.`,
651
729
  }));
652
730
  }
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
731
  // Drain comes due the moment the bolt flies, kill or not.
662
732
  const drain = this.applyDrain(spell, force, overcast);
663
733
  report.push('', ...drain);
@@ -769,27 +839,21 @@ export class CastCommand extends BypassCommand {
769
839
  target.combatOpponent = target.combatOpponent ?? actor;
770
840
  target.lastExchangeAt = now;
771
841
  actor.surrendered = false;
772
- // The spell's own numbers -- identical to the exchange path below.
773
- const spellAp = spell.soak === 'armor' ? Math.floor(force / 3) : 0;
774
- const armorEaten = spellAp > 0
775
- ? Math.min(spellAp, target.plane === 'meat' ? target.getArmorValue() : 0)
776
- : 0;
777
- const magicResistance = spell.soak === 'armor' ? 0 : target.qualityRating('magic-resistance');
778
- const soakPool = (spell.soak === 'willpower' ? target.willpower
779
- : spell.soak === 'body' ? target.body
780
- : Math.max(0, target.getSoakPool() - armorEaten)) + magicResistance;
781
- const damageBase = spell.soak === 'armor' ? force : Math.max(1, force - 2);
782
- const strike = exchange.resolveStrike(actor, target, {
783
- label: `with a Force ${force} ${spellKey}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}${armorEaten > 0 ? ` (AP -${armorEaten})` : ''}`,
784
- pool: actor.getMagicPool() + actor.consumeEdgeBoost(),
785
- damageBase,
786
- soakPool,
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);
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
+ }
793
857
  enc.syncWounds();
794
858
  if (target.isDown()) {
795
859
  report.push(...await exchange.resolveDeath(target, actor, {
@@ -804,10 +868,6 @@ export class CastCommand extends BypassCommand {
804
868
  fallLine: `${target.name} sags and drops -- out cold, breathing. Their gear scatters across ${room.name}.`,
805
869
  })));
806
870
  }
807
- if (spell.area) {
808
- report.push(...await this.splashArea(exchange, spellKey, spell, force, target, damageBase, spellAp));
809
- enc.syncWounds();
810
- }
811
871
  const drain = this.applyDrain(spell, force, overcast);
812
872
  report.push('', ...drain);
813
873
  await exchange.announce(drain);
@@ -818,61 +878,252 @@ export class CastCommand extends BypassCommand {
818
878
  return human ? phaseHint(this.scene, actor).trim() : report.join('\n');
819
879
  }
820
880
  /**
821
- * The rest of the room, for an AREA spell: every other hostile takes
822
- * the same damage, resisted with the same soak the primary target
823
- * used (minus the spell's AP against armor-soaked elementals). No
824
- * attack roll and no defense roll -- a blast doesn't aim and can't be
825
- * dodged; the drain the ball spells charge is what pays for it.
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.
826
908
  */
827
- async splashArea(exchange, spellKey, spell, force, primary, damageBase, spellAp) {
828
- const room = this.actor.currentLocation;
829
- // Who the blast catches: everyone else standing on this plane --
830
- // EXCEPT the caster's own crew and any other player. Canon area
831
- // spells don't discriminate, but a shell that murders your companion
832
- // because you aimed past them reads as a bug, not a rule (ruling
833
- // 2026-08-25); the mage's own people get the benefit of the doubt.
834
- const caught = this.scene.getActorsInRoom(room).filter(a => a !== this.actor && a !== primary
835
- && a.plane === this.actor.plane
836
- && !a.isIncapacitated()
837
- && !this.scene.isHumanControlled(a)
838
- && !(a instanceof NPC && a.allyOf === this.actor.name));
839
- if (caught.length === 0)
840
- return [];
841
- const lines = ['', `The blast washes over the rest of the room.`];
842
- for (const bystander of caught) {
843
- const armorEaten = spellAp > 0
844
- ? Math.min(spellAp, bystander.plane === 'meat' ? bystander.getArmorValue() : 0)
845
- : 0;
846
- const soakPool = spell.soak === 'willpower' ? bystander.willpower
847
- : spell.soak === 'body' ? bystander.body
848
- : Math.max(0, bystander.getSoakPool() - armorEaten);
849
- const soak = rollPool(Math.max(1, soakPool));
850
- const dealt = Math.max(0, damageBase - soak.hits);
851
- this.logger.meta(`${spellKey} splash on ${bystander.name}: soak ${formatRoll(soak)} vs ${damageBase}`);
852
- if (dealt === 0) {
853
- lines.push(` ${bystander.name} rides it out -- shaken, still standing.`);
854
- continue;
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}`;
855
941
  }
856
- if (spell.stun)
857
- bystander.takeStun(dealt);
858
- else
859
- bystander.takeDamage(dealt);
860
- this.logger.write(`${this.actor.name}'s ${spellKey} splashed ${dealt} onto ${bystander.name}.`);
861
- lines.push(` ${bystander.name} is caught in it -- ${dealt} ${spell.stun ? 'stun' : 'damage'}.`);
862
- if (bystander.isDown()) {
863
- lines.push(...await exchange.resolveDeath(bystander, this.actor, {
864
- fallLine: `${bystander.name} goes down in the blast.`,
865
- }));
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
+ }
866
970
  }
867
- else if (bystander.isUnconscious()) {
868
- lines.push(...await exchange.resolveDeath(bystander, this.actor, {
869
- fatal: false,
870
- fallLine: `${bystander.name} drops where the blast found them -- out cold, breathing.`,
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}.`,
871
1094
  }));
872
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
+ }
873
1104
  }
874
- await exchange.announce(lines);
875
- return lines;
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()}`];
876
1127
  }
877
1128
  async castHeal(force, overcast, rest = []) {
878
1129
  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: resisted by WILLPOWER alone; armor and muscle mean nothing. Damage F-2, drain F-3.' },
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: same reach as manabolt but the damage is STUN. Damage F-2, drain F-3.' },
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: resisted by BODY alone; armor still means nothing. Damage F-2, drain F-3.' },
148
- { key: 'fireball', cast: 'fireball', how: 'cast fireball at <target>',
149
- line: 'indirect elemental: soaked like a gunshot (Body + armor, AP F/3) but it hits at FULL Force. Drain F-2.' },
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: full-Force damage, soaked like a gunshot. Cheaper than fire -- drain F-3.' },
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: full-Force damage that eats armor on the way in. Drain F-3.' },
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 washing sheet of flame at full Force, soaked like a gunshot. Drain F-3.' },
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, full Force, damage lands STUN. Drain F-3.' },
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, then they resist with Willpower. Damage F-2, drain F-6.' },
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 damage: the quiet way to take one alive. Damage F-2, drain F-6.' },
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 -- BODY resists, bone gives. Damage F-2, drain F-6.' },
164
- { key: 'manaball', cast: 'manaball', how: 'cast manaball at <target>',
165
- line: 'AREA direct mana: your target takes the exchange, everyone else in the room eats the blast. Damage F-2, drain F.' },
166
- { key: 'stunball', cast: 'stunball', how: 'cast stunball at <target>',
167
- line: 'AREA direct mana, STUN: the room-clearing knockout. Damage F-2, drain F.' },
168
- { key: 'powerball', cast: 'powerball', how: 'cast powerball at <target>',
169
- line: 'AREA direct physical: BODY resists, armor does not. Damage F-2, drain F.' },
170
- { key: 'ball-lightning', cast: 'ball-lightning', how: 'cast ball-lightning at <target>',
171
- line: 'AREA indirect elemental: full-Force damage across the room, soaked with armor. Drain F-1.' },
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,21 @@
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
- export const ENGINE_VERSION = '1.197.0';
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';
3368
3384
  //# sourceMappingURL=engine-version.js.map
@@ -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
- const defense = turn.noDefense
697
- ? rollPool(0)
698
- : rollPool(defender.getDefensePool() + inspired + counterspell + coverDice + proneDefence + turn.defense);
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.385.0",
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.",