@maka/maka-cli 5.134.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 (43) 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/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 +11 -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/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
@@ -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,15 @@
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
+ export const ENGINE_VERSION = '1.40.0';
363
373
  //# 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';
@@ -2528,6 +2533,18 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2528
2533
  // wanted to end a call -- the intent is unambiguous mid-call.
2529
2534
  CommandFactory.registerCommand('end', EndCallCommand);
2530
2535
  CommandFactory.registerCommand('hangup', EndCallCommand);
2536
+ // THE COMBAT TURN (utilities/combat-turn.ts). "end turn" arrives
2537
+ // through EndCallCommand ("end" is the hangup) and is handed here.
2538
+ CommandFactory.registerCommand('end-turn', EndTurnCommand);
2539
+ CommandFactory.registerCommand('endturn', EndTurnCommand);
2540
+ CommandFactory.registerCommand('done', EndTurnCommand);
2541
+ CommandFactory.registerCommand('pass', EndTurnCommand);
2542
+ CommandFactory.registerCommand('initiative', InitiativeCommand);
2543
+ CommandFactory.registerCommand('tracker', InitiativeCommand);
2544
+ CommandFactory.registerCommand('turn', InitiativeCommand);
2545
+ CommandFactory.registerCommand('aim', AimCommand);
2546
+ CommandFactory.registerCommand('defend', DefendCommand);
2547
+ CommandFactory.registerCommand('delay', DelayCommand);
2531
2548
  // 'train' used to alias advance; it belongs to crew instruction now
2532
2549
  // (commands/crew.ts) -- self-training is "advance".
2533
2550
  CommandFactory.registerCommand('advance', AdvanceCommand);
@@ -5305,6 +5322,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5305
5322
  // throws, least of all on the panel a dying player is reading.
5306
5323
  const docwagon = this.docwagonSlot(this.docwagonByPlayer?.get(player.name) ?? (player === this.player ? this.docwagon : undefined));
5307
5324
  lines.push(`${armorNote}${arSlot} | ${link} | ${docwagon}`);
5325
+ // THE COMBAT TURN (utilities/combat-turn.ts): whose phase it is, the
5326
+ // score, and what this player has left to spend -- present only
5327
+ // while a fight is on, like every transient line below.
5328
+ const combatLine = this.scene?.encounterFor?.(player)?.hudLine(player);
5329
+ if (combatLine)
5330
+ lines.push(combatLine);
5308
5331
  // Lines 4+: transient states, present only while active. Future
5309
5332
  // contexts (astral projection, VR hot-sim) join here the same way.
5310
5333
  // THE COST IS THE ENGINE'S. Every one of these lines printed a
@@ -5988,7 +6011,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5988
6011
  static HELP_CATEGORIES = [
5989
6012
  { 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
6013
  { 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']] },
6014
+ { 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
6015
  // The party layer: everyone who walks (or flies, or manifests) at
5993
6016
  // your side answers to these.
5994
6017
  // "players" folded back under crew after playtesting ("crew
@@ -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
@@ -229,6 +229,39 @@ export class Player extends AbstractPlayer {
229
229
  rallyInitBonus = 0;
230
230
  directedDice = 0;
231
231
  inspiredGuardDice = 0;
232
+ // ==================== THE ACTION PHASE (utilities/combat-turn.ts) ====
233
+ //
234
+ // TAKE AIM (SR5 p.166): +1 die per Simple Action spent aiming, up to
235
+ // half Willpower rounded up, on the NEXT attack -- and lost the moment
236
+ // "any other kind of action (including a Free Action)" is taken first.
237
+ // Cleared by action-cost.ts on every non-aim spend, by the encounter
238
+ // at every phase boundary, and consumed by the strike that uses it.
239
+ aimBonus = 0;
240
+ aimTarget;
241
+ clearAim() {
242
+ this.aimBonus = 0;
243
+ this.aimTarget = undefined;
244
+ }
245
+ /** p.166: the cap on sequential Take Aim bonuses. */
246
+ get aimCap() {
247
+ return Math.max(1, Math.ceil(this.willpower / 2));
248
+ }
249
+ // PROGRESSIVE RECOIL (p.175-176): the rounds fired keep counting across
250
+ // Action Phases and Combat Turns until a phase goes by without a shot.
251
+ // firedThisPhase is the encounter's evidence for that reset.
252
+ recoilRoundsFired = 0;
253
+ firedThisPhase = false;
254
+ /**
255
+ * THE STANDING ANSWER TO "SOMEONE IS SHOOTING AT YOU" (p.168, p.191).
256
+ * Interrupt actions are declared in response to an attack, outside
257
+ * the defender's own phase. A terminal cannot prompt mid-pass -- and
258
+ * a hosted table cannot wait on one player's keystroke -- so the
259
+ * player sets a policy ("defend dodge") that the engine applies when
260
+ * an attack comes in and the Initiative Score can pay for it. Full
261
+ * Defense is different: declared once, -10, and it lasts the turn
262
+ * (CombatEncounter.declareFullDefense).
263
+ */
264
+ defensePolicy = 'none';
232
265
  // Set by the "surrender" command: this actor has thrown their hands up.
233
266
  // Cleared the moment they initiate violence again (see
234
267
  // 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,96 @@ 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
+ enc.onHumanPhase = (p, e) => this.game?.autoFight?.onPhaseBegins?.(p, e);
196
+ await enc.start(this.encounterMembers(room, opts.aggressor, opts.target), opts);
197
+ return enc;
198
+ }
199
+ /** The encounter is over (called by the encounter itself). */
200
+ endEncounter(enc) {
201
+ if (this.encounters.get(enc.room) === enc)
202
+ this.encounters.delete(enc.room);
203
+ }
204
+ /**
205
+ * Someone walks into a room where a fight is on (p.160 entering
206
+ * combat late): the party and the hostile join it; anyone else stays
207
+ * out until they act.
208
+ */
209
+ admitToEncounter(actor) {
210
+ const enc = this.encounterIn(actor.currentLocation);
211
+ if (!enc || enc.has(actor) || actor.isIncapacitated())
212
+ return undefined;
213
+ const npc = actor;
214
+ const party = this.isHumanControlled(actor) || !!npc.allyOf;
215
+ const hostile = !this.isHumanControlled(actor) && npc.hostile === true && !npc.allyOf;
216
+ if (!party && !hostile)
217
+ return undefined;
218
+ if (!enc.participants.some(p => p.actor.sharesCombatPlane(actor)))
219
+ return undefined;
220
+ enc.join(actor);
221
+ return enc;
222
+ }
132
223
  beginPlayerExchange(playerName) {
133
224
  this._playerExchangesActive.add(playerName ?? this.player.name);
134
225
  }
@@ -491,6 +582,13 @@ export class Scene extends AbstractScene {
491
582
  // map was never cleaned on removal, leaving a dead Room reference.
492
583
  this.characterLocations.delete(name);
493
584
  this.logger.write(`Removed actor: ${name}`);
585
+ // A fallen participant's Action Phase, if it was live, moves on;
586
+ // the encounter itself re-checks who is still standing on its next
587
+ // step (utilities/combat-turn.ts run).
588
+ const enc = room ? this.encounterIn(room) : undefined;
589
+ if (enc && enc.phaseActor === actor) {
590
+ void enc.dropPhaseActor(actor);
591
+ }
494
592
  // Tell the Game an actor left the world -- the companion roster
495
593
  // (Game.onActorRemoved) prunes shells and settles their bound
496
594
  // devices (a wrecked frame, a bricked deck).
@@ -26,6 +26,9 @@ const MENU_VERBS = new Set([
26
26
  // The sprite database reads like the spellbook, so it rides the same
27
27
  // panel -- reference you consult, not a menu you spend from.
28
28
  'sprites',
29
+ // The initiative tracker (commands/initiative.ts) is a reference
30
+ // panel too: who acts when, this Combat Turn.
31
+ 'initiative', 'tracker', 'turn',
29
32
  ]);
30
33
  // Windows Terminal draws astral-plane emoji (📞 call icon, 📍 HUD pin,
31
34
  // 👥 companions, 💀) TWO columns wide, but blessed's 2015-era unicode
@@ -0,0 +1,87 @@
1
+ export class ActionBudget {
2
+ /** Free Actions left this phase (one, p.158). */
3
+ free = 1;
4
+ /** Simple Actions left. A Complex Action spends both at once. */
5
+ simple = 2;
6
+ /** Whether an attack action has been taken this phase (p.164). */
7
+ attackTaken = false;
8
+ /** Everything spent so far, in order, for the tracker. */
9
+ spent = [];
10
+ /** A Complex Action needs the whole phase: both Simples unspent. */
11
+ get complexAvailable() {
12
+ return this.simple === 2;
13
+ }
14
+ /** Nothing left but to end the turn. */
15
+ get exhausted() {
16
+ return this.simple === 0 && this.free === 0;
17
+ }
18
+ /** The Simple/Complex half is spent -- only a Free Action remains. */
19
+ get actionsExhausted() {
20
+ return this.simple === 0;
21
+ }
22
+ /**
23
+ * Why this spend would be refused, or undefined when it is affordable.
24
+ * Checking never spends; spend() calls this first.
25
+ */
26
+ refusalFor(kind, attack = false) {
27
+ if (kind === 'free') {
28
+ if (this.free <= 0)
29
+ return `You've already used your Free Action this Action Phase (SR5 p.158 -- one per phase).`;
30
+ return undefined;
31
+ }
32
+ if (attack && this.attackTaken) {
33
+ return `You've already attacked this Action Phase -- only one of its actions can be an attack (SR5 p.164). "end turn" and go again next pass.`;
34
+ }
35
+ if (kind === 'simple') {
36
+ if (this.simple <= 0) {
37
+ return `You've spent both Simple Actions this Action Phase (SR5 p.163)${this.spentComplex() ? ' -- a Complex Action takes the whole phase (p.167)' : ''}. "end turn" to let the next combatant act.`;
38
+ }
39
+ return undefined;
40
+ }
41
+ // complex
42
+ if (!this.complexAvailable) {
43
+ return this.simple === 0
44
+ ? `Nothing left this Action Phase -- a Complex Action needs the whole phase (SR5 p.167). "end turn".`
45
+ : `A Complex Action takes the WHOLE Action Phase (SR5 p.167), and you've already spent a Simple Action. Use the other Simple, or "end turn".`;
46
+ }
47
+ return undefined;
48
+ }
49
+ /** Spends the action; returns the refusal instead when it cannot be paid. */
50
+ spend(kind, label, attack = false) {
51
+ const refusal = this.refusalFor(kind, attack);
52
+ if (refusal)
53
+ return refusal;
54
+ if (kind === 'free')
55
+ this.free -= 1;
56
+ else if (kind === 'simple')
57
+ this.simple -= 1;
58
+ else
59
+ this.simple = 0;
60
+ if (attack)
61
+ this.attackTaken = true;
62
+ this.spent.push({ kind, label, attack: attack || undefined });
63
+ return undefined;
64
+ }
65
+ spentComplex() {
66
+ return this.spent.some(s => s.kind === 'complex');
67
+ }
68
+ /** One line for the HUD: what is still on the table. */
69
+ describe() {
70
+ const parts = [];
71
+ if (this.simple === 2)
72
+ parts.push('2 Simple (or 1 Complex)');
73
+ else if (this.simple === 1)
74
+ parts.push('1 Simple');
75
+ else
76
+ parts.push('no Simple/Complex left');
77
+ parts.push(this.free > 0 ? '1 Free' : 'no Free');
78
+ return parts.join(' · ');
79
+ }
80
+ /** The phase's ledger, e.g. "Ready Weapon (Simple), Fire (Simple, attack)". */
81
+ ledger() {
82
+ if (this.spent.length === 0)
83
+ return 'nothing yet';
84
+ return this.spent.map(s => `${s.label} (${s.kind === 'free' ? 'Free' : s.kind === 'simple' ? 'Simple' : 'Complex'}${s.attack ? ', attack' : ''})`).join(', ');
85
+ }
86
+ }
87
+ //# sourceMappingURL=action-budget.js.map
@@ -0,0 +1,66 @@
1
+ import { hint } from './hints.js';
2
+ /** The encounter this actor is fighting in, if any. */
3
+ export function encounterOf(scene, actor) {
4
+ return scene.encounterFor?.(actor);
5
+ }
6
+ /**
7
+ * The refusal when it is not this actor's Action Phase -- the wording
8
+ * every verb shares, so a player learns one sentence.
9
+ */
10
+ export function notYourPhase(enc, actor) {
11
+ const who = enc.phaseActor;
12
+ if (who && who !== actor) {
13
+ return `It's ${who.name}'s Action Phase -- yours comes round at Initiative ${enc.scoreOf(actor)}.${hint(` ("initiative" shows the order.)`)}`;
14
+ }
15
+ return `The pass is resolving -- your Action Phase comes round at Initiative ${enc.scoreOf(actor)}.`;
16
+ }
17
+ /**
18
+ * Pays for an action, or explains why it cannot be paid. Undefined means
19
+ * "go ahead" -- out of combat, or in combat with the action affordable
20
+ * and now spent.
21
+ */
22
+ export function billAction(scene, actor, kind, label, opts = {}) {
23
+ const enc = encounterOf(scene, actor);
24
+ if (!enc || enc.ended)
25
+ return undefined;
26
+ if (!enc.isPhaseOf(actor))
27
+ return notYourPhase(enc, actor);
28
+ const budget = enc.budgetOf(actor);
29
+ if (!budget)
30
+ return notYourPhase(enc, actor);
31
+ const refusal = budget.spend(kind, label, opts.attack === true);
32
+ if (refusal)
33
+ return refusal;
34
+ // p.166: aiming is lost "if the character takes any other kind of
35
+ // action (including a Free Action) before attacking". An attack
36
+ // consumes the bonus at the roll (combat-exchange.ts), so it is left
37
+ // in place here for the strike to read.
38
+ if (!opts.aiming && !opts.attack)
39
+ actor.clearAim();
40
+ scene.updateStatus?.();
41
+ return undefined;
42
+ }
43
+ /**
44
+ * A verb that is refused outright mid-fight unless it is your phase --
45
+ * for things that are not actions at all but still cannot happen while
46
+ * someone else is acting (walking out of the room, say).
47
+ */
48
+ export function requirePhase(scene, actor) {
49
+ const enc = encounterOf(scene, actor);
50
+ if (!enc || enc.ended)
51
+ return undefined;
52
+ if (!enc.isPhaseOf(actor))
53
+ return notYourPhase(enc, actor);
54
+ return undefined;
55
+ }
56
+ /** What is left this phase, for a verb's trailing hint. */
57
+ export function phaseHint(scene, actor) {
58
+ const enc = encounterOf(scene, actor);
59
+ const budget = enc?.budgetOf(actor);
60
+ if (!enc || !budget)
61
+ return '';
62
+ return budget.actionsExhausted
63
+ ? hint(` (${budget.free > 0 ? 'A Free Action left, then ' : ''}"end turn".)`)
64
+ : hint(` (${budget.describe()} left.)`);
65
+ }
66
+ //# sourceMappingURL=action-cost.js.map
@@ -70,6 +70,11 @@ export class AutoFight {
70
70
  continue;
71
71
  if (player.autoStance === 'manual')
72
72
  continue;
73
+ // A COMBAT TURN schedules from the phase (onPhaseBegins), never
74
+ // from a settled strike -- acting between phases is the thing the
75
+ // turn structure exists to stop.
76
+ if (this.game.scene.encounterFor?.(player))
77
+ continue;
73
78
  const target = combatants.find(c => c !== player);
74
79
  if (!target)
75
80
  continue;
@@ -81,6 +86,59 @@ export class AutoFight {
81
86
  this.pending.set(player.name, t);
82
87
  }
83
88
  }
89
+ /**
90
+ * A COMBAT TURN handed this human their Action Phase
91
+ * (utilities/combat-turn.ts). At any stance but manual the autopilot
92
+ * plays the phase after the same interruptible beat: draw if
93
+ * holstered, the stance's strike, then "end turn". Typing anything
94
+ * cancels it and takes over, exactly as between exchanges.
95
+ */
96
+ onPhaseBegins(player, enc) {
97
+ if (player.autoStance === 'manual')
98
+ return;
99
+ this.cancelPending(player.name);
100
+ const t = setTimeout(() => {
101
+ this.pending.delete(player.name);
102
+ void this.playPhase(player, enc);
103
+ }, AutoFight.REENGAGE_DELAY_MS);
104
+ this.pending.set(player.name, t);
105
+ }
106
+ async playPhase(player, enc) {
107
+ const logger = Logger.getInstance();
108
+ if (player.autoStance === 'manual' || !enc.isPhaseOf(player) || player.isIncapacitated())
109
+ return;
110
+ const run = async (cmd) => {
111
+ if (!enc.isPhaseOf(player))
112
+ return;
113
+ logger.meta(`⚔ [${player.autoStance}] ${cmd}`, 'cyan');
114
+ const result = (await this.game.handleInputAs(player.name, cmd)).text;
115
+ if (result)
116
+ logger.log(result);
117
+ };
118
+ const target = enc.enemiesOf(player)[0];
119
+ if (!target) {
120
+ await run('end-turn');
121
+ return;
122
+ }
123
+ if (player.autoStance === 'non-lethal' && target.surrendered) {
124
+ if (player.plane === 'meat' && target.plane === 'meat'
125
+ && player.grappling !== target.name && !target.grappledBy) {
126
+ await run(`restrain ${target.name}`);
127
+ }
128
+ else {
129
+ logger.logWithColor(`⚔ ${target.name} has surrendered -- you hold fire. (non-lethal)`, 'yellow');
130
+ }
131
+ await run('end-turn');
132
+ this.game.updateStatus();
133
+ return;
134
+ }
135
+ const weapon = player.getCarriedWeapon();
136
+ if (weapon && !player.weaponDrawn && player.plane === 'meat')
137
+ await run('draw');
138
+ await run(this.chooseAction(player, target));
139
+ await run('end-turn');
140
+ this.game.updateStatus();
141
+ }
84
142
  /**
85
143
  * The beat elapsed uninterrupted -- act, if the fight still stands.
86
144
  * Every guard here re-checks live state: the target may have died, fled,