Files
Dune/tests/richese-strongholds-runtime.test.ts

213 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { presenceAt } from '../game/force-presence';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { createRicheseStrongholdsFixture, finishRicheseStrongholdsBattle, nextRicheseStrongholdsNativeStep, revealRicheseStrongholdsBattle, richeseStrongholdsPlayer, type RicheseStrongholdsFixture } from './fixture-richese-strongholds';
function chooseStone(fixture: RicheseStrongholdsFixture, mode: 'kill' | 'ignore') {
const game = revealRicheseStrongholdsBattle(fixture);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, fixture.richese);
return applyAction(game, fixture.richese, { type: 'decision', event: game.battle!.event, mode });
}
/** Actual native settlement, independently accounting for later Collection. */
function settle(fixture: RicheseStrongholdsFixture, revealed: Game, callers: string[] = []) {
// Calls are declared on the real battle before deriving the independent quote.
let voted = revealed;
while (voted.battle?.revealed && !voted.decision && !voted.response) {
const next = nextRicheseStrongholdsNativeStep(voted);
if (!next || next.action.type !== 'traitorCall') break;
const remaining = viewGame(voted, voted.host).battle!.traitorVoters.filter(id => voted.battle!.traitorCalls[id] === undefined);
if (remaining.length === 1) break;
next.action.call = callers.includes(next.actor);
voted = applyAction(voted, next.actor, next.action);
}
// The last vote resolves immediately, so quote the still-live original source
// with exactly those public calls; never alter the engine's actual source.
const quoted = structuredClone(voted);
for (const actor of viewGame(quoted, quoted.host).battle!.traitorVoters) quoted.battle!.traitorCalls[actor] = callers.includes(actor);
const quote = quoteStrongholdFactionsBattle(quoted);
const resolved = finishRicheseStrongholdsBattle(fixture, voted, callers);
const collection = resolved.phase === 7 && !resolved.phaseOpening
? quoteSpiceCollection({ ...resolved, players: resolved.players.map(p => ({ ...p, spice: 0 })) }).receipts
: [];
for (const player of voted.players) {
const payment = quote.payments.find(p => p.player === player.id)?.ownPayment ?? 0;
const income = quote.strongholdIncome.filter(p => p.player === player.id).reduce((sum, p) => sum + p.amount, 0);
const bounty = quote.bounty?.player === player.id ? quote.bounty.amount : 0;
const choam = quote.choamIncome?.owner === player.id ? quote.choamIncome.amount : 0;
const collected = collection.filter(p => p.player === player.id).reduce((sum, p) => sum + p.strongholds + p.collected, 0);
assert.equal(richeseStrongholdsPlayer(resolved, player.id).spice, player.spice - payment + income + bounty + choam + collected, player.faction);
}
assert.equal(resolved.lastBattleContext?.winner, quote.winner);
return { resolved, quote };
}
void test('genuine E2 Richese setup supplies the separate cache, one-force shipment and first END Mentat control even for zero', () => {
for (const noFieldValue of [0, 3, 5] as const) {
const fixture = createRicheseStrongholdsFixture({ noFieldValue });
assert.equal(treacheryDeck(fixture.initial.expansions).length, 35);
assert.equal(fixture.initial.deck.length, 35);
assert.equal(fixture.initial.richeseCache!.length, 10);
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'richese')!.noField!.tokens.length, 3);
const before = richeseStrongholdsPlayer(fixture.beforeShipment, fixture.richese);
const shipped = richeseStrongholdsPlayer(fixture.afterShipment, fixture.richese);
assert.equal(shipped.spice, before.spice - 1, 'actual marker shipment pays one stronghold-force price');
assert.equal(shipped.reserves, before.reserves, 'shipment does not yet transfer physical reserves');
assert.equal(presenceAt(shipped, fixture.kind), 1);
assert.equal(strongholdControllers(fixture.beforeFirstMentat.players, false)[fixture.kind], fixture.richese);
assert.equal(fixture.beforeFirstMentat.strongholdCards!.owners[fixture.kind], null);
const ended = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(ended.turn, 2);
assert.equal(ended.strongholdCards!.owners[fixture.kind], fixture.richese);
assert.equal(fixture.afterFirstMentat.strongholdCards!.claimedTurn, 1);
}
});
void test('original undealt CLI initializer continues without redealing, and native unbid-cache acquisition really supplies Stone', () => {
const original = createRicheseStrongholdsFixture().initial;
const before = structuredClone(original);
const fixture = createRicheseStrongholdsFixture({ initial: original, stone: true });
assert.deepEqual(original, before);
assert.deepEqual(fixture.initial, before);
const acquisition = fixture.acquisition!;
assert.ok(acquisition.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
assert.equal(acquisition.before.players.some(p => p.hand.some(c => c.id === 'richese-stone-burner')), false);
let game = acquisition.before;
for (let count = 0; !richeseStrongholdsPlayer(game, fixture.richese).hand.some(card => card.id === 'richese-stone-burner') && count < 100; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
for (const player of acquisition.before.players) {
assert.equal(richeseStrongholdsPlayer(game, player.id).spice, player.spice, 'unbid acquisition has no original purchase charge');
}
assert.deepEqual(richeseStrongholdsPlayer(game, fixture.richese).hand.map(card => card.id).sort(),
[...richeseStrongholdsPlayer(acquisition.before, fixture.richese).hand.map(card => card.id), 'richese-stone-burner'].sort());
assert.ok(richeseStrongholdsPlayer(game, fixture.richese).hand.some(c => c.id === 'richese-stone-burner'));
assert.equal(game.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
});
void test('actual voluntary reveal removes zero presence or materializes 3/5 up to reserves, never exceeding the native twenty-counter pool', () => {
for (const noFieldValue of [0, 3, 5] as const) {
const fixture = createRicheseStrongholdsFixture({ noFieldValue, reserves: 2 });
const before = fixture.beforeReveal;
const revealed = applyAction(before, fixture.revealStep.actor, fixture.revealStep.action);
const player = richeseStrongholdsPlayer(revealed, fixture.richese);
assert.equal(player.forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`] ?? 0, Math.min(2, noFieldValue));
assert.equal(player.reserves, 2 - Math.min(2, noFieldValue));
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
assert.equal(presenceAt(player, fixture.kind), Math.min(2, noFieldValue));
assert.equal(player.noField!.deployed, null);
assert.equal(revealed.strongholdCards!.owners[fixture.kind], fixture.richese, 'losing physical control does not reassign the retained card before END Mentat');
}
});
void test('native marker-only plans use the reserve-limited private pool, reveal 0/3/5 only after both plans, and preserve supported costs', () => {
for (const noFieldValue of [0, 3, 5] as const) {
const fixture = createRicheseStrongholdsFixture({ noFieldValue, ownerDial: noFieldValue ? 1 : 0, support: noFieldValue ? 1 : 0 });
const own = viewGame(fixture.game, fixture.richese).battle!;
assert.equal(own.ownForces!.normal, noFieldValue);
assert.ok(own.ownForces!.normal <= 20);
assert.equal(own.strongholdEffects[fixture.richese], fixture.kind);
const sealed = applyAction(fixture.game, fixture.richese, fixture.planActions[0].action);
assert.ok(richeseStrongholdsPlayer(sealed, fixture.richese).noField!.deployed);
assert.equal(richeseStrongholdsPlayer(sealed, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`], undefined);
const revealed = revealRicheseStrongholdsBattle(fixture);
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).noField!.deployed, null);
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`] ?? 0, noFieldValue);
const { resolved, quote } = settle(fixture, revealed);
assert.equal(quote.winner, fixture.richese, 'printed Habbanya advantage applies to the zero-plan tie as well as ordinary supported victory');
assert.equal(quote.payments.find(p => p.player === fixture.richese)!.ownPayment, noFieldValue ? 1 : 0);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.opponent).tanks, 6);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).tanks, noFieldValue ? 1 : 0);
}
});
void test('Arrakeen allowance funds real supported marker battle costs without creating spendable spice', () => {
const fixture = createRicheseStrongholdsFixture({ kind: 'arrakeen', noFieldValue: 5, ownerDial: 3, support: 3 });
const revealed = revealRicheseStrongholdsBattle(fixture);
const { quote, resolved } = settle(fixture, revealed);
const payment = quote.payments.find(p => p.player === fixture.richese)!;
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 1);
assert.equal(quote.choamIncome!.amount, 1);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).forces['arrakeen:10'], 2);
});
void test('real cache-acquired Stone uses Habbanya undialed ties in both revealed modes, with declared support, native deaths and ordinary retention', () => {
for (const mode of ['kill', 'ignore'] as const) {
const fixture = createRicheseStrongholdsFixture({ stone: true, ownerDial: 2, opponentDial: 2, support: 2, opponentSupport: 2 });
const chosen = chooseStone(fixture, mode);
const { resolved, quote } = settle(fixture, chosen);
assert.equal(quote.winner, fixture.richese);
assert.deepEqual(quote.stone!.attacker, [4]);
assert.deepEqual(quote.stone!.defender, [4]);
assert.equal(richeseStrongholdsPlayer(resolved, fixture.richese).forces[`${fixture.kind}:${territory(fixture.kind).sectors[0]}`], 4);
for (const plan of fixture.planActions) {
assert.equal(richeseStrongholdsPlayer(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill');
assert.equal(quote.payments.find(p => p.player === plan.actor)!.cost, 2);
}
assert.equal(quote.choamIncome!.amount, 2, 'actual four-spice battle support produces native CHOAM income');
assert.ok(richeseStrongholdsPlayer(resolved, fixture.richese).hand.some(c => c.id === 'richese-stone-burner'));
}
const loser = createRicheseStrongholdsFixture({ stone: true, ownerDial: 3, support: 3, opponentDial: 1, opponentSupport: 1 });
const lost = settle(loser, chooseStone(loser, 'ignore'));
assert.equal(lost.quote.winner, loser.opponent, 'Habbanya does not override unequal undialed forces');
assert.ok(lost.resolved.discard.some(c => c.id === 'richese-stone-burner'));
});
void test('native traitor and explosion precedence outrank real Stone plus Habbanya; successful traitor waives declared support', () => {
const traitor = createRicheseStrongholdsFixture({ stone: true, traitors: 'owner', ownerDial: 2, support: 2 });
const called = settle(traitor, chooseStone(traitor, 'kill'), [traitor.richese]);
assert.equal(called.quote.winner, traitor.richese);
assert.equal(called.quote.payments.find(p => p.player === traitor.richese)!.freeByTraitor, true);
assert.equal(richeseStrongholdsPlayer(called.resolved, traitor.richese).leaders.find(l => l.id === traitor.planActions[0].action.leader)!.dead, false);
const mutual = createRicheseStrongholdsFixture({ stone: true, traitors: 'both' });
assert.equal(settle(mutual, chooseStone(mutual, 'kill'), [mutual.richese, mutual.opponent]).quote.winner, null);
const explosion = createRicheseStrongholdsFixture({ stone: true, ownerDefense: 'shield', opponentWeapon: 'lasgun', ownerDial: 1, support: 1, opponentDial: 1, opponentSupport: 1 });
const exploded = settle(explosion, chooseStone(explosion, 'kill'));
assert.equal(exploded.quote.explosion, true);
assert.equal(exploded.quote.winner, null);
assert.equal(exploded.quote.bounty, null);
assert.equal(richeseStrongholdsPlayer(exploded.resolved, explosion.richese).tanks, 6);
assert.equal(richeseStrongholdsPlayer(exploded.resolved, explosion.opponent).tanks, 6);
});
void test('all four existing policies legally consume real Richese marker plans, Stone admission and both-mode choice views', () => {
const marker = createRicheseStrongholdsFixture({ noFieldValue: 3, kind: 'arrakeen' });
const stone = createRicheseStrongholdsFixture({ stone: true, ownerDial: 1, support: 1, opponentDial: 1, opponentSupport: 1 });
for (const difficulty of DIFFICULTIES) {
for (const fixture of [marker, stone]) {
let game = structuredClone(fixture.game);
for (const actor of [fixture.richese, fixture.opponent]) {
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan'); assert.ok(plan, difficulty);
game = applyAction(game, actor, plan);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
while (game.decision || game.response) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
settle(fixture, game);
}
const game = revealRicheseStrongholdsBattle(stone);
const view = viewGame(game, stone.richese); view.players.find(p => p.id === stone.richese)!.bot = difficulty;
const mode = botActions(view)[0];
assert.ok(mode.mode === 'kill' || mode.mode === 'ignore');
const chosen = applyAction(game, stone.richese, mode);
assert.equal(chosen.battle!.stoneBurner![stone.richese], mode.mode);
assert.equal(finishRicheseStrongholdsBattle(stone, chosen).lastBattleContext!.winner, stone.richese);
}
});