@zekel/warble-way-galaxy 0.0.0-stage → 0.1.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 (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +23 -2
  3. package/dist/ai.d.ts +12 -0
  4. package/dist/ai.js +961 -0
  5. package/dist/ai.js.map +1 -0
  6. package/dist/choices.d.ts +2 -0
  7. package/dist/choices.js +235 -0
  8. package/dist/choices.js.map +1 -0
  9. package/dist/data/abilities.d.ts +9 -0
  10. package/dist/data/abilities.js +41 -0
  11. package/dist/data/abilities.js.map +1 -0
  12. package/dist/data/archetypes.d.ts +16 -0
  13. package/dist/data/archetypes.js +42 -0
  14. package/dist/data/archetypes.js.map +1 -0
  15. package/dist/data/cards.d.ts +8 -0
  16. package/dist/data/cards.js +89 -0
  17. package/dist/data/cards.js.map +1 -0
  18. package/dist/data/habitats.d.ts +27 -0
  19. package/dist/data/habitats.js +93 -0
  20. package/dist/data/habitats.js.map +1 -0
  21. package/dist/data/levels.d.ts +2 -0
  22. package/dist/data/levels.js +19 -0
  23. package/dist/data/levels.js.map +1 -0
  24. package/dist/data/ruins.d.ts +27 -0
  25. package/dist/data/ruins.js +117 -0
  26. package/dist/data/ruins.js.map +1 -0
  27. package/dist/data/travelTables.d.ts +35 -0
  28. package/dist/data/travelTables.js +79 -0
  29. package/dist/data/travelTables.js.map +1 -0
  30. package/dist/engine.d.ts +193 -0
  31. package/dist/engine.js +3392 -0
  32. package/dist/engine.js.map +1 -0
  33. package/dist/index.d.ts +40 -0
  34. package/dist/index.js +773 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/legalMoves.d.ts +3 -0
  37. package/dist/legalMoves.js +547 -0
  38. package/dist/legalMoves.js.map +1 -0
  39. package/dist/lore/ancient-ruins.md +13 -0
  40. package/dist/lore/factions.md +18 -0
  41. package/dist/lore/galaxy-overview.md +17 -0
  42. package/dist/lore/grull.md +17 -0
  43. package/dist/lore/hexapods.md +15 -0
  44. package/dist/lore/humans.md +17 -0
  45. package/dist/lore/kralkin.md +17 -0
  46. package/dist/lore/organizations.md +15 -0
  47. package/dist/lore/story-seeds.md +27 -0
  48. package/dist/lore/xaxalon-4.md +15 -0
  49. package/dist/lore.d.ts +12 -0
  50. package/dist/lore.js +140 -0
  51. package/dist/lore.js.map +1 -0
  52. package/dist/rng.d.ts +25 -0
  53. package/dist/rng.js +53 -0
  54. package/dist/rng.js.map +1 -0
  55. package/dist/types.d.ts +548 -0
  56. package/dist/types.js +30 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/views.d.ts +2 -0
  59. package/dist/views.js +257 -0
  60. package/dist/views.js.map +1 -0
  61. package/dist/voice.d.ts +2 -0
  62. package/dist/voice.js +27 -0
  63. package/dist/voice.js.map +1 -0
  64. package/package.json +35 -3
package/dist/ai.js ADDED
@@ -0,0 +1,961 @@
1
+ import { GameError } from '@zekel/game-kit';
2
+ import { ABILITY_STAT, HAZARD_CONSEQUENCE_BY_STAT } from './data/abilities.js';
3
+ import { HABITATS } from './data/habitats.js';
4
+ import { CLUB_MISSIONS, DIAMOND_PIRATES, HEART_RECRUITS, SPADE_HAZARDS } from './data/travelTables.js';
5
+ import { anyoneHasEffective, bestForAbility, bestForStat, cellsWhere, describeMission, describeRecruit, inAttackRange, parseCellId, partyQualifiesFor, ruinAttackRange, ruinDistanceToGoal, ruinStepToward, unnamedCap, ruinGoalCell, ruinMapForLevel, ruinReachable, } from './engine.js';
6
+ import { getLegalMoves } from './legalMoves.js';
7
+ import { randomInt, rollDice } from './rng.js';
8
+ export function parseTier(difficulty) {
9
+ if (difficulty === 'easy')
10
+ return 'easy';
11
+ if (difficulty === 'hard' || difficulty === 'mcts')
12
+ return 'hard'; // no adversary → no search tier; hard is the anchor
13
+ return 'medium';
14
+ }
15
+ export function parseStrategy(s) {
16
+ if (!s)
17
+ return null;
18
+ const v = s.trim().toLowerCase();
19
+ if (['runner', 'cautious', 'survivor', 'trader'].includes(v))
20
+ return 'runner';
21
+ if (['privateer', 'pirate', 'fighter', 'hunter'].includes(v))
22
+ return 'privateer';
23
+ if (['delver', 'explorer', 'looter', 'digger'].includes(v))
24
+ return 'delver';
25
+ if (['ghost', 'stealth', 'sneak', 'infiltrator'].includes(v))
26
+ return 'ghost';
27
+ if (['dread', 'dreadpirate', 'corsair', 'terror'].includes(v))
28
+ return 'dread';
29
+ if (['tycoon', 'hoarder', 'merchant', 'magnate'].includes(v))
30
+ return 'tycoon';
31
+ return null;
32
+ }
33
+ /** THE evaluation function — the one "how well is this run going" number.
34
+ * Consumers: the tier self-test, the harness leaderScalar (same formula inlined
35
+ * in harness/analysis.ts — keep them in sync), and future search tiers.
36
+ * Reads the getScoreboard row shape. */
37
+ export function progressScore(row) {
38
+ return ((row.credits ?? 0) +
39
+ 150 * Math.max(0, (row.level ?? 1) - 1) +
40
+ 25 * (row.xp ?? 0) +
41
+ 40 * (row.crew ?? 0) +
42
+ 300 * Math.max(0, (row.habitat ?? 1) - 1) +
43
+ 50 * (row.ship_damage_level ?? 0) // 0 hobbled … 3 pristine
44
+ );
45
+ }
46
+ const AVG_D6 = 3.5;
47
+ function say(playerId, move, narration) {
48
+ return { move, narration: `${playerId} (bot) ${narration}` };
49
+ }
50
+ /** Expected margin of an Nd6+bonus check vs a minimum (positive = favored). */
51
+ function margin(bonus, min, dice = 3) {
52
+ return AVG_D6 * dice + bonus - min;
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Creation — the strategic setup choice (runbook §6): the archetype IS the pick
56
+ // ---------------------------------------------------------------------------
57
+ const CREATION_BY_STRATEGY = {
58
+ runner: { MEC: 3, PIL: 2, RES: 1 }, // Fix-it + escapes + peeks
59
+ privateer: { PIL: 3, SWA: 2, MEC: 1 }, // advantage everywhere + boarding
60
+ delver: { SWA: 3, DIP: 2, SNE: 1 }, // ground combat + conversions + movement
61
+ // ghost = the amendment-A3 finale specialist: the proven survival core, then
62
+ // levels 2-3-4 all go to SNE (Personal Cloaking) for a solo stealth finale.
63
+ // The XP-doubling curve makes the commitment early-or-never (AI-NOTES §4).
64
+ ghost: { PIL: 3, MEC: 2, RES: 1 },
65
+ // dread = the A9 shakedown corsair: DEM 3 is Fearsome Reputation (tribute),
66
+ // PIL 2 keeps space advantage + escapes, DIP 1 opens the A10 body channels.
67
+ dread: { DEM: 3, PIL: 2, DIP: 1 },
68
+ // tycoon = the A11 Fortune chaser: the captain aura farms crew-carried
69
+ // missions forever; every credit is banked, nothing is bought but hull.
70
+ tycoon: { LEA: 3, MEC: 2, RES: 1 },
71
+ };
72
+ const CREATION_MEDIUM = { PIL: 3, MEC: 2, RES: 1 };
73
+ /** Hard keeps the proven survival core — natural PIL 3 is Flying Ace + space
74
+ * advantage (winning an exchange beats a failed escape's guaranteed damage),
75
+ * MEC repairs, RES purges pirates from the deck via the peek. Hard's edge is
76
+ * POLICY: speed-first economy, shorter missions, crew-carrier risk shifting,
77
+ * and leveling MEC→3 for Ms/Mr Fix-it. (An earlier MEC-3/PIL-2/LEA-1 build
78
+ * measured WORSE than medium — losing Flying Ace + the peek cost more than
79
+ * turn-1 Fix-it bought; see AI-NOTES §1.) */
80
+ const CREATION_HARD = { PIL: 3, MEC: 2, RES: 1 };
81
+ const ALL_ABILITIES = ['SWA', 'ARM', 'DEM', 'HAK', 'RES', 'MEC', 'PIL', 'GUN', 'SNE', 'LEA', 'DIP', 'ACT'];
82
+ function creationMove(state, playerId, tier, strategy) {
83
+ let scores;
84
+ if (tier === 'easy') {
85
+ // Random 3/2/1 over three distinct abilities (seeded — the true floor).
86
+ const pool = [...ALL_ABILITIES];
87
+ let s = state.seed;
88
+ scores = {};
89
+ for (const v of [3, 2, 1]) {
90
+ const r = randomInt(s, pool.length);
91
+ s = r.nextSeed;
92
+ scores[pool.splice(r.value, 1)[0]] = v;
93
+ }
94
+ }
95
+ else if (strategy) {
96
+ scores = CREATION_BY_STRATEGY[strategy];
97
+ }
98
+ else {
99
+ scores = tier === 'hard' ? CREATION_HARD : CREATION_MEDIUM;
100
+ }
101
+ const label = Object.entries(scores)
102
+ .map(([a, v]) => `${a} ${v}`)
103
+ .join('/');
104
+ return say(playerId, {
105
+ type: 'create_character',
106
+ character_name: 'Bot Pilot',
107
+ race: 'human',
108
+ ship_name: 'Harness Runner',
109
+ method: 'recommended',
110
+ scores,
111
+ }, `creates a ${strategy ?? tier} build: ${label}.`);
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // easy — uniform-random over the concrete legal set
115
+ // ---------------------------------------------------------------------------
116
+ /** Moves whose legal-move entry is a fill-in skeleton (values the bot must supply). */
117
+ function isFillIn(move) {
118
+ switch (move.type) {
119
+ case 'create_character':
120
+ return true;
121
+ case 'report_roll':
122
+ return move.dice.length === 0;
123
+ case 'report_card_draw':
124
+ case 'report_res_discard':
125
+ return move.card === '';
126
+ case 'move_being':
127
+ return move.to === '';
128
+ case 'setup_encounter':
129
+ return move.attacker_placements.length === 0;
130
+ default:
131
+ return false;
132
+ }
133
+ }
134
+ /** Dynamic salt for the easy tier's randomness: `state.seed` only advances on
135
+ * REPORT moves, so decision-only stretches (e.g. ruin rounds with no rolls)
136
+ * would freeze a pure seed-keyed coin into a deterministic loop — harness-
137
+ * caught as 5000-turn SAFETY_LIMITs. The salt folds in state that changes
138
+ * every round, keeping runs deterministic per seed but never frozen. */
139
+ function easySalt(state) {
140
+ let salt = state.deck.remaining.length * 101 + state.crew.length * 13 + state.current_habitat * 7;
141
+ if (state.encounter?.kind === 'ruin_run' && state.encounter.board) {
142
+ const b = state.encounter.board;
143
+ salt += b.round * 7919 + b.tokens.reduce((a, t) => a + t.moved + (t.ended ? 3 : 0), 0) * 31;
144
+ }
145
+ const char = state.character;
146
+ if (char)
147
+ salt += char.credits + char.xp * 5;
148
+ return salt >>> 0;
149
+ }
150
+ function randomFrom(state, items) {
151
+ return items[randomInt(state.seed + easySalt(state), items.length).value];
152
+ }
153
+ function easyMove(state, playerId) {
154
+ const pending = state.pending;
155
+ // Physical randomness is not a decision — always faithful.
156
+ if (pending?.kind === 'roll')
157
+ return simulatedRoll(state, playerId);
158
+ if (pending?.kind === 'card_draw')
159
+ return simulatedDraw(state, playerId);
160
+ if (pending?.kind === 'res_discard') {
161
+ // The peek IS a decision: coin-flip it, then discard a random peeked card.
162
+ if (randomInt(state.seed + easySalt(state) + 1, 2).value === 0)
163
+ return say(playerId, { type: 'skip_res_peek' }, 'skips the peek (random).');
164
+ const pick = randomInt(state.seed, Math.min(pending.peek_count, state.deck.remaining.length));
165
+ return say(playerId, { type: 'report_res_discard', card: state.deck.remaining[pick.value] }, 'peeks and discards at random.');
166
+ }
167
+ if (pending?.kind === 'ruin_setup') {
168
+ // Random-but-valid setup.
169
+ const map = ruinMapForLevel(pending.level);
170
+ const starts = cellsWhere(map, 'S');
171
+ const spots = [...cellsWhere(map, 'A')];
172
+ let s = state.seed;
173
+ const placements = [];
174
+ for (let i = 0; i < 3; i++) {
175
+ const r = randomInt(s, spots.length);
176
+ s = r.nextSeed;
177
+ placements.push(spots.splice(r.value, 1)[0]);
178
+ }
179
+ const threat = (randomInt(s + 1, 3).value + 1);
180
+ return say(playerId, {
181
+ type: 'setup_encounter',
182
+ crew_names: state.crew.map((c) => c.name),
183
+ include_character: true,
184
+ start_location: randomFrom(state, starts),
185
+ threat_level: threat,
186
+ attacker_placements: placements,
187
+ }, `sets up at random (threat ${threat}).`);
188
+ }
189
+ // Ruin party phase: random play must include MOVEMENT, or a cloaked (natural
190
+ // SNE≥3, never-attacking) random build stalls forever with attackers that
191
+ // "see no one" (a real harness-caught liveness hole). 50%: wander to a
192
+ // random reachable cell; else a random concrete action.
193
+ if (pending?.kind === 'ruin_turn' && state.encounter?.kind === 'ruin_run' && state.encounter.board) {
194
+ const token = state.encounter.board.tokens.find((t) => !t.ended);
195
+ if (token && randomInt(state.seed + easySalt(state) + 3, 2).value === 0) {
196
+ const cells = [...ruinReachable(state, token.name).keys()].sort();
197
+ if (cells.length > 0) {
198
+ const to = randomFrom(state, cells);
199
+ return say(playerId, { type: 'move_being', being: token.name, to }, `wanders to ${to} (random).`);
200
+ }
201
+ }
202
+ }
203
+ // Equip is a FREE, reversible action — a uniform-random agent oscillates
204
+ // gear between beings forever (harness-caught: 5000-turn SAFETY_LIMIT).
205
+ // Keep first-time equips (progress); drop re-shuffles of worn gear.
206
+ const isGearShuffle = (m) => {
207
+ if (m.type !== 'equip_item')
208
+ return false;
209
+ const norm = (x) => x.toLowerCase().replace(/[^a-z0-9]/g, '');
210
+ const item = state.items.find((i) => norm(i.name) === norm(m.item_name) || norm(i.id) === norm(m.item_name));
211
+ if (!item)
212
+ return false;
213
+ const worn = state.character?.gear.weapon === item.id ||
214
+ state.character?.gear.armor === item.id ||
215
+ state.crew.some((c) => c.gear.weapon === item.id || c.gear.armor === item.id);
216
+ return worn;
217
+ };
218
+ const concrete = getLegalMoves(state, playerId).filter((m) => !isFillIn(m.move) && !isGearShuffle(m.move));
219
+ if (concrete.length === 0) {
220
+ // Only fill-ins remain (e.g. every being still has movement but nothing
221
+ // else to do) — end the first unended being's turn if possible.
222
+ throw new GameError('NO_SUGGESTION', 'easy tier found no concrete legal move (unexpected — investigate).');
223
+ }
224
+ const pick = randomFrom(state, concrete);
225
+ return say(playerId, pick.move, `plays at random: ${pick.move_id}.`);
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // Faithful physical-randomness simulation (all tiers)
229
+ // ---------------------------------------------------------------------------
230
+ function simulatedRoll(state, playerId) {
231
+ const pending = state.pending;
232
+ if (pending?.kind !== 'roll')
233
+ throw new GameError('NO_SUGGESTION', 'no roll pending');
234
+ const { dice } = rollDice(state.seed, pending.dice_count);
235
+ return say(playerId, { type: 'report_roll', dice }, `rolls ${pending.dice_count}d6 for the ${pending.reason.replace(/_/g, ' ')}: ${dice.join(', ')}.`);
236
+ }
237
+ function simulatedDraw(state, playerId) {
238
+ const pick = randomInt(state.seed, state.deck.remaining.length);
239
+ const card = state.deck.remaining[pick.value];
240
+ return say(playerId, { type: 'report_card_draw', card }, `draws ${card} from the simulated travel deck.`);
241
+ }
242
+ function knobsFor(tier, strategy) {
243
+ const base = {
244
+ tier,
245
+ strategy,
246
+ // Fighting WITH advantage usually wins outright; a failed escape is a
247
+ // guaranteed hit — so both tiers fight the +3/+5 pirates and only run
248
+ // from the ♦2 monster (+7). Verified by the tier self-test.
249
+ fight_max_atk: 5,
250
+ hack_min_margin: -2,
251
+ boards: true,
252
+ delves: false,
253
+ speed_first: false, // measured a LOSER even at 400c with A1 repairs: 400c = ~2.7 hull levels, and hull is king (AI-NOTES §1)
254
+ repair_reserve: tier === 'hard' ? 300 : 450, // hard converts credits→hull more aggressively (A1)
255
+ crew_carrier: tier === 'hard',
256
+ distance_weight: 0, // measured: distance-weighted ranking starves the mission economy
257
+ hazard_ev: tier === 'hard',
258
+ fixit_leveling: false, // measured: MEC→3 (Fix-it) leveling HALVES survival vs PIL-stacking (AI-NOTES §1)
259
+ recruit_greedy: false, // measured: junk crew crowd out good recruits (2045 → 1908)
260
+ survival_buys: false, // measured: credits→durability wash (score −140, +4 moves) — keep the simpler policy
261
+ };
262
+ if (strategy === 'runner')
263
+ return { ...base, fight_max_atk: -1, boards: false, speed_first: true };
264
+ // Privateer fights every WINNABLE fight and always boards — the old
265
+ // fight-anything gate (99) measured suicidal (64-move runs, 40/40 losses)
266
+ // and left the A11 Bounty ending unreachable by its own specialist.
267
+ if (strategy === 'privateer')
268
+ return { ...base, fight_max_atk: 5, boards: true };
269
+ if (strategy === 'delver')
270
+ return { ...base, delves: true };
271
+ return base;
272
+ }
273
+ function bestMissionOption(state, missions, k) {
274
+ const ranked = missions
275
+ .filter((m) => m.goal.kind === 'ability_check' && partyQualifiesFor(state, m.goal.ability))
276
+ .map((m) => {
277
+ const goal = m.goal;
278
+ const rawMargin = margin(bestForAbility(state, goal.ability).bonus, goal.min_roll);
279
+ // QUALIFY on the raw check margin; RANK by the distance-weighted score.
280
+ // (Filtering on the weighted score made `hard` reject the last viable
281
+ // habitat missions and fall through to suicidal ruins jobs — see the
282
+ // tier self-test history in AI-NOTES §1.)
283
+ return { m, rawMargin, score: rawMargin - m.distance * k.distance_weight };
284
+ })
285
+ .filter((x) => x.rawMargin >= -1.5)
286
+ .sort((a, b) => b.score - a.score || b.m.credits - a.m.credits);
287
+ return ranked[0]?.m ?? null;
288
+ }
289
+ /** A ruins job is survivable only with a real attack aboard (or a delver build). */
290
+ function canFightRuins(state, k) {
291
+ if (k.delves)
292
+ return true;
293
+ const attack = Math.max(bestForAbility(state, 'SWA').bonus, bestForAbility(state, 'GUN').bonus, bestForAbility(state, 'DEM').bonus);
294
+ return attack >= 2;
295
+ }
296
+ export function chooseMove(state, playerId, tier = 'medium', strategy = null) {
297
+ if (state.phase === 'creation')
298
+ return creationMove(state, playerId, tier, strategy);
299
+ if (tier === 'easy')
300
+ return easyMove(state, playerId);
301
+ const k = knobsFor(tier, strategy);
302
+ const pending = state.pending;
303
+ if (pending?.kind === 'roll') {
304
+ // Declare a matching consumable first when the check is unfavored (WR §6).
305
+ const declared = pending.declared_items.reduce((sum, id) => {
306
+ const item = state.items.find((i) => i.id === id);
307
+ return sum + (item?.effect.kind === 'ability_bonus' ? item.effect.bonus : 0);
308
+ }, 0);
309
+ if (pending.min_roll !== null && pending.score_source.ability) {
310
+ const m = margin(pending.score_source.bonus + declared, pending.min_roll, pending.dice_count);
311
+ if (m < 0) {
312
+ const boost = state.items.find((i) => i.type === 'consumable' &&
313
+ !state.consumed_item_ids.includes(i.id) &&
314
+ !pending.declared_items.includes(i.id) &&
315
+ i.effect.kind === 'ability_bonus' &&
316
+ (i.effect.ability === 'ANY' || i.effect.ability === pending.score_source.ability));
317
+ if (boost)
318
+ return say(playerId, { type: 'use_item', item_name: boost.name }, `declares ${boost.name} for the unfavored roll.`);
319
+ }
320
+ }
321
+ return simulatedRoll(state, playerId);
322
+ }
323
+ if (pending?.kind === 'card_draw')
324
+ return simulatedDraw(state, playerId);
325
+ if (pending?.kind === 'res_discard') {
326
+ // Peek + dump the most dangerous card (a pirate, else a hazard).
327
+ const remaining = [...state.deck.remaining];
328
+ const peeked = [];
329
+ let s = state.seed;
330
+ for (let i = 0; i < pending.peek_count && remaining.length > 0; i++) {
331
+ const r = randomInt(s, remaining.length);
332
+ s = r.nextSeed;
333
+ peeked.push(remaining.splice(r.value, 1)[0]);
334
+ }
335
+ const isPirate = (c) => c.endsWith('D') && ['2', '3', '4', '5', '6', '7', '8', '9'].includes(c.slice(0, -1));
336
+ const discard = peeked.find(isPirate) ?? peeked.find((c) => c.endsWith('S')) ?? peeked[0];
337
+ return say(playerId, { type: 'report_res_discard', card: discard }, `peeks ${pending.peek_count} card(s) and discards ${discard}.`);
338
+ }
339
+ if (pending?.kind === 'hazard_option') {
340
+ const rank = pending.card_id.replace(/[HDSC]$/, '');
341
+ const hazard = SPADE_HAZARDS[rank];
342
+ if (pending.must_fail) {
343
+ const cost = (ability) => {
344
+ const c = HAZARD_CONSEQUENCE_BY_STAT[ABILITY_STAT[ability]];
345
+ if (c === 'lose_credits')
346
+ return state.character.credits >= -100 ? 0 : 3;
347
+ if (c === 'crew_unrest')
348
+ return state.crew.length > 0 ? 1 : 0;
349
+ if (c === 'ship_damage')
350
+ return state.ship.damage === 'hobbled' ? 4 : 2;
351
+ return 3; // death — avoid
352
+ };
353
+ const option = cost(hazard.option1.ability) <= cost(hazard.option2.ability) ? 1 : 2;
354
+ return say(playerId, { type: 'choose_hazard_option', option }, `has neither ability and fails option ${option} (least-bad consequence).`);
355
+ }
356
+ const consequenceCost = (ability) => {
357
+ const c = HAZARD_CONSEQUENCE_BY_STAT[ABILITY_STAT[ability]];
358
+ // Recalibrated for amendment A1: a damage level is repairable for 150c,
359
+ // so −100c ≈ 0.67 repair-units — but mid-journey damage still carries a
360
+ // death-spiral premium (no yard until arrival).
361
+ if (c === 'lose_credits')
362
+ return 0.7;
363
+ if (c === 'crew_unrest')
364
+ return state.crew.length > 0 ? 1.2 : 0.2;
365
+ if (c === 'ship_damage')
366
+ return state.ship.damage === 'pristine' ? 3 : 6; // the killer, but now repairable
367
+ return 5; // death check (A4: wounds first, but a wounded run is fragile)
368
+ };
369
+ const options = [
370
+ [1, hazard.option1],
371
+ [2, hazard.option2],
372
+ ]
373
+ .filter(([, o]) => anyoneHasEffective(state, o.ability))
374
+ .map(([n, o]) => {
375
+ const isDeath = HAZARD_CONSEQUENCE_BY_STAT[ABILITY_STAT[o.ability]] === 'death';
376
+ const m = margin(bestForAbility(state, o.ability).bonus, o.min_roll);
377
+ // P(pass) via a coarse 3d6 curve (σ≈3): 0.5 + margin/8, clamped.
378
+ const pFail = Math.min(1, Math.max(0, 0.5 - m / 8));
379
+ // hard: minimize expected consequence cost; medium: raw odds.
380
+ const score = k.hazard_ev ? -pFail * consequenceCost(o.ability) : m;
381
+ return { n, o, score, isDeath };
382
+ })
383
+ .sort((a, b) => b.score - a.score);
384
+ const pick = options[0];
385
+ // Risk-shift: stake a crew carrier on a Brawn (death) option (WR §10 —
386
+ // the carrier takes the consequence).
387
+ let carrier;
388
+ if (k.crew_carrier && pick.isDeath) {
389
+ // Only shift the risk when it costs NO odds: the crew carrier's score
390
+ // must equal the best aboard (a weaker carrier fails — and dies — more).
391
+ const best = bestForAbility(state, pick.o.ability).bonus;
392
+ const equal = state.crew.find((c) => (c.abilities[pick.o.ability] ?? 0) >= best);
393
+ if (equal)
394
+ carrier = equal.name;
395
+ }
396
+ return say(playerId, { type: 'choose_hazard_option', option: pick.n, ...(carrier ? { carrier } : {}) }, `faces the hazard with its best option${carrier ? ` — ${carrier} carries the risk` : ''}.`);
397
+ }
398
+ if (pending?.kind === 'space_mission_offer') {
399
+ const rank = pending.card_id.replace(/[HDSC]$/, '');
400
+ const entry = CLUB_MISSIONS[rank];
401
+ // Journey-depth cap (harness-caught, post-A5): with evasion making space
402
+ // survivable indefinitely, accepting every sandwich turns the journey
403
+ // into perpetual motion — the bot never docks again (5000-turn runs).
404
+ if (state.journey_stack.length >= 4) {
405
+ return say(playerId, { type: 'decline_mission' }, 'declines — the job stack is deep enough; time to make port.');
406
+ }
407
+ if (entry.kind === 'choice') {
408
+ const one = bestMissionOption(state, [entry.option1], k);
409
+ const two = bestMissionOption(state, [entry.option2], k);
410
+ const pick = one && two ? (entry.option2.credits > entry.option1.credits ? 2 : 1) : one ? 1 : two ? 2 : null;
411
+ if (pick) {
412
+ const m = pick === 1 ? entry.option1 : entry.option2;
413
+ return say(playerId, { type: 'accept_mission', option: pick }, `accepts space mission option ${pick} (${describeMission(m)}).`);
414
+ }
415
+ }
416
+ else if (k.delves) {
417
+ return say(playerId, { type: 'accept_mission', option: 1 }, `accepts the ruins job (${describeMission(entry.mission)}).`);
418
+ }
419
+ return say(playerId, { type: 'decline_mission' }, 'declines the mission (unqualified, bad odds, or off-strategy).');
420
+ }
421
+ if (pending?.kind === 'space_recruit_offer') {
422
+ const rank = pending.card_id.replace(/[HDSC]$/, '');
423
+ const recruit = HEART_RECRUITS[rank];
424
+ const room = state.crew.length + 1 < state.ship.capacity;
425
+ // A space recruit attempt is FREE (no permanent mark, no cost — WR §9), so
426
+ // greedy tiers always try with room; medium keeps a sanity threshold.
427
+ if (room && (k.recruit_greedy || margin(bestForStat(state, recruit.idealized).bonus, recruit.min_roll) >= -1.5)) {
428
+ return say(playerId, { type: 'attempt_recruit', recruit_name: recruit.id }, `tries to recruit the drifting ${describeRecruit(recruit)}.`);
429
+ }
430
+ return say(playerId, { type: 'decline_recruit' }, 'leaves the drifter (no room or bad odds).');
431
+ }
432
+ if (pending?.kind === 'ruin_discovery') {
433
+ if (k.delves)
434
+ return say(playerId, { type: 'enter_ruin', depth: 1 }, 'docks with the ruin to loot the first level (delver).');
435
+ return say(playerId, { type: 'skip_ruin' }, 'passes the ruins by (staying on mission).');
436
+ }
437
+ if (pending?.kind === 'ruin_setup') {
438
+ const map = ruinMapForLevel(pending.level);
439
+ const start = cellsWhere(map, 'S')[0];
440
+ const sp = parseCellId(start);
441
+ const goalCell = cellsWhere(map, 'G')[0] ?? start;
442
+ const gp = parseCellId(goalCell);
443
+ // Place attackers (a) NEVER sealing the start→goal path — sweep-caught on
444
+ // R3: the line-clearance heuristic put an attacker on the only door out
445
+ // of the start pocket, deterministically jailing every retry — and (b)
446
+ // among path-preserving combos, as far as possible from the straight
447
+ // walking line (each cell of clearance is a round of unmolested walking).
448
+ const rows = map.grid.rows;
449
+ const walkable = (r, c, blocked) => {
450
+ const ch = rows[r - 1]?.[c - 1];
451
+ return ch !== undefined && ch !== '#' && ch !== '~' && !blocked.has(`r${r}c${c}`);
452
+ };
453
+ const pathOpen = (blocked) => {
454
+ const seen = new Set([start]);
455
+ const queue = [sp];
456
+ while (queue.length > 0) {
457
+ const cur = queue.shift();
458
+ if (cur.r === gp.r && cur.c === gp.c)
459
+ return true;
460
+ for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
461
+ const id = `r${cur.r + dr}c${cur.c + dc}`;
462
+ if (seen.has(id) || !walkable(cur.r + dr, cur.c + dc, blocked))
463
+ continue;
464
+ seen.add(id);
465
+ queue.push({ r: cur.r + dr, c: cur.c + dc });
466
+ }
467
+ }
468
+ return false;
469
+ };
470
+ const lineSamples = [];
471
+ for (let t = 0; t <= 10; t++) {
472
+ lineSamples.push({ r: sp.r + ((gp.r - sp.r) * t) / 10, c: sp.c + ((gp.c - sp.c) * t) / 10 });
473
+ }
474
+ const spots = cellsWhere(map, 'A').map((cell) => {
475
+ const p = parseCellId(cell);
476
+ const clearance = Math.min(...lineSamples.map((s) => Math.max(Math.abs(p.r - s.r), Math.abs(p.c - s.c))));
477
+ return { cell, clearance };
478
+ });
479
+ let bestCombo = null;
480
+ for (let i = 0; i < spots.length; i++)
481
+ for (let j = i + 1; j < spots.length; j++)
482
+ for (let l = j + 1; l < spots.length; l++) {
483
+ const combo = [spots[i], spots[j], spots[l]];
484
+ if (!pathOpen(new Set(combo.map((x) => x.cell))))
485
+ continue;
486
+ const score = combo.reduce((a, x) => a + x.clearance, 0);
487
+ if (!bestCombo || score > bestCombo.score)
488
+ bestCombo = { cells: combo.map((x) => x.cell).sort(), score };
489
+ }
490
+ const placements = bestCombo?.cells ??
491
+ spots
492
+ .sort((a, b) => b.clearance - a.clearance || a.cell.localeCompare(b.cell))
493
+ .slice(0, 3)
494
+ .map((x) => x.cell); // every combo seals the path (shouldn't happen on the printed maps) — fall back to clearance
495
+ // The stealth line (probe-verified: 0 ground deaths): a natural-SNE≥3
496
+ // character enters ALONE and goal-walks cloaked — Personal Cloaking means
497
+ // attackers can never target a being that never attacks. Delvers keep
498
+ // their party looting game instead.
499
+ if ((state.character?.abilities.SNE ?? 0) >= 3 && k.strategy !== 'delver') {
500
+ return say(playerId, {
501
+ type: 'setup_encounter',
502
+ crew_names: [],
503
+ include_character: true,
504
+ unnamed_count: 0, // the purist cloak-walk: nobody to trip over (A10)
505
+ start_location: start,
506
+ threat_level: 1,
507
+ attacker_placements: placements,
508
+ }, 'slips in ALONE under Personal Cloaking (threat 1) — the crew guards the ship.');
509
+ }
510
+ return say(playerId, {
511
+ type: 'setup_encounter',
512
+ crew_names: state.crew.map((c) => c.name),
513
+ include_character: true,
514
+ start_location: start,
515
+ threat_level: 1,
516
+ attacker_placements: placements,
517
+ }, `sets up the ${map.id} encounter (threat 1, everyone in, attackers far).`);
518
+ }
519
+ if (pending?.kind === 'ruin_turn') {
520
+ const enc = state.encounter;
521
+ if (enc?.kind !== 'ruin_run' || !enc.board)
522
+ throw new GameError('NO_SUGGESTION', 'ruin_turn without a board.');
523
+ const board = enc.board;
524
+ const map = ruinMapForLevel(enc.level);
525
+ const token = board.tokens.find((t) => !t.ended);
526
+ // A cloaked character NEVER acts (attacking breaks Personal Cloaking) —
527
+ // pure pacifist goal-walk (the probe-verified stealth line). EXCEPTION,
528
+ // sweep-caught: attackers who see no one STAND STILL, and standing still
529
+ // can BODY-BLOCK a corridor forever (26 finale attempts, 0 completions,
530
+ // all move-capped). A cloak-build can't fight its way through — so when
531
+ // the walk is physically blocked, RETREAT and RE-ENTER: an abandoned run
532
+ // fails, the finale is retriable (WR §17), and a fresh setup re-rolls the
533
+ // blockers' placement. The cloak makes the walk back free.
534
+ const pacifist = token.is_character && (state.character?.abilities.SNE ?? 0) >= 3 && k.strategy !== 'delver';
535
+ if (pacifist) {
536
+ // One consistent, occupant-aware metric (ruinStepToward) decides both
537
+ // "advance" and "blocked" — mixing wall-only distance with blocked
538
+ // reachability made the walker oscillate between two heuristics.
539
+ const goalCell = ruinGoalCell(state);
540
+ const reach = ruinReachable(state, token.name);
541
+ if (reach.has(goalCell)) {
542
+ return say(playerId, { type: 'move_being', being: token.name, to: goalCell }, `${token.name} ghosts onto the goal!`);
543
+ }
544
+ const step = token.moved === 0 ? ruinStepToward(state, token.name, goalCell) : null;
545
+ if (step)
546
+ return say(playerId, { type: 'move_being', being: token.name, to: step }, `${token.name} slips forward to ${step}.`);
547
+ if (token.moved === 0) {
548
+ // BLOCKED: unseeing attackers body-block corridors forever. Retreat
549
+ // and re-enter — the run fails, the finale is retriable (WR §17), and
550
+ // a fresh setup re-rolls the blockers. The cloak makes the walk free.
551
+ if (token.cell === board.start_cell) {
552
+ return say(playerId, { type: 'escape_to_start', being: token.name }, `${token.name} slips back out — blocked; reset the board rather than break cloak.`);
553
+ }
554
+ const back = ruinStepToward(state, token.name, board.start_cell);
555
+ if (back)
556
+ return say(playerId, { type: 'move_being', being: token.name, to: back }, `${token.name} ghosts back toward the entrance (${back}).`);
557
+ }
558
+ return say(playerId, { type: 'end_being_turn', being: token.name }, `${token.name} waits, unseen.`);
559
+ }
560
+ if (!token.acted && !pacifist) {
561
+ // 1. Clear a standing attacker in true range — delvers with DIP try to
562
+ // CONVERT weak attackers first (a crewmate instead of a corpse).
563
+ const range = ruinAttackRange(state, token.name);
564
+ const target = board.attackers.find((a) => !a.defeated && inAttackRange(map, token.cell, a.cell, range));
565
+ if (target) {
566
+ const crew = state.crew.find((c) => c.name === token.name);
567
+ const dip = token.is_character ? (state.character.abilities.DIP ?? 0) : (crew?.abilities.DIP ?? 0);
568
+ if (k.delves && dip >= 2 && target.atk <= 3 && state.crew.length + 1 < state.ship.capacity) {
569
+ return say(playerId, { type: 'convert_attacker', being: token.name, target: target.id }, `${token.name} calls for ${target.id}'s surrender.`);
570
+ }
571
+ return say(playerId, { type: 'attack', being: token.name, target: target.id }, `${token.name} attacks ${target.id}.`);
572
+ }
573
+ // 2. Loot an adjacent cache (delver: also beings) — free value en route.
574
+ const adjacentTo = (ch) => {
575
+ const p = parseCellId(token.cell);
576
+ for (let dr = -1; dr <= 1; dr++)
577
+ for (let dc = -1; dc <= 1; dc++) {
578
+ if (dr === 0 && dc === 0)
579
+ continue;
580
+ const id = `r${p.r + dr}c${p.c + dc}`;
581
+ const pr = parseCellId(id);
582
+ const row = map.grid.rows[pr.r - 1];
583
+ if (row && row[pr.c - 1] === ch && !board.closed_spots.includes(id))
584
+ return true;
585
+ }
586
+ return false;
587
+ };
588
+ if (adjacentTo('I'))
589
+ return say(playerId, { type: 'collect_item', being: token.name }, `${token.name} opens the adjacent cache.`);
590
+ if (k.delves && adjacentTo('B'))
591
+ return say(playerId, { type: 'recruit_at', being: token.name }, `${token.name} hails the adjacent figure.`);
592
+ }
593
+ // 3. Delvers detour to reachable loot: end movement next to an open cache.
594
+ const reachable = ruinReachable(state, token.name);
595
+ if (k.delves && !token.acted) {
596
+ const lootAdjacent = [...reachable.keys()]
597
+ .filter((cell) => {
598
+ const p = parseCellId(cell);
599
+ for (let dr = -1; dr <= 1; dr++)
600
+ for (let dc = -1; dc <= 1; dc++) {
601
+ if (dr === 0 && dc === 0)
602
+ continue;
603
+ const row = map.grid.rows[p.r + dr - 1];
604
+ const ch = row?.[p.c + dc - 1];
605
+ const id = `r${p.r + dr}c${p.c + dc}`;
606
+ if ((ch === 'I' || ch === 'B') && !board.closed_spots.includes(id))
607
+ return true;
608
+ }
609
+ return false;
610
+ })
611
+ .sort((a, b) => reachable.get(a) - reachable.get(b) || a.localeCompare(b))[0];
612
+ if (lootAdjacent && token.moved === 0) {
613
+ return say(playerId, { type: 'move_being', being: token.name, to: lootAdjacent }, `${token.name} moves to the cache at arm's reach (${lootAdjacent}).`);
614
+ }
615
+ }
616
+ // 4. Advance toward the goal by real walking distance.
617
+ const goal = ruinGoalCell(state);
618
+ if (reachable.has(goal))
619
+ return say(playerId, { type: 'move_being', being: token.name, to: goal }, `${token.name} makes for the goal!`);
620
+ const current = ruinDistanceToGoal(state, token.cell);
621
+ const best = [...reachable.keys()].sort((a, b) => ruinDistanceToGoal(state, a) - ruinDistanceToGoal(state, b) || a.localeCompare(b))[0];
622
+ if (best && ruinDistanceToGoal(state, best) < current && token.moved === 0) {
623
+ return say(playerId, { type: 'move_being', being: token.name, to: best }, `${token.name} advances to ${best}.`);
624
+ }
625
+ return say(playerId, { type: 'end_being_turn', being: token.name }, `${token.name} holds.`);
626
+ }
627
+ if (pending?.kind === 'ruin_continue') {
628
+ if (pending.is_mission) {
629
+ return say(playerId, { type: 'enter_ruin', depth: pending.next_level }, `descends to level ${pending.next_level} (the job needs it).`);
630
+ }
631
+ return say(playerId, { type: 'skip_ruin' }, 'takes the loot and leaves.');
632
+ }
633
+ if (pending?.kind === 'sacrifice_offer') {
634
+ // A14a: shirts absorb what actually kills runs — the character's death
635
+ // check and hull damage. Credits/unrest land as rolled (bodies are dearer).
636
+ const rank = pending.hazard_card_id.replace(/[HDSC]$/, '');
637
+ const hazard = SPADE_HAZARDS[rank];
638
+ const opt = pending.hazard_option === 2 ? hazard.option2 : hazard.option1;
639
+ const consequence = HAZARD_CONSEQUENCE_BY_STAT[ABILITY_STAT[opt.ability]];
640
+ const worthIt = (consequence === 'death' && pending.carrier_who === 'self') || consequence === 'ship_damage';
641
+ if (worthIt)
642
+ return say(playerId, { type: 'sacrifice_unnamed' }, 'lets an unnamed hand take the hit — the ship and the captain matter more.');
643
+ return say(playerId, { type: 'decline_sacrifice' }, 'takes the consequence — no need to spend a life on it.');
644
+ }
645
+ if (pending?.kind === 'evade_offer') {
646
+ // A5: the evade attempt is FREE on failure (the normal fight just starts),
647
+ // so try it whenever the encounter isn't a PROFITABLE fight. Ghosts and
648
+ // runners always slip away.
649
+ const rank = pending.card_id.replace(/[HDSC]$/, '');
650
+ const pirate = DIAMOND_PIRATES[rank];
651
+ const wouldFight = bestForAbility(state, 'PIL').bonus >= 1 && pirate.special !== 'space_combat_advantage' && pirate.space_attack <= k.fight_max_atk;
652
+ const evadeMargin = margin(bestForAbility(state, 'SNE').bonus, 10 + pirate.speed);
653
+ // The dread corsair WANTS the encounter when the shakedown is favored —
654
+ // tribute is the build's economy (A9).
655
+ if (k.strategy === 'dread' && margin(bestForAbility(state, 'DEM').bonus + (state.dread ?? 0), 10 + pirate.space_attack) >= 0) {
656
+ return say(playerId, { type: 'decline_evade' }, `turns TOWARD the ♦${rank} — easy tribute.`);
657
+ }
658
+ // Near-certain slips beat even profitable fights (a fight's upside never
659
+ // repays a hull-loss tail — sweep-verified: SNE3+PIL2 declining evades to
660
+ // fight died 10× sooner than SNE3 alone).
661
+ if (!wouldFight || evadeMargin >= 2 || k.strategy === 'ghost' || k.strategy === 'runner') {
662
+ return say(playerId, { type: 'attempt_evade' }, `runs silent past the ♦${rank}.`);
663
+ }
664
+ return say(playerId, { type: 'decline_evade' }, `engages the ♦${rank} — a winnable fight is loot and XP.`);
665
+ }
666
+ if (pending?.kind === 'space_combat_action') {
667
+ if (state.encounter?.kind !== 'space_combat')
668
+ throw new GameError('NO_SUGGESTION', 'space_combat_action without a space combat.');
669
+ const enc = state.encounter;
670
+ if (enc.stage === 'loss_reroll_offer')
671
+ return say(playerId, { type: 'use_reroll' }, 'burns the Flying Ace reroll.');
672
+ const pirate = DIAMOND_PIRATES[enc.pirate_rank];
673
+ const debuff = enc.pirate_debuff ?? 0;
674
+ const effectiveAtk = pirate.space_attack - debuff - (enc.player_buff ?? 0); // A6 volley + Redline prototype
675
+ const hasAdvantage = bestForAbility(state, 'PIL').bonus >= 1 && pirate.special !== 'space_combat_advantage';
676
+ const hak = bestForAbility(state, 'HAK').bonus;
677
+ const gun = bestForAbility(state, 'GUN').bonus;
678
+ const dem = bestForAbility(state, 'DEM').bonus;
679
+ // A20 Redline: overcharge before committing to exchanges.
680
+ if (enc.redline_used !== true &&
681
+ bestForAbility(state, 'MEC').bonus >= 2 &&
682
+ margin(bestForAbility(state, 'MEC').bonus, 12) >= 0 &&
683
+ hasAdvantage &&
684
+ pirate.space_attack - debuff <= k.fight_max_atk + 2) {
685
+ return say(playerId, { type: 'redline' }, 'redlines the engines before the exchange.');
686
+ }
687
+ // A6: soften a tough-but-plannable fight with the once-per-encounter volley.
688
+ if (enc.volley_used !== true &&
689
+ gun >= 1 &&
690
+ margin(gun, 12) >= 0 &&
691
+ hasAdvantage &&
692
+ pirate.space_attack >= 3 &&
693
+ effectiveAtk <= k.fight_max_atk) {
694
+ return say(playerId, { type: 'open_fire' }, `opens the engagement with a ranged volley at the ♦${enc.pirate_rank}.`);
695
+ }
696
+ if (hasAdvantage && effectiveAtk <= k.fight_max_atk) {
697
+ return say(playerId, { type: 'fight_round' }, `fights ♦${enc.pirate_rank} — advantage and acceptable odds.`);
698
+ }
699
+ if (hak >= 1 && margin(hak, pirate.min_hacking) >= k.hack_min_margin) {
700
+ return say(playerId, { type: 'attempt_hack' }, 'goes for the hack.');
701
+ }
702
+ // A7/A9: bluff the rabble off entirely when the odds beat a running exit —
703
+ // an intimidate failure keeps you IN the fight, so it must clearly
704
+ // outroll the escape (whose failure still gets you out). Dread (A9b) rides
705
+ // the roll; the dread corsair leans in harder (tribute pays for the risk).
706
+ const escapeMargin = margin(state.ship.handling + bestForAbility(state, 'PIL').bonus, 10 + pirate.space_attack);
707
+ const intimidateMargin = margin(dem + (state.dread ?? 0), 10 + pirate.space_attack);
708
+ // A13: vs the rabble (atk ≤ 3) a failed bluff now EXITS like a failed
709
+ // escape (one hit, they quit) — so the gate drops to plain better-odds.
710
+ const rabble = pirate.space_attack <= 3;
711
+ const intimidateGate = k.strategy === 'dread'
712
+ ? intimidateMargin >= -2
713
+ : rabble
714
+ ? intimidateMargin >= escapeMargin && intimidateMargin >= -1
715
+ : intimidateMargin >= escapeMargin + 1 && intimidateMargin >= -1;
716
+ if (dem >= 1 && intimidateGate) {
717
+ return say(playerId, { type: 'attempt_intimidate' }, `orders the ♦${enc.pirate_rank} to stand down or burn.`);
718
+ }
719
+ return say(playerId, { type: 'attempt_escape' }, 'runs for it.');
720
+ }
721
+ if (pending?.kind === 'boarding_decision') {
722
+ if (state.encounter?.kind !== 'space_combat')
723
+ throw new GameError('NO_SUGGESTION', 'boarding_decision without a space combat.');
724
+ const enc = state.encounter;
725
+ const pirate = DIAMOND_PIRATES[enc.pirate_rank];
726
+ // A10: free value first — canvass the beaten crew for a body (the pending
727
+ // returns here after the roll).
728
+ if (enc.survivors_rolled !== true &&
729
+ bestForAbility(state, 'DIP').bonus >= 1 &&
730
+ (state.unnamed_crew ?? 0) < unnamedCap(state)) {
731
+ return say(playerId, { type: 'recruit_survivors' }, 'canvasses the beaten crew for a spare hand.');
732
+ }
733
+ if (pending.stage === 'board_or_leave') {
734
+ const attack = Math.max(bestForAbility(state, 'SWA').bonus, bestForAbility(state, 'GUN').bonus, bestForAbility(state, 'DEM').bonus);
735
+ const threshold = k.strategy === 'privateer' ? 1 : 2;
736
+ if (k.boards && attack >= threshold && pirate.ground_attack <= (k.strategy === 'privateer' ? 5 : 2)) {
737
+ return say(playerId, { type: 'board_ship' }, 'boards the disabled ship.');
738
+ }
739
+ return say(playerId, { type: 'leave_pirate' }, 'leaves the disabled pirates adrift.');
740
+ }
741
+ const upgrade = k.strategy === 'runner'
742
+ ? pirate.speed > state.ship.speed
743
+ : pirate.capacity > state.ship.capacity || pirate.speed > state.ship.speed + 1 || pirate.handling > state.ship.handling + 1;
744
+ if (upgrade)
745
+ return say(playerId, { type: 'take_ship' }, 'takes the captured ship (an upgrade, despite arriving Hobbled).');
746
+ return say(playerId, { type: 'abandon_ship' }, 'keeps its own ship.');
747
+ }
748
+ if (pending?.kind === 'level_up') {
749
+ const char = state.character;
750
+ // Ghost strategy — amendment-A3 arc, level INTO the finale: open SNE at
751
+ // the first even level, deepen to 3 (Personal Cloaking → solo stealth
752
+ // finale). Measured as a DEFAULT policy this halves survival (same trap
753
+ // as fixit-leveling: anything but PIL-stacking costs the midgame), so it
754
+ // is a specialist archetype, not the baseline (AI-NOTES §1/§4).
755
+ if (k.strategy === 'ghost') {
756
+ const sne = char.abilities.SNE ?? 0;
757
+ const reachingEven = (char.level + 1) % 2 === 0;
758
+ if (sne === 0 && reachingEven)
759
+ return say(playerId, { type: 'level_up', ability: 'SNE' }, 'opens SNE (even-level unlock) — training toward Personal Cloaking.');
760
+ if (sne >= 1 && sne < 3)
761
+ return say(playerId, { type: 'level_up', ability: 'SNE' }, 'deepens SNE toward Personal Cloaking.');
762
+ }
763
+ if (k.fixit_leveling) {
764
+ const mec = char.abilities.MEC ?? 0;
765
+ if (mec >= 1 && mec < 3)
766
+ return say(playerId, { type: 'level_up', ability: 'MEC' }, 'levels MEC toward Ms/Mr Fix-it.');
767
+ }
768
+ const owned = Object.entries(char.abilities)
769
+ .filter(([, v]) => v >= 1)
770
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
771
+ return say(playerId, { type: 'level_up', ability: owned[0][0] }, `levels up ${owned[0][0]}.`);
772
+ }
773
+ if (pending?.kind === 'release_crew') {
774
+ const weakest = [...state.crew].sort((a, b) => Object.values(a.abilities).reduce((x, y) => x + y, 0) - Object.values(b.abilities).reduce((x, y) => x + y, 0))[0];
775
+ return say(playerId, { type: 'release_crew', crew_name: weakest.name }, 'releases the weakest crew member.');
776
+ }
777
+ if (state.phase === 'at_habitat')
778
+ return habitatMove(state, playerId, k);
779
+ throw new GameError('NO_SUGGESTION', `Bot has no policy for phase ${state.phase} (unexpected — investigate).`);
780
+ }
781
+ function habitatShopItem(state, name) {
782
+ return HABITATS[state.current_habitat].shop.find((s) => s.name === name) ?? null;
783
+ }
784
+ function habitatMove(state, playerId, k) {
785
+ const legal = getLegalMoves(state, playerId);
786
+ const byId = (prefix) => legal.find((m) => m.move_id.startsWith(prefix));
787
+ const repair = byId('attempt-repair');
788
+ if (repair)
789
+ return say(playerId, repair.move, 'attempts a repair roll.');
790
+ // A10: work the docks when the option is open — a body costs one roll.
791
+ const docks = byId('recruit-unnamed');
792
+ if (docks)
793
+ return say(playerId, docks.move, 'works the docks for an unnamed hand.');
794
+ // A18 Shipwright: hull is king — fabricate ARMOR whenever the chest is deep
795
+ // (armor is the one stat the early shops never sell).
796
+ const fabricate = byId('fabricate-armor');
797
+ if (fabricate && state.character.credits >= 1200) {
798
+ return say(playerId, fabricate.move, 'fabricates a +1 armor part (Shipwright, A18).');
799
+ }
800
+ // Amendment A1: paid repairs — the hull IS the health bar (95% of losses),
801
+ // so convert surplus credits into damage levels while keeping a working
802
+ // reserve for missions/speed. Free roll always tried first (above).
803
+ if (byId('pay-repair') && state.character.credits >= k.repair_reserve) {
804
+ return say(playerId, { type: 'pay_repair' }, `pays 150c for yard repairs (${state.ship.damage} ship, ${state.character.credits}c).`);
805
+ }
806
+ const unrest = byId('attempt-reduce-unrest');
807
+ if (unrest)
808
+ return say(playerId, unrest.move, 'attempts to ease crew unrest.');
809
+ // Equip anything bought/found and still unequipped (mechanic coverage, §9):
810
+ // the character first, then crew (spare gear on a shelf helps no one).
811
+ const char = state.character;
812
+ for (const item of state.items) {
813
+ if (item.type !== 'weapon' && item.type !== 'armor')
814
+ continue;
815
+ const equippedSomewhere = char.gear.weapon === item.id || char.gear.armor === item.id || state.crew.some((c) => c.gear.weapon === item.id || c.gear.armor === item.id);
816
+ if (equippedSomewhere)
817
+ continue;
818
+ const slot = item.type;
819
+ if (char.gear[slot] === null) {
820
+ return say(playerId, { type: 'equip_item', item_name: item.name, being: char.name }, `straps on the ${item.name}.`);
821
+ }
822
+ const openCrew = state.crew.find((c) => c.gear[slot] === null);
823
+ if (openCrew) {
824
+ return say(playerId, { type: 'equip_item', item_name: item.name, being: openCrew.name }, `hands ${openCrew.name} the ${item.name}.`);
825
+ }
826
+ }
827
+ // GEAR UP, THEN GO: the finale ruin waits directly beyond H3 (no travel
828
+ // cards — entering costs nothing but the run itself), and every H3 mission
829
+ // flown "first" is 14-18 more cards of ship-loss lottery. So at H3: buy the
830
+ // ground kit (+3 GUN / +3 ARM, 800c each — deep hull reserve, credits are
831
+ // hull) and ENTER THE FINALE the moment the party is geared.
832
+ // (The A11 tycoon never gears and never enters — it farms and BANKS toward
833
+ // the Fortune ending; Legend catches the long grinds either way.)
834
+ if (state.current_habitat === 3 && k.strategy !== 'tycoon') {
835
+ // The war chest becomes the war party: gauntlet traces show the party
836
+ // losing ~1 crew per level and dying at R3 with 0 crew + a wounded
837
+ // character — while sitting on 3,600-6,500c. Capacity is BODIES, and
838
+ // bodies are ablative armor (a landed hit kills ONE crew; the character
839
+ // only wounds). Buy the kit, buy the hull space, recruit the cantina dry.
840
+ for (const name of ['Weapon +3 GUN', 'Armor +3 ARM', 'Ship part Capacity +3']) {
841
+ const item = habitatShopItem(state, name);
842
+ if (!item)
843
+ continue;
844
+ if (state.items.some((i) => i.name === item.name))
845
+ continue; // one copy is enough
846
+ if (item.effect.kind === 'ship_stat' && item.effect.stat === 'capacity' && state.ship.capacity >= 6)
847
+ continue;
848
+ if (state.character.credits >= item.cost + 1200 && legal.some((m) => m.move_id === `buy-${item.id}`)) {
849
+ return say(playerId, { type: 'buy_item', item_name: item.name }, `buys the ${item.name} — the finale won't fight fair.`);
850
+ }
851
+ }
852
+ // A16: MK-II the kit before the gauntlet when the chest is deep.
853
+ const tinker = legal.find((m) => m.move_id.startsWith('upgrade-'));
854
+ if (tinker && state.character.credits >= 1400) {
855
+ return say(playerId, tinker.move, 'puts the kit on the workbench (Tinker, A16).');
856
+ }
857
+ const geared = state.items.some((i) => i.name.startsWith('Weapon +3 GUN')) && state.items.some((i) => i.name.startsWith('Armor +3 ARM'));
858
+ const recruitRows = legal.filter((m) => m.move_id.startsWith('recruit-') && m.move_id !== 'recruit-unnamed');
859
+ const roomLeft = state.crew.length + 1 < state.ship.capacity;
860
+ if (geared && roomLeft && recruitRows.length > 0 && state.crew.length < 4) {
861
+ // Any-margin attempts: a failed row only marks itself, and every body
862
+ // recruited is a level of the gauntlet survived.
863
+ const ranked = recruitRows
864
+ .map((m) => {
865
+ const r = HABITATS[3].cantina.find((c) => c.id === m.move_id.replace('recruit-', ''));
866
+ return { m, margin: margin(bestForStat(state, r.idealized).bonus, r.min_roll) };
867
+ })
868
+ .sort((a, b) => b.margin - a.margin);
869
+ return say(playerId, ranked[0].m.move, 'recruits for the war party (the gauntlet takes one body per level).');
870
+ }
871
+ const finaleDoor = byId('depart');
872
+ if (geared && finaleDoor && (state.crew.length >= 4 || !roomLeft || recruitRows.length === 0)) {
873
+ return say(playerId, finaleDoor.move, 'enters the SEASON 1 FINALE — geared, crewed, and done waiting.');
874
+ }
875
+ }
876
+ // Everyone farms before marching — measured TWICE: early departure killed
877
+ // the default (−25% score) AND the ghost (126-move runs, 193/200 ship
878
+ // losses in the first harness batch). The uncapped harness shows farming
879
+ // parties still reach the finale 37% of the time; the ghost's edge is the
880
+ // SNE arc + the solo stealth finale, not a faster map.
881
+ const habitat = HABITATS[state.current_habitat];
882
+ const recruitMoves = legal.filter((m) => m.move_id.startsWith('recruit-') && m.move_id !== 'recruit-unnamed');
883
+ if (recruitMoves.length > 0 && state.crew.length + 1 < state.ship.capacity) {
884
+ const ranked = recruitMoves
885
+ .map((m) => {
886
+ const r = habitat.cantina.find((c) => c.id === m.move_id.replace('recruit-', ''));
887
+ return { m, r, margin: margin(bestForStat(state, r.idealized).bonus, r.min_roll) };
888
+ })
889
+ .sort((a, b) => b.margin - a.margin);
890
+ if (ranked[0].margin >= 0) {
891
+ return say(playerId, ranked[0].m.move, `tries to recruit the ${describeRecruit(ranked[0].r)}.`);
892
+ }
893
+ }
894
+ // Speed-first economy: fewer cards drawn = fewer ways to die.
895
+ const buys = legal.filter((m) => m.move_id.startsWith('buy-'));
896
+ if (k.speed_first) {
897
+ const speedBuy = buys.find((m) => {
898
+ const item = habitat.shop.find((s) => s.id === m.move_id.replace('buy-', ''));
899
+ return item.effect.kind === 'ship_stat' && item.effect.stat === 'speed';
900
+ });
901
+ if (speedBuy)
902
+ return say(playerId, speedBuy.move, 'buys engine upgrades (fewer cards, fewer funerals).');
903
+ }
904
+ // Targeted survival buys: handling (escapes) and ship armor (every exchange).
905
+ if (k.survival_buys) {
906
+ const lifesaver = buys.find((m) => {
907
+ const item = habitat.shop.find((s) => s.id === m.move_id.replace('buy-', ''));
908
+ return item.effect.kind === 'ship_stat' && (item.effect.stat === 'handling' || item.effect.stat === 'armor');
909
+ });
910
+ if (lifesaver)
911
+ return say(playerId, lifesaver.move, 'buys survival hardware (handling/armor).');
912
+ }
913
+ const missions = habitat.missions.filter((m) => legal.some((l) => l.move_id === `take-mission-${m.id}`));
914
+ const mission = bestMissionOption(state, missions, k);
915
+ if (mission)
916
+ return say(playerId, { type: 'take_mission', mission_name: mission.id }, `takes the ${describeMission(mission)} mission.`);
917
+ // Delvers take ruins missions when nothing better is on the board.
918
+ if (k.delves) {
919
+ const ruinsMission = missions.find((m) => m.goal.kind === 'ruin');
920
+ if (ruinsMission)
921
+ return say(playerId, { type: 'take_mission', mission_name: ruinsMission.id }, `takes the ${describeMission(ruinsMission)} ruins job.`);
922
+ }
923
+ const depart = byId('depart');
924
+ if (depart)
925
+ return say(playerId, depart.move, `departs Habitat #${state.current_habitat}.`);
926
+ // Marginal missions beat idling (H1's 4-mission gate must be fed).
927
+ const anyMission = missions.find((m) => m.goal.kind === 'ability_check');
928
+ if (anyMission)
929
+ return say(playerId, { type: 'take_mission', mission_name: anyMission.id }, `takes the ${describeMission(anyMission)} mission (nothing better to do).`);
930
+ // A ruins job without a fighter aboard is a death march — take it only with
931
+ // a real attack (or a delver build)…
932
+ const anyRuins = missions.find((m) => m.goal.kind === 'ruin');
933
+ if (anyRuins && canFightRuins(state, k)) {
934
+ return say(playerId, { type: 'take_mission', mission_name: anyRuins.id }, `takes the ${describeMission(anyRuins)} ruins job (last resort).`);
935
+ }
936
+ // …otherwise dig out: any purchase can help (a "+1 Any" consumable
937
+ // re-qualifies ability missions), and even a bad-odds recruit adds scores.
938
+ if (buys.length > 0) {
939
+ const cheapest = buys
940
+ .map((m) => ({ m, cost: habitat.shop.find((s) => s.id === m.move_id.replace('buy-', '')).cost }))
941
+ .sort((a, b) => a.cost - b.cost)[0];
942
+ return say(playerId, cheapest.m.move, 'buys the cheapest useful gear (digging out of a dry habitat).');
943
+ }
944
+ const desperateRecruit = legal.find((m) => m.move_id.startsWith('recruit-') && m.move_id !== 'recruit-unnamed');
945
+ if (desperateRecruit && state.crew.length + 1 < state.ship.capacity) {
946
+ return say(playerId, desperateRecruit.move, 'tries a long-shot recruit (any new scores help).');
947
+ }
948
+ // Truly cornered: the ruins job beats wedging (the printed game has the same
949
+ // corner — see POST-PORT edge case 2).
950
+ if (anyRuins)
951
+ return say(playerId, { type: 'take_mission', mission_name: anyRuins.id }, `braves the ${describeMission(anyRuins)} ruins job (cornered).`);
952
+ // Absolute backstop: if ANYTHING is legal, do it rather than throw — e.g.
953
+ // pay-repair below the policy's credit reserve as the last action standing
954
+ // (harness-caught with a PIL3/GUN2/MEC1 build). The engine's MAROONED rule
955
+ // handles the truly-empty case; the bot must never wedge above it.
956
+ const anything = legal[0];
957
+ if (anything)
958
+ return say(playerId, anything.move, `takes the only action left (${anything.move_id}).`);
959
+ throw new GameError('NO_SUGGESTION', 'Bot has exhausted its actions at this habitat (unexpected — investigate).');
960
+ }
961
+ //# sourceMappingURL=ai.js.map