fix: unify native Bureaucrat allied funding receipts
This commit is contained in:
1 parent
60d1bd3b59
commit
06342ad34c
15 files changed
+478
-156
No files matched your search
@@ -3,11 +3,8 @@ import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature } from '../game/bureaucrat-payment';
|
||||
import { guildBetrayalNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { bureaucratPaymentSignature } from '../game/bureaucrat-payment';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
|
||||
import {
|
||||
betrayalFundingSeat as seat, chooseBetrayalFunding, createBureaucratBetrayalFundingFixture,
|
||||
@@ -80,21 +77,6 @@ function proveReceipt(f: BureaucratBetrayalFundingFixture, done: Game, diverted:
|
||||
rejectUnchanged(ended, f.shipper, { type: 'endMovement' });
|
||||
}
|
||||
|
||||
void test('Fresh native Guild classic setup genuinely trains its distinct Bureaucrat and preserves all14 custody', () => {
|
||||
const f = createBureaucratBetrayalFundingFixture({ donorAmount: 3 });
|
||||
assert.equal(f.initial.status, 'setup');
|
||||
assert.equal(f.initial.leaderSkills!.assignments.length, 0);
|
||||
assert.equal(f.trained.status, 'setup');
|
||||
assert.equal(f.trained.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, f.trainer);
|
||||
assert.ok(![f.shipper, f.holder, f.guild, f.donor].includes(f.trainer));
|
||||
validateLeaderSkills(f.trained.leaderSkills!, f.trained.players);
|
||||
const skills = f.trained.leaderSkills!;
|
||||
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
assert.equal(guildBetrayalNexusLeaderSkillsProfile(f.game), true);
|
||||
assert.equal(bureaucratPaymentModeSupported(f.game), true);
|
||||
assert.equal(bureaucratAlliedFundingModeSupported(f.game), true);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const [primary, donorAmount] of [[5, 0], [5, 3], [3, 5], [5, 5], [0, 5]]) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
|
||||
import { advancePairedIxNexusSkills } from './fixture-paired-ix-nexus-skills';
|
||||
import {
|
||||
allowE1Responses, bureaucratE1PhysicalCards, chooseBureaucratE1Payment,
|
||||
@@ -67,21 +66,6 @@ function assertShipmentCosts(f: BureaucratE1ShipmentFixture, game: Game): void {
|
||||
assert.deepEqual(bureaucratE1PhysicalCards(game), f.physical);
|
||||
}
|
||||
|
||||
void test('paired E1 no-Homeworlds nexus skills admit both Bureaucrat mode gates and nothing broader', () => {
|
||||
const base = createBureaucratE1FundingBase({});
|
||||
assert.deepEqual(base.game.expansions, ['ix']);
|
||||
assert.equal(base.game.homeworlds, undefined);
|
||||
assert.equal(bureaucratPaymentModeSupported(base.game), true);
|
||||
assert.equal(bureaucratAlliedFundingModeSupported(base.game), true);
|
||||
const single = structuredClone(base.game);
|
||||
single.players = single.players.filter(p => p.id !== 'tleilaxu');
|
||||
assert.equal(bureaucratPaymentModeSupported(single), false);
|
||||
const multi = structuredClone(base.game);
|
||||
multi.expansions = ['ix', 'choam'];
|
||||
assert.equal(bureaucratPaymentModeSupported(multi), false);
|
||||
assert.equal(bureaucratAlliedFundingModeSupported(multi), false);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true])
|
||||
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]])
|
||||
for (const redirect of [false, true]) {
|
||||
@@ -401,3 +385,62 @@ void test('paired E1 Nexus Guild route: Guild Cunning is the Guild\'s own card a
|
||||
assert.deepEqual(seat(paid, base.guild).forces,
|
||||
{ ...seat(before, base.guild).forces, [`${target}:${sector}`]: 5 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The same genuine paired E1 roster selected without its Nexus Cards module:
|
||||
// no Nexus state is fabricated, and the real turn-two worm window and native
|
||||
// alliance remain the only route to an allied auction/shipment receipt.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
for (const redirect of [false, true]) {
|
||||
void test(`Basic no-Nexus paired E1 allied auction 5+3: ${redirect ? 'Bank two' : 'full Emperor income'} once, one card of record and the original next lot`, () => {
|
||||
const f = createBureaucratE1AuctionFixture({ nexus: false, payerShare: 5, donorShare: 3 });
|
||||
assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorShare);
|
||||
assert.equal(seat(f.beforeAction, f.payer).spice, seat(f.beforePledge, f.payer).spice);
|
||||
const paid = payBureaucratE1Auction(f);
|
||||
const pending = allowE1Responses(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.bureaucratPayments?.pending?.source.payee, f.emperor);
|
||||
assert.equal(pending.bureaucratPayments?.pending?.source.payer, f.payer);
|
||||
assert.equal(pending.bureaucratPayments?.pending?.source.amount, f.amount);
|
||||
const done = allowE1Responses(chooseBureaucratE1Payment(pending, redirect));
|
||||
assertAuctionOutcome(f, done, redirect ? 2 : 0);
|
||||
assert.equal(done.bureaucratPayments?.pending, undefined);
|
||||
});
|
||||
|
||||
void test(`Basic no-Nexus paired E1 allied shipment 5+3: ${redirect ? 'Bank two' : 'full Guild income'} once, one army commitment and real Movement continuation`, () => {
|
||||
const f = createBureaucratE1ShipmentFixture({ nexus: false, primary: 5, pledge: 3 });
|
||||
const { game: paid } = declareBureaucratE1Shipment(f);
|
||||
const pending = allowE1Responses(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.bureaucratPayments?.pending?.source.payee, f.guild);
|
||||
assert.equal(pending.bureaucratPayments?.pending?.source.payer, f.payer);
|
||||
assert.equal(pending.bureaucratPayments?.pending?.source.amount, f.amount);
|
||||
const done = allowE1Responses(chooseBureaucratE1Payment(pending, redirect));
|
||||
assert.equal(seat(done, f.guild).spice, seat(f.beforePledge, f.guild).spice + f.amount - (redirect ? 2 : 0));
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0);
|
||||
assertShipmentCosts(f, done);
|
||||
assert.equal(done.phase, 5);
|
||||
assert.equal(done.active, f.payer);
|
||||
const ended = applyAction(structuredClone(done), f.payer, { type: 'endMovement' });
|
||||
assert.notEqual(ended.active, f.payer);
|
||||
assert.deepEqual(counters(ended), counters(done));
|
||||
});
|
||||
}
|
||||
|
||||
void test('Basic no-Nexus paired E1 receipts never pool two sub-five shares and never mint a card', () => {
|
||||
const auction = createBureaucratE1AuctionFixture({ nexus: false, payerShare: 3, donorShare: 3 });
|
||||
const auctionDone = allowE1Responses(payBureaucratE1Auction(auction));
|
||||
assert.notEqual(auctionDone.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(auctionDone.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(auctionDone.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(seat(auctionDone, auction.emperor).spice, seat(auction.beforePledge, auction.emperor).spice + 6);
|
||||
assertAuctionOutcome(auction, auctionDone, 0);
|
||||
const shipment = createBureaucratE1ShipmentFixture({ nexus: false, primary: 3, pledge: 3 });
|
||||
const shipmentDone = allowE1Responses(declareBureaucratE1Shipment(shipment).paid);
|
||||
assert.notEqual(shipmentDone.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(shipmentDone.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(seat(shipmentDone, shipment.guild).spice, seat(shipment.beforePledge, shipment.guild).spice + 6);
|
||||
assertShipmentCosts(shipment, shipmentDone);
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { advancePairedE3Nexus, quietPairedE3NexusChoice, pairedE3NexusClean } from './fixture-paired-e3-nexus-modules';
|
||||
import { advancePairedE3Nexus, quietPairedE3NexusChoice, pairedE3NexusClean,
|
||||
pairedE3NexusInventory } from './fixture-paired-e3-nexus-modules';
|
||||
import {
|
||||
allowE3Responses, bureaucratE3PhysicalCards, chooseBureaucratE3Payment,
|
||||
createBureaucratE3AuctionFixture, createBureaucratE3ShipmentFixture,
|
||||
@@ -304,3 +305,112 @@ for (const advanced of [false, true]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('Basic no-Nexus E3 allied nonstronghold shipment pays ten for five physical forces; native cancellation precedes one Bank diversion', () => {
|
||||
const f = createBureaucratE3ShipmentFixture({
|
||||
nexus: false, primary: 5, pledge: 5, target: 'habbanya_erg', counter: true,
|
||||
});
|
||||
assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - 5);
|
||||
assert.equal(f.beforeAction.aid[f.donor].amount, 5);
|
||||
assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge));
|
||||
const { paid } = declareBureaucratE3Shipment(f);
|
||||
assert.equal(paid.response?.kind, 'guildIncome');
|
||||
assert.equal(paid.response?.amount, 10);
|
||||
assert.equal(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(f.counterCard!), true);
|
||||
assertShipmentCosts(f, paid);
|
||||
assert.equal(seat(paid, f.payer).forces[`${f.target}:17`], 5);
|
||||
const canceled = allowE3Responses(applyAction(structuredClone(paid), f.trainer,
|
||||
{ type: 'card', card: f.counterCard!, mode: 'cancel' }));
|
||||
assert.equal(seat(canceled, f.guild).spice, seat(f.beforePledge, f.guild).spice);
|
||||
assert.equal(canceled.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(canceled, f.trainer).bureaucrat.pending, null);
|
||||
assert.notEqual(canceled.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(canceled.discard.filter((card) => card.id === f.counterCard).length, 1);
|
||||
assertShipmentCosts(f, canceled);
|
||||
assert.equal(canceled.phase, 5);
|
||||
assert.equal(canceled.active, f.payer);
|
||||
|
||||
const pending = allowE3Responses(structuredClone(paid));
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.decision?.player, f.trainer);
|
||||
for (const viewer of [f.trainer, f.emperor]) {
|
||||
const invoice = viewGame(pending, viewer).bureaucrat.pending!;
|
||||
assert.equal(invoice.payer, null);
|
||||
assert.equal(invoice.amount, 10);
|
||||
assert.equal(invoice.payee, f.guild);
|
||||
assert.equal('contributions' in invoice, false);
|
||||
assert.equal('signature' in invoice, false);
|
||||
}
|
||||
const done = allowE3Responses(chooseBureaucratE3Payment(pending));
|
||||
assertShipmentCosts(f, done);
|
||||
assert.equal(seat(done, f.guild).spice, seat(f.beforePledge, f.guild).spice + 8);
|
||||
assert.equal(done.bureaucratPayments?.used.length, 1);
|
||||
assert.deepEqual(hand(done, f.trainer), hand(f.beforeAction, f.trainer));
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(done.phase, 5);
|
||||
assert.equal(done.active, f.payer);
|
||||
pairedE3NexusInventory(done);
|
||||
const moved = applyAction(structuredClone(done), f.payer, { type: 'endMovement' });
|
||||
assert.equal(moved.active, null);
|
||||
assert.equal(moved.phase, 7);
|
||||
assert.equal(moved.decision, null);
|
||||
assert.deepEqual(counters(moved), counters(done));
|
||||
});
|
||||
|
||||
void test('Advanced no-Nexus E3 auction with an unused selected CHOAM deck qualifies the donor alone and resumes the original lot after native allowance', () => {
|
||||
const f = createBureaucratE3AuctionFixture({
|
||||
advanced: true, nexus: false, families: ['choam'], payerShare: 3, donorShare: 5, counter: true,
|
||||
});
|
||||
assert.equal(f.worm.nexus, true);
|
||||
assert.ok(f.worm.players.every((p) => p.ally === null));
|
||||
assert.equal(seat(f.alliance, f.payer).ally, f.donor);
|
||||
assert.equal(seat(f.alliance, f.donor).ally, f.payer);
|
||||
assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - 5);
|
||||
assert.equal(f.beforeAction.aid[f.donor].amount, 5);
|
||||
const paid = payBureaucratE3Auction(f);
|
||||
assert.equal(paid.response?.kind, 'emperorIncome');
|
||||
assert.equal(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(f.counterCard!), true);
|
||||
assertAuctionCosts(f, paid);
|
||||
const canceled = allowE3Responses(applyAction(structuredClone(paid), f.trainer,
|
||||
{ type: 'card', card: f.counterCard!, mode: 'cancel' }));
|
||||
assert.equal(seat(canceled, f.emperor).spice, seat(f.beforePledge, f.emperor).spice);
|
||||
assert.equal(canceled.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(canceled, f.trainer).bureaucrat.pending, null);
|
||||
assert.notEqual(canceled.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(canceled.discard.filter((card) => card.id === f.counterCard).length, 1);
|
||||
assertAuctionCosts(f, canceled);
|
||||
assert.deepEqual(hand(canceled, f.payer), [...hand(f.beforeAction, f.payer), f.card]);
|
||||
assert.equal(canceled.currentAuctionSale, null);
|
||||
assert.equal(canceled.auction!.index, f.beforeAction.auction!.index + 1);
|
||||
|
||||
const pending = allowE3Responses(structuredClone(paid));
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
for (const viewer of [f.trainer, f.guild]) {
|
||||
const invoice = viewGame(pending, viewer).bureaucrat.pending!;
|
||||
assert.equal(invoice.payer, null);
|
||||
assert.equal(invoice.amount, 8);
|
||||
assert.equal(invoice.payee, f.emperor);
|
||||
assert.equal('contributions' in invoice, false);
|
||||
assert.equal('signature' in invoice, false);
|
||||
}
|
||||
const done = allowE3Responses(chooseBureaucratE3Payment(pending));
|
||||
assertAuctionOutcome(f, done, 2);
|
||||
assert.deepEqual(counters(done), counters(f.beforeAction));
|
||||
assert.deepEqual(hand(done, f.trainer), hand(f.beforeAction, f.trainer));
|
||||
assert.equal(done.auction!.active, canceled.auction!.active);
|
||||
assert.equal(done.auction!.cards[done.auction!.index].id, canceled.auction!.cards[canceled.auction!.index].id);
|
||||
pairedE3NexusInventory(done);
|
||||
});
|
||||
|
||||
void test('No-Nexus E3 actual auction shares neither pool sub-five amounts nor qualify the trainer or native payee', () => {
|
||||
for (const [donor, payerShare, donorShare] of [
|
||||
['moritani', 4, 4], [ALLIANCE.trainer, 3, 5], [ALLIANCE.auctionPayee, 3, 5],
|
||||
] as const) {
|
||||
const f = createBureaucratE3AuctionFixture({ nexus: false, donor, payerShare, donorShare });
|
||||
const done = allowE3Responses(payBureaucratE3Auction(f));
|
||||
assert.notEqual(done.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
assertAuctionOutcome(f, done, 0);
|
||||
assert.deepEqual(counters(done), counters(f.beforeAction));
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import test from 'node:test';
|
||||
import { applyAction, initializeHomeworldOccupationGameForAudit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { bureaucratAlliedFundingModeSupported } from '../game/bureaucrat-payment';
|
||||
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
|
||||
import {
|
||||
advanceHomeworldFunding, allowHomeworldFundingResponses as allow, chooseHomeworldFunding as choose,
|
||||
@@ -60,7 +59,6 @@ for (const advanced of [false, true]) for (const kind of ['auction', 'shipment']
|
||||
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) for (const redirect of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} ${payerShare}+${donorShare} ${redirect ? 'redirects two' : 'declines'} with full debits and original continuation`, () => {
|
||||
const f = fixture({ kind, advanced, payerShare, donorShare });
|
||||
assert.equal(bureaucratAlliedFundingModeSupported(f.game), true);
|
||||
assert.equal(player(f.alliance, f.payer).ally, f.donor);
|
||||
assert.equal(player(f.alliance, f.donor).ally, f.payer);
|
||||
assert.equal(player(f.beforeAction, f.donor).spice, player(f.beforePledge, f.donor).spice - donorShare);
|
||||
|
||||
@@ -9,8 +9,9 @@ import { territory } from '../game/board';
|
||||
import {
|
||||
advancePairedIxNexusSkills, createPairedIxNexusSkillsFixture,
|
||||
finishPairedIxNexusSkillsBattle, pairedIxNexusSkillsPlayer,
|
||||
revealPairedIxNexusSkillsBattle, type PairedIxNexusSkillsFixture,
|
||||
revealPairedIxNexusSkillsBattle, type PairedIxSkillsFixture,
|
||||
} from './fixture-paired-ix-nexus-skills';
|
||||
import { skillOfferRolls } from './fixture-independent-card-modules';
|
||||
|
||||
export type BureaucratE1FundingStep = { actor: string; action: Action };
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
@@ -25,8 +26,9 @@ export const BUREAUCRAT_E1_REVIVAL_ROSTER = ['ixians', 'tleilaxu', 'atreides', '
|
||||
export const BUREAUCRAT_E1_REVIVAL_TRAINER = 'harkonnen';
|
||||
|
||||
/** Original 47/14/12 setup → real Storm → real Mentat → conserved worm →
|
||||
* real alliance → real closing draws. Only the native all14 shuffle is scoped;
|
||||
* wallets, phases, assignments and armies are never assigned. */
|
||||
* real alliance and, unless the Nexus Cards module is omitted, its real closing
|
||||
* native draws. Only the native all14 shuffle is scoped; wallets, phases,
|
||||
* assignments and armies are never assigned. */
|
||||
function entropy<T>(operation: () => T, rolls: number[] = []): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
@@ -60,33 +62,39 @@ function skillRolls(selected: LeaderSkillId[]): number[] {
|
||||
return shuffleRolls(source, [...desired, ...remainder]);
|
||||
}
|
||||
/** Only the native all14 shuffle order is scoped, so the named classic seat can
|
||||
* genuinely select the Bureaucrat card from its own original offer. */
|
||||
* genuinely select the Bureaucrat card from its own original offer. The default
|
||||
* selects the original Nexus Cards module; `nexus: false` instead selects the
|
||||
* ordinary Leader Skills module in the real lobby and starts it normally. */
|
||||
function pairedE1Initial(
|
||||
code: string,
|
||||
factions: readonly string[],
|
||||
trainer: string,
|
||||
skills: Record<string, LeaderSkillId>,
|
||||
advanced: boolean,
|
||||
nexus: boolean,
|
||||
): Game {
|
||||
let game = createGame(code, newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction as FactionId));
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
if (nexus) game.nexusCards ??= { cards: null, phase: null };
|
||||
else game = applyAction(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
assert.ok(factions.includes(trainer) && skills[trainer] === 'bureaucrat' && skills.ixians === 'suk-graduate');
|
||||
const selected = game.players.map(p => skills[p.faction]);
|
||||
return entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected));
|
||||
return nexus ? entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected))
|
||||
: entropy(() => applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }),
|
||||
skillOfferRolls(game, skills));
|
||||
}
|
||||
/** Build the shared paired E1 fixture with the native skills requested. The
|
||||
* shared helper assigns each seat the first card of its genuine offer. */
|
||||
function pairedE1Skills(factions: readonly string[], trainer: string, advanced: boolean): PairedIxNexusSkillsFixture {
|
||||
function pairedE1Skills(factions: readonly string[], trainer: string, advanced: boolean, nexus = true): PairedIxSkillsFixture {
|
||||
const skills: Record<string, LeaderSkillId> = {
|
||||
ixians: 'suk-graduate', tleilaxu: 'sandmaster', guild: 'smuggler',
|
||||
emperor: 'rihani-decipherer', atreides: 'prana-bindu-adept', harkonnen: 'killer-medic',
|
||||
[trainer]: 'bureaucrat',
|
||||
};
|
||||
const fixture = createPairedIxNexusSkillsFixture({
|
||||
initial: pairedE1Initial(`BUREAUCRATE1${factions.length}${advanced ? 'A' : 'B'}`, factions, trainer, skills, advanced),
|
||||
advanced, skill: 'suk-graduate',
|
||||
initial: pairedE1Initial(`BUREAUCRATE1${factions.length}${advanced ? 'A' : 'B'}`, factions, trainer, skills, advanced, nexus),
|
||||
advanced, nexus, skill: 'suk-graduate',
|
||||
});
|
||||
assert.equal(fixture.afterSetup.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, trainer);
|
||||
return fixture;
|
||||
@@ -107,9 +115,9 @@ export function chooseBureaucratE1Payment(game: Game, redirect = true): Game {
|
||||
}
|
||||
|
||||
export interface BureaucratE1FundingBase {
|
||||
fixture: PairedIxNexusSkillsFixture;
|
||||
fixture: PairedIxSkillsFixture;
|
||||
actions: BureaucratE1FundingStep[];
|
||||
/** Turn-2 Nexus window before the requested real alliance forms. */
|
||||
/** Turn-2 worm spice-blow window before the requested real alliance forms. */
|
||||
worm: Game;
|
||||
/** Genuine mutual alliance between the payer and the requested donor. */
|
||||
alliance: Game;
|
||||
@@ -123,14 +131,15 @@ export interface BureaucratE1FundingBase {
|
||||
guild: string;
|
||||
emperor: string;
|
||||
}
|
||||
/** Genuine paired E1 setup with skills, a real worm Nexus and a real mutual
|
||||
* alliance between the payer and the requested donor. Never assigns wallets,
|
||||
* armies, phase, assignment or alliance state. */
|
||||
/** Genuine paired E1 setup with skills, the real turn-two worm window and a real
|
||||
* mutual alliance between the payer and the requested donor; the original Nexus
|
||||
* Cards module is selected by default and omitted by `nexus: false`. Never
|
||||
* assigns wallets, armies, phase, assignment or alliance state. */
|
||||
export function createBureaucratE1FundingBase({
|
||||
advanced = false, payer = 'harkonnen', donor = 'ixians',
|
||||
}: { advanced?: boolean; payer?: string; donor?: string } = {}): BureaucratE1FundingBase {
|
||||
advanced = false, nexus = true, payer = 'harkonnen', donor = 'ixians',
|
||||
}: { advanced?: boolean; nexus?: boolean; payer?: string; donor?: string } = {}): BureaucratE1FundingBase {
|
||||
assert.notEqual(payer, donor);
|
||||
const fixture = pairedE1Skills(BUREAUCRAT_E1_ROSTER, BUREAUCRAT_E1_TRAINER, advanced);
|
||||
const fixture = pairedE1Skills(BUREAUCRAT_E1_ROSTER, BUREAUCRAT_E1_TRAINER, advanced, nexus);
|
||||
const actions: BureaucratE1FundingStep[] = [];
|
||||
let game = structuredClone(fixture.beforeAlliance);
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
|
||||
@@ -178,9 +187,9 @@ export function bureaucratE1PhysicalCards(game: Game): string[] {
|
||||
}
|
||||
|
||||
export function createBureaucratE1AuctionFixture({
|
||||
advanced = false, payerShare = 5, donorShare = 3, donor = 'ixians',
|
||||
}: { advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string } = {}): BureaucratE1AuctionFixture {
|
||||
const base = createBureaucratE1FundingBase({ advanced, donor });
|
||||
advanced = false, nexus = true, payerShare = 5, donorShare = 3, donor = 'ixians',
|
||||
}: { advanced?: boolean; nexus?: boolean; payerShare?: number; donorShare?: number; donor?: string } = {}): BureaucratE1AuctionFixture {
|
||||
const base = createBureaucratE1FundingBase({ advanced, nexus, donor });
|
||||
const actions = [...base.actions];
|
||||
let game = structuredClone(base.game);
|
||||
for (let n = 0; game.auction!.active !== base.payer && n < game.players.length + 2; n++) {
|
||||
@@ -239,9 +248,9 @@ export interface BureaucratE1ShipmentFixture extends BureaucratE1FundingBase {
|
||||
}
|
||||
|
||||
export function createBureaucratE1ShipmentFixture({
|
||||
advanced = false, primary = 5, donor = 'ixians', pledge = 3,
|
||||
}: { advanced?: boolean; primary?: number; donor?: string; pledge?: number } = {}): BureaucratE1ShipmentFixture {
|
||||
const base = createBureaucratE1FundingBase({ advanced, donor });
|
||||
advanced = false, nexus = true, primary = 5, donor = 'ixians', pledge = 3,
|
||||
}: { advanced?: boolean; nexus?: boolean; primary?: number; donor?: string; pledge?: number } = {}): BureaucratE1ShipmentFixture {
|
||||
const base = createBureaucratE1FundingBase({ advanced, nexus, donor });
|
||||
const actions = [...base.actions];
|
||||
let game = structuredClone(base.game);
|
||||
const payer = base.payer;
|
||||
@@ -285,7 +294,7 @@ export function declareBureaucratE1Shipment(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BureaucratE1RevivalFixture {
|
||||
fixture: PairedIxNexusSkillsFixture;
|
||||
fixture: PairedIxSkillsFixture;
|
||||
actions: BureaucratE1FundingStep[];
|
||||
staging: string[];
|
||||
game: Game;
|
||||
@@ -318,9 +327,9 @@ function holdE1Card(
|
||||
* to the tanks, then Tleilaxu's native expanded-limit benefit genuinely lets a
|
||||
* five-force paid revival exceed five spice. Never assigns wallets or tanks. */
|
||||
export function createBureaucratE1RevivalFixture({
|
||||
advanced = false, payer = 'atreides', amount = 5, counter = false,
|
||||
}: { advanced?: boolean; payer?: string; amount?: number; counter?: boolean } = {}): BureaucratE1RevivalFixture {
|
||||
const fixture = pairedE1Skills(BUREAUCRAT_E1_REVIVAL_ROSTER, BUREAUCRAT_E1_REVIVAL_TRAINER, advanced);
|
||||
advanced = false, nexus = true, payer = 'atreides', amount = 5, counter = false,
|
||||
}: { advanced?: boolean; nexus?: boolean; payer?: string; amount?: number; counter?: boolean } = {}): BureaucratE1RevivalFixture {
|
||||
const fixture = pairedE1Skills(BUREAUCRAT_E1_REVIVAL_ROSTER, BUREAUCRAT_E1_REVIVAL_TRAINER, advanced, nexus);
|
||||
const actions: BureaucratE1FundingStep[] = [];
|
||||
const staging: string[] = [];
|
||||
assert.notEqual(payer, BUREAUCRAT_E1_REVIVAL_TRAINER);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
advancePairedE3Nexus, createPairedE3NexusSetup, pairedE3NexusClean,
|
||||
pairedE3NexusInventory, pairedE3NexusPlayer, pairedE3NexusReload,
|
||||
quietPairedE3NexusChoice, stepPairedE3Nexus,
|
||||
type PairedE3NexusOptions,
|
||||
} from './fixture-paired-e3-nexus-modules';
|
||||
|
||||
export type BureaucratE3FundingStep = { actor: string; action: Action };
|
||||
@@ -13,10 +14,12 @@ export const BUREAUCRAT_E3_ROSTER = ['ecaz', 'moritani', 'guild', 'atreides', 'e
|
||||
/** The single classic seat trained with the native Bureaucrat skill. */
|
||||
export const BUREAUCRAT_E3_TRAINER = 'atreides';
|
||||
|
||||
export type BureaucratE3FundingOptions = Pick<PairedE3NexusOptions,
|
||||
'advanced' | 'nexus' | 'families' | 'tech' | 'strongholds'>;
|
||||
export interface BureaucratE3FundingBase {
|
||||
actions: BureaucratE3FundingStep[];
|
||||
staging: string[];
|
||||
/** Turn-2 Spice blow Nexus window: real alliances may still form here. */
|
||||
/** Turn-2 real worm alliance window, independent of optional Nexus Cards. */
|
||||
worm: Game;
|
||||
/** Turn-2 Bidding boundary with the requested real mutual alliance formed. */
|
||||
game: Game;
|
||||
@@ -61,14 +64,14 @@ function holdE3Card(
|
||||
return incoming.id;
|
||||
}
|
||||
|
||||
/** Genuine paired E3 setup with skills, a real turn-2 worm Nexus and a real
|
||||
/** Genuine paired E3 setup with skills, a real turn-2 worm alliance window and a
|
||||
* mutual alliance between the payer and the requested donor. Never assigns
|
||||
* wallets, armies, phase, assignment or alliance state. */
|
||||
export function createBureaucratE3FundingBase(
|
||||
{ advanced = false, donor = 'moritani' }: { advanced?: boolean; donor?: string } = {},
|
||||
{ donor = 'moritani', ...options }: BureaucratE3FundingOptions & { donor?: string } = {},
|
||||
): BureaucratE3FundingBase {
|
||||
const options = { roster: [...BUREAUCRAT_E3_ROSTER], advanced, skills: true };
|
||||
const setup = createPairedE3NexusSetup(options);
|
||||
const advanced = options.advanced ?? false;
|
||||
const setup = createPairedE3NexusSetup({ ...options, advanced, roster: [...BUREAUCRAT_E3_ROSTER], skills: true });
|
||||
assert.ok(setup.afterSetup.leaderSkills!.assignments.some(
|
||||
(a) => a.owner === BUREAUCRAT_E3_TRAINER && a.skill === 'bureaucrat' && a.leader,
|
||||
));
|
||||
@@ -145,11 +148,11 @@ export function bureaucratE3PhysicalCards(game: Game): string[] {
|
||||
}
|
||||
|
||||
export function createBureaucratE3AuctionFixture({
|
||||
advanced = false, payerShare = 5, donorShare = 3, donor = 'moritani', counter = false,
|
||||
}: {
|
||||
advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string; counter?: boolean;
|
||||
payerShare = 5, donorShare = 3, donor = 'moritani', counter = false, ...options
|
||||
}: BureaucratE3FundingOptions & {
|
||||
payerShare?: number; donorShare?: number; donor?: string; counter?: boolean;
|
||||
} = {}): BureaucratE3AuctionFixture {
|
||||
const base = createBureaucratE3FundingBase({ advanced, donor });
|
||||
const base = createBureaucratE3FundingBase({ ...options, donor });
|
||||
const actions = [...base.actions];
|
||||
const staging = [...base.staging];
|
||||
let game = pairedE3NexusReload(base.game);
|
||||
@@ -208,33 +211,38 @@ export interface BureaucratE3ShipmentFixture extends BureaucratE3FundingBase {
|
||||
donorAmount: number;
|
||||
amount: number;
|
||||
payee: string;
|
||||
cost: number;
|
||||
counterCard?: string;
|
||||
physical: string[];
|
||||
}
|
||||
|
||||
export function createBureaucratE3ShipmentFixture({
|
||||
advanced = false, primary = 5, partner = 'moritani', pledge = 3,
|
||||
}: {
|
||||
advanced?: boolean; primary?: number; partner?: string; pledge?: number;
|
||||
primary = 5, partner = 'moritani', pledge = 3, target = 'habbanya_ridge_sietch', counter = false, ...options
|
||||
}: BureaucratE3FundingOptions & {
|
||||
primary?: number; partner?: string; pledge?: number;
|
||||
target?: 'habbanya_ridge_sietch' | 'habbanya_erg'; counter?: boolean;
|
||||
} = {}): BureaucratE3ShipmentFixture {
|
||||
const base = createBureaucratE3FundingBase({ advanced, donor: partner });
|
||||
const base = createBureaucratE3FundingBase({ ...options, donor: partner });
|
||||
const actions = [...base.actions];
|
||||
const staging = [...base.staging];
|
||||
let game = pairedE3NexusReload(base.game);
|
||||
game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 5 && s.active === base.payer && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
|
||||
assert.equal(game.turn, 2);
|
||||
const target = 'habbanya_ridge_sietch';
|
||||
assert.ok(game.players.every((p) => !Object.keys(p.forces).some((key) => key.startsWith(target))));
|
||||
const physical = bureaucratE3PhysicalCards(game);
|
||||
const counterCard = counter ? holdE3Card(game, base.trainer, (card) => card.effect === 'karama', staging) : undefined;
|
||||
const beforePledge = pairedE3NexusReload(game);
|
||||
const aid: Action = { type: 'pledgeAid', amount: pledge };
|
||||
actions.push({ actor: partner, action: aid });
|
||||
game = applyAction(game, partner, aid);
|
||||
assert.equal(game.aid[partner].amount, pledge);
|
||||
const beforeAction = pairedE3NexusReload(game);
|
||||
const amount = primary + pledge;
|
||||
const cost = primary + pledge;
|
||||
const amount = cost / (target === 'habbanya_erg' ? 2 : 1);
|
||||
assert.ok(Number.isInteger(amount) && amount > 0, 'The declared shares pay the exact original shipment cost');
|
||||
const action: Action = { type: 'ship', territory: target, sector: 17, amount, elite: 0, allyPayment: pledge };
|
||||
return { ...base, actions, staging, game, physical,
|
||||
beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, payee: base.guild };
|
||||
return { ...base, actions, staging, game, physical, counterCard,
|
||||
beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, cost, payee: base.guild };
|
||||
}
|
||||
|
||||
/** Declare once. In Advanced the original Guild interception commits the ship. */
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface PairedE3NexusOptions {
|
||||
initial?: Game;
|
||||
roster?: readonly FactionId[];
|
||||
advanced?: boolean;
|
||||
nexus?: boolean;
|
||||
/** Additional ordinary decks selected before setup; no extra faction seat. */
|
||||
families?: readonly ('ix' | 'choam')[];
|
||||
skills?: boolean;
|
||||
training?: Partial<Record<FactionId, LeaderSkillId>>;
|
||||
homeworlds?: boolean;
|
||||
@@ -145,7 +148,7 @@ function selectedSkills(g: Game, training?: PairedE3NexusOptions['training']): L
|
||||
return g.players.map(p => training?.[p.faction] ?? preferred[p.faction]!);
|
||||
}
|
||||
function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] {
|
||||
const original = traitorDeck(g.players), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null;
|
||||
const original = traitorDeck(g.players, g.expansions.includes('ix')), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null;
|
||||
const source = original.filter(c => c !== loyal), desired = [...source];
|
||||
const target = faction === 'ecaz' ? source.find(card => leaders('ecaz').some(l => l.id === card))
|
||||
: leaders('guild').find(l => l.name === 'Master Bewt')?.id;
|
||||
@@ -160,16 +163,20 @@ function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] {
|
||||
export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): PairedE3NexusSetup {
|
||||
const roster = options.roster ?? options.initial?.players.map(p => p.faction) ?? ['ecaz', 'moritani', 'guild', 'emperor'];
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const nexus = options.nexus ?? true;
|
||||
const families = options.families ?? options.initial?.expansions.filter((e): e is 'ix' | 'choam' => e === 'ix' || e === 'choam') ?? [];
|
||||
let g = options.initial ? pairedE3NexusReload(options.initial)
|
||||
: createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
|
||||
: createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]);
|
||||
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
|
||||
assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.equal(g.advanced, advanced); assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort());
|
||||
assert.equal(new Set(families).size, families.length);
|
||||
assert.deepEqual(g.players.map(p => p.faction), roster);
|
||||
assert.ok(g.players.length >= 2 && g.players.length <= 6 && new Set(roster).size === roster.length);
|
||||
assert.ok(roster.includes('ecaz') && roster.includes('moritani'));
|
||||
assert.ok(roster.every(f => ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
|
||||
...(advanced ? [] : ['harkonnen'])].includes(f)));
|
||||
const skills = options.skills ?? (options.initial ? !!g.leaderSkills : false);
|
||||
assert.ok(nexus || skills, 'The no-Nexus route keeps the original all14 Skills module');
|
||||
const discovery = options.discovery ?? !!g.discoveryEnabled;
|
||||
const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds,
|
||||
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
|
||||
@@ -181,14 +188,27 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa
|
||||
if (g.status === 'lobby') {
|
||||
for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled)
|
||||
g = stepPairedE3Nexus(g, { actor: g.host, action: { type, enabled } }, actions);
|
||||
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
|
||||
g.discoveryEnabled = discovery;
|
||||
if (nexus) g.nexusCards ??= { cards: null, phase: null };
|
||||
else {
|
||||
assert.equal(g.nexusCards, undefined, 'Never erase a selected or played Nexus component');
|
||||
g = stepPairedE3Nexus(g, { actor: g.host,
|
||||
action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }, actions);
|
||||
}
|
||||
for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
if (options.initial) g = skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g);
|
||||
const initialize = () => {
|
||||
if (nexus) return skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g);
|
||||
const step: PairedE3NexusStep = { actor: g.host,
|
||||
action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
|
||||
actions.push(structuredClone(step));
|
||||
return applyAction(pairedE3NexusReload(g), step.actor, step.action);
|
||||
};
|
||||
if (options.initial) g = initialize();
|
||||
else {
|
||||
const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g, options.training);
|
||||
const rest = sourceSkills.filter(c => !selected.includes(c));
|
||||
const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const cards = treacheryDeck(['ecaz']), remaining = [...cards];
|
||||
const cards = treacheryDeck(g.expansions), remaining = [...cards];
|
||||
const prefix = g.players.flatMap(p => Array.from({ length: p.faction === 'harkonnen' ? 2 : 1 }, () => {
|
||||
const worthless = remaining.findIndex(c => c.kind === 'worthless');
|
||||
const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield'); assert.ok(at >= 0);
|
||||
@@ -196,16 +216,17 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa
|
||||
}));
|
||||
const rolls = [...(skills ? permutation(sourceSkills, skillOrder) : []),
|
||||
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
|
||||
...(nexus ? Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff) : []),
|
||||
...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)]),
|
||||
...Array<number>(spiceDeck().length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff),
|
||||
...Array<number>(spiceDeck(g.expansions.includes('ix')).length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff),
|
||||
...(!skills && !roster.includes('beneGesserit') ? traitorRolls(g, options.assassinationFaction) : [])];
|
||||
g = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g));
|
||||
g = lottery(rolls, initialize);
|
||||
}
|
||||
}
|
||||
assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery);
|
||||
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled);
|
||||
assert.ok(g.nexusCards?.cards);
|
||||
if (nexus) assert.ok(g.nexusCards?.cards);
|
||||
else assert.equal(g.nexusCards, undefined);
|
||||
const offered = pairedE3NexusReload(g), selected = selectedSkills(g, options.training);
|
||||
for (let n = 0; g.status === 'setup' && n < 300; n++) {
|
||||
let next: PairedE3NexusStep;
|
||||
@@ -279,6 +300,11 @@ export function createPairedE3NexusProgramme(options: PairedE3NexusOptions = {})
|
||||
for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]])
|
||||
g = stepPairedE3Nexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = pairedE3NexusReload(g);
|
||||
if (!g.nexusCards) {
|
||||
g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
|
||||
pairedE3NexusInventory(g);
|
||||
return { ...f, game: g, alliance, drawing: null, draws: [], maker, claim, firstMentat };
|
||||
}
|
||||
g = advancePairedE3Nexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice);
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = ['ecaz', 'moritani', ...cards.deck.filter(c => c !== 'ecaz' && c !== 'moritani')];
|
||||
@@ -350,10 +376,12 @@ export function quotePairedE3NexusBattle(g: Game): BattleResolutionQuote {
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true });
|
||||
}
|
||||
export function pairedE3NexusInventory(g: Game): void {
|
||||
const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players);
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
if (g.nexusCards) {
|
||||
const nx = g.nexusCards.cards!; validateNexusCards(nx, g.players);
|
||||
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());
|
||||
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(g.expansions).map(c => c.id).sort());
|
||||
assert.equal(g.richeseCache?.length ?? 0, 0);
|
||||
if (g.leaderSkills) {
|
||||
validateLeaderSkills(g.leaderSkills, g.players);
|
||||
@@ -372,7 +400,7 @@ export function pairedE3NexusInventory(g: Game): void {
|
||||
}
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
|
||||
assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired,
|
||||
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players).sort());
|
||||
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players, g.expansions.includes('ix')).sort());
|
||||
assert.equal(pairedE3NexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
|
||||
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID); validateAmbassadors(g.ecazAmbassadors!);
|
||||
assert.deepEqual(g.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
import { territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { pairedNexusLeaderSkillsProfile, selectedDeckLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { skillOfferRolls } from './fixture-independent-card-modules';
|
||||
|
||||
export type PairedIxNexusSkillsStep = { actor: string; action: Action };
|
||||
export type PairedIxNexusSkillsOptions = {
|
||||
@@ -17,6 +18,9 @@ export type PairedIxNexusSkillsOptions = {
|
||||
* Existing first hands, offers, actor IDs and stock are authoritative. */
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
/** Default selects the original Nexus Cards module; `false` starts the same
|
||||
* roster and all14 lottery from the plain Leader Skills module instead. */
|
||||
nexus?: boolean;
|
||||
program?: 'suboids' | 'faceDance';
|
||||
skill?: LeaderSkillId;
|
||||
band?: 'normal' | 'skilled';
|
||||
@@ -56,6 +60,9 @@ export type PairedIxNexusSkillsFixture = {
|
||||
actions: PairedIxNexusSkillsStep[];
|
||||
staging: string[];
|
||||
};
|
||||
/** The no-Nexus producer has no drawing frame at all: the three Nexus-draw
|
||||
* fields are genuinely absent rather than empty or fabricated. */
|
||||
export type PairedIxSkillsFixture = Omit<PairedIxNexusSkillsFixture, 'beforeNexusDraw' | 'drawActions' | 'afterNexusDraw'>;
|
||||
export function pairedIxNexusSkillsPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player, `Missing original E1 seat ${actor}`);
|
||||
@@ -174,10 +181,17 @@ function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]):
|
||||
staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`);
|
||||
}
|
||||
/** Original 47/14/12 native setup → Storm → real Mentat → conserved worm →
|
||||
* real alliance → real closing native draws. Board/card relocation is explicit;
|
||||
* wallets, phases, turns, effects, stock and sealed plans are never assigned. */
|
||||
export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions = {}): PairedIxNexusSkillsFixture {
|
||||
* real alliance → real closing native draws. The original Nexus Cards module is
|
||||
* selected by default; `nexus: false` starts the identical roster and all14
|
||||
* lottery from the plain module, so no Nexus state is fabricated or erased.
|
||||
* Board/card relocation is explicit; wallets, phases, turns, effects, stock and
|
||||
* sealed plans are never assigned. */
|
||||
export function createPairedIxNexusSkillsFixture(options?: PairedIxNexusSkillsOptions & { nexus?: true }): PairedIxNexusSkillsFixture;
|
||||
export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions & { nexus: false }): PairedIxSkillsFixture;
|
||||
export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions): PairedIxSkillsFixture;
|
||||
export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions = {}): PairedIxSkillsFixture {
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const nexus = options.nexus ?? true;
|
||||
const program = options.program ?? 'suboids', band = options.band ?? 'skilled';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('PAIREDIXNEXUSSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
@@ -202,16 +216,30 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt
|
||||
const initial = structuredClone(game);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.leaderSkills, 'Original all14 custody cannot be replaced');
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
if (nexus) {
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
} else {
|
||||
assert.equal(game.nexusCards, undefined, 'A no-Nexus programme must begin without selected or played Nexus Cards');
|
||||
const step: PairedIxNexusSkillsStep = { actor: game.host,
|
||||
action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } };
|
||||
actions.push(step); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
for (const p of game.players) if (!p.ready) {
|
||||
const step: PairedIxNexusSkillsStep = { actor: p.id, action: { type: 'ready' } };
|
||||
actions.push(step); game = stepPairedIxNexusSkills(game, step);
|
||||
}
|
||||
game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected));
|
||||
if (nexus) game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected));
|
||||
else {
|
||||
const training = Object.fromEntries(game.players.map((p, index) => [p.faction, selected[index]]));
|
||||
const step: PairedIxNexusSkillsStep = { actor: game.host,
|
||||
action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
|
||||
actions.push(step); game = entropy(() => applyAction(game, step.actor, step.action), skillOfferRolls(game, training));
|
||||
}
|
||||
staging.push('Scoped original all14 shuffle selects native offers before starting Treachery deals; no offers are replaced');
|
||||
}
|
||||
assert.ok(pairedNexusLeaderSkillsProfile(game));
|
||||
assert.ok(nexus ? pairedNexusLeaderSkillsProfile(game) : selectedDeckLeaderSkillsProfile(game));
|
||||
if (!nexus) assert.equal(game.nexusCards, undefined);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.equal(game.leaderSkills!.assignments.length, 0);
|
||||
let offered = structuredClone(game), trainer = '';
|
||||
@@ -255,7 +283,9 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
orderSpice(game, ['land', 'land'], staging);
|
||||
game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions);
|
||||
reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion');
|
||||
if (nexus) {
|
||||
reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion');
|
||||
}
|
||||
game = advancePairedIxNexusSkills(game, g => g.phase === 8 && clean(g), actions);
|
||||
let beforeFirstMentat!: Game, firstMentatStep!: PairedIxNexusSkillsStep;
|
||||
while (game.turn === 1) {
|
||||
@@ -270,17 +300,20 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt
|
||||
const beforeAlliance = structuredClone(game);
|
||||
const allianceActions: PairedIxNexusSkillsStep[] = partners.map((actor, i) => ({ actor, action: { type: 'alliance', target: partners[1 - i] } }));
|
||||
for (const step of allianceActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
|
||||
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu'));
|
||||
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')];
|
||||
staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands');
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
const drawActions: PairedIxNexusSkillsStep[] = [ixians, tleilaxu].map(actor => ({ actor,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }));
|
||||
for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
|
||||
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
const afterNexusDraw = structuredClone(game);
|
||||
let drawing: Pick<PairedIxNexusSkillsFixture, 'beforeNexusDraw' | 'drawActions' | 'afterNexusDraw'> | undefined;
|
||||
if (nexus) {
|
||||
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu'));
|
||||
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')];
|
||||
staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands');
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
const drawActions: PairedIxNexusSkillsStep[] = [ixians, tleilaxu].map(actor => ({ actor,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }));
|
||||
for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
|
||||
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions);
|
||||
drawing = { beforeNexusDraw, drawActions, afterNexusDraw: structuredClone(game) };
|
||||
}
|
||||
game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions);
|
||||
reserveBoard(game);
|
||||
const kind = program === 'faceDance' ? 'sietch_tabr' : 'arrakeen';
|
||||
@@ -311,11 +344,11 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
|
||||
beforeAlliance, allianceActions, beforeNexusDraw, drawActions, afterNexusDraw, beforeBattle, battleStep,
|
||||
beforeAlliance, allianceActions, ...drawing, beforeBattle, battleStep,
|
||||
game, ixians, tleilaxu, opponent, partners, trainer, skill, band, program, kind, location, replacementSource,
|
||||
secondLocation, planActions, actions, staging };
|
||||
}
|
||||
export function preparePairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture, state = f.game): Game {
|
||||
export function preparePairedIxNexusSkillsBattle(f: PairedIxSkillsFixture, state = f.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let i = 0; i < 160; i++) {
|
||||
const step = nextPairedIxNexusSkillsStep(game);
|
||||
@@ -325,12 +358,12 @@ export function preparePairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture,
|
||||
}
|
||||
throw Error('Paired E1 native skill posture did not reach private plans');
|
||||
}
|
||||
export function beginPairedIxNexusSkillsCunning(f: PairedIxNexusSkillsFixture, state = preparePairedIxNexusSkillsBattle(f)): Game {
|
||||
export function beginPairedIxNexusSkillsCunning(f: PairedIxSkillsFixture, state = preparePairedIxNexusSkillsBattle(f)): Game {
|
||||
const offer = viewGame(state, f.ixians).nexusSuboids?.offer;
|
||||
assert.ok(offer); assert.equal(offer.blocked, null);
|
||||
return stepPairedIxNexusSkills(state, { actor: f.ixians, action: { type: 'nexusSuboids', event: offer.event } });
|
||||
}
|
||||
export function revealPairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture,
|
||||
export function revealPairedIxNexusSkillsBattle(f: PairedIxSkillsFixture,
|
||||
options: { state?: Game; cunning?: boolean; plans?: PairedIxNexusSkillsStep[] } = {}): Game {
|
||||
let game = preparePairedIxNexusSkillsBattle(f, options.state ?? f.game);
|
||||
if (options.cunning) game = advancePairedIxNexusSkills(beginPairedIxNexusSkillsCunning(f, game), clean);
|
||||
@@ -348,14 +381,14 @@ export function finishPairedIxNexusSkillsBattle(state: Game,
|
||||
export function finishPairedIxNexusSkillsTurn(state: Game): Game {
|
||||
return advancePairedIxNexusSkills(state, g => g.turn > state.turn);
|
||||
}
|
||||
export function choosePairedIxNexusSkillsSecondBattle(f: PairedIxNexusSkillsFixture, state: Game): Game {
|
||||
export function choosePairedIxNexusSkillsSecondBattle(f: PairedIxSkillsFixture, state: Game): Game {
|
||||
assert.ok(f.secondLocation);
|
||||
let game = advancePairedIxNexusSkills(state, g => g.phase === 6 && clean(g) && !!g.active);
|
||||
const actor = game.active!; assert.ok(actor === f.ixians || actor === f.opponent);
|
||||
game = stepPairedIxNexusSkills(game, { actor, action: { type: 'chooseBattle', territory: 'carthag', target: actor === f.ixians ? f.opponent : f.ixians } });
|
||||
return preparePairedIxNexusSkillsBattle(f, game);
|
||||
}
|
||||
export function preparePairedIxNexusSkillsExpiryBattle(f: PairedIxNexusSkillsFixture, state: Game): Game {
|
||||
export function preparePairedIxNexusSkillsExpiryBattle(f: PairedIxSkillsFixture, state: Game): Game {
|
||||
let game = advancePairedIxNexusSkills(state, g => g.phase === 5 && clean(g));
|
||||
reserveBoard(game); place(game, f.ixians, f.location, 3); place(game, f.tleilaxu, f.location, 2);
|
||||
game = advancePairedIxNexusSkills(game, g => g.phase === 6 && clean(g));
|
||||
|
||||
Reference in new issue
Block a user