Files
Dune/tests/e3-nexus-ecaz-runtime.test.ts

369 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { ownedTech } from '../game/tech-tokens';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldCombatLocation } from '../game/homeworld-combat';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceE3NexusEcaz as advance, createE3NexusEcazClosing as closing,
createE3NexusEcazCoalition as coalition, createE3NexusEcazCunning as cunning,
createE3NexusEcazDiscovery as discovery, e3NexusEcazClean as clean,
e3NexusEcazPlayer as player, e3NexusEcazPolicy as policy, e3NexusEcazPool as pool,
e3NexusEcazReload as reload, e3NexusEcazStep as step, revealE3NexusEcaz as reveal,
shipE3NexusEcaz as ship,
type E3NexusEcazStep,
} from './fixture-e3-nexus-ecaz';
/** The last battle can enter ordinary Collection before this owned suffix returns. */
function ordinaryCollectionSpice(g: Game, actor: string): number {
return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0;
}
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, 'Rejected ownership, stale event and unsupported FORCE choices cannot consume original pieces.');
}
/** Count only real zones. The separate Duke never becomes a sixth native disc. */
function physical(g: Game): void {
const nx = g.nexusCards!.cards!;
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(),
treacheryDeck(['ecaz']).map(c => c.id).sort());
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
for (const p of g.players) {
const visiting = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) +
visiting.reduce((a, f) => a + f.normal + f.elite, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) +
visiting.reduce((a, f) => a + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
}
assert.equal(player(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
assert.ok(g.players.every(p => p.leaders.every(l => l.id !== DUKE_VIDAL_ID)));
}
/** Four minimal saved policies at genuine windows; assert their consumer effect. */
function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const action = policy(g, actor, difficulty)[0]; assert.ok(action, `${difficulty} needs the actual owned action.`);
const after = step(g, { actor, action }); effect(after, action); physical(after);
}
}
function battleQuote(g: Game): BattleResolutionQuote {
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!;
assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = player(g, actor), view = viewGame(g, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader) ??
(g.dukeVidal?.controller === actor && b.plans[actor].leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined),
forces: view.ownForces, stronghold: view.strongholdEffects[actor] };
};
const context = homeworldContext(g);
const home = b.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...context,
players: context.players.map(p => ({ ...p, ally: player(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds,
turn: g.turn, territory: b.territory, aggressor: publicBattle.aggressor,
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
attacker: side(b.attacker), defender: side(b.defender),
voters: publicBattle.traitorVoters.map(actor => ({ id: actor, called: false, traitors: player(g, actor).traitors,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : player(g, actor).ally! })),
participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
function settle(g: Game, actions?: E3NexusEcazStep[], exercisePolicies = true): Game {
for (let n = 0; n < 180; n++) {
g = advance(g, s => !s.response && !s.phaseOpening &&
(!!s.decision && ['battleLosses', 'battleCards', 'techToken'].includes(s.decision.kind) || !s.battle && clean(s)), actions);
if (!g.decision && !g.battle && clean(g)) return g;
const d = g.decision; assert.ok(d);
let action: Action;
if (d.kind === 'battleLosses') {
action = { type: 'decision', choice: 0 };
if (exercisePolicies) policies(g, d.player, (after, selected) => {
const option = d.options[Number(selected.choice)], owner = d.forceOwner ?? d.player;
assert.ok(option);
assert.equal(player(after, owner).tanks, player(g, owner).tanks + option.normal + option.elite);
});
} else if (d.kind === 'battleCards') {
action = { type: 'decision', discard: [] };
if (exercisePolicies) policies(g, d.player, (after, selected) => {
for (const id of selected.discard as string[]) {
assert.ok(!player(after, d.player).hand.some(c => c.id === id));
assert.equal(after.discard.filter(c => c.id === id).length, 1);
}
assert.notEqual(after.decision?.kind, 'battleCards');
});
} else if (d.kind === 'techToken') {
assert.equal(g.battle, null, 'Physical battle cleanup precedes mandatory winner Tech custody.');
reject(g, d.loser, { type: 'decision', token: d.choices[0] });
reject(g, d.player, { type: 'decision', decline: true });
action = { type: 'decision', token: d.choices[0] };
if (exercisePolicies) policies(g, d.player, (after, selected) => {
const token = selected.token as keyof NonNullable<Game['techTokens']>;
assert.ok(d.choices.includes(token)); assert.equal(g.techTokens![token].owner, d.loser);
assert.equal(after.techTokens![token].owner, d.player);
assert.equal(player(after, d.player).spice, player(g, d.player).spice, 'Taking a token is not another income payment.');
});
} else throw Error(`Unexpected original Ecaz settlement ${d.kind}`);
g = step(g, { actor: d.player, action }, actions);
}
throw Error('Original Ecaz physical settlement did not finish.');
}
function assertClosing(f: { owner: string; others: string[]; alliance: Game; draw: { before: Game; step: E3NexusEcazStep; after: Game } }): void {
assert.equal(player(f.alliance, f.owner).ally, null);
assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]);
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
policies(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.others[0], f.draw.step.action);
reject(f.draw.after, f.owner, f.draw.step.action);
}
void test('original no-Skills Ecaz closing draw → paid Wallach visitor → living unclaimed Duke → native support settlement → physical end-turn expiry', () => {
const f = cunning({ discovery: true }); assertClosing(f);
const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources);
const quoted = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location,
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
assert.equal(player(f.arrival.settled, f.owner).spice, player(f.arrival.before, f.owner).spice - quoted.cost);
assert.equal(player(f.arrival.settled, f.owner).reserves, player(f.arrival.before, f.owner).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(player(f.arrival.settled, f.owner).forces).some(key => key.startsWith('homeworld:')));
assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:')));
assert.ok(!f.game.battle!.ecazOccupy, 'A real native-home battle never becomes Arrakis Occupy.');
const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action);
assert.equal(f.cunning.before.dukeVidal!.controller, null);
assert.equal(f.cunning.before.dukeVidal!.leader.dead, false);
reject(f.cunning.before, f.opponent, action);
reject(f.cunning.before, f.owner, { ...action, event: 'foreign-window' });
policies(f.cunning.before, f.owner, after => {
assert.equal(after.dukeVidal!.controller, f.owner); assert.equal(after.dukeVidal!.source, 'ecazNexus');
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
});
reject(f.cunning.after, f.owner, action);
reject(f.game, f.owner, action);
const originalDisc = structuredClone(f.cunning.after.dukeVidal!.leader);
const revealed = reveal(f.game, f.plans, f.actions), quote = battleQuote(revealed);
const nativeSide = revealed.battle!.attacker === f.opponent ? 'attacker' : 'defender';
assert.ok(quote.scores![nativeSide] > quote.leaderStrengths[nativeSide] + 3,
'The actual native Homeworld supplies additional free strength, not additional paid support.');
assert.equal(quote.winner, f.opponent);
assert.equal(quote.payments.find(p => p.player === f.opponent)!.ownPayment, 3,
'Native free strength does not fund three dialed support.');
const lostToken = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(lostToken);
const done = settle(revealed, f.actions);
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3);
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(pool(done, f.owner, f.location).normal, 0);
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
assert.equal(done.techTokens![lostToken].owner, f.opponent);
assert.equal(done.dukeVidal!.leader.dead, originalDisc.dead);
assert.equal(done.dukeVidal!.leader.deaths, originalDisc.deaths);
assert.deepEqual(done.strongholdCards!.owners, revealed.strongholdCards!.owners, 'A Homeworld win cannot acquire an Arrakis Stronghold Card.');
physical(done);
const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions);
assert.equal(later.dukeVidal!.controller, null); assert.equal(later.dukeVidal!.source, null);
assert.equal(later.dukeVidal!.leader.dead, originalDisc.dead);
assert.equal(later.dukeVidal!.leader.deaths, originalDisc.deaths);
assert.equal(later.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
physical(later);
});
for (const advanced of [true, false]) void test(`${advanced ? 'Advanced held Tabr+Tech+Homeworlds' : 'Basic even-FORCE Tech'} original Ecaz-selected Arrakis coalition keeps source-local payments, owner casualties and winner rewards`, () => {
const f = coalition({ advanced, tech: true, strongholds: advanced, homeworlds: advanced }); assertClosing(f);
assert.ok(f.claim);
if (advanced) {
assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false));
assert.equal(f.firstMentat.after.strongholdCards!.owners.sietch_tabr, f.owner);
}
for (const arrival of f.arrivals) {
const actor = arrival.step.actor, amount = Number(arrival.step.action.amount);
assert.equal(player(arrival.settled, actor).reserves, player(arrival.before, actor).reserves - amount);
assert.equal(player(arrival.settled, actor).forces[f.location], (player(arrival.before, actor).forces[f.location] ?? 0) + amount);
assert.ok(player(arrival.settled, actor).spice < player(arrival.before, actor).spice);
}
const lastArrival = f.arrivals.at(-1)!.settled, accrued = lastArrival.techTokens!.heighliners;
assert.ok(accrued.owner); assert.equal(accrued.spice, ownedTech(lastArrival.techTokens, accrued.owner).length);
assert.equal(f.beforeBattle.techTokens!.heighliners.spice, 0);
assert.equal(player(f.beforeBattle, accrued.owner).spice, player(lastArrival, accrued.owner).spice + accrued.spice,
'Only the ORIGINAL Heighliners holder receives the phase-end shipment benefit.');
assert.equal(ecazOccupancyRelation(f.beforeBattle.players, f.owner, f.ally, { kind: 'territory', id: 'sietch_tabr' }), 'ecazAlliance');
assert.equal(ecazOccupancyRelation(f.beforeBattle.players, f.owner, f.ally, { kind: 'homeworld', id: 'homeworld:emperor' }), 'different');
const d = f.game.decision; assert.ok(d?.kind === 'ecazBattleLead');
reject(f.game, f.ally, { type: 'decision', event: d.event, lead: f.owner });
reject(f.game, f.owner, { type: 'decision', event: 'stale', lead: f.owner });
reject(f.game, f.owner, { type: 'decision', event: d.event, lead: f.opponent });
const chooseAndReveal = (state: Game, lead: string) => {
let g = advance(state, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false);
const profile = viewGame(g, lead).battle!.ecazOccupy!.profile!;
const own = player(g, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(g, f.opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const plans: E3NexusEcazStep[] = [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: advanced ? 3 : 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: advanced ? 1 : 0 } },
];
const before = reload(g);
policies(g, lead, (offered) => {
assert.equal(player(offered, lead).spice, player(g, lead).spice, 'Sealing is not a second debit.');
const branch = reveal(offered, [plans[1]]), quoted = battleQuote(branch), finished = settle(branch, undefined, false);
assert.equal(finished.lastBattleContext!.winner, quoted.winner);
for (const payment of quoted.payments) {
const income = quoted.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0);
assert.equal(player(finished, payment.player).spice, player(branch, payment.player).spice - payment.ownPayment + income +
(quoted.bounty?.player === payment.player ? quoted.bounty.amount : 0) + ordinaryCollectionSpice(finished, payment.player));
}
physical(finished);
});
g = reveal(g, plans);
return { before, revealed: g, quote: battleQuote(g), done: settle(g) };
};
policies(f.game, f.owner, (after, chosen) => {
const lead = String(chosen.lead);
assert.ok([f.owner, f.ally].includes(lead));
const result = chooseAndReveal(after, lead);
assert.equal(result.done.lastBattleContext!.winner, lead);
const enemyToken = ownedTech(result.before.techTokens, f.opponent)[0]; assert.ok(enemyToken);
assert.equal(result.done.techTokens![enemyToken].owner, lead);
assert.equal(ownedTech(result.done.techTokens, lead).length, 2);
assert.equal(ownedTech(result.done.techTokens, lead === f.owner ? f.ally : f.owner).length, 1);
});
const selected = step(f.game, { actor: f.owner, action: { type: 'decision', event: d.event, lead: f.owner } });
const result = chooseAndReveal(selected, f.owner), fixed = advanced ? 3 : 2;
assert.deepEqual(result.quote.fixedLosses, [{ owner: f.owner, normal: fixed, elite: 0 }]);
assert.equal(result.quote.casualties!.owner, f.ally);
assert.equal(player(result.done, f.owner).tanks - player(result.before, f.owner).tanks, fixed);
assert.equal(player(result.done, f.ally).tanks - player(result.before, f.ally).tanks, 3);
assert.equal(player(result.done, f.opponent).tanks - player(result.before, f.opponent).tanks, 8);
assert.equal(player(result.done, f.owner).forces[f.location], 2);
assert.equal(player(result.done, f.ally).forces[f.location], 1);
assert.equal(player(result.done, f.owner).spice, player(result.before, f.owner).spice - (advanced ? 3 : 0) + (advanced ? 1 : 0));
assert.equal(player(result.done, f.ally).spice, player(result.before, f.ally).spice, 'The selected Ecaz payer never debits its variable army owner.');
assert.deepEqual(result.quote.strongholdIncome, advanced ? [{ player: f.owner, amount: 1 }] : []);
const alliedLead = chooseAndReveal(step(f.game, { actor: f.owner,
action: { type: 'decision', event: d.event, lead: f.ally } }), f.ally);
assert.equal(alliedLead.done.lastBattleContext!.winner, f.ally);
assert.deepEqual(alliedLead.quote.strongholdIncome, [], 'The allied lead cannot borrow Ecaz’s held Tabr battle income.');
assert.equal(player(alliedLead.done, f.owner).spice, player(alliedLead.before, f.owner).spice);
assert.equal(player(alliedLead.done, f.ally).spice, player(alliedLead.before, f.ally).spice - (advanced ? 3 : 0));
assert.equal(alliedLead.done.techTokens![ownedTech(alliedLead.before.techTokens, f.opponent)[0]].owner, f.ally);
const completed = advance(result.done, s => s.turn === 4 && s.phase === 0);
assert.equal(completed.status, 'playing'); assert.equal(completed.lastBattleContext!.winner, f.owner);
assert.equal(completed.techTokens![ownedTech(result.before.techTokens, f.opponent)[0]].owner, f.owner);
physical(completed);
});
void test('the real Basic odd-FORCE coalition admits provisional ceiling losses, floor survivors and native Nexus continuation', () => {
const f = coalition({ advanced: false, tech: true }, 3);
assertClosing(f);
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
const admitted = step(f.beforeBattle, f.choice), d = admitted.decision;
assert.ok(admitted.battle);
assert.ok(d?.kind === 'ecazBattleLead');
physical(admitted);
for (const lead of [f.owner, f.ally]) {
const selected = step(admitted, { actor: f.owner, action: { type: 'decision', event: d.event, lead } });
const ready = advance(selected, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false);
const profile = viewGame(ready, lead).battle!.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, lead);
assert.equal(profile.payer, lead);
assert.equal(profile.forceOwner, f.ally);
const own = player(ready, lead).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
const enemy = player(ready, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && enemy);
const revealed = reveal(ready, [
{ actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 0 } },
]), q = battleQuote(revealed), done = settle(revealed);
assert.equal(q.winner, lead);
assert.equal(done.lastBattleContext!.winner, lead);
assert.deepEqual(q.fixedLosses, [{ owner: f.owner, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(q.casualties!.owner, f.ally);
assert.deepEqual(q.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(q.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(q.strongholdIncome, []);
for (const payment of q.payments)
assert.equal(player(done, payment.player).spice,
player(revealed, payment.player).spice + ordinaryCollectionSpice(done, payment.player));
assert.equal(player(done, f.owner).tanks - player(ready, f.owner).tanks, Math.ceil(3 / 2));
assert.equal(player(done, f.owner).forces[f.location], Math.floor(3 / 2));
assert.equal(player(done, f.ally).tanks - player(ready, f.ally).tanks, 3);
assert.equal(player(done, f.ally).forces[f.location], 1);
assert.equal(player(done, f.opponent).tanks - player(ready, f.opponent).tanks, 8);
const token = ownedTech(ready.techTokens, f.opponent)[0]; assert.ok(token);
assert.equal(done.techTokens![token].owner, lead);
assert.equal(ownedTech(done.techTokens, lead).length, 2);
assert.equal(ownedTech(done.techTokens, lead === f.owner ? f.ally : f.owner).length, 1);
physical(done);
const continued = advance(done, s => s.turn === 4 && s.phase === 0);
assert.equal(continued.status, 'playing');
assert.equal(continued.lastBattleContext!.winner, lead);
assert.equal(continued.techTokens![token].owner, lead);
physical(continued);
}
});
void test('original Discovery reveal and free parent entry precede, rather than predeal, the later qualifying Ecaz closing draw', () => {
const f = discovery({ advanced: false, roster: ['ecaz', 'guild', 'emperor', 'beneGesserit'], tech: true, homeworlds: true });
assert.equal(player(f.arrival.settled, f.owner).reserves, player(f.arrival.before, f.owner).reserves - 2);
assert.ok(player(f.arrival.settled, f.owner).spice < player(f.arrival.before, f.owner).spice);
assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.ok(Object.values(f.entry.before.nexusCards!.cards!.hands).every(c => c === null));
const offer = viewGame(f.entry.before, f.owner), action = discoveryEntryMoveAction(offer, offer.discoveryEntry!.sources); assert.ok(action);
reject(f.entry.before, f.others[0], action);
reject(f.entry.before, f.owner, { ...action, event: 'stale' });
reject(f.entry.after, f.owner, action);
policies(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);
assert.deepEqual(after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
if (offered.groups) {
assert.equal(player(after, f.owner).forces['cistern:0'], 2);
assert.equal(player(after, f.owner).forces['gara_kulon:8'] ?? 0, 0);
} else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2);
assert.notEqual(after.decision?.kind, 'discoveryEntry');
});
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
assert.equal(f.draw.before.turn, 2); assertClosing(f);
const later = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s));
assert.equal(player(later, f.owner).forces['cistern:0'], 2);
physical(later);
});
void test('the existing Basic Harkonnen roster boundary still permits source-clear Cunning without Skills or an invented seat parity guard', () => {
const f = closing({ advanced: false, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f);
const arrival = ship(f.game, f.owner, 'homeworld:guild', 1);
const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
policies(g, 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);
});
});