213 lines
14 KiB
TypeScript
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);
|
|
}
|
|
});
|