fix: unify native Bureaucrat allied funding receipts

This commit is contained in:
Quintine committed 2026-10-18 01:44:24 +11:00
1 parent 60d1bd3b59
commit 06342ad34c
15 files changed
+478 -156

No files matched your search

+65 -10
View File
@@ -33,12 +33,13 @@ A verbal promise alone transfers no spice.
## Connected boundary and explicit interpretations
The connected paths are ordinary paid auctions, Guild shipment income, direct
Richese auction proceeds and actual bribe transfers in the base or CHOAM-only
skill profile. Basic Tleilaxu with Leader Skills and base opponents additionally
supports a single payer's actual paid revival price to Tleilaxu. Other expansion
payment sources remain outside this batch. Emperor gifts are excluded because
the user ruled on 11 October that gifts are not payments.
The connected sources are ordinary paid auctions, native Emperor/Guild and
Richese seller receipts, actual bribe transfers and the original paid Tleilaxu
revival price in admitted Leader Skills compositions. A separate free-revival
award is not part of that price. Registered all14 setups use the same receipt
capability for single and allied payers; optional-module and Advanced role
admission remains separate. Emperor gifts are excluded because the user ruled
on 11 October that gifts are not payments.
### User ruling — 11 October 2026
@@ -93,10 +94,12 @@ Allied funding is evaluated per actual payer, not by adding different payers'
shares to create the five-spice trigger. A three-plus-three payment, or
four-plus-three, has no eligible payer and proceeds through original funding,
payment, card/force commitment and recipient income without a Bureaucrat offer.
In supported classic, E1, E2 and E3 profiles without Homeworlds, individually
qualifying allied-funded auction and shipment receipts now offer one optional
redirection:
five-plus-three, three-plus-five, five-plus-five and fully ally-funded five.
In admitted classic and native E1/E2/E3 Skills compositions, individually
qualifying allied-funded auction and shipment receipts offer one optional
redirection: five-plus-three, three-plus-five, five-plus-five and fully
ally-funded five. This does not require incidental Nexus Cards or one exact
selected family. Original World and phase-module receipts retain their native
allocation and callbacks.
The receipt loses exactly two, never two per contributing payer. A share paid
by the trainer or recipient cannot qualify, but does not mask a different
eligible payer. Homeworld allied and split-recipient income is connected under
@@ -448,3 +451,55 @@ unchanged, and only the redirection consumes the skill. This is explicitly stage
UI evidence. After the reported outage, all 699 opening rooms and their original
seat/recovery records matched the private backup; database integrity and foreign
keys passed. The healthy server was reused.
## Registered native allied receipts — 18 October 2026
The ordinary Skills receipt capability now governs both single and allied
funding. The separate allied-only classifier is removed, not aliased. Registered
selected-family all14 tables no longer need incidental Nexus Cards or one exact
family to use an otherwise supported auction or shipment receipt. Original
World/phase allocation, module admission and Advanced role limits stay separate.
This supersedes the narrower **dated 13 October scope bounds** above, not the
printed card, explicit user rulings or original economic callbacks.
Before repair, a genuine Basic Ecaz/Moritani/Guild/Atreides/Emperor plain-Skills
table formed a real turn-two worm alliance. Moritani pledged five from twelve;
Ecaz's five-force nonstronghold shipment cost ten, paid five plus that escrow.
The extra allied guard rejected it byte-identically. After repair, Ecaz and
Moritani retain seven each, five forces arrive once, Ecaz reserves are nine,
and the escrow is consumed once. Redirecting gives Guild eight, from five to
thirteen; the Atreides trainer gains nothing and original Movement resumes.
The shared individual-payer threshold, trainer/payee exclusions, private
contribution legs, two-spice Bank diversion and once-per-phase use are unchanged.
Native income cancellation still resolves first and creates no Bureaucrat
offer/use. Declining keeps the later phase opportunity. No revival price,
free-revival award, Banker invoice, gift classification or occupied split changes.
The new E1 plain producer retains actual starting armies before funding:
HMS six, Carthag ten, Arrakeen ten and Tuek's five. Its older Nexus battle
producer's conserved board-return staging is retained only in that legacy path;
it is not claimed as natural native movement. A real Advanced no-Nexus shipment
still opens native Guild interception. An eight-force shipment paid three plus
Ixian escrow five leaves Harkonnen nine spice/two reserves with Carthag ten
untouched, and Ixians five spice/HMS six untouched. All four legal policies
redirect the eligible receipt, give Guild six (six to twelve), and resume the
next real movement seat.
Three actual390px GameTable flows use isolated memory-only original producers:
Basic cost-ten/five-force shipment passes native Karama allowance and redirects
two, then reaches Collection; Advanced extra-selected-CHOAM auction three-plus-five
allows the full eight (Emperor ten to eighteen, no use, original next lot at
Guild); a fresh original counter branch cancels that income (Emperor stays ten,
no offer/use, original next lot, original Karama consumed). Prompts show gross,
recipient and payer-null without contribution legs. Browser errors and horizontal
overflow are absent. Temporary routes were removed and their memory released204;
no stored room, dev-server restart, deployment or assurance campaign is involved.
Frozen17-file **442/442**, focused original nonstronghold1/1 and parent-owned
types/lint/build pass. New consumer boundaries live in
`tests/bureaucrat-e1-funding.test.ts` and `tests/bureaucrat-e3-funding.test.ts`;
default Nexus, classic/World/occupied/Betrayal and original phase payment
consumers remain in that union. Earlier fixture/helper/profile-only pins are
deleted rather than repinned. This is scoped local rule/control proof, not
complete Skills, module-combination, save/recovery or deployed acceptance.
+25 -3
View File
@@ -29,6 +29,28 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Native allied Bureaucrat receipts — 18 October. Prototyped / Partial.**
An original no-Nexus Eca/Mori all14 five-own/five-allied shipment rejected
unchanged before repair. Single and allied receipts now share the existing
ordinary Skills capability; the extra classifier is removed without an alias.
Registered selected families need no incidental Nexus or exact single family.
Individual thresholds, private shares, full costs/escrow, native cancellation,
Bank diversion and module/Advanced role admission remain.
Real Basic cost10/five-force shipping leaves Eca/Mori seven each, Guild13,
Atreides10 and consumed escrow, then resumes Movement/Collection. New E1 plain
funding retains all native starting armies. Actual Advanced no-Nexus Guild
interception and all four policies settle an original3+5 receipt, preserve
Carthag10/HMS6 and continue to Ixian movement.
Three actual390px flows cover native allowance→redirect, Advanced unused-CHOAM
auction allowance→full income/next lot and native cancellation→no offer/use.
No browser errors/overflow. Temporary memory-only routes were removed/released;
no stored room or server restart. Frozen17-file **442/442**, focused1/1,
types/lint/build pass; existing build warnings remain. No new source/ruling,
complete-mode, deployed, calibration or deferred-assurance claim.
[Current contract and evidence](BUREAUCRAT_PAYMENTS.md#registered-native-allied-receipts--18-october-2026).
**Registered native E3 selected decks — 18 October. Prototyped / Partial.**
Sixteen ordinary plain/card all14 starts rejected before; all16 now reach
native setup. Registered seats simply require their selected ordinary family;
@@ -1509,9 +1531,9 @@ types/lint and build pass. No deployed, save/recovery or full-mode claim follows
**Bureaucrat / Homeworld allied income and two 13 October rulings — 13 October.**
Homeworlds + Leader Skills games now connect qualifying allied-funded ordinary
auction and Guild-shipment income (`bureaucratAlliedFundingModeSupported` returns
`homeworldLeaderSkillsProfile` for Homeworlds). Genuine Basic/Advanced classic
Homeworld Skills games train Bureaucrat on Atreides and seat native Guild;
auction and Guild-shipment income through the admitted Homeworld Skills
capability. Genuine Basic/Advanced classic Homeworld Skills games train
Bureaucrat on Atreides and seat native Guild;
53 consumer cases cover 5+3/3+5/5+5/fully donor-funded five, decline/redirect,
sub-five nonpooling, owner/payee exclusion without masking an eligible payer,
private gross-only invoices with payer null, stale/cross-actor rejection, four
+31 -5
View File
@@ -1,5 +1,31 @@
# Dune implementation status
## 18 October 2026 — Registered native allied Bureaucrat receipts
A genuine Basic no-Nexus Eca/Mori all14 table, real worm alliance and original
Mori pledge5 reproduced an unchanged rejection of the cost10/five-force shipment.
The extra allied-only classifier is deleted; all engine guard/stamp/validation
callers use the existing ordinary receipt capability, with no alias. Obsolete
private guard arguments and profile-only tests are removed, not re-pinned.
Thresholds, private contribution legs, original native costs/callbacks, Bank2,
once-per-phase use and module/Advanced role admission stay.
Default-preserving E1/E3 original producers add no-Nexus normal starts and an
unused selected CHOAM deck. New plain E1 funding preserves native starting
armies rather than inheriting the older Nexus battle producer's board-return
staging. Actual Advanced native Guild interception still precedes the receipt.
Four legal policies preserve Carthag10/HMS6, settle3+5 once and resume Ixian
movement. No physical source, income award or phase is invented.
Frozen17-file **442/442**, focused original nonstronghold1/1, types/lint/build and
actual controllers pass. Three390px original GameTable flows prove native
allowance→Bank2/Collection, full Advanced extra-deck auction income→next lot,
and original Karama cancellation→no offer/use with one acquired card. Errors
and overflow are absent. Temporary routes/memory are removed/released204;
existing games and server are untouched. Existing build warnings remain.
No new source ruling, full-mode, deployed, calibration or assurance claim.
[Current scope/evidence](BUREAUCRAT_PAYMENTS.md#registered-native-allied-receipts--18-october-2026).
## 18 October 2026 — Registered native E3 selected-deck Skills
Sixteen normal mixed-native/unused-deck/plain/card starts reject before.
@@ -3078,8 +3104,8 @@ receives the fee minus two and exactly two goes to the Bank. Neither printed car
addresses the other, so this is an explicit ruling rather than a source
derivation. A new bounded composition implements it:
`guildBetrayalNexusLeaderSkillsProfile` (classic Nexus plus Leader Skills plus
the Betrayal preview) is admitted by `bureaucratPaymentModeSupported`,
`bureaucratAlliedFundingModeSupported` and `guildBetrayalModeSupported`, and
the Betrayal preview) is admitted by the ordinary Bureaucrat receipt capability
and `guildBetrayalModeSupported`, and
`initializeGuildBetrayalLeaderSkillsGameForAudit` deals the composition from a
fresh classic native-Guild lobby. No other Betrayal or preview composition is
admitted, and existing no-skills Betrayal behavior is unchanged.
@@ -3122,9 +3148,9 @@ save/deployed or full-mode claim is opened by this bounded proof.
## 13 October 2026 — Homeworld allied income and two Bureaucrat rulings
`bureaucratAlliedFundingModeSupported` now returns `homeworldLeaderSkillsProfile`
for Homeworlds games, so qualifying allied-funded ordinary auction and
Guild-shipment income in Homeworlds + Leader Skills compositions offer the
The Homeworld Skills capability now connects qualifying allied-funded
ordinary auction and Guild-shipment income in Homeworlds + Leader Skills
compositions to the
one-time two-spice redirection. Nothing broader opened: multi-expansion,
preview overlays and the occupation/Betrayal previews remain excluded.
+16
View File
@@ -7,6 +7,22 @@ and fixture opt-ins are removed; existing games are not retrofitted.
Earlier dated references to a separate entry describe historical checkpoints.
See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
## Registered native allied Bureaucrat receipts — 18 October 2026
Single and actual allied receipts now share the ordinary Skills capability.
Registered all14 selected-family tables need no incidental Nexus Cards or exact
single family for otherwise supported auction/shipment funding. Original
module/Advanced role admission, individual payer thresholds, private shares,
native income cancellation, full costs and Bank2 remain. No new ruling or
played-save conversion occurs.
Real no-Nexus E1/E3 normal starts, actual Advanced Guild interception and an
unused selected CHOAM deck retain original armies, escrow and continuation.
Frozen17-file442/442, types/lint/build and three actual390px native controls
pass. All four legal policies settle the genuine Advanced E1 receipt without
changing Carthag10/HMS6. Full-mode/AI calibration and deferred assurance remain.
[Source, exact receipts and limits](BUREAUCRAT_PAYMENTS.md#registered-native-allied-receipts--18-october-2026).
## Registered native E3 selected decks — 18 October 2026
Fresh all14 now validates every registered actual seat against its selected
-7
View File
@@ -50,13 +50,6 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
}
export function bureaucratAlliedFundingModeSupported(game: LeaderSkillProfile): boolean {
if (!bureaucratPaymentModeSupported(game)) return false;
if (game.homeworlds) return homeworldLeaderSkillsProfile(game);
if (game.expansions.length === 1 && (game.expansions[0] === 'ecaz' || game.expansions[0] === 'ix'))
return pairedNexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game);
return !game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam';
}
export function bureaucratUsed(
used: readonly BureaucratPaymentUse[],
turn: number,
+7 -9
View File
@@ -7,7 +7,7 @@ import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsu
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard } from './leader-skill-cards';
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
usesPlanetologistBattleSpecial, planetologistSpecialNeedsChoice,
@@ -9079,15 +9079,13 @@ function bureaucratPaymentQuote(g: Game,source: Pick<BureaucratPaymentSource,'pa
const owner = nativeBureaucrat(g)?.owner ?? null;
return quoteBureaucratPayment({...source,owner,turn:g.turn,phase:g.phase,used:g.bureaucratPayments?.used ?? []});
}
function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{payer:string;amount:number}[],privateFunding=false) {
function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{payer:string;amount:number}[]) {
if (!g.leaderSkills || !payee) return;
const paid = shares.filter(share => share.amount > 0);
const owner = nativeBureaucrat(g)?.owner;
if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) ||
!paid.some(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee)) return;
requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.');
requireRule(paid.length === 1 && !privateFunding || bureaucratAlliedFundingModeSupported(g),
'Bureaucrat on allied funding or multiple payer contributions in this module composition is awaiting integration.');
}
function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number,
contributions?:BureaucratPaymentSource['contributions']):
@@ -9103,7 +9101,7 @@ function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:stri
const paid = shares.filter(share => share.amount > 0);
if (!privateFunding && paid.length === 1)
return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount);
if (!bureaucratAlliedFundingModeSupported(g)) return {};
if (!bureaucratPaymentModeSupported(g)) return {};
const owner = nativeBureaucrat(g)?.owner;
const qualifying = paid.find(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee);
return qualifying ? stampBureaucratPayment(g,kind,qualifying.payer,payee,
@@ -9125,7 +9123,7 @@ function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source),
'The saved Bureaucrat payment changed its actual payer, payee, amount or event.');
if (source.contributions) {
requireRule(bureaucratAlliedFundingModeSupported(g) && source.privateFunding === true &&
requireRule(bureaucratPaymentModeSupported(g) && source.privateFunding === true &&
(source.kind === 'auction' || source.kind === 'shipment') &&
source.contributions.length >= 1 && source.contributions.length <= 2 &&
source.contributions.every(share => g.players.some(player => player.id === share.payer)) &&
@@ -17854,7 +17852,7 @@ function checkShipmentIncomeFunding(g: Game, p: Player, cost: number, allyPaymen
if (!guild) return;
const amounts = shipmentIncomeContributions(g, p, cost, allyPayment);
checkBureaucratContributors(g,g.karamaShipping?.player === p.id ? undefined : guild.id,[{payer:p.id,amount:p.id === guild.id ? 0 : cost-allyPayment},
...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0);
...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])]);
validateGuildPaymentContributions(g, amounts.reduce((sum, amount) => sum + amount, 0), amounts);
}
function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string,
@@ -32834,7 +32832,7 @@ function applyActionInner(
validateRicheseFunding(amount, allyPayment, ownAvailable, allyAvailable);
if (amount !== null) {
const parties = auctionBureaucratParties(g,p,amount,allyPayment,lot.owner);
checkBureaucratContributors(g,parties.payee,parties.shares,allyPayment > 0);
checkBureaucratContributors(g,parties.payee,parties.shares);
}
g.richeseAuction = submitRicheseBid(
lot,
@@ -32888,7 +32886,7 @@ function applyActionInner(
? contribution(g, p, a.bid, action.allyPayment)
: 0;
const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment);
checkBureaucratContributors(g,parties.payee,parties.shares,a.allyPayment > 0);
checkBureaucratContributors(g,parties.payee,parties.shares);
a.bidder = id;
a.passed = [];
} else if (!a.passed.includes(id)) a.passed.push(id);
+1
View File
@@ -4956,6 +4956,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way/Chemistry, or sealed/actually late-played Portable Snooper. Named protection stays distinct; copying the used role imports no activation, numeric modifier or late-play right. Sealed sources offer after reveal; a new late source may offer before the unresolved outcome, preserving prior false traitor decisions. No fresh opportunity follows a successful traitor declaration. The selected Worthless discards even after a win. Older base/printed-only battles keep their original source windows. Current World/Stronghold contexts retain independent original effects; own earned Carthag may add Snooper to a copied Shield but cannot create protection without a used source. A native Diplomat can protect its selected disc; captives grant no normal band. Surviving trained losers retain the separate printed/copied-strength retreat policy. Mirror/numeric inheritance remains separate.',
'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.',
'Registered all14 tables use one ordinary Bureaucrat receipt capability for both single and allied funding. Selected native E1/E2/E3 decks, original Worlds and supported phase modules need no incidental Nexus Cards or exact single-family selection. A native worm alliance is not a Nexus Card. Module and Advanced role admission remain separate; full combined-mode acceptance is unfinished.',
'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.',
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
'Physical Fremen Ambassador relocation offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement retain their own limits. Normal Banker income is mandatory in fresh supported ordinary Skills setups, with provisional original-earned-faction custody and no retrofit. Diplomat retreat retains its conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz coalition skills follow their bounded contracts; shared-Duke assignment, exceptional roles and other combined modules remain unfinished. Normal module starts are labelled previews, not complete-mode acceptance.',
+1 -19
View File
@@ -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]]) {
+59 -16
View File
@@ -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);
});
+111 -1
View File
@@ -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);
+36 -27
View File
@@ -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);
+25 -17
View File
@@ -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. */
+42 -14
View File
@@ -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());
+59 -26
View File
@@ -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));