404 lines
27 KiB
TypeScript
404 lines
27 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
|
|
type Action, type Game,
|
|
} from '../game/engine';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { strongholdControllers } from '../game/stronghold-cards';
|
|
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
|
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
|
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
|
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
|
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
|
import {
|
|
advanceStandaloneHomeworldNexus as advance, assertStandaloneHomeworldNexusCustody as custody,
|
|
buyStandaloneHomeworldMoritaniArmament as buyArmament, openStandaloneHomeworldGuildDeath as guildDeathBattle,
|
|
createStandaloneHomeworldEcazCunning as ecazProgramme,
|
|
createStandaloneHomeworldNexusClosing as closing, createStandaloneHomeworldNexusDiscovery as discovery,
|
|
createStandaloneHomeworldNexusSetup as setup,
|
|
nextStandaloneHomeworldNexusStep as next, openStandaloneHomeworldMoritaniDeath as deathBattle,
|
|
orderStandaloneHomeworldNexusSpice as orderSpice, reloadStandaloneHomeworldNexus as reload,
|
|
revealStandaloneHomeworldNexusPlans as reveal, settleStandaloneHomeworldNexus as settle,
|
|
standaloneHomeworldNexusClean as clean, standaloneHomeworldNexusPlayer as player,
|
|
standaloneHomeworldNexusPolicy as policy, standaloneHomeworldNexusPool as pool,
|
|
stepStandaloneHomeworldNexus as step, type StandaloneHomeworldNexusStep,
|
|
} from './fixture-homeworld-standalone-nexus';
|
|
|
|
function reject(g: Game, actor: string, action: Action, reason?: RegExp): void {
|
|
const before = structuredClone(g);
|
|
if (reason) assert.throws(() => applyAction(g, actor, action), reason);
|
|
else assert.throws(() => applyAction(g, actor, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
/** Every difficulty acts at a genuine window and demonstrates its physical
|
|
* consumer transition. Applying an action without throwing is not the proof. */
|
|
function legalPolicies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const offered = policy(g, actor, difficulty); assert.ok(offered[0], `${difficulty} needs an actual owned choice.`);
|
|
const after = applyAction(reload(g), actor, offered[0]);
|
|
consume(after, offered[0]); custody(after);
|
|
}
|
|
}
|
|
function finishSettlement(g: Game, actions?: StandaloneHomeworldNexusStep[]): Game {
|
|
for (let n = 0; n < 80; n++) {
|
|
const boundary = settle(g, actions); g = boundary.game;
|
|
if (boundary.kind === 'settled') return g;
|
|
const d = g.decision; assert.ok(d);
|
|
let action: Action;
|
|
if (d.kind === 'battleLosses') action = { type: 'decision', choice: 0 };
|
|
else if (d.kind === 'battleCards') action = { type: 'decision', discard: [] };
|
|
else if (d.kind === 'sukRescue') action = { type: 'decision', event: d.event, choice: 0 };
|
|
else if (d.kind === 'moritaniAssassinate') action = { type: 'decision', event: d.event, decline: true };
|
|
else if (d.kind === 'techToken') {
|
|
const target = d.choices[0];
|
|
assert.equal(g.battle, null, 'Original battle disposal precedes mandatory winner Tech.');
|
|
assert.ok(!g.pendingSukRescue && !g.pendingWinnerDiscards);
|
|
for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? [])
|
|
assert.ok(g.discard.some(c => c.id === entry.card), 'Played cards are physically cleaned before the reward.');
|
|
reject(g, d.loser, { type: 'decision', token: target });
|
|
reject(g, d.player, { type: 'decision', decline: true });
|
|
legalPolicies(g, d.player, (after, selected) => {
|
|
const token = selected.token as keyof NonNullable<Game['techTokens']>;
|
|
assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player);
|
|
});
|
|
action = { type: 'decision', token: target };
|
|
} else throw Error(`Unexpected original settlement boundary ${d.kind}`);
|
|
g = step(g, { actor: d.player, action }, actions);
|
|
}
|
|
throw Error('Original physical settlement did not finish.');
|
|
}
|
|
|
|
void test('a genuine native closing deal and living unclaimed Ecaz Cunning coexist with real native Wallach custody, never Intrusion or Homeworld Occupy', () => {
|
|
const f = ecazProgramme({ discovery: true });
|
|
assert.equal(f.firstStorm.techTokens && ownedTech(f.firstStorm.techTokens, f.owner).length, 1);
|
|
assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false));
|
|
assert.equal(f.afterSetup.dukeVidal!.controller, null);
|
|
assert.equal(f.afterSetup.dukeVidal!.leader.dead, false);
|
|
assert.equal(player(f.alliance, f.owner).ally, null);
|
|
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
|
assert.ok(!f.draw.after.nexusCards!.cards!.deck.includes('ecaz'));
|
|
legalPolicies(f.draw.before, f.owner, after => {
|
|
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
|
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
|
|
});
|
|
reject(f.draw.before, f.opponent, f.draw.step.action);
|
|
reject(f.draw.after, f.owner, f.draw.step.action);
|
|
|
|
const shipper = player(f.arrival.before, f.owner), arrived = player(f.arrival.settled, f.owner);
|
|
const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources);
|
|
const choice = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location,
|
|
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
|
|
assert.equal(arrived.spice, shipper.spice - choice.cost);
|
|
assert.equal(arrived.reserves, shipper.reserves - 1);
|
|
assert.equal(pool(f.arrival.settled, f.owner, f.location).normal, 1);
|
|
assert.deepEqual(pool(f.arrival.settled, f.opponent, f.location), pool(f.arrival.before, f.opponent, f.location));
|
|
assert.ok(!f.arrival.settled.pendingAmbassador && !f.arrival.settled.pendingTerrorEntry);
|
|
assert.notEqual(f.arrival.settled.decision?.kind, 'intrusion');
|
|
assert.ok(!Object.keys(arrived.forces).some(key => key.startsWith('homeworld:')));
|
|
assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:')));
|
|
assert.equal(f.game.battle!.territory, f.location);
|
|
custody(f.arrival.settled);
|
|
|
|
const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action);
|
|
reject(f.cunning.before, f.opponent, action);
|
|
reject(f.cunning.before, f.owner, { ...action, event: 'stale' });
|
|
legalPolicies(f.cunning.before, f.owner, after => {
|
|
assert.equal(after.dukeVidal!.controller, f.owner);
|
|
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
|
|
});
|
|
assert.equal(f.cunning.after.dukeVidal!.controller, f.owner);
|
|
assert.equal(f.cunning.after.dukeVidal!.source, 'ecazNexus');
|
|
assert.equal(f.cunning.after.dukeVidal!.leader.dead, false);
|
|
assert.ok(!f.cunning.after.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID));
|
|
assert.ok(player(f.cunning.after, f.owner).leaders.every(l => l.id !== DUKE_VIDAL_ID));
|
|
reject(f.cunning.after, f.owner, action);
|
|
reject(f.game, f.owner, action, /interaction|unavailable|current/i);
|
|
|
|
const revealed = reveal(f.game, f.plans, f.actions);
|
|
assert.equal(player(revealed, f.opponent).spice, player(f.game, f.opponent).spice,
|
|
'The real sealed plan does not debit dialed support at reveal.');
|
|
const done = finishSettlement(revealed, f.actions);
|
|
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
|
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3,
|
|
'Free native strength never pays for three dialed support.');
|
|
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 3);
|
|
assert.equal(pool(done, f.opponent, f.location).normal, pool(revealed, f.opponent, f.location).normal - 3);
|
|
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
|
assert.equal(pool(done, f.owner, f.location).normal, 0);
|
|
assert.equal(done.dukeVidal!.leader.dead, false);
|
|
assert.equal(done.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
|
const token = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(token);
|
|
assert.equal(done.techTokens![token].owner, f.opponent);
|
|
custody(done);
|
|
|
|
// The genuine next Nexus changes the alliance, not the physical identity of
|
|
// either counter pool. Paid Arrakis shipments may now occupy one territory;
|
|
// those same seats still cannot use allied native-home shipment as Occupy.
|
|
let g = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions);
|
|
assert.equal(g.dukeVidal!.controller, null);
|
|
orderSpice(g, true);
|
|
g = advance(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), f.actions);
|
|
const guild = g.players.find(p => p.faction === 'guild')!.id;
|
|
g = step(g, { actor: guild, action: { type: 'alliance', target: null } }, f.actions);
|
|
for (const [actor, target] of [[f.owner, guild], [guild, f.owner]])
|
|
g = step(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
|
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'territory', id: 'polar_sink' }), 'ecazAlliance');
|
|
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'homeworld', id: 'homeworld:guild' }), 'different');
|
|
const shipped: Record<string, boolean> = {};
|
|
for (let n = 0; n < 240; n++) {
|
|
if (shipped[f.owner] && shipped[guild] && g.phase >= 6 && clean(g)) break;
|
|
if (g.phase === 5 && clean(g) && g.active && !shipped[g.active] && [f.owner, guild].includes(g.active)) {
|
|
const actor = g.active, view = viewGame(g, actor), own = player(g, actor);
|
|
const reserves = nativeShipmentSources(view, 1, 0); assert.ok(reserves);
|
|
if (actor === f.owner) {
|
|
const alliedHome = homeworldShipmentChoice(view, 'homeworld:guild', reserves);
|
|
assert.equal(alliedHome.action, null);
|
|
assert.ok(alliedHome.blocked);
|
|
}
|
|
const before = reload(g);
|
|
g = step(g, { actor, action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1,
|
|
elite: 0, allyPayment: 0, homeworldSources: reserves } }, f.actions);
|
|
g = advance(g, s => !s.pendingShipment && clean(s), f.actions);
|
|
assert.equal(player(g, actor).reserves, own.reserves - 1);
|
|
assert.equal(player(g, actor).forces['polar_sink:0'], (player(before, actor).forces['polar_sink:0'] ?? 0) + 1);
|
|
assert.ok(player(g, actor).spice < own.spice);
|
|
shipped[actor] = true;
|
|
} else {
|
|
const owned = next(g); assert.ok(owned); g = step(g, owned, f.actions);
|
|
}
|
|
}
|
|
assert.ok(shipped[f.owner] && shipped[guild]);
|
|
assert.equal(player(g, f.owner).forces['polar_sink:0'], 1);
|
|
assert.equal(player(g, guild).forces['polar_sink:0'], 1);
|
|
assert.equal(g.battle, null, 'Original allied Arrakis Occupy is not a battle between those two seats.');
|
|
custody(g);
|
|
});
|
|
|
|
void test('original Moritani private rescue → assassination → exact death Skill return → card cleanup/Tech → private replacement precedes five actual own deaths and paid own revival', () => {
|
|
const f = closing({ faction: 'moritani', advanced: true, discovery: true, tech: true, strongholds: true });
|
|
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani');
|
|
const target = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
|
assert.equal(player(f.afterSetup, f.opponent).leaders.find(l => l.id === target)!.name, 'Master Bewt');
|
|
assert.ok(player(f.afterSetup, f.owner).traitors.includes(target));
|
|
const firstDisc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
|
|
const discs = [firstDisc, ...player(f.afterSetup, f.opponent).leaders.map(l => l.id).filter(id => id !== target)];
|
|
const purchased = buyArmament(f, f.game);
|
|
assert.equal(player(purchased.game, f.owner).spice, player(purchased.before, f.owner).spice - purchased.bought.length);
|
|
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.weapon));
|
|
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.defense));
|
|
let g = purchased.game;
|
|
for (const [index, disc] of discs.entries()) {
|
|
const battle = index === 0 ? deathBattle(f, g, disc, true)
|
|
: guildDeathBattle(f, g, disc, purchased.weapon, purchased.defense);
|
|
const visitor = index === 0 ? f.owner : f.opponent;
|
|
assert.equal(player(battle.settledArrival, visitor).reserves, player(battle.arrival.before, visitor).reserves - 1);
|
|
assert.equal(pool(battle.settledArrival, visitor, battle.location).normal, 1);
|
|
assert.ok(player(battle.settledArrival, visitor).spice < player(battle.arrival.before, visitor).spice);
|
|
const revealed = reveal(battle.game, battle.plans, f.actions);
|
|
assert.equal(player(revealed, f.opponent).spice, player(battle.game, f.opponent).spice);
|
|
g = revealed;
|
|
if (index === 0) {
|
|
let boundary = settle(g, f.actions);
|
|
for (let n = 0; boundary.kind === 'losses' || boundary.kind === 'rescue'; n++) {
|
|
assert.ok(n < 12);
|
|
const d = boundary.game.decision; assert.ok(d);
|
|
g = step(boundary.game, { actor: d.player, action: { type: 'decision', ...(d.kind === 'sukRescue' ? { event: d.event } : {}), choice: 0 } }, f.actions);
|
|
boundary = settle(g, f.actions);
|
|
}
|
|
assert.equal(boundary.kind, 'assassination'); g = boundary.game;
|
|
assert.equal(g.lastBattleContext!.sukRescue!.completed, true);
|
|
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
|
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks);
|
|
assert.equal(pool(g, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal,
|
|
'Native rescue saves its real dialed casualty back at the SAME home.');
|
|
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 +
|
|
player(revealed, f.owner).leaders.find(l => l.id === disc)!.strength,
|
|
'Ordinary paid support and the killed opposing-disc reward both settle before assassination.');
|
|
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice,
|
|
'A native-home loss pays no Arrakis Worthless income; the separate assassination bounty is still pending.');
|
|
assert.equal(player(g, f.owner).leaders.find(l => l.id === disc)!.dead, true);
|
|
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
|
assert.notEqual(revealed.battle!.plans[f.opponent].leader, target);
|
|
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
|
const bounty = player(g, f.opponent).leaders.find(l => l.id === target)!.strength;
|
|
const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors], before = reload(g);
|
|
const pendingToken = ownedTech(g.techTokens, f.owner)[0]; assert.ok(pendingToken);
|
|
assert.equal(g.techTokens![pendingToken].owner, f.owner, 'Winner reward has not bypassed the private aftermath.');
|
|
const action: Action = { type: 'decision', event: d.event, card: target };
|
|
reject(g, f.opponent, action);
|
|
reject(g, f.owner, { ...action, event: 'stale' });
|
|
reject(g, f.owner, { ...action, card: revealed.battle!.plans[f.opponent].leader });
|
|
legalPolicies(g, f.owner, after => {
|
|
assert.equal(player(after, f.opponent).leaders.find(l => l.id === target)!.dead, true);
|
|
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + bounty);
|
|
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === target), false);
|
|
assert.equal(after.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
|
});
|
|
g = step(g, { actor: f.owner, action }, f.actions);
|
|
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.deaths,
|
|
player(before, f.opponent).leaders.find(l => l.id === target)!.deaths + 1);
|
|
assert.deepEqual(g.homeworlds!.custody, before.homeworlds!.custody);
|
|
assert.equal(player(g, f.opponent).tanks, player(before, f.opponent).tanks);
|
|
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === target), false);
|
|
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
|
g = finishSettlement(g, f.actions);
|
|
const worthless = battle.plans[0].action.weapon as string; assert.ok(worthless);
|
|
assert.ok(g.discard.some(c => c.id === worthless));
|
|
assert.ok(!player(g, f.owner).hand.some(c => c.id === worthless));
|
|
const token = ownedTech(before.techTokens, f.owner)[0]; assert.ok(token);
|
|
assert.equal(g.techTokens![token].owner, f.opponent);
|
|
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + bounty);
|
|
g = advance(g, s => s.phase === 8, f.actions);
|
|
const replacement = g.moritaniAssassinate!.opportunities.at(-1)!;
|
|
assert.equal(replacement.stage, 'replaced'); assert.equal(replacement.replacement, reserve[0]);
|
|
assert.deepEqual(g.traitorReserve, reserve.slice(1));
|
|
assert.deepEqual(player(g, f.owner).traitors, [...held.filter(c => c !== target), reserve[0]]);
|
|
assert.equal('replacement' in viewGame(g, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
|
|
} else g = finishSettlement(g, f.actions);
|
|
const victim = index === 0 ? f.owner : f.opponent, winner = index === 0 ? f.opponent : f.owner;
|
|
assert.equal(g.lastBattleContext!.winner, winner);
|
|
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, index + 1);
|
|
assert.equal(player(g, victim).leaders.find(l => l.id === disc)!.deaths,
|
|
player(battle.game, victim).leaders.find(l => l.id === disc)!.deaths + 1);
|
|
assert.equal(player(g, victim).tanks, player(revealed, victim).tanks + 1);
|
|
assert.equal(pool(g, victim, battle.location).normal, 0);
|
|
if (index > 0) {
|
|
assert.equal(player(g, f.owner).leaders.filter(l => l.dead).length, 1,
|
|
'The actual native winner stays alive; auctioned Shield stops the original Guild projectile before its loser cleanup.');
|
|
assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
|
assert.equal(pool(g, f.owner, battle.location).normal, pool(revealed, f.owner, battle.location).normal - 1);
|
|
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice - 1 +
|
|
player(revealed, f.opponent).leaders.find(l => l.id === disc)!.strength,
|
|
'The actual native winner pays support and receives the ordinary opposing-leader death reward once.');
|
|
for (const card of [purchased.weapon, purchased.defense]) {
|
|
assert.ok(player(g, f.owner).hand.some(c => c.id === card));
|
|
assert.ok(!g.discard.some(c => c.id === card));
|
|
}
|
|
if (battle.plans[1].action.weapon) {
|
|
assert.ok(g.discard.some(c => c.id === battle.plans[1].action.weapon));
|
|
assert.ok(!player(g, f.opponent).hand.some(c => c.id === battle.plans[1].action.weapon));
|
|
}
|
|
} else {
|
|
const weapon = battle.plans[1].action.weapon as string;
|
|
assert.equal(player(g, f.opponent).hand.filter(c => c.id === weapon).length, 1);
|
|
assert.ok(!g.discard.some(c => c.id === weapon));
|
|
}
|
|
custody(g);
|
|
const turn = g.turn;
|
|
g = advance(g, s => s.turn === turn + 1 && s.phase === 1 && clean(s), f.actions);
|
|
orderSpice(g);
|
|
g = advance(g, s => s.phase === 4 && clean(s), f.actions);
|
|
if (g.revivalOrder) {
|
|
assert.ok(g.revivalOrder.remaining.includes(f.opponent));
|
|
g = advance(g, s => s.phase === 4 && clean(s) &&
|
|
s.revivalOrder?.remaining[0] === f.opponent, f.actions);
|
|
}
|
|
if (index < discs.length - 1) reject(g, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/);
|
|
}
|
|
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 5);
|
|
assert.equal(g.leaderSkills!.assignments.some(a => a.owner === f.opponent), false);
|
|
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
|
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
|
const before = reload(g), dead = player(g, f.opponent).leaders.find(l => l.id === target)!;
|
|
reject(g, f.owner, { type: 'reviveLeader', leader: target });
|
|
g = step(g, { actor: f.opponent, action: { type: 'reviveLeader', leader: target } }, f.actions);
|
|
g = advance(g, s => !!s.leaderSkills!.offers[f.opponent], f.actions);
|
|
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
|
|
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
|
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 4);
|
|
assert.equal(viewGame(g, f.owner).leaderSkills!.offer, null);
|
|
assert.equal(viewGame(g, f.opponent).leaderSkills!.offer!.cards.length, 0);
|
|
const event = viewGame(g, f.opponent).leaderSkills!.offer!.event;
|
|
legalPolicies(g, f.opponent, after => {
|
|
assert.equal(viewGame(after, f.opponent).leaderSkills!.offer!.cards.length, 2);
|
|
assert.equal(player(after, f.opponent).spice, player(g, f.opponent).spice);
|
|
});
|
|
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, mode: 'draw' } }, f.actions);
|
|
const offer = viewGame(g, f.opponent).leaderSkills!, skill = offer.offer!.cards.find(s => !offer.unavailableSkills?.[s]); assert.ok(skill);
|
|
reject(g, f.owner, { type: 'leaderSkill', event, skill, leader: target });
|
|
reject(g, f.opponent, { type: 'leaderSkill', event, skill, leader: DUKE_VIDAL_ID });
|
|
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, skill, leader: target } }, f.actions);
|
|
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader, target);
|
|
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
|
|
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'moritani'); custody(g);
|
|
});
|
|
|
|
void test('Basic native Moritani loss keeps normal same-home Suk but never gains Advanced private assassination', () => {
|
|
const f = closing({ faction: 'moritani', advanced: false, discovery: false, tech: true });
|
|
const disc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
|
|
const battle = deathBattle(f, f.game, disc, true), revealed = reveal(battle.game, battle.plans, f.actions);
|
|
const done = finishSettlement(revealed, f.actions);
|
|
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
|
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
|
|
assert.deepEqual(done.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
|
assert.equal(pool(done, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal);
|
|
const target = done.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
|
assert.equal(player(done, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
|
assert.equal(done.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate');
|
|
custody(done);
|
|
});
|
|
|
|
for (const faction of ['ecaz', 'moritani'] as const) void test(`${faction} original Discovery shipment/reveal/free nested entry keeps all native reserve identity while all12 Nexus stay undealt`, () => {
|
|
const f = discovery({ faction, advanced: false, roster: [faction, 'guild', 'emperor', 'beneGesserit'], tech: true });
|
|
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
|
|
assert.ok(player(f.afterShipment, f.owner).spice < player(f.beforeShipment, f.owner).spice);
|
|
assert.equal(pool(f.afterShipment, f.owner, `homeworld:${faction}`).normal, player(f.afterShipment, f.owner).reserves);
|
|
assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
|
|
assert.equal(player(f.entry.before, f.owner).forces['gara_kulon:8'], 2);
|
|
assert.equal(player(f.entry.after, f.owner).forces['gara_kulon:8'] ?? 0, 0);
|
|
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
|
|
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
|
assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
|
assert.deepEqual(f.entry.after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
|
|
assert.ok(Object.values(f.entry.after.nexusCards!.cards!.hands).every(c => c === null));
|
|
const action = discoveryEntryMoveAction(viewGame(f.entry.before, f.owner), viewGame(f.entry.before, f.owner).discoveryEntry!.sources); assert.ok(action);
|
|
reject(f.entry.before, f.opponent, action);
|
|
reject(f.entry.after, f.owner, action);
|
|
legalPolicies(f.entry.before, f.owner, (after, offered) => {
|
|
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
|
assert.equal(player(after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
|
if (offered.groups) assert.equal(player(after, f.owner).forces['cistern:0'], 2);
|
|
else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2);
|
|
assert.notEqual(after.decision?.kind, 'discoveryEntry');
|
|
});
|
|
custody(f.entry.after);
|
|
});
|
|
|
|
void test('original skill offers keep human choices while Duke training and Advanced Harkonnen reject before custody conversion', () => {
|
|
const original = createGame('SUPPLIEDHWE3NX', newPlayer('native-original', 'Original native', 'ecaz'), false, ['ecaz']);
|
|
joinGame(original, newPlayer('guild-original', 'Original Guild', 'guild'));
|
|
const f = setup({ initial: original });
|
|
const trained = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
|
|
const own = viewGame(f.offered, f.owner).leaderSkills!;
|
|
assert.ok(own.offer!.cards.includes(trained.skill));
|
|
assert.ok(own.eligibleLeaders.some(l => l.id === trained.leader));
|
|
reject(f.offered, f.owner, { type: 'leaderSkill', event: own.offer!.event, skill: trained.skill, leader: DUKE_VIDAL_ID });
|
|
const unsupported = own.offer!.cards.includes('mentat') ? 'warmaster' : 'mentat';
|
|
if (!own.offer!.cards.includes(unsupported)) reject(f.offered, f.owner,
|
|
{ type: 'leaderSkill', event: own.offer!.event, skill: unsupported, leader: trained.leader });
|
|
legalPolicies(f.offered, f.owner, after => {
|
|
const assignment = after.leaderSkills!.assignments.find(a => a.owner === f.owner); assert.ok(assignment);
|
|
assert.ok(own.offer!.cards.includes(assignment.skill));
|
|
assert.ok(player(after, f.owner).leaders.some(l => l.id === assignment.leader));
|
|
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
|
|
});
|
|
for (const [advanced, factions] of [
|
|
[true, ['ecaz', 'guild', 'harkonnen']],
|
|
[true, ['moritani', 'guild', 'harkonnen']],
|
|
] as const) {
|
|
let g = createGame('ORIGINALSTANDALONEGUARD', newPlayer(factions[0], factions[0], factions[0]), advanced, ['ecaz']);
|
|
for (const faction of factions.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
|
|
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
|
g.nexusCards = { cards: null, phase: null };
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
const before = structuredClone(g);
|
|
assert.throws(() => initializeLeaderSkillsGameForAudit(g)); assert.deepEqual(g, before);
|
|
assert.equal(g.homeworlds!.custody, null);
|
|
}
|
|
});
|