@maka/maka-cli 5.133.0 → 5.135.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.
Files changed (44) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/aim.js +60 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +132 -3
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +12 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +102 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +24 -6
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/defend.js +74 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/delay.js +34 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +5 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +5 -0
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +11 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/end-turn.js +58 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +6 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +14 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +15 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/heal.js +6 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/initiative.js +20 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +56 -59
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +23 -7
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +31 -10
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +6 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +5 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +6 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +5 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +8 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/game.js +24 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +2 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +21 -4
  31. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +44 -0
  32. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +33 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +98 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/ui.js +3 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/utilities/action-budget.js +87 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +66 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +58 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +99 -3
  39. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +568 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/utilities/companion-heel.js +67 -9
  41. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +20 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/movement-cost.js +80 -0
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +122 -0
  44. package/package.json +1 -1
@@ -0,0 +1,58 @@
1
+ import { Command } from './command.js';
2
+ import { notYourPhase } from '../utilities/action-cost.js';
3
+ /**
4
+ * END TURN -- committing your Action Phase (SR5 p.158-159).
5
+ *
6
+ * The Combat Turn (utilities/combat-turn.ts) hands each combatant an
7
+ * Action Phase in initiative order: two Simple Actions or one Complex,
8
+ * plus a Free. A human's phase waits for this verb. Whatever is unspent
9
+ * when you type it is forfeit -- the book gives no refund -- and the
10
+ * pass moves to the next Initiative Score. When everyone has acted,
11
+ * every score drops by 10 (p.159) and anyone still above zero goes
12
+ * again; when nobody is, a new Combat Turn rolls fresh initiative.
13
+ *
14
+ * WHAT NOW COSTS AN ACTION, swept as one family (CLAUDE.md: "partial
15
+ * canon is worse than either extreme"):
16
+ * Free drop prone (p.164) · drop an object (p.163) · surrender ·
17
+ * release a hold · running past your Walk Rate (p.162)
18
+ * Simple draw / holster (Ready Weapon, p.165) · take cover (p.166) ·
19
+ * stand up (p.166) · take aim (p.166) · fire a weapon (p.165)
20
+ * · pick up an object (p.165) · equip / unequip · use a
21
+ * simple device · observe in detail (search, p.165)
22
+ * Complex melee attack (p.167) · subdue / grapple · struggle free
23
+ * (p.195) · cast a spell (p.167) · summon (p.167) · sprint
24
+ * (p.167) · reload (p.163/167) · first aid (p.206)
25
+ * Score Full Defense -10 for the turn (p.191) · dodge / block /
26
+ * parry -5 each, by "defend" policy (p.168)
27
+ * Movement is metres from a whole-turn budget (p.161), not an action.
28
+ *
29
+ * WHY THE WORD IS "end turn" AND NOT "end": "end" already hangs up a
30
+ * commlink call (commands/end-call.ts). EndCallCommand hands "end turn"
31
+ * / "end phase" here; "done" and "pass" are the short forms.
32
+ */
33
+ export class EndTurnCommand extends Command {
34
+ static verb = 'end-turn';
35
+ static description = 'Commit your Action Phase (SR5 p.158): anything unspent is forfeit and the pass moves to the next combatant. Also "end turn", "done", "pass". "initiative" shows the order; "help end-turn" lists what each verb costs.';
36
+ async execute(_args = []) {
37
+ const actor = this.actor;
38
+ const enc = this.scene.encounterFor?.(actor);
39
+ if (!enc) {
40
+ return `You're not in a fight -- there's no Action Phase to end.`;
41
+ }
42
+ if (!enc.isPhaseOf(actor)) {
43
+ return notYourPhase(enc, actor);
44
+ }
45
+ const budget = enc.budgetOf(actor);
46
+ const ledger = budget?.ledger() ?? 'nothing';
47
+ this.logger.write(`${actor.name} ends their Action Phase (${ledger}).`);
48
+ if (this.scene.isHumanControlled(actor)) {
49
+ // Logged now rather than returned: the phases that follow announce
50
+ // themselves while this command is still running, and a return
51
+ // value would land under them, out of order.
52
+ this.logger.log(`{cyan-fg}You end your Action Phase${budget && budget.spent.length > 0 ? ` -- ${ledger}` : ' -- nothing spent'}.{/cyan-fg}`, { actor: actor.name });
53
+ }
54
+ await enc.endPhase(actor);
55
+ return this.scene.isHumanControlled(actor) ? '' : `${actor.name} ends their Action Phase.`;
56
+ }
57
+ }
58
+ //# sourceMappingURL=end-turn.js.map
@@ -2,12 +2,18 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { Category } from '../types/shared/item-enum.js';
4
4
  import { Player } from '../models/player.js';
