@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
@@ -0,0 +1,568 @@
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, for the autopilot hook. */
26
+ onHumanPhase;
27
+ constructor(scene, logger, room) {
28
+ this.scene = scene;
29
+ this.logger = logger;
30
+ this.room = room;
31
+ }
32
+ // ------------------------------------------------------------ setup ----
33
+ /**
34
+ * Opens the encounter: everyone rolls (surprise first, if it was an
35
+ * ambush), the order is announced, and the first pass runs until it
36
+ * reaches a human's Action Phase -- or the fight is already over.
37
+ */
38
+ async start(members, opts) {
39
+ this.turn = 1;
40
+ this.pass = 1;
41
+ for (const m of members)
42
+ this.addParticipant(m);
43
+ if (opts.target)
44
+ this.noteAttack(opts.aggressor, opts.target);
45
+ this.seedHostility();
46
+ const metaLines = ['Initiative (SR5 p.159):'];
47
+ const worldLines = [];
48
+ // SURPRISE (p.192): the ambushed side rolls Reaction + Intuition (3).
49
+ // Failure: -10 Initiative and no defense against the surprisers
50
+ // until their next Action Phase. The aggressor never rolls -- an
51
+ // ambusher is by definition not surprised (p.193).
52
+ if (opts.ambush) {
53
+ for (const p of this.participants) {
54
+ if (p.actor === opts.aggressor)
55
+ continue;
56
+ if (this.sameSide(p.actor, opts.aggressor))
57
+ continue;
58
+ const alerted = this.isNpc(p.actor) && p.actor.hostile ? 3 : 0;
59
+ const pool = Math.max(1, p.actor.reaction + p.actor.intuition + alerted + p.actor.woundModifier);
60
+ const roll = rollPool(pool);
61
+ if (roll.hits >= 3) {
62
+ metaLines.push(` ${p.actor.name} Surprise (Reaction + Intuition, 3): ${formatRoll(roll)} -- ready.`);
63
+ }
64
+ else {
65
+ p.surprised = true;
66
+ p.score -= INITIATIVE_PASS_DROP;
67
+ metaLines.push(` ${p.actor.name} Surprise (Reaction + Intuition, 3): ${formatRoll(roll)} -- SURPRISED (-10, no defense this pass).`);
68
+ worldLines.push(`${p.actor.name} is caught flat-footed.`);
69
+ }
70
+ }
71
+ }
72
+ for (const p of this.order()) {
73
+ metaLines.push(` ${p.actor.name}: ${formatInitiative(p.roll)}${p.score !== p.roll.score ? ` -> ${p.score}` : ''}`);
74
+ }
75
+ worldLines.push(`Combat Turn ${this.turn} -- ${this.orderLine()}.`);
76
+ this.scene.addWorldEvent(`A fight broke out in ${this.room.name}: ${this.participants.map(p => p.actor.name).join(', ')}.`);
77
+ await this.announce(worldLines, metaLines);
78
+ await this.run();
79
+ }
80
+ addParticipant(actor) {
81
+ const existing = this.participantOf(actor);
82
+ if (existing)
83
+ return existing;
84
+ const roll = rollInitiativeScore(actor.getInitiativeAttribute(), actor.getInitiativeDice());
85
+ // Rally (commands/lead.ts) is a banked +score bonus, consumed here;
86
+ // Combat Paralysis (p.80) halves the first score of a fresh fight.
87
+ let score = roll.score + (actor.rallyInitBonus ?? 0);
88
+ actor.rallyInitBonus = 0;
89
+ if (actor.paralysisFreeze) {
90
+ actor.paralysisFreeze = false;
91
+ score = Math.ceil(score / 2);
92
+ }
93
+ actor.resetMovementTurn();
94
+ actor.clearAim();
95
+ const p = {
96
+ actor, roll, score, woundAtSync: actor.woundModifier,
97
+ acted: false, delayed: false, surprised: false,
98
+ };
99
+ this.participants.push(p);
100
+ return p;
101
+ }
102
+ /**
103
+ * Someone walks into a running fight (p.160 Changing Initiative):
104
+ * roll as normal, minus 10 per pass already gone this turn.
105
+ */
106
+ join(actor) {
107
+ const p = this.addParticipant(actor);
108
+ const late = (this.pass - 1) * INITIATIVE_PASS_DROP;
109
+ if (late > 0)
110
+ p.score -= late;
111
+ this.logger.write(`Encounter ${this.room.name}: ${actor.name} joins at ${p.score} (pass ${this.pass}).`);
112
+ return p;
113
+ }
114
+ /**
115
+ * The seeded hostilities: every hostile NPC against every member of the
116
+ * party present; every party member against whoever their master's
117
+ * opponent is. Attacks add pairs as they happen (noteAttack).
118
+ */
119
+ seedHostility() {
120
+ const party = this.participants.filter(p => this.isPartySide(p.actor));
121
+ const street = this.participants.filter(p => !this.isPartySide(p.actor));
122
+ for (const s of street) {
123
+ if (!(this.isNpc(s.actor) && s.actor.hostile))
124
+ continue;
125
+ for (const p of party)
126
+ this.notePair(s.actor, p.actor);
127
+ }
128
+ for (const p of this.participants) {
129
+ const opp = p.actor.combatOpponent;
130
+ if (opp && this.participantOf(opp))
131
+ this.notePair(p.actor, opp);
132
+ }
133
+ }
134
+ // ---------------------------------------------------------- queries ----
135
+ participantOf(actor) {
136
+ return this.participants.find(p => p.actor === actor);
137
+ }
138
+ has(actor) {
139
+ return this.participantOf(actor) !== undefined;
140
+ }
141
+ isPhaseOf(actor) {
142
+ return !this.ended && this.phaseActor === actor;
143
+ }
144
+ budgetOf(actor) {
145
+ return this.isPhaseOf(actor) ? this.budget : undefined;
146
+ }
147
+ scoreOf(actor) {
148
+ return this.participantOf(actor)?.score ?? 0;
149
+ }
150
+ isSurprised(actor) {
151
+ return this.participantOf(actor)?.surprised === true;
152
+ }
153
+ fullDefenseActive(actor) {
154
+ return this.participantOf(actor)?.fullDefenseTurn === this.turn;
155
+ }
156
+ /** Present in the room, on their feet, still a participant. */
157
+ present(p) {
158
+ return p.actor.currentLocation === this.room && !p.actor.isIncapacitated();
159
+ }
160
+ isNpc(actor) {
161
+ return !this.scene.isHumanControlled(actor);
162
+ }
163
+ /** The party: humans and the shells bound to them. */
164
+ isPartySide(actor) {
165
+ if (this.scene.isHumanControlled(actor))
166
+ return true;
167
+ const ally = actor.allyOf;
168
+ return !!ally;
169
+ }
170
+ sameSide(a, b) {
171
+ return this.isPartySide(a) === this.isPartySide(b);
172
+ }
173
+ pairKey(a, b) {
174
+ return [a.name, b.name].sort().join('|');
175
+ }
176
+ notePair(a, b) {
177
+ if (a === b)
178
+ return;
179
+ const key = this.pairKey(a, b);
180
+ this.pairs.set(key, { a: a.name, b: b.name, notedAt: Date.now() });
181
+ }
182
+ /** An attack renews (or creates) the hostility between two actors. */
183
+ noteAttack(attacker, defender) {
184
+ if (!this.has(defender) && defender.currentLocation === this.room)
185
+ this.join(defender);
186
+ if (!this.has(attacker) && attacker.currentLocation === this.room)
187
+ this.join(attacker);
188
+ this.notePair(attacker, defender);
189
+ }
190
+ /**
191
+ * Is anyone still fighting anyone? A pair is live while both stand in
192
+ * the room, and neither has surrendered since the pair was last renewed
193
+ * by an attack -- hands up ends a fight unless somebody keeps shooting.
194
+ */
195
+ stillHostile() {
196
+ for (const pair of this.pairs.values()) {
197
+ const a = this.participants.find(p => p.actor.name === pair.a);
198
+ const b = this.participants.find(p => p.actor.name === pair.b);
199
+ if (!a || !b || !this.present(a) || !this.present(b))
200
+ continue;
201
+ if (this.surrenderBreaks(a.actor, pair) || this.surrenderBreaks(b.actor, pair))
202
+ continue;
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+ surrenderBreaks(actor, pair) {
208
+ if (!actor.surrendered) {
209
+ this.surrenderSeenAt.delete(actor.name);
210
+ return false;
211
+ }
212
+ let seen = this.surrenderSeenAt.get(actor.name);
213
+ if (seen === undefined) {
214
+ seen = Date.now();
215
+ this.surrenderSeenAt.set(actor.name, seen);
216
+ }
217
+ return pair.notedAt <= seen;
218
+ }
219
+ /** The other side, present and standing, for a given actor. */
220
+ enemiesOf(actor) {
221
+ const out = [];
222
+ for (const pair of this.pairs.values()) {
223
+ if (pair.a !== actor.name && pair.b !== actor.name)
224
+ continue;
225
+ const otherName = pair.a === actor.name ? pair.b : pair.a;
226
+ const other = this.participants.find(p => p.actor.name === otherName);
227
+ if (!other || !this.present(other))
228
+ continue;
229
+ if (this.surrenderBreaks(other.actor, pair))
230
+ continue;
231
+ out.push(other.actor);
232
+ }
233
+ return out;
234
+ }
235
+ // ------------------------------------------------------------ order ----
236
+ /** ERIC (p.159): Edge, Reaction, Intuition, then a coin -- decided once
237
+ * per participant so the order is stable across a pass. */
238
+ coin = new Map();
239
+ order() {
240
+ return [...this.participants].sort((x, y) => {
241
+ if (y.score !== x.score)
242
+ return y.score - x.score;
243
+ if (y.actor.edge !== x.actor.edge)
244
+ return y.actor.edge - x.actor.edge;
245
+ if (y.actor.reaction !== x.actor.reaction)
246
+ return y.actor.reaction - x.actor.reaction;
247
+ if (y.actor.intuition !== x.actor.intuition)
248
+ return y.actor.intuition - x.actor.intuition;
249
+ return this.coinFor(y) - this.coinFor(x);
250
+ });
251
+ }
252
+ coinFor(p) {
253
+ let c = this.coin.get(p.actor.name);
254
+ if (c === undefined) {
255
+ c = Math.random();
256
+ this.coin.set(p.actor.name, c);
257
+ }
258
+ return c;
259
+ }
260
+ orderLine() {
261
+ return this.order()
262
+ .filter(p => this.present(p))
263
+ .map(p => `${p.actor.name} ${p.score}`)
264
+ .join(', ');
265
+ }
266
+ /** The next participant owed an Action Phase this pass. */
267
+ nextActor() {
268
+ const ready = this.order().filter(p => this.present(p) && !p.acted && p.score > 0);
269
+ return ready.find(p => !p.delayed) ?? ready[0];
270
+ }
271
+ // ----------------------------------------------------- the pass loop ----
272
+ /**
273
+ * Advances the encounter until it needs a human (their Action Phase is
274
+ * live and waits for "end turn") or it is over. Re-entrant-safe: a
275
+ * nested call while a pass is already being driven returns at once.
276
+ */
277
+ async run() {
278
+ if (this.running || this.ended)
279
+ return;
280
+ this.running = true;
281
+ try {
282
+ while (!this.ended) {
283
+ this.syncWounds();
284
+ if (!this.stillHostile()) {
285
+ await this.end('over');
286
+ return;
287
+ }
288
+ if (this.phaseActor)
289
+ return; // a human phase is live
290
+ const next = this.nextActor();
291
+ if (!next) {
292
+ await this.endPass();
293
+ continue;
294
+ }
295
+ await this.beginPhase(next);
296
+ if (this.scene.isHumanControlled(next.actor))
297
+ return;
298
+ // An NPC's phase: the brain plays it, then the phase closes.
299
+ try {
300
+ await runNpcActionPhase(this, next.actor);
301
+ }
302
+ catch (err) {
303
+ this.logger.error(`Encounter: ${next.actor.name}'s phase failed: ${err}`);
304
+ }
305
+ this.closePhase(next);
306
+ }
307
+ }
308
+ finally {
309
+ this.running = false;
310
+ }
311
+ }
312
+ async beginPhase(p) {
313
+ this.phaseActor = p.actor;
314
+ this.budget = new ActionBudget();
315
+ p.surprised = false;
316
+ p.delayed = false;
317
+ p.actor.clearAim();
318
+ // Recoil resets the moment an Action Phase passes without firing --
319
+ // tracked by the phase, settled by attack.ts (p.175-176).
320
+ p.actor.firedThisPhase = false;
321
+ // p.162: "Running characters must use a Free Action in each
322
+ // Initiative Pass they are considered running." Already past the
323
+ // Walk Rate this turn means the phase opens with its Free Action
324
+ // spent on staying at a run.
325
+ if (p.actor.isRunningThisTurn)
326
+ this.budget.spend('free', 'Run (still running)');
327
+ const human = this.scene.isHumanControlled(p.actor);
328
+ if (human) {
329
+ 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.`;
330
+ this.logger.log(`\n{cyan-fg}${budgetLine}{/cyan-fg}`, { actor: p.actor.name });
331
+ this.scene.updateStatus();
332
+ this.onHumanPhase?.(p.actor, this);
333
+ return;
334
+ }
335
+ await this.announce([`${p.actor.name} acts (Initiative ${p.score}).`], []);
336
+ }
337
+ movementLine(actor) {
338
+ const left = actor.movementLeftMeters;
339
+ const walkLeft = Math.max(0, actor.walkRateMeters - actor.movedMetersThisTurn);
340
+ return `${left} m of movement left this turn (${walkLeft} m at a walk)`;
341
+ }
342
+ /** The human typed "end turn" (or lost the phase -- fled, dropped). */
343
+ async endPhase(actor) {
344
+ const p = this.participantOf(actor);
345
+ if (!p || this.phaseActor !== actor)
346
+ return;
347
+ this.closePhase(p);
348
+ this.scene.updateStatus();
349
+ await this.run();
350
+ }
351
+ /** Delay (p.161): step back and act after the others this pass. */
352
+ async delayPhase(actor) {
353
+ const p = this.participantOf(actor);
354
+ if (!p || this.phaseActor !== actor)
355
+ return;
356
+ // Only meaningful if someone else is still to act this pass.
357
+ const others = this.order().filter(o => o !== p && this.present(o) && !o.acted && o.score > 0);
358
+ p.delayed = true;
359
+ this.phaseActor = undefined;
360
+ this.budget = undefined;
361
+ if (others.length === 0) {
362
+ // Nobody left to wait for -- the delayed phase is simply now.
363
+ p.delayed = false;
364
+ await this.beginPhase(p);
365
+ return;
366
+ }
367
+ this.scene.updateStatus();
368
+ await this.run();
369
+ }
370
+ closePhase(p) {
371
+ // A progressive-recoil phase without a shot resets the count (p.175).
372
+ if (!p.actor.firedThisPhase)
373
+ p.actor.recoilRoundsFired = 0;
374
+ p.acted = true;
375
+ p.delayed = false;
376
+ this.phaseActor = undefined;
377
+ this.budget = undefined;
378
+ this.scene.notifyExchangeSettled(this.participants.map(x => x.actor));
379
+ }
380
+ /** The phase actor has left the room or fallen: the pass moves on. */
381
+ async dropPhaseActor(actor) {
382
+ if (this.phaseActor !== actor)
383
+ return;
384
+ const p = this.participantOf(actor);
385
+ if (p)
386
+ this.closePhase(p);
387
+ await this.run();
388
+ }
389
+ /**
390
+ * An actor walks out of the room (commands/go.ts): they are no longer
391
+ * a participant. If it was their phase the pass moves on; if nobody
392
+ * left is fighting anybody, the encounter ends. Synchronous by design
393
+ * -- the caller is mid-move -- so the continuation is kicked off, not
394
+ * awaited; with no human left in the room nothing it prints is seen.
395
+ */
396
+ leave(actor) {
397
+ const p = this.participantOf(actor);
398
+ if (!p || this.ended)
399
+ return;
400
+ if (this.phaseActor === actor)
401
+ this.closePhase(p);
402
+ this.participants = this.participants.filter(x => x !== p);
403
+ actor.clearAim();
404
+ if (!this.stillHostile()) {
405
+ void this.end('left');
406
+ return;
407
+ }
408
+ void this.run();
409
+ }
410
+ /** p.159: everyone drops 10; anyone above zero goes again. */
411
+ async endPass() {
412
+ for (const p of this.participants) {
413
+ p.score -= INITIATIVE_PASS_DROP;
414
+ p.acted = false;
415
+ p.delayed = false;
416
+ }
417
+ const again = this.participants.filter(p => this.present(p) && p.score > 0);
418
+ if (again.length > 0) {
419
+ this.pass += 1;
420
+ await this.announce([], [`Initiative Pass ${this.pass}: everyone -10 -> ${this.orderLine()}.`]);
421
+ return;
422
+ }
423
+ await this.newTurn();
424
+ }
425
+ /** p.159 step 5: a fresh Combat Turn -- fresh rolls, fresh movement. */
426
+ async newTurn() {
427
+ if (this.turn >= CombatEncounter.MAX_TURNS) {
428
+ await this.end('burnout');
429
+ return;
430
+ }
431
+ this.turn += 1;
432
+ this.pass = 1;
433
+ const metaLines = [`Combat Turn ${this.turn} -- new Initiative (p.159):`];
434
+ for (const p of this.participants) {
435
+ p.roll = rollInitiativeScore(p.actor.getInitiativeAttribute(), p.actor.getInitiativeDice());
436
+ p.score = p.roll.score + (p.actor.rallyInitBonus ?? 0);
437
+ p.actor.rallyInitBonus = 0;
438
+ p.woundAtSync = p.actor.woundModifier;
439
+ p.acted = false;
440
+ p.delayed = false;
441
+ p.surprised = false;
442
+ p.actor.resetMovementTurn();
443
+ if (this.present(p))
444
+ metaLines.push(` ${p.actor.name}: ${formatInitiative(p.roll)}`);
445
+ }
446
+ await this.announce([`Combat Turn ${this.turn} -- ${this.orderLine()}.`], metaLines);
447
+ }
448
+ // ------------------------------------------------- score adjustments ----
449
+ /**
450
+ * p.160: wound modifiers hit the Initiative Score the moment the wound
451
+ * lands. The attribute already carries woundModifier, so the score
452
+ * moves by the DIFFERENCE since the last look.
453
+ */
454
+ syncWounds() {
455
+ for (const p of this.participants) {
456
+ const now = p.actor.woundModifier;
457
+ if (now === p.woundAtSync)
458
+ continue;
459
+ p.score += now - p.woundAtSync;
460
+ p.woundAtSync = now;
461
+ }
462
+ }
463
+ /**
464
+ * An Interrupt Action (p.167-168): affordable only with enough score
465
+ * left -- "his Initiative Score is already in the negatives so he
466
+ * can't" (p.191). Returns the refusal, or undefined after paying.
467
+ */
468
+ spendInterrupt(actor, cost, label) {
469
+ const p = this.participantOf(actor);
470
+ if (!p)
471
+ return `${actor.name} isn't in this fight.`;
472
+ if (p.surprised)
473
+ return `${actor.name} is surprised -- no interrupt actions until their next Action Phase (SR5 p.192).`;
474
+ if (p.score < cost) {
475
+ return p.score <= 0
476
+ ? `${actor.name} has no Initiative Score left this turn -- ${label} needs ${cost} (SR5 p.168).`
477
+ : `${actor.name} has only ${p.score} Initiative left -- ${label} costs ${cost} (SR5 p.168).`;
478
+ }
479
+ p.score -= cost;
480
+ this.logger.meta(` ${actor.name}: ${label} (-${cost} Initiative -> ${p.score})`, undefined, { room: this.room.name });
481
+ return undefined;
482
+ }
483
+ /** Full Defense (p.191): -10, Willpower to every defense this turn. */
484
+ declareFullDefense(actor) {
485
+ const p = this.participantOf(actor);
486
+ if (!p)
487
+ return `You're not in a fight.`;
488
+ if (p.fullDefenseTurn === this.turn)
489
+ return `You're already on Full Defense this Combat Turn.`;
490
+ const refusal = this.spendInterrupt(actor, 10, 'Full Defense');
491
+ if (refusal)
492
+ return refusal;
493
+ p.fullDefenseTurn = this.turn;
494
+ return undefined;
495
+ }
496
+ // -------------------------------------------------------------- end ----
497
+ async end(reason) {
498
+ if (this.ended)
499
+ return;
500
+ this.ended = true;
501
+ this.phaseActor = undefined;
502
+ this.budget = undefined;
503
+ for (const p of this.participants) {
504
+ p.actor.clearAim();
505
+ p.actor.recoilRoundsFired = 0;
506
+ p.actor.firedThisPhase = false;
507
+ }
508
+ this.scene.endEncounter(this);
509
+ const line = reason === 'burnout'
510
+ ? `The fight in ${this.room.name} burns out -- nobody can land the finishing blow.`
511
+ : reason === 'left'
512
+ ? `The fight in ${this.room.name} is over -- nobody left to fight.`
513
+ : `The fight in ${this.room.name} is over.`;
514
+ await this.announce([line], []);
515
+ this.scene.updateStatus();
516
+ // Spirits whose services ran out depart once the dust settles.
517
+ this.scene.ownerGame?.settleSpiritServices?.();
518
+ }
519
+ // ------------------------------------------------------------ output ----
520
+ /** A human is present in the room to see it. */
521
+ witnessed() {
522
+ return this.scene.getPlayers().some(p => p.currentLocation === this.room);
523
+ }
524
+ async announce(lines, meta) {
525
+ if ((lines.length === 0 && meta.length === 0) || !this.witnessed())
526
+ return;
527
+ const scope = { room: this.room.name };
528
+ for (const m of meta)
529
+ this.logger.meta(m, undefined, scope);
530
+ if (lines.length > 0)
531
+ this.logger.log(`\n${lines.join('\n')}`, scope);
532
+ this.scene.updateStatus();
533
+ const beat = process.env.MAKA_NO_BEATS === '1' ? 0 : CombatEncounter.BEAT_MS;
534
+ if (beat > 0)
535
+ await new Promise(resolve => setTimeout(resolve, beat));
536
+ }
537
+ /** The initiative tracker, for the "initiative" verb. */
538
+ trackerLines() {
539
+ const out = [`Combat Turn ${this.turn}, Initiative Pass ${this.pass}${this.phaseActor ? ` -- ${this.phaseActor.name}'s Action Phase` : ''}`];
540
+ for (const p of this.order()) {
541
+ const state = !this.present(p) ? (p.actor.isIncapacitated() ? 'down' : 'gone')
542
+ : p.actor.surrendered ? 'surrendered'
543
+ : p.acted ? 'acted' : p.delayed ? 'delaying' : p.score > 0 ? 'to act' : 'spent';
544
+ const marks = [
545
+ p.surprised ? 'surprised' : '',
546
+ p.fullDefenseTurn === this.turn ? 'full defense' : '',
547
+ p.actor.aimBonus > 0 ? `aiming +${p.actor.aimBonus}` : '',
548
+ ].filter(Boolean).join(', ');
549
+ out.push(` ${p.actor === this.phaseActor ? '>' : ' '} ${p.actor.name.padEnd(18)} ${String(p.score).padStart(3)} ${state}${marks ? ` (${marks})` : ''}`);
550
+ }
551
+ if (this.budget && this.phaseActor) {
552
+ out.push(` Actions: ${this.budget.describe()} -- spent: ${this.budget.ledger()}.`);
553
+ }
554
+ return out;
555
+ }
556
+ /** The HUD line for one player. */
557
+ hudLine(player) {
558
+ const p = this.participantOf(player);
559
+ if (!p)
560
+ return undefined;
561
+ if (this.phaseActor === player && this.budget) {
562
+ return `{cyan-fg}⏱ YOUR PHASE{/cyan-fg} T${this.turn}/P${this.pass} Init ${p.score} · ${this.budget.describe()} · ${player.movementLeftMeters} m · "end turn"`;
563
+ }
564
+ const who = this.phaseActor ? `${this.phaseActor.name} acting` : 'resolving';
565
+ return `⏱ COMBAT T${this.turn}/P${this.pass} Init ${p.score} · ${who}${p.fullDefenseTurn === this.turn ? ' · FULL DEFENSE' : ''}`;
566
+ }
567
+ }
568
+ //# 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