@maka/maka-cli 5.134.0 → 5.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/aim.js +60 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +132 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +12 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +102 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +24 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/defend.js +74 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/delay.js +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +11 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/end-turn.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/heal.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/initiative.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +42 -59
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +23 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +31 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +16 -15
- package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +10 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +18 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +35 -18
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +21 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +50 -13
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +102 -5
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +12 -12
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-budget.js +87 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +66 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +101 -5
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +571 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +20 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/movement-cost.js +80 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +122 -0
- package/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +0 -182
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { rollInitiativeScore, formatInitiative, INITIATIVE_PASS_DROP, rollPool, formatRoll } from './dice.js';
|
|
2
|
+
import { ActionBudget } from './action-budget.js';
|
|
3
|
+
import { runNpcActionPhase } from './npc-combat-brain.js';
|
|
4
|
+
export class CombatEncounter {
|
|
5
|
+
scene;
|
|
6
|
+
logger;
|
|
7
|
+
room;
|
|
8
|
+
/** The breath between phases so a fight reads as a fight. MAKA_NO_BEATS
|
|
9
|
+
* zeroes it for harnesses. */
|
|
10
|
+
static BEAT_MS = 600;
|
|
11
|
+
/** A fight nobody can end (two immortal shells trading misses) is a
|
|
12
|
+
* livelock, not a story: past this many Combat Turns the encounter
|
|
13
|
+
* breaks off. Generous -- a real fight is over in three or four. */
|
|
14
|
+
static MAX_TURNS = 30;
|
|
15
|
+
turn = 0;
|
|
16
|
+
pass = 0;
|
|
17
|
+
ended = false;
|
|
18
|
+
participants = [];
|
|
19
|
+
/** Whose Action Phase is live, and their budget. */
|
|
20
|
+
phaseActor;
|
|
21
|
+
budget;
|
|
22
|
+
pairs = new Map();
|
|
23
|
+
surrenderSeenAt = new Map();
|
|
24
|
+
running = false;
|
|
25
|
+
/** Every human phase that began -- a seam for tests and for any future
|
|
26
|
+
* phase clock; nothing plays the phase for a human (the fight
|
|
27
|
+
* autopilot was retired 2026-09-07: a phase you commit with "end
|
|
28
|
+
* turn" is yours to play). */
|
|
29
|
+
onHumanPhase;
|
|
30
|
+
constructor(scene, logger, room) {
|
|
31
|
+
this.scene = scene;
|
|
32
|
+
this.logger = logger;
|
|
33
|
+
this.room = room;
|
|
34
|
+
}
|
|
35
|
+
// ------------------------------------------------------------ setup ----
|
|
36
|
+
/**
|
|
37
|
+
* Opens the encounter: everyone rolls (surprise first, if it was an
|
|
38
|
+
* ambush), the order is announced, and the first pass runs until it
|
|
39
|
+
* reaches a human's Action Phase -- or the fight is already over.
|
|
40
|
+
*/
|
|
41
|
+
async start(members, opts) {
|
|
42
|
+
this.turn = 1;
|
|
43
|
+
this.pass = 1;
|
|
44
|
+
for (const m of members)
|
|
45
|
+
this.addParticipant(m);
|
|
46
|
+
if (opts.target)
|
|
47
|
+
this.noteAttack(opts.aggressor, opts.target);
|
|
48
|
+
this.seedHostility();
|
|
49
|
+
const metaLines = ['Initiative (SR5 p.159):'];
|
|
50
|
+
const worldLines = [];
|
|
51
|
+
// SURPRISE (p.192): the ambushed side rolls Reaction + Intuition (3).
|
|
52
|
+
// Failure: -10 Initiative and no defense against the surprisers
|
|
53
|
+
// until their next Action Phase. The aggressor never rolls -- an
|
|
54
|
+
// ambusher is by definition not surprised (p.193).
|
|
55
|
+
if (opts.ambush) {
|
|
56
|
+
for (const p of this.participants) {
|
|
57
|
+
if (p.actor === opts.aggressor)
|
|
58
|
+
continue;
|
|
59
|
+
if (this.sameSide(p.actor, opts.aggressor))
|
|
60
|
+
continue;
|
|
61
|
+
const alerted = this.isNpc(p.actor) && p.actor.hostile ? 3 : 0;
|
|
62
|
+
const pool = Math.max(1, p.actor.reaction + p.actor.intuition + alerted + p.actor.woundModifier);
|
|
63
|
+
const roll = rollPool(pool);
|
|
64
|
+
if (roll.hits >= 3) {
|
|
65
|
+
metaLines.push(` ${p.actor.name} Surprise (Reaction + Intuition, 3): ${formatRoll(roll)} -- ready.`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
p.surprised = true;
|
|
69
|
+
p.score -= INITIATIVE_PASS_DROP;
|
|
70
|
+
metaLines.push(` ${p.actor.name} Surprise (Reaction + Intuition, 3): ${formatRoll(roll)} -- SURPRISED (-10, no defense this pass).`);
|
|
71
|
+
worldLines.push(`${p.actor.name} is caught flat-footed.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const p of this.order()) {
|
|
76
|
+
metaLines.push(` ${p.actor.name}: ${formatInitiative(p.roll)}${p.score !== p.roll.score ? ` -> ${p.score}` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
worldLines.push(`Combat Turn ${this.turn} -- ${this.orderLine()}.`);
|
|
79
|
+
this.scene.addWorldEvent(`A fight broke out in ${this.room.name}: ${this.participants.map(p => p.actor.name).join(', ')}.`);
|
|
80
|
+
await this.announce(worldLines, metaLines);
|
|
81
|
+
await this.run();
|
|
82
|
+
}
|
|
83
|
+
addParticipant(actor) {
|
|
84
|
+
const existing = this.participantOf(actor);
|
|
85
|
+
if (existing)
|
|
86
|
+
return existing;
|
|
87
|
+
const roll = rollInitiativeScore(actor.getInitiativeAttribute(), actor.getInitiativeDice());
|
|
88
|
+
// Rally (commands/lead.ts) is a banked +score bonus, consumed here;
|
|
89
|
+
// Combat Paralysis (p.80) halves the first score of a fresh fight.
|
|
90
|
+
let score = roll.score + (actor.rallyInitBonus ?? 0);
|
|
91
|
+
actor.rallyInitBonus = 0;
|
|
92
|
+
if (actor.paralysisFreeze) {
|
|
93
|
+
actor.paralysisFreeze = false;
|
|
94
|
+
score = Math.ceil(score / 2);
|
|
95
|
+
}
|
|
96
|
+
actor.resetMovementTurn();
|
|
97
|
+
actor.clearAim();
|
|
98
|
+
const p = {
|
|
99
|
+
actor, roll, score, woundAtSync: actor.woundModifier,
|
|
100
|
+
acted: false, delayed: false, surprised: false,
|
|
101
|
+
};
|
|
102
|
+
this.participants.push(p);
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Someone walks into a running fight (p.160 Changing Initiative):
|
|
107
|
+
* roll as normal, minus 10 per pass already gone this turn.
|
|
108
|
+
*/
|
|
109
|
+
join(actor) {
|
|
110
|
+
const p = this.addParticipant(actor);
|
|
111
|
+
const late = (this.pass - 1) * INITIATIVE_PASS_DROP;
|
|
112
|
+
if (late > 0)
|
|
113
|
+
p.score -= late;
|
|
114
|
+
this.logger.write(`Encounter ${this.room.name}: ${actor.name} joins at ${p.score} (pass ${this.pass}).`);
|
|
115
|
+
return p;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The seeded hostilities: every hostile NPC against every member of the
|
|
119
|
+
* party present; every party member against whoever their master's
|
|
120
|
+
* opponent is. Attacks add pairs as they happen (noteAttack).
|
|
121
|
+
*/
|
|
122
|
+
seedHostility() {
|
|
123
|
+
const party = this.participants.filter(p => this.isPartySide(p.actor));
|
|
124
|
+
const street = this.participants.filter(p => !this.isPartySide(p.actor));
|
|
125
|
+
for (const s of street) {
|
|
126
|
+
if (!(this.isNpc(s.actor) && s.actor.hostile))
|
|
127
|
+
continue;
|
|
128
|
+
for (const p of party)
|
|
129
|
+
this.notePair(s.actor, p.actor);
|
|
130
|
+
}
|
|
131
|
+
for (const p of this.participants) {
|
|
132
|
+
const opp = p.actor.combatOpponent;
|
|
133
|
+
if (opp && this.participantOf(opp))
|
|
134
|
+
this.notePair(p.actor, opp);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------- queries ----
|
|
138
|
+
participantOf(actor) {
|
|
139
|
+
return this.participants.find(p => p.actor === actor);
|
|
140
|
+
}
|
|
141
|
+
has(actor) {
|
|
142
|
+
return this.participantOf(actor) !== undefined;
|
|
143
|
+
}
|
|
144
|
+
isPhaseOf(actor) {
|
|
145
|
+
return !this.ended && this.phaseActor === actor;
|
|
146
|
+
}
|
|
147
|
+
budgetOf(actor) {
|
|
148
|
+
return this.isPhaseOf(actor) ? this.budget : undefined;
|
|
149
|
+
}
|
|
150
|
+
scoreOf(actor) {
|
|
151
|
+
return this.participantOf(actor)?.score ?? 0;
|
|
152
|
+
}
|
|
153
|
+
isSurprised(actor) {
|
|
154
|
+
return this.participantOf(actor)?.surprised === true;
|
|
155
|
+
}
|
|
156
|
+
fullDefenseActive(actor) {
|
|
157
|
+
return this.participantOf(actor)?.fullDefenseTurn === this.turn;
|
|
158
|
+
}
|
|
159
|
+
/** Present in the room, on their feet, still a participant. */
|
|
160
|
+
present(p) {
|
|
161
|
+
return p.actor.currentLocation === this.room && !p.actor.isIncapacitated();
|
|
162
|
+
}
|
|
163
|
+
isNpc(actor) {
|
|
164
|
+
return !this.scene.isHumanControlled(actor);
|
|
165
|
+
}
|
|
166
|
+
/** The party: humans and the shells bound to them. */
|
|
167
|
+
isPartySide(actor) {
|
|
168
|
+
if (this.scene.isHumanControlled(actor))
|
|
169
|
+
return true;
|
|
170
|
+
const ally = actor.allyOf;
|
|
171
|
+
return !!ally;
|
|
172
|
+
}
|
|
173
|
+
sameSide(a, b) {
|
|
174
|
+
return this.isPartySide(a) === this.isPartySide(b);
|
|
175
|
+
}
|
|
176
|
+
pairKey(a, b) {
|
|
177
|
+
return [a.name, b.name].sort().join('|');
|
|
178
|
+
}
|
|
179
|
+
notePair(a, b) {
|
|
180
|
+
if (a === b)
|
|
181
|
+
return;
|
|
182
|
+
const key = this.pairKey(a, b);
|
|
183
|
+
this.pairs.set(key, { a: a.name, b: b.name, notedAt: Date.now() });
|
|
184
|
+
}
|
|
185
|
+
/** An attack renews (or creates) the hostility between two actors. */
|
|
186
|
+
noteAttack(attacker, defender) {
|
|
187
|
+
if (!this.has(defender) && defender.currentLocation === this.room)
|
|
188
|
+
this.join(defender);
|
|
189
|
+
if (!this.has(attacker) && attacker.currentLocation === this.room)
|
|
190
|
+
this.join(attacker);
|
|
191
|
+
this.notePair(attacker, defender);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Is anyone still fighting anyone? A pair is live while both stand in
|
|
195
|
+
* the room, and neither has surrendered since the pair was last renewed
|
|
196
|
+
* by an attack -- hands up ends a fight unless somebody keeps shooting.
|
|
197
|
+
*/
|
|
198
|
+
stillHostile() {
|
|
199
|
+
for (const pair of this.pairs.values()) {
|
|
200
|
+
const a = this.participants.find(p => p.actor.name === pair.a);
|
|
201
|
+
const b = this.participants.find(p => p.actor.name === pair.b);
|
|
202
|
+
if (!a || !b || !this.present(a) || !this.present(b))
|
|
203
|
+
continue;
|
|
204
|
+
if (this.surrenderBreaks(a.actor, pair) || this.surrenderBreaks(b.actor, pair))
|
|
205
|
+
continue;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
surrenderBreaks(actor, pair) {
|
|
211
|
+
if (!actor.surrendered) {
|
|
212
|
+
this.surrenderSeenAt.delete(actor.name);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
let seen = this.surrenderSeenAt.get(actor.name);
|
|
216
|
+
if (seen === undefined) {
|
|
217
|
+
seen = Date.now();
|
|
218
|
+
this.surrenderSeenAt.set(actor.name, seen);
|
|
219
|
+
}
|
|
220
|
+
return pair.notedAt <= seen;
|
|
221
|
+
}
|
|
222
|
+
/** The other side, present and standing, for a given actor. */
|
|
223
|
+
enemiesOf(actor) {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const pair of this.pairs.values()) {
|
|
226
|
+
if (pair.a !== actor.name && pair.b !== actor.name)
|
|
227
|
+
continue;
|
|
228
|
+
const otherName = pair.a === actor.name ? pair.b : pair.a;
|
|
229
|
+
const other = this.participants.find(p => p.actor.name === otherName);
|
|
230
|
+
if (!other || !this.present(other))
|
|
231
|
+
continue;
|
|
232
|
+
if (this.surrenderBreaks(other.actor, pair))
|
|
233
|
+
continue;
|
|
234
|
+
out.push(other.actor);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
// ------------------------------------------------------------ order ----
|
|
239
|
+
/** ERIC (p.159): Edge, Reaction, Intuition, then a coin -- decided once
|
|
240
|
+
* per participant so the order is stable across a pass. */
|
|
241
|
+
coin = new Map();
|
|
242
|
+
order() {
|
|
243
|
+
return [...this.participants].sort((x, y) => {
|
|
244
|
+
if (y.score !== x.score)
|
|
245
|
+
return y.score - x.score;
|
|
246
|
+
if (y.actor.edge !== x.actor.edge)
|
|
247
|
+
return y.actor.edge - x.actor.edge;
|
|
248
|
+
if (y.actor.reaction !== x.actor.reaction)
|
|
249
|
+
return y.actor.reaction - x.actor.reaction;
|
|
250
|
+
if (y.actor.intuition !== x.actor.intuition)
|
|
251
|
+
return y.actor.intuition - x.actor.intuition;
|
|
252
|
+
return this.coinFor(y) - this.coinFor(x);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
coinFor(p) {
|
|
256
|
+
let c = this.coin.get(p.actor.name);
|
|
257
|
+
if (c === undefined) {
|
|
258
|
+
c = Math.random();
|
|
259
|
+
this.coin.set(p.actor.name, c);
|
|
260
|
+
}
|
|
261
|
+
return c;
|
|
262
|
+
}
|
|
263
|
+
orderLine() {
|
|
264
|
+
return this.order()
|
|
265
|
+
.filter(p => this.present(p))
|
|
266
|
+
.map(p => `${p.actor.name} ${p.score}`)
|
|
267
|
+
.join(', ');
|
|
268
|
+
}
|
|
269
|
+
/** The next participant owed an Action Phase this pass. */
|
|
270
|
+
nextActor() {
|
|
271
|
+
const ready = this.order().filter(p => this.present(p) && !p.acted && p.score > 0);
|
|
272
|
+
return ready.find(p => !p.delayed) ?? ready[0];
|
|
273
|
+
}
|
|
274
|
+
// ----------------------------------------------------- the pass loop ----
|
|
275
|
+
/**
|
|
276
|
+
* Advances the encounter until it needs a human (their Action Phase is
|
|
277
|
+
* live and waits for "end turn") or it is over. Re-entrant-safe: a
|
|
278
|
+
* nested call while a pass is already being driven returns at once.
|
|
279
|
+
*/
|
|
280
|
+
async run() {
|
|
281
|
+
if (this.running || this.ended)
|
|
282
|
+
return;
|
|
283
|
+
this.running = true;
|
|
284
|
+
try {
|
|
285
|
+
while (!this.ended) {
|
|
286
|
+
this.syncWounds();
|
|
287
|
+
if (!this.stillHostile()) {
|
|
288
|
+
await this.end('over');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (this.phaseActor)
|
|
292
|
+
return; // a human phase is live
|
|
293
|
+
const next = this.nextActor();
|
|
294
|
+
if (!next) {
|
|
295
|
+
await this.endPass();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
await this.beginPhase(next);
|
|
299
|
+
if (this.scene.isHumanControlled(next.actor))
|
|
300
|
+
return;
|
|
301
|
+
// An NPC's phase: the brain plays it, then the phase closes.
|
|
302
|
+
try {
|
|
303
|
+
await runNpcActionPhase(this, next.actor);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
this.logger.error(`Encounter: ${next.actor.name}'s phase failed: ${err}`);
|
|
307
|
+
}
|
|
308
|
+
this.closePhase(next);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
this.running = false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async beginPhase(p) {
|
|
316
|
+
this.phaseActor = p.actor;
|
|
317
|
+
this.budget = new ActionBudget();
|
|
318
|
+
p.surprised = false;
|
|
319
|
+
p.delayed = false;
|
|
320
|
+
p.actor.clearAim();
|
|
321
|
+
// Recoil resets the moment an Action Phase passes without firing --
|
|
322
|
+
// tracked by the phase, settled by attack.ts (p.175-176).
|
|
323
|
+
p.actor.firedThisPhase = false;
|
|
324
|
+
// p.162: "Running characters must use a Free Action in each
|
|
325
|
+
// Initiative Pass they are considered running." Already past the
|
|
326
|
+
// Walk Rate this turn means the phase opens with its Free Action
|
|
327
|
+
// spent on staying at a run.
|
|
328
|
+
if (p.actor.isRunningThisTurn)
|
|
329
|
+
this.budget.spend('free', 'Run (still running)');
|
|
330
|
+
const human = this.scene.isHumanControlled(p.actor);
|
|
331
|
+
if (human) {
|
|
332
|
+
const budgetLine = `Your Action Phase -- Combat Turn ${this.turn}, pass ${this.pass}, Initiative ${p.score}. ${this.budget.describe()}; ${this.movementLine(p.actor)}. "end turn" when you're done.`;
|
|
333
|
+
this.logger.log(`\n{cyan-fg}${budgetLine}{/cyan-fg}`, { actor: p.actor.name });
|
|
334
|
+
this.scene.updateStatus();
|
|
335
|
+
this.onHumanPhase?.(p.actor, this);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
await this.announce([`${p.actor.name} acts (Initiative ${p.score}).`], []);
|
|
339
|
+
}
|
|
340
|
+
movementLine(actor) {
|
|
341
|
+
const left = actor.movementLeftMeters;
|
|
342
|
+
const walkLeft = Math.max(0, actor.walkRateMeters - actor.movedMetersThisTurn);
|
|
343
|
+
return `${left} m of movement left this turn (${walkLeft} m at a walk)`;
|
|
344
|
+
}
|
|
345
|
+
/** The human typed "end turn" (or lost the phase -- fled, dropped). */
|
|
346
|
+
async endPhase(actor) {
|
|
347
|
+
const p = this.participantOf(actor);
|
|
348
|
+
if (!p || this.phaseActor !== actor)
|
|
349
|
+
return;
|
|
350
|
+
this.closePhase(p);
|
|
351
|
+
this.scene.updateStatus();
|
|
352
|
+
await this.run();
|
|
353
|
+
}
|
|
354
|
+
/** Delay (p.161): step back and act after the others this pass. */
|
|
355
|
+
async delayPhase(actor) {
|
|
356
|
+
const p = this.participantOf(actor);
|
|
357
|
+
if (!p || this.phaseActor !== actor)
|
|
358
|
+
return;
|
|
359
|
+
// Only meaningful if someone else is still to act this pass.
|
|
360
|
+
const others = this.order().filter(o => o !== p && this.present(o) && !o.acted && o.score > 0);
|
|
361
|
+
p.delayed = true;
|
|
362
|
+
this.phaseActor = undefined;
|
|
363
|
+
this.budget = undefined;
|
|
364
|
+
if (others.length === 0) {
|
|
365
|
+
// Nobody left to wait for -- the delayed phase is simply now.
|
|
366
|
+
p.delayed = false;
|
|
367
|
+
await this.beginPhase(p);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
this.scene.updateStatus();
|
|
371
|
+
await this.run();
|
|
372
|
+
}
|
|
373
|
+
closePhase(p) {
|
|
374
|
+
// A progressive-recoil phase without a shot resets the count (p.175).
|
|
375
|
+
if (!p.actor.firedThisPhase)
|
|
376
|
+
p.actor.recoilRoundsFired = 0;
|
|
377
|
+
p.acted = true;
|
|
378
|
+
p.delayed = false;
|
|
379
|
+
this.phaseActor = undefined;
|
|
380
|
+
this.budget = undefined;
|
|
381
|
+
this.scene.notifyExchangeSettled(this.participants.map(x => x.actor));
|
|
382
|
+
}
|
|
383
|
+
/** The phase actor has left the room or fallen: the pass moves on. */
|
|
384
|
+
async dropPhaseActor(actor) {
|
|
385
|
+
if (this.phaseActor !== actor)
|
|
386
|
+
return;
|
|
387
|
+
const p = this.participantOf(actor);
|
|
388
|
+
if (p)
|
|
389
|
+
this.closePhase(p);
|
|
390
|
+
await this.run();
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* An actor walks out of the room (commands/go.ts): they are no longer
|
|
394
|
+
* a participant. If it was their phase the pass moves on; if nobody
|
|
395
|
+
* left is fighting anybody, the encounter ends. Synchronous by design
|
|
396
|
+
* -- the caller is mid-move -- so the continuation is kicked off, not
|
|
397
|
+
* awaited; with no human left in the room nothing it prints is seen.
|
|
398
|
+
*/
|
|
399
|
+
leave(actor) {
|
|
400
|
+
const p = this.participantOf(actor);
|
|
401
|
+
if (!p || this.ended)
|
|
402
|
+
return;
|
|
403
|
+
if (this.phaseActor === actor)
|
|
404
|
+
this.closePhase(p);
|
|
405
|
+
this.participants = this.participants.filter(x => x !== p);
|
|
406
|
+
actor.clearAim();
|
|
407
|
+
if (!this.stillHostile()) {
|
|
408
|
+
void this.end('left');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
void this.run();
|
|
412
|
+
}
|
|
413
|
+
/** p.159: everyone drops 10; anyone above zero goes again. */
|
|
414
|
+
async endPass() {
|
|
415
|
+
for (const p of this.participants) {
|
|
416
|
+
p.score -= INITIATIVE_PASS_DROP;
|
|
417
|
+
p.acted = false;
|
|
418
|
+
p.delayed = false;
|
|
419
|
+
}
|
|
420
|
+
const again = this.participants.filter(p => this.present(p) && p.score > 0);
|
|
421
|
+
if (again.length > 0) {
|
|
422
|
+
this.pass += 1;
|
|
423
|
+
await this.announce([], [`Initiative Pass ${this.pass}: everyone -10 -> ${this.orderLine()}.`]);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
await this.newTurn();
|
|
427
|
+
}
|
|
428
|
+
/** p.159 step 5: a fresh Combat Turn -- fresh rolls, fresh movement. */
|
|
429
|
+
async newTurn() {
|
|
430
|
+
if (this.turn >= CombatEncounter.MAX_TURNS) {
|
|
431
|
+
await this.end('burnout');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.turn += 1;
|
|
435
|
+
this.pass = 1;
|
|
436
|
+
const metaLines = [`Combat Turn ${this.turn} -- new Initiative (p.159):`];
|
|
437
|
+
for (const p of this.participants) {
|
|
438
|
+
p.roll = rollInitiativeScore(p.actor.getInitiativeAttribute(), p.actor.getInitiativeDice());
|
|
439
|
+
p.score = p.roll.score + (p.actor.rallyInitBonus ?? 0);
|
|
440
|
+
p.actor.rallyInitBonus = 0;
|
|
441
|
+
p.woundAtSync = p.actor.woundModifier;
|
|
442
|
+
p.acted = false;
|
|
443
|
+
p.delayed = false;
|
|
444
|
+
p.surprised = false;
|
|
445
|
+
p.actor.resetMovementTurn();
|
|
446
|
+
if (this.present(p))
|
|
447
|
+
metaLines.push(` ${p.actor.name}: ${formatInitiative(p.roll)}`);
|
|
448
|
+
}
|
|
449
|
+
await this.announce([`Combat Turn ${this.turn} -- ${this.orderLine()}.`], metaLines);
|
|
450
|
+
}
|
|
451
|
+
// ------------------------------------------------- score adjustments ----
|
|
452
|
+
/**
|
|
453
|
+
* p.160: wound modifiers hit the Initiative Score the moment the wound
|
|
454
|
+
* lands. The attribute already carries woundModifier, so the score
|
|
455
|
+
* moves by the DIFFERENCE since the last look.
|
|
456
|
+
*/
|
|
457
|
+
syncWounds() {
|
|
458
|
+
for (const p of this.participants) {
|
|
459
|
+
const now = p.actor.woundModifier;
|
|
460
|
+
if (now === p.woundAtSync)
|
|
461
|
+
continue;
|
|
462
|
+
p.score += now - p.woundAtSync;
|
|
463
|
+
p.woundAtSync = now;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* An Interrupt Action (p.167-168): affordable only with enough score
|
|
468
|
+
* left -- "his Initiative Score is already in the negatives so he
|
|
469
|
+
* can't" (p.191). Returns the refusal, or undefined after paying.
|
|
470
|
+
*/
|
|
471
|
+
spendInterrupt(actor, cost, label) {
|
|
472
|
+
const p = this.participantOf(actor);
|
|
473
|
+
if (!p)
|
|
474
|
+
return `${actor.name} isn't in this fight.`;
|
|
475
|
+
if (p.surprised)
|
|
476
|
+
return `${actor.name} is surprised -- no interrupt actions until their next Action Phase (SR5 p.192).`;
|
|
477
|
+
if (p.score < cost) {
|
|
478
|
+
return p.score <= 0
|
|
479
|
+
? `${actor.name} has no Initiative Score left this turn -- ${label} needs ${cost} (SR5 p.168).`
|
|
480
|
+
: `${actor.name} has only ${p.score} Initiative left -- ${label} costs ${cost} (SR5 p.168).`;
|
|
481
|
+
}
|
|
482
|
+
p.score -= cost;
|
|
483
|
+
this.logger.meta(` ${actor.name}: ${label} (-${cost} Initiative -> ${p.score})`, undefined, { room: this.room.name });
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
/** Full Defense (p.191): -10, Willpower to every defense this turn. */
|
|
487
|
+
declareFullDefense(actor) {
|
|
488
|
+
const p = this.participantOf(actor);
|
|
489
|
+
if (!p)
|
|
490
|
+
return `You're not in a fight.`;
|
|
491
|
+
if (p.fullDefenseTurn === this.turn)
|
|
492
|
+
return `You're already on Full Defense this Combat Turn.`;
|
|
493
|
+
const refusal = this.spendInterrupt(actor, 10, 'Full Defense');
|
|
494
|
+
if (refusal)
|
|
495
|
+
return refusal;
|
|
496
|
+
p.fullDefenseTurn = this.turn;
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
// -------------------------------------------------------------- end ----
|
|
500
|
+
async end(reason) {
|
|
501
|
+
if (this.ended)
|
|
502
|
+
return;
|
|
503
|
+
this.ended = true;
|
|
504
|
+
this.phaseActor = undefined;
|
|
505
|
+
this.budget = undefined;
|
|
506
|
+
for (const p of this.participants) {
|
|
507
|
+
p.actor.clearAim();
|
|
508
|
+
p.actor.recoilRoundsFired = 0;
|
|
509
|
+
p.actor.firedThisPhase = false;
|
|
510
|
+
}
|
|
511
|
+
this.scene.endEncounter(this);
|
|
512
|
+
const line = reason === 'burnout'
|
|
513
|
+
? `The fight in ${this.room.name} burns out -- nobody can land the finishing blow.`
|
|
514
|
+
: reason === 'left'
|
|
515
|
+
? `The fight in ${this.room.name} is over -- nobody left to fight.`
|
|
516
|
+
: `The fight in ${this.room.name} is over.`;
|
|
517
|
+
await this.announce([line], []);
|
|
518
|
+
this.scene.updateStatus();
|
|
519
|
+
// Spirits whose services ran out depart once the dust settles.
|
|
520
|
+
this.scene.ownerGame?.settleSpiritServices?.();
|
|
521
|
+
}
|
|
522
|
+
// ------------------------------------------------------------ output ----
|
|
523
|
+
/** A human is present in the room to see it. */
|
|
524
|
+
witnessed() {
|
|
525
|
+
return this.scene.getPlayers().some(p => p.currentLocation === this.room);
|
|
526
|
+
}
|
|
527
|
+
async announce(lines, meta) {
|
|
528
|
+
if ((lines.length === 0 && meta.length === 0) || !this.witnessed())
|
|
529
|
+
return;
|
|
530
|
+
const scope = { room: this.room.name };
|
|
531
|
+
for (const m of meta)
|
|
532
|
+
this.logger.meta(m, undefined, scope);
|
|
533
|
+
if (lines.length > 0)
|
|
534
|
+
this.logger.log(`\n${lines.join('\n')}`, scope);
|
|
535
|
+
this.scene.updateStatus();
|
|
536
|
+
const beat = process.env.MAKA_NO_BEATS === '1' ? 0 : CombatEncounter.BEAT_MS;
|
|
537
|
+
if (beat > 0)
|
|
538
|
+
await new Promise(resolve => setTimeout(resolve, beat));
|
|
539
|
+
}
|
|
540
|
+
/** The initiative tracker, for the "initiative" verb. */
|
|
541
|
+
trackerLines() {
|
|
542
|
+
const out = [`Combat Turn ${this.turn}, Initiative Pass ${this.pass}${this.phaseActor ? ` -- ${this.phaseActor.name}'s Action Phase` : ''}`];
|
|
543
|
+
for (const p of this.order()) {
|
|
544
|
+
const state = !this.present(p) ? (p.actor.isIncapacitated() ? 'down' : 'gone')
|
|
545
|
+
: p.actor.surrendered ? 'surrendered'
|
|
546
|
+
: p.acted ? 'acted' : p.delayed ? 'delaying' : p.score > 0 ? 'to act' : 'spent';
|
|
547
|
+
const marks = [
|
|
548
|
+
p.surprised ? 'surprised' : '',
|
|
549
|
+
p.fullDefenseTurn === this.turn ? 'full defense' : '',
|
|
550
|
+
p.actor.aimBonus > 0 ? `aiming +${p.actor.aimBonus}` : '',
|
|
551
|
+
].filter(Boolean).join(', ');
|
|
552
|
+
out.push(` ${p.actor === this.phaseActor ? '>' : ' '} ${p.actor.name.padEnd(18)} ${String(p.score).padStart(3)} ${state}${marks ? ` (${marks})` : ''}`);
|
|
553
|
+
}
|
|
554
|
+
if (this.budget && this.phaseActor) {
|
|
555
|
+
out.push(` Actions: ${this.budget.describe()} -- spent: ${this.budget.ledger()}.`);
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
/** The HUD line for one player. */
|
|
560
|
+
hudLine(player) {
|
|
561
|
+
const p = this.participantOf(player);
|
|
562
|
+
if (!p)
|
|
563
|
+
return undefined;
|
|
564
|
+
if (this.phaseActor === player && this.budget) {
|
|
565
|
+
return `{cyan-fg}⏱ YOUR PHASE{/cyan-fg} T${this.turn}/P${this.pass} Init ${p.score} · ${this.budget.describe()} · ${player.movementLeftMeters} m · "end turn"`;
|
|
566
|
+
}
|
|
567
|
+
const who = this.phaseActor ? `${this.phaseActor.name} acting` : 'resolving';
|
|
568
|
+
return `⏱ COMBAT T${this.turn}/P${this.pass} Init ${p.score} · ${who}${p.fullDefenseTurn === this.turn ? ' · FULL DEFENSE' : ''}`;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
//# sourceMappingURL=combat-turn.js.map
|
|
@@ -34,6 +34,18 @@ export function scriptRolls(hits) {
|
|
|
34
34
|
export function scriptedRollsRemaining() {
|
|
35
35
|
return scriptedHits?.length ?? 0;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* THE INITIATIVE SEAM, the summed-dice twin of scriptRolls: each entry
|
|
39
|
+
* is the dice TOTAL the next rollInitiativeScore call shows (the
|
|
40
|
+
* attribute is still added on top). A Combat Turn test that wants "the
|
|
41
|
+
* guard acts before the runner" pins two totals rather than spying on
|
|
42
|
+
* Math.random -- which would also pin every pool rolled in between.
|
|
43
|
+
* Engine code never calls this.
|
|
44
|
+
*/
|
|
45
|
+
let scriptedInitiative;
|
|
46
|
+
export function scriptInitiative(totals) {
|
|
47
|
+
scriptedInitiative = totals ? [...totals] : undefined;
|
|
48
|
+
}
|
|
37
49
|
export function rollPool(pool, limit, opts = {}) {
|
|
38
50
|
const size = Math.max(0, Math.floor(pool));
|
|
39
51
|
const scripted = scriptedHits && scriptedHits.length > 0 ? scriptedHits.shift() : undefined;
|
|
@@ -106,6 +118,14 @@ export const MAX_INITIATIVE_DICE = 5;
|
|
|
106
118
|
export function rollInitiativeScore(attribute, dice) {
|
|
107
119
|
const attr = Math.max(0, Math.floor(attribute));
|
|
108
120
|
const size = Math.min(MAX_INITIATIVE_DICE, Math.max(0, Math.floor(dice)));
|
|
121
|
+
const scripted = scriptedInitiative && scriptedInitiative.length > 0 ? scriptedInitiative.shift() : undefined;
|
|
122
|
+
if (scripted !== undefined) {
|
|
123
|
+
// Faces that add up to the scripted total (clamped to what the dice
|
|
124
|
+
// can show); the total is what every caller reads.
|
|
125
|
+
const total = Math.max(size, Math.min(size * 6, Math.floor(scripted)));
|
|
126
|
+
const rolls = Array.from({ length: size }, (_, i) => Math.min(6, Math.max(1, Math.floor(total / size) + (i < total % size ? 1 : 0))));
|
|
127
|
+
return { attribute: attr, dice: size, rolls, total: size === 0 ? 0 : total, score: attr + (size === 0 ? 0 : total) };
|
|
128
|
+
}
|
|
109
129
|
const rolls = Array.from({ length: size }, () => 1 + Math.floor(Math.random() * 6));
|
|
110
130
|
const total = rolls.reduce((sum, r) => sum + r, 0);
|
|
111
131
|
return { attribute: attr, dice: size, rolls, total, score: attr + total };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Player } from '../models/player.js';
|
|
2
|
+
import { rollPool, formatRoll } from './dice.js';
|
|
3
|
+
import { physicalLimit } from './grapple.js';
|
|
4
|
+
import { hint } from './hints.js';
|
|
5
|
+
import { billAction, encounterOf } from './action-cost.js';
|
|
6
|
+
/**
|
|
7
|
+
* GROUND COSTS IN A FIGHT (SR5 p.161-162, RAG-checked 2026-09-06).
|
|
8
|
+
*
|
|
9
|
+
* The rates are the Player's (walkRateMeters, runRateMeters, the
|
|
10
|
+
* whole-turn movedMetersThisTurn); this is the one place a crossing is
|
|
11
|
+
* BILLED against them, shared by move.ts, the melee reach in attack.ts,
|
|
12
|
+
* and the NPC brain so the three cannot drift:
|
|
13
|
+
*
|
|
14
|
+
* - Up to the Walk Rate, movement is free of actions.
|
|
15
|
+
* - "As soon as a character exceeds their Walk Rate they are
|
|
16
|
+
* considered Running until the end of the Combat Turn" -- and
|
|
17
|
+
* "Running characters must use a Free Action in each Initiative
|
|
18
|
+
* Pass they are considered running" (p.162). The first crossing
|
|
19
|
+
* that breaks the Walk Rate spends the phase's Free Action; the
|
|
20
|
+
* encounter charges it again at the start of every later phase in
|
|
21
|
+
* the turn (combat-turn.ts beginPhase). No Free Action left means
|
|
22
|
+
* no breaking into a run this phase.
|
|
23
|
+
* - Past the Run Rate needs a SPRINT: a Complex Action, Running +
|
|
24
|
+
* Strength [Physical], +2 m per hit (+1 for dwarfs and trolls,
|
|
25
|
+
* which this engine has no character for -- Player.SPRINT_METERS_PER_HIT).
|
|
26
|
+
*
|
|
27
|
+
* Out of combat everything here is a no-op returning undefined: a walk
|
|
28
|
+
* across a bar is not a Combat Turn.
|
|
29
|
+
*/
|
|
30
|
+
export function spendMovementMeters(scene, actor, meters, mode, logger) {
|
|
31
|
+
const enc = encounterOf(scene, actor);
|
|
32
|
+
if (!enc || enc.ended)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (meters <= 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (!enc.isPhaseOf(actor)) {
|
|
37
|
+
return `It's not your Action Phase -- movement happens on your turn (SR5 p.161).`;
|
|
38
|
+
}
|
|
39
|
+
// THE SPRINT (p.162): Complex Action, rolled when asked for, never
|
|
40
|
+
// applied silently to cover a shortfall.
|
|
41
|
+
if (mode === 'sprint' && actor.sprintMetersThisTurn === 0) {
|
|
42
|
+
const bill = billAction(scene, actor, 'complex', 'Sprint');
|
|
43
|
+
if (bill)
|
|
44
|
+
return bill;
|
|
45
|
+
const skill = actor.skillRating('running');
|
|
46
|
+
const roll = rollPool(Math.max(1, skill + actor.strength + actor.bonus('athletics') + actor.woundModifier), physicalLimit(actor));
|
|
47
|
+
const bought = roll.hits * Player.SPRINT_METERS_PER_HIT;
|
|
48
|
+
actor.sprintMetersThisTurn = bought;
|
|
49
|
+
logger?.meta(`Sprint (Running + Strength [Physical]): ${formatRoll(roll)} -- +${bought} m this turn`);
|
|
50
|
+
}
|
|
51
|
+
const left = actor.movementLeftMeters;
|
|
52
|
+
if (meters > left) {
|
|
53
|
+
return left <= 0
|
|
54
|
+
? `You've covered all your ground this Combat Turn (${actor.runRateMeters} m at a dead run). Nothing left in the legs until the next turn.`
|
|
55
|
+
: `That's ${meters.toFixed(1)} m and you have ${left.toFixed(1)} m left this turn (Run Rate ${actor.runRateMeters} m).${hint(` ("sprint" buys more ground -- a Complex Action, Running + Strength.)`)}`;
|
|
56
|
+
}
|
|
57
|
+
// BREAKING INTO A RUN costs the phase's Free Action (p.162).
|
|
58
|
+
const wasRunning = actor.isRunningThisTurn;
|
|
59
|
+
const willRun = actor.movedMetersThisTurn + meters > actor.walkRateMeters;
|
|
60
|
+
if (willRun && !wasRunning) {
|
|
61
|
+
const bill = billAction(scene, actor, 'free', 'Run');
|
|
62
|
+
if (bill) {
|
|
63
|
+
const walkLeft = Math.max(0, actor.walkRateMeters - actor.movedMetersThisTurn);
|
|
64
|
+
return `${bill} Running takes a Free Action (SR5 p.162) -- you can still walk ${walkLeft.toFixed(1)} m.`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
actor.movedMetersThisTurn += meters;
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** A line for the player after a crossing that changed their pace. */
|
|
71
|
+
export function paceNote(scene, actor, wasRunning) {
|
|
72
|
+
const enc = encounterOf(scene, actor);
|
|
73
|
+
if (!enc || enc.ended)
|
|
74
|
+
return '';
|
|
75
|
+
if (actor.isRunningThisTurn && !wasRunning) {
|
|
76
|
+
return ` You're RUNNING now -- -2 dice on everything else this Combat Turn, harder to hit at range (SR5 p.162).`;
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=movement-cost.js.map
|