5
+ import { billAction } from '../utilities/action-cost.js';
5
6
  export class EquipCommand extends Command {
6
7
  static verb = 'equip';
7
8
  static description = 'Equip an item or everything in your inventory.';
8
9
  async execute(args) {
9
10
  if (!args)
10
11
  return '';
12
+ // Getting gear onto your body mid-fight is a Simple Action's worth
13
+ // of hands (SR5 p.165 Ready Weapon / Pick Up Object).
14
+ const bill = billAction(this.scene, this.actor, 'simple', 'Equip');
15
+ if (bill)
16
+ return bill;
11
17
  const out = this.equip(args);
12
18
  // Worn state is sheet-visible and changes rarely, so the DURABLE
13
19
  // path carries it (maka-cli.com suggestion, 2026-09-01): an equip
@@ -9,6 +9,7 @@ import { heldBy, holding } from '../utilities/grapple.js';
9
9
  import { hint } from '../utilities/hints.js';
10
10
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
11
11
  import { gridIconBlock } from '../utilities/grid-view.js';
12
+ import { requirePhase } from '../utilities/action-cost.js';
12
13
  import { camerasLive } from '../utilities/surveillance.js';
13
14
  import { maybeSinSweep } from '../utilities/sin.js';
14
15
  import { despawnEphemerals } from '../utilities/ephemeral.js';
@@ -166,6 +167,11 @@ export class GoCommand extends Command {
166
167
  if (gripHeld) {
167
168
  return `You're holding ${gripHeld.name} -- walk away and the hold is gone. "release" first if that's the plan.`;
168
169
  }
170
+ // THE COMBAT TURN (utilities/combat-turn.ts): leaving the room is
171
+ // movement, and movement happens on your own Action Phase.
172
+ const phase = requirePhase(this.scene, this.actor);
173
+ if (phase)
174
+ return phase;
169
175
  // No walking out from between the narration beats of a combat exchange
170
176
  // you're part of (see CombatExchange.beginExchange) -- found via a real
171
177
  // session where the player slipped rooms mid-exchange. The pause
@@ -381,6 +387,10 @@ export class GoCommand extends Command {
381
387
  const gapBlock = this.checkHostBoundary(targetRoom);
382
388
  if (gapBlock)
383
389
  return gapBlock;
390
+ // Out of the fight before the room hears you go: a participant's
391
+ // AI chain is suspended (NPC.combatPhaseLocked), and the "exits"
392
+ // stimulus below is what makes a hostile give chase.
393
+ this.scene.encounterIn?.(currentRoom)?.leave(this.actor);
384
394
  this.actor.performAction('exits', `${this.actor.currentLocation.name} to ${targetRoom.name}`);
385
395
  this.actor.cameFrom = { room: this.actor.currentLocation.name, back: GoCommand.opposite(direction) };
386
396
  this.actor.currentLocation = targetRoom; // Update player's location
@@ -418,6 +428,7 @@ export class GoCommand extends Command {
418
428
  const gapBlock = this.checkHostBoundary(nextRoom);
419
429
  if (gapBlock)
420
430
  return gapBlock;
431
+ this.scene.encounterIn?.(currentRoom)?.leave(this.actor);
421
432
  this.actor.cameFrom = { room: this.actor.currentLocation.name, back: GoCommand.opposite(direction) };
422
433
  this.actor.currentLocation = nextRoom;
423
434
  // Land IN the doorway you came through when there is one to
@@ -436,6 +447,9 @@ export class GoCommand extends Command {
436
447
  }
437
448
  this.actor.performAction('enters', `${nextRoom.name}${this.actor.atSpot ? ` (at the ${this.actor.atSpot})` : ''}`);
438
449
  }
450
+ // Walking into a room with a fight on joins it, late (p.160): the
451
+ // party and the hostile roll in; anyone else stays out until they act.
452
+ this.scene.admitToEncounter?.(this.actor);
439
453
  return this.finishMove(crossNote, currentRoom);
440
454
  }
441
455
  /**
@@ -4,6 +4,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
5
  import { physicalLimit, heldBy, holding, releaseGrapple } from '../utilities/grapple.js';
6
6
  import { spotOf, ensureAtSpot } from '../utilities/spots.js';
7
+ import { billAction } from '../utilities/action-cost.js';
7
8
  /**
8
9
  * SUBDUING (SR5e p.195) -- "grapple"/"restrain"/"clinch": seize someone
9
10
  * and hold them, dealing NO damage. The RAW shape, faithfully: a normal
@@ -74,6 +75,12 @@ export class GrappleCommand extends Command {
74
75
  const isPlayer = this.scene.isHumanControlled(actor);
75
76
  const lines = [];
76
77
  const meta = [];
78
+ // A GRAPPLE IS A MELEE ATTACK (p.195 Subduing Combat: "the attacker
79
+ // makes a normal melee attack"), so it is the Complex Action a melee
80
+ // attack is (p.167) -- and the phase's one attack (p.164).
81
+ const bill = billAction(this.scene, actor, 'complex', 'Grapple (Melee Attack)', { attack: true });
82
+ if (bill)
83
+ return bill;
77
84
  // Someone with their hands up doesn't resist a zip-tie: restraining a
78
85
  // surrendered target is automatic and quiet (threshold 4 to wriggle
79
86
  // out -- betraying a surrender is on them).
@@ -137,6 +144,10 @@ export class GrappleCommand extends Command {
137
144
  }
138
145
  }
139
146
  release(held) {
147
+ // Letting go is a Free Action (p.163 Drop Object -- opening the hand).
148
+ const bill = billAction(this.scene, this.actor, 'free', 'Release Hold');
149
+ if (bill)
150
+ return bill;
140
151
  releaseGrapple(held, this.actor);
141
152
  this.actor.performAction('releases', `${held.name} from the hold`);
142
153
  this.scene.updateStatus();
@@ -168,6 +179,10 @@ export class StruggleCommand extends Command {
168
179
  return `Nobody's holding you.`;
169
180
  }
170
181
  const threshold = actor.grappleThreshold ?? 2;
182
+ // THE ESCAPE IS A COMPLEX ACTION (p.195), spent on the attempt.
183
+ const bill = billAction(this.scene, actor, 'complex', 'Break Grapple');
184
+ if (bill)
185
+ return bill;
171
186
  // Two RAW roads out (p.195 Unarmed+Strength; p.135 Escape Artist,
172
187
  // Agility-linked -- the contortionist's slip vs the wrestler's
173
188
  // wrench). Whichever pool is bigger is the one they instinctively use.
@@ -4,6 +4,7 @@ import { formatRoll } from '../utilities/dice.js';
4
4
  import { hint } from '../utilities/hints.js';
5
5
  import { sameSpot, spotOf, spotRefusal } from '../utilities/spots.js';
6
6
  import { FIRST_AID_THRESHOLD, firstAidCap, firstAidRefusal, medkitRating, performFirstAid, } from '../utilities/first-aid.js';
7
+ import { billAction } from '../utilities/action-cost.js';
7
8
  /**
8
9
  * HEAL -- first aid in the field (SR5 p.205-206). The mechanic is in
9
10
  * utilities/first-aid.ts; this file is the verb and its words.
@@ -84,6 +85,11 @@ export class HealCommand extends Command {
84
85
  return spotRefusal(patient.name, spotOf(patient));
85
86
  }
86
87
  const cap = firstAidCap(actor, kit);
88
+ // FIRST AID IS A COMPLEX ACTION (p.206) -- the action budget the
89
+ // class doc once said this engine lacked now exists.
90
+ const bill = billAction(this.scene, actor, 'complex', 'First Aid');
91
+ if (bill)
92
+ return bill;
87
93
  const result = performFirstAid(actor, patient, kit, track);
88
94
  const summary = track === 'physical' ? patient.conditionSummary() : patient.stunSummary();
89
95
  const trained = actor.skillRating('first-aid') > 0;
@@ -0,0 +1,20 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ /**
4
+ * THE INITIATIVE TRACKER (SR5 p.159-160): who acts when, this Combat
5
+ * Turn and pass, with scores, and what the live phase has spent. A
6
+ * reference panel, so ui.ts renders it into the Mechanics column
7
+ * (MENU_VERBS) rather than the world log.
8
+ */
9
+ export class InitiativeCommand extends Command {
10
+ static verb = 'initiative';
11
+ static description = 'The initiative tracker: Combat Turn, Initiative Pass, every combatant\'s score and whose Action Phase is live (SR5 p.159). Also "turn", "tracker".';
12
+ async execute(_args = []) {
13
+ const enc = this.scene.encounterIn?.(this.actor.currentLocation);
14
+ if (!enc) {
15
+ return `No fight running here.${hint(` (Attacking someone opens a Combat Turn: initiative for everyone, then Action Phases in order -- "end turn" commits yours.)`)}`;
16
+ }
17
+ return enc.trackerLines().join('\n');
18
+ }
19
+ }
20
+ //# sourceMappingURL=initiative.js.map
@@ -2,11 +2,10 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { GoCommand } from './go.js';
4
4
  import { NPC } from '../models/npc.js';
5
- import { Player } from '../models/player.js';
6
5
  import { Direction } from '../types/shared/direction-enum.js';
7
6
  import { Door } from '../models/door.js';
8
7
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
9
- import { heldBy, holding, physicalLimit } from '../utilities/grapple.js';
8
+ import { heldBy, holding } from '../utilities/grapple.js';
10
9
  import { spotsActive, resolveSpot, spotOf, describeSpotRoster, OPEN_FLOOR, pathBetweenSpots, spotDistanceMeters, spotClimbSteps, blockingRegion, terrainRefusal, reseatOpenFloor, routeForMove, stepFrom, spotForCell, actorCell, standingRoomNear, yieldSquareToMaster, furnitureCells, approachCellFor, exitSpot, concealedFrom, revealActor, onTheFloor } from '../utilities/spots.js';
11
10
  // fallDamageDVMeters, not fallDamageDV: a climb that fell short drops
12
11
  // you from where you GOT to, which is a distance in metres, not a whole
@@ -14,6 +13,9 @@ import { spotsActive, resolveSpot, spotOf, describeSpotRoster, OPEN_FLOOR, pathB
14
13
  // do fall a level.
15
14
  import { fallDamageDVMeters, climbHitsFor, climbMetersFrom, climbSurfaceModifier, METERS_PER_CELL, METERS_PER_LEVEL, key, distanceMeters } from '../utilities/room-grid.js';
16
15
  import { rollPool, formatRoll } from '../utilities/dice.js';
16
+ import { trailMaster } from '../utilities/companion-heel.js';
17
+ import { spendMovementMeters, paceNote } from '../utilities/movement-cost.js';
18
+ import { requirePhase } from '../utilities/action-cost.js';
17
19
  /**
18
20
  * "walk to <spot>" / "run to <spot>" / "sprint to <spot>" -- crossing
19
21
  * the CURRENT room to one of its "at" spots (see utilities/spots.ts;
@@ -101,9 +103,22 @@ export class MoveCommand extends Command {
101
103
  }
102
104
  return `Spots are a meat-world concern -- on the grid there is no distance at all, so "go <room>" puts your persona anywhere by name.`;
103
105
  }
106
+ /** Your shells trail you across the room (companion-heel.ts
107
+ * trailMaster): after every in-room move they re-place themselves
108
+ * beside you. An NPC mover has no Game in its context and drags
109
+ * nobody. */
110
+ trailCompanions() {
111
+ if (!this.game)
112
+ return;
113
+ trailMaster(this.actor, this.game.companions);
114
+ }
104
115
  async execute(args = []) {
105
116
  const actor = this.actor;
106
117
  const room = actor.currentLocation;
118
+ // THE COMBAT TURN: you move on your own Action Phase (p.158-161).
119
+ const phase = requirePhase(this.scene, actor);
120
+ if (phase)
121
+ return phase;
107
122
  // (see offPlaneMoveRefusal below for why the off-plane refusals are
108
123
  // no longer one sentence shared by every plane)
109
124
  // "move to the bar" reads naturally -- strip the connective and
@@ -623,6 +638,14 @@ export class MoveCommand extends Command {
623
638
  const crossing = mine !== undefined && room.ensureGrid()
624
639
  ? spotDistanceMeters(room, mine, spot.name)
625
640
  : undefined;
641
+ // GROUND COSTS IN A FIGHT (p.161-162, utilities/movement-cost.ts).
642
+ const wasRunning = actor.isRunningThisTurn;
643
+ if (crossing !== undefined && crossing > 0) {
644
+ const spend = this.spendMeters(crossing);
645
+ if (spend)
646
+ return spend;
647
+ }
648
+ const pace = paceNote(this.scene, actor, wasRunning);
626
649
  actor.atSpot = spot.name;
627
650
  // Crossing to a named place gives up any stepped-to square: you're
628
651
  // AT the bar now, and seating puts you beside it (spots.ts).
@@ -633,6 +656,7 @@ export class MoveCommand extends Command {
633
656
  // an NPC's own spot is a social event for them (the bartender greets
634
657
  // you when you reach the bar).
635
658
  actor.performAction('moves to', `the ${spot.name}`);
659
+ this.trailCompanions();
636
660
  this.logger.write(`MoveCommand: ${actor.name} ${this.mode()}s to ${spot.name} in ${room.name}.`);
637
661
  // Companions drift along with their master (go.ts follow precedent).
638
662
  if (actor === this.scene.getPlayer() && this.game) {
@@ -664,16 +688,16 @@ export class MoveCommand extends Command {
664
688
  // "cross to" a place that isn't one.
665
689
  if (spot.name === OPEN_FLOOR) {
666
690
  if (toWhom) {
667
- return `You step out onto the open floor where ${toWhom} stands${crossNote}.${here.length > 0 ? ` Also out here: ${here.join(', ')}.` : ''}`;
691
+ return `You step out onto the open floor where ${toWhom} stands${crossNote}.${here.length > 0 ? ` Also out here: ${here.join(', ')}.` : ''}${pace}`;
668
692
  }
669
- return `You peel off to the open floor${crossNote} -- nobody's company but your own.${here.length > 0 ? ` Out here with you: ${here.join(', ')}.` : ''}`;
693
+ return `You peel off to the open floor${crossNote} -- nobody's company but your own.${here.length > 0 ? ` Out here with you: ${here.join(', ')}.` : ''}${pace}`;
670
694
  }
671
- const pace = this.mode() === 'walk' ? 'cross to' : this.mode() === 'run' ? 'hustle over to' : 'sprint to';
695
+ const paceVerb = this.mode() === 'walk' ? 'cross to' : this.mode() === 'run' ? 'hustle over to' : 'sprint to';
672
696
  const spotDesc = spot.description ? ` ${spot.description}` : '';
673
697
  const company = toWhom
674
698
  ? `, coming up beside ${toWhom}.`
675
699
  : '.';
676
- return `${descentNote ? `${descentNote} ` : ''}You ${pace} the ${spot.name}${crossNote}${company}${spotDesc}${here.length > 0 ? ` Here: ${here.join(', ')}.` : ''}`;
700
+ return `${descentNote ? `${descentNote} ` : ''}You ${paceVerb} the ${spot.name}${crossNote}${company}${spotDesc}${here.length > 0 ? ` Here: ${here.join(', ')}.` : ''}${pace}`;
677
701
  }
678
702
  /**
679
703
  * "move south 2" -- a tactical step across the room's own grid, as
@@ -756,12 +780,19 @@ export class MoveCommand extends Command {
756
780
  : `there's a wall`;
757
781
  return `You can't step ${direction} -- ${why}.`;
758
782
  }
783
+ // Paces are metres from the turn's budget (p.161-162).
784
+ const wasRunning = actor.isRunningThisTurn;
785
+ const stepSpend = this.spendMeters(taken * METERS_PER_CELL);
786
+ if (stepSpend)
787
+ return stepSpend;
788
+ const stepPace = paceNote(this.scene, actor, wasRunning);
759
789
  const wasAt = actor.atSpot;
760
790
  actor.atCell = cell;
761
791
  actor.atSpot = spotForCell(room, cell);
762
792
  // A shell of yours on that square steps aside (spots.ts heelsTo).
763
793
  yieldSquareToMaster(room, actor);
764
794
  actor.performAction('steps', `${taken} ${taken === 1 ? 'pace' : 'paces'} ${direction}`);
795
+ this.trailCompanions();
765
796
  this.scene.updateStatus();
766
797
  this.scene.updateExits(room);
767
798
  // QUIET STEPS (user, 2026-09-01: "You step 1 pace east" is spammy now
@@ -780,69 +811,27 @@ export class MoveCommand extends Command {
780
811
  const arrived = actor.atSpot !== wasAt && actor.atSpot !== OPEN_FLOOR
781
812
  ? `You're at the ${actor.atSpot} now.`
782
813
  : '';
783
- return [shortNote, arrived].filter(Boolean).join(' ');
784
- }
785
- /**
786
- * IS THIS ACTOR IN A COMBAT TURN AT ALL?
787
- *
788
- * `inExchange` is only true while an exchange is being resolved, so it
789
- * cannot answer "am I in a fight" between commands. `lastExchangeAt`
790
- * can, and it is the predicate resolvePartingShot already uses for
791
- * "engaged" -- reusing it keeps one definition of being in a fight
792
- * rather than minting a second that can drift from it.
793
- */
794
- static FIGHT_WINDOW_MS = 60_000;
795
- inCombatTurn(actor) {
796
- return !!actor.combatOpponent && Date.now() - actor.lastExchangeAt < MoveCommand.FIGHT_WINDOW_MS;
814
+ return [shortNote, arrived, stepPace.trim()].filter(Boolean).join(' ');
797
815
  }
798
816
  /**
799
817
  * Bills a crossing against the Combat Turn's movement rate, or returns
800
- * the refusal when there is not enough left (SR5 p.161-162).
818
+ * the refusal when there is not enough left (SR5 p.161-162). The
819
+ * rules -- walk free, running spends the Free Action, sprint is a
820
+ * Complex Action with a test -- live in utilities/movement-cost.ts,
821
+ * shared with the melee reach in attack.ts and the NPC brain.
801
822
  *
802
- * Returns undefined -- meaning "go ahead" -- whenever this is not a
803
- * combat turn, which is every ordinary walk across a bar.
823
+ * Returns undefined -- meaning "go ahead" -- whenever there is no
824
+ * Combat Turn running, which is every ordinary walk across a bar.
804
825
  */
805
826
  spendMovement(actor, to) {
806
- if (!this.inCombatTurn(actor))
807
- return undefined;
808
827
  const room = actor.currentLocation;
809
828
  const from = actorCell(room, actor);
810
829
  if (!from)
811
830
  return undefined;
812
- const cost = distanceMeters(from, to);
813
- if (cost <= 0)
814
- return undefined;
815
- // THE SPRINT ACTION (p.162), and this is where the engine's existing
816
- // "sprint to <x>" verb finally means something mechanical. Canon
817
- // makes it a Complex Action -- Running + Strength [Physical], each
818
- // hit buying more ground -- so it is rolled when the player asks for
819
- // it, not applied automatically to cover a shortfall.
820
- //
821
- // ATHLETICS, NOT RUNNING: this engine's skill list has no Running
822
- // (nor Gymnastics); it collapses both into `athletics`, which
823
- // resolvePartingShot already rolls for exactly this -- breaking away
824
- // at a dead run. Mapping canon's skill onto the one this game has is
825
- // better than inventing a skill nobody can raise.
826
- if (this.mode() === 'sprint' && actor.sprintMetersThisTurn === 0) {
827
- // RUNNING: sprinting is Running (p.133), and this pool already
828
- // pairs it with Strength, which is the attribute canon links.
829
- const skill = actor.skillRating('running');
830
- const roll = rollPool(Math.max(1, skill + actor.strength + actor.bonus('athletics') + actor.woundModifier), physicalLimit(actor));
831
- const bought = roll.hits * Player.SPRINT_METERS_PER_HIT;
832
- actor.sprintMetersThisTurn = bought;
833
- this.logger.meta(`Sprint (Running + Strength [Physical]): ${formatRoll(roll)} -- +${bought} m this turn`);
834
- }
835
- if (cost > actor.movementLeftMeters) {
836
- const left = actor.movementLeftMeters;
837
- // Name the numbers. A refusal that says only "you can't" in a
838
- // system the player cannot see is the kind of wall that gets
839
- // reported as a bug.
840
- return left <= 0
841
- ? `You've already covered your ground this turn (${actor.runRateMeters} m at a dead run). Nothing left in the legs until the next exchange.`
842
- : `That's ${cost.toFixed(1)} m and you have ${left.toFixed(1)} m left this turn (Run Rate ${actor.runRateMeters} m).${hint(` ("sprint" buys more ground -- Athletics + Strength, ${Player.SPRINT_METERS_PER_HIT} m a hit.)`)}`;
843
- }
844
- actor.movedMetersThisTurn += cost;
845
- return undefined;
831
+ return this.spendMeters(distanceMeters(from, to));
832
+ }
833
+ spendMeters(meters) {
834
+ return spendMovementMeters(this.scene, this.actor, meters, this.mode(), this.logger);
846
835
  }
847
836
  /** Closes the last few paces to someone already at your own spot --
848
837
  * you end up beside them rather than merely in the same zone. */
@@ -867,6 +856,7 @@ export class MoveCommand extends Command {
867
856
  actor.atCell = cell;
868
857
  actor.atSpot = spotForCell(room, cell);
869
858
  actor.performAction('steps up beside', target.name);
859
+ this.trailCompanions();
870
860
  this.scene.updateStatus();
871
861
  this.scene.updateExits(room);
872
862
  const pace = actor.isRunningThisTurn ? ` You're running now -- everything else this turn is at -2.` : '';
@@ -1013,9 +1003,13 @@ export class MoveCommand extends Command {
1013
1003
  const cell = standingRoomNear(room, target, actor);
1014
1004
  if (!cell)
1015
1005
  return `There's no room to stand at the ${direction} way out.`;
1006
+ const doorSpend = this.spendMovement(actor, cell);
1007
+ if (doorSpend)
1008
+ return doorSpend;
1016
1009
  actor.atCell = cell;
1017
1010
  actor.atSpot = spotForCell(room, cell);
1018
1011
  actor.performAction('moves to', `the ${direction} doorway`);
1012
+ this.trailCompanions();
1019
1013
  this.scene.updateStatus();
1020
1014
  this.scene.updateExits(room);
1021
1015
  return `You cross to the ${direction} way out.${hint(` ("go ${direction}" to step through.)`)}`;
@@ -1041,6 +1035,9 @@ export class MoveCommand extends Command {
1041
1035
  const cell = standingRoomNear(room, body.cell, actor);
1042
1036
  if (!cell)
1043
1037
  return `There's no room to stand over ${body.name}.`;
1038
+ const bodySpend = this.spendMovement(actor, cell);
1039
+ if (bodySpend)
1040
+ return bodySpend;
1044
1041
  actor.atCell = cell;
1045
1042
  actor.atSpot = spotForCell(room, cell);
1046
1043
  actor.performAction('stands over', `${body.name}'s body`);
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { rollPool, formatRoll } from '../utilities/dice.js';
4
4
  import { spotOf } from '../utilities/spots.js';
5
+ import { billAction, phaseHint } from '../utilities/action-cost.js';
5
6
  /**
6
7
  * SIT, LIE DOWN, STAND UP (player request 2026-08-27: sit and lie down
7
8
  * as "moving" commands).
@@ -23,9 +24,11 @@ import { spotOf } from '../utilities/spots.js';
23
24
  * be inventing rules, which is a worse failure than a verb that is
24
25
  * merely flavour.
25
26
  *
26
- * The surprise clause on p.164 is not modelled -- this engine has no
27
- * surprise state, the same gap noted in cover.ts. Left out rather than
28
- * faked.
27
+ * The surprise clause on p.164 is modelled since the Combat Turn landed
28
+ * (utilities/combat-turn.ts): a character who failed the Surprise Test
29
+ * cannot drop prone until their next Action Phase. Both postures bill
30
+ * their action through action-cost.ts -- prone the phase's Free, standing
31
+ * a Simple.
29
32
  */
30
33
  export class PostureCommand extends Command {
31
34
  static verb = 'sit';
@@ -59,14 +62,22 @@ export class PostureCommand extends Command {
59
62
  if (want === 'standing')
60
63
  return this.standUp(here);
61
64
  if (want === 'prone') {
62
- // FREE ACTION (p.164) -- costs nothing, which is exactly why it
63
- // is worth doing the moment rounds start coming in.
65
+ // FREE ACTION (p.164) -- the phase's one Free Action, which is
66
+ // exactly why it is worth doing the moment rounds start coming
67
+ // in. "As long as he is not surprised": the encounter knows.
68
+ const enc = this.scene.encounterFor?.(actor);
69
+ if (enc?.isSurprised(actor)) {
70
+ return `You're caught flat-footed -- you don't even get to hit the dirt (SR5 p.164, p.192).`;
71
+ }
72
+ const bill = billAction(this.scene, actor, 'free', 'Drop Prone');
73
+ if (bill)
74
+ return bill;
64
75
  actor.posture = 'prone';
65
76
  actor.performAction('drops prone', where ?? actor.currentLocation.name);
66
77
  this.scene.updateStatus?.();
67
78
  return `You go flat${here}.`
68
79
  + `\n {green-fg}PRONE:{/green-fg} +2 harder to hit at range, but -2 to defend up close and -1 swinging from the floor.`
69
- + hint(` ("stand" to get back up -- a Simple Action.)`);
80
+ + hint(` ("stand" to get back up -- a Simple Action.)`) + phaseHint(this.scene, actor);
70
81
  }
71
82
  actor.posture = 'sitting';
72
83
  actor.performAction('sits down', where ?? actor.currentLocation.name);
@@ -76,6 +87,11 @@ export class PostureCommand extends Command {
76
87
  /** STAND UP: a Simple Action, and a real test when you're hurt. */
77
88
  standUp(here) {
78
89
  const actor = this.actor;
90
+ // The SIMPLE ACTION (p.166) is spent on the attempt: a wounded
91
+ // character who fails the test has still used it trying.
92
+ const bill = billAction(this.scene, actor, 'simple', 'Stand Up');
93
+ if (bill)
94
+ return bill;
79
95
  const wounded = actor.woundModifier < 0;
80
96
  if (wounded) {
81
97
  // p.166: "if the character is wounded and attempting to stand, he
@@ -95,7 +111,7 @@ export class PostureCommand extends Command {
95
111
  actor.posture = 'standing';
96
112
  actor.performAction('stands up', here.trim() || actor.currentLocation.name);
97
113
  this.scene.updateStatus?.();
98
- return wounded ? `You drag yourself upright${here}.` : `You get to your feet${here}.`;
114
+ return (wounded ? `You drag yourself upright${here}.` : `You get to your feet${here}.`) + phaseHint(this.scene, actor);
99
115
  }
100
116
  }
101
117
  /** "lie"/"lie down"/"prone" -- same command, different way in. */
@@ -1,17 +1,25 @@
1
1
  import { Command } from './command.js';
2
2
  import { Category } from '../types/shared/item-enum.js';
3
3
  import { Item } from '../models/item.js';
4
+ import { billAction, encounterOf, phaseHint } from '../utilities/action-cost.js';
4
5
  /**
5
6
  * Refills the equipped firearm's magazine by consuming one
6
- * Ammunition-category item from the pack. Firearms hold
7
- * Item.MAGAZINE_CAPACITY rounds and every shot (retaliation included)
8
- * spends one -- see combat-exchange.ts. Reloading mid-fight is allowed
9
- * (the between-exchanges pause is exactly the window for it), just not
10
- * mid-exchange.
7
+ * Ammunition-category item from the pack. Every shot spends a round --
8
+ * see combat-exchange.ts.
9
+ *
10
+ * WHAT A RELOAD COSTS IN A COMBAT TURN (SR5 p.163 Reloading Weapons,
11
+ * p.165-167, RAG-checked 2026-09-06): a clip is Remove Clip (Simple) +
12
+ * Insert Clip (Simple) -- both of a phase's Simple Actions; every other
13
+ * feed (cylinder, break action, belt, drum, internal magazine, muzzle
14
+ * loader, speed loader) is a Complex Action. Either way it is the whole
15
+ * phase, so it is billed as one. The one discount canon gives: a
16
+ * smartgun linked to a smartlink ejects its clip as a Free Action
17
+ * (p.164 Eject Smartgun Clip), leaving only the Simple to seat the new
18
+ * one.
11
19
  */
12
20
  export class ReloadCommand extends Command {
13
21
  static verb = 'reload';
14
- static description = 'Reload your equipped firearm -- consumes one Ammunition item from your pack.';
22
+ static description = 'Reload your equipped firearm -- consumes one Ammunition item from your pack. In a fight it takes the whole Action Phase (Remove Clip + Insert Clip, SR5 p.163), or a Free + a Simple with a smartgun you\'re linked to (p.164).';
15
23
  async execute(_args = []) {
16
24
  const actor = this.actor;
17
25
  if (this.scene.isHumanControlled(actor) && this.scene.isPlayerExchangeActive(actor.name)) {
@@ -21,15 +29,28 @@ export class ReloadCommand extends Command {
21
29
  if (!weapon || !weapon.isFirearm()) {
22
30
  return `You have no firearm equipped to reload.`;
23
31
  }
24
- if (weapon.ammo >= Item.MAGAZINE_CAPACITY) {
25
- return `The ${weapon.name} is already fully loaded (${weapon.ammo}/${Item.MAGAZINE_CAPACITY}).`;
32
+ if (weapon.ammo >= weapon.magazineCapacity) {
33
+ return `The ${weapon.name} is already fully loaded (${weapon.ammo}/${weapon.magazineCapacity}).`;
26
34
  }
27
35
  // NON-LETHAL STANCE: stun rounds first if you carry any (gel,
28
36
  // stick-n-shock -- p.434); otherwise whatever is in the pack.
29
37
  const boxes = actor.inventory.getAllItems().filter(i => i.category === Category.Ammunition);
30
38
  const ammo = (actor.autoStance === 'non-lethal' ? boxes.find(b => Item.isStunAmmo(b)) : undefined) ?? boxes[0];
31
39
  if (!ammo) {
32
- return `No ammunition in your pack -- the ${weapon.name} stays at ${weapon.ammo}/${Item.MAGAZINE_CAPACITY}. Find a spare clip.`;
40
+ return `No ammunition in your pack -- the ${weapon.name} stays at ${weapon.ammo}/${weapon.magazineCapacity}. Find a spare clip.`;
41
+ }
42
+ // THE ACTION (see the class doc). A reload also needs the gun in
43
+ // hand: "insert a fresh clip into a READY firearm" (p.165).
44
+ if (encounterOf(this.scene, actor)) {
45
+ if (!actor.weaponDrawn) {
46
+ return `The ${weapon.name} is holstered -- "draw" it first (Ready Weapon, a Simple Action, p.165).`;
47
+ }
48
+ const smartEject = weapon.isSmartgun && actor.smartlinkTier() !== undefined;
49
+ const bill = smartEject
50
+ ? (billAction(this.scene, actor, 'free', 'Eject Smartgun Clip') ?? billAction(this.scene, actor, 'simple', 'Insert Clip'))
51
+ : billAction(this.scene, actor, 'complex', weapon.reloadFeed === 'c' ? 'Remove Clip + Insert Clip' : 'Reload Weapon');
52
+ if (bill)
53
+ return bill;
33
54
  }
34
55
  actor.inventory.consumeOne(ammo.name);
35
56
  weapon.reload(ammo.name);
@@ -37,7 +58,7 @@ export class ReloadCommand extends Command {
37
58
  this.actor.performAction('reloads', weapon.name);
38
59
  this.scene.updateInventory(this.scene.getPlayer().inventory);
39
60
  this.scene.updateStatus();
40
- return `You slap the ${ammo.name} home -- ${weapon.name} loaded, ${weapon.ammo}/${Item.MAGAZINE_CAPACITY}.${weapon.stunAmmo() ? ' Stun rounds: it drops people, it does not kill them.' : actor.autoStance === 'non-lethal' ? ' No stun rounds in the pack -- that gun is LETHAL until you find some.' : ''}`;
61
+ return `You slap the ${ammo.name} home -- ${weapon.name} loaded, ${weapon.ammo}/${weapon.magazineCapacity}.${weapon.stunAmmo() ? ' Stun rounds: it drops people, it does not kill them.' : actor.autoStance === 'non-lethal' ? ' No stun rounds in the pack -- that gun is LETHAL until you find some.' : ''}${phaseHint(this.scene, actor)}`;
41
62
  }
42
63
  }
43
64
  //# sourceMappingURL=reload.js.map
@@ -5,6 +5,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
5
5
  import { perceptionTest } from '../utilities/perception.js';
6
6
  import { canReach, exitSpot, spotsActive, revealWithPerception, theSpot, sealedRouteTo, } from '../utilities/spots.js';
7
7
  import { placeName } from '../utilities/log-style.js';
8
+ import { billAction } from '../utilities/action-cost.js';
8
9
  /**
9
10
  * Barrier rendering (2026-08-24 playtest: "I don't see THE THING I'm
10
11
  * supposed to act on -- just a description... and I still see the
@@ -136,6 +137,11 @@ export class SearchCommand extends Command {
136
137
  static description = 'Search for items in your area';
137
138
  async execute(_args) {
138
139
  const room = this.actor.currentLocation;
140
+ // OBSERVE IN DETAIL (SR5 p.165): the Perception Test below is the
141
+ // Simple Action the book names, inside a Combat Turn.
142
+ const bill = billAction(this.scene, this.actor, 'simple', 'Observe in Detail');
143
+ if (bill)
144
+ return bill;
139
145
  // Searching is a PERCEPTION test now (Intuition + perception skill;
140
146
  // unskilled defaults to Intuition - 1): no hits means the room keeps
141
147
  // its secrets this pass -- retry freely, but until a roll lands the
@@ -1,6 +1,7 @@
1
1
  import { Command } from './command.js';
2
2
  import { rollPool, formatRoll } from '../utilities/dice.js';
3
3
  import { hint } from '../utilities/hints.js';
4
+ import { billAction } from '../utilities/action-cost.js';
4
5
  /**
5
6
  * Conjuring (SR5e p.300-303): "summon <force>" calls a spirit -- an
6
7
  * opposed Magic + conjuring test against the spirit's Force dice. NET
@@ -50,6 +51,10 @@ export class SummonCommand extends Command {
50
51
  return `A Force ${force} spirit would wear YOU as the vessel -- twice your Magic (${actor.magic * 2}) is the hard ceiling.`;
51
52
  }
52
53
  const overcast = force > actor.magic;
54
+ // SUMMONING IS A COMPLEX ACTION (SR5 p.167) inside a Combat Turn.
55
+ const bill = billAction(this.scene, actor, 'complex', 'Summoning');
56
+ if (bill)
57
+ return bill;
53
58
  // The opposed test (p.300): Magic + conjuring vs the spirit's Force.
54
59
  const conjuring = actor.skillRating('conjuring');
55
60
  const mine = rollPool(Math.max(1, actor.magic + (conjuring > 0 ? conjuring : -1) + actor.woundModifier - actor.sustainingPenalty));
@@ -1,4 +1,5 @@
1
1
  import { Command } from './command.js';
2
+ import { billAction } from '../utilities/action-cost.js';
2
3
  /**
3
4
  * Throwing your hands up, as a real mechanical act instead of just talk.
4
5
  * The wound-aware AI guidance already nudges a losing NPC toward
@@ -27,6 +28,11 @@ export class SurrenderCommand extends Command {
27
28
  if (actor.inExchange) {
28
29
  return `Lead is flying -- wait for the exchange to resolve, then surrender in the lull.`;
29
30
  }
31
+ // Hands up is a gesture and a phrase -- a Free Action (SR5 p.164
32
+ // Gesture / Speak) in a Combat Turn.
33
+ const bill = billAction(this.scene, actor, 'free', 'Surrender');
34
+ if (bill)
35
+ return bill;
30
36
  actor.surrendered = true;
31
37
  // Break the fight both ways: no parting shot for walking away from a
32
38
  // surrender, and the opponent's own combat memory of you clears too.