@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.
- 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 +56 -59
- package/bundle/typescript/src/commands/game/sideQuest/commands/posture.js +23 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +31 -10
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/summon.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/unequip.js +6 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +8 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +17 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +24 -1
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +2 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +21 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +44 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +33 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +98 -0
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-budget.js +87 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +66 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +99 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +568 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/companion-heel.js +67 -9
- 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
|
@@ -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,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
14
|
-
|
|
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 };
|