221 lines
11 KiB
TypeScript
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);
|
|
});
|