@maka/maka-cli 5.133.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 (44) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/aim.js +60 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +132 -3
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/brandish.js +12 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +102 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/cover.js +24 -6
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/defend.js +74 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/delay.js +34 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +5 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/drop.js +5 -0
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +11 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/end-turn.js +58 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/equip.js +6 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +14 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +15 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/heal.js +6 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/initiative.js +20 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +56 -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 +17 -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/companion-heel.js +67 -9
  41. package/bundle/typescript/src/commands/game/sideQuest/utilities/dice.js +20 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/movement-cost.js +80 -0
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +122 -0
  44. 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
@@ -1,22 +1,80 @@
1
+ import { actorCell, standingRoomNear, onTheFloor, heelsTo } from './spots.js';
2
+ /** Chebyshev distance in cells, or undefined when either has no cell. */
3
+ function cellGap(room, a, b) {
4
+ const ca = actorCell(room, a);
5
+ const cb = actorCell(room, b);
6
+ if (!ca || !cb)
7
+ return undefined;
8
+ return Math.max(Math.abs(ca.x - cb.x), Math.abs(ca.y - cb.y), Math.abs(ca.z - cb.z));
9
+ }
10
+ /** Is the shell beside its master -- same room, same spot, and at most
11
+ * one square away (or on a room with no grid to measure)? */
12
+ export function atMastersSide(entry, master) {
13
+ const room = master.currentLocation;
14
+ if (entry.npc.currentLocation !== room || entry.npc.atSpot !== master.atSpot)
15
+ return false;
16
+ const gap = cellGap(room, entry.npc, master);
17
+ return gap === undefined || gap <= 1;
18
+ }
19
+ /** Put the shell on the nearest free square beside its master, at the
20
+ * master's spot. */
21
+ export function placeBeside(entry, master) {
22
+ const room = master.currentLocation;
23
+ if (entry.npc.currentLocation !== room)
24
+ entry.npc.currentLocation = room;
25
+ entry.npc.atSpot = master.atSpot;
26
+ const here = actorCell(room, master);
27
+ entry.npc.atCell = here ? standingRoomNear(room, here, entry.npc) : undefined;
28
+ }
1
29
  /**
2
30
  * BRING A COMPANION TO HEEL (player ruling 2026-09-06: "recall" is
3
31
  * "return to me" -- the frame comes to your side and follows again;
4
32
  * putting it away is "stow"). The shell crosses to the master's room
5
- * and spot, its cell is released so seating finds it a place beside
6
- * them, and any HOLDING flag clears so the next "go" drags it along
7
- * like any companion. Returns whether it had anywhere to come from.
33
+ * and spot, takes the nearest free square beside them, and any HOLDING
34
+ * flag clears so the next move drags it along like any companion.
35
+ * Returns whether it had anywhere to come from -- judged by CELLS, not
36
+ * spot names: a frame eight squares off on the same open floor shares
37
+ * the spot name and is not "at your side" (transcript 2026-09-06).
8
38
  *
9
39
  * One function, used by Game.heelCompanion and by the tests that pin
10
40
  * recall, so a mock Game and the real one cannot disagree about it.
11
41
  */
12
42
  export function bringToHeel(entry, master) {
13
- const here = master.currentLocation;
14
- const moved = entry.npc.currentLocation !== here || entry.npc.atSpot !== master.atSpot || !!entry.holding;
15
- if (entry.npc.currentLocation !== here)
16
- entry.npc.currentLocation = here;
17
- entry.npc.atSpot = master.atSpot;
18
- entry.npc.atCell = undefined;
43
+ const moved = !!entry.holding || !atMastersSide(entry, master);
44
+ placeBeside(entry, master);
19
45
  entry.holding = false;
20
46
  return { moved };
21
47
  }
48
+ /**
49
+ * COMPANIONS TRAIL THEIR MASTER ACROSS THE ROOM (playtest 2026-09-06:
50
+ * "It will follow to another room, but then just sit at the door").
51
+ * go.ts heels every shell on a ROOM change; nothing did on an in-room
52
+ * move, so a rigger who paced eight squares across the Neon Strip left
53
+ * the frame parked in the doorway. Called after every successful
54
+ * in-room move (move.ts): each shell of the mover's that is on the
55
+ * floor here, not HOLDING and not hired crew, is re-placed beside them.
56
+ * The frame answers to the meat body only (go.ts HEELS_ON): a rigger
57
+ * flying one frame does not drag the others around.
58
+ */
59
+ export function trailMaster(master, entries) {
60
+ if (master.plane !== 'meat')
61
+ return [];
62
+ const room = master.currentLocation;
63
+ const moved = [];
64
+ for (const entry of entries) {
65
+ if (entry.holding || entry.kind === 'ally')
66
+ continue;
67
+ if (!heelsTo(entry.npc, master))
68
+ continue;
69
+ if (entry.npc.currentLocation !== room || !onTheFloor(entry.npc))
70
+ continue;
71
+ if (entry.npc.isIncapacitated() || entry.npc.inExchange)
72
+ continue;
73
+ if (atMastersSide(entry, master))
74
+ continue;
75
+ placeBeside(entry, master);
76
+ moved.push(entry.npc);
77
+ }
78
+ return moved;
79
+ }
22
80
  //# sourceMappingURL=companion-heel.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 };