215 lines
14 KiB
TypeScript
215 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { strongholdControllers } from '../game/stronghold-cards';
|
|
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
|
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
|
import { advanceEcazStronghold, ecazStrongholdFixture, ecazStrongholdSeat as seat,
|
|
finishEcazStronghold, openEcazStronghold, type EcazStrongholdFixture } from './fixture-ecaz-strongholds';
|
|
|
|
function freshLobby(advanced = true): Game {
|
|
let game = createGame('ECAZSTRONGHOLDGUARD', newPlayer('auth-ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
|
joinGame(game, newPlayer('auth-guild', 'Guild', 'guild'));
|
|
joinGame(game, newPlayer('auth-emperor', 'Emperor', 'emperor'));
|
|
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
|
return game;
|
|
}
|
|
function reveal(fixture: EcazStrongholdFixture, lead: 'ecaz' | 'ally', options: {
|
|
variable?: number; opponentDial?: number; tie?: boolean; defense?: string;
|
|
} = {}) {
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const defense = options.defense ?? stageEcazOccupyCard(fixture.game, actor, c => c.kind === 'shield');
|
|
fixture.staging.push(`Conserved physical Shield ${defense} at selected ${actor} exposes the original winner-card boundary before Collection.`);
|
|
let game = openEcazStronghold(fixture, lead);
|
|
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
|
let own = seat(game, actor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
|
let other = seat(game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
|
if (options.tie) {
|
|
const matching = seat(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
|
|
seat(game, fixture.opponent).leaders.some(o => !o.dead && !o.usedAt && o.strength === l.strength));
|
|
assert.ok(matching, 'The original faction leaders supply an actual equal-strength pair.');
|
|
own = matching;
|
|
other = seat(game, fixture.opponent).leaders.find(l => !l.dead && !l.usedAt && l.strength === own.strength)!;
|
|
}
|
|
const variable = options.variable ?? 3;
|
|
const before = structuredClone(game);
|
|
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
|
|
support: variable, leader: own.id, defense });
|
|
const opponentDial = options.opponentDial ?? (options.tie ? profile.fixedEcazDial + variable : 0);
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentDial,
|
|
support: opponentDial, leader: other.id });
|
|
assert.ok(game.battle?.revealed, 'Both original selected players seal real plans.');
|
|
const battle = game.battle!;
|
|
const side = (id: string): ResolutionCombatant => {
|
|
const player = seat(game, id), view = viewGame(game, id).battle!;
|
|
assert.ok(view.ownForces);
|
|
return { id, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
|
|
plan: battle.plans[id], leader: player.leaders.find(l => l.id === battle.plans[id].leader),
|
|
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
|
|
};
|
|
const publicBattle = viewGame(game, actor).battle!;
|
|
const quote = quoteBattleResolution({ advanced: true, turn: game.turn, territory: fixture.territory,
|
|
ecazOccupy: publicBattle.ecazOccupy!.profile!, aggressor: publicBattle.aggressor,
|
|
attacker: side(battle.attacker), defender: side(battle.defender),
|
|
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: seat(game, id).traitors,
|
|
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
|
|
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
|
|
pendingAuditorPresent: false, pendingRetentionPresent: false });
|
|
for (const voter of publicBattle.traitorVoters)
|
|
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
|
const afterBattle = structuredClone(game);
|
|
game = finishEcazStronghold(game);
|
|
return { game, afterBattle, before, actor, quote, ownLeader: own.id, opponentLeader: other.id };
|
|
}
|
|
|
|
void test('Advanced E3 Stronghold setup retains authenticated seats, exact deck and actual shared end-Mentat custody', () => {
|
|
const admitted = freshLobby();
|
|
const fixture = ecazStrongholdFixture({ initial: admitted });
|
|
assert.deepEqual(fixture.initial.players.map(p => p.id), admitted.players.map(p => p.id));
|
|
assert.equal(fixture.ecaz, 'auth-ecaz'); assert.equal(fixture.ally, 'auth-guild');
|
|
assert.deepEqual([...fixture.afterSetup.deck, ...fixture.afterSetup.discard,
|
|
...fixture.afterSetup.players.flatMap(p => p.hand)].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
|
|
assert.ok(Object.values(fixture.initial.strongholdCards!.owners).every(owner => owner === null));
|
|
assert.ok(Object.values(fixture.afterSetup.strongholdCards!.owners).every(owner => owner === null));
|
|
assert.equal(fixture.beforeMentat.strongholdCards!.owners.arrakeen, null);
|
|
assert.equal(strongholdControllers(fixture.beforeMentat.players, false).arrakeen, fixture.ecaz);
|
|
const settled = applyAction(fixture.beforeMentat, fixture.mentatStep.actor, fixture.mentatStep.action);
|
|
assert.deepEqual(settled.strongholdCards, fixture.afterMentat.strongholdCards);
|
|
assert.equal(settled.strongholdCards!.claimedTurn, 2);
|
|
assert.equal(Object.values(settled.strongholdCards!.owners).filter(id => id === fixture.ecaz).length, 1);
|
|
assert.ok(!Object.values(settled.strongholdCards!.owners).includes(fixture.ally));
|
|
assert.equal(seat(settled, fixture.ecaz).ally, fixture.ally);
|
|
assert.equal(seat(settled, fixture.ally).ally, fixture.ecaz);
|
|
});
|
|
|
|
void test('Only an actual pending Ecaz lead choice is JSON-restored and consumed once', () => {
|
|
const fixture = ecazStrongholdFixture();
|
|
const restored: Game = JSON.parse(JSON.stringify(fixture.game));
|
|
const decision = restored.decision;
|
|
assert.ok(decision?.kind === 'ecazBattleLead');
|
|
const action = { type: 'decision', event: decision.event, lead: fixture.ally };
|
|
const continued = applyAction(restored, fixture.ecaz, action);
|
|
assert.equal(continued.battle!.ecazOccupy!.lead, fixture.ally);
|
|
assert.equal(continued.strongholdCards!.owners.arrakeen, fixture.ecaz);
|
|
assert.throws(() => applyAction(continued, fixture.ecaz, action));
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const) for (const variable of [0, 1, 3])
|
|
void test(`Arrakeen ${lead} lead: only actual variable support ${variable} receives holder bank funding`, () => {
|
|
const fixture = ecazStrongholdFixture();
|
|
const result = reveal(fixture, lead, { variable });
|
|
const { game, afterBattle, before, actor, quote } = result;
|
|
const payment = quote.payments.find(p => p.player === actor)!;
|
|
assert.equal(payment.bankSupport, lead === 'ecaz' ? Math.min(2, variable) : 0);
|
|
assert.equal(payment.ownPayment, lead === 'ecaz' ? Math.max(0, variable - 2) : variable);
|
|
assert.equal(afterBattle.decision?.kind, 'battleCards');
|
|
assert.equal(seat(before, actor).spice - seat(afterBattle, actor).spice, payment.ownPayment);
|
|
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
assert.equal(seat(afterBattle, nonLead).spice, seat(before, nonLead).spice);
|
|
assert.equal(seat(game, actor).spice, seat(afterBattle, actor).spice + 2);
|
|
assert.equal(seat(game, nonLead).spice, seat(afterBattle, nonLead).spice + 2);
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.equal(seat(game, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
|
|
assert.equal(seat(game, fixture.ally).forces[fixture.location], 4 - variable);
|
|
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, variable);
|
|
assert.equal(seat(game, fixture.opponent).forces[fixture.location], undefined);
|
|
assert.ok(!seat(game, actor).leaders.find(l => l.id === result.ownLeader)!.dead);
|
|
assert.ok(!seat(game, fixture.opponent).leaders.find(l => l.id === result.opponentLeader)!.dead);
|
|
const scores = quote.scores!;
|
|
assert.equal(scores[quote.winner === before.battle!.attacker ? 'attacker' : 'defender'],
|
|
3 + variable + seat(before, actor).leaders.find(l => l.id === result.ownLeader)!.strength);
|
|
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.ecaz);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Habbanya tie advantage belongs only to the actual ${lead} plan owner`, () => {
|
|
const fixture = ecazStrongholdFixture({ kind: 'habbanya_ridge_sietch' });
|
|
const prepared = openEcazStronghold(fixture, lead);
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const view = viewGame(prepared, actor).battle!;
|
|
if (lead === 'ecaz') assert.equal(view.aggressor, fixture.opponent,
|
|
'The actual holder must overturn an opposing ordinary tie priority.');
|
|
assert.equal(view.strongholdEffects[actor], lead === 'ecaz' ? fixture.territory : null);
|
|
assert.equal(view.tieWinner, lead === 'ecaz' ? fixture.ecaz : view.aggressor);
|
|
const result = reveal(fixture, lead, { variable: 0, tie: true });
|
|
assert.equal(result.quote.scores!.attacker, result.quote.scores!.defender);
|
|
assert.equal(result.game.lastBattleContext!.winner, view.tieWinner);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Tabr winning income is paid once to ${lead} only when that selected plan owner holds the card`, () => {
|
|
const fixture = ecazStrongholdFixture({ kind: 'sietch_tabr' });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const shield = stageEcazOccupyCard(fixture.game, actor, c => c.kind === 'shield');
|
|
fixture.staging.push(`Conserved physical Shield ${shield} transferred to selected ${actor}; no face or wallet manufactured.`);
|
|
const result = reveal(fixture, lead, { variable: 1, opponentDial: 2, defense: shield });
|
|
const { game, afterBattle, before, quote } = result;
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.deepEqual(quote.strongholdIncome, lead === 'ecaz' ? [{ player: actor, amount: 2 }] : []);
|
|
assert.equal(seat(afterBattle, actor).spice, seat(before, actor).spice - 1 + (lead === 'ecaz' ? 2 : 0));
|
|
assert.ok(seat(game, actor).hand.some(c => c.id === shield));
|
|
const afterCollection = advanceEcazStronghold(game, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
|
|
assert.ok(seat(afterCollection, actor).hand.some(c => c.id === shield));
|
|
assert.equal(afterCollection.strongholdCards!.owners.sietch_tabr, fixture.ecaz);
|
|
assert.equal(afterCollection.strongholdCards!.claimedTurn, 2);
|
|
assert.equal(seat(afterCollection, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(seat(afterCollection, fixture.ally).forces[fixture.location], 3);
|
|
});
|
|
|
|
void test('All four minimal policies consume actual Ecaz lead, original counter and selected plan slots', () => {
|
|
const fixture = ecazStrongholdFixture();
|
|
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
const policy = (game: Game, actor: string) => {
|
|
const view = viewGame(game, actor);
|
|
view.players.find(p => p.id === actor)!.bot = difficulty;
|
|
return botActions(view);
|
|
};
|
|
const lead = policy(fixture.game, fixture.ecaz).find(a => a.type === 'decision');
|
|
assert.ok(lead);
|
|
let game = applyAction(fixture.game, fixture.ecaz, lead);
|
|
assert.equal(game.response?.kind, 'ecazOccupy');
|
|
const counter = policy(game, fixture.opponent).find(a => a.type === 'passResponse' ||
|
|
a.type === 'card' && a.mode === 'cancel');
|
|
assert.ok(counter);
|
|
game = applyAction(game, fixture.opponent, counter);
|
|
game = advanceEcazStronghold(game, g => !g.response && !g.decision && !g.battle?.preparation && (!g.battle?.preLeader || g.battle.preLeader.closed));
|
|
const actor = game.battle!.ecazOccupy!.lead!;
|
|
const plan = policy(game, actor).find(a => a.type === 'battlePlan');
|
|
assert.ok(plan);
|
|
game = applyAction(game, actor, plan);
|
|
assert.ok(game.battle!.plans[actor]);
|
|
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
|
assert.ok(game.battle!.plans[actor].dial >= profile.fixedEcazDial);
|
|
for (const selected of ['ecaz', 'ally'] as const) {
|
|
const prepared = openEcazStronghold(fixture, selected);
|
|
const selectedActor = selected === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const selectedPlan = policy(prepared, selectedActor).find(a => a.type === 'battlePlan');
|
|
assert.ok(selectedPlan);
|
|
applyAction(prepared, selectedActor, selectedPlan);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('Actual Stronghold initializer excludes Basic, E1/E2 mixtures, native pair and all other overlays', () => {
|
|
assert.throws(() => initializeStrongholdFactionsGameForAudit(freshLobby(false)));
|
|
for (const overlay of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'discoveryEnabled', 'ecazTreachery'] as const) {
|
|
const game = freshLobby();
|
|
Object.assign(game, { [overlay]: true });
|
|
assert.throws(() => initializeStrongholdFactionsGameForAudit(game));
|
|
}
|
|
for (const expansion of ['ix', 'choam'] as const) {
|
|
const mixed = freshLobby(); mixed.expansions.push(expansion);
|
|
assert.throws(() => initializeStrongholdFactionsGameForAudit(mixed));
|
|
}
|
|
let pair = createGame('ECAZMORITANISTRONGHOLDGUARD', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
|
joinGame(pair, newPlayer('moritani', 'Moritani', 'moritani'));
|
|
joinGame(pair, newPlayer('guild', 'Guild', 'guild'));
|
|
for (const player of pair.players) pair = applyAction(pair, player.id, { type: 'ready' });
|
|
assert.throws(() => initializeStrongholdFactionsGameForAudit(pair));
|
|
});
|