Add optional short alliance victory
Verify and publish checkpoint container / container (push) Successful in 11m2s

This commit is contained in:
Quintine committed 2026-10-28 21:40:24 +11:00
1 parent 8fb9f59106
commit e896570f07
25 files changed
+632 -27

No files matched your search

+16 -2
View File
@@ -1,11 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { adminLobbyConfirmation, adminLobbyResponse, clearAdminLobbyRequest, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '../lib/admin-lobby-client';
import { adminLobbyConfirmation, adminLobbyResponse, clearAdminLobbyRequest, describeAdminLobbyAction, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '../lib/admin-lobby-client';
import type { AdminLobbyView } from '../lib/admin-lobby-configuration';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
const view = (): AdminLobbyView => ({ code: 'ABCD2345', version: 4, status: 'lobby', editable: true, blockedReason: null, host: 'host',
advanced: false, techTokens: false, strongholdCards: false, control: { ...DEFAULT_ROOM_CONTROL },
advanced: false, techTokens: false, strongholdCards: false, shortAllianceVictory: false, control: { ...DEFAULT_ROOM_CONTROL },
players: [{ id: 'host', name: 'Host', faction: 'atreides', bot: null, position: 1, ready: false, hostEligible: true }] });
function storage() {
const values = new Map<string, string>();
@@ -59,3 +59,17 @@ void test('lobby response accepts unreadable room diagnostics but rejects privat
{ ...lobby, control: { ...lobby.control, reason: 'Private audit' } }, { ...lobby, players: [{ ...lobby.players[0], position: 7 }] }])
assert.throws(() => adminLobbyResponse(invalid, lobby.code));
});
void test('short alliance selection survives the same validated retry and confirmation path', () => {
const current = view(), tab = storage();
const action = { type: 'shortAllianceVictory' as const, enabled: true };
const input = newAdminLobbyRequest(current, action, 'Use shorter alliance game');
assert.match(describeAdminLobbyAction(action, current), /Enable.*three-stronghold alliance victory/);
saveAdminLobbyRequest(tab, 'a', current.code, input);
assert.deepEqual(readAdminLobbyRequest(tab, 'a', current.code), input);
const updated = { ...current, version: 5, shortAllianceVictory: true };
assert.equal(adminLobbyResponse(updated, current.code).shortAllianceVictory, true);
assert.equal(adminLobbyConfirmation({ operationId: input.operationId, replayed: false, appliedVersion: 5, lobby: updated }, current.code, input).lobby.shortAllianceVictory, true);
assert.throws(() => adminLobbyResponse({ ...updated, shortAllianceVictory: 'true' }, current.code));
assert.throws(() => newAdminLobbyRequest(current, { ...action, enabled: 'true' } as unknown as typeof action, 'QA'));
});
+36 -1
View File
@@ -191,11 +191,12 @@ void test('lobby migration is additive and preserves existing game/seat/admin/cr
}
});
void test('admin lobby input permits only seven exact bounded commands with valid base factions and public parameters', () => {
void test('admin lobby input permits only exact bounded commands with valid base factions and public parameters', () => {
const valid: AdminLobbyAction[] = [
{ type: 'rules', advanced: true },
{ type: 'techTokens', enabled: true },
{ type: 'strongholdCards', enabled: false },
{ type: 'shortAllianceVictory', enabled: true },
{ type: 'addBot', faction: 'guild', difficulty: 'Hard' },
{ type: 'removeBot', target: 'bot' },
{
@@ -217,6 +218,8 @@ void test('admin lobby input permits only seven exact bounded commands with vali
{ type: 'addBot', faction: 'ixians', difficulty: 'Hard' },
{ type: 'addBot', faction: 'guild', difficulty: 'Impossible' },
{ type: 'rules', advanced: 'true' },
{ type: 'shortAllianceVictory', enabled: 'true' },
{ type: 'shortAllianceVictory', enabled: true, extra: true },
{ type: 'assignHost', target: '' },
{ type: 'assignHost', target: 'guest', token: secret() },
{
@@ -264,6 +267,7 @@ void test('viewer receives only public lobby configuration and active-human host
'editable',
'host',
'players',
'shortAllianceVictory',
'status',
'strongholdCards',
'techTokens',
@@ -298,6 +302,37 @@ void test('viewer receives only public lobby configuration and active-human host
}
});
void test('audited admin short-alliance selection preserves seats, resets readiness and replays exactly once', async () => {
const f = await fixture();
try {
assert.equal((await f.read()).shortAllianceVictory, false);
for (const auth of [f.auth, f.guestAuth])
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
const secure = f.secureRows();
const input = operation(f.version(), { type: 'shortAllianceVictory', enabled: true });
const changed = await f.configure(input);
assert.equal(changed.lobby.shortAllianceVictory, true);
assert.ok(changed.lobby.players.every(player => !player.ready));
assert.equal((await f.rooms.readRoom(f.code)).shortAllianceVictory, true);
assert.deepEqual(f.secureRows(), secure);
assert.equal(audits(f).length, 1);
const audit = audits(f)[0];
assert.equal(JSON.parse(String(audit.before_configuration)).shortAllianceVictory, false);
assert.equal(JSON.parse(String(audit.after_configuration)).shortAllianceVictory, true);
const replay = await f.configure(input);
assert.equal(replay.replayed, true);
assert.equal(replay.appliedVersion, changed.appliedVersion);
assert.equal(replay.lobby.shortAllianceVictory, true);
assert.equal(audits(f).length, 1);
const disabled = await f.configure(operation(f.version(), { type: 'shortAllianceVictory', enabled: false }));
assert.equal(disabled.lobby.shortAllianceVictory, false);
assert.equal((await f.rooms.readRoom(f.code)).shortAllianceVictory, undefined);
assert.deepEqual(f.secureRows(), secure);
} finally {
f.sqlite.close();
}
});
void test('neutral admin changes reuse ordinary rule/AI actions, preserve host sessions and clear human readiness', async () => {
const f = await fixture();
try {
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { allianceVictoryTarget } from '../game/alliance-victory-option';
import type { TwoPlayerControllers } from '../game/two-player-controllers';
const seats = ['atreides', 'harkonnen', 'fremen', 'guild', 'emperor', 'beneGesserit'];
const twoPeople: TwoPlayerControllers = {
version: 1,
controllers: [
{ id: 'atreides', seats: ['atreides', 'harkonnen'] },
{ id: 'fremen', seats: ['fremen', 'guild'] },
],
};
void test('existing solo and alliance targets remain unchanged unless the option is selected', () => {
for (let count = 3; count <= 6; count++) {
const players = seats.slice(0, count);
assert.equal(allianceVictoryTarget(players, undefined, false), 3);
assert.equal(allianceVictoryTarget(players, undefined, true), 4);
assert.equal(allianceVictoryTarget(players, null, true, false), 4);
assert.equal(allianceVictoryTarget(players, undefined, false, true), 3);
assert.equal(allianceVictoryTarget(players, undefined, true, true), 3);
}
});
void test('two physical controllers retain four strongholds with either setup', () => {
const before = structuredClone(twoPeople);
assert.equal(allianceVictoryTarget(seats.slice(0, 2), undefined, false, true), 4);
assert.equal(allianceVictoryTarget(seats.slice(0, 2), undefined, true, true), 4);
assert.equal(allianceVictoryTarget(seats.slice(0, 4), twoPeople, false, true), 4);
assert.equal(allianceVictoryTarget(seats.slice(0, 4), twoPeople, true, true), 4);
assert.deepEqual(twoPeople, before);
});
void test('invalid selection, side and controller mappings cannot change the target', () => {
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), undefined, true, null as unknown as boolean), TypeError);
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), undefined, true, 'yes' as unknown as boolean), TypeError);
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), undefined, 'yes' as unknown as boolean, true));
assert.throws(() => allianceVictoryTarget(['same', 'same', 'third'], undefined, true, true));
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), twoPeople, true, true));
});
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { territory } from '../game/board';
import { botActions } from '../game/bots';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
export type ShortAllianceStep = { actor: string; action: Action };
export function shortAllianceLobby(): Game {
const game = createGame('SHORTALLIANCE', newPlayer('at', 'Atreides', 'atreides'));
joinGame(game, newPlayer('em', 'Emperor', 'emperor'));
joinGame(game, newPlayer('gu', 'Guild', 'guild'));
return game;
}
/** Only real owned controls advance setup and the first turn. Quiet Movement
* is deliberately ended so the fixture cannot win before the real Nexus. */
function nextStep(game: Game): ShortAllianceStep | null {
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (!action) continue;
if (game.status === 'playing' && game.phase === 5 && game.active === player.id &&
!game.response && !game.decision && !game.truthtrance && !game.phaseOpening)
return { actor: player.id, action: { type: 'endMovement' } };
return { actor: player.id, action };
}
return null;
}
export function advanceShortAlliance(game: Game, predicate: (game: Game) => boolean,
actions: ShortAllianceStep[], maximum = 1200): Game {
for (let count = 0; count < maximum; count++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before the requested boundary.');
const step = nextStep(game);
assert.ok(step, `No native continuation at ${game.status}/${game.turn}/${game.phase}.`);
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original game did not reach the requested alliance boundary.');
}
/** Genuine Basic setup and genuine Nexus/alliance; only the unopened original
* Spice deck order is selected to make the next normal worm deterministic. */
export function genuineShortAlliance(enabled: boolean) {
let game = shortAllianceLobby();
const actions: ShortAllianceStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = applyAction(game, actor, action);
};
if (enabled) act('at', { type: 'shortAllianceVictory', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
act('at', { type: 'start' });
game = advanceShortAlliance(game, state => state.status === 'playing' && state.turn === 2 && state.phase === 0, actions);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(worm >= 0, 'The original unopened deck retains an ordinary worm.');
const beforeDeck = game.spiceDeck.map(card => JSON.stringify(card)).sort();
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeDeck);
game = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
!state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions);
act('at', { type: 'alliance', target: 'em' });
act('em', { type: 'alliance', target: 'at' });
assert.equal(game.players.find(player => player.id === 'at')!.ally, 'em');
assert.equal(game.players.find(player => player.id === 'em')!.ally, 'at');
game = advanceShortAlliance(game, state => !state.nexus && state.phase >= 2, actions);
return { game, actions };
}
/** The setup and alliance above are real. For the rare three-stronghold
* victory boundary, this arranges conserved forces and the Collection phase;
* the actual final Collection readiness and Mentat win remain native actions. */
export function shortAllianceCollection(source: Game): Game {
const game = structuredClone(source);
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
const at = game.players.find(player => player.id === 'at')!;
const em = game.players.find(player => player.id === 'em')!;
for (const id of ['arrakeen', 'carthag']) {
at.forces[`${id}:${territory(id).sectors[0]}`] = 1;
at.reserves--;
}
em.forces[`tueks_sietch:${territory('tueks_sietch').sectors[0]}`] = 1;
em.reserves--;
Object.assign(game, { phase: 7, ready: [], active: null, storm: 18,
response: null, decision: null, truthtrance: null, phaseOpening: null });
return game;
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { TERRITORIES } from '../game/board';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthRevival, createTruthRevivalFixture, nextTruthRevival, truthRevivalClean,
truthRevivalInventory, truthRevivalPlayer,
} from './fixture-truth-revival';
import { futureBattleLeaderRecords } from './fixture-truth-future-battle-leader';
export const actFutureBattleRevival = actTruthRevival;
export const futureBattleRevivalInventory = truthRevivalInventory;
export const futureBattleRevivalRecords = futureBattleLeaderRecords;
/** The source has real Basic turn-two storm casualties and has reached quiet
* Revival, with one original Truthtrance still in Harkonnen's hand. */
export function createFutureBattleRevivalFixture() {
const base = createTruthRevivalFixture();
const game = structuredClone(base.game);
assert.equal(game.phase, 4); assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, []); assert.ok(truthRevivalClean(game));
const target = truthRevivalPlayer(game, base.target);
const available = target.leaders.filter(leader => !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(available.length >= 2);
const leader = available[0].id, alternative = available[1].id;
const question = {kind: 'futureBattleLeader', target: base.target, turn: game.turn, leader};
return { base, game, source: structuredClone(game), asker: base.asker, target: base.target,
truth: base.truth, leader, alternative, question, originalCards: base.originalCards,
ask: {type: 'truthAsk', question} as unknown as Action };
}
export type FutureBattleRevivalFixture = ReturnType<typeof createFutureBattleRevivalFixture>;
export function askFutureBattleRevival(f: FutureBattleRevivalFixture): Game {
let game = actFutureBattleRevival(f.game, f.asker, {type: 'card', card: f.truth});
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const seat = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(seat);
game = actFutureBattleRevival(game, seat.id, {type: 'truthPass'});
}
assert.equal(game.truthtrance?.stage, 'ask');
return actFutureBattleRevival(game, f.asker, f.ask);
}
/** Finish Revival and skip unrelated Movement opportunities, preserving the
* target's own turn for its explicit decision or no-battle expiry. */
export function reachFutureBattleRevivalMovement(game: Game, f: FutureBattleRevivalFixture): Game {
for (let count = 0; count < 800; count++) {
if (game.phase === 5 && game.active === f.target && truthRevivalClean(game)) return game;
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = actFutureBattleRevival(game, game.active, {type: 'endMovement'}); continue;
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the original native Revival opportunity.');
game = actFutureBattleRevival(game, next.actor, next.action);
}
throw Error('The original Revival source did not reach the respondent Movement turn.');
}
/** Make a real one-force shipment into an existing unallied opponent group,
* then use ordinary battle-selection and preparation actions. */
export function reachFutureBattleRevivalBattle(game: Game, f: FutureBattleRevivalFixture): Game {
const target = truthRevivalPlayer(game, f.target);
const location = game.players.filter(player => player.id !== f.target && player.id !== target.ally)
.flatMap(player => Object.entries(player.forces)).find(([key, count]) => {
const separator = key.lastIndexOf(':'), name = key.slice(0, separator), sector = Number(key.slice(separator + 1));
return count > 0 && sector !== game.storm && TERRITORIES.some(territory => territory.id === name && territory.sectors.includes(sector));
});
assert.ok(location, 'The native source retains an eligible opponent group.');
const separator = location[0].lastIndexOf(':'), territory = location[0].slice(0, separator);
const sector = Number(location[0].slice(separator + 1));
game = actFutureBattleRevival(game, f.target, {type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0});
for (let count = 0; count < 800; count++) {
if (game.phase === 5 && game.active === f.target && truthRevivalClean(game)) {
game = actFutureBattleRevival(game, f.target, {type: 'endMovement'}); continue;
}
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = actFutureBattleRevival(game, game.active, {type: 'endMovement'}); continue;
}
if (game.phase === 6 && game.battle && [game.battle.attacker, game.battle.defender].includes(f.target) &&
truthRevivalClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
if (game.phase === 6 && !game.battle && game.active && truthRevivalClean(game)) {
const choice = viewGame(game, game.active).battleChoices.find(row => row.territory === territory &&
[row.attacker, row.defender].includes(f.target));
if (choice) {
game = actFutureBattleRevival(game, choice.chooser, {type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker});
continue;
}
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the original native battle preparation.');
game = actFutureBattleRevival(game, next.actor, next.action);
}
throw Error('The native source did not reach the target’s first battle.');
}
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { advanceShortAlliance, shortAllianceLobby, type ShortAllianceStep } from './fixture-short-alliance-victory';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const table = (game: GameView, busy = false) => renderToStaticMarkup(createElement(GameTable,
{ game, busy, send: async () => {}, onExit() {} }));
function allianceLabel(html: string) {
const label = html.match(/<label\b[^>]*>(?:(?!<\/label>)[\s\S])*Optional: two-member alliances win with three strongholds(?:(?!<\/label>)[\s\S])*<\/label>/)?.[0];
assert.ok(label, 'The optional alliance victory control is present.');
return label;
}
void test('short alliance lobby option shows its selected state and belongs to the host', () => {
const original = shortAllianceLobby();
const fresh = allianceLabel(table(viewGame(original, 'at')));
assert.match(fresh, /type="checkbox"/);
assert.doesNotMatch(fresh, /\bchecked=/);
assert.doesNotMatch(fresh, /\bdisabled=/);
const selected = applyAction(original, 'at', { type: 'shortAllianceVictory', enabled: true });
const host = allianceLabel(table(viewGame(selected, 'at')));
const guest = allianceLabel(table(viewGame(selected, 'em')));
assert.match(host, /\bchecked=/);
assert.doesNotMatch(host, /\bdisabled=/);
assert.match(guest, /\bchecked=/);
assert.match(guest, /\bdisabled=/);
assert.match(allianceLabel(table(viewGame(selected, 'at'), true)), /\bdisabled=/);
assert.throws(() => applyAction(selected, 'em', { type: 'shortAllianceVictory', enabled: false }));
});
void test('a real started game keeps its selected alliance rule visible to every seat', () => {
let game = applyAction(shortAllianceLobby(), 'at', { type: 'shortAllianceVictory', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, 'at', { type: 'start' });
const actions: ShortAllianceStep[] = [];
game = advanceShortAlliance(game, state => state.status === 'playing', actions);
assert.ok(actions.length > 0, 'Complete the real private setup controls.');
for (const player of game.players) {
const html = table(viewGame(game, player.id));
assert.match(html, /Optional alliance rule: a two-member alliance wins with three strongholds/);
assert.match(html, /Two-controller games retain their four-stronghold target/);
}
});
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { quoteVictory } from '../game/victory-quote';
import { advanceShortAlliance, genuineShortAlliance, shortAllianceCollection, shortAllianceLobby,
type ShortAllianceStep } from './fixture-short-alliance-victory';
void test('only the host changes the optional target, clearing readiness and preserving the saved selection', () => {
let game = shortAllianceLobby();
assert.equal(viewGame(game, 'at').shortAllianceVictory, false);
game = applyAction(game, 'at', { type: 'ready' });
game = applyAction(game, 'em', { type: 'ready' });
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'em', { type: 'shortAllianceVictory', enabled: true }));
assert.throws(() => applyAction(game, 'at', { type: 'shortAllianceVictory', enabled: 'true' }));
assert.deepEqual(game, before);
game = applyAction(game, 'at', { type: 'shortAllianceVictory', enabled: true });
assert.equal(game.shortAllianceVictory, true);
assert.ok(game.players.every(player => !player.ready));
for (const player of game.players) assert.equal(viewGame(game, player.id).shortAllianceVictory, true);
const restored = JSON.parse(JSON.stringify(game)) as Game;
assert.equal(viewGame(restored, 'em').shortAllianceVictory, true);
game = applyAction(restored, 'at', { type: 'shortAllianceVictory', enabled: false });
assert.equal(viewGame(game, 'at').shortAllianceVictory, false);
assert.equal(game.shortAllianceVictory, undefined);
});
for (const enabled of [false, true]) {
void test(`Basic genuine Nexus alliance ${enabled ? 'wins' : 'does not win'} three strongholds under ${enabled ? 'short' : 'default'} rules`, () => {
const native = genuineShortAlliance(enabled);
assert.ok(native.actions.some(step => step.action.type === 'alliance' && step.actor === 'at'));
assert.ok(native.actions.some(step => step.action.type === 'alliance' && step.actor === 'em'));
const game = shortAllianceCollection(native.game);
const before = structuredClone(game);
const progress = viewGame(game, 'at').victoryProgress.find(row => row.player === 'at');
assert.ok(progress);
assert.deepEqual(new Set(progress.members), new Set(['at', 'em']));
assert.equal(progress.strongholds.length, 3);
assert.equal(progress.target, enabled ? 3 : 4);
assert.equal(progress.qualifies, enabled);
const quote = quoteVictory(game);
assert.deepEqual(new Set(quote.winner), new Set(enabled ? ['at', 'em'] : []));
let current = game;
for (const player of game.players) {
if (current.status === 'finished') break;
current = applyAction(current, player.id, { type: 'ready' });
}
assert.deepEqual(game, before, 'Checking and resolving must not mutate the staged source.');
assert.equal(current.status, enabled ? 'finished' : 'playing');
assert.deepEqual(new Set(current.winner), new Set(enabled ? ['at', 'em'] : []));
assert.equal(viewGame(JSON.parse(JSON.stringify(current)), 'at').shortAllianceVictory, enabled);
});
}
void test('two real controllers with four faction seats retain the four-stronghold target', () => {
let game = createGame('TWOALLIANCE', newPlayer('at', 'Atreides', 'atreides'));
game = applyAction(game, 'at', { type: 'twoPlayerMode', enabled: true });
joinControlledFaction(game, newPlayer('em', 'Emperor', 'emperor'), 'at');
joinGame(game, newPlayer('gu', 'Guild', 'guild'));
joinControlledFaction(game, newPlayer('fr', 'Fremen', 'fremen'), 'gu');
game = applyAction(game, 'at', { type: 'shortAllianceVictory', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, 'at', { type: 'start' });
const actions: ShortAllianceStep[] = [];
game = advanceShortAlliance(game, state => state.status === 'playing', actions);
// This controlled two-person boundary stages reciprocal alliance and board
// positions; the other cases above exercise an actual Nexus alliance.
game.players.find(player => player.id === 'at')!.ally = 'em';
game.players.find(player => player.id === 'em')!.ally = 'at';
game = shortAllianceCollection(game);
const progress = viewGame(game, 'at').victoryProgress.find(row => row.player === 'at');
assert.ok(progress);
assert.equal(progress.target, 4);
assert.equal(progress.qualifies, false);
assert.deepEqual(quoteVictory(game).winner, []);
});
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type Action, type Game } from '../game/engine';
import { nextTruthRevival, truthRevivalClean, truthRevivalPlayer } from './fixture-truth-revival';
import {
actFutureBattleRevival as act, askFutureBattleRevival as ask,
createFutureBattleRevivalFixture as fixture, futureBattleRevivalInventory as inventory,
futureBattleRevivalRecords as records, reachFutureBattleRevivalBattle as reachBattle,
reachFutureBattleRevivalMovement as reachMovement,
} from './fixture-truth-future-battle-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
for (const answer of ['yes', 'no'] as const) {
void test(`Basic Revival future-battle leader ${answer} binds the first actual later battle`, () => {
const f = fixture(), asked = ask(f);
assert.equal(asked.phase, 4); assert.deepEqual(asked.truthtrance?.question, f.question);
let game = act(asked, f.target, {type: 'truthAnswer', answer});
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'waiting');
game = reachMovement(game, f);
assert.equal(records(game)[0].status, 'waiting');
game = reachBattle(game, f);
assert.equal(records(game)[0].status, 'bound');
assert.equal(records(game)[0].battle, game.battle!.event);
const plan = (leader: string): Action => ({type: 'battlePlan', leader, dial: 0, support: 0, weapon: null, defense: null});
rejected(game, f.target, plan(answer === 'yes' ? f.alternative : f.leader));
game = act(game, f.target, plan(answer === 'yes' ? f.leader : f.alternative));
assert.equal(records(game)[0].status, 'bound');
const opponent = game.battle!.attacker === f.target ? game.battle!.defender : game.battle!.attacker;
const other = truthRevivalPlayer(game, opponent).leaders.find(leader =>
!leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(other);
game = act(game, opponent, plan(other.id));
assert.ok(game.battle?.revealed); assert.equal(records(game)[0].status, 'settled');
assert.equal(records(game).length, 1);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
}
void test('Basic Revival conditional leader answers expire if the respondent never fights', () => {
const f = fixture();
for (const answer of ['yes', 'no'] as const) {
let game = act(ask(f), f.target, {type: 'truthAnswer', answer});
game = reachMovement(game, f);
assert.ok(Object.values(truthRevivalPlayer(game, f.target).forces).every(amount => amount === 0));
for (let count = 0; count < 1200 && game.turn === f.game.turn && game.phase <= 6 && game.status === 'playing'; count++) {
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = act(game, game.active, {type: 'endMovement'}); continue;
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the original turn without inventing a target battle.');
game = act(game, next.actor, next.action);
}
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'expired');
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('a pending native Truthtrance priority must finish before its Revival-phase question can be asked', () => {
const f = fixture();
const game = act(f.game, f.asker, {type: 'card', card: f.truth});
assert.equal(game.truthtrance?.stage, 'priority');
rejected(game, f.asker, f.ask);
assert.deepEqual(inventory(game), f.originalCards);
});