@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/aim.js +60 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +132 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +12 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +102 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +24 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/defend.js +74 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/delay.js +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +11 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-turn.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/heal.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiative.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +42 -59
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +23 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +31 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +11 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +24 -1
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +21 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +33 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +98 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-budget.js +87 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +66 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +99 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +568 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/movement-cost.js +80 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +122 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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:
|
|
99
|
-
//
|
|
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 =
|
|
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 =
|
|
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,
|