@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.
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 +42 -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/stance.js +16 -15
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +5 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +10 -3
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +5 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +8 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +18 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/game.js +35 -18
  30. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +3 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +21 -4
  32. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +44 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +50 -13
  34. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +102 -5
  35. package/bundle/typescript/src/commands/game/sideQuest/ui.js +12 -12
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/action-budget.js +87 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +66 -0
  38. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +101 -5
  39. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +571 -0
  40. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +20 -0
  41. package/bundle/typescript/src/commands/game/sideQuest/utilities/movement-cost.js +80 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +122 -0
  43. package/package.json +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +0 -182
@@ -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,10 +1,12 @@
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
5
- * surrender -- this gives that nudge teeth: the flag it sets is what a
6
- * GUARDED auto-fighter honors by holding fire (see utilities/auto-fight.ts;
7
- * an AGGRESSIVE one doesn't have to). Surrendering also breaks the fight:
6
+ * surrender -- this gives that nudge teeth: the flag it sets is what the
7
+ * Combat Turn reads to decide whether anyone is still hostile
8
+ * (CombatEncounter.stillHostile) and what "restrain" holds without a
9
+ * roll (p.195). Surrendering also breaks the fight:
8
10
  * both sides' combat memory clears, so walking away afterwards invites no
9
11
  * parting shot. The flag is revoked the moment the surrendered actor
10
12
  * initiates violence again (CombatExchange.beginExchange).
@@ -27,6 +29,11 @@ export class SurrenderCommand extends Command {
27
29
  if (actor.inExchange) {
28
30
  return `Lead is flying -- wait for the exchange to resolve, then surrender in the lull.`;
29
31
  }
32
+ // Hands up is a gesture and a phrase -- a Free Action (SR5 p.164
33
+ // Gesture / Speak) in a Combat Turn.
34
+ const bill = billAction(this.scene, actor, 'free', 'Surrender');
35
+ if (bill)
36
+ return bill;
30
37
  actor.surrendered = true;
31
38
  // Break the fight both ways: no parting shot for walking away from a
32
39
  // surrender, and the opponent's own combat memory of you clears too.
@@ -5,6 +5,7 @@ import { fileReach } from '../utilities/planes.js';
5
5
  import { canReach, ensureAtSpot, sealedRouteTo } from '../utilities/spots.js';
6
6
  import { hint } from '../utilities/hints.js';
7
7
  import { Category } from '../types/shared/item-enum.js';
8
+ import { billAction } from '../utilities/action-cost.js';
8
9
  export class TakeCommand extends Command {
9
10
  static verb = 'take';
10
11
  static description = 'Pick up an item. "take all" sweeps everything here -- but only after you have searched the room.';
@@ -25,6 +26,10 @@ export class TakeCommand extends Command {
25
26
  screen: this.screen, game: this.game,
26
27
  }).execute();
27
28
  }
29
+ // PICK UP OBJECT (SR5 p.165): a Simple Action in a Combat Turn.
30
+ const bill = billAction(this.scene, this.actor, 'simple', 'Pick Up Object');
31
+ if (bill)
32
+ return bill;
28
33
  if (wanted === 'all' || wanted === 'everything') {
29
34
  return this.takeAll();
30
35
  }
@@ -1,9 +1,15 @@
1
1
  import { Command } from './command.js';
2
2
  import { dropActiveCall, CALL_ICON, CALL_COLOR } from '../utilities/comm-style.js';
3
+ import { billAction } from '../utilities/action-cost.js';
3
4
  export class UnequipCommand extends Command {
4
5
  static verb = 'unequip';
5
6
  static description = 'Unequip an item.';
6
7
  async execute(args) {
8
+ // Taking gear off is a Simple Action's worth of hands (SR5 p.165
9
+ // Pick Up/Put Down Object) inside a Combat Turn.
10
+ const bill = billAction(this.scene, this.actor, 'simple', 'Unequip');
11
+ if (bill)
12
+ return bill;
7
13
  const slotName = args.join(' ').toLowerCase();
8
14
  // TAKING THE LINK OFF HANGS UP THE CALL (MgrjzDYTzY8sJdvkT: "when I
9
15
  // unequipped my commlink, I went offline, but the call with deditri
@@ -6,6 +6,7 @@ import { ensureAtSpot, ensureAtExit } from '../utilities/spots.js';
6
6
  import { applyDeviceOpened } from '../utilities/devices.js';
7
7
  import { hint } from '../utilities/hints.js';
8
8
  import { FIRST_AID_THRESHOLD, firstAidCap, firstAidRefusal, performFirstAid, } from '../utilities/first-aid.js';
9
+ import { billAction } from '../utilities/action-cost.js';
9
10
  export class UseCommand extends Command {
10
11
  static verb = 'use';
11
12
  static description = 'Use a thing: an item from your pack (a medkit, a stim patch), or a device in the room -- "use keypad 4471" offers a code, "use safe" tries it with what you are carrying. With one device here, a bare code works too.';
@@ -16,6 +17,13 @@ export class UseCommand extends Command {
16
17
  preferredCategory;
17
18
  verbNoun = 'use';
18
19
  async execute(args) {
20
+ // USE SIMPLE DEVICE (SR5 p.166-167): a Simple Action in a Combat
21
+ // Turn -- a medkit's first aid is the Complex Action heal.ts charges,
22
+ // but the thing this verb does with one is "use the kit", which the
23
+ // book prices as a Simple Action of the device.
24
+ const bill = billAction(this.scene, this.actor, 'simple', 'Use Simple Device');
25
+ if (bill)
26
+ return bill;
19
27
  return this.use(args);
20
28
  }
21
29
  /**
@@ -359,5 +359,22 @@
359
359
  // beside you (companion-heel.ts trailMaster) -- they used to heel only on
360
360
  // a room change and sat in the doorway while you paced. "recall" judges
361
361
  // "already at your side" by cells, not spot names.
362
- export const ENGINE_VERSION = '1.39.0';
362
+ // 1.40.0 (2026-09-06): THE COMBAT TURN (SR5 p.158-168). Physical-plane
363
+ // combat is a turn/pass loop per room (utilities/combat-turn.ts):
364
+ // initiative for everyone, Action Phases in order, -10 per pass, fresh
365
+ // rolls per Combat Turn. COMMAND SEMANTICS in shared scenes: "attack"
366
+ // is one strike on your own phase (no retaliation, no auto-draw); new
367
+ // verbs end-turn/done/pass, initiative, aim, defend, delay; draw,
368
+ // holster, cover, stand, reload, take, drop, equip, use, search,
369
+ // grapple, struggle, release, cast, summon, dispel, heal and movement
370
+ // are refused outside your Action Phase and spend its Free/Simple/
371
+ // Complex budget. NPC participants act by rule, not by the LLM chain.
372
+ // 1.41.0 (2026-09-07): THE STANCE IS INTENT, NOT AN AUTOPILOT. The fight
373
+ // autopilot (utilities/auto-fight.ts) is gone: a phase you commit with
374
+ // "end turn" is never played for you, in a hosted run or a local one.
375
+ // "stance" / Tab keep the half the rules read -- lethal or non-lethal,
376
+ // the damage type your next blow means (p.186 flat of the blade, stun
377
+ // rounds on reload). SAVE SHAPE: autoStance is lethal | non-lethal;
378
+ // "manual" loads as lethal.
379
+ export const ENGINE_VERSION = '1.41.0';
363
380
  //# sourceMappingURL=engine-version.js.map
@@ -74,6 +74,11 @@ import { GiveCommand } from './commands/give.js';
74
74
  import { ReadCommand } from './commands/read.js';
75
75
  import { KillCommand } from './commands/kill.js';
76
76
  import { AttackCommand } from './commands/attack.js';
77
+ import { EndTurnCommand } from './commands/end-turn.js';
78
+ import { InitiativeCommand } from './commands/initiative.js';
79
+ import { AimCommand } from './commands/aim.js';
80
+ import { DefendCommand } from './commands/defend.js';
81
+ import { DelayCommand } from './commands/delay.js';
77
82
  import { SpellsCommand } from './commands/spells.js';
78
83
  import { InventoryCommand } from './commands/inv.js';
79
84
  import { EquipmentCommand } from './commands/equipment.js';
@@ -174,7 +179,6 @@ import { TimeCommand } from './commands/time.js';
174
179
  // The HUD's clock -- read in the engine so a shared run reads the
175
180
  // host's wall clock, never each client's own (item B).
176
181
  import { clockTime, worldNow } from './utilities/world-clock.js';
177
- import { AutoFight } from './utilities/auto-fight.js';
178
182
  import { CommandFactory } from './factories/command-factory.js';
179
183
  import { SceneSynthesizer } from './factories/scene-factory.js';
180
184
  import { SceneSeedGenerator, FIXER_NAME, isFixerName } from './factories/scene-seed-generator.js';
@@ -190,9 +194,6 @@ export default class Game {
190
194
  sceneSeed;
191
195
  scene;
192
196
  player;
193
- // The fight autopilot (Tab / "stance" command) -- created once, reads
194
- // live game state at fire time so it survives scene transitions.
195
- autoFight = new AutoFight(this);
196
197
  // Just three surfaces: a full-width log, a HUD strip, and the input box.
197
198
  // The old always-on Inventory/Equipment/Map sidebar boxes became the
198
199
  // on-demand inv/equipment/map commands (see commands/inv.ts et al.) to
@@ -2528,6 +2529,18 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2528
2529
  // wanted to end a call -- the intent is unambiguous mid-call.
2529
2530
  CommandFactory.registerCommand('end', EndCallCommand);
2530
2531
  CommandFactory.registerCommand('hangup', EndCallCommand);
2532
+ // THE COMBAT TURN (utilities/combat-turn.ts). "end turn" arrives
2533
+ // through EndCallCommand ("end" is the hangup) and is handed here.
2534
+ CommandFactory.registerCommand('end-turn', EndTurnCommand);
2535
+ CommandFactory.registerCommand('endturn', EndTurnCommand);
2536
+ CommandFactory.registerCommand('done', EndTurnCommand);
2537
+ CommandFactory.registerCommand('pass', EndTurnCommand);
2538
+ CommandFactory.registerCommand('initiative', InitiativeCommand);
2539
+ CommandFactory.registerCommand('tracker', InitiativeCommand);
2540
+ CommandFactory.registerCommand('turn', InitiativeCommand);
2541
+ CommandFactory.registerCommand('aim', AimCommand);
2542
+ CommandFactory.registerCommand('defend', DefendCommand);
2543
+ CommandFactory.registerCommand('delay', DelayCommand);
2531
2544
  // 'train' used to alias advance; it belongs to crew instruction now
2532
2545
  // (commands/crew.ts) -- self-training is "advance".
2533
2546
  CommandFactory.registerCommand('advance', AdvanceCommand);
@@ -3645,7 +3658,6 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3645
3658
  this.player.inExchange = false;
3646
3659
  this.player.combatOpponent = undefined;
3647
3660
  this.player.activeCallPartner = undefined;
3648
- this.autoFight.cancelPending();
3649
3661
  }
3650
3662
  /** The panel refresh both errand legs need. */
3651
3663
  /**
@@ -3768,7 +3780,6 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3768
3780
  this.player.inExchange = false;
3769
3781
  this.player.combatOpponent = undefined;
3770
3782
  this.player.activeCallPartner = undefined;
3771
- this.autoFight.cancelPending();
3772
3783
  this.scene = hub;
3773
3784
  this.scene.addRoom(this._homeRoom);
3774
3785
  this.connectHomeRoom(hub.determineStartRoom());
@@ -4600,7 +4611,6 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4600
4611
  if (this._presenceEquipTimer)
4601
4612
  clearTimeout(this._presenceEquipTimer);
4602
4613
  this.stopPursuitHeartbeat();
4603
- this.autoFight.cancelPending();
4604
4614
  for (const ko of this.scene?.playerKnockouts.values() ?? []) {
4605
4615
  if (ko.timer)
4606
4616
  clearTimeout(ko.timer);
@@ -5220,14 +5230,15 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5220
5230
  }
5221
5231
  statusLinesFor(player) {
5222
5232
  const lines = [];
5223
- // Line 1: the fight autopilot mode. The stance lives HERE -- the
5233
+ // Line 1: the stance -- lethal or non-lethal in the meat (the damage
5234
+ // type your next blow means; models/player.ts FightStance), Brute
5235
+ // Force or Hack on the Fly on the Matrix. It lives HERE -- the
5224
5236
  // shortest HUD line -- because appended to the long readiness line it
5225
5237
  // wrapped out of the box on narrower terminals and effectively only
5226
- // ever showed in the Tab announcement. Always shown, even MANUAL: the
5227
- // HUD is how players discover Tab cycles it at all (see
5228
- // utilities/auto-fight.ts). MANUAL is white, not gray -- blessed's
5229
- // gray is ANSI "bright black", which on a stock dark terminal palette
5230
- // is illegible enough that the stance looked absent entirely.
5238
+ // ever showed in the Tab announcement. Always shown: the HUD is how
5239
+ // players discover Tab flips it at all. Red for the lethal routes,
5240
+ // yellow for the careful ones -- never gray, which is ANSI "bright
5241
+ // black" and illegible on a stock dark palette.
5231
5242
  //
5232
5243
  // WHERE YOU ARE MOVED OUT (player ruling 2026-08-25): the room name
5233
5244
  // now captions the Map box and the "@ spot" captions the Room box,
@@ -5235,9 +5246,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5235
5246
  // what two panels already show. The breadcrumb went with it -- the
5236
5247
  // district map draws the way you came far better than a text trail.
5237
5248
  // THE STANCE THE HUD SHOWS IS THE ONE IN FORCE (uMQAhaAysgaKpWFkn,
5238
- // 2026-09-03). Jacked in, the fight autopilot is not what Tab
5239
- // moves and not what your next action obeys -- the Matrix stance
5240
- // is (Brute Force or Hack on the Fly). Showing the meat stance to
5249
+ // 2026-09-03). Jacked in, the fight stance is not what Tab moves
5250
+ // and not what your next action obeys -- the Matrix stance is
5251
+ // (Brute Force or Hack on the Fly). Showing the meat stance to
5241
5252
  // a persona would be the same class of bug as the 2026-08-27
5242
5253
  // report that the HUD did not follow Tab at all: a readout naming
5243
5254
  // a setting the player cannot currently change.
@@ -5245,7 +5256,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5245
5256
  const stanceWord = onMatrix ? MATRIX_STANCE_ACTION[player.matrixStance] : player.autoStance;
5246
5257
  const stanceColor = onMatrix
5247
5258
  ? (player.matrixStance === 'attack' ? 'red' : 'yellow')
5248
- : player.autoStance === 'lethal' ? 'red' : player.autoStance === 'non-lethal' ? 'yellow' : 'white';
5259
+ : player.autoStance === 'lethal' ? 'red' : 'yellow';
5249
5260
  // WHAT'S IN YOUR HANDS rides alongside the stance (player ruling
5250
5261
  // 2026-08-25). The two answer the same question -- how ready are you
5251
5262
  // -- and the stance line was left short and lonely once the location
@@ -5305,6 +5316,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5305
5316
  // throws, least of all on the panel a dying player is reading.
5306
5317
  const docwagon = this.docwagonSlot(this.docwagonByPlayer?.get(player.name) ?? (player === this.player ? this.docwagon : undefined));
5307
5318
  lines.push(`${armorNote}${arSlot} | ${link} | ${docwagon}`);
5319
+ // THE COMBAT TURN (utilities/combat-turn.ts): whose phase it is, the
5320
+ // score, and what this player has left to spend -- present only
5321
+ // while a fight is on, like every transient line below.
5322
+ const combatLine = this.scene?.encounterFor?.(player)?.hudLine(player);
5323
+ if (combatLine)
5324
+ lines.push(combatLine);
5308
5325
  // Lines 4+: transient states, present only while active. Future
5309
5326
  // contexts (astral projection, VR hot-sim) join here the same way.
5310
5327
  // THE COST IS THE ENGINE'S. Every one of these lines printed a
@@ -5988,7 +6005,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5988
6005
  static HELP_CATEGORIES = [
5989
6006
  { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach'], ['run'], ['sprint'], ['climb', 'mantle', 'scale', 'clamber'], ['descend'], ['sit', 'kneel'], ['lie', 'prone'], ['stand'], ['follow'], ['unfollow'], ['map', 'exits'], ['search'], ['sneak']] },
5990
6007
  { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['loot'], ['drop'], ['give'], ['store'], ['open'], ['close'], ['put'], ['equip'], ['unequip'], ['fit'], ['unfit'], ['brandish', 'draw'], ['holster'], ['reload'], ['use'], ['read']] },
5991
- { title: 'combat', entries: [['attack'], ['kill'], ['subdue'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['cover'], ['stance'], ['surrender'], ['edge'], ['rest'], ['heal', 'firstaid', 'bandage', 'patch']] },
6008
+ { title: 'combat', entries: [['attack'], ['kill'], ['subdue'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['cover'], ['aim'], ['defend'], ['delay'], ['end-turn', 'endturn', 'done', 'pass'], ['initiative', 'tracker', 'turn'], ['stance'], ['surrender'], ['edge'], ['rest'], ['heal', 'firstaid', 'bandage', 'patch']] },
5992
6009
  // The party layer: everyone who walks (or flies, or manifests) at
5993
6010
  // your side answers to these.
5994
6011
  // "players" folded back under crew after playtesting ("crew
@@ -82,7 +82,7 @@ export function savedPlayer(name, overrides = {}) {
82
82
  },
83
83
  edgeRemaining: 3,
84
84
  haggledThisJob: false,
85
- autoStance: 'manual',
85
+ autoStance: 'lethal',
86
86
  visitedRooms: [],
87
87
  inventory: [],
88
88
  equipment: {},
@@ -244,6 +244,8 @@ export function paritySeed(overrides = {}) {
244
244
  export async function bootSession(opts = {}) {
245
245
  process.env.MAKA_NO_DDP = '1';
246
246
  process.env.MAKA_NO_LORE = '1';
247
+ // Combat narration beats are pacing for a person; a harness has none.
248
+ process.env.MAKA_NO_BEATS = '1';
247
249
  process.env.MAKA_NO_TELEMETRY = '1';
248
250
  const events = opts.events;
249
251
  const session = await createHeadlessSession({
@@ -95,12 +95,29 @@ export class Item extends AbstractItem {
95
95
  break;
96
96
  }
97
97
  }
98
- // Firearms track ammunition: a flat magazine, starting full. Melee
99
- // weapons never touch it. See fireRound()/reload() and combat-exchange.
98
+ // Firearms track ammunition: the row's magazine when it has one,
99
+ // the flat default otherwise, starting full. Melee weapons never
100
+ // touch it. See fireRound()/reload() and combat-exchange.
100
101
  if (this.isFirearm()) {
101
- this.ammo = Item.MAGAZINE_CAPACITY;
102
+ this.ammo = this.magazineCapacity;
102
103
  }
103
104
  }
105
+ /**
106
+ * ROUNDS IN A FULL MAGAZINE: the catalog row's "ammo" (p.421-431 --
107
+ * 15(c) for a Predator, 6(cy) for a revolver) when the row carries
108
+ * one, else the engine's flat default for generated gear.
109
+ */
110
+ get magazineCapacity() {
111
+ const rounds = this.row?.weapon?.ammo?.rounds;
112
+ return rounds && rounds > 0 ? rounds : Item.MAGAZINE_CAPACITY;
113
+ }
114
+ /** How it reloads (p.163 Reloading Weapons): 'c' clip, 'cy' cylinder,
115
+ * 'm' internal magazine, 'b' break action, 'd' drum, 'belt', 'ml'
116
+ * muzzle loader. A clip is two Simple Actions; everything else a
117
+ * Complex. Unknown gear reloads like a clip. */
118
+ get reloadFeed() {
119
+ return this.row?.weapon?.ammo?.feed ?? 'c';
120
+ }
104
121
  // ======================== Stacking ========================
105
122
  /**
106
123
  * WHAT FUNGIBLE MEANS HERE (user ruling): only CONSUMABLES stack.
@@ -200,7 +217,7 @@ export class Item extends AbstractItem {
200
217
  loadedAmmo;
201
218
  reload(ammoName) {
202
219
  if (this.isFirearm()) {
203
- this.ammo = Item.MAGAZINE_CAPACITY;
220
+ this.ammo = this.magazineCapacity;
204
221
  if (ammoName !== undefined)
205
222
  this.loadedAmmo = ammoName;
206
223
  }
@@ -909,6 +909,38 @@ description text.`;
909
909
  this.logger.write(`AI fallback for appearance (${this.name}): ${err}`);
910
910
  }
911
911
  }
912
+ /**
913
+ * IN A FIGHT, THE TURN LOOP OWNS THIS SHELL (utilities/combat-turn.ts).
914
+ * While this NPC is a participant in a running encounter, its LLM
915
+ * chain -- act, respondTo, recursiveReflect -- is suspended: the
916
+ * deterministic brain (utilities/npc-combat-brain.ts) plays its Action
917
+ * Phases in initiative order, and a model answering on its own clock
918
+ * would only be refused by the phase gate and grind to a halt on the
919
+ * refusals. Perception (see/hear) still records history, so the shell
920
+ * knows what happened when the chain resumes after the fight.
921
+ */
922
+ combatPhaseLocked() {
923
+ return !!this._scene?.encounterFor?.(this);
924
+ }
925
+ /**
926
+ * One verb, executed now, no reflection -- the brain's way of acting
927
+ * inside an Action Phase. Returns the command's own result so the
928
+ * brain can read a refusal, and files it into history like any act.
929
+ */
930
+ async actInCombat(commandString) {
931
+ if (this.deathClaimed)
932
+ return '';
933
+ const parsed = await this.commandRegistry.parse(commandString, this);
934
+ if (!parsed) {
935
+ this.logger.error(`${this.name} (combat) tried to perform unknown command: ${commandString}`);
936
+ return '';
937
+ }
938
+ const result = await parsed.command.execute(parsed.args);
939
+ if (result)
940
+ this._addToHistory(result);
941
+ this.relayToMaster(commandString, result);
942
+ return result ?? '';
943
+ }
912
944
  async act(commandString) {
913
945
  // A removed actor must STOP: dismissal/death happens while an AI
914
946
  // burst is still in flight, and without this latch a real session's
@@ -918,6 +950,10 @@ description text.`;
918
950
  this.logger.write(`${this.name} is gone from the world -- dropping queued action "${commandString}".`);
919
951
  return;
920
952
  }
953
+ if (this.combatPhaseLocked()) {
954
+ this.logger.write(`${this.name} is in a Combat Turn -- the turn loop acts for them; dropping "${commandString}".`);
955
+ return;
956
+ }
921
957
  const parsed = await this.commandRegistry.parse(commandString, this);
922
958
  if (parsed) {
923
959
  // Capture the result of the command
@@ -1361,6 +1397,10 @@ description text.`;
1361
1397
  this.logger.write(`${this.name} is gone from the world -- reflect chain ends.`);
1362
1398
  return;
1363
1399
  }
1400
+ if (this.combatPhaseLocked()) {
1401
+ this.logger.write(`${this.name} is in a Combat Turn -- reflect chain yields to the turn loop.`);
1402
+ return;
1403
+ }
1364
1404
  // THE GRIND BREAKER. Record what just happened, then refuse to keep
1365
1405
  // going round on it. Deterministic on purpose: the prompt already
1366
1406
  // asks for honesty about failure and got it -- what it lacked was
@@ -1538,6 +1578,10 @@ ${worldEventsSummary}
1538
1578
  : [];
1539
1579
  }
1540
1580
  async respondTo(actor, message) {
1581
+ if (this.combatPhaseLocked()) {
1582
+ this.logger.write(`${this.name} is in a Combat Turn -- no AI reaction to "${message.slice(0, 40)}".`);
1583
+ return;
1584
+ }
1541
1585
  this.logger.write('Attempting to respond');
1542
1586
  // A fresh stimulus earns at most one fresh speech (see the field doc)
1543
1587
  // -- and a clean slate of attempts. Something new has happened, so
@@ -15,9 +15,17 @@ import { physicalLimit } from '../utilities/grapple.js';
15
15
  import { astralLimit } from '../utilities/limits.js';
16
16
  import { Inventory } from './inventory.js';
17
17
  import { AbstractPlayer } from '../types/shared/abstracts.js';
18
- export const STANCE_CYCLE = ['manual', 'non-lethal', 'lethal'];
18
+ export const STANCE_CYCLE = ['lethal', 'non-lethal'];
19
19
  /** The old names, still accepted from saves and the stance command. */
20
- export const STANCE_ALIASES = { guarded: 'non-lethal', aggressive: 'lethal', nonlethal: 'non-lethal', 'non lethal': 'non-lethal' };
20
+ export const STANCE_ALIASES = {
21
+ guarded: 'non-lethal', aggressive: 'lethal', manual: 'lethal',
22
+ nonlethal: 'non-lethal', 'non lethal': 'non-lethal', stun: 'non-lethal', kill: 'lethal',
23
+ };
24
+ /** What each stance means, in one line -- the HUD's and the verb's. */
25
+ export const STANCE_DESCRIPTION = {
26
+ lethal: 'your weapons deal the damage they were sold to deal; "attack" and "kill" mean it',
27
+ 'non-lethal': 'Stun wherever the gear allows -- stun rounds when you reload, the flat of the blade (p.186), bare hands -- and a lethal gun with lethal rounds says so before it fires',
28
+ };
21
29
  export const MATRIX_STANCE_CYCLE = ['attack', 'sleaze'];
22
30
  /** What each stance is CALLED in the book -- printed everywhere the
23
31
  * engine used to say "loud"/"quiet", so the vocabulary teaches the
@@ -36,8 +44,8 @@ export function normalizeMatrixStance(v) {
36
44
  return MATRIX_STANCE_ALIASES[k] ?? (MATRIX_STANCE_CYCLE.includes(k) ? k : 'attack');
37
45
  }
38
46
  export function normalizeStance(v) {
39
- const k = (v ?? 'manual').toLowerCase();
40
- return STANCE_ALIASES[k] ?? (STANCE_CYCLE.includes(k) ? k : 'manual');
47
+ const k = (v ?? 'lethal').toLowerCase();
48
+ return STANCE_ALIASES[k] ?? (STANCE_CYCLE.includes(k) ? k : 'lethal');
41
49
  }
42
50
  /** Meat and a jumped-in drone share the physical world. */
43
51
  export function isPhysicalPlane(plane) {
@@ -185,16 +193,12 @@ export class Player extends AbstractPlayer {
185
193
  // session where an NPC's overlapping AI loops fired two exchanges at
186
194
  // once and the target died TWICE (two kill events, two narrations).
187
195
  inExchange = false;
188
- // Fight-or-flight autopilot (see utilities/auto-fight.ts): the FIGHT
189
- // half runs automatically at the set temperature, the flight stays
190
- // manual -- always. Tab cycles it in the UI; "stance" sets it by name.
191
- // manual -- today's behavior, you type every swing
192
- // guarded -- auto-fights, but finishes non-lethally (switches to
193
- // subdue when the target is nearly down) and stands down
194
- // if they surrender
195
- // aggressive -- fights until they're dead; surrender means nothing
196
+ // The fight stance (see FightStance above): lethal or non-lethal, the
197
+ // damage type your next blow is meant to deal. Tab cycles it in the UI;
198
+ // "stance" sets it by name. Every action is still yours to type -- the
199
+ // Combat Turn hands you the phase and waits for "end turn".
196
200
  // Accessor (live-sheet pass, 2026-09-01): stance is sheet-visible.
197
- _autoStance = 'manual';
201
+ _autoStance = 'lethal';
198
202
  get autoStance() { return this._autoStance; }
199
203
  set autoStance(v) {
200
204
  if (this._autoStance === v)
@@ -229,6 +233,39 @@ export class Player extends AbstractPlayer {
229
233
  rallyInitBonus = 0;
230
234
  directedDice = 0;
231
235
  inspiredGuardDice = 0;
236
+ // ==================== THE ACTION PHASE (utilities/combat-turn.ts) ====
237
+ //
238
+ // TAKE AIM (SR5 p.166): +1 die per Simple Action spent aiming, up to
239
+ // half Willpower rounded up, on the NEXT attack -- and lost the moment
240
+ // "any other kind of action (including a Free Action)" is taken first.
241
+ // Cleared by action-cost.ts on every non-aim spend, by the encounter
242
+ // at every phase boundary, and consumed by the strike that uses it.
243
+ aimBonus = 0;
244
+ aimTarget;
245
+ clearAim() {
246
+ this.aimBonus = 0;
247
+ this.aimTarget = undefined;
248
+ }
249
+ /** p.166: the cap on sequential Take Aim bonuses. */
250
+ get aimCap() {
251
+ return Math.max(1, Math.ceil(this.willpower / 2));
252
+ }
253
+ // PROGRESSIVE RECOIL (p.175-176): the rounds fired keep counting across
254
+ // Action Phases and Combat Turns until a phase goes by without a shot.
255
+ // firedThisPhase is the encounter's evidence for that reset.
256
+ recoilRoundsFired = 0;
257
+ firedThisPhase = false;
258
+ /**
259
+ * THE STANDING ANSWER TO "SOMEONE IS SHOOTING AT YOU" (p.168, p.191).
260
+ * Interrupt actions are declared in response to an attack, outside
261
+ * the defender's own phase. A terminal cannot prompt mid-pass -- and
262
+ * a hosted table cannot wait on one player's keystroke -- so the
263
+ * player sets a policy ("defend dodge") that the engine applies when
264
+ * an attack comes in and the Initiative Score can pay for it. Full
265
+ * Defense is different: declared once, -10, and it lasts the turn
266
+ * (CombatEncounter.declareFullDefense).
267
+ */
268
+ defensePolicy = 'none';
232
269
  // Set by the "surrender" command: this actor has thrown their hands up.
233
270
  // Cleared the moment they initiate violence again (see
234
271
  // CombatExchange.beginExchange). Guarded auto-fight honors it;
@@ -6,6 +6,7 @@ import { hint } from '../utilities/hints.js';
6
6
  import { Category, Size, Rating } from '../types/shared/item-enum.js';
7
7
  import { AbstractScene } from '../types/shared/abstracts.js';
8
8
  import { rollPool, formatRoll } from '../utilities/dice.js';
9
+ import { CombatEncounter } from '../utilities/combat-turn.js';
9
10
  export class Scene extends AbstractScene {
10
11
  story;
11
12
  // SYMMETRIC PLAYERS: the scene holds a LIST of human players --
@@ -129,6 +130,95 @@ export class Scene extends AbstractScene {
129
130
  // meaning -- begin/end act on the primary player, the query asks
130
131
  // "is ANY human's exchange live".
131
132
  _playerExchangesActive = new Set();
133
+ // ==================== COMBAT ENCOUNTERS (utilities/combat-turn.ts) ====
134
+ //
135
+ // One Combat Turn structure per ROOM: the fight in the loading bay and
136
+ // the fight in the office are two initiative orders, as they would be
137
+ // at a table. An encounter holds the participants, their scores and
138
+ // whose Action Phase is live; the scene only owns the map.
139
+ encounters = new Map();
140
+ /** The live fight in a room, if any. */
141
+ encounterIn(room) {
142
+ if (!room)
143
+ return undefined;
144
+ const enc = this.encounters.get(room);
145
+ return enc && !enc.ended ? enc : undefined;
146
+ }
147
+ /** The fight this actor is a participant in, if any. */
148
+ encounterFor(actor) {
149
+ const enc = this.encounterIn(actor.currentLocation);
150
+ return enc?.has(actor) ? enc : undefined;
151
+ }
152
+ /**
153
+ * Who is in the fight when `aggressor` opens on `target`: the two of
154
+ * them, every human and bound shell in the room (the party fights as
155
+ * one), every NPC flagged hostile, and anyone already engaged with
156
+ * either. A bystander is scenery; a bartender is not opposition until
157
+ * someone makes them so (noteAttack pulls them in then).
158
+ */
159
+ encounterMembers(room, aggressor, target) {
160
+ const out = new Set([aggressor]);
161
+ if (target)
162
+ out.add(target);
163
+ for (const a of room.getActors()) {
164
+ if (a === aggressor || a === target)
165
+ continue;
166
+ if (!a.sharesCombatPlane(aggressor) || a.isIncapacitated())
167
+ continue;
168
+ const npc = a;
169
+ if (npc.ephemeral?.kind === 'bystander')
170
+ continue;
171
+ const party = this.isHumanControlled(a) || !!npc.allyOf;
172
+ const hostile = !this.isHumanControlled(a) && npc.hostile === true && !npc.allyOf;
173
+ const engaged = !!a.combatOpponent && (a.combatOpponent === aggressor || a.combatOpponent === target);
174
+ if (party || hostile || engaged)
175
+ out.add(a);
176
+ }
177
+ return [...out];
178
+ }
179
+ /**
180
+ * Opens a fight in a room -- or renews the one already running there.
181
+ * Resolves once the first pass has run up to a human's Action Phase
182
+ * (or the fight has already ended).
183
+ */
184
+ async startEncounter(room, opts) {
185
+ const existing = this.encounterIn(room);
186
+ if (existing) {
187
+ if (opts.target)
188
+ existing.noteAttack(opts.aggressor, opts.target);
189
+ else if (!existing.has(opts.aggressor))
190
+ existing.join(opts.aggressor);
191
+ return existing;
192
+ }
193
+ const enc = new CombatEncounter(this, this.logger, room);
194
+ this.encounters.set(room, enc);
195
+ await enc.start(this.encounterMembers(room, opts.aggressor, opts.target), opts);
196
+ return enc;
197
+ }
198
+ /** The encounter is over (called by the encounter itself). */
199
+ endEncounter(enc) {
200
+ if (this.encounters.get(enc.room) === enc)
201
+ this.encounters.delete(enc.room);
202
+ }
203
+ /**
204
+ * Someone walks into a room where a fight is on (p.160 entering
205
+ * combat late): the party and the hostile join it; anyone else stays
206
+ * out until they act.
207
+ */
208
+ admitToEncounter(actor) {
209
+ const enc = this.encounterIn(actor.currentLocation);
210
+ if (!enc || enc.has(actor) || actor.isIncapacitated())
211
+ return undefined;
212
+ const npc = actor;
213
+ const party = this.isHumanControlled(actor) || !!npc.allyOf;
214
+ const hostile = !this.isHumanControlled(actor) && npc.hostile === true && !npc.allyOf;
215
+ if (!party && !hostile)
216
+ return undefined;
217
+ if (!enc.participants.some(p => p.actor.sharesCombatPlane(actor)))
218
+ return undefined;
219
+ enc.join(actor);
220
+ return enc;
221
+ }
132
222
  beginPlayerExchange(playerName) {
133
223
  this._playerExchangesActive.add(playerName ?? this.player.name);
134
224
  }
@@ -141,13 +231,13 @@ export class Scene extends AbstractScene {
141
231
  return this._playerExchangesActive.size > 0;
142
232
  }
143
233
  /**
144
- * CombatExchange.finishExchange() -> here -> the fight autopilot (see
145
- * utilities/auto-fight.ts). Optional-chained like updateStatus: harmless
146
- * before the Game reference is wired, or under a partial test stub, or
147
- * for NPC-vs-NPC fights (the autopilot ignores those itself).
234
+ * CombatExchange.finishExchange() and CombatEncounter.closePhase() ->
235
+ * here. This used to arm the fight autopilot (utilities/auto-fight.ts,
236
+ * retired 2026-09-07 with the Combat Turn: a phase you commit with
237
+ * "end turn" is not one the game plays for you). What remains is the
238
+ * KO'd player's view of the fight still raging around them.
148
239
  */
149
240
  notifyExchangeSettled(combatants) {
150
- this.game?.autoFight?.onExchangeSettled?.(combatants);
151
241
  // While a player lies KO'd, every settling exchange is a beat of
152
242
  // the fight still raging around them: learn its hostiles, then see
153
243
  // whether the room has settled (see maybeResolvePlayerKnockout).
@@ -491,6 +581,13 @@ export class Scene extends AbstractScene {
491
581
  // map was never cleaned on removal, leaving a dead Room reference.
492
582
  this.characterLocations.delete(name);
493
583
  this.logger.write(`Removed actor: ${name}`);
584
+ // A fallen participant's Action Phase, if it was live, moves on;
585
+ // the encounter itself re-checks who is still standing on its next
586
+ // step (utilities/combat-turn.ts run).
587
+ const enc = room ? this.encounterIn(room) : undefined;
588
+ if (enc && enc.phaseActor === actor) {
589
+ void enc.dropPhaseActor(actor);
590
+ }
494
591
  // Tell the Game an actor left the world -- the companion roster
495
592
  // (Game.onActorRemoved) prunes shells and settles their bound
496
593
  // devices (a wrecked frame, a bricked deck).