@maka/maka-cli 5.134.0 → 5.136.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/aim.js +60 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +132 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +12 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +102 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +24 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/defend.js +74 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/delay.js +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +11 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-turn.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/heal.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiative.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +42 -59
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +23 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +31 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +16 -15
- package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +10 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +18 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +35 -18
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +21 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +50 -13
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +102 -5
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +12 -12
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-budget.js +87 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +66 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +101 -5
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +571 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/movement-cost.js +80 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +122 -0
- package/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +0 -182
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.136.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.",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Command } from './command.js';
|
|
2
|
+
import { billAction, phaseHint } from '../utilities/action-cost.js';
|
|
3
|
+
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
4
|
+
import { hint } from '../utilities/hints.js';
|
|
5
|
+
/**
|
|
6
|
+
* TAKE AIM (SR5 p.166, RAG-checked 2026-09-06): a Simple Action that
|
|
7
|
+
* adds +1 die to the next ranged attack, cumulative across Simple
|
|
8
|
+
* Actions and Action Phases, up to one-half Willpower rounded up -- and
|
|
9
|
+
* lost the moment "any other kind of action (including a Free Action)"
|
|
10
|
+
* comes before the shot. The bonus lands in combat-exchange.ts and is
|
|
11
|
+
* consumed by the strike; action-cost.ts clears it on every other spend.
|
|
12
|
+
*
|
|
13
|
+
* Only a READY ranged weapon can be aimed: a holstered pistol has nothing
|
|
14
|
+
* to line up, and a blade is not aimed.
|
|
15
|
+
*/
|
|
16
|
+
export class AimCommand extends Command {
|
|
17
|
+
static verb = 'aim';
|
|
18
|
+
static description = 'Take Aim (a Simple Action, SR5 p.166): +1 die on your next shot, stacking up to half your Willpower across aims -- lost if you do anything else first. "aim <name>" lines up on someone in particular.';
|
|
19
|
+
async execute(args = []) {
|
|
20
|
+
const actor = this.actor;
|
|
21
|
+
if (actor.plane !== 'meat' && actor.plane !== 'drone') {
|
|
22
|
+
return `Aiming is for a weapon in your hands -- there is nothing to line up from here.`;
|
|
23
|
+
}
|
|
24
|
+
const weapon = actor.getCarriedWeapon();
|
|
25
|
+
if (!weapon || !weapon.isFirearm()) {
|
|
26
|
+
return weapon
|
|
27
|
+
? `The ${weapon.name} isn't aimed, it's swung.`
|
|
28
|
+
: `Nothing in hand to aim.`;
|
|
29
|
+
}
|
|
30
|
+
if (!actor.weaponDrawn) {
|
|
31
|
+
return `The ${weapon.name} is holstered -- "draw" it first (Ready Weapon, a Simple Action, p.165).`;
|
|
32
|
+
}
|
|
33
|
+
if (!this.scene.encounterFor?.(actor)) {
|
|
34
|
+
return `Nobody's shooting yet -- aiming only counts inside a Combat Turn.${hint(` (Attack someone to open one.)`)}`;
|
|
35
|
+
}
|
|
36
|
+
let targetName;
|
|
37
|
+
if (args.length > 0) {
|
|
38
|
+
const query = args.join(' ').replace(/^(at|on)\s+/i, '');
|
|
39
|
+
const candidates = this.scene.getActorsInRoom(actor.currentLocation)
|
|
40
|
+
.filter(a => a !== actor && a.sharesCombatPlane(actor));
|
|
41
|
+
targetName = fuzzyPickName(query, candidates.map(a => a.name)) ?? undefined;
|
|
42
|
+
if (!targetName)
|
|
43
|
+
return `Nobody named "${query}" here to aim at.`;
|
|
44
|
+
}
|
|
45
|
+
const cap = actor.aimCap;
|
|
46
|
+
if (actor.aimBonus >= cap) {
|
|
47
|
+
return `You're already lined up as well as you can be (+${actor.aimBonus}, the cap for Willpower ${actor.willpower}). Shoot.`;
|
|
48
|
+
}
|
|
49
|
+
const bill = billAction(this.scene, actor, 'simple', 'Take Aim', { aiming: true });
|
|
50
|
+
if (bill)
|
|
51
|
+
return bill;
|
|
52
|
+
actor.aimBonus += 1;
|
|
53
|
+
if (targetName)
|
|
54
|
+
actor.aimTarget = targetName;
|
|
55
|
+
actor.performAction('takes aim', targetName ? `at ${targetName}` : `down the ${weapon.name}`);
|
|
56
|
+
this.scene.updateStatus();
|
|
57
|
+
return `You settle the ${weapon.name}${targetName ? ` on ${targetName}` : ''} -- +${actor.aimBonus} on your next shot${actor.aimBonus >= cap ? ' (the cap)' : ''}.${hint(` Anything but another aim or the shot itself loses it.`)}${phaseHint(this.scene, actor)}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=aim.js.map
|
|
@@ -6,8 +6,10 @@ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
|
6
6
|
import { describeFledTarget } from '../utilities/pursuit.js';
|
|
7
7
|
import { shellsInRoom, resolveBodyAttack, crossPlaneRefusal } from '../utilities/planes.js';
|
|
8
8
|
import { heldBy, holding } from '../utilities/grapple.js';
|
|
9
|
-
import { spotOf, ensureAtSpot, ensureAtExit } from '../utilities/spots.js';
|
|
9
|
+
import { spotOf, ensureAtSpot, ensureAtExit, canReach, spotDistanceMeters } from '../utilities/spots.js';
|
|
10
10
|
import { resolveBarrierStrike } from '../utilities/barrier-combat.js';
|
|
11
|
+
import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
|
|
12
|
+
import { spendMovementMeters } from '../utilities/movement-cost.js';
|
|
11
13
|
/**
|
|
12
14
|
* Shadowrun-style dice-pool combat, one full exchange per command:
|
|
13
15
|
*
|
|
@@ -49,7 +51,10 @@ export class AttackCommand extends Command {
|
|
|
49
51
|
if (gripHolder) {
|
|
50
52
|
return `${gripHolder.name} has you locked down -- "struggle" free before you fight.`;
|
|
51
53
|
}
|
|
52
|
-
// Violence is loud -- sneak mode ends the moment you swing.
|
|
54
|
+
// Violence is loud -- sneak mode ends the moment you swing. Whether
|
|
55
|
+
// you were sneaking WHEN you swung is what makes it an ambush (SR5
|
|
56
|
+
// p.192 Surprise), so that is read before it is cleared.
|
|
57
|
+
const ambush = this.actor.sneaking;
|
|
53
58
|
this.actor.sneaking = false;
|
|
54
59
|
// A recon frame has cameras, not guns (see Player.droneArmed) --
|
|
55
60
|
// jumped-in combat needs an armed airframe.
|
|
@@ -115,7 +120,22 @@ export class AttackCommand extends Command {
|
|
|
115
120
|
: device.opensExit ? ensureAtExit(this.actor, device.opensExit) : {};
|
|
116
121
|
if (reach.refusal)
|
|
117
122
|
return reach.refusal;
|
|
118
|
-
|
|
123
|
+
// In a Combat Turn the shot is an action and the gun must
|
|
124
|
+
// already be in hand (p.165); outside one it draws itself.
|
|
125
|
+
let drawn = null;
|
|
126
|
+
const barrierEnc = this.scene.encounterFor?.(this.actor);
|
|
127
|
+
if (barrierEnc) {
|
|
128
|
+
const held = this.actor.getCarriedWeapon();
|
|
129
|
+
if (held && !this.actor.weaponDrawn) {
|
|
130
|
+
return `The ${held.name} is holstered -- "draw" it first (Ready Weapon, a Simple Action, SR5 p.165).`;
|
|
131
|
+
}
|
|
132
|
+
const bill = billAction(this.scene, this.actor, held?.isFirearm() ? 'simple' : 'complex', 'Attack Barrier', { attack: true });
|
|
133
|
+
if (bill)
|
|
134
|
+
return bill;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
drawn = this.actor.drawWeapon();
|
|
138
|
+
}
|
|
119
139
|
const strike = resolveBarrierStrike(this.scene, this.logger, this.actor, device, room, {
|
|
120
140
|
heat: (amount, why) => this.game?.addHeat(amount, why),
|
|
121
141
|
});
|
|
@@ -199,6 +219,14 @@ export class AttackCommand extends Command {
|
|
|
199
219
|
if (this.usesEquippedWeapon() && weaponItem?.isFirearm() && weaponItem.ammo <= 0) {
|
|
200
220
|
return `Click -- the ${weaponItem.name} is dry. "reload" (needs ammunition), or go in swinging with something else.`;
|
|
201
221
|
}
|
|
222
|
+
// THE COMBAT TURN (utilities/combat-turn.ts): on the physical planes
|
|
223
|
+
// a fight is a turn/pass loop and this attack is ONE action in the
|
|
224
|
+
// actor's own Action Phase. The Matrix and the astral keep the older
|
|
225
|
+
// exchange below -- the Matrix has its own initiative structure
|
|
226
|
+
// (utilities/ic.ts) and astral combat has not been swept yet.
|
|
227
|
+
if (this.actor.plane === 'meat' || this.actor.plane === 'drone') {
|
|
228
|
+
return await this.executeInEncounter(target, room, weaponItem ?? undefined, ambush);
|
|
229
|
+
}
|
|
202
230
|
const report = [];
|
|
203
231
|
// ROOM GRIDS (spatial wave 2026-08-25): MELEE (fists, or an
|
|
204
232
|
// equipped weapon that isn't a firearm) needs adjacency -- the
|
|
@@ -287,6 +315,107 @@ export class AttackCommand extends Command {
|
|
|
287
315
|
exchange.releaseExchange();
|
|
288
316
|
}
|
|
289
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* ONE STRIKE IN A COMBAT TURN (SR5 p.158-167, RAG-checked 2026-09-06).
|
|
320
|
+
*
|
|
321
|
+
* Opening a fight rolls initiative for everyone in the room who is in
|
|
322
|
+
* it (Scene.encounterMembers) and runs the passes up to this actor's
|
|
323
|
+
* own Action Phase -- a faster opponent acts BEFORE the shot that
|
|
324
|
+
* started it, which is the whole point of the score. Then:
|
|
325
|
+
* - Ready Weapon is its own Simple Action (p.165): nothing draws for
|
|
326
|
+
* you any more. A holstered gun is a refusal that names the verb.
|
|
327
|
+
* - Melee needs reach, and ground costs metres from the whole-turn
|
|
328
|
+
* budget (p.161, utilities/movement-cost.ts).
|
|
329
|
+
* - The action: a melee attack is COMPLEX (p.167); firing a readied
|
|
330
|
+
* gun is SIMPLE (p.165). One attack per phase (p.164).
|
|
331
|
+
* - The strike resolves (combat-exchange.ts resolveStrike) with the
|
|
332
|
+
* turn's own modifiers: aim, charge, surprise, active defense.
|
|
333
|
+
* No retaliation here: the target answers on THEIR phase.
|
|
334
|
+
*/
|
|
335
|
+
async executeInEncounter(target, room, weaponItem, ambush) {
|
|
336
|
+
const actor = this.actor;
|
|
337
|
+
const human = this.scene.isHumanControlled(actor);
|
|
338
|
+
let enc = this.scene.encounterFor?.(actor);
|
|
339
|
+
if (!enc) {
|
|
340
|
+
enc = await this.scene.startEncounter(room, { aggressor: actor, target, ambush });
|
|
341
|
+
if (enc.ended) {
|
|
342
|
+
return human ? `` : `The fight was over before ${actor.name} got to act.`;
|
|
343
|
+
}
|
|
344
|
+
if (actor.isIncapacitated()) {
|
|
345
|
+
return human ? `You're down before you can act.` : `${actor.name} went down before acting.`;
|
|
346
|
+
}
|
|
347
|
+
if (!enc.isPhaseOf(actor)) {
|
|
348
|
+
// Someone faster is acting (a human's phase is waiting for its
|
|
349
|
+
// "end turn"); this attack becomes the next phase's business.
|
|
350
|
+
const who = enc.phaseActor?.name ?? 'the others';
|
|
351
|
+
return human
|
|
352
|
+
? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${this.actionVerb() === 'attacks' ? 'attack' : 'move'} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
|
|
353
|
+
: `${actor.name} squares up -- initiative rolled; ${who} first.`;
|
|
354
|
+
}
|
|
355
|
+
if (target.isIncapacitated() || target.currentLocation !== room) {
|
|
356
|
+
return human ? `${target.name} is no longer standing in front of you.` : `${target.name} is no longer a target.`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else if (!enc.isPhaseOf(actor)) {
|
|
360
|
+
return notYourPhase(enc, actor);
|
|
361
|
+
}
|
|
362
|
+
enc.noteAttack(actor, target);
|
|
363
|
+
// READY WEAPON (p.165) is an action, not a beat.
|
|
364
|
+
if (this.usesEquippedWeapon() && weaponItem && !actor.weaponDrawn) {
|
|
365
|
+
return `The ${weaponItem.name} is holstered -- "draw" it first (Ready Weapon, a Simple Action, SR5 p.165).${phaseHint(this.scene, actor)}`;
|
|
366
|
+
}
|
|
367
|
+
const isMelee = !this.usesEquippedWeapon() || !weaponItem?.isFirearm();
|
|
368
|
+
const report = [];
|
|
369
|
+
// MELEE REACH: ground costs (p.161-162).
|
|
370
|
+
if (isMelee) {
|
|
371
|
+
const targetSpot = spotOf(target);
|
|
372
|
+
const mine = spotOf(actor);
|
|
373
|
+
if (targetSpot !== undefined && !canReach(actor, targetSpot)) {
|
|
374
|
+
const meters = mine !== undefined && room.ensureGrid() ? spotDistanceMeters(room, mine, targetSpot) : 0;
|
|
375
|
+
const spend = spendMovementMeters(this.scene, actor, meters, 'walk', this.logger);
|
|
376
|
+
if (spend)
|
|
377
|
+
return `${target.name} is ${Math.round(meters)} m away. ${spend}`;
|
|
378
|
+
const reach = ensureAtSpot(actor, targetSpot);
|
|
379
|
+
if (reach.refusal)
|
|
380
|
+
return reach.refusal;
|
|
381
|
+
if (reach.line)
|
|
382
|
+
report.push(reach.line);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// THE ACTION.
|
|
386
|
+
const label = isMelee ? (this.usesEquippedWeapon() ? 'Melee Attack' : 'Subdue') : 'Fire Weapon';
|
|
387
|
+
const bill = billAction(this.scene, actor, isMelee ? 'complex' : 'simple', label, { attack: true });
|
|
388
|
+
if (bill)
|
|
389
|
+
return bill;
|
|
390
|
+
const exchange = new CombatExchange(this.scene, this.logger, actor);
|
|
391
|
+
actor.performAction(this.actionVerb(), target.name);
|
|
392
|
+
this.scene.addWorldEvent(`${actor.name} ${this.actionVerb()} ${target.name} in ${room.name}.`);
|
|
393
|
+
// The engagement memory pursuit and the parting shot key off.
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
actor.combatOpponent = target;
|
|
396
|
+
actor.lastExchangeAt = now;
|
|
397
|
+
target.combatOpponent = target.combatOpponent ?? actor;
|
|
398
|
+
target.lastExchangeAt = now;
|
|
399
|
+
actor.surrendered = false;
|
|
400
|
+
if (!isMelee) {
|
|
401
|
+
actor.firedThisPhase = true;
|
|
402
|
+
actor.recoilRoundsFired += 1;
|
|
403
|
+
}
|
|
404
|
+
const profile = this.strikeProfile(exchange, target);
|
|
405
|
+
const strike = exchange.resolveStrike(actor, target, profile);
|
|
406
|
+
report.push(...strike.world, ...strike.meta);
|
|
407
|
+
await exchange.announce(strike.world, strike.meta);
|
|
408
|
+
enc.syncWounds();
|
|
409
|
+
const takedown = await this.resolveTakedown(exchange, target, actor);
|
|
410
|
+
if (takedown)
|
|
411
|
+
report.push(...takedown);
|
|
412
|
+
this.scene.updateStatus();
|
|
413
|
+
if (human) {
|
|
414
|
+
const tail = phaseHint(this.scene, actor).trim();
|
|
415
|
+
return tail;
|
|
416
|
+
}
|
|
417
|
+
return report.join('\n');
|
|
418
|
+
}
|
|
290
419
|
/** subdue.ts overrides to false: its strike ignores the equipped gun entirely. */
|
|
291
420
|
usesEquippedWeapon() {
|
|
292
421
|
return true;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from './command.js';
|
|
2
2
|
import { Category } from '../types/shared/item-enum.js';
|
|
3
3
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
4
|
+
import { billAction, phaseHint } from '../utilities/action-cost.js';
|
|
4
5
|
/**
|
|
5
6
|
* The two halves of the holster mechanic (see Player.weaponDrawn): an
|
|
6
7
|
* equipped weapon rides holstered until "brandish" puts it in hand, and
|
|
@@ -38,6 +39,11 @@ export class BrandishCommand extends Command {
|
|
|
38
39
|
if (actor.weaponDrawn) {
|
|
39
40
|
return `The ${carried.name} is already in your hand.`;
|
|
40
41
|
}
|
|
42
|
+
// READY WEAPON (SR5 p.165): a Simple Action. In a Combat Turn nothing
|
|
43
|
+
// draws for you any more -- the holster is a decision, not a beat.
|
|
44
|
+
const bill = billAction(this.scene, actor, 'simple', 'Ready Weapon');
|
|
45
|
+
if (bill)
|
|
46
|
+
return bill;
|
|
41
47
|
actor.drawWeapon();
|
|
42
48
|
// A threat display is the OPPOSITE of sneaking.
|
|
43
49
|
actor.sneaking = false;
|
|
@@ -60,7 +66,7 @@ export class BrandishCommand extends Command {
|
|
|
60
66
|
const ammoNote = carried.isFirearm()
|
|
61
67
|
? (carried.ammo === 0 ? ' The magazine is DRY -- "reload".' : ` ${carried.ammo} rounds ready.`)
|
|
62
68
|
: '';
|
|
63
|
-
return `You draw the ${carried.name}.${ammoNote} Menace: ${formatRoll(menaceRoll)} -- everyone here just watched you do it
|
|
69
|
+
return `You draw the ${carried.name}.${ammoNote} Menace: ${formatRoll(menaceRoll)} -- everyone here just watched you do it.${phaseHint(this.scene, actor)}`;
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
export class HolsterCommand extends Command {
|
|
@@ -78,6 +84,11 @@ export class HolsterCommand extends Command {
|
|
|
78
84
|
if (!actor.weaponDrawn) {
|
|
79
85
|
return `The ${carried.name} is already holstered.`;
|
|
80
86
|
}
|
|
87
|
+
// Putting a weapon away is the Simple Action's other half (p.165
|
|
88
|
+
// Pick Up/Put Down Object).
|
|
89
|
+
const bill = billAction(this.scene, actor, 'simple', 'Holster Weapon');
|
|
90
|
+
if (bill)
|
|
91
|
+
return bill;
|
|
81
92
|
actor.stowWeapon();
|
|
82
93
|
actor.performAction('holsters', carried.name);
|
|
83
94
|
this.logger.write(`${actor.name} holsters ${carried.name}.`);
|
|
@@ -9,6 +9,7 @@ import { crossPlaneRefusal } from '../utilities/planes.js';
|
|
|
9
9
|
import { spotOf, ensureAtSpot } from '../utilities/spots.js';
|
|
10
10
|
import { NPC } from '../models/npc.js';
|
|
11
11
|
import { spellFor, catalogSpells } from '../utilities/catalog.js';
|
|
12
|
+
import { billAction, notYourPhase, phaseHint } from '../utilities/action-cost.js';
|
|
12
13
|
/**
|
|
13
14
|
* ISpell and SPELLS now live in `../spells.js` (imported above) -- the ONE
|
|
14
15
|
* source shared with the chargen spell picker, so the cast mechanics and
|
|
@@ -530,6 +531,13 @@ export class CastCommand extends BypassCommand {
|
|
|
530
531
|
report.push(reach.line);
|
|
531
532
|
}
|
|
532
533
|
}
|
|
534
|
+
// THE COMBAT TURN (utilities/combat-turn.ts): on the physical planes
|
|
535
|
+
// a combat spell is one Complex Action in the caster's own Action
|
|
536
|
+
// Phase (p.167) -- the target answers on theirs. The astral keeps
|
|
537
|
+
// the exchange below.
|
|
538
|
+
if (this.actor.plane === 'meat' || this.actor.plane === 'drone') {
|
|
539
|
+
return await this.castInEncounter(exchange, spell, spellKey, force, overcast, target, room, flavor, report);
|
|
540
|
+
}
|
|
533
541
|
// Movement locks while the exchange resolves, same as attack.ts. The
|
|
534
542
|
// caster's hands are shaping mana, not clearing leather (no
|
|
535
543
|
// aggressor draw) -- but the TARGET's counter is their weapon, so
|
|
@@ -669,6 +677,100 @@ export class CastCommand extends BypassCommand {
|
|
|
669
677
|
exchange.releaseExchange();
|
|
670
678
|
}
|
|
671
679
|
}
|
|
680
|
+
/**
|
|
681
|
+
* ONE SPELL IN A COMBAT TURN (SR5 p.158-167). Opening a fight rolls
|
|
682
|
+
* initiative for the room and runs the passes up to the caster's own
|
|
683
|
+
* phase (a faster target acts BEFORE the incantation lands, which is
|
|
684
|
+
* what the old "moves before the incantation finishes" line was
|
|
685
|
+
* reaching for). Then the cast is the phase's Complex Action and its
|
|
686
|
+
* one attack; the bolt resolves with the turn's modifiers
|
|
687
|
+
* (combat-exchange.ts), the area splashes, and drain comes due. No
|
|
688
|
+
* counter here -- the target answers on THEIR phase.
|
|
689
|
+
*/
|
|
690
|
+
async castInEncounter(exchange, spell, spellKey, force, overcast, target, room, flavor, report) {
|
|
691
|
+
const actor = this.actor;
|
|
692
|
+
const human = this.scene.isHumanControlled(actor);
|
|
693
|
+
let enc = this.scene.encounterFor?.(actor);
|
|
694
|
+
if (!enc) {
|
|
695
|
+
enc = await this.scene.startEncounter(room, { aggressor: actor, target, ambush: false });
|
|
696
|
+
if (enc.ended)
|
|
697
|
+
return human ? '' : `The fight was over before ${actor.name} could cast.`;
|
|
698
|
+
if (actor.isIncapacitated())
|
|
699
|
+
return human ? `You're down before you can cast.` : `${actor.name} went down before casting.`;
|
|
700
|
+
if (!enc.isPhaseOf(actor)) {
|
|
701
|
+
const who = enc.phaseActor?.name ?? 'the others';
|
|
702
|
+
return human
|
|
703
|
+
? `Initiative rolled -- ${who} ${enc.phaseActor ? 'acts' : 'act'} first. Your ${spellKey} waits for your Action Phase.${hint(` ("initiative" shows the order.)`)}`
|
|
704
|
+
: `${actor.name} gathers mana -- initiative rolled; ${who} first.`;
|
|
705
|
+
}
|
|
706
|
+
if (target.isIncapacitated() || target.currentLocation !== room) {
|
|
707
|
+
return `${target.name} is no longer a target.`;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
else if (!enc.isPhaseOf(actor)) {
|
|
711
|
+
return notYourPhase(enc, actor);
|
|
712
|
+
}
|
|
713
|
+
enc.noteAttack(actor, target);
|
|
714
|
+
// CAST SPELL is a Complex Action (p.167) and the phase's attack (p.164).
|
|
715
|
+
const bill = billAction(this.scene, actor, 'complex', `Cast ${spellKey}`, { attack: true });
|
|
716
|
+
if (bill)
|
|
717
|
+
return bill;
|
|
718
|
+
actor.performAction(`casts ${spellKey} at`, target.name);
|
|
719
|
+
this.scene.addWorldEvent(`${actor.name} ${flavor.act} ${target.name} in ${room.name}.`);
|
|
720
|
+
const now = Date.now();
|
|
721
|
+
actor.combatOpponent = target;
|
|
722
|
+
actor.lastExchangeAt = now;
|
|
723
|
+
target.combatOpponent = target.combatOpponent ?? actor;
|
|
724
|
+
target.lastExchangeAt = now;
|
|
725
|
+
actor.surrendered = false;
|
|
726
|
+
// The spell's own numbers -- identical to the exchange path below.
|
|
727
|
+
const spellAp = spell.soak === 'armor' ? Math.floor(force / 3) : 0;
|
|
728
|
+
const armorEaten = spellAp > 0
|
|
729
|
+
? Math.min(spellAp, target.plane === 'meat' ? target.getArmorValue() : 0)
|
|
730
|
+
: 0;
|
|
731
|
+
const magicResistance = spell.soak === 'armor' ? 0 : target.qualityRating('magic-resistance');
|
|
732
|
+
const soakPool = (spell.soak === 'willpower' ? target.willpower
|
|
733
|
+
: spell.soak === 'body' ? target.body
|
|
734
|
+
: Math.max(0, target.getSoakPool() - armorEaten)) + magicResistance;
|
|
735
|
+
const damageBase = spell.soak === 'armor' ? force : Math.max(1, force - 2);
|
|
736
|
+
const strike = exchange.resolveStrike(actor, target, {
|
|
737
|
+
label: `with a Force ${force} ${spellKey}${overcast ? ' (OVERCAST)' : ''}, ${this.castingLabel()}${armorEaten > 0 ? ` (AP -${armorEaten})` : ''}`,
|
|
738
|
+
pool: actor.getMagicPool() + actor.consumeEdgeBoost(),
|
|
739
|
+
damageBase,
|
|
740
|
+
soakPool,
|
|
741
|
+
dealStun: spell.stun,
|
|
742
|
+
isSpell: true,
|
|
743
|
+
limit: force,
|
|
744
|
+
});
|
|
745
|
+
report.push('', ...strike.world, ...strike.meta);
|
|
746
|
+
await exchange.announce(strike.world, strike.meta);
|
|
747
|
+
enc.syncWounds();
|
|
748
|
+
if (target.isDown()) {
|
|
749
|
+
report.push(...await exchange.resolveDeath(target, actor, {
|
|
750
|
+
fallLine: `${target.name} ${flavor.fall} Their gear scatters across ${room.name}.`,
|
|
751
|
+
}));
|
|
752
|
+
}
|
|
753
|
+
else if (target.isUnconscious()) {
|
|
754
|
+
report.push(...(this.scene.isHumanControlled(target)
|
|
755
|
+
? await exchange.resolvePlayerKnockout(actor, target)
|
|
756
|
+
: await exchange.resolveDeath(target, actor, {
|
|
757
|
+
fatal: false,
|
|
758
|
+
fallLine: `${target.name} sags and drops -- out cold, breathing. Their gear scatters across ${room.name}.`,
|
|
759
|
+
})));
|
|
760
|
+
}
|
|
761
|
+
if (spell.area) {
|
|
762
|
+
report.push(...await this.splashArea(exchange, spellKey, spell, force, target, damageBase, spellAp));
|
|
763
|
+
enc.syncWounds();
|
|
764
|
+
}
|
|
765
|
+
const drain = this.applyDrain(spell, force, overcast);
|
|
766
|
+
report.push('', ...drain);
|
|
767
|
+
await exchange.announce(drain);
|
|
768
|
+
const collapse = await this.drainCollapse(exchange, target);
|
|
769
|
+
report.push(...collapse);
|
|
770
|
+
enc.syncWounds();
|
|
771
|
+
this.scene.updateStatus();
|
|
772
|
+
return human ? phaseHint(this.scene, actor).trim() : report.join('\n');
|
|
773
|
+
}
|
|
672
774
|
/**
|
|
673
775
|
* The rest of the room, for an AREA spell: every other hostile takes
|
|
674
776
|
* the same damage, resisted with the same soak the primary target
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from './command.js';
|
|
2
2
|
import { hint } from '../utilities/hints.js';
|
|
3
3
|
import { coverAvailableFor, coverSpotsIn, isInCover, takeCoverHere, spotOf, sealedRouteTo, firstReachable } from '../utilities/spots.js';
|
|
4
|
+
import { billAction, phaseHint, requirePhase } from '../utilities/action-cost.js';
|
|
4
5
|
/**
|
|
5
6
|
* TAKE COVER (player request 2026-08-27: "I should be able to 'Take
|
|
6
7
|
* Cover' as a combat command").
|
|
@@ -20,10 +21,11 @@ import { coverAvailableFor, coverSpotsIn, isInCover, takeCoverHere, spotOf, seal
|
|
|
20
21
|
* standing behind the crates is a position and taking cover is a
|
|
21
22
|
* choice, and only the choice pays.
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* p.166 also bars a SURPRISED character from taking cover. Since the
|
|
25
|
+
* Combat Turn landed (utilities/combat-turn.ts, 2026-09-06) the engine
|
|
26
|
+
* has a surprise state to read -- the Surprise Test at an ambushed
|
|
27
|
+
* fight's open -- and this verb reads it. The action itself is billed
|
|
28
|
+
* through action-cost.ts like every other verb with a cost.
|
|
27
29
|
*/
|
|
28
30
|
export class CoverCommand extends Command {
|
|
29
31
|
static verb = 'cover';
|
|
@@ -35,6 +37,11 @@ export class CoverCommand extends Command {
|
|
|
35
37
|
? `Cover is a meat-world idea -- there's nothing to hide behind on the grid.`
|
|
36
38
|
: `Nothing out here casts a shadow you can get behind.`;
|
|
37
39
|
}
|
|
40
|
+
// Whose phase it is comes before where the cover is: the second
|
|
41
|
+
// answer is useless until the first is "yours".
|
|
42
|
+
const phase = requirePhase(this.scene, actor);
|
|
43
|
+
if (phase)
|
|
44
|
+
return phase;
|
|
38
45
|
const room = actor.currentLocation;
|
|
39
46
|
const available = coverAvailableFor(actor);
|
|
40
47
|
if (!available) {
|
|
@@ -62,16 +69,27 @@ export class CoverCommand extends Command {
|
|
|
62
69
|
if (isInCover(actor)) {
|
|
63
70
|
return `You're already tucked in behind ${spotOf(actor) ? `the ${spotOf(actor)}` : 'it'} -- as small as this gets.`;
|
|
64
71
|
}
|
|
72
|
+
// THE SIMPLE ACTION the class doc names (p.166) is now actually
|
|
73
|
+
// spent, inside a Combat Turn. p.166 also bars a SURPRISED character
|
|
74
|
+
// from taking cover -- the encounter knows who is surprised.
|
|
75
|
+
const enc = this.scene.encounterFor?.(actor);
|
|
76
|
+
if (enc?.isSurprised(actor)) {
|
|
77
|
+
return `You're caught flat-footed -- no cover until you get your bearings (SR5 p.166, p.192).`;
|
|
78
|
+
}
|
|
79
|
+
const bill = billAction(this.scene, actor, 'simple', 'Take Cover');
|
|
80
|
+
if (bill)
|
|
81
|
+
return bill;
|
|
65
82
|
takeCoverHere(actor);
|
|
66
83
|
const dice = available === 'good' ? 4 : 2;
|
|
67
84
|
actor.performAction('takes cover', `at the ${spotOf(actor)}`);
|
|
68
85
|
this.logger.write(`CoverCommand: ${actor.name} took ${available} cover at ${spotOf(actor)}.`);
|
|
69
86
|
this.scene.updateStatus?.();
|
|
70
|
-
return available === 'good'
|
|
87
|
+
return (available === 'good'
|
|
71
88
|
? `You put ${spotOf(actor) ? `the ${spotOf(actor)}` : 'it'} between you and the room and fold down behind it -- most of you is gone.`
|
|
72
89
|
+ `\n {green-fg}GOOD COVER: +${dice} to defend.{/green-fg}${hint(` You keep it until you move.`)}`
|
|
73
90
|
: `You drop in behind ${spotOf(actor) ? `the ${spotOf(actor)}` : 'it'} -- not all of you fits, but enough.`
|
|
74
|
-
+ `\n {green-fg}PARTIAL COVER: +${dice} to defend.{/green-fg}${hint(` You keep it until you move.`)}
|
|
91
|
+
+ `\n {green-fg}PARTIAL COVER: +${dice} to defend.{/green-fg}${hint(` You keep it until you move.`)}`)
|
|
92
|
+
+ phaseHint(this.scene, actor);
|
|
75
93
|
}
|
|
76
94
|
}
|
|
77
95
|
//# sourceMappingURL=cover.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Command } from './command.js';
|
|
2
|
+
import { hint } from '../utilities/hints.js';
|
|
3
|
+
/**
|
|
4
|
+
* ACTIVE DEFENSE (SR5 p.168, p.190-192, RAG-checked 2026-09-06).
|
|
5
|
+
*
|
|
6
|
+
* The book's four options all spend Initiative Score rather than actions:
|
|
7
|
+
* FULL DEFENSE -10, +Willpower to every Defense Test for the rest of
|
|
8
|
+
* the Combat Turn; declarable "at any point in a Combat
|
|
9
|
+
* Turn, even before the character's Action Phase" (p.191)
|
|
10
|
+
* DODGE -5, +Gymnastics on one Defense Test, any attack
|
|
11
|
+
* BLOCK -5, +Unarmed Combat on one test, melee only
|
|
12
|
+
* PARRY -5, +the melee weapon's skill on one test, melee
|
|
13
|
+
* only, weapon in hand
|
|
14
|
+
* "Only if he has enough Initiative Score left in the Combat Turn to pay
|
|
15
|
+
* the price" (p.167-168): a score at or below zero buys none of them.
|
|
16
|
+
*
|
|
17
|
+
* Full Defense is declared here and now. The other three answer an
|
|
18
|
+
* attack that has not happened yet, so they are set as a POLICY the
|
|
19
|
+
* engine applies when a shot or swing comes in and the score can pay
|
|
20
|
+
* (combat-exchange.ts) -- a terminal cannot ask mid-pass, and a hosted
|
|
21
|
+
* table cannot wait on one player's keystroke.
|
|
22
|
+
*/
|
|
23
|
+
export class DefendCommand extends Command {
|
|
24
|
+
static verb = 'defend';
|
|
25
|
+
static description = 'Active defense (SR5 p.191): "defend full" spends 10 Initiative for +Willpower on every defense this turn; "defend dodge", "defend block" or "defend parry" sets what you do when attacked (-5 Initiative each time it fires, +Gymnastics / +Unarmed / +weapon skill); "defend none" clears it.';
|
|
26
|
+
async execute(args = []) {
|
|
27
|
+
const actor = this.actor;
|
|
28
|
+
const word = (args[0] ?? '').toLowerCase();
|
|
29
|
+
const enc = this.scene.encounterFor?.(actor);
|
|
30
|
+
if (!word) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`Defense policy: ${actor.defensePolicy.toUpperCase()}${enc?.fullDefenseActive(actor) ? ' -- FULL DEFENSE this turn' : ''}.`,
|
|
33
|
+
'',
|
|
34
|
+
` full -- -10 Initiative now, +Willpower (${actor.willpower}) on every defense this Combat Turn (p.191)`,
|
|
35
|
+
` dodge -- -5 when it fires, +Gymnastics (${actor.skillRating('gymnastics')}) against any attack (p.168)`,
|
|
36
|
+
` block -- -5 when it fires, +Unarmed Combat (${actor.skillRating('unarmed')}) against melee (p.168)`,
|
|
37
|
+
` parry -- -5 when it fires, +your melee weapon's skill against melee, weapon in hand (p.168)`,
|
|
38
|
+
` none -- plain Reaction + Intuition`,
|
|
39
|
+
'',
|
|
40
|
+
`Interrupts need Initiative Score left to pay for them${enc ? ` -- you have ${enc.scoreOf(actor)}` : ''}.`,
|
|
41
|
+
];
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
if (/^(full|full-defense|fulldefense)$/.test(word) || (word === 'full' && args[1] === 'defense')) {
|
|
45
|
+
if (!enc)
|
|
46
|
+
return `You're not in a fight -- Full Defense is a Combat Turn thing.${hint(` ("defend dodge" and friends can be set any time.)`)}`;
|
|
47
|
+
if (actor.plane !== 'meat' && actor.plane !== 'drone')
|
|
48
|
+
return `Full Defense is a body's move.`;
|
|
49
|
+
const refusal = enc.declareFullDefense(actor);
|
|
50
|
+
if (refusal)
|
|
51
|
+
return refusal;
|
|
52
|
+
actor.performAction('goes on full defense', '');
|
|
53
|
+
this.scene.updateStatus();
|
|
54
|
+
return `You go on FULL DEFENSE -- -10 Initiative (now ${enc.scoreOf(actor)}), +${actor.willpower} to every defense for the rest of this Combat Turn (SR5 p.191).`;
|
|
55
|
+
}
|
|
56
|
+
const policy = ['dodge', 'block', 'parry', 'none'].find(p => p === word);
|
|
57
|
+
if (!policy) {
|
|
58
|
+
return `Defend how? "defend full", "defend dodge", "defend block", "defend parry" or "defend none".`;
|
|
59
|
+
}
|
|
60
|
+
actor.defensePolicy = policy;
|
|
61
|
+
this.scene.updateStatus();
|
|
62
|
+
switch (policy) {
|
|
63
|
+
case 'none':
|
|
64
|
+
return `Defense policy cleared -- plain Reaction + Intuition from here.`;
|
|
65
|
+
case 'dodge':
|
|
66
|
+
return `You'll DODGE what comes in: -5 Initiative each time, +${actor.skillRating('gymnastics')} (Gymnastics) on that Defense Test${actor.skillRating('gymnastics') === 0 ? ' -- which is nothing; the score would be spent for no dice' : ''}.`;
|
|
67
|
+
case 'block':
|
|
68
|
+
return `You'll BLOCK melee attacks: -5 Initiative each time, +${actor.skillRating('unarmed')} (Unarmed Combat) on that Defense Test.`;
|
|
69
|
+
case 'parry':
|
|
70
|
+
return `You'll PARRY melee attacks with your weapon: -5 Initiative each time, +your melee skill on that Defense Test${!actor.weaponDrawn || actor.getCarriedWeapon()?.isFirearm() ? ' -- once you have a melee weapon in hand' : ''}.`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=defend.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command } from './command.js';
|
|
2
|
+
import { notYourPhase } from '../utilities/action-cost.js';
|
|
3
|
+
/**
|
|
4
|
+
* DELAYING ACTIONS (SR5 p.161, RAG-checked 2026-09-06): "declared during
|
|
5
|
+
* Step 3A", before any action is taken; the character keeps their score
|
|
6
|
+
* and acts on a lower Initiative Score later in the same pass. If the
|
|
7
|
+
* pass ends first, the standard -10 applies like anyone else's.
|
|
8
|
+
*
|
|
9
|
+
* Modelled as "act after everyone else still owed a phase this pass"
|
|
10
|
+
* -- the engine cannot offer the book's interrupt-at-any-moment, so it
|
|
11
|
+
* gives the last word instead, which is what delaying is for.
|
|
12
|
+
*/
|
|
13
|
+
export class DelayCommand extends Command {
|
|
14
|
+
static verb = 'delay';
|
|
15
|
+
static description = 'Delay your Action Phase (SR5 p.161): keep your Initiative Score but act after the others this pass. Only before you have spent anything.';
|
|
16
|
+
async execute(_args = []) {
|
|
17
|
+
const actor = this.actor;
|
|
18
|
+
const enc = this.scene.encounterFor?.(actor);
|
|
19
|
+
if (!enc)
|
|
20
|
+
return `You're not in a fight -- nothing to delay.`;
|
|
21
|
+
if (!enc.isPhaseOf(actor))
|
|
22
|
+
return notYourPhase(enc, actor);
|
|
23
|
+
const budget = enc.budgetOf(actor);
|
|
24
|
+
if (budget && budget.spent.length > 0) {
|
|
25
|
+
return `Too late to delay -- you've already acted this phase (${budget.ledger()}). "end turn" instead.`;
|
|
26
|
+
}
|
|
27
|
+
if (this.scene.isHumanControlled(actor)) {
|
|
28
|
+
this.logger.log(`{cyan-fg}You hold your action, watching.{/cyan-fg}`, { actor: actor.name });
|
|
29
|
+
}
|
|
30
|
+
await enc.delayPhase(actor);
|
|
31
|
+
return this.scene.isHumanControlled(actor) ? '' : `${actor.name} holds their action.`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=delay.js.map
|
|
@@ -2,6 +2,7 @@ import { CastCommand } from './cast.js';
|
|
|
2
2
|
import { BypassCommand } from './bypass-command.js';
|
|
3
3
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
4
4
|
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
5
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
5
6
|
/**
|
|
6
7
|
* The magical barrier's OWN verb (player ruling: "solve" was too broad --
|
|
7
8
|
* a ward wants "dispel", not a generic answer verb), and the second half
|
|
@@ -70,6 +71,10 @@ export class DispelCommand extends CastCommand {
|
|
|
70
71
|
if (!this.actor.isCaster()) {
|
|
71
72
|
return `Unweaving a ward takes talent you don't have -- you'd need to be Awakened, or have a spell formula, grimoire, or focus on you.`;
|
|
72
73
|
}
|
|
74
|
+
// DISPELLING IS A COMPLEX ACTION (SR5 p.295) inside a Combat Turn.
|
|
75
|
+
const bill = billAction(this.scene, this.actor, 'complex', 'Dispel');
|
|
76
|
+
if (bill)
|
|
77
|
+
return bill;
|
|
73
78
|
// An actor's name beats a barrier noun -- "dispel ghost" means the
|
|
74
79
|
// mage, "dispel ward" falls through to the barrier route below.
|
|
75
80
|
if (args && args.length > 0) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from './command.js';
|
|
2
|
+
import { billAction } from '../utilities/action-cost.js';
|
|
2
3
|
export class DropCommand extends Command {
|
|
3
4
|
static verb = 'drop';
|
|
4
5
|
static description = 'Drop an item from your inventory.';
|
|
@@ -14,6 +15,10 @@ export class DropCommand extends Command {
|
|
|
14
15
|
? `Your pack is streets away with your body -- the ${this.actor.riggedDrone?.name ?? 'drone'} carries nothing to drop.`
|
|
15
16
|
: `Your pack is with your body. Come back to it first.`;
|
|
16
17
|
}
|
|
18
|
+
// DROP OBJECT (SR5 p.163): a Free Action in a Combat Turn.
|
|
19
|
+
const bill = billAction(this.scene, this.actor, 'free', 'Drop Object');
|
|
20
|
+
if (bill)
|
|
21
|
+
return bill;
|
|
17
22
|
const itemName = args.join(' ').toLowerCase(); // Assuming the item name may contain spaces
|
|
18
23
|
// Check if the item exists in the player's inventory
|
|
19
24
|
if (!this.actor.inventory.hasItem(itemName)) {
|
|
@@ -3,8 +3,17 @@ import { NPC } from '../models/npc.js';
|
|
|
3
3
|
import { CALL_ICON, CALL_COLOR } from '../utilities/comm-style.js';
|
|
4
4
|
export class EndCallCommand extends Command {
|
|
5
5
|
static verb = 'end-call';
|
|
6
|
-
static description = 'Hang up your current commlink call.';
|
|
7
|
-
async execute() {
|
|
6
|
+
static description = 'Hang up your current commlink call. ("end turn" commits your Action Phase in a fight -- see end-turn.)';
|
|
7
|
+
async execute(args = []) {
|
|
8
|
+
// "end turn" / "end phase" is the Combat Turn's commit, not a hangup
|
|
9
|
+
// (commands/end-turn.ts). The bare verb keeps its older meaning.
|
|
10
|
+
if (/^(turn|phase|round|action)s?$/i.test(args[0] ?? '')) {
|
|
11
|
+
const { EndTurnCommand } = await import('./end-turn.js');
|
|
12
|
+
return new EndTurnCommand({
|
|
13
|
+
actor: this.actor, scene: this.scene, rooms: this.rooms,
|
|
14
|
+
screen: this.screen, game: this.game,
|
|
15
|
+
}).execute();
|
|
16
|
+
}
|
|
8
17
|
const partner = this.actor.activeCallPartner;
|
|
9
18
|
if (!partner) {
|
|
10
19
|
// A PLAYER call hangs up for EVERYONE (player ruling): the game
|