314 lines
24 KiB
TypeScript
314 lines
24 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { territory } from '../game/board';
|
|
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
|
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
|
import {
|
|
advancePairedIxNexusSkills, beginPairedIxNexusSkillsCunning, choosePairedIxNexusSkillsSecondBattle,
|
|
createPairedIxNexusSkillsFixture, finishPairedIxNexusSkillsBattle, finishPairedIxNexusSkillsTurn,
|
|
pairedIxNexusSkillsPlayer as player, preparePairedIxNexusSkillsBattle, preparePairedIxNexusSkillsExpiryBattle,
|
|
quotePairedIxNexusSkillsBattle, revealPairedIxNexusSkillsBattle, stepPairedIxNexusSkills,
|
|
type PairedIxNexusSkillsFixture, type PairedIxNexusSkillsStep,
|
|
} from './fixture-paired-ix-nexus-skills';
|
|
|
|
function reject(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'Rejected consumer actions cannot consume physical cards, forces, spice or receipts');
|
|
}
|
|
function treachery(game: Game): string[] {
|
|
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
|
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
|
|
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
|
}
|
|
function custody(game: Game, original: Game): void {
|
|
assert.equal(treachery(game).length, 47);
|
|
assert.deepEqual(treachery(game), treachery(original));
|
|
assert.equal(new Set(treachery(game)).size, 47);
|
|
validateLeaderSkills(game.leaderSkills!, game.players);
|
|
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
|
|
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
|
|
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort());
|
|
const nexus = game.nexusCards!.cards!;
|
|
const faces = [...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is NonNullable<typeof c> => c !== null)];
|
|
assert.equal(faces.length, 12); assert.equal(new Set(faces).size, 12);
|
|
const stock = (g: Game) => [...(g.traitorReserve ?? []), ...g.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
|
|
assert.deepEqual(stock(game), stock(original)); assert.equal(new Set(stock(game)).size, stock(game).length);
|
|
for (const p of game.players) {
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
|
|
if (p.elites) {
|
|
const old = player(original, p.id).elites!;
|
|
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
|
old.reserves + old.tanks + Object.values(old.forces).reduce((sum, n) => sum + n, 0));
|
|
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
|
|
}
|
|
}
|
|
}
|
|
function city(game: Game, actor: string): number {
|
|
return game.phase === 7 && !game.phaseOpening ? quoteSpiceCollection(game).receipts
|
|
.filter(r => r.player === actor).reduce((sum, r) => sum + r.collected + r.strongholds, 0) : 0;
|
|
}
|
|
function money(revealed: Game, settled: Game): void {
|
|
const quote = quotePairedIxNexusSkillsBattle(revealed);
|
|
for (const p of revealed.players) {
|
|
const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
|
|
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
|
|
assert.equal(player(settled, p.id).spice, p.spice - paid + bounty + city(settled, p.id), p.faction);
|
|
}
|
|
assert.deepEqual(quote.strongholdIncome, [], 'Printed city collection is separate; this profile has no Stronghold Cards');
|
|
}
|
|
function chooseCyborgLosses(state: Game, f: PairedIxNexusSkillsFixture): Game {
|
|
const losses = finishPairedIxNexusSkillsBattle(state, 'losses');
|
|
assert.equal(losses.decision?.kind, 'battleLosses');
|
|
if (losses.decision?.kind !== 'battleLosses') throw Error('Actual typed casualty choice required');
|
|
const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3); assert.ok(choice >= 0);
|
|
assert.equal(player(losses, f.ixians).tanks, player(state, f.ixians).tanks, 'Typed commitment precedes physical Suk settlement');
|
|
return stepPairedIxNexusSkills(losses, { actor: f.ixians, action: { type: 'decision', choice } });
|
|
}
|
|
function dance(f: PairedIxNexusSkillsFixture, pending: Game): Game {
|
|
assert.equal(pending.decision?.kind, 'faceDance');
|
|
const count = player(pending, f.ixians).forces[f.location]; assert.ok(count >= 2);
|
|
return stepPairedIxNexusSkills(pending, { actor: f.tleilaxu, action: { type: 'decision', reveal: true,
|
|
sources: { [f.replacementSource]: 2, reserves: count - 2 }, sector: territory(f.kind).sectors[0] } });
|
|
}
|
|
|
|
for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] as const) {
|
|
void test(`original paired E1 ${advanced ? 'Advanced' : 'Basic'} ${band} Warmaster: full unsupported Suboids, real bonus and typed losses`, () => {
|
|
const f = createPairedIxNexusSkillsFixture({ advanced, band, skill: 'warmaster', dial: 3, support: 0 });
|
|
assert.equal(f.beforeFirstMentat.turn, 1); assert.equal(f.afterFirstMentat.turn, 2);
|
|
assert.equal(f.beforeAlliance.nexus, true);
|
|
assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]);
|
|
assert.equal(player(f.afterNexusDraw, f.ixians).ally, null);
|
|
assert.equal(player(f.afterNexusDraw, f.tleilaxu).ally, null);
|
|
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians');
|
|
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu');
|
|
custody(f.afterNexusDraw, f.afterSetup);
|
|
const ready = preparePairedIxNexusSkillsBattle(f), offer = viewGame(ready, f.ixians).nexusSuboids!.offer!;
|
|
reject(ready, f.tleilaxu, { type: 'nexusSuboids', event: offer.event });
|
|
const active = beginPairedIxNexusSkillsCunning(f, ready);
|
|
assert.deepEqual(player(active, f.ixians).forces, player(ready, f.ixians).forces);
|
|
assert.deepEqual(player(active, f.ixians).elites, player(ready, f.ixians).elites);
|
|
assert.equal(player(active, f.ixians).spice, player(ready, f.ixians).spice);
|
|
assert.equal(active.nexusCards!.cards!.hands[f.ixians], null);
|
|
reject(active, f.ixians, { type: 'nexusSuboids', event: offer.event });
|
|
const revealed = revealPairedIxNexusSkillsBattle(f, { state: active });
|
|
const quote = quotePairedIxNexusSkillsBattle(revealed);
|
|
assert.equal(quote.result, 'normal'); assert.equal(quote.winner, f.ixians);
|
|
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
|
|
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
|
|
const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender';
|
|
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: band === 'skilled' ? 3 : 1, mode: band }]);
|
|
assert.equal(quote.leaderStrengths[side], player(revealed, f.ixians).leaders.find(l => l.id === f.planActions[0].action.leader)!.strength);
|
|
assert.ok(quote.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
|
|
if (advanced) {
|
|
const ordinary = quotePairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f));
|
|
assert.ok(!ordinary.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
|
|
assert.equal(quote.payments.find(r => r.player === f.ixians)!.ownPayment, 0);
|
|
assert.equal(quote.payments.find(r => r.player === f.ixians)!.bankSupport, 0);
|
|
} else assert.deepEqual(quote.payments, []);
|
|
const losses = finishPairedIxNexusSkillsBattle(revealed, 'losses');
|
|
if (losses.decision?.kind !== 'battleLosses') throw Error('Missing actual typed winner casualties');
|
|
const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0); assert.ok(choice >= 0);
|
|
const settled = finishPairedIxNexusSkillsBattle(stepPairedIxNexusSkills(losses, { actor: f.ixians, action: { type: 'decision', choice } }));
|
|
assert.equal(player(settled, f.ixians).forces[f.location], 3);
|
|
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
|
|
assert.equal(player(settled, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
|
|
assert.equal(player(settled, f.ixians).elites!.tanks, player(revealed, f.ixians).elites!.tanks);
|
|
assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
|
|
money(revealed, settled); custody(settled, f.afterSetup);
|
|
});
|
|
|
|
void test(`original paired E1 ${advanced ? 'Advanced' : 'Basic'} ${band} Suk: physical rescue precedes equal Suboid substitution, paid once`, () => {
|
|
const f = createPairedIxNexusSkillsFixture({ advanced, band, skill: 'suk-graduate' });
|
|
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true }), quote = quotePairedIxNexusSkillsBattle(revealed);
|
|
assert.equal(quote.winner, f.ixians); assert.equal(quote.sukGraduate!.mode, band);
|
|
if (advanced) {
|
|
const payment = quote.payments.find(r => r.player === f.ixians)!;
|
|
assert.equal(payment.ownPayment, 1); assert.equal(payment.bankSupport, 0);
|
|
} else assert.deepEqual(quote.payments, []);
|
|
const original = player(revealed, f.ixians);
|
|
let selected = chooseCyborgLosses(revealed, f);
|
|
let event = '', choice = 0;
|
|
if (band === 'skilled') {
|
|
const rescue = finishPairedIxNexusSkillsBattle(selected, 'rescue');
|
|
if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original skilled Suk rescue');
|
|
assert.equal(player(rescue, f.ixians).tanks, original.tanks, 'No physical losses occur before the skilled rescue choice');
|
|
choice = rescue.decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite' && o.kept.key === f.location);
|
|
assert.ok(choice >= 0); event = rescue.decision.event;
|
|
selected = stepPairedIxNexusSkills(rescue, { actor: f.ixians, action: { type: 'decision', event, choice } });
|
|
} else {
|
|
assert.equal(selected.lastBattleContext!.sukRescue!.completed, true, 'The sole normal elite rescue is applied automatically');
|
|
}
|
|
const pending = finishPairedIxNexusSkillsBattle(selected, 'substitution');
|
|
assert.equal(pending.decision?.kind, 'ixSubstitution');
|
|
const p = player(pending, f.ixians);
|
|
assert.equal(p.tanks, original.tanks + 2); assert.equal(p.elites!.tanks, original.elites!.tanks + 2);
|
|
assert.equal(p.battleLosses, original.battleLosses + 2);
|
|
assert.equal(p.reserves, original.reserves + (band === 'normal' ? 1 : 0));
|
|
assert.equal(p.elites!.reserves, original.elites!.reserves + (band === 'normal' ? 1 : 0));
|
|
assert.equal(p.forces[f.location], band === 'normal' ? 3 : 4);
|
|
assert.equal(p.elites!.forces[f.location] ?? 0, band === 'normal' ? 0 : 1);
|
|
assert.equal(p.spice, original.spice - (advanced ? 1 : 0));
|
|
reject(pending, f.ixians, { type: 'decision', event, choice });
|
|
reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 3 } });
|
|
const exchanged = stepPairedIxNexusSkills(pending, { actor: f.ixians, action: { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } } });
|
|
const settled = finishPairedIxNexusSkillsBattle(exchanged), after = player(settled, f.ixians);
|
|
assert.equal(after.forces[f.location], p.forces[f.location]);
|
|
assert.equal(after.elites!.forces[f.location], band === 'normal' ? 2 : 3);
|
|
assert.equal(after.tanks, p.tanks, 'Equal physical exchange does not count the original Cyborg loss twice');
|
|
assert.equal(after.elites!.tanks, original.elites!.tanks);
|
|
assert.equal(after.battleLosses, p.battleLosses);
|
|
assert.equal(after.reserves, p.reserves); assert.equal(after.elites!.reserves, p.elites!.reserves);
|
|
assert.equal(settled.lastBattleContext!.sukRescue!.completed, true);
|
|
assert.equal(settled.lastBattleContext!.ixSubstitution!.completed, true);
|
|
money(revealed, settled); custody(settled, f.afterSetup);
|
|
});
|
|
}
|
|
|
|
void test('own sealed plan blocks Cunning; active Suboids persist across real battles and expire at the actual next turn', () => {
|
|
const f = createPairedIxNexusSkillsFixture({ skill: 'warmaster', dial: 3, support: 0, secondBattle: true });
|
|
const prepared = preparePairedIxNexusSkillsBattle(f), offer = viewGame(prepared, f.ixians).nexusSuboids!.offer!;
|
|
const sealed = stepPairedIxNexusSkills(prepared, f.planActions[0]);
|
|
reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event });
|
|
const settled = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f, { cunning: true }));
|
|
const next = choosePairedIxNexusSkillsSecondBattle(f, settled);
|
|
assert.equal(viewGame(next, f.ixians).nexusSuboids!.active, true);
|
|
const unused = (actor: string) => player(next, actor).leaders.find(l => !l.dead && !l.usedAt &&
|
|
!next.leaderSkills!.assignments.some(assignment => assignment.leader === l.id))!;
|
|
const plans: PairedIxNexusSkillsStep[] = [
|
|
{ actor: f.ixians, action: { type: 'battlePlan', leader: unused(f.ixians).id, dial: 3, support: 0, weapon: null, defense: null } },
|
|
{ actor: f.opponent, action: { type: 'battlePlan', leader: unused(f.opponent).id, dial: 0, support: 0, weapon: null, defense: null } },
|
|
];
|
|
const second = revealPairedIxNexusSkillsBattle(f, { state: next, plans });
|
|
assert.equal(quotePairedIxNexusSkillsBattle(second).casualties!.forces.normalFreeSupport, true);
|
|
const secondSettled = finishPairedIxNexusSkillsBattle(second), turn = finishPairedIxNexusSkillsTurn(secondSettled);
|
|
assert.equal(viewGame(turn, f.ixians).nexusSuboids!.active, false);
|
|
const expired = preparePairedIxNexusSkillsExpiryBattle(f, turn);
|
|
const leader = player(expired, f.ixians).leaders.find(l => !l.dead &&
|
|
!expired.leaderSkills!.assignments.some(assignment => assignment.leader === l.id))!;
|
|
reject(expired, f.ixians, { type: 'battlePlan', leader: leader.id, dial: 3, support: 0, weapon: null, defense: null });
|
|
const legal = stepPairedIxNexusSkills(expired, { actor: f.ixians, action: { type: 'battlePlan', leader: leader.id, dial: 1.5, support: 0, weapon: null, defense: null } });
|
|
assert.equal(legal.battle!.plans[f.ixians].dial, 1.5); custody(legal, f.afterSetup);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`original paired ${advanced ? 'Advanced' : 'Basic'} Face Dance: Suk/card cleanup/rewards finish first; partial Cunning draws before retirement`, () => {
|
|
const f = createPairedIxNexusSkillsFixture({ advanced, program: 'faceDance', skill: 'suk-graduate' });
|
|
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true }), quote = quotePairedIxNexusSkillsBattle(revealed);
|
|
const enemy = player(revealed, f.opponent).leaders.find(l => l.id === f.planActions[1].action.leader)!;
|
|
assert.deepEqual(quote.bounty, { player: f.ixians, amount: enemy.strength });
|
|
const cards = finishPairedIxNexusSkillsBattle(revealed, 'cards');
|
|
assert.equal(cards.decision?.kind, 'battleCards');
|
|
assert.equal(cards.lastBattleContext!.sukRescue!.completed, true);
|
|
const defense = f.planActions[0].action.defense as string;
|
|
const cleaned = stepPairedIxNexusSkills(cards, { actor: f.ixians, action: { type: 'decision', discard: [defense] } });
|
|
const pending = finishPairedIxNexusSkillsBattle(cleaned, 'faceDance');
|
|
assert.ok(pending.discard.some(c => c.id === defense));
|
|
assert.equal(player(pending, f.opponent).leaders.find(l => l.id === enemy.id)!.dead, true);
|
|
money(revealed, pending);
|
|
const winner = structuredClone(player(pending, f.ixians));
|
|
const oldStock = structuredClone(player(pending, f.tleilaxu).faceDancers!);
|
|
assert.ok(oldStock.some(c => c.leader === f.trainer && !c.revealed));
|
|
reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: winner.forces[f.location] + 1 }, sector: territory(f.kind).sectors[0] });
|
|
const after = dance(f, pending), returned = player(after, f.ixians), replacement = player(after, f.tleilaxu);
|
|
assert.equal(returned.forces[f.location] ?? 0, 0); assert.equal(returned.elites!.forces[f.location] ?? 0, 0);
|
|
assert.equal(returned.reserves, winner.reserves + winner.forces[f.location]);
|
|
assert.equal(returned.elites!.reserves, winner.elites!.reserves + (winner.elites!.forces[f.location] ?? 0));
|
|
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.battleLosses, winner.battleLosses);
|
|
assert.equal(returned.elites!.tanks, winner.elites!.tanks);
|
|
assert.equal(returned.leaders.find(l => l.id === f.trainer)!.deaths, winner.leaders.find(l => l.id === f.trainer)!.deaths + 1);
|
|
assert.equal(returned.leaders.find(l => l.id === f.trainer)!.dead, true);
|
|
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
|
assert.equal(after.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
|
|
assert.deepEqual(returned.hand, winner.hand);
|
|
for (const p of pending.players) assert.equal(player(after, p.id).spice, p.spice + city(after, p.id), 'Face Dance grants no extra bounty/support charge');
|
|
assert.equal(replacement.forces[f.location], winner.forces[f.location]);
|
|
assert.equal(replacement.forces[f.replacementSource], 1);
|
|
assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - winner.forces[f.location] + 2);
|
|
const revealedStock = replacement.faceDancers!.filter(c => c.revealed); assert.equal(revealedStock.length, 1);
|
|
const untouched = replacement.faceDancers!.filter(c => !c.revealed);
|
|
assert.deepEqual(untouched, oldStock.filter(c => c.leader !== f.trainer));
|
|
const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!;
|
|
const action = nexusFaceDancersAction(view, offer.event); assert.ok(action);
|
|
const reserve = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn;
|
|
reject(after, f.tleilaxu, { ...action, leaders: [f.trainer] });
|
|
const replaced = stepPairedIxNexusSkills(after, { actor: f.tleilaxu, action });
|
|
assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
|
|
assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
|
|
assert.notEqual(reserve[0], f.trainer);
|
|
assert.deepEqual([...replaced.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort());
|
|
assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance);
|
|
assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null);
|
|
reject(replaced, f.tleilaxu, action); custody(replaced, f.afterSetup);
|
|
const mentat = advancePairedIxNexusSkills(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
|
|
const ordinary = stepPairedIxNexusSkills(mentat, { actor: f.tleilaxu, action: { type: 'replaceFaceDancer', leader: untouched[0].leader } });
|
|
const done = advancePairedIxNexusSkills(ordinary, g => !g.response && !g.decision);
|
|
assert.equal(player(done, f.tleilaxu).faceDancerReplacedTurn, done.turn);
|
|
reject(done, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(done, f.tleilaxu).faceDancers![0].leader });
|
|
const turn = finishPairedIxNexusSkillsTurn(done);
|
|
assert.equal(turn.leaderSkills!.deck.filter(s => s === f.skill).length, 1); custody(turn, f.afterSetup);
|
|
if (advanced) {
|
|
const revival = advancePairedIxNexusSkills(turn, g => g.phase === 4 && !g.phaseOpening && !g.response && !g.decision);
|
|
reject(revival, f.tleilaxu, { type: 'reviveForeignGhola', leader: enemy.id });
|
|
assert.equal(player(revival, f.opponent).leaders.find(l => l.id === enemy.id)!.gholaBy, undefined,
|
|
'An actual dead foreign disc does not bypass the native five-living-leader ghola limit');
|
|
}
|
|
});
|
|
|
|
void test('normal-band Face Dance selects a genuine matching untrained disc and cannot return its living trainer skill', () => {
|
|
const f = createPairedIxNexusSkillsFixture({ program: 'faceDance', band: 'normal', skill: 'warmaster' });
|
|
const selected = f.planActions[0].action.leader as string;
|
|
assert.notEqual(selected, f.trainer);
|
|
const original = structuredClone(player(f.afterSetup, f.tleilaxu).faceDancers!);
|
|
assert.ok(original.some(c => c.leader === selected && !c.revealed));
|
|
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true });
|
|
const quote = quotePairedIxNexusSkillsBattle(revealed);
|
|
const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender';
|
|
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: 1, mode: 'normal' }]);
|
|
const pending = finishPairedIxNexusSkillsBattle(revealed, 'faceDance');
|
|
assert.deepEqual(player(pending, f.tleilaxu).faceDancers, original);
|
|
const after = dance(f, pending);
|
|
assert.equal(player(after, f.ixians).leaders.find(l => l.id === selected)!.dead, true);
|
|
assert.equal(player(after, f.ixians).leaders.find(l => l.id === f.trainer)!.dead, false);
|
|
assert.deepEqual(after.leaderSkills!.assignments.find(a => a.owner === f.ixians),
|
|
pending.leaderSkills!.assignments.find(a => a.owner === f.ixians));
|
|
assert.equal(after.leaderSkills!.deck.includes('warmaster'), false);
|
|
custody(after, f.afterSetup);
|
|
});
|
|
|
|
void test('native bot Face Dance uses original physical reserve/board sources after genuine skilled winner aftermath', () => {
|
|
const f = createPairedIxNexusSkillsFixture({ program: 'faceDance', skill: 'suk-graduate' });
|
|
const pending = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f, { cunning: true }), 'faceDance');
|
|
const view = viewGame(pending, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = 'Easy';
|
|
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action); assert.equal(action.reveal, true);
|
|
const after = stepPairedIxNexusSkills(pending, { actor: f.tleilaxu, action });
|
|
assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0);
|
|
assert.equal(player(after, f.tleilaxu).forces[f.location], player(pending, f.ixians).forces[f.location]);
|
|
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
|
custody(after, f.afterSetup);
|
|
});
|
|
|
|
void test('authenticated original starting hands/offers continue without redeal; foreign leader assignment and absent requested skill are rejected', () => {
|
|
const lobby = createGame('PAIREDIXSKILLSAUTH', newPlayer('http-ix', 'Ix', 'ixians'), true, ['ix']);
|
|
for (const [id, faction] of [['http-tl', 'tleilaxu'], ['http-guild', 'guild'], ['http-emperor', 'emperor']] as const)
|
|
joinGame(lobby, newPlayer(id, faction, faction));
|
|
const fresh = createPairedIxNexusSkillsFixture({ initial: lobby, skill: 'warmaster' });
|
|
const saved = structuredClone(fresh.offered), owner = player(saved, fresh.ixians);
|
|
assert.ok(owner.hand.length > 0); assert.ok(Object.keys(saved.leaderSkills!.offers).length > 0);
|
|
const offer = saved.leaderSkills!.offers[fresh.ixians], foreign = player(saved, fresh.opponent).leaders[0];
|
|
reject(saved, fresh.ixians, { type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader: foreign.id });
|
|
const unavailable = LEADER_SKILL_CARDS.find(c => !offer.cards.includes(c.id))!.id;
|
|
assert.throws(() => createPairedIxNexusSkillsFixture({ initial: saved, skill: unavailable }));
|
|
const continued = createPairedIxNexusSkillsFixture({ initial: saved, skill: 'warmaster' });
|
|
assert.deepEqual(continued.initial, saved);
|
|
assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.hand]), saved.players.map(p => [p.id, p.hand]));
|
|
assert.deepEqual(continued.offered.leaderSkills!.offers, saved.leaderSkills!.offers);
|
|
assert.equal(continued.ixians, 'http-ix'); assert.equal(continued.tleilaxu, 'http-tl');
|
|
assert.equal(continued.afterNexusDraw.nexusCards!.cards!.hands['http-ix'], 'ixians');
|
|
assert.equal(continued.afterNexusDraw.nexusCards!.cards!.hands['http-tl'], 'tleilaxu');
|
|
custody(continued.afterNexusDraw, continued.afterSetup);
|
|
});
|