Integrate expanded native Stronghold faction rules
Broaden existing Advanced Stronghold profile to selected E1/E2 native pairs/mixed families and classic opponents;35/47 ordinary decks with separate Richese10cache. Native No-Field/real Stone acquisition/battles, FaceDancer originalwinner/replacement/endMentat and legalAI reuse existinghandlers. Targeted16, actual11 rule cases,18unstaged games11066actions/no rejects/290JSON restores; corrected per-family reporter exercised on3two-seat families. Human390pxG7VDY6KBv12 Stone kill/Habbanya/retain and736HJUD3v8 two-reserve FaceDance. Finalquick26/build/guide205links. No new save/privacy/custody/HTTP/full6500 assurance campaign, pending rulings/public/deployed gates unchanged.
This commit is contained in:
1 parent
05ff2697bc
commit
288870c842
16 files changed
+1067
-82
No files matched your search
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
||||
|
||||
import type { Game } from '../game/engine';
|
||||
import { isAuditorLeader, treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
|
||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
@@ -105,41 +106,58 @@ void test('native Stronghold samples admit every two-to-six-seat roster and pres
|
||||
assert.equal(run(out, '--profile', 'stronghold-factions',
|
||||
'--players', 'all', '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
const results = json<{ results: CliResult[] }>(join(out, 'results.json')).results;
|
||||
assert.deepEqual(results.map(result => result.name),
|
||||
[2, 3, 4, 5, 6].map(seats => `stronghold-factions-${seats}-advanced`));
|
||||
const roster = ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'];
|
||||
const canonical = treacheryDeck(['ix', 'choam']).map(card => card.id).sort();
|
||||
assert.equal(canonical.length, 47);
|
||||
assert.equal(new Set(canonical).size, 47);
|
||||
const families = [
|
||||
{ native: 'ixians-choam', expansions: ['ix', 'choam'],
|
||||
roster: ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 151 },
|
||||
{ native: 'ixians-tleilaxu', expansions: ['ix'],
|
||||
roster: ['ixians', 'tleilaxu', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 156 },
|
||||
{ native: 'choam-richese', expansions: ['choam'],
|
||||
roster: ['choam', 'richese', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 161 },
|
||||
{ native: 'ixians-tleilaxu-choam-richese', expansions: ['ix', 'choam'],
|
||||
roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'], counts: [4, 5, 6], ordinal: 166 },
|
||||
];
|
||||
const scenarios = families.flatMap(family => family.counts.map((count, index) => ({
|
||||
...family, count, seed: 1000 + family.ordinal + index,
|
||||
name: `stronghold-factions-${family.native}-${count}-advanced`,
|
||||
})));
|
||||
assert.deepEqual(results.map(result => result.name), scenarios.map(scenario => scenario.name));
|
||||
for (const [index, result] of results.entries()) {
|
||||
const scenario = scenarios[index];
|
||||
assert.equal(result.seed, scenario.seed);
|
||||
assert.equal(result.error, 'Error: Action limit');
|
||||
assert.equal(result.actions, 1);
|
||||
const game = json<Game>(join(out, `failed-${result.name}.json`));
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, ['ix', 'choam']);
|
||||
assert.deepEqual(game.players.map(player => player.faction), roster.slice(0, index + 2));
|
||||
assert.deepEqual(game.expansions, scenario.expansions);
|
||||
assert.deepEqual(game.players.map(player => player.faction), scenario.roster.slice(0, scenario.count));
|
||||
assert.deepEqual(game.players.map(player => player.bot),
|
||||
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, index + 2));
|
||||
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, scenario.count));
|
||||
const deck = treacheryDeck(scenario.expansions).map(card => card.id).sort();
|
||||
assert.equal(deck.length, scenario.expansions.includes('ix') ? 47 : 35);
|
||||
const cache = scenario.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : [];
|
||||
const inventory = sampleInventory(game);
|
||||
assert.deepEqual(inventory.cards, canonical);
|
||||
assert.deepEqual(inventory.cards, [...deck, ...cache].sort());
|
||||
assert.deepEqual((game.richeseCache ?? []).map(card => card.id).sort(), cache);
|
||||
verifySampleCustody(game, inventory);
|
||||
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(),
|
||||
STRONGHOLD_CARDS.map(card => card.id).sort());
|
||||
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||||
assert.equal(game.strongholdCards!.claimedTurn, 0);
|
||||
assert.equal(game.players.find(player => player.faction === 'choam')!.leaders
|
||||
.filter(isAuditorLeader).length, 1);
|
||||
const choam = game.players.find(player => player.faction === 'choam');
|
||||
if (choam) assert.equal(choam.leaders.filter(isAuditorLeader).length, 1);
|
||||
}
|
||||
for (const scenario of scenarios.filter(candidate => candidate.count === 6)) {
|
||||
const snapshot = join(out, `failed-${scenario.name}.json`);
|
||||
const original = readFileSync(snapshot, 'utf8');
|
||||
const continued = join(area, `continued-${scenario.native}`);
|
||||
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000',
|
||||
'--max-actions', '1').status, 1);
|
||||
const report = assertFailedEvidence(continued, scenario.name, scenario.seed, true);
|
||||
assert.equal(report.options.resume?.path, snapshot);
|
||||
verifySampleCustody(json<Game>(join(continued, `failed-${scenario.name}.json`)),
|
||||
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
|
||||
assert.equal(readFileSync(snapshot, 'utf8'), original);
|
||||
}
|
||||
const snapshot = join(out, 'failed-stronghold-factions-6-advanced.json');
|
||||
const original = readFileSync(snapshot, 'utf8');
|
||||
const continued = join(area, 'continued');
|
||||
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000',
|
||||
'--max-actions', '1').status, 1);
|
||||
const report = assertFailedEvidence(continued, 'stronghold-factions-6-advanced', 1155, true);
|
||||
assert.equal(report.options.resume?.path, snapshot);
|
||||
verifySampleCustody(json<Game>(join(continued, 'failed-stronghold-factions-6-advanced.json')),
|
||||
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
|
||||
assert.equal(readFileSync(snapshot, 'utf8'), original);
|
||||
});
|
||||
|
||||
void test('native Stronghold admission and resume reject foreign profiles and corrupted custody before gameplay', (t) => {
|
||||
@@ -150,11 +168,11 @@ void test('native Stronghold admission and resume reject foreign profiles and co
|
||||
const out = join(area, 'native');
|
||||
assert.equal(run(out, '--profile', 'stronghold-factions', '--players', '2',
|
||||
'--max-actions', '1').status, 1);
|
||||
const source = json<Game>(join(out, 'failed-stronghold-factions-2-advanced.json'));
|
||||
const source = json<Game>(join(out, 'failed-stronghold-factions-ixians-choam-2-advanced.json'));
|
||||
const corruptions: [string, (game: Game) => void][] = [
|
||||
['basic', game => { game.advanced = false; }],
|
||||
['wrong-deck', game => { game.expansions = ['ix']; }],
|
||||
['foreign-faction', game => { game.players[1].faction = 'richese'; }],
|
||||
['foreign-faction', game => { game.players[1].faction = 'ecaz'; }],
|
||||
['missing-module', game => { delete game.strongholdCards; }],
|
||||
['nexus', game => { game.nexusCards = { cards: null, phase: null }; }],
|
||||
['tech', game => { game.techTokens = createTechTokens(); }],
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import type { StrongholdId } from '../game/stronghold-cards';
|
||||
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type RicheseStrongholdsOptions = {
|
||||
initial?: Game;
|
||||
seatIds?: [string, string, string];
|
||||
kind?: 'arrakeen' | 'habbanya_ridge_sietch';
|
||||
noFieldValue?: NoFieldValue;
|
||||
markerBattle?: boolean;
|
||||
reserves?: number;
|
||||
stone?: boolean;
|
||||
ownerDial?: number;
|
||||
opponentDial?: number;
|
||||
support?: number;
|
||||
opponentSupport?: number;
|
||||
traitors?: 'owner' | 'both';
|
||||
ownerDefense?: Card['kind'];
|
||||
opponentWeapon?: Card['kind'];
|
||||
};
|
||||
export type RicheseStrongholdsFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeShipment: Game;
|
||||
shipmentStep: StrongholdFactionsNativeStep;
|
||||
afterShipment: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: StrongholdFactionsNativeStep;
|
||||
afterFirstMentat: Game;
|
||||
beforeReveal: Game;
|
||||
revealStep: StrongholdFactionsNativeStep;
|
||||
beforeBattle: Game;
|
||||
battleStep: StrongholdFactionsNativeStep;
|
||||
game: Game;
|
||||
richese: string;
|
||||
choam: string;
|
||||
opponent: string;
|
||||
kind: StrongholdId;
|
||||
acquisition: { before: Game; steps: StrongholdFactionsNativeStep[]; after: Game } | null;
|
||||
planActions: StrongholdFactionsNativeStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export const richeseStrongholdsPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player, `Missing native seat ${actor}`);
|
||||
return player;
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Consume only live native windows; ordinary setup choices come from real views. */
|
||||
export function nextRicheseStrongholdsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||
if (!game.phaseOpening && !game.response && game.decision) {
|
||||
const decision = game.decision, actor = decision.player;
|
||||
const event = game.richeseBidding?.event;
|
||||
if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
|
||||
if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
|
||||
if (decision.kind === 'richeseCache') {
|
||||
const card = game.richeseCache?.find(c => c.id === 'richese-stone-burner') ?? game.richeseCache?.[0];
|
||||
assert.ok(card);
|
||||
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === 'richese-stone-burner' } };
|
||||
if (decision.kind === 'stoneBurner') return { actor, action: { type: 'decision', event: decision.event, mode: 'ignore' } };
|
||||
}
|
||||
if (clean(game) && game.richeseAuction && !game.richeseAuction.outcome) {
|
||||
assert.ok(game.richeseAuction.active);
|
||||
return { actor: game.richeseAuction.active, action: { type: 'richeseBid', event: game.richeseAuction.event, amount: game.richeseAuction.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function step(game: Game): Game {
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(next, 'A native Richese continuation requires an unsealed human plan');
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
function advance(game: Game, predicate: (game: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (predicate(game)) return game;
|
||||
assert.equal(game.status, 'playing');
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
// Select legal original dials so the next storm band cannot cross Habbanya.
|
||||
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 4;
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Native Richese continuation did not reach its requested boundary');
|
||||
}
|
||||
function initialize(game: Game): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array);
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
|
||||
return array;
|
||||
};
|
||||
try { return initializeStrongholdFactionsGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function reserveBoard(game: Game) {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, amount: number) {
|
||||
const player = richeseStrongholdsPlayer(game, actor);
|
||||
assert.ok(player.reserves >= amount && amount >= 0);
|
||||
player.reserves -= amount;
|
||||
if (amount) player.forces[location] = (player.forces[location] ?? 0) + amount;
|
||||
}
|
||||
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) {
|
||||
const player = richeseStrongholdsPlayer(game, actor);
|
||||
const held = player.hand.find(c => c.kind === kind);
|
||||
if (held) return held.id;
|
||||
const at = game.deck.findIndex(c => c.kind === kind);
|
||||
assert.ok(at >= 0, `Missing canonical native ${kind}`);
|
||||
assert.ok(player.hand.length < 4);
|
||||
const card = game.deck.splice(at, 1)[0]; player.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
|
||||
return card.id;
|
||||
}
|
||||
function traitor(game: Game, actor: string, identity: string, staging: string[]) {
|
||||
const player = richeseStrongholdsPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0]; assert.ok(returned);
|
||||
const at = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(at >= 0 || donor);
|
||||
if (at >= 0) game.traitorReserve!.splice(at, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved native traitor ${identity} exchanged for ${returned}`);
|
||||
}
|
||||
|
||||
/** Real Advanced E2 setup, native cache producer and one-force shipment, followed
|
||||
* by actual first END Mentat. Only labeled conserved board/card/bank positions
|
||||
* are staged after setup. No phases, owners, markers or cache faces are assigned.
|
||||
* An original CLI initializer snapshot is continued without RNG or redealing. */
|
||||
export function createRicheseStrongholdsFixture(options: RicheseStrongholdsOptions = {}): RicheseStrongholdsFixture {
|
||||
const kind = options.kind ?? 'habbanya_ridge_sietch';
|
||||
const location = `${kind}:${territory(kind).sectors[0]}`;
|
||||
const staging: string[] = [];
|
||||
let game: Game;
|
||||
if (options.initial) {
|
||||
game = structuredClone(options.initial);
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0), 'Continue only the original fresh undealt CLI setup');
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['choam', 'guild', 'richese']);
|
||||
} else {
|
||||
const ids = options.seatIds ?? ['richese', 'choam', 'guild'];
|
||||
game = createGame('RICHESESTRONGHOLDS', newPlayer(ids[0], 'Richese', 'richese'), true, ['choam']);
|
||||
joinGame(game, newPlayer(ids[1], 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer(ids[2], 'Guild', 'guild'));
|
||||
}
|
||||
if (game.status === 'lobby') {
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initialize(game);
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 200; count++) {
|
||||
let next: StrongholdFactionsNativeStep | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id); view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = { actor: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(next, `Native Richese setup stalled at ${game.setupStage}`);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const richese = game.players.find(p => p.faction === 'richese')!.id;
|
||||
const choam = game.players.find(p => p.faction === 'choam')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
// Stone comes from the actual unbid-cache acquisition producer, never a hand injection.
|
||||
game = advance(game, g => g.decision?.kind === 'richeseCache');
|
||||
const acquisitionBefore = structuredClone(game), acquisitionSteps: StrongholdFactionsNativeStep[] = [];
|
||||
for (let count = 0; !richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner') && count < 100; count++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
acquisitionSteps.push(next); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
const acquisition = { before: acquisitionBefore, steps: acquisitionSteps, after: structuredClone(game) };
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === richese);
|
||||
reserveBoard(game);
|
||||
for (const player of game.players) player.spice = 20;
|
||||
staging.push('After real setup and cache acquisition: return original board counters to their own reserves; stage twenty-spice bank balances for original seats');
|
||||
const beforeShipment = structuredClone(game);
|
||||
const token = richeseStrongholdsPlayer(game, richese).noField!.tokens.find(t => t.value === (options.noFieldValue ?? 5))!;
|
||||
const shipmentStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'ship', noField: token.id, event: richeseStrongholdsPlayer(game, richese).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } };
|
||||
game = applyAction(game, shipmentStep.actor, shipmentStep.action);
|
||||
game = advance(game, g => !g.response && !g.decision && !g.phaseOpening);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = advance(game, g => g.phase === 8 && clean(g));
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
||||
for (let count = 0; game.turn === 1 && count < 100; count++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
const before = structuredClone(game); game = applyAction(game, next.actor, next.action);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.strongholdCards!.owners[kind], richese);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
game = advance(game, g => g.phase === 5 && clean(g));
|
||||
reserveBoard(game);
|
||||
const markerBattle = options.markerBattle ?? !options.stone;
|
||||
const reserves = options.reserves ?? 20;
|
||||
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves <= 20);
|
||||
place(game, richese, 'polar_sink:0', 20 - reserves);
|
||||
place(game, opponent, location, 6);
|
||||
for (const player of game.players) player.spice = 20;
|
||||
staging.push(`Before native second Movement completion: stage six opposing battle counters, ${20 - reserves} Richese counters in Polar Sink and twenty-spice bank balances; preserve real concealed marker and first-Mentat card ownership`);
|
||||
const beforeReveal = structuredClone(game);
|
||||
const revealStep: StrongholdFactionsNativeStep = { actor: richese, action: { type: 'revealNoField', event: richeseStrongholdsPlayer(game, richese).noFieldEvent, token: token.id } };
|
||||
if (!markerBattle) {
|
||||
game = applyAction(game, richese, revealStep.action);
|
||||
reserveBoard(game); place(game, richese, location, 6); place(game, opponent, location, 6);
|
||||
staging.push('After actual voluntary materialization: conserve six ordinary physical counters per combatant at the printed Stronghold, never a mixed-marker battle');
|
||||
}
|
||||
const defense = options.ownerDefense ? heldCard(game, richese, options.ownerDefense, staging) : null;
|
||||
const enemyWeapon = options.opponentWeapon ? heldCard(game, opponent, options.opponentWeapon, staging) : null;
|
||||
const ownLeader = richeseStrongholdsPlayer(game, richese).leaders.find(l => l.strength === 5 && !l.dead)!;
|
||||
const otherLeader = richeseStrongholdsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead)!;
|
||||
assert.ok(ownLeader && otherLeader);
|
||||
if (options.traitors) {
|
||||
traitor(game, richese, otherLeader.id, staging);
|
||||
if (options.traitors === 'both') traitor(game, opponent, ownLeader.id, staging);
|
||||
}
|
||||
game = advance(game, g => g.phase === 6 && clean(g));
|
||||
const beforeBattle = structuredClone(game), actor = game.active!;
|
||||
assert.ok(actor === richese || actor === opponent);
|
||||
const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === richese ? opponent : richese } };
|
||||
game = applyAction(game, actor, battleStep.action);
|
||||
for (let count = 0; count < 120; count++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
if (!next) break;
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && !game.decision && !game.response);
|
||||
const planActions: StrongholdFactionsNativeStep[] = [
|
||||
{ actor: richese, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, leader: ownLeader.id, weapon: options.stone ? 'richese-stone-burner' : null, defense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: otherLeader.id, weapon: enemyWeapon } },
|
||||
];
|
||||
return { initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeReveal, revealStep, beforeBattle, battleStep, game, richese, choam, opponent, kind, acquisition, planActions, staging };
|
||||
}
|
||||
|
||||
export function revealRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const plan of fixture.planActions) {
|
||||
game = applyAction(game, plan.actor, plan.action);
|
||||
while (game.response || game.decision?.kind === 'fullPlanOffer') game = step(game);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
export function finishRicheseStrongholdsBattle(fixture: RicheseStrongholdsFixture, state: Game, callers: string[] = []): Game {
|
||||
let game = state;
|
||||
for (let count = 0; count < 160; count++) {
|
||||
if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Native Richese battle suffix did not finish');
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type TleilaxuStrongholdsOptions = { initial?: Game; kind?: 'sietch_tabr' | typeof MOBILE_STRONGHOLD };
|
||||
export type TleilaxuStrongholdsFixture = {
|
||||
initial: Game; afterSetup: Game; beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep;
|
||||
afterFirstMentat: Game; beforeBattle: Game; game: Game; revealed: Game; faceDance: Game;
|
||||
ix: string; tleilaxu: string; opponent: string; kind: StrongholdId; location: string;
|
||||
pointer: string; winnerLeader: string; loserLeader: string; weapon: string; defense: string;
|
||||
battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[];
|
||||
sourceActions: StrongholdFactionsNativeStep[]; staging: string[];
|
||||
};
|
||||
export const tleilaxuStrongholdsPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`;
|
||||
function run(game: Game, action: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
|
||||
actions.push(structuredClone(action)); return applyAction(game, action.actor, action.action);
|
||||
}
|
||||
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action, 'A real human battle plan is required');
|
||||
game = run(game, action, actions);
|
||||
}
|
||||
throw Error('Native Tleilaxu Stronghold boundary did not open');
|
||||
}
|
||||
function reserveBoard(game: Game) {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, count: number, elite = 0) {
|
||||
const player = tleilaxuStrongholdsPlayer(game, actor);
|
||||
assert.ok(player.reserves >= count && count >= elite && (player.elites?.reserves ?? 0) >= elite);
|
||||
player.reserves -= count; player.forces[location] = (player.forces[location] ?? 0) + count;
|
||||
if (elite) { player.elites!.reserves -= elite; player.elites!.forces[location] = (player.elites!.forces[location] ?? 0) + elite; }
|
||||
}
|
||||
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) {
|
||||
const player = tleilaxuStrongholdsPlayer(game, actor), held = player.hand.find(c => c.kind === kind);
|
||||
if (held) return held.id;
|
||||
const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
|
||||
assert.ok(player.hand.length < 4);
|
||||
const card = game.deck.splice(index, 1)[0]; player.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); return card.id;
|
||||
}
|
||||
function matchingLeader(game: Game, ix: string, tleilaxu: string) {
|
||||
const held = tleilaxuStrongholdsPlayer(game, tleilaxu).faceDancers!;
|
||||
return tleilaxuStrongholdsPlayer(game, ix).leaders.filter(l => !l.dead && held.some(c => !c.revealed && c.leader === l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
}
|
||||
|
||||
/** Root Advanced PDF logical p38, lines 1066–1075: actual native draw, original
|
||||
* winner's rewards, no second bounty, and zero/up-to-army replacement. Only
|
||||
* the actual first native Face Dancer shuffle uses test entropy, scoped to
|
||||
* its source setup action. Provided original CLI setup is cloned, never
|
||||
* redealt; saved choices, deck and offers remain untouched. Conserved
|
||||
* board/card staging is labeled; no owners, phase, plans or opportunities are
|
||||
* assigned. This intentionally excludes foreign gholas, capture and overlays. */
|
||||
export function createTleilaxuStrongholdsFixture(options: TleilaxuStrongholdsOptions = {}): TleilaxuStrongholdsFixture {
|
||||
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
|
||||
const kind = options.kind ?? 'sietch_tabr';
|
||||
let game: Game;
|
||||
if (options.initial) {
|
||||
game = structuredClone(options.initial);
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0));
|
||||
assert.equal(game.advanced, true);
|
||||
assert.ok(game.expansions.includes('ix') && game.expansions.every(deck => deck === 'ix' || deck === 'choam') &&
|
||||
new Set(game.expansions).size === game.expansions.length, 'Use the selected native Ix deck, optionally with CHOAM');
|
||||
} else {
|
||||
game = createGame('TLEILAXUSTRONGHOLDS', newPlayer('ix', 'Ixians', 'ixians'), true, ['ix']);
|
||||
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
}
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['guild', 'ixians', 'tleilaxu']);
|
||||
if (game.status === 'lobby') {
|
||||
for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
|
||||
game = initializeStrongholdFactionsGameForAudit(game);
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
for (let setup = 0; game.status === 'setup' && setup < 200; setup++) {
|
||||
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action);
|
||||
if (game.decision?.kind !== 'ixSetup') {
|
||||
game = run(game, action, actions); continue;
|
||||
}
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.equal(game.setupStage, undefined);
|
||||
assert.ok(game.players.every(p => p.faceDancers === undefined));
|
||||
// Ix first shuffles the unchosen starting Treachery Cards. Preserve that
|
||||
// genuine shuffle; seed only the subsequent native Traitor Deck shuffle.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const originalRandom = crypto.getRandomValues.bind(crypto);
|
||||
let remainderCalls = Math.max(0, game.ixSetupCards!.length - 2);
|
||||
let faceCalls = game.players.reduce((sum, p) => sum + p.leaders.length, 0) + 1 -
|
||||
new Set(game.players.flatMap(p => p.traitors)).size - 1;
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array);
|
||||
assert.ok(array instanceof Uint32Array, 'Native setup entropy uses integer random words');
|
||||
if (remainderCalls > 0) { remainderCalls--; originalRandom(array); return array; }
|
||||
if (faceCalls <= 0) { originalRandom(array); return array; }
|
||||
faceCalls--;
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { game = run(game, action, actions); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
staging.push('First-draw test entropy: only native Face Dancer Traitor Deck shuffle in the original Ix starting-card decision; restore immediately; no redeal, saved-choice rewrite or later draw override.');
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const ix = game.players.find(p => p.faction === 'ixians')!.id;
|
||||
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
game = advance(game, state => state.phase === 8 && clean(state), actions);
|
||||
reserveBoard(game);
|
||||
place(game, ix, MOBILE_LOCATION, 6, 3); place(game, ix, key('sietch_tabr'), 1);
|
||||
place(game, tleilaxu, key('arrakeen'), 1); place(game, opponent, key('carthag'), 1);
|
||||
staging.push('Before real first END Mentat: redistribute original counters through their own reserves; Ix controls HMS and Tabr, Tleilaxu Arrakeen, Guild Carthag; no three-hold victory.');
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
||||
while (game.turn === 1) {
|
||||
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action);
|
||||
const before = structuredClone(game); game = run(game, action, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = action; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
assert.equal(game.strongholdCards!.owners[kind], ix);
|
||||
const winnerLeader = matchingLeader(game, ix, tleilaxu);
|
||||
assert.ok(winnerLeader, 'Actual first native Face Dancer draw must provide an Ix leader');
|
||||
game = advance(game, state => state.phase === 5 && clean(state), actions);
|
||||
const pointer = game.mobileStronghold!.location; assert.ok(pointer && pointer !== MOBILE_LOCATION);
|
||||
reserveBoard(game);
|
||||
const location = key(kind);
|
||||
place(game, ix, location, 6, 3); place(game, opponent, location, 6);
|
||||
if (kind !== MOBILE_STRONGHOLD) place(game, ix, MOBILE_LOCATION, 1, 1);
|
||||
else place(game, ix, key('sietch_tabr'), 1);
|
||||
place(game, ix, pointer, 2, 1); place(game, tleilaxu, key('arrakeen'), 3);
|
||||
staging.push('Before native movement completion: conserve six counters per combatant (Ix three suboids/three cyborgs), two Ix outside the HMS pointer (one cyborg), real Tleilaxu board sources in Arrakeen; retain first-Mentat cards.');
|
||||
for (const player of game.players) player.spice = 20;
|
||||
staging.push('Explicit bank-balance staging: original three seats each start this battle with twenty spice; no bounty, support or Stronghold receipt injected.');
|
||||
const weapon = heldCard(game, ix, 'projectile', staging), defense = heldCard(game, ix, 'shield', staging);
|
||||
const loserLeader = tleilaxuStrongholdsPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; assert.ok(loserLeader);
|
||||
game = advance(game, state => state.phase === 6 && clean(state), actions);
|
||||
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([ix, opponent].includes(actor));
|
||||
const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ix ? opponent : ix } };
|
||||
game = run(game, battleAction, actions);
|
||||
if (kind === MOBILE_STRONGHOLD) {
|
||||
game = advance(game, state => state.decision?.kind === 'strongholdCopy' || !!state.battle?.strongholdCopy, actions);
|
||||
assert.equal(strongholdControllers(game.players, true).sietch_tabr, ix);
|
||||
if (game.decision?.kind === 'strongholdCopy')
|
||||
game = run(game, { actor: ix, action: { type: 'decision', event: game.decision.event, stronghold: 'sietch_tabr' } }, actions);
|
||||
else assert.equal(game.battle!.strongholdCopy, 'sietch_tabr', 'Native sole-copy declaration is automatic');
|
||||
}
|
||||
game = advance(game, state => !!state.battle && !state.battle.revealed && !nextStrongholdFactionsNativeStep(state), actions);
|
||||
const planActions: StrongholdFactionsNativeStep[] = [
|
||||
{ actor: ix, action: { type: 'battlePlan', dial: 2, support: 1, leader: winnerLeader.id, weapon, defense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: 1.5, support: 1, leader: loserLeader.id, weapon: null, defense: null } },
|
||||
];
|
||||
const fixture = { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game: structuredClone(game), ix, tleilaxu, opponent, kind, location, pointer, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, weapon, defense, battleAction, planActions, sourceActions: actions, staging };
|
||||
const revealed = revealTleilaxuStrongholdsBattle(fixture, actions);
|
||||
const faceDance = resolveTleilaxuStrongholdsBattle({ ...fixture, revealed }, actions);
|
||||
return { ...fixture, revealed, faceDance };
|
||||
}
|
||||
export function revealTleilaxuStrongholdsBattle(fixture: Pick<TleilaxuStrongholdsFixture, 'game' | 'planActions'>, sourceActions: StrongholdFactionsNativeStep[] = []): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const plan of fixture.planActions) {
|
||||
game = run(game, plan, sourceActions);
|
||||
while (game.response || game.decision?.kind === 'fullPlanOffer') {
|
||||
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); game = run(game, action, sourceActions);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function resolveTleilaxuStrongholdsBattle(fixture: Pick<TleilaxuStrongholdsFixture, 'revealed' | 'ix'>, sourceActions: StrongholdFactionsNativeStep[] = []): Game {
|
||||
const quote = quoteStrongholdFactionsBattle(fixture.revealed); assert.equal(quote.winner, fixture.ix);
|
||||
let game = structuredClone(fixture.revealed);
|
||||
for (let n = 0; n < 160; n++) {
|
||||
if (game.decision?.kind === 'faceDance') return game;
|
||||
const action = nextStrongholdFactionsNativeStep(game); assert.ok(action); game = run(game, action, sourceActions);
|
||||
}
|
||||
throw Error('Original winner aftermath did not reach native Face Dance');
|
||||
}
|
||||
export function finishTleilaxuStrongholdsTurn(state: Game): Game {
|
||||
const turn = state.turn; return advance(structuredClone(state), game => game.turn > turn, []);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { createLeaderSkills } from '../game/leader-skills';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
function fixture(t: test.TestContext) {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
@@ -955,19 +957,33 @@ void test('Banker income CLI requires a fresh exact-version private backup and p
|
||||
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
|
||||
});
|
||||
|
||||
function strongholdFactionsLobby(count = 3) {
|
||||
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), true, ['ix', 'choam']);
|
||||
const factions = ['choam', 'atreides', 'harkonnen', 'emperor', 'guild'] as const;
|
||||
function strongholdFactionsLobby(
|
||||
count = 3,
|
||||
roster: readonly FactionId[] = ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'],
|
||||
expansions = ['ix', 'choam'],
|
||||
) {
|
||||
const game = createGame('PROTOTYP', newPlayer('i', roster[0], roster[0]), true, expansions);
|
||||
for (let index = 1; index < count; index++)
|
||||
joinGame(game, newPlayer('seat-' + index, factions[index - 1], factions[index - 1]));
|
||||
joinGame(game, newPlayer('seat-' + index, roster[index], roster[index]));
|
||||
game.players.forEach(player => { player.ready = true; });
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Stronghold factions admits only fresh exact-version native Advanced lobbies without changing seats or other rooms', t => {
|
||||
const { db } = fixture(t);
|
||||
for (const count of [2, 3, 4, 5, 6]) {
|
||||
const lobby = strongholdFactionsLobby(count);
|
||||
const families: { roster: FactionId[]; expansions: string[]; minimum?: number }[] = [
|
||||
{ roster: ['ixians', 'choam', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix', 'choam'] },
|
||||
{ roster: ['ixians', 'tleilaxu', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['ix'] },
|
||||
{ roster: ['choam', 'richese', 'atreides', 'harkonnen', 'emperor', 'guild'], expansions: ['choam'] },
|
||||
{ roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'guild'], expansions: ['ix', 'choam'], minimum: 4 },
|
||||
...(['ixians', 'tleilaxu', 'choam', 'richese'] as const).map(faction => ({
|
||||
roster: [faction, 'guild', 'atreides', 'harkonnen', 'emperor', 'fremen'] as FactionId[],
|
||||
expansions: [faction === 'ixians' || faction === 'tleilaxu' ? 'ix' : 'choam'],
|
||||
})),
|
||||
];
|
||||
for (const { family, count } of families.flatMap(family =>
|
||||
[2, 3, 4, 5, 6].filter(count => count >= (family.minimum ?? 2)).map(count => ({ family, count })))) {
|
||||
const lobby = strongholdFactionsLobby(count, family.roster, family.expansions);
|
||||
const original = structuredClone(lobby);
|
||||
db.prepare('UPDATE rooms SET state=?,version=7 WHERE code=?').run(JSON.stringify(lobby), lobby.code);
|
||||
const seats = db.prepare('SELECT * FROM seats').all();
|
||||
@@ -982,19 +998,21 @@ void test('Stronghold factions admits only fresh exact-version native Advanced l
|
||||
assert.equal(row.version, 8);
|
||||
assert.equal(saved.status, 'setup');
|
||||
assert.equal(saved.advanced, true);
|
||||
assert.deepEqual(saved.expansions, ['ix', 'choam']);
|
||||
assert.deepEqual(saved.expansions, family.expansions);
|
||||
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
|
||||
original.players.map(p => [p.id, p.name, p.faction]));
|
||||
assert.deepEqual(saved.playerPositions, original.playerPositions);
|
||||
const cards = [...saved.deck, ...saved.discard, ...(saved.ixSetupCards ?? []), ...saved.players.flatMap(p => p.hand)];
|
||||
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, 47);
|
||||
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(family.expansions).map(card => card.id).sort());
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, family.expansions.includes('ix') ? 47 : 35);
|
||||
const cache = family.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : [];
|
||||
assert.deepEqual((saved.richeseCache ?? []).map(card => card.id).sort(), cache);
|
||||
assert.deepEqual(saved.strongholdCards, createStrongholdCards());
|
||||
const ix = saved.players.find(p => p.faction === 'ixians')!;
|
||||
assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) +
|
||||
const ix = saved.players.find(p => p.faction === 'ixians');
|
||||
if (ix) assert.equal((ix.elites?.reserves ?? 0) + (ix.elites?.tanks ?? 0) +
|
||||
Object.values(ix.elites?.forces ?? {}).reduce((sum, amount) => sum + amount, 0), 7);
|
||||
const choam = saved.players.find(p => p.faction === 'choam')!;
|
||||
assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1);
|
||||
const choam = saved.players.find(p => p.faction === 'choam');
|
||||
if (choam) assert.equal(choam.leaders.filter(leader => leader.id === 'choam-auditor').length, 1);
|
||||
for (const p of saved.players) {
|
||||
const view = viewGame(saved, p.id);
|
||||
assert.deepEqual(view.strongholdCards, saved.strongholdCards);
|
||||
@@ -1017,8 +1035,11 @@ void test('Stronghold faction admission rejects incompatible modules, history, o
|
||||
game => { game.advanced = false; },
|
||||
game => { game.expansions = ['ix']; },
|
||||
game => { game.expansions = ['ix', 'choam', 'ecaz']; },
|
||||
game => { game.players[1] = newPlayer(game.players[1].id, 'Classic', 'guild'); game.players[1].ready = true; },
|
||||
game => { game.players[2] = newPlayer(game.players[2].id, 'Tleilaxu', 'tleilaxu'); game.players[2].ready = true; },
|
||||
game => { game.expansions = ['choam']; },
|
||||
game => { game.expansions = ['ix', 'choam', 'ix']; },
|
||||
game => { game.expansions = []; },
|
||||
game => { game.players[2].faction = 'ecaz'; },
|
||||
game => { game.players[0].faction = 'guild'; game.players[1].faction = 'fremen'; },
|
||||
game => { game.players[0].ready = false; },
|
||||
game => { game.turn = 2; },
|
||||
game => { game.players[0].battleLosses = 1; },
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
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);
|
||||
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).spice, 20, 'sealing a subsidized plan does not credit its wallet');
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import { createTleilaxuStrongholdsFixture, finishTleilaxuStrongholdsTurn, tleilaxuStrongholdsPlayer as player, type TleilaxuStrongholdsFixture } from './fixture-tleilaxu-strongholds';
|
||||
|
||||
function at(game: Game, actor: string, site: string) {
|
||||
return Object.entries(player(game, actor).forces).filter(([key]) => splitLocation(key).territory === site).reduce((sum, [, count]) => sum + count, 0);
|
||||
}
|
||||
function replace(fixture: TleilaxuStrongholdsFixture, sources: Record<string, number>): Game {
|
||||
return applyAction(fixture.faceDance, fixture.tleilaxu, { type: 'decision', reveal: true, sources, sector: territory(fixture.kind).sectors[0] });
|
||||
}
|
||||
function returnedWinner(fixture: TleilaxuStrongholdsFixture, after: Game) {
|
||||
const before = player(fixture.faceDance, fixture.ix), winner = player(after, fixture.ix);
|
||||
const remaining = at(fixture.faceDance, fixture.ix, fixture.kind);
|
||||
const elite = Object.entries(before.elites!.forces).filter(([key]) => splitLocation(key).territory === fixture.kind).reduce((sum, [, count]) => sum + count, 0);
|
||||
assert.equal(at(after, fixture.ix, fixture.kind), 0);
|
||||
assert.equal(winner.reserves, before.reserves + remaining);
|
||||
assert.equal(winner.elites!.reserves, before.elites!.reserves + elite);
|
||||
assert.equal(winner.tanks, before.tanks, 'Face Dance returns survivors, not a second set of battle casualties');
|
||||
assert.equal(winner.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(winner.forces[fixture.pointer], before.forces[fixture.pointer], 'forces outside the HMS pointer are not inside its battle');
|
||||
assert.equal(winner.elites!.forces[fixture.pointer], before.elites!.forces[fixture.pointer]);
|
||||
assert.equal(winner.spice, before.spice, 'the Face Dancer leader earns no extra bounty or clawback');
|
||||
assert.deepEqual(winner.hand, before.hand, 'original winner keeps its actual physical battle cards');
|
||||
const leader = winner.leaders.find(l => l.id === fixture.winnerLeader)!;
|
||||
assert.equal(leader.dead, true); assert.equal(leader.deaths, before.leaders.find(l => l.id === fixture.winnerLeader)!.deaths + 1);
|
||||
assert.equal(after.lastBattleContext!.winner, fixture.ix);
|
||||
assert.deepEqual(after.strongholdCards, fixture.faceDance.strongholdCards);
|
||||
assert.equal(player(after, fixture.tleilaxu).faceDancers!.find(c => c.leader === fixture.winnerLeader)!.revealed, true);
|
||||
}
|
||||
|
||||
void test('actual native Ix/Tleilaxu setup draws its matching Face Dancer and first END Mentat awards the Stronghold Cards', () => {
|
||||
const fixture = createTleilaxuStrongholdsFixture();
|
||||
assert.equal(fixture.afterSetup.advanced, true);
|
||||
assert.deepEqual(fixture.afterSetup.players.map(p => p.faction), ['ixians', 'tleilaxu', 'guild']);
|
||||
const cards = [...fixture.afterSetup.deck, ...fixture.afterSetup.discard, ...fixture.afterSetup.players.flatMap(p => p.hand)];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(fixture.afterSetup.expansions).map(c => c.id).sort());
|
||||
assert.equal(player(fixture.afterSetup, fixture.ix).elites!.forces[MOBILE_LOCATION], 3);
|
||||
assert.ok(player(fixture.afterSetup, fixture.tleilaxu).faceDancers!.some(c => c.leader === fixture.winnerLeader && !c.revealed), 'match came from actual first native draw');
|
||||
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(fixture.beforeFirstMentat.strongholdCards!.owners).every(owner => owner === null));
|
||||
const claimed = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
|
||||
assert.equal(claimed.turn, 2);
|
||||
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, true));
|
||||
assert.deepEqual(claimed.strongholdCards, fixture.afterFirstMentat.strongholdCards);
|
||||
});
|
||||
|
||||
void test('original undealt CLI setup is not mutated, reinitialized or deterministically redealt', () => {
|
||||
const original = createTleilaxuStrongholdsFixture().initial, before = structuredClone(original);
|
||||
const fixture = createTleilaxuStrongholdsFixture({ initial: original });
|
||||
assert.deepEqual(original, before); assert.deepEqual(fixture.initial, before);
|
||||
assert.ok(player(fixture.faceDance, fixture.tleilaxu).faceDancers!.some(c => c.leader === fixture.winnerLeader && !c.revealed));
|
||||
assert.equal(fixture.faceDance.decision?.kind, 'faceDance');
|
||||
});
|
||||
|
||||
void test('original winner receives native casualties, leader bounty, physical card choice and retained Tabr income before Face Dance', () => {
|
||||
for (const kind of ['sietch_tabr', MOBILE_STRONGHOLD] as const) {
|
||||
const fixture = createTleilaxuStrongholdsFixture({ kind });
|
||||
const quote = quoteStrongholdFactionsBattle(fixture.revealed);
|
||||
assert.equal(quote.winner, fixture.ix);
|
||||
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.ix, amount: 1 }]);
|
||||
assert.deepEqual(quote.bounty, { player: fixture.ix, amount: player(fixture.revealed, fixture.opponent).leaders.find(l => l.id === fixture.loserLeader)!.strength });
|
||||
const original = player(fixture.revealed, fixture.ix), winner = player(fixture.faceDance, fixture.ix), losses = quote.casualties!.options[0];
|
||||
assert.equal(winner.tanks, original.tanks + losses.normal + losses.elite);
|
||||
assert.equal(winner.elites!.tanks, original.elites!.tanks + losses.elite);
|
||||
assert.equal(at(fixture.faceDance, fixture.ix, kind), 6 - losses.normal - losses.elite);
|
||||
assert.equal(winner.spice, original.spice - quote.payments.find(p => p.player === fixture.ix)!.ownPayment + quote.bounty!.amount + 1);
|
||||
assert.ok(winner.hand.some(c => c.id === fixture.weapon)); assert.ok(winner.hand.some(c => c.id === fixture.defense));
|
||||
assert.equal(player(fixture.faceDance, fixture.opponent).tanks, 6);
|
||||
assert.equal(player(fixture.faceDance, fixture.opponent).leaders.find(l => l.id === fixture.loserLeader)!.dead, true);
|
||||
assert.equal(winner.leaders.find(l => l.id === fixture.winnerLeader)!.dead, false);
|
||||
assert.equal(fixture.faceDance.decision?.kind, 'faceDance');
|
||||
assert.equal(fixture.faceDance.decision!.identity, fixture.winnerLeader);
|
||||
assert.deepEqual(fixture.faceDance.strongholdCards, fixture.beforeBattle.strongholdCards);
|
||||
if (kind === MOBILE_STRONGHOLD) assert.equal(fixture.revealed.battle!.strongholdCopy, 'sietch_tabr');
|
||||
}
|
||||
});
|
||||
|
||||
void test('native replacement permits zero, partial and full quantities from actual reserves and board, without transferring cards mid-turn', () => {
|
||||
const fixture = createTleilaxuStrongholdsFixture(), maximum = at(fixture.faceDance, fixture.ix, fixture.kind);
|
||||
const board = `arrakeen:${territory('arrakeen').sectors[0]}`;
|
||||
const choices: Record<string, number>[] = [{}, { reserves: 1 }, { [board]: 2 }, { reserves: maximum - 2, [board]: 2 }];
|
||||
for (const sources of choices) {
|
||||
const after = replace(fixture, sources), total = Object.values(sources).reduce((sum, count) => sum + count, 0);
|
||||
returnedWinner(fixture, after);
|
||||
assert.equal(at(after, fixture.tleilaxu, fixture.kind), total);
|
||||
assert.equal(player(after, fixture.tleilaxu).reserves, player(fixture.faceDance, fixture.tleilaxu).reserves - (sources.reserves ?? 0));
|
||||
assert.equal(player(after, fixture.tleilaxu).forces[board], 3 - (sources[board] ?? 0));
|
||||
assert.equal(strongholdControllers(after.players, true)[fixture.kind], total ? fixture.tleilaxu : null);
|
||||
assert.equal(strongholdBenefit(after.strongholdCards, fixture.ix, fixture.kind), fixture.kind);
|
||||
assert.equal(strongholdBenefit(after.strongholdCards, fixture.tleilaxu, fixture.kind), null);
|
||||
const next = finishTleilaxuStrongholdsTurn(after);
|
||||
assert.equal(next.strongholdCards!.claimedTurn, after.turn);
|
||||
assert.equal(next.strongholdCards!.owners[fixture.kind], total ? fixture.tleilaxu : null);
|
||||
assert.equal(strongholdBenefit(next.strongholdCards, fixture.tleilaxu, fixture.kind), total ? fixture.kind : null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('HMS replacement returns inside subtypes only, leaving the outside pointer and copied benefit intact until END Mentat', () => {
|
||||
const fixture = createTleilaxuStrongholdsFixture({ kind: MOBILE_STRONGHOLD });
|
||||
const maximum = at(fixture.faceDance, fixture.ix, MOBILE_STRONGHOLD);
|
||||
const after = replace(fixture, { reserves: maximum }); returnedWinner(fixture, after);
|
||||
assert.equal(at(after, fixture.tleilaxu, MOBILE_STRONGHOLD), maximum);
|
||||
assert.equal(after.mobileStronghold!.location, fixture.pointer);
|
||||
assert.equal(player(after, fixture.ix).forces[fixture.pointer], 2);
|
||||
assert.equal(player(after, fixture.ix).elites!.forces[fixture.pointer], 1);
|
||||
assert.equal(after.strongholdCards!.owners[MOBILE_STRONGHOLD], fixture.ix);
|
||||
assert.equal(after.strongholdCards!.owners.sietch_tabr, fixture.ix);
|
||||
const next = finishTleilaxuStrongholdsTurn(after);
|
||||
assert.equal(next.strongholdCards!.owners[MOBILE_STRONGHOLD], fixture.tleilaxu);
|
||||
assert.equal(next.strongholdCards!.owners.sietch_tabr, fixture.ix);
|
||||
});
|
||||
|
||||
void test('army cap and physical source availability reject illegal replacement without consuming the actual Face Dancer', () => {
|
||||
const fixture = createTleilaxuStrongholdsFixture(), before = structuredClone(fixture.faceDance);
|
||||
const maximum = at(before, fixture.ix, fixture.kind), board = `arrakeen:${territory('arrakeen').sectors[0]}`;
|
||||
for (const sources of [{ reserves: maximum + 1 }, { [board]: 4 }, { reserves: -1 }, { reserves: 0.5 }, { [fixture.location]: 1 }]) {
|
||||
assert.throws(() => replace(fixture, sources));
|
||||
assert.deepEqual(fixture.faceDance, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('declining the genuine matching Face Dancer leaves the winner, leader and end-turn control unchanged', () => {
|
||||
const fixture = createTleilaxuStrongholdsFixture();
|
||||
const after = applyAction(fixture.faceDance, fixture.tleilaxu, { type: 'decision', reveal: false });
|
||||
assert.equal(at(after, fixture.ix, fixture.kind), at(fixture.faceDance, fixture.ix, fixture.kind));
|
||||
assert.equal(player(after, fixture.ix).leaders.find(l => l.id === fixture.winnerLeader)!.dead, false);
|
||||
assert.equal(player(after, fixture.tleilaxu).faceDancers!.find(c => c.leader === fixture.winnerLeader)!.revealed, false);
|
||||
assert.equal(finishTleilaxuStrongholdsTurn(after).strongholdCards!.owners[fixture.kind], fixture.ix);
|
||||
});
|
||||
|
||||
void test('all four native policies execute the actual Stronghold Face Dance using legal physical sources', () => {
|
||||
for (const kind of ['sietch_tabr', MOBILE_STRONGHOLD] as const) {
|
||||
const fixture = createTleilaxuStrongholdsFixture({ kind });
|
||||
for (const level of DIFFICULTIES) {
|
||||
const view = viewGame(fixture.faceDance, fixture.tleilaxu); view.players.find(p => p.id === fixture.tleilaxu)!.bot = level;
|
||||
const action: Action | undefined = botActions(view).find(a => a.type === 'decision'); assert.ok(action);
|
||||
assert.equal(action.reveal, true);
|
||||
const sources = action.sources as Record<string, number>, count = Object.values(sources).reduce((sum, n) => sum + n, 0);
|
||||
assert.equal(count, at(fixture.faceDance, fixture.ix, kind));
|
||||
const after = applyAction(fixture.faceDance, fixture.tleilaxu, action); returnedWinner(fixture, after);
|
||||
assert.equal(at(after, fixture.tleilaxu, kind), count);
|
||||
assert.equal(finishTleilaxuStrongholdsTurn(after).strongholdCards!.owners[kind], fixture.tleilaxu);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user