Compose standalone E3 Nexus without Leader Skills
This commit is contained in:
1 parent
ec3d26f663
commit
185f3b37be
18 files changed
+1675
-38
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer } from '../game/engine';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload, nativeTypedStep } from './fixture-discovery-native-typed';
|
||||
import { putDiscovery } from './fixture-discovery';
|
||||
@@ -14,6 +14,13 @@ void test('native Moritani assassination retains the actual revealed Discovery b
|
||||
game.discoveryEnabled = true; game.techTokens = createTechTokens();
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 100; n++) game = nativeTypedStep(game);
|
||||
// Keep the conserved token supply available: the first real Spice Blow
|
||||
// draws ordinary land, rather than randomly placing this later fixture token.
|
||||
for (const position of [0, 1]) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = nativeTypedPhase(game, 5);
|
||||
nativeTypedClearBoard(game);
|
||||
// Conserved original supply token and reserve positions; inspect/reveal,
|
||||
@@ -40,7 +47,6 @@ void test('native Moritani assassination retains the actual revealed Discovery b
|
||||
const next = nextSkillsTechBattleStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.decision?.kind === 'moritaniAssassinate');
|
||||
assert.equal(viewGame(game, 'm').moritaniAssassinate!.pending!.territory, 'cistern');
|
||||
const spice = nativeTypedPlayer(game, 'm').spice;
|
||||
const event = game.decision.event;
|
||||
game = applyAction(nativeTypedReload(game), 'm', { type: 'decision', event, card: 'guild-1' });
|
||||
@@ -49,5 +55,4 @@ void test('native Moritani assassination retains the actual revealed Discovery b
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].territory, 'cistern');
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].card, 'guild-1');
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'revealed');
|
||||
assert.equal(viewGame(game, 'm').moritaniAssassinate!.history[0].territory, 'cistern');
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
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 remains guarded before any selected lead, loss or reward can be consumed', () => {
|
||||
const f = coalition({ advanced: false, tech: true }, 3);
|
||||
reject(f.beforeBattle, f.choice.actor, f.choice.action, /Odd-force Basic Occupy/);
|
||||
assert.equal(player(f.beforeBattle, f.owner).forces[f.location], 3);
|
||||
assert.equal(f.beforeBattle.battle, null); physical(f.beforeBattle);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(later.nexusCards!.cards!.hands[f.owner], 'ecaz'); 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);
|
||||
});
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen is still an eligible no-Skills roster opponent, not authority to bypass the canonical Duke Cunning exclusion', () => {
|
||||
const f = closing({ advanced: true, 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);
|
||||
const offer = viewGame(g, f.owner).nexusEcazDuke; assert.ok(offer);
|
||||
assert.match(offer.blocked!, /Advanced Harkonnen/);
|
||||
assert.equal(nexusEcazDukeAction(viewGame(g, f.owner)), null);
|
||||
reject(g, f.owner, { type: 'nexusEcazDuke', event: offer.event }, /Advanced Harkonnen/);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.equal(g.dukeVidal!.controller, null); assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
physical(g);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { nativeE3HomeworldPool as homePool } from './fixture-homeworld-native-e3-modules';
|
||||
import { nexusMoritaniAction } from '../game/nexus-moritani-options';
|
||||
import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import {
|
||||
advanceMoritaniNexus as advance, assertMoritaniNexusPhysical as physical,
|
||||
closeMoritaniNexus as close, createMoritaniNexusClosing as closing,
|
||||
createMoritaniNexusDiscovery as discovery, createMoritaniNexusLobby as lobby,
|
||||
moritaniNexusAct as act, moritaniNexusClean as clean, moritaniNexusPlayer as player,
|
||||
moritaniNexusPolicy as policy, moritaniNexusReload as reload,
|
||||
openMoritaniNexusBattle as openBattle, openMoritaniNexusLoss as loss,
|
||||
orderMoritaniNexusSpice as orderSpice, revealMoritaniNexus as reveal,
|
||||
settleMoritaniNexus as settle, shipMoritaniNexus as ship,
|
||||
type MoritaniNexusStep,
|
||||
} from './fixture-e3-nexus-moritani';
|
||||
|
||||
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): void {
|
||||
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
|
||||
}
|
||||
/** Every policy consumes a real private owned window from a JSON continuation. */
|
||||
function policies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const saved = reload(g); player(saved, actor).bot = difficulty;
|
||||
const selected = policy(saved, actor, difficulty)[0]; assert.ok(selected, `${difficulty} needs an actual owned choice.`);
|
||||
const after = applyAction(saved, actor, selected); consume(after, selected); physical(after);
|
||||
}
|
||||
}
|
||||
function finish(g: Game, actions?: MoritaniNexusStep[]): Game {
|
||||
for (let n = 0; n < 12; n++) {
|
||||
g = settle(g, actions);
|
||||
const d = g.decision;
|
||||
if (!d) return g;
|
||||
if (d.kind === 'moritaniAssassinate') {
|
||||
g = act(g, { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }, actions);
|
||||
continue;
|
||||
}
|
||||
assert.equal(d.kind, 'techToken');
|
||||
if (d.kind !== 'techToken') throw Error('Expected actual winner Tech choice.');
|
||||
assert.equal(g.battle, null, 'The battle and physical card cleanup precede its reward.');
|
||||
for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? [])
|
||||
assert.ok(g.discard.some(c => c.id === entry.card));
|
||||
reject(g, d.loser, { type: 'decision', token: d.choices[0] });
|
||||
reject(g, d.player, { type: 'decision', decline: true });
|
||||
policies(g, d.player, (after, action) => {
|
||||
const token = action.token as keyof NonNullable<Game['techTokens']>;
|
||||
assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player);
|
||||
});
|
||||
g = act(g, { actor: d.player, action: { type: 'decision', token: d.choices[0] } }, actions);
|
||||
}
|
||||
throw Error('Original native aftermath reopened.');
|
||||
}
|
||||
|
||||
void test('no-Skills native closing draw, paid Guild-home loss, printed bounty and private Mentat replacement obey the original physical order', () => {
|
||||
const f = closing({ homeworlds: true, tech: true, strongholds: true, discovery: true });
|
||||
assert.equal(f.firstMentat.after.strongholdCards!.owners.sietch_tabr, f.owner);
|
||||
assert.equal(f.firstMentat.after.strongholdCards!.owners.tueks_sietch, f.opponent);
|
||||
assert.equal(f.alliance.players.find(p => p.id === f.owner)!.ally, null);
|
||||
policies(f.draw.before, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'moritani');
|
||||
assert.ok(!after.nexusCards!.cards!.deck.includes('moritani'));
|
||||
});
|
||||
reject(f.draw.before, f.opponent, f.draw.step.action);
|
||||
reject(f.draw.after, f.owner, f.draw.step.action);
|
||||
const b = loss(f, f.game);
|
||||
const sources = nativeShipmentSources(viewGame(b.arrival.before, f.owner), 1, 0); assert.ok(sources);
|
||||
const quote = homeworldShipmentChoice(viewGame(b.arrival.before, f.owner), b.location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
|
||||
assert.equal(player(b.settledArrival, f.owner).spice, player(b.arrival.before, f.owner).spice - quote.cost);
|
||||
assert.equal(player(b.settledArrival, f.owner).reserves, player(b.arrival.before, f.owner).reserves - 1);
|
||||
assert.equal(homePool(b.settledArrival, f.owner, b.location).normal, 1);
|
||||
assert.ok(!b.settledArrival.pendingTerrorEntry && !b.settledArrival.pendingAmbassador);
|
||||
const revealed = reveal(b.game, b.plans, f.actions);
|
||||
assert.equal(player(revealed, f.opponent).spice, player(b.game, f.opponent).spice, 'Sealed support is not prepaid.');
|
||||
let g = settle(revealed, f.actions);
|
||||
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
assert.equal(player(g, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, true);
|
||||
assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
||||
assert.equal(homePool(g, f.owner, b.location).normal, 0);
|
||||
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks);
|
||||
assert.equal(homePool(g, f.opponent, b.location).normal, homePool(revealed, f.opponent, b.location).normal);
|
||||
const killed = player(revealed, f.owner).leaders.find(l => l.id === b.ownLeader)!;
|
||||
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice,
|
||||
'Native assassination pauses the original winner casualties, support, bounty and cleanup continuation.');
|
||||
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice);
|
||||
const token = ownedTech(g.techTokens, f.owner)[0]; assert.ok(token);
|
||||
const target = player(g, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||
assert.equal(target.dead, false); assert.notEqual(b.enemyLeader, f.target);
|
||||
const before = reload(g), action: Action = { type: 'decision', event: d.event, card: f.target };
|
||||
reject(g, f.opponent, action); reject(g, f.owner, { ...action, event: 'stale' });
|
||||
reject(g, f.owner, { ...action, card: b.enemyLeader });
|
||||
policies(g, f.owner, after => {
|
||||
assert.equal(player(after, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
|
||||
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + target.strength);
|
||||
assert.equal(after.techTokens![token].owner, f.owner, 'Winner Tech cannot bypass private assassination and cleanup.');
|
||||
});
|
||||
g = act(g, { actor: f.owner, action }, f.actions);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
|
||||
assert.ok(player(g, f.owner).traitors.includes(f.target), 'The revealed physical card remains held until Mentat.');
|
||||
reject(g, f.owner, action);
|
||||
g = finish(g, f.actions);
|
||||
assert.equal(g.techTokens![token].owner, f.opponent);
|
||||
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks + 1);
|
||||
assert.equal(homePool(g, f.opponent, b.location).normal, homePool(revealed, f.opponent, b.location).normal - 1);
|
||||
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 + killed.strength +
|
||||
ordinaryCollectionSpice(g, f.opponent), 'The original winner support/bounty settles once after assassination.');
|
||||
const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors];
|
||||
g = advance(g, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions);
|
||||
const receipt = g.moritaniAssassinate!.opportunities[0];
|
||||
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]);
|
||||
assert.deepEqual(g.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(g, f.owner).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
|
||||
const balance = player(g, f.owner).spice;
|
||||
g = act(g, { actor: f.owner, action: { type: 'decision', decline: true } }, f.actions);
|
||||
assert.equal(player(g, f.owner).spice, balance, 'Replacement never pays the bounty a second time.');
|
||||
physical(g);
|
||||
});
|
||||
|
||||
void test('native Moritani Cunning spends one physical Nexus for an original supply placement and real assassination Terror entry, never relocation or home placement', () => {
|
||||
const f = closing({ roster: ['moritani', 'guild', 'atreides'], homeworlds: true, tech: true, strongholds: true });
|
||||
let g = advance(f.game, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions);
|
||||
const view = viewGame(g, f.owner), token = g.moritaniTerror!.tokens.find(t => t.kind === 'assassination' && t.status === 'available')!;
|
||||
const action = nexusMoritaniAction(view, view.nexusMoritani!.event, token.id, 'polar_sink'); assert.ok(action);
|
||||
reject(g, f.opponent, action); reject(g, f.owner, { ...action, nexus: 'stale' });
|
||||
reject(g, f.owner, { ...action, territory: 'homeworld:guild' });
|
||||
reject(g, f.owner, { ...action, territory: 'homeworld:moritani' });
|
||||
reject(g, f.owner, { ...action, territory: MOBILE_STRONGHOLD });
|
||||
const placed = g.moritaniTerror!.tokens.find(t => t.status === 'placed')!;
|
||||
reject(g, f.owner, { ...action, token: placed.id });
|
||||
policies(g, f.owner, (after, chosen) => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1);
|
||||
const committed = advance(after, s => clean(s) && !s.pendingMoritaniPlacement);
|
||||
assert.equal(committed.moritaniTerror!.tokens.find(t => t.id === chosen.token)!.location, chosen.territory);
|
||||
assert.equal(committed.moritaniTerror!.tokens.filter(t => t.status === 'available').length,
|
||||
g.moritaniTerror!.tokens.filter(t => t.status === 'available').length - 1);
|
||||
});
|
||||
g = act(g, { actor: f.owner, action }, f.actions);
|
||||
g = advance(g, s => clean(s) && !s.pendingMoritaniPlacement, f.actions);
|
||||
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'polar_sink');
|
||||
assert.equal(g.nexusMoritaniHistory!.at(-1)!.stage, 'complete');
|
||||
reject(g, f.owner, action);
|
||||
g = advance(g, s => s.turn === 3 && s.phase === 5 && s.active === f.opponent && clean(s), f.actions);
|
||||
const beforeShipment = reload(g), sources = nativeShipmentSources(viewGame(g, f.opponent), 1, 0); assert.ok(sources);
|
||||
g = act(g, { actor: f.opponent, action: { type: 'ship', territory: 'polar_sink', sector: 0,
|
||||
amount: 1, elite: 0, allyPayment: 0, homeworldSources: sources } }, f.actions);
|
||||
g = advance(g, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniTerror', f.actions);
|
||||
if (g.pendingTerrorEntry!.stage === 'select') {
|
||||
g = act(g, { actor: f.owner, action: { type: 'decision', token: token.id } }, f.actions);
|
||||
}
|
||||
const before = reload(g), revealAction: Action = { type: 'decision', reveal: true };
|
||||
reject(g, f.opponent, revealAction);
|
||||
g = act(g, { actor: f.owner, action: revealAction }, f.actions);
|
||||
g = advance(g, s => !s.pendingShipment && !s.pendingTerrorEntry && clean(s), f.actions);
|
||||
const dead = player(g, f.opponent).leaders.filter(l => l.dead && !player(before, f.opponent).leaders.find(old => old.id === l.id)!.dead);
|
||||
assert.equal(dead.length, 1);
|
||||
assert.equal(dead[0].deaths, player(before, f.opponent).leaders.find(l => l.id === dead[0].id)!.deaths + 1);
|
||||
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + dead[0].strength);
|
||||
assert.equal(player(g, f.opponent).reserves, player(beforeShipment, f.opponent).reserves - 1);
|
||||
assert.equal(player(g, f.opponent).forces['polar_sink:0'], 1);
|
||||
assert.ok(player(g, f.opponent).spice < player(beforeShipment, f.opponent).spice);
|
||||
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed');
|
||||
assert.equal(g.moritaniAssassinate!.opportunities.length, 0, 'Terror death is not a fabricated post-loss Traitor assassination.');
|
||||
reject(g, f.owner, revealAction); physical(g);
|
||||
});
|
||||
|
||||
void test('actual Harkonnen Nexus Secret Ally draws and returns physical extra Traitors before a no-Skills native loss can assassinate the retained identity', () => {
|
||||
const f = closing({ nexus: 'harkonnen', homeworlds: true, tech: true });
|
||||
let g = advance(f.game, s => s.phase === 8 && clean(s), f.actions);
|
||||
assert.equal(g.players.some(p => p.faction === 'harkonnen'), false);
|
||||
assert.equal(player(g, f.owner).traitors.includes(f.target), false);
|
||||
// Select only the still-undealt original reserve. Both other draw identities
|
||||
// stay physical, and the old held identity is returned by the owner below.
|
||||
const i = g.traitorReserve!.indexOf(f.target); assert.ok(i >= 0);
|
||||
g.traitorReserve!.unshift(g.traitorReserve!.splice(i, 1)[0]);
|
||||
const before = reload(g), view = viewGame(g, f.owner), offer = view.nexusTraitors!.offer!;
|
||||
const action = nexusTraitorDrawAction(view, offer.event, 'secretAlly'); assert.ok(action);
|
||||
reject(g, f.opponent, action); reject(g, f.owner, { ...action, event: 'stale' });
|
||||
policies(g, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.deepEqual(after.traitorReserve, before.traitorReserve!.slice(2));
|
||||
});
|
||||
g = act(g, { actor: f.owner, action }, f.actions);
|
||||
const pending = viewGame(g, f.owner).nexusTraitors!.pending!;
|
||||
const choices = pending.choices.map(c => c.id), returned = choices.filter(id => id !== f.target); assert.equal(returned.length, 2);
|
||||
const returnAction = nexusTraitorReturnAction(viewGame(g, f.owner), pending.event, returned); assert.ok(returnAction);
|
||||
reject(g, f.opponent, returnAction); reject(g, f.owner, { ...returnAction, cards: [returned[0], returned[0]] });
|
||||
policies(g, f.owner, after => {
|
||||
assert.equal(player(after, f.owner).traitors.length, player(before, f.owner).traitors.length);
|
||||
assert.equal(after.traitorReserve!.length, before.traitorReserve!.length);
|
||||
});
|
||||
g = act(g, { actor: f.owner, action: returnAction }, f.actions);
|
||||
assert.deepEqual(player(g, f.owner).traitors, [f.target]);
|
||||
reject(g, f.owner, returnAction);
|
||||
const b = loss(f, g), revealed = reveal(b.game, b.plans, f.actions);
|
||||
g = settle(revealed, f.actions);
|
||||
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const bounty = player(g, f.opponent).leaders.find(l => l.id === f.target)!.strength, balance = player(g, f.owner).spice;
|
||||
g = act(g, { actor: f.owner, action: { type: 'decision', event: d.event, card: f.target } }, f.actions);
|
||||
assert.equal(player(g, f.owner).spice, balance + bounty);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
|
||||
g = finish(g, f.actions); physical(g);
|
||||
});
|
||||
|
||||
void test('Advanced normal Traitor call forfeits assassination, while Basic keeps the printed ordinary call and battle outcome', () => {
|
||||
for (const advanced of [true, false]) {
|
||||
const f = closing({ advanced, normalCall: true, homeworlds: true, tech: true });
|
||||
const b = loss(f, f.game, true); assert.equal(b.enemyLeader, f.target);
|
||||
let g = settle(reveal(b.game, b.plans, f.actions), f.actions, true);
|
||||
assert.notEqual(g.decision?.kind, 'moritaniAssassinate');
|
||||
g = finish(g, f.actions);
|
||||
assert.equal(g.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
|
||||
assert.equal(player(g, f.opponent).forces[b.location] ?? 0, 0);
|
||||
assert.equal(player(g, f.owner).forces[b.location], 1);
|
||||
assert.equal(player(g, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, false);
|
||||
if (advanced) {
|
||||
assert.equal(g.moritaniAssassinate!.normalTraitorCall, true);
|
||||
assert.equal(g.moritaniAssassinate!.opportunities.length, 0);
|
||||
const blocked = viewGame(g, f.owner).moritaniAssassinate; assert.ok(blocked?.blocked);
|
||||
const emperor = g.players.find(p => p.faction === 'emperor')!.id;
|
||||
const arrival = ship(g, f.owner, 'homeworld:emperor', 1, f.actions);
|
||||
const battle = openBattle(arrival.settled, 'homeworld:emperor', f.owner, emperor, f.actions);
|
||||
const own = player(battle, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemy = player(battle, emperor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
g = finish(reveal(battle, [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
|
||||
{ actor: emperor, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } },
|
||||
], f.actions), f.actions);
|
||||
assert.equal(g.lastBattleContext!.winner, emperor);
|
||||
assert.equal(g.moritaniAssassinate!.opportunities.length, 0, 'A later real native-home loss cannot recover the forfeited power.');
|
||||
assert.equal(player(g, emperor).leaders.find(l => l.id === enemy.id)!.dead, false);
|
||||
} else assert.ok(!g.moritaniAssassinate, 'Basic must not receive the Advanced private assassination power.');
|
||||
reject(g, f.owner, { type: 'decision', event: b.game.battle!.event, card: f.target });
|
||||
physical(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic no-Skills ordinary Homeworld loss uses printed battle casualties without an Advanced assassination suffix', () => {
|
||||
const f = closing({ advanced: false, homeworlds: true, tech: true });
|
||||
const b = loss(f, f.game), revealed = reveal(b.game, b.plans, f.actions);
|
||||
const done = finish(revealed, f.actions);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.ok(!done.moritaniAssassinate);
|
||||
assert.equal(player(done, f.owner).leaders.find(l => l.id === b.ownLeader)!.dead, true);
|
||||
assert.equal(player(done, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
||||
assert.equal(homePool(done, f.owner, b.location).normal, 0);
|
||||
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice +
|
||||
player(revealed, f.owner).leaders.find(l => l.id === b.ownLeader)!.strength);
|
||||
physical(done);
|
||||
});
|
||||
|
||||
void test('original held Tabr rewards only its local normal win after real paid support, before loser Tech is transferred', () => {
|
||||
const f = closing({ tech: true, strongholds: true });
|
||||
const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`;
|
||||
const arrival = ship(f.game, f.opponent, location, 1, f.actions);
|
||||
assert.equal(player(arrival.settled, f.opponent).reserves, player(arrival.before, f.opponent).reserves - 1);
|
||||
assert.ok(player(arrival.settled, f.opponent).spice < player(arrival.before, f.opponent).spice);
|
||||
const g = openBattle(arrival.settled, location, f.owner, f.opponent, f.actions);
|
||||
const own = player(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemy = player(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
const revealed = reveal(g, [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 3, support: 3 } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } },
|
||||
], f.actions);
|
||||
const pending = settle(revealed, f.actions);
|
||||
assert.equal(pending.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(pending, f.owner).spice, player(revealed, f.owner).spice - 3 + 1 + ordinaryCollectionSpice(pending, f.owner));
|
||||
assert.equal(player(pending, f.owner).forces[location], player(revealed, f.owner).forces[location] - 3);
|
||||
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 3);
|
||||
assert.equal(player(pending, f.opponent).forces[location] ?? 0, 0);
|
||||
const token = ownedTech(revealed.techTokens, f.opponent)[0]; assert.ok(token);
|
||||
assert.equal(pending.techTokens![token].owner, f.owner, 'A sole loser token transfers automatically after physical cleanup.');
|
||||
const done = finish(pending, f.actions);
|
||||
assert.equal(done.techTokens![token].owner, f.owner);
|
||||
assert.equal(player(done, f.owner).spice, player(pending, f.owner).spice, 'Tech choice cannot replay held-card income.');
|
||||
physical(done);
|
||||
});
|
||||
|
||||
void test('original Discovery supply, reveal and free nested entry survive the late closing draw and source-local physical battle loss without Skills', () => {
|
||||
const f = discovery({ homeworlds: true, tech: true, strongholds: true });
|
||||
assert.equal(player(f.shipment.settled, f.owner).reserves, player(f.shipment.before, f.owner).reserves - 2);
|
||||
assert.ok(player(f.shipment.settled, f.owner).spice < player(f.shipment.before, f.owner).spice);
|
||||
const entryView = viewGame(f.entry.before, f.owner), entry = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entry);
|
||||
reject(f.entry.before, f.opponent, entry); reject(f.entry.before, f.owner, { ...entry, event: 'stale' });
|
||||
policies(f.entry.before, f.owner, (after, action) => {
|
||||
const moved = Array.isArray(action.groups)
|
||||
? action.groups.reduce((sum, group) => sum + Number(group.normal) + Number(group.elite), 0) : 0;
|
||||
assert.equal(player(after, f.owner).forces['cistern:0'] ?? 0, moved);
|
||||
assert.equal(player(after, f.owner).forces['gara_kulon:8'] ?? 0, 2 - moved);
|
||||
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
});
|
||||
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
|
||||
assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
||||
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
assert.equal(player(f.entry.after, f.owner).forces[f.shipment.step.action.territory + ':' + f.shipment.step.action.sector] ?? 0, 0);
|
||||
let g = advance(f.game, s => s.phase === 1 && clean(s), f.actions); orderSpice(g, true);
|
||||
const closed = close(f, g); g = closed.game;
|
||||
assert.equal(closed.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani');
|
||||
const arrival = ship(g, f.opponent, 'cistern:0', 1, f.actions);
|
||||
assert.equal(player(arrival.settled, f.opponent).forces['cistern:0'], 1);
|
||||
assert.ok(player(arrival.settled, f.opponent).spice < player(arrival.before, f.opponent).spice);
|
||||
g = openBattle(arrival.settled, 'cistern:0', f.owner, f.opponent, f.actions);
|
||||
const own = player(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemy = player(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && l.id !== f.target).sort((a, b) => b.strength - a.strength)[0];
|
||||
const revealed = reveal(g, [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: 1 } },
|
||||
], f.actions);
|
||||
const done = finish(revealed, f.actions);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(player(done, f.owner).forces['cistern:0'] ?? 0, 0);
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 2);
|
||||
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 1);
|
||||
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 1 + ordinaryCollectionSpice(done, f.opponent),
|
||||
'Ordinary Collection is separate; held Tuek income and native-home strength do not migrate into a nested battle.');
|
||||
assert.equal(homePool(done, f.owner, 'homeworld:moritani').normal, homePool(revealed, f.owner, 'homeworld:moritani').normal);
|
||||
physical(done);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen and mixed-E3 original lobbies remain inadmissible without mutating the declared source pieces', () => {
|
||||
const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
const mixed = lobby(); joinGame(mixed, newPlayer('ecaz', 'Ecaz', 'ecaz'));
|
||||
for (const g of [native, mixed]) {
|
||||
let ready = reload(g);
|
||||
for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } });
|
||||
const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { territory } from '../game/board';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
import { nexusCardAction } from '../game/nexus-card-options';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nextNativeE3HomeworldStep, nativeE3HomeworldPlayer, nativeE3HomeworldPool } from './fixture-homeworld-native-e3-modules';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export { nativeE3HomeworldPlayer as e3NexusEcazPlayer, nativeE3HomeworldPool as e3NexusEcazPool };
|
||||
export type E3NexusEcazStep = { actor: string; action: Action };
|
||||
export type E3NexusEcazTransition = { before: Game; step: E3NexusEcazStep; after: Game };
|
||||
export type E3NexusEcazOptions = {
|
||||
/** An original undealt lobby or its native setup; never convert a played save. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
roster?: readonly FactionId[];
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
homeworlds?: boolean;
|
||||
discovery?: boolean;
|
||||
};
|
||||
export type E3NexusEcazSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game; owner: string; others: string[];
|
||||
actions: E3NexusEcazStep[];
|
||||
};
|
||||
export type E3NexusEcazClosing = E3NexusEcazSetup & {
|
||||
firstMentat: E3NexusEcazTransition; alliance: Game; draw: E3NexusEcazTransition; game: Game;
|
||||
claim?: E3NexusEcazTransition;
|
||||
};
|
||||
export type E3NexusEcazCoalition = E3NexusEcazClosing & {
|
||||
ally: string; opponent: string; location: string; ecazForces: number;
|
||||
arrivals: (E3NexusEcazTransition & { settled: Game })[]; beforeBattle: Game; choice: E3NexusEcazStep; game: Game;
|
||||
};
|
||||
export const e3NexusEcazReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const e3NexusEcazClean = (g: Game): boolean =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
|
||||
/** Minimal persisted bot profiles, projected only after genuine owner custody exists. */
|
||||
export function e3NexusEcazPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
|
||||
const saved = e3NexusEcazReload(g);
|
||||
nativeE3HomeworldPlayer(saved, actor).bot = difficulty;
|
||||
return botActions(viewGame(e3NexusEcazReload(saved), actor));
|
||||
}
|
||||
export function e3NexusEcazStep(g: Game, step: E3NexusEcazStep, actions?: E3NexusEcazStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(e3NexusEcazReload(g), step.actor, step.action));
|
||||
}
|
||||
export function nextE3NexusEcazStep(g: Game): E3NexusEcazStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
||||
if (g.nexusCards?.phase?.stage === 'drawing' && !g.decision) {
|
||||
const actor = g.nexusCards.phase.eligible.find(id => !g.nexusCards!.phase!.done.includes(id));
|
||||
if (actor) {
|
||||
const view = viewGame(g, actor), choices = view.nexusCards!.choices;
|
||||
const action = nexusCardAction(view, choices.includes('keep') ? 'keep' : 'draw'); assert.ok(action);
|
||||
return { actor, action };
|
||||
}
|
||||
}
|
||||
if (g.decision?.kind === 'ecazBattleLead' || g.decision?.kind === 'techToken') return null;
|
||||
if (g.decision?.kind === 'homeworldShipmentGuild') return { actor: g.decision.player,
|
||||
action: { type: 'decision', event: g.decision.event, allow: true } };
|
||||
if (g.decision && ['advisor', 'intrusion', 'advisorBattle'].includes(g.decision.kind))
|
||||
return { actor: g.decision.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextNativeE3HomeworldStep(g);
|
||||
}
|
||||
export function advanceE3NexusEcaz(g: Game, until: (g: Game) => boolean, actions?: E3NexusEcazStep[]): Game {
|
||||
for (let n = 0; n < 3000; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextE3NexusEcazStep(g);
|
||||
assert.ok(next, 'The actual owner must choose coalition lead, seal a plan or select the winner Tech token.');
|
||||
g = e3NexusEcazStep(g, next, actions);
|
||||
}
|
||||
throw Error('Original no-Skills Ecaz chronology did not reach its owned boundary.');
|
||||
}
|
||||
function permutation(source: readonly string[], desired: readonly string[]): number[] {
|
||||
const current = [...source], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return rolls;
|
||||
}
|
||||
/** Select only ORIGINAL undealt Nexus/Treachery permutations. No hand is assigned. */
|
||||
function initialize(g: Game): Game {
|
||||
const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = [];
|
||||
for (const p of g.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) {
|
||||
const index = remaining.findIndex(c => c.kind === 'worthless' || c.kind === 'shield');
|
||||
assert.ok(index >= 0); front.push(remaining.splice(index, 1)[0].id);
|
||||
}
|
||||
const rolls = [...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(c => c !== 'ecaz')]),
|
||||
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])];
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let index = 0;
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initializeNexusGameForAudit(g); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function createE3NexusEcazSetup(options: E3NexusEcazOptions = {}): E3NexusEcazSetup {
|
||||
const roster = options.roster ?? ['ecaz', 'guild', 'emperor'];
|
||||
let g = options.initial ? e3NexusEcazReload(options.initial)
|
||||
: createGame('E3NEXUSECAZ', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ecaz']);
|
||||
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
|
||||
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0);
|
||||
assert.equal(g.advanced, options.advanced ?? g.advanced); assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.equal(g.players.filter(p => p.faction === 'ecaz').length, 1);
|
||||
assert.ok(g.players.every(p => ['ecaz', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', 'harkonnen'].includes(p.faction)));
|
||||
assert.ok(!g.leaderSkills && !g.ecazTreachery);
|
||||
const modules = { techTokens: options.tech ?? !!g.techTokens,
|
||||
strongholdCards: options.strongholds ?? !!g.strongholdCards, homeworlds: options.homeworlds ?? !!g.homeworlds };
|
||||
const actions: E3NexusEcazStep[] = [];
|
||||
if (g.status === 'lobby') {
|
||||
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
|
||||
g = e3NexusEcazStep(g, { actor: g.host, action: { type, enabled } }, actions);
|
||||
g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled;
|
||||
g.nexusCards ??= { cards: null, phase: null };
|
||||
} else {
|
||||
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
|
||||
assert.equal(!!g.discoveryEnabled, options.discovery ?? !!g.discoveryEnabled);
|
||||
assert.ok(g.nexusCards?.cards, 'Never graft Nexus onto a dealt setup.');
|
||||
}
|
||||
const initial = e3NexusEcazReload(g);
|
||||
if (g.status === 'lobby') {
|
||||
for (const p of g.players) if (!p.ready) g = e3NexusEcazStep(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
g = initialize(g);
|
||||
}
|
||||
const offered = e3NexusEcazReload(g), owner = g.players.find(p => p.faction === 'ecaz')!.id;
|
||||
for (let n = 0; g.status === 'setup' && n < 200; n++) {
|
||||
const next = nextE3NexusEcazStep(g); assert.ok(next);
|
||||
g = e3NexusEcazStep(g, next, actions);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.equal(nativeE3HomeworldPlayer(g, owner).reserves, 14);
|
||||
assert.equal(Object.values(nativeE3HomeworldPlayer(g, owner).forces).reduce((n, f) => n + f, 0), 6);
|
||||
return { initial, offered, afterSetup: g, owner, others: g.players.filter(p => p.id !== owner).map(p => p.id), actions };
|
||||
}
|
||||
/** Only unplayed Spice Cards move within their original deck. */
|
||||
export function orderE3NexusEcazSpice(g: Game, worm = false): void {
|
||||
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
|
||||
? 'worm' in c && !c.greatMaker && !c.suppressed : 'territory' in c && !c.discovery));
|
||||
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
export function shipE3NexusEcaz(g: Game, actor: string, destination: string, amount: number,
|
||||
actions?: E3NexusEcazStep[]): E3NexusEcazTransition & { settled: Game } {
|
||||
g = advanceE3NexusEcaz(g, s => s.phase === 5 && s.active === actor && e3NexusEcazClean(s), actions);
|
||||
const before = e3NexusEcazReload(g), sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined;
|
||||
if (g.homeworlds) assert.ok(sources);
|
||||
let action: Action;
|
||||
if (destination.startsWith('homeworld:')) {
|
||||
assert.ok(sources);
|
||||
const choice = homeworldShipmentChoice(viewGame(g, actor), destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? 'Original native invasion is unavailable.'); action = choice.action;
|
||||
} else {
|
||||
const [kind, sector] = destination.split(':');
|
||||
action = { type: 'ship', territory: kind, sector: Number(sector), amount, elite: 0, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
const step = { actor, action }; g = e3NexusEcazStep(g, step, actions);
|
||||
const after = e3NexusEcazReload(g);
|
||||
g = advanceE3NexusEcaz(g, s => !s.pendingShipment && !s.pendingHomeworldShipment && e3NexusEcazClean(s), actions);
|
||||
return { before, step, after, settled: g };
|
||||
}
|
||||
function finishMentat(g: Game, actions: E3NexusEcazStep[]): E3NexusEcazTransition {
|
||||
const turn = g.turn;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
const before = e3NexusEcazReload(g), step = nextE3NexusEcazStep(g); assert.ok(step);
|
||||
g = e3NexusEcazStep(g, step, actions);
|
||||
if (g.turn !== turn) return { before, step, after: g };
|
||||
}
|
||||
throw Error('Original Mentat did not finish.');
|
||||
}
|
||||
/** Two other real seats ally; only then can unallied Ecaz draw at Nexus closing. */
|
||||
export function closeE3NexusEcaz(f: E3NexusEcazSetup, state: Game, firstMentat: E3NexusEcazTransition): E3NexusEcazClosing {
|
||||
let g = advanceE3NexusEcaz(state, s => s.turn === 2 && s.phase === 1 && e3NexusEcazClean(s), f.actions);
|
||||
orderE3NexusEcazSpice(g, true);
|
||||
g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions);
|
||||
for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]])
|
||||
g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = e3NexusEcazReload(g);
|
||||
g = advanceE3NexusEcaz(g, s => s.nexusCards?.phase?.stage === 'drawing' && e3NexusEcazClean(s), f.actions);
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
|
||||
const before = e3NexusEcazReload(g), step: E3NexusEcazStep = { actor: f.owner,
|
||||
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
g = e3NexusEcazStep(g, step, f.actions);
|
||||
return { ...f, firstMentat, alliance, draw: { before, step, after: g }, game: g };
|
||||
}
|
||||
export function createE3NexusEcazClosing(options: E3NexusEcazOptions = {}, claimTabr = false): E3NexusEcazClosing {
|
||||
const f = createE3NexusEcazSetup(options); assert.ok(f.others.length >= 2);
|
||||
let g = e3NexusEcazReload(f.afterSetup); orderE3NexusEcazSpice(g);
|
||||
let claim: E3NexusEcazTransition | undefined;
|
||||
if (claimTabr) {
|
||||
const arrival = shipE3NexusEcaz(g, f.owner, 'sietch_tabr:14', 1, f.actions);
|
||||
g = arrival.settled; claim = arrival;
|
||||
}
|
||||
g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions);
|
||||
const mentat = finishMentat(g, f.actions);
|
||||
return { ...closeE3NexusEcaz(f, mentat.after, mentat), claim };
|
||||
}
|
||||
export function openE3NexusEcazBattle(g: Game, destination: string, first: string, second: string,
|
||||
actions?: E3NexusEcazStep[]): Game {
|
||||
g = advanceE3NexusEcaz(g, s => s.phase === 6 && e3NexusEcazClean(s) && !!s.active, actions);
|
||||
const choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === destination &&
|
||||
[first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice);
|
||||
g = e3NexusEcazStep(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: destination,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
|
||||
return advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
export function createE3NexusEcazCunning(options: E3NexusEcazOptions = {}) {
|
||||
const f = createE3NexusEcazClosing({ ...options, advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'],
|
||||
homeworlds: true, tech: true, strongholds: true });
|
||||
const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id, location = 'homeworld:beneGesserit';
|
||||
const arrival = shipE3NexusEcaz(f.game, f.owner, location, 1, f.actions);
|
||||
let g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 6 && e3NexusEcazClean(s) && !s.battle, f.actions);
|
||||
const before = e3NexusEcazReload(g), action = nexusEcazDukeAction(viewGame(g, f.owner)); assert.ok(action);
|
||||
const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions);
|
||||
const cunning: E3NexusEcazTransition = { before, step, after: g };
|
||||
g = openE3NexusEcazBattle(g, location, f.owner, opponent, f.actions);
|
||||
const own = nativeE3HomeworldPlayer(g, f.owner).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemy = nativeE3HomeworldPlayer(g, opponent).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
const plans: E3NexusEcazStep[] = [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } },
|
||||
];
|
||||
return { ...f, opponent, location, arrival, cunning, game: g, plans };
|
||||
}
|
||||
/** Every army reaches Tabr by paid native shipment; no board/wallet/card staging. */
|
||||
export function createE3NexusEcazCoalition(options: E3NexusEcazOptions = {}, ecazForces?: number): E3NexusEcazCoalition {
|
||||
const f = createE3NexusEcazClosing({ ...options, roster: ['ecaz', 'guild', 'emperor'] }, true);
|
||||
let g = advanceE3NexusEcaz(f.game, s => s.turn === 3 && s.phase === 1 && e3NexusEcazClean(s), f.actions);
|
||||
orderE3NexusEcazSpice(g, true);
|
||||
g = advanceE3NexusEcaz(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && e3NexusEcazClean(s), f.actions);
|
||||
const ally = g.players.find(p => p.faction === 'emperor')!.id, opponent = g.players.find(p => p.faction === 'guild')!.id;
|
||||
g = e3NexusEcazStep(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
|
||||
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
|
||||
g = e3NexusEcazStep(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const location = `sietch_tabr:${territory('sietch_tabr').sectors[0]}`;
|
||||
const amount = ecazForces ?? (g.advanced ? 5 : 4);
|
||||
const requested: Record<string, number> = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 };
|
||||
const arrivals: (E3NexusEcazTransition & { settled: Game })[] = [];
|
||||
const shipped = new Set<string>();
|
||||
for (let n = 0; n < 300; n++) {
|
||||
if (g.phase === 6 && e3NexusEcazClean(g)) break;
|
||||
if (g.phase === 5 && e3NexusEcazClean(g) && g.active && !shipped.has(g.active)) {
|
||||
const actor = g.active, arrival = shipE3NexusEcaz(g, actor, location, requested[actor], f.actions);
|
||||
arrivals.push(arrival); shipped.add(actor); g = arrival.settled;
|
||||
} else {
|
||||
const next = nextE3NexusEcazStep(g); assert.ok(next); g = e3NexusEcazStep(g, next, f.actions);
|
||||
}
|
||||
}
|
||||
assert.equal(shipped.size, 3);
|
||||
const beforeBattle = e3NexusEcazReload(g), choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === 'sietch_tabr'); assert.ok(choice);
|
||||
const owned: E3NexusEcazStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
if (g.advanced || amount % 2 === 0) g = e3NexusEcazStep(g, owned, f.actions);
|
||||
return { ...f, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice: owned, game: g };
|
||||
}
|
||||
export function revealE3NexusEcaz(g: Game, plans: readonly E3NexusEcazStep[], actions?: E3NexusEcazStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
g = e3NexusEcazStep(g, plan, actions);
|
||||
if (!g.battle?.revealed) g = advanceE3NexusEcaz(g, s => !!s.battle && e3NexusEcazClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
/** Token placement uses its original supply lottery, not a preplaced token. */
|
||||
export function createE3NexusEcazDiscovery(options: E3NexusEcazOptions = {}) {
|
||||
const f = createE3NexusEcazSetup({ ...options, discovery: true });
|
||||
let g = e3NexusEcazReload(f.afterSetup);
|
||||
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
|
||||
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
|
||||
if (g.advanced) {
|
||||
const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin'); assert.ok(second > 0);
|
||||
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]);
|
||||
}
|
||||
g = advanceE3NexusEcaz(g, s => s.phase === 1 && e3NexusEcazClean(s), f.actions);
|
||||
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
|
||||
const step = nextE3NexusEcazStep(g); assert.ok(step); f.actions.push(structuredClone(step));
|
||||
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(e3NexusEcazReload(g), step.actor, step.action));
|
||||
}
|
||||
const destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const arrival = shipE3NexusEcaz(g, f.owner, `${destination.territory}:${destination.sector}`, 2, f.actions);
|
||||
g = advanceE3NexusEcaz(arrival.settled, s => s.phase === 7 && e3NexusEcazClean(s), f.actions);
|
||||
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
|
||||
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
|
||||
g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
|
||||
const beforeReveal = e3NexusEcazReload(g);
|
||||
g = e3NexusEcazStep(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
|
||||
const revealed = e3NexusEcazReload(g);
|
||||
g = advanceE3NexusEcaz(g, s => s.phase === 8 && e3NexusEcazClean(s), f.actions);
|
||||
const firstMentat = finishMentat(g, f.actions);
|
||||
g = advanceE3NexusEcaz(firstMentat.after, s => !s.response && !s.phaseOpening &&
|
||||
s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
|
||||
const before = e3NexusEcazReload(g), view = viewGame(g, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
const step = { actor: f.owner, action }; g = e3NexusEcazStep(g, step, f.actions);
|
||||
const entry: E3NexusEcazTransition = { before, step, after: e3NexusEcazReload(g) };
|
||||
const closing = closeE3NexusEcaz(f, g, firstMentat);
|
||||
return { ...closing, arrival, token, beforeReveal, revealed, entry };
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { territory } from '../game/board';
|
||||
import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { nextDiscoveryStandaloneNexusE3SkillsStep } from './fixture-discovery-standalone-nexus-e3-skills';
|
||||
import { moritaniStrongholdsPolicy } from './fixture-moritani-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type MoritaniNexusStep = { actor: string; action: Action };
|
||||
export type MoritaniNexusTransition = { before: Game; step: MoritaniNexusStep; after: Game };
|
||||
export type MoritaniNexusOptions = {
|
||||
initial?: Game; advanced?: boolean; roster?: readonly FactionId[];
|
||||
homeworlds?: boolean; discovery?: boolean; tech?: boolean; strongholds?: boolean;
|
||||
nexus?: 'moritani' | 'harkonnen'; normalCall?: boolean;
|
||||
};
|
||||
export type MoritaniNexusSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game; owner: string; opponent: string;
|
||||
target: string; actions: MoritaniNexusStep[];
|
||||
};
|
||||
export type MoritaniNexusClosing = MoritaniNexusSetup & {
|
||||
firstStorm: Game; firstMentat: MoritaniNexusTransition; alliance: Game;
|
||||
draw: MoritaniNexusTransition; game: Game;
|
||||
};
|
||||
export type MoritaniNexusBattle = {
|
||||
arrival: MoritaniNexusTransition; settledArrival: Game; game: Game;
|
||||
location: string; plans: MoritaniNexusStep[]; ownLeader: string; enemyLeader: string;
|
||||
};
|
||||
export const moritaniNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const moritaniNexusClean = (g: Game) =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export const moritaniNexusPlayer = (g: Game, actor: string) => {
|
||||
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
|
||||
};
|
||||
export const moritaniNexusPolicy = moritaniStrongholdsPolicy;
|
||||
export function moritaniNexusAct(g: Game, step: MoritaniNexusStep, actions?: MoritaniNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(moritaniNexusReload(g), step.actor, step.action));
|
||||
}
|
||||
export function nextMoritaniNexusStep(g: Game): MoritaniNexusStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
||||
const d = g.decision;
|
||||
if (d && ['moritaniAssassinate', 'techToken'].includes(d.kind)) return null;
|
||||
if (d?.kind === 'homeworldShipmentGuild') return { actor: d.player, action: { type: 'decision', event: d.event, allow: true } };
|
||||
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
|
||||
if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d?.kind === 'grummanCollection') return { actor: d.player,
|
||||
action: { type: 'decision', event: d.event, decline: true } };
|
||||
}
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(g);
|
||||
if (next?.action.type === 'stormDial') next.action.amount = g.turn === 1 ? 0 : 1;
|
||||
return next;
|
||||
}
|
||||
export function advanceMoritaniNexus(g: Game, until: (g: Game) => boolean, actions?: MoritaniNexusStep[]): Game {
|
||||
for (let n = 0; n < 3000; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextMoritaniNexusStep(g); assert.ok(next, 'Original sealed plans or native aftermath require their actual owner.');
|
||||
g = moritaniNexusAct(g, next, actions);
|
||||
}
|
||||
throw Error('Original no-Skills Moritani programme did not reach its owned boundary.');
|
||||
}
|
||||
/** Scope entropy to the original undealt physical supply. UUID generation stays native. */
|
||||
function permutation(source: readonly string[], desired: readonly string[]): number[] {
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
function lottery<T>(rolls: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Game {
|
||||
const roster: readonly FactionId[] = options.roster ?? ['moritani', 'guild', 'emperor'];
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
let g = options.initial ? moritaniNexusReload(options.initial)
|
||||
: createGame('E3NEXUSMORITANI', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
|
||||
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
|
||||
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.equal(g.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1);
|
||||
assert.ok(g.players.some(p => p.faction === 'moritani'));
|
||||
assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
|
||||
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
|
||||
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
|
||||
assert.ok(g.players.length >= (options.tech ? 3 : 2) && g.players.length <= 6);
|
||||
assert.ok(!options.strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
|
||||
for (const [type, enabled, present] of [
|
||||
['homeworlds', options.homeworlds ?? false, !!g.homeworlds],
|
||||
['techTokens', options.tech ?? false, !!g.techTokens],
|
||||
['strongholdCards', options.strongholds ?? false, !!g.strongholdCards],
|
||||
] as const) if (enabled !== present) g = applyAction(g, g.host, { type, enabled });
|
||||
g.discoveryEnabled = options.discovery ?? false;
|
||||
g.nexusCards ??= { cards: null, phase: null };
|
||||
assert.ok(g.nexusCards.cards === null && g.nexusCards.phase === null);
|
||||
return g;
|
||||
}
|
||||
export function createMoritaniNexusSetup(options: MoritaniNexusOptions = {}): MoritaniNexusSetup {
|
||||
let g = createMoritaniNexusLobby(options);
|
||||
const initial = moritaniNexusReload(g), actions: MoritaniNexusStep[] = [];
|
||||
const owner = g.players.find(p => p.faction === 'moritani')!.id;
|
||||
const opponent = g.players.find(p => p.faction === 'guild')!.id; assert.ok(opponent);
|
||||
const target = leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id;
|
||||
for (const p of g.players) if (!p.ready) g = moritaniNexusAct(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
if (options.initial) g = initializeNexusGameForAudit(g);
|
||||
else {
|
||||
const nx = options.nexus ?? 'moritani', cards = treacheryDeck(['ecaz']), remaining = [...cards];
|
||||
const front = g.players.flatMap(p => {
|
||||
const kinds = [p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
|
||||
return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; });
|
||||
});
|
||||
const traitors = traitorDeck(g.players), desired = [target, ...traitors.filter(c => c !== target)];
|
||||
const index = nx === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4;
|
||||
[desired[0], desired[index]] = [desired[index], desired[0]];
|
||||
const spiceCount = spiceDeck().length + (g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0);
|
||||
g = lottery([...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(NEXUS_FACTIONS, [nx, ...NEXUS_FACTIONS.filter(c => c !== nx)]),
|
||||
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)]),
|
||||
...Array<number>(spiceCount - 1).fill(0xffffffff), ...permutation(traitors, desired)], () => initializeNexusGameForAudit(g));
|
||||
}
|
||||
const offered = moritaniNexusReload(g);
|
||||
// A real Bene Gesserit prediction can postpone the same original Traitor deal.
|
||||
// Supplied human lobbies keep their native lottery.
|
||||
while (g.status === 'setup') {
|
||||
let next = nextMoritaniNexusStep(g); assert.ok(next);
|
||||
if (!options.initial && g.setupStage === 'prediction') {
|
||||
const source = traitorDeck(g.players), desired = [target, ...source.filter(c => c !== target)];
|
||||
const index = options.nexus === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4;
|
||||
[desired[0], desired[index]] = [desired[index], desired[0]];
|
||||
const step = next; actions.push(structuredClone(step));
|
||||
g = lottery(permutation(source, desired), () => applyAction(moritaniNexusReload(g), step.actor, step.action));
|
||||
continue;
|
||||
}
|
||||
if (!options.initial && options.nexus !== 'harkonnen' && g.setupStage === 'traitors' && moritaniNexusPlayer(g, owner).traitorChoices.length)
|
||||
next = { actor: owner, action: { type: 'traitor', leader: target } };
|
||||
if (g.decision?.kind === 'moritaniSetup' &&
|
||||
g.players.every(p => Object.entries(p.forces).every(([key, amount]) => !key.startsWith('sietch_tabr:') || amount === 0)))
|
||||
next = { actor: owner, action: { type: 'decision', territory: 'sietch_tabr', sector: territory('sietch_tabr').sectors[0] } };
|
||||
g = moritaniNexusAct(g, next, actions);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
return { initial, offered, afterSetup: g, owner, opponent, target, actions };
|
||||
}
|
||||
/** Only unplayed original Spice Cards are positioned. */
|
||||
export function orderMoritaniNexusSpice(g: Game, worm = false): void {
|
||||
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
|
||||
? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery));
|
||||
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
export function closeMoritaniNexus(f: MoritaniNexusSetup, state: Game): { alliance: Game; draw: MoritaniNexusTransition; game: Game } {
|
||||
let g = advanceMoritaniNexus(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && moritaniNexusClean(s), f.actions);
|
||||
const others = g.players.filter(p => p.id !== f.owner); assert.ok(others.length >= 2);
|
||||
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
|
||||
g = moritaniNexusAct(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = moritaniNexusReload(g);
|
||||
g = advanceMoritaniNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && moritaniNexusClean(s), f.actions);
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
|
||||
const before = moritaniNexusReload(g), step: MoritaniNexusStep = { actor: f.owner,
|
||||
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
g = moritaniNexusAct(g, step, f.actions);
|
||||
return { alliance, draw: { before, step, after: moritaniNexusReload(g) }, game: g };
|
||||
}
|
||||
export function createMoritaniNexusClosing(options: MoritaniNexusOptions = {}): MoritaniNexusClosing {
|
||||
const f = createMoritaniNexusSetup(options); let g = moritaniNexusReload(f.afterSetup);
|
||||
orderMoritaniNexusSpice(g);
|
||||
g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions);
|
||||
const firstStorm = moritaniNexusReload(g);
|
||||
g = advanceMoritaniNexus(g, s => s.phase === 8 && s.decision?.kind === 'moritaniPlacement', f.actions);
|
||||
const token = g.moritaniTerror!.tokens.find(t => t.kind === 'extortion' && t.status === 'available'); assert.ok(token);
|
||||
g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'decision', token: token.id, territory: 'arrakeen' } }, f.actions);
|
||||
let firstMentat: MoritaniNexusTransition | undefined;
|
||||
while (g.turn === 1) {
|
||||
const before = moritaniNexusReload(g), step = nextMoritaniNexusStep(g); assert.ok(step);
|
||||
g = moritaniNexusAct(g, step, f.actions);
|
||||
if (g.turn === 2) firstMentat = { before, step, after: moritaniNexusReload(g) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions);
|
||||
orderMoritaniNexusSpice(g, true);
|
||||
return { ...f, firstStorm, firstMentat, ...closeMoritaniNexus(f, g) };
|
||||
}
|
||||
export function shipMoritaniNexus(g: Game, actor: string, destination: string, amount: number,
|
||||
actions?: MoritaniNexusStep[]): MoritaniNexusTransition & { settled: Game } {
|
||||
g = advanceMoritaniNexus(g, s => s.phase === 5 && s.active === actor && moritaniNexusClean(s), actions);
|
||||
const before = moritaniNexusReload(g), view = viewGame(g, actor);
|
||||
let action: Action;
|
||||
const sources = g.homeworlds ? nativeShipmentSources(view, amount, 0) : undefined;
|
||||
if (destination.startsWith('homeworld:')) {
|
||||
assert.ok(sources); const quote = homeworldShipmentChoice(view, destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(quote.action, quote.blocked ?? 'Original paid native entry unavailable.'); action = quote.action;
|
||||
} else {
|
||||
const [territory, sector] = destination.split(':');
|
||||
action = { type: 'ship', territory, sector: Number(sector), amount, elite: 0, allyPayment: 0,
|
||||
...(sources ? { homeworldSources: sources } : {}) };
|
||||
}
|
||||
const step = { actor, action }; g = moritaniNexusAct(g, step, actions);
|
||||
const after = moritaniNexusReload(g);
|
||||
g = advanceMoritaniNexus(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && moritaniNexusClean(s), actions);
|
||||
return { before, step, after, settled: g };
|
||||
}
|
||||
export function openMoritaniNexusBattle(g: Game, location: string, owner: string, opponent: string,
|
||||
actions?: MoritaniNexusStep[]): Game {
|
||||
g = advanceMoritaniNexus(g, s => s.phase === 6 && moritaniNexusClean(s) && !!s.active, actions);
|
||||
assert.ok(g.active === owner || g.active === opponent);
|
||||
const territory = location.startsWith('homeworld:') ? location : location.split(':')[0];
|
||||
g = moritaniNexusAct(g, { actor: g.active!, action: { type: 'chooseBattle', territory,
|
||||
target: g.active === owner ? opponent : owner } }, actions);
|
||||
return advanceMoritaniNexus(g, s => !!s.battle && moritaniNexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
export function openMoritaniNexusLoss(f: MoritaniNexusSetup, state: Game, normalCall = false): MoritaniNexusBattle {
|
||||
const location = state.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch:5';
|
||||
const arrival = shipMoritaniNexus(state, f.owner, location, 1, f.actions);
|
||||
const g = openMoritaniNexusBattle(arrival.settled, location, f.owner, f.opponent, f.actions);
|
||||
const own = moritaniNexusPlayer(g, f.owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemy = moritaniNexusPlayer(g, f.opponent).leaders.filter(l => !l.dead && !l.usedAt && (normalCall || l.id !== f.target))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const weapon = moritaniNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
|
||||
const worthless = moritaniNexusPlayer(g, f.owner).hand.find(c => c.kind === 'worthless');
|
||||
assert.ok(own && enemy && weapon && worthless);
|
||||
return { arrival, settledArrival: arrival.settled, game: g, location, ownLeader: own.id, enemyLeader: enemy.id, plans: [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0, weapon: worthless.id } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: g.advanced ? 1 : 0, weapon: weapon.id } },
|
||||
] };
|
||||
}
|
||||
export function revealMoritaniNexus(g: Game, plans: MoritaniNexusStep[], actions?: MoritaniNexusStep[]): Game {
|
||||
for (const step of plans) {
|
||||
g = moritaniNexusAct(g, step, actions);
|
||||
if (!g.battle?.revealed) g = advanceMoritaniNexus(g, s => !!s.battle && moritaniNexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
export function settleMoritaniNexus(g: Game, actions?: MoritaniNexusStep[], normalCall = false): Game {
|
||||
for (let n = 0; n < 160; n++) {
|
||||
if (!g.response && !g.phaseOpening && (g.decision?.kind === 'moritaniAssassinate' || g.decision?.kind === 'techToken' || !g.battle && moritaniNexusClean(g))) return g;
|
||||
const next = nextMoritaniNexusStep(g); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = normalCall && moritaniNexusPlayer(g, next.actor).faction === 'moritani';
|
||||
g = moritaniNexusAct(g, next, actions);
|
||||
}
|
||||
throw Error('Original native battle suffix did not settle.');
|
||||
}
|
||||
export function createMoritaniNexusDiscovery(options: MoritaniNexusOptions = {}) {
|
||||
const f = createMoritaniNexusSetup({ ...options, discovery: true }); let g = moritaniNexusReload(f.afterSetup);
|
||||
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
|
||||
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
|
||||
const second = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin');
|
||||
assert.ok(second > 0); g.spiceDeck.splice(1, 0, g.spiceDeck.splice(second, 1)[0]);
|
||||
g = advanceMoritaniNexus(g, s => s.phase === 1 && moritaniNexusClean(s), f.actions);
|
||||
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
|
||||
const step = nextMoritaniNexusStep(g); assert.ok(step); f.actions.push(structuredClone(step));
|
||||
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(moritaniNexusReload(g), step.actor, step.action));
|
||||
}
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const shipment = shipMoritaniNexus(g, f.owner, `${placement.territory}:${placement.sector}`, 2, f.actions);
|
||||
g = advanceMoritaniNexus(shipment.settled, s => s.phase === 7 && moritaniNexusClean(s), f.actions);
|
||||
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
|
||||
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
|
||||
g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
|
||||
g = moritaniNexusAct(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
|
||||
const revealed = moritaniNexusReload(g);
|
||||
g = advanceMoritaniNexus(g, s => s.turn === 2 && !s.response && !s.phaseOpening && s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
|
||||
const before = moritaniNexusReload(g), view = viewGame(g, f.owner);
|
||||
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
const step = { actor: f.owner, action }; g = moritaniNexusAct(g, step, f.actions);
|
||||
return { ...f, shipment, revealed, token, entry: { before, step, after: moritaniNexusReload(g) }, game: g };
|
||||
}
|
||||
export function assertMoritaniNexusPhysical(g: Game): void {
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
if (g.homeworlds) homeworldGameIntegrity(g);
|
||||
const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : [];
|
||||
for (const p of g.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).reduce((n, h) => n + (h.forces[p.id]?.normal ?? 0), 0);
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0) + visitors, 20);
|
||||
}
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? [];
|
||||
assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired].sort(), traitorDeck(g.players).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());
|
||||
}
|
||||
Reference in new issue
Block a user