Add optional short alliance victory
Verify and publish checkpoint container / container (push) Successful in 11m2s
Verify and publish checkpoint container / container (push) Successful in 11m2s
This commit is contained in:
1 parent
8fb9f59106
commit
e896570f07
25 files changed
+632
-27
No files matched your search
@@ -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'));
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user