Files
Dune/tests/victory-extended-turn-runtime.test.ts
Quintine 64706cc7e0
Verify and publish checkpoint container / container (push) Successful in 10m42s
Check Advanced victory after Mentat actions
2026-10-29 09:15:34 +11:00

221 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { baseDeck, spiceDeck } from '../game/cards';
import { createTechTokens } from '../game/tech-tokens';
import type { FactionId } from '../game/catalog';
import { actAdvancedRevivalOrder, nextAdvancedRevivalOrder } from './fixture-advanced-revival-order';
/** Controlled, conserved late-game position. Original strongholds and Tech
* ownership are directly arranged to expose the rare simultaneous qualifying
* result. Collection, Mentat and agreement actions use the live engine; this
* is not an earned ten-turn game or a saved-room test. */
function position(options: { advanced?: boolean; guild?: boolean; fremen?: boolean; choam?: boolean; prediction?: boolean } = {}): Game {
const g = createGame('EXTENDEDVICTORY', newPlayer('ix', 'Ixians', 'ixians'), options.advanced ?? true, options.choam ? ['choam'] : []);
const seats: [string, string, FactionId][] = [
['at', 'Atreides', 'atreides'], ['em', 'Emperor', 'emperor'], ['bg', 'Bene Gesserit', 'beneGesserit'],
];
if (options.guild) seats.push(['gu', 'Guild', 'guild']);
if (options.fremen) seats.push(['fr', 'Fremen', 'fremen']);
if (options.choam) seats.push(['ch', 'CHOAM', 'choam']);
for (const [id, name, faction] of seats) g.players.push(newPlayer(id, name, faction));
assert.ok(g.players.length <= 6);
Object.assign(g, { status: 'playing', phase: 7, turn: 10, storm: 18,
order: g.players.map(player => player.id), ready: [], phaseOpening: null,
decision: null, response: null, active: null, deck: baseDeck(), spiceDeck: spiceDeck(),
mobileStronghold: { location: 'red_chasm:7' }, techTokens: createTechTokens() });
for (const p of g.players) Object.assign(p, { hand: [], forces: {},
reserves: 20, spice: 20, traitors: [], traitorChoices: [] });
const hold = (id: string, ...names: string[]) => {
const p = g.players.find(player => player.id === id)!;
for (const name of names) p.forces[`${name}:${territory(name).sectors[0]}`] = 1;
p.reserves -= names.length;
};
g.players[1].ally = 'em'; g.players[2].ally = 'at';
hold('at', 'arrakeen', 'carthag'); hold('em', 'sietch_tabr');
hold('ix', 'tueks_sietch', 'habbanya_ridge_sietch');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1; g.players[0].reserves--;
for (const token of Object.values(g.techTokens!)) token.owner = 'at';
if (options.prediction) g.players[3].prediction = { faction: 'atreides', turn: 10 };
return g;
}
function enterMentat(source: Game): Game {
let g = structuredClone(source);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.phase === 7 && g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.phase, 8);
if (g.players.some(player => player.faction === 'choam')) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.decision?.kind, 'choamMentat');
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
} else if (g.advanced) {
// Fresh Advanced Mentat evaluates victory after all ordinary opportunities.
assert.equal(g.status, 'playing');
assert.ok(!g.victoryContinuation);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
}
return g;
}
function offer(g: Game) {
assert.ok(g.victoryContinuation, 'The actual Mentat check offers the qualifying sides a vote');
return g.victoryContinuation;
}
function vote(g: Game, id: string, mode: 'finish' | 'continue' | 'accept', extra?: number): Game {
return applyAction(g, id, { type: 'victoryContinuation', event: offer(g).event, mode,
...(extra === undefined ? {} : { extra }) });
}
function accept(g: Game, extra = 0): Game {
const voters = [...offer(g).voters];
g = vote(g, voters[0], 'continue', extra);
for (const id of voters.slice(1)) g = vote(g, id, 'accept');
return g;
}
function finishMentat(g: Game): Game {
if (g.phase !== 8) return g;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function reject(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
function startEleventh(source = position(), extra = 0): Game {
let g = accept(enterMentat(source), extra);
assert.equal(g.victoryFinalTurn, 11);
if (g.phase === 8) g = finishMentat(g);
assert.equal(g.status, 'playing'); assert.equal(g.turn, 11); assert.equal(g.phase, 0);
return g;
}
/** Controlled follow-up: retain the actual extended-turn state, but advance
* its phase/ready boundary directly to Collection. Tests below do not claim a
* full natural turn from Storm to Collection; all later checks and votes are
* native engine actions on the labelled board. */
function stagedCollection(source: Game): Game {
const g = structuredClone(source);
Object.assign(g, { phase: 7, ready: [], active: null, decision: null, response: null,
phaseOpening: null, stormPending: null, stormResolution: null });
return g;
}
function clearForces(g: Game) {
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((total, amount) => total + amount, 0);
p.forces = {};
}
}
void test('unanimous turn-ten agreement opens real turn eleven without replaying the prior board', () => {
const original = position();
const opening = enterMentat(original);
const before = opening.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves }));
const agreed = accept(opening, 1);
assert.equal(agreed.turn, 11); assert.equal(agreed.phase, 0);
assert.equal(agreed.victoryFinalTurn, 11); assert.equal(agreed.victoryExtraStrongholds, 1);
assert.deepEqual(agreed.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before);
const next = finishMentat(agreed);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 11); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 11); assert.equal(viewGame(next, 'ix').turn, 11);
assert.deepEqual(next.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before);
});
void test('Basic qualifying sides may also agree to play turn eleven', () => {
const next = startEleventh(position({ advanced: false }));
assert.equal(next.advanced, false);
assert.equal(next.victoryFinalTurn, 11);
});
void test('accepted turn eleven follows native phases through its next Mentat check', () => {
const source = position();
// The turn-ten public board is controlled as above. Select two existing,
// unopened ordinary Spice cards to avoid an unrelated worm negotiation;
// this is not a claim about their naturally shuffled original order.
const originalSpice = source.spiceDeck.map(card => JSON.stringify(card)).sort();
const front: Game['spiceDeck'] = [];
for (let i = 0; i < 2; i++) {
const index = source.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(index >= 0);
front.push(source.spiceDeck.splice(index, 1)[0]);
}
source.spiceDeck.unshift(...front);
assert.deepEqual(source.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let game = startEleventh(source, 1);
const phases = new Set<number>();
const actions: { actor: string; action: Action }[] = [];
for (let count = 0; count < 1500 && game.status === 'playing' && game.phase !== 8; count++) {
assert.equal(game.turn, 11);
phases.add(game.phase);
const next = nextAdvancedRevivalOrder(game);
assert.ok(next, `Native turn-11 control unavailable at phase ${game.phase}, decision ${game.decision?.kind ?? 'none'}`);
game = actAdvancedRevivalOrder(game, next.actor, next.action, actions);
}
assert.equal(game.turn, 11);
assert.equal(game.phase, 8, 'The original phase sequence reaches the next Mentat check');
assert.equal(game.status, 'playing');
assert.ok(!game.victoryContinuation);
for (const p of game.players) game = actAdvancedRevivalOrder(game, p.id, { type: 'ready' }, actions);
assert.ok(game.status === 'finished' || game.victoryContinuation,
'The extended final-turn check settles a winner or offers the next agreement');
assert.equal(game.victoryFinalTurn, 11);
assert.ok(actions.length > 0);
for (const phase of [0, 1, 2, 3, 4, 5, 7])
assert.ok(phases.has(phase), `Native phase ${phase} was not exercised`);
});
void test('a turn-ten refusal settles the original result and private prediction', () => {
const g = enterMentat(position({ prediction: true })), pending = offer(g);
reject(g, 'bg', { type: 'victoryContinuation', event: pending.event, mode: 'finish' });
const settled = vote(g, pending.voters[0], 'finish');
assert.equal(settled.status, 'finished'); assert.equal(settled.turn, 10);
assert.deepEqual(settled.winner, ['bg']); assert.ok(!settled.victoryContinuation);
});
void test('a second unanimous agreement at turn eleven extends the final turn again', () => {
const controlled = stagedCollection(startEleventh()), opening = enterMentat(controlled);
assert.equal(opening.turn, 11); assert.equal(offer(opening).turn, 11);
const agreed = accept(opening);
assert.equal(agreed.victoryFinalTurn, 12);
const next = finishMentat(agreed);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 12); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 12);
});
void test('extended final-turn fallback applies Fremen, Guild and absent-faction outcomes', () => {
for (const [options, expected] of [
[{ guild: true, fremen: true }, 'fr'], [{ guild: true }, 'gu'], [{}, 'ix'],
] as const) {
const controlled = stagedCollection(startEleventh(position(options), 1));
clearForces(controlled);
// The Atreides-owned Tech set gives it one hold in the no-Guild fallback.
// Shift that physical set to Ix for an unambiguous highest-count winner.
for (const token of Object.values(controlled.techTokens!)) token.owner = 'ix';
const settled = enterMentat(controlled);
assert.equal(settled.turn, 11); assert.equal(settled.status, 'finished');
assert.deepEqual(settled.winner, [expected]);
}
});
void test('a sole qualifying stronghold winner precedes fallback on the extended final turn', () => {
const controlled = stagedCollection(startEleventh());
// Controlled conserved removal prevents the alliance from qualifying while
// leaving the original Ixian three-stronghold group intact.
const emperor = controlled.players.find(p => p.id === 'em')!;
emperor.reserves += Object.values(emperor.forces).reduce((sum, count) => sum + count, 0);
emperor.forces = {};
const settled = enterMentat(controlled);
assert.equal(settled.status, 'finished'); assert.deepEqual(settled.winner, ['ix']);
});
void test('CHOAM Mentat continuation advances exactly once from turn ten to eleven', () => {
const opening = enterMentat(position({ choam: true }));
assert.equal(offer(opening).resume, 'advance');
const next = accept(opening, 1);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 11); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 11); assert.equal(next.victoryExtraStrongholds, 1);
});