diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ded021bc..dea2d6a4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,29 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Native Ix/CHOAM independent cards + Skills — 17 October.** Fresh +classic or selected E1/E2 native/base all14 Skills now admits the three +independent cards without other modules. Original36/38/50 ordinary decks and +separate Richese cache remain. One shared independent profile feeds native +capabilities, avoiding setup/No-Field/Suk/Planetologist/Smuggler mismatches. +Original normal Sandmaster worm pickup also accepts this independent variant. + +The frozen22-file set passes **223/223**, types/lint and build. Genuine Ix +programmes retain normal-first fees, typed returns, trained rescue and the +paid Cyborg revival price; Richese programmes retain funded/capped marker +materialization, trained Mentat scoring, mandatory disposal and legal Harass +fallback without spending a refused seal. All six bounded native/mixed-family +table smokes reach turn3 or an earlier win. Real390px native Ix card selection +then skill offer has no browser errors or overflow. Source entropy and conserved +physical card staging remain explicit, not invented armies/payments. + +Evidence: `tests/fixture-ix-independent-skills.ts`, +`tests/ix-independent-skills-runtime.test.ts`, +`tests/fixture-choam-independent-skills.ts` and +`tests/choam-independent-skills-runtime.test.ts`. +Existing action/view shapes, profile exclusions and printed native components +are retained; no full-mode, calibration or deployed claim follows. + **Standalone Ecaz Skills coalition — 17 October.** Native Ecaz/classic all14 Skills now reaches ordinary allied Arrakis battles without Nexus or Worlds. Fresh Basic participation and the existing native profile feed the same lead, diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index 6d1199ed..5166dce3 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -299,3 +299,53 @@ native battle programmes/82 actions, not strategic calibration. Related18-file union225/225, types/lint, build and actual prototype help pass with existing warnings. No full-mode, deployed, HTTP/database or broad assurance claim. + +## Native E1/E2 Leader Skills composition — 17 October 2026 + +The independent three-card variant now composes fresh classic or selected +Ix/CHOAM native/base all-fourteen Skills in Basic/Advanced2–6. Original ordinary +inventories are36/38/50 cards; a seated Richese retains its separate cache. +This follows the printed faction-independent variant permission above and +reuses the existing native setup, not an extra physical card or faction power. + +The shared `independentTreacheryLeaderSkillsProfile` replaces the old +classic-only predicate. Its selected-family native form feeds +`nativeExpansionLeaderSkillsProfile`, so No-Field shipment, Planetologist, +Smuggler, Suk, Rihani and Sandmaster consumers use one coherent capability +instead of local bypasses. Recruits and Bureaucrat use the same admission. +Original home/foreign custody, printed free rates, Cyborg prices and native +unlimited revival exceptions remain; no revival recipient is reassigned. + +An actual Ix/Cyborg/Suboid programme pays the three normal-first reserve fee, +returns the typed undialed army, then applies trained Suk only to committed +casualties. The next real Revival counts a prior free return: Recruits leaves +one extra free Suboid and a three-counter request costs5 (paid Suboid2 plus +paid Cyborg3), reaching Tleilaxu. Fee counters are not rescued. Sandmaster's +ordinary worm destination pickup also remains available with this independent +variant: one spice from the actual destination, not another move or source gain. + +Existing fee-before-return, native reserve-capped No-Field materialization, +green Planetologist substitution and after-use disposal interpretations remain. +Worlds, Nexus, Discovery, Tech/Strongholds, E3 natives and unrelated previews +stay outside this fresh Skills/independent envelope. No played-save conversion, +new numerical ruling, complete-mode or publication certification follows. + +Evidence: `tests/ix-independent-skills-runtime.test.ts`, +`tests/fixture-ix-independent-skills.ts`, +`tests/choam-independent-skills-runtime.test.ts` and +`tests/fixture-choam-independent-skills.ts`. Original native card timing and +funding controls were exercised directly, including marker5 capped to3 with +zero reserves left for the fee: the refused seal changes no state; legal Harass +returns one and loses two committed counters. Funded Basic/Advanced marker3 +plans win by one with the trained Mentat bonus, not an invented support bonus. +The existing conserved post-Nexus Sandmaster fixture with the independent +variant transfers exactly one destination spice and preserves movement allowance. + +Frozen22-file223/223, types/lint/build and actual prototype help pass. Six +bounded native/mixed-family games reach turn3 or an earlier win. Real390px +normal-lobby native Ix starting-card choice precedes its actual skill offer, +with no app errors or root overflow. Ix/CHOAM setup entropy and conserved +physical card staging are explicit; their armies, paid receipts and phases +follow native actions. This does not certify all combinations, strategic AI, +persistence/privacy assurance or a deployed revision. + diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f3407037..4de4c479 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,36 @@ # Dune implementation status +## 17 October 2026 — Native independent cards with all14 Skills + +The former classic-only independent-card Skills profile now admits selected +Ix/CHOAM native/base Basic/Advanced2–6, without unrelated modules. Rename it to +`independentTreacheryLeaderSkillsProfile` and migrate every caller without an +alias. Its native form feeds the existing native capability once, fixing +setup/No-Field/Suk/Planetologist/Smuggler disagreement without per-producer +bypasses. Native decks36/38/50, separate Richese cache, normal Banker income, +native prices, skill assignment and revival exceptions remain original. + +Disjoint native programmes cover real Ix card-before-training, paid typed +shipment, normal-first reserve fee, sector-specific Harass, committed-only Suk +rescue and the next real Recruits revival. The Cyborg request pays5 to Tleilaxu +after one prior free return; fee counters are never rescued. Richese marker +materialization precedes the fee, trained Mentat gives the real one-point +winning margin, and an unfunded capped marker rejects unchanged before legal +Harass fallback. Normal Sandmaster worm pickup now accepts the independent +variant under its original trainer/phase/destination rules. + +The frozen22-file **223/223**, types/lint and build pass. Six bounded +Basic/Advanced native and mixed-family games reach turn3 or an earlier win; +explicit native rule/control programmes also run outside the test runner. +Actual390px native Ix normal-lobby card choice then training has no browser +errors or overflow. Existing build warnings remain. Ix/CHOAM fixture setup +entropy and conserved physical card positioning are labelled; no armies, +wallets, phases or receipts are manufactured. No deployment or old-game +conversion occurs; full modes/combinations and final assurance remain open. +Sandmaster uses the existing conserved post-Nexus fixture rather than a claim +of an additional naturally encountered worm. +[Current source/scope and evidence](ECAZ_TREACHERY_RULES.md#native-e1e2-leader-skills-composition--17-october-2026). + ## 17 October 2026 — Independent native Ecaz Skills coalition Both Basic and Advanced normal Ecaz/classic Skills-only starts were rejected diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 5cee88a2..9755890e 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -7,6 +7,30 @@ 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). +## Native independent Treachery composition — 17 October 2026 + +Fresh all14 Skills with the independent Ecaz cards now admits original selected +Ixians/Tleilaxu and/or CHOAM/Richese with classics, Basic/Advanced2–6, without +other modules. Source-ordered native hands, Ix card-before-training, Richese's +separate cache, Auditor exclusion and actual typed force/revival rules remain. +The public lobby and existing prototype command use the same setup. + +One shared independent profile now feeds both ordinary and native skill +capabilities. Smuggler marker shipment/collection, Planetologist and native +Suk/Rihani/Sandmaster quotes no longer disagree with admitted setup. The +independent-card blanket block on normal Sandmaster worm collection is removed; +its actual native trainer, destination, phase and physical pickup rules remain. +No old predicate alias or played-game conversion is retained. + +Frozen22-file223/223, types/lint and build pass. Original Ix typed fees/Harass/ +Suk and next-Recruits pricing, Richese funded/capped marker plans, Mentat margin +and Harass fallback execute through their native sources. Six bounded native/ +mixed-family games reach turn3 or an earlier win; actual390px Ix initial card +selection then training has no browser errors or overflow. Those are changed-path +development proofs, not full-rule/combination acceptance or calibration. + +[Card-source boundaries and native physical programme](ECAZ_TREACHERY_RULES.md#native-e1e2-leader-skills-composition--17-october-2026). + ## Standalone Ecaz coalition — 17 October 2026 Native Ecaz/classic all-fourteen Skills no longer needs Nexus Cards or diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 70effc97..4b139c98 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { homeworldLeaderSkillsProfile, classicIndependentLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { @@ -40,7 +40,7 @@ export function bureaucratPaymentSignature(value: object): string { return JSON.stringify({ ...value, signature: undefined }); } export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean { - return classicIndependentLeaderSkillsProfile(game) || (!game.homeworlds && game.expansions.length === 1 && + return independentTreacheryLeaderSkillsProfile(game) || (!game.homeworlds && game.expansions.length === 1 && (game.expansions[0] === 'choam' || game.expansions[0] === 'ix') && pairedNexusLeaderSkillsProfile(game)) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || diff --git a/game/engine.ts b/game/engine.ts index f79e3eb6..b8146cbc 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicIndependentLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -10701,9 +10701,9 @@ export function initializeDiscoveryGameForAudit(state: Game): Game { export function initializeLeaderSkillsGameForAudit(state: Game): Game { requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); const homeworlds = homeworldLeaderSkillsProfile(state); - const independent = classicIndependentLeaderSkillsProfile(state); + const independent = independentTreacheryLeaderSkillsProfile(state); if (state.ecazTreachery) { - requireRule(independent, 'Independent Treachery with Leader Skills requires a fresh classic lobby without other modules.'); + requireRule(independent, 'Independent Treachery with Leader Skills requires a fresh classic or selected Ix/CHOAM lobby without other modules.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } @@ -13829,7 +13829,7 @@ function recruitsModeSupported(g: Game) { return ( (!g.homeworlds || ecazHomeworldProfile(g)) && !g.nexusCards && - (!g.leaderSkills || classicIndependentLeaderSkillsProfile(g)) && + (!g.leaderSkills || independentTreacheryLeaderSkillsProfile(g)) && (!g.techTokens || modules) && (!g.strongholdCards || modules) && !g.discoveryEnabled diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 02c8dd16..231d38b0 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -455,7 +455,8 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo } export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - return mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || + return (game.expansions.length > 0 && independentTreacheryLeaderSkillsProfile(game)) || + mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game) || advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); } @@ -507,9 +508,9 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean return true; } -/** Independent utility cards with original classic all14 setup, without other modules. */ -export function classicIndependentLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.ecazTreachery !== true || game.expansions.length || +/** Independent utility cards with original classic or selected E1/E2 all14 setup. */ +export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (game.ecazTreachery !== true || game.expansions.length > 2 || typeof game.advanced !== 'boolean' || !game.players || game.players.length < 2 || game.players.length > 6 || game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || @@ -518,20 +519,27 @@ export function classicIndependentLeaderSkillsProfile(game: LeaderSkillProfile): game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; + for (let i = 0; i < game.expansions.length; i++) { + if (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false; + for (let j = 0; j < i; j++) if (game.expansions[j] === game.expansions[i]) return false; + } const players = game.players; for (let i = 0; i < players.length; i++) { for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; - let classic = false; + let selected = false; for (const faction of FACTIONS) - if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; } - if (!classic) return false; + if (faction.id === players[i].faction) { + selected = faction.expansion === 'base' || game.expansions.includes(faction.expansion); + break; + } + if (!selected) return false; } return true; } /** Shared by rule quotes, controls and minimal legal bot participation. */ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean { - return classicIndependentLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) && + return independentTreacheryLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) && (!game.expansions.length || nativeExpansionLeaderSkillsProfile(game) || (game.expansions.length === 1 && game.expansions[0] === 'choam'))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || diff --git a/game/reference.ts b/game/reference.ts index 6ebfc1d2..a44d1745 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4931,6 +4931,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Explicit guild-betrayal --leader-skills retains the original classic native-Guild Nexus profile without Homeworlds, Tech, Strongholds, Discovery, Semuta or other overlays. All14 training remains native. Ordinary skill quotes now admit the composition, restoring actual Mentat private questions before posture and Sandmaster ground collection rather than only enabling Bureaucrat. Native card custody, paid forces, original Betrayal Use/Pass and movement/plan continuation stay unchanged. Other combinations and complete-mode acceptance remain separate.', 'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.', 'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.', + 'The independent Recruits, Reinforcements and Harass & Withdraw cards may be selected with all fourteen Skills in fresh classic or selected Ix/CHOAM native/base Basic/Advanced games, without other modules. Original36/38/50 ordinary decks, any separate Richese cache, native setup, free-revival exceptions and paid Cyborg prices stay. Native typed fee/withdrawal/Suk outcomes and ordinary Smuggler, Planetologist and Sandmaster capabilities use one shared admission; the card variant does not silently remove those skills. Other faction/module combinations retain their explicit guards.', 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', 'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke/Arrakis coalition and Moritani assassination compose original rules; mixed E3, exceptional copied defenses, active skill/Stone and other guards stay separate.', 'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. --discoveries additionally composes original Discovery7/8 in this classic profile. Saved hands/offers remain authoritative; the actual end-Spice closing draw follows Maker votes/typed rides, both Advanced piles and settled alliance. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Homeworlds, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.', @@ -4984,6 +4985,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.', 'Classic or selected Ix/CHOAM native/base rosters, including Homeworlds, may retain original unused Tech Tokens3–6 and/or Stronghold Cards2–6 Advanced. Setup, first-Storm Tech and actual end-Mentat custody remain original. Recruits changes current native free rates, not the once-per-phase Axlotl trigger/delayed payout. Harass returns are not free Revival.', 'Selected Ix/CHOAM native/base Basic/Advanced Worlds use original38/50 independent-card setup with optional original phase modules. Current native bonuses, typed deposits and accepted paid/free classifications stay. Ix keeps its multiple-Cyborg allowance; Tleilaxu and CHOAM remain unlimited rather than being reduced to seven. Other card/overlay/public-start gates remain.', + 'Fresh classic or selected Ix/CHOAM games may combine these three cards with all fourteen Leader Skills, without other modules. The current native free allowance is still doubled after prior recorded use. An Ix revival keeps its paid Cyborg surcharge and any Tleilaxu recipient; native CHOAM/Tleilaxu limits are not reduced to seven. No separate skill or native revival ledger is invented.', ], related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'], checklist: [ @@ -5010,6 +5012,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Classic and selected Ix/CHOAM Basic/Advanced Worlds retain exact native normal-first sources, sealed when ambiguous; unique sources may derive. Own-World fees cannot also fund dial/casualties; original face/support stays, including explosion thresholds. Original phase modules preserve their own prerequisites and earned custody. Native-only World Traitors, typed Tanks and fee-before-Harass stay provisional composition, not a new card FAQ.', 'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.', 'Selected Ix/CHOAM Worlds retain original38/50 setup and native stock, optionally with original Tech3+ and Advanced Strongholds2+. Cyborg substitution and Face Dance use their actual physical pools, never a duplicate fee origin. Native Richese must retain three World reserves AFTER its own projected No-Field reveal before R seals; rejection moves no counter or marker. Original phase rewards and earned held-card support stay; admitted Stone compositions retain their separate guards.', + 'With supported native Ix/CHOAM all-fourteen Skills, the same physical fee and score rules apply. Trained Suk may rescue the actual committed battle casualties, never the three reserve counters spent on this card. A native Richese marker must still leave the full fee after its own materialization; future Harass returns cannot fund a refused seal.', ], related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'], checklist: [ @@ -5038,6 +5041,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.', 'Classic and selected Ix/CHOAM Basic/Advanced Worlds may choose the independent cards before original setup. Harass works on Arrakis or a foreign World, never an own World. Return actual undialed board/visitor types through native custody: normal/special to the original primary World except Advanced Emperor normal to Kaitain and Sardaukar to Salusa. Original commitment stays across thresholds; native-only World Traitors/Face Dancers, late defense/explosion and disposal stay.', 'Classic or selected Ix/CHOAM native/base rosters, including Homeworlds, compose original Tech3+ and/or Advanced Strongholds2+. An earned held Stronghold affects only its original eligible plan/actual support; +2 R adds no supported counters. Harass deposits are not off-planet shipment/free Revival. Original winner card cleanup/Tech reward and native queues retain their order.', + 'Supported native Ix/CHOAM all-fourteen Skills keep the same typed undialed complement and original selected-trainer effects. Ix may return both Suboids and Cyborgs, then resolve trained rescue only on the remaining committed casualties. A capped Richese marker may legally play Harass when Reinforcements is unfunded; unambiguous single-sector returns stay automatic.', ], related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'], checklist: [ diff --git a/game/sandmaster-worm.ts b/game/sandmaster-worm.ts index 91fd96f3..7d53ce33 100644 --- a/game/sandmaster-worm.ts +++ b/game/sandmaster-worm.ts @@ -19,7 +19,7 @@ export function sandmasterWormCollection( game.players.find(p => p.id === player)?.faction !== 'fremen') return null; const blocked = (reason: string): SandmasterDestinationCollection => ({ leader, key: null, before: 0, piles: [], blocked: reason }); - if (!sandmasterModeSupported(game) || game.ecazTreachery || + if (!sandmasterModeSupported(game) || !(nativeExpansionLeaderSkillsProfile(game) || game.players.every(p => faction(p.faction).expansion === 'base'))) return blocked('Sandmaster worm collection with other optional modules is still being integrated.'); diff --git a/tests/choam-independent-skills-runtime.test.ts b/tests/choam-independent-skills-runtime.test.ts new file mode 100644 index 00000000..35d5171f --- /dev/null +++ b/tests/choam-independent-skills-runtime.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import { forceRevivalLimit, forceRevivalQuote } from '../game/revival'; +import type { BattleResolutionQuote } from '../game/battle-resolution-quote'; +import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle'; +import { + assertChoamIndependentSkillsCustody, choamIndependentSkillsPlayer, + createChoamIndependentSkillsFixture, + finishChoamIndependentSkillsBattle, openChoamIndependentSkillsBattle, + sealChoamIndependentSkillsBoth, type ChoamIndependentSkillsFixture, +} from './fixture-choam-independent-skills'; + +const owner = (fixture: ChoamIndependentSkillsFixture, game: Game) => + choamIndependentSkillsPlayer(game, fixture.richese); +const rival = (fixture: ChoamIndependentSkillsFixture, game: Game) => + choamIndependentSkillsPlayer(game, fixture.opponent); +/** The quote side belongs to whichever combatant the order-based label names. */ +const side = (game: Game, actor: string) => + game.battle!.attacker === actor ? 'attacker' as const : 'defender' as const; +const bonusOf = (game: Game, quote: BattleResolutionQuote, actor: string) => + quote.leaderSkillBonuses[side(game, actor)]; +const scoreOf = (game: Game, quote: BattleResolutionQuote, actor: string) => { + assert.ok(quote.scores, 'A resolved battle carries totals'); + return quote.scores[side(game, actor)]; +}; +const held = (game: Game, id: string) => + [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)] + .filter((card) => card.id === id); + +for (const advanced of [false, true] as const) + void test(`${advanced ? 'Advanced' : 'Basic'} CHOAM/Richese independent all14 funds Reinforcements after the concealed marker materializes and returns the real undialed army`, () => { + const fixture = createChoamIndependentSkillsFixture(advanced ? { advanced: true } : {}); + // The independent three-card marker rides the selected E2 ordinary deck, and + // Richese keeps its own separate ten-card cache alongside all fourteen skills. + assert.equal(fixture.offered.richeseCache!.length, 10); + // Live plan window: the preview already forecasts the capped materialization + // and the three reserves the fee must find afterwards. + const open = openChoamIndependentSkillsBattle(fixture); + assert.deepEqual(open.battle!.plans, {}); + assert.equal(owner(fixture, open).reserves, advanced ? 20 : 6); + const preview = viewGame(open, fixture.richese).battle!.reinforcements!; + assert.equal(preview.blocked, null); + assert.equal(preview.noField!.materializes, 3); + assert.equal(preview.noField!.reservesAfterReveal, advanced ? 17 : 3); + assert.equal(preview.normal, 3); + const withdraw = viewGame(open, fixture.richese).battle!.harassWithdraw!; + assert.equal(withdraw.blocked, null); + assert.equal(withdraw.forces.normal, 3); + assert.equal(withdraw.forces.elite, 0); + assert.deepEqual(withdraw.locations, { [fixture.location]: { normal: 3, elite: 0 } }); + // The reveal materializes the capped physical counters; the fee follows them. + const revealed = sealChoamIndependentSkillsBoth(fixture, 'both', open); + assert.equal(revealed.battle!.revealed, true); + const shown = owner(fixture, revealed); + assert.equal(shown.forces[fixture.location], 3); + assert.equal(shown.reserves, advanced ? 17 : 3); + assert.equal(shown.tanks, 0); + assert.equal(shown.noField!.deployed, null); + assert.equal(shown.noField!.lastShipped, fixture.token.id); + assert.equal(new Set(shown.noField!.tokens.map((token) => token.id)).size, 3); + // The revealed totals: dial + Reinforcements + trained Mentat + physical leader. + const quote = quoteIndependentSkillsBattle(revealed, { returns: fixture.returns }); + assert.deepEqual(quote.reinforcements, + [{ player: fixture.richese, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]); + assert.deepEqual(quote.harassWithdraw?.map((entry) => ({ returned: entry.returned, locations: entry.locations })), + [{ returned: { normal: advanced ? 2 : 1, elite: 0 }, + locations: { [fixture.location]: { normal: advanced ? 2 : 1, elite: 0 } } }]); + assert.deepEqual(bonusOf(revealed, quote, fixture.richese), + { bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] }); + assert.deepEqual(bonusOf(revealed, quote, fixture.opponent), { bonus: 0, applied: [] }); + assert.equal(scoreOf(revealed, quote, fixture.richese) - scoreOf(revealed, quote, fixture.opponent), 1); + // The whole undialed army lies in the marker's own sector, so the native + // return is unambiguous and needs no typed allocation. + const spice = owner(fixture, revealed).spice; + const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns }); + assert.equal(done.lastBattleContext?.winner, fixture.richese); + assert.equal(done.lastBattleContext?.result, 'normal'); + const after = owner(fixture, done); + assert.equal(after.reserves, advanced ? 16 : 1, + 'Revealed reserves, minus the three-reserve fee, plus the returned undialed counter'); + assert.equal(after.tanks, advanced ? 4 : 5); + assert.deepEqual(after.forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 }); + assert.equal(after.spice, spice - Number(fixture.plans.both.action.support ?? 0)); + for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) { + assert.equal(held(done, card).length, 1, card); + assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card); + assert.ok(done.players.every((player) => !player.hand.some((face) => face.id === card)), card); + } + assertChoamIndependentSkillsCustody(fixture, done); + // The same physical dial without Reinforcements loses: its +2 score, + // rather than another force or support payment, supplies the winning margin. + const plain = finishChoamIndependentSkillsBattle( + sealChoamIndependentSkillsBoth(fixture, 'plain', open), { return: fixture.returns }); + assert.equal(plain.lastBattleContext?.winner, fixture.opponent); + assert.equal(owner(fixture, plain).reserves, advanced ? 17 : 3); + assert.equal(owner(fixture, plain).tanks, 3); + assert.deepEqual(owner(fixture, plain).forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 }); + assertChoamIndependentSkillsCustody(fixture, plain); + }); + +void test('A reserve-capped marker five at three reserves refuses the funded plan before the reveal and still plays the real Harass fallback', () => { + const fixture = createChoamIndependentSkillsFixture({ marker: 5, reserves: 3, stopBeforePlans: true }); + const preview = viewGame(fixture.beforePlans, fixture.richese).battle!.reinforcements!; + assert.equal(preview.noField!.materializes, 3, 'The reveal is capped by the physical reserves'); + assert.equal(preview.noField!.reservesAfterReveal, 0); + assert.equal(preview.normal + preview.elite, 0); + const saved = structuredClone(fixture.beforePlans); + for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) { + assert.throws(() => applyAction(fixture.beforePlans, fixture.richese, plan.action)); + assert.deepEqual(fixture.beforePlans, saved, 'A refused plan reserves no counter, card or window'); + } + const revealed = sealChoamIndependentSkillsBoth(fixture, 'harass'); + assert.equal(revealed.battle!.revealed, true); + const shown = owner(fixture, revealed); + assert.equal(shown.forces[fixture.location], 3, 'The capped marker still materializes its three counters'); + assert.equal(shown.reserves, 0); + assert.equal(shown.tanks, 0); + const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns }); + assert.equal(done.lastBattleContext?.winner, fixture.opponent); + const after = owner(fixture, done); + assert.equal(after.reserves, 1, 'Only the undialed counter returned; no fee was charged'); + assert.equal(after.tanks, 2, 'The two dialed counters reached the Tanks'); + assert.deepEqual(after.forces, { 'broken_land:12': 5, 'carthag:11': 12 }); + assertChoamIndependentSkillsCustody(fixture, done); +}); + +void test('The independent CHOAM/Richese setup keeps the separate cache and each seat printed revival exception', () => { + const fixture = createChoamIndependentSkillsFixture({}); + assert.equal(fixture.offered.richeseCache!.length, 10); + assert.ok(fixture.offered.richeseCache!.every((card) => card.id.startsWith('richese-')), + 'The cache holds only Richese faces, never ordinary deck cards'); + for (const card of fixture.offered.richeseCache!) + assert.ok(!fixture.offered.deck.some((face) => face.id === card.id), + `${card.id} stays out of the ordinary deck`); + const choam = choamIndependentSkillsPlayer(fixture.afterSetup, fixture.choam); + assert.equal(forceRevivalLimit(fixture.afterSetup, choam), 20); + assert.deepEqual(forceRevivalQuote(fixture.afterSetup, choam, 5), + { free: 0, normalCost: 10, cost: 5 }, + 'CHOAM pays one spice per returned force with no free revival and no normal limit'); + for (const actor of [fixture.richese, fixture.classic]) { + const seat = choamIndependentSkillsPlayer(fixture.afterSetup, actor); + assert.equal(forceRevivalLimit(fixture.afterSetup, seat), 3); + assert.deepEqual(forceRevivalQuote(fixture.afterSetup, seat, 5), + { free: 2, normalCost: 6, cost: 6 }); + } + const done = finishChoamIndependentSkillsBattle( + sealChoamIndependentSkillsBoth(fixture, 'both'), { return: fixture.returns }); + assertChoamIndependentSkillsCustody(fixture, done); + // Every seat keeps its own counters and the printed hand limit end to end. + for (const player of done.players) { + const pool = player.reserves + player.tanks + + Object.values(player.forces).reduce((sum, count) => sum + count, 0); + assert.equal(pool, 20, player.faction); + } + assert.equal(rival(fixture, done).tanks, rival(fixture, fixture.beforePlans).tanks + + (rival(fixture, fixture.beforePlans).forces[fixture.location] ?? 0), + 'The defeated garrison, not armies elsewhere, reached the Tanks'); +}); diff --git a/tests/fixture-choam-independent-skills.ts b/tests/fixture-choam-independent-skills.ts new file mode 100644 index 00000000..6e48befc --- /dev/null +++ b/tests/fixture-choam-independent-skills.ts @@ -0,0 +1,631 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, joinGame, nativeHandLimit, newPlayer, viewGame, + type Action, type Game, type Player, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { MOBILE_STRONGHOLD, territory } from '../game/board'; +import type { Card } from '../game/cards'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import type { NoFieldValue } from '../game/richese-no-field'; +import { nextRicheseReinforcementsStep } from './fixture-richese-reinforcements'; + +export type ChoamIndependentSkillsStep = { actor: string; action: Action }; +/** + * 'immediate' ships the final concealed marker straight from twenty reserves on + * turn one (the only deterministic Advanced timeline: later turns depend on the + * original storm card). 'tuning' is the Basic route whose two real reveals plus + * one real paid tuning shipment leave an exact reserve budget off the one + * deterministic opening Spice Blow. + */ +export type ChoamIndependentSkillsRoute = 'tuning' | 'immediate'; +export type ChoamIndependentSkillsPolicy = { + /** Real posture decision per seat: hide the trained leader behind the shield. */ + hide?: Record; + stone?: 'kill' | 'ignore'; + guildTiming?: 'first' | 'last'; +}; +export type ChoamIndependentSkillsOptions = { + /** Continue only the original fresh undealt lobby or its own post-start setup. */ + initial?: Game; + advanced?: boolean; + route?: ChoamIndependentSkillsRoute; + marker?: NoFieldValue; + /** Own physical reserves the Basic 'tuning' route must leave before shipping. */ + reserves?: number; + /** Stop at the live pre-plan battle window so the caller seals the plans. */ + stopBeforePlans?: boolean; + seatIds?: [string, string, string]; +}; +export type ChoamIndependentSkillsSetup = { + /** The original fresh undealt lobby before any staged module selection. */ + initial: Game; + /** Immediately after the host's real module-selected start: skill offers dealt. */ + offered: Game; + afterSetup: Game; + richese: string; + choam: string; + classic: string; + /** Requested and actually assigned skill per seat id. */ + skills: Record; + /** The leader disc each seat attached its skill to. */ + trainers: Record; + actions: ChoamIndependentSkillsStep[]; + staging: string[]; +}; +export type ChoamIndependentSkillsFixture = ChoamIndependentSkillsSetup & { + advanced: boolean; + route: ChoamIndependentSkillsRoute; + /** Real battle stronghold the concealed marker was shipped into. */ + location: string; + opponent: string; + beforeShipment: Game; + shipmentStep: ChoamIndependentSkillsStep; + afterShipment: Game; + beforeBattle: Game; + battleStep: ChoamIndependentSkillsStep; + /** Live plan window: battle chosen, Mentat question and postures resolved. */ + beforePlans: Game; + /** Revealed battle: both original plans sealed, postures committed. With + * stopBeforePlans this is instead the live pre-plan window awaiting a seal. */ + game: Game; + /** Exact undialed complement of the commitment, for the caller's preview quote. */ + returns: HarassWithdrawSelection; + token: { id: string; value: NoFieldValue }; + /** Capped physical counters the concealed marker materializes on reveal. */ + materializes: number; + tuning: number; + reservesBefore: Record; + plans: { + both: ChoamIndependentSkillsStep; + reinforcements: ChoamIndependentSkillsStep; + harass: ChoamIndependentSkillsStep; + plain: ChoamIndependentSkillsStep; + opponent: ChoamIndependentSkillsStep; + }; + hide: Record; + /** Every real native action the programme took, in order. */ + nativeSteps: ChoamIndependentSkillsStep[]; +}; + +/** The one deterministic opening Spice Blow of the original initializer. */ +export const CHOAM_INDEPENDENT_SKILLS_BLOW = { territory: 'broken_land', sector: 12 } as const; +/** Empty strongholds: cheapest to ship into and free of any starting army. */ +export const CHOAM_INDEPENDENT_SKILLS_CITIES = [ + { territory: 'carthag', sector: 11 }, + { territory: 'sietch_tabr', sector: 14 }, + { territory: 'habbanya_ridge_sietch', sector: 17 }, +] as const; +/** The ordinary deck of the selected E2 expansion plus the independent three. */ +export const CHOAM_INDEPENDENT_SKILLS_EXPANSIONS = ['choam'] as const; + +export function choamIndependentSkillsPlayer(game: Game, actor: string): Player { + const player = game.players.find((seat) => seat.id === actor); + assert.ok(player, `Missing original seat ${actor}`); + return player; +} +export function choamIndependentSkillsSeat(game: Game, faction: string): Player { + const player = game.players.find((seat) => seat.faction === faction); + assert.ok(player, `Missing native ${faction} seat`); + return player; +} +export const choamIndependentSkillsClean = (game: Game) => + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + +/** Conserved original cards, pools and the printed hand limit for every seat. + * The dealt card multiset is fixed from the original start onward; each seat's + * counter total is fixed from the completed setup onward. */ +export function assertChoamIndependentSkillsCustody( + fixture: { offered: Game; afterSetup: Game }, + game: Game, +): void { + assert.ok(game.leaderSkills); + validateLeaderSkills(game.leaderSkills, game.players); + const cards = (state: Game) => [...state.deck, ...state.discard, ...(state.richeseCache ?? []), + ...(state.richeseRemoved ?? []), ...state.players.flatMap((player) => player.hand)] + .map((card) => card.id).sort(); + assert.deepEqual(cards(game), cards(fixture.offered), + 'Every original deck, cache, discard and hand face keeps physical custody.'); + const pools = (state: Game) => state.players.map((player) => [player.id, + player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), + player.elites + ? player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0) + : 0]); + assert.deepEqual(pools(game), pools(fixture.afterSetup), + 'Every physical counter stays inside its own native pool.'); + for (const player of game.players) + assert.ok(player.hand.length <= nativeHandLimit(player), + `${player.faction} stays inside its printed hand limit.`); +} +/** Private two-card offers are dealt in seat order, so the first positions of + * the solved all-fourteen deck decide the requesting seat's real choice. */ +function permutationDraws(source: readonly T[], desired: readonly T[]): number[] { + const working = [...source], draws: number[] = []; + for (let index = working.length - 1; index > 0; index--) { + const from = working.indexOf(desired[index]); + assert.ok(from >= 0 && from <= index); + draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296)); + [working[index], working[from]] = [working[from], working[index]]; + } + assert.deepEqual(working, desired); + return draws; +} +/** Solve only the original fourteen-card skill lottery. Every later native + * shuffle keeps the same fixed maximum fill the tuned opening timeline uses. */ +function skillDraws(draws: readonly number[], run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) + array[0] = cursor < draws.length ? draws[cursor++] : 0xffffffff; + else if (array) new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Move one conserved physical face from wherever it still lives. No face is + * ever minted, purchased or duplicated. */ +function positionCard(game: Game, actor: string, cardId: string, staging: string[]): void { + const player = choamIndependentSkillsPlayer(game, actor); + if (player.hand.some((card) => card.id === cardId)) return; + const zones: { label: string; zone: Card[] }[] = [ + { label: 'remaining deck', zone: game.deck }, + { label: 'Richese cache', zone: game.richeseCache ?? [] }, + { label: 'discard pile', zone: game.discard }, + ]; + for (const { label, zone } of zones) { + const index = zone.findIndex((card) => card.id === cardId); + if (index < 0) continue; + assert.ok(player.hand.length < nativeHandLimit(player), + `${actor}'s printed hand limit leaves room for ${cardId}`); + player.hand.push(zone.splice(index, 1)[0]); + staging.push(`Conserved ${cardId}: ${label} → ${actor}'s hand (no purchase, payment or replacement)`); + return; + } + assert.fail(`Conserved physical ${cardId} is missing for ${actor}`); +} +function availableLeaders(game: Game, actor: string, location: string) { + return choamIndependentSkillsPlayer(game, actor).leaders + .filter((leader) => !leader.dead && (!leader.usedAt || leader.usedAt === location)) + .sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id)); +} + +/** Requested skill per native seat: only Richese's Mentat changes battle + * strength. The other two need a projectile weapon or snooper defense that the + * special slots hold, so they stay inert and need no extra card custody. */ +function requestedSkills(richese: string, choam: string, classic: string): Record { + return { + [richese]: 'mentat', + [choam]: 'swordmaster-of-ginaz', + [classic]: 'killer-medic', + }; +} +function skillDeckOrder(skills: Record, seats: readonly string[]): LeaderSkillId[] { + const requested = seats.map((seat) => skills[seat]); + const rest = LEADER_SKILL_CARDS.map((card) => card.id).filter((id) => !requested.includes(id)); + return requested.flatMap((id) => [id, rest.shift()!]).concat(rest); +} + +/** Genuine fresh CHOAM/Richese plus one classic seat: the original ordinary E2 + * deck with Richese's separate ten-card cache, all fourteen Leader Skills and + * the independent three-card Treachery marker. The initial skill lottery and + * opening native shuffles are controlled; later actions keep real entropy. */ +export function createChoamIndependentSkillsSetup( + options: ChoamIndependentSkillsOptions = {}, +): ChoamIndependentSkillsSetup { + const advanced = options.initial ? options.initial.advanced : options.advanced ?? false; + const ids = options.seatIds ?? ['richese', 'choam', 'atreides']; + const factions = ['richese', 'choam', 'atreides'] as const; + let game: Game; + if (options.initial) { + game = structuredClone(options.initial); + assert.ok(game.status === 'lobby' || + (game.status === 'setup' && !!game.leaderSkills && game.turn === 1 && game.phase === 0), + 'Continue only the original fresh undealt independent-card setup.'); + assert.equal(game.advanced, advanced); + assert.deepEqual([...game.expansions].sort(), [...CHOAM_INDEPENDENT_SKILLS_EXPANSIONS]); + assert.ok(game.players.some((player) => player.faction === 'richese')); + } else { + game = createGame('CHOAMINDEPENDENTSKILLS', newPlayer(ids[0], 'Richese', factions[0]), advanced, + [...CHOAM_INDEPENDENT_SKILLS_EXPANSIONS]); + joinGame(game, newPlayer(ids[1], 'CHOAM', factions[1])); + joinGame(game, newPlayer(ids[2], 'Atreides', factions[2])); + } + const richese = choamIndependentSkillsSeat(game, 'richese').id; + const choam = choamIndependentSkillsSeat(game, 'choam').id; + const classic = choamIndependentSkillsSeat(game, 'atreides').id; + const skills = requestedSkills(richese, choam, classic); + const staging: string[] = []; + const actions: ChoamIndependentSkillsStep[] = []; + const initial = structuredClone(game); + if (game.status === 'lobby') { + assert.ok(!game.lobbyOptions && !game.deck.length && !game.richeseCache && + game.players.every((player) => !player.hand.length), + 'Only the original fresh undealt lobby accepts the two module selections.'); + for (const option of ['leaderSkills', 'ecazTreachery'] as const) { + const action: Action = { type: 'moduleOption', module: option, enabled: true }; + actions.push({ actor: game.host, action }); + game = applyAction(game, game.host, action); + } + staging.push('The host selected the Leader Skills and independent Ecaz Treachery module options in the original lobby before any seat was ready.'); + for (const player of game.players) if (!player.ready) { + const action: Action = { type: 'ready' }; + actions.push({ actor: player.id, action }); + game = applyAction(game, player.id, action); + } + const order = skillDeckOrder(skills, [richese, choam, classic]); + assert.equal(order.length, LEADER_SKILL_CARDS.length); + const action: Action = advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }; + actions.push({ actor: game.host, action }); + game = skillDraws(permutationDraws(LEADER_SKILL_CARDS.map((card) => card.id), order), + () => applyAction(game, game.host, action)); + staging.push('Solved the original fourteen-card skill lottery with real Fisher-Yates draws so each seat is offered its requested skill; the same start kept the tuned fixed-maximum fill for every later shuffle.'); + } + assert.equal(game.status, 'setup'); + assert.equal(game.ecazTreachery, true); + assert.equal(game.turn, 1); + assert.equal(game.phase, 0); + assert.ok(game.leaderSkills); + assert.equal(game.richeseCache?.length, 10, 'The separate ten-card Richese cache is dealt.'); + const offered = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 400; count++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor]; + const view = viewGame(game, actor).leaderSkills!; + const wanted = skills[actor]; + assert.ok(offer.cards.includes(wanted) && !view.unavailableSkills?.[wanted], + `The requested original ${wanted} skill must be offered to ${actor}`); + const leader = choamIndependentSkillsPlayer(game, actor).leaders + .filter((disc) => view.eligibleLeaders.some((eligible) => eligible.id === disc.id)) + .sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0]; + assert.ok(leader); + const action: Action = { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id }; + actions.push({ actor, action }); + game = applyAction(game, actor, action); + continue; + } + let next: ChoamIndependentSkillsStep | undefined; + for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find((seat) => seat.id === player.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (action) { next = { actor: player.id, action }; break; } + } + assert.ok(next, `Original independent-card setup stalled at ${game.setupStage}/${game.decision?.kind}`); + actions.push(structuredClone(next)); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.status, 'playing'); + // The undealt start is the reference for both the dealt card multiset and the + // untouched physical counter pools the setup redistributes. + assertChoamIndependentSkillsCustody({ offered, afterSetup: offered }, game); + const assigned = (seat: string) => game.leaderSkills!.assignments + .find((assignment) => assignment.owner === seat)!; + for (const seat of [richese, choam, classic]) + assert.equal(assigned(seat).skill, skills[seat]); + return { + initial, offered, afterSetup: structuredClone(game), richese, choam, classic, skills, + trainers: { [richese]: assigned(richese).leader, [choam]: assigned(choam).leader, + [classic]: assigned(classic).leader }, + actions, staging, + }; +} + +/** Only genuine native windows; a null result with a live battle means a human + * must seal a plan, and a typed withdrawal allocation is the caller's choice. */ +export function nextChoamIndependentSkillsStep( + game: Game, + policy: ChoamIndependentSkillsPolicy = {}, +): ChoamIndependentSkillsStep | null { + if (!game.response && !game.phaseOpening && game.decision) { + const decision = game.decision; + if (decision.kind === 'leaderSkillVisibility') + return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, + hide: policy.hide?.[decision.player] ?? false } }; + if (decision.kind === 'mentatQuestion') + return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; + if (decision.kind === 'guildTiming') + return { actor: decision.player, action: { type: 'decision', + take: (policy.guildTiming ?? 'last') === 'first' } }; + if (decision.kind === 'rihani') + return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } }; + // The typed withdrawal allocation and the Suk rescue choice stay the + // caller's; a winner's legal casualty pick continues through the shared + // native continuation policy. + if (decision.kind === 'harassWithdraw' || decision.kind === 'sukRescue') return null; + } + return nextRicheseReinforcementsStep(game, policy.stone ?? 'ignore'); +} + +/** Real native hidden-marker shipments, real ordinary movement and both + * original plans. Only conserved card faces are staged, and the trained leader + * is the physical disc the programme commits so its skill can resolve. */ +export function createChoamIndependentSkillsFixture( + options: ChoamIndependentSkillsOptions = {}, +): ChoamIndependentSkillsFixture { + const setup = createChoamIndependentSkillsSetup(options); + const advanced = setup.afterSetup.advanced; + const route: ChoamIndependentSkillsRoute = options.route ?? (advanced ? 'immediate' : 'tuning'); + if (route === 'tuning') + assert.equal(advanced, false, + 'The Advanced storm card makes every turn after the first nondeterministic; use the immediate route'); + assert.ok(route !== 'immediate' || options.reserves === undefined || options.reserves === 20, + 'The immediate native route starts with twenty real reserves; tuning is a separate timeline'); + const staging = [...setup.staging]; + let game = setup.afterSetup; + const { richese } = setup; + const marker: NoFieldValue = options.marker ?? 3; + const reuseValue: NoFieldValue = marker === 3 ? 0 : 3; + const baseline = marker === 3 ? 15 : 12; + const target = options.reserves ?? (marker === 3 ? 6 : 8); + const tuning = route === 'tuning' ? baseline - target : 0; + assert.ok(route === 'immediate' || (target >= 0 && target <= baseline), + 'The tuning route can only leave these reserve budgets'); + assert.ok(tuning <= 10, 'The tuning shipment must stay inside the spice the real route collected'); + const nativeSteps: ChoamIndependentSkillsStep[] = []; + const act = (step: ChoamIndependentSkillsStep) => { + nativeSteps.push(structuredClone(step)); + game = applyAction(game, step.actor, step.action); + }; + const rich = () => choamIndependentSkillsPlayer(game, richese); + const tokenOf = (value: NoFieldValue) => rich().noField!.tokens.find((token) => token.value === value)!; + const stormFreeCity = () => { + const city = CHOAM_INDEPENDENT_SKILLS_CITIES.find((candidate) => candidate.sector !== game.storm); + assert.ok(city, 'The natural route keeps one empty stronghold outside the storm'); + return city; + }; + /** One untouched ordinary army holding a real stronghold outside the storm. */ + const battleTarget = () => { + for (const player of game.players) { + if (player.id === richese) continue; + for (const [key, count] of Object.entries(player.forces)) { + if (count <= 0) continue; + const [stronghold, sector] = key.split(':'); + if (!territory(stronghold) || territory(stronghold).type !== 'stronghold') continue; + if (stronghold === MOBILE_STRONGHOLD || Number(sector) === game.storm) continue; + return { seat: player.id, faction: player.faction, territory: stronghold, sector: Number(sector) }; + } + } + assert.fail('The timeline needs one untouched starting army outside the storm'); + }; + const done = { shipment: {} as Record, reveal: {} as Record }; + let open = false; + let beforeShipment: Game | undefined; + let afterShipment: Game | undefined; + let shipmentStep: ChoamIndependentSkillsStep | undefined; + let beforeBattle: Game | undefined; + let battleStep: ChoamIndependentSkillsStep | undefined; + let reservesBefore: Record | undefined; + let army: { seat: string; faction: string; territory: string; sector: number } | undefined; + /** Both combatants hide their trained leader behind the shield. */ + const hide: Record = { [richese]: true }; + const clear = () => choamIndependentSkillsClean(game); + // Native actions only: no counter is placed by hand and no wallet is set. + for (let count = 0; count < 2400 && !open; count++) { + assert.equal(game.status, 'playing'); + if (done.shipment[4] && !afterShipment && clear()) afterShipment = structuredClone(game); + const choices = game.phase === 6 && clear() + ? viewGame(game, richese).battleChoices ?? [] : []; + if (!open && choices.some((choice) => choice.territory === army?.territory)) { + const choice = choices.find((candidate) => candidate.territory === army!.territory)!; + positionCard(game, richese, REINFORCEMENTS_CARD, staging); + positionCard(game, richese, HARASS_WITHDRAW_CARD, staging); + beforeBattle = structuredClone(game); + battleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: army!.territory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }; + act(battleStep); + open = true; + break; + } + if (game.phase === 2 && game.turn === 4 && clear() && + rich().charityTurn !== game.turn && rich().spice < 2) { + act({ actor: richese, action: { type: 'charity' } }); + continue; + } + if (game.phase === 5 && game.active === richese && clear()) { + const turn = game.turn; + if (route === 'tuning' && turn === 1 && !done.shipment[1]) { + act({ actor: richese, action: { type: 'ship', noField: tokenOf(5).id, + event: rich().noFieldEvent, territory: CHOAM_INDEPENDENT_SKILLS_BLOW.territory, + sector: CHOAM_INDEPENDENT_SKILLS_BLOW.sector } }); + done.shipment[1] = true; + continue; + } + if (route === 'tuning' && turn === 2 && rich().noField!.deployed && !done.reveal[2]) { + act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent, + token: rich().noField!.deployed!.tokenId } }); + done.reveal[2] = true; + continue; + } + if (route === 'tuning' && turn === 2 && !done.shipment[2] && !rich().shipped) { + const city = stormFreeCity(); + act({ actor: richese, action: { type: 'ship', noField: tokenOf(reuseValue).id, + event: rich().noFieldEvent, territory: city.territory, sector: city.sector } }); + done.shipment[2] = true; + continue; + } + if (route === 'tuning' && turn === 3 && rich().noField!.deployed && !done.reveal[3]) { + assert.equal(rich().noField!.deployed!.tokenId, tokenOf(reuseValue).id, + 'The reuse token must still be the concealed one this timeline reveals'); + act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent, + token: rich().noField!.deployed!.tokenId } }); + done.reveal[3] = true; + continue; + } + if (route === 'tuning' && turn === 3 && !done.shipment[3] && !rich().shipped) { + const city = stormFreeCity(); + act({ actor: richese, action: { type: 'ship', territory: city.territory, + sector: city.sector, amount: tuning } }); + done.shipment[3] = true; + continue; + } + const finalTurn = route === 'tuning' ? 4 : 1; + if (turn === finalTurn && !done.shipment[4] && !rich().shipped) { + army = battleTarget(); + hide[army.seat] = true; + beforeShipment = structuredClone(game); + reservesBefore = Object.fromEntries(game.players.map((player) => [player.id, player.reserves])); + shipmentStep = { actor: richese, action: { type: 'ship', noField: tokenOf(marker).id, + event: rich().noFieldEvent, territory: army.territory, sector: army.sector } }; + act(shipmentStep); + done.shipment[4] = true; + continue; + } + assert.ok(turn <= (route === 'tuning' ? 4 : 1), + 'The natural timeline must reach its battle by the scheduled turn'); + } + const step = nextChoamIndependentSkillsStep(game, { hide, stone: 'ignore' }); + assert.ok(step, `Native Richese continuation stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`); + if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0; + act(step); + } + assert.ok(open, 'The final concealed shipment must reach a real ordinary battle'); + assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep && army && reservesBefore, + 'The timeline must record its final shipment, army and battle admission'); + for (let count = 0; count < 120; count++) { + const step = nextChoamIndependentSkillsStep(game, { hide, stone: 'ignore' }); + if (!step) break; + act(step); + } + assert.ok(game.battle && !game.battle.revealed && clear()); + assert.deepEqual(game.battle.plans, {}); + const opponent = army.seat; + const location = `${army.territory}:${army.sector}`; + const enemyForces = choamIndependentSkillsPlayer(game, opponent).forces[location] ?? 0; + assert.ok(enemyForces > 0, 'The battle army must still hold the stronghold'); + const reserves = reservesBefore[richese]; + if (route === 'tuning') + assert.equal(reserves, target, 'The tuning route must leave the requested reserve budget'); + const materializes = Math.min(tokenOf(marker).value, reserves); + const ownLeader = setup.trainers[richese]; + assert.ok(availableLeaders(game, richese, army.territory).some((disc) => disc.id === ownLeader), + 'The trained Mentat disc must still be an available battle leader'); + const ownDial = advanced ? Math.min(1, Math.floor(materializes / 2)) : 2; + assert.ok(ownDial <= materializes, 'The plan may not dial counters the marker cannot materialize'); + const undialed = materializes - ownDial; + assert.ok(undialed >= 1, 'Harass & Withdraw needs a real undialed counter to return'); + // The announced dial is the battle number in both rules sets; the opposing + // army is tuned so the trained Mentat bonus is exactly decisive: the same + // physical plan loses by one without those two strength. + const defenderLeaders = availableLeaders(game, opponent, army.territory); + const ownStrength = choamIndependentSkillsPlayer(game, richese) + .leaders.find((disc) => disc.id === ownLeader)!.strength; + const attackerWith = ownDial + 2 + ownStrength + 2; + const boundary = defenderLeaders.flatMap((leader) => { + const dial = attackerWith - 1 - leader.strength; + return dial >= 0 && dial <= enemyForces ? [{ dial, leader: leader.id }] : []; + })[0]; + assert.ok(boundary, 'The real opposing roster must hold the decisive skill boundary dial'); + const otherLeader = boundary.leader; + const enemyDial = boundary.dial; + const plan = (weapon: string | null, defense: string | null): ChoamIndependentSkillsStep => ({ + actor: richese, + action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0, + leader: ownLeader, weapon, defense }, + }); + const plans = { + both: plan(REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD), + reinforcements: plan(REINFORCEMENTS_CARD, null), + harass: plan(null, HARASS_WITHDRAW_CARD), + plain: plan(null, null), + opponent: { actor: opponent, action: { type: 'battlePlan', dial: enemyDial, + support: advanced ? enemyDial : 0, leader: otherLeader } }, + } satisfies ChoamIndependentSkillsFixture['plans']; + const returns: HarassWithdrawSelection = { [location]: { normal: undialed, elite: 0 } }; + const beforePlans = game; + if (options.stopBeforePlans) + return { + ...setup, advanced, route, location, opponent, beforeShipment, shipmentStep, afterShipment, + beforeBattle, battleStep, beforePlans, game: beforePlans, returns, + token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore, plans, hide, nativeSteps, + }; + const before = applyAction(applyAction(game, richese, plans.both.action), opponent, plans.opponent.action); + assert.ok(before.battle?.revealed, 'Both original plans must reveal the marker and the machine'); + assert.ok(!before.decision && !before.response && !before.phaseOpening, + `The whole undialed army is in one sector, so the native return needs no typed allocation (saw ${before.decision?.kind}).`); + return { + ...setup, advanced, route, location, opponent, beforeShipment, shipmentStep, afterShipment, + beforeBattle, battleStep, beforePlans, game: before, returns, token: structuredClone(tokenOf(marker)), + materializes, tuning, reservesBefore, plans, hide, nativeSteps, + }; +} + +/** From the recorded pre-battle snapshot, choose the battle and consume only its + * native windows, stopping at the live window where a human seals a plan. */ +export function openChoamIndependentSkillsBattle( + fixture: ChoamIndependentSkillsFixture, policy: ChoamIndependentSkillsPolicy = {}, +): Game { + let state = applyAction(fixture.beforeBattle, fixture.battleStep.actor, fixture.battleStep.action); + for (let count = 0; count < 120; count++) { + const step = nextChoamIndependentSkillsStep(state, { ...policy, hide: policy.hide ?? fixture.hide }); + if (!step) break; + state = applyAction(state, step.actor, step.action); + } + assert.ok(state.battle && !state.battle.revealed, 'The battle must open at its live plan window'); + return state; +} + +/** Seal the machine plan and the opposing plan from one real pre-plan window. */ +export function sealChoamIndependentSkillsBoth( + fixture: ChoamIndependentSkillsFixture, + plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'both', + state: Game = fixture.beforePlans, + policy: ChoamIndependentSkillsPolicy = { hide: fixture.hide }, +): Game { + const first = sealChoamIndependentSkillsPlan(state, fixture.plans[plan], policy); + return sealChoamIndependentSkillsPlan(first, fixture.plans.opponent, policy); +} + +/** Seal one real plan and consume only the native windows it opens. */ +export function sealChoamIndependentSkillsPlan( + game: Game, step: ChoamIndependentSkillsStep, policy: ChoamIndependentSkillsPolicy = {}, +): Game { + let state = applyAction(game, step.actor, step.action); + for (let count = 0; count < 200; count++) { + if (!state.response && !state.phaseOpening && !state.decision) return state; + const next = nextChoamIndependentSkillsStep(state, policy); + assert.ok(next, `Sealed plan left an unconsumed native window at ${state.decision?.kind}`); + state = applyAction(state, next.actor, next.action); + } + throw Error('Sealed plan did not finish its native preparation'); +} + +/** Resolve the real aftermath: Traitor calls, an optional typed withdrawal + * allocation, the winner's legal casualty pick and mandatory disposal, stopping + * at the original clean Collection boundary. */ +export function finishChoamIndependentSkillsBattle( + game: Game, + options: { callers?: readonly string[]; return?: HarassWithdrawSelection; + actions?: ChoamIndependentSkillsStep[]; until?: (state: Game) => boolean } = {}, +): Game { + const until = options.until ?? ((state: Game) => state.phase === 7 && choamIndependentSkillsClean(state)); + for (let count = 0; count < 400; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + let step: ChoamIndependentSkillsStep | null; + if (game.decision?.kind === 'harassWithdraw') { + assert.ok(options.return, 'Supply the genuine typed undialed return allocation'); + step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, + returns: options.return } }; + } else { + const next = nextChoamIndependentSkillsStep(game); + step = next && next.action.type === 'traitorCall' + ? { ...next, action: { type: 'traitorCall', call: options.callers?.includes(next.actor) ?? false } } + : next; + } + assert.ok(step, 'Resolve both original plans before their physical aftermath'); + options.actions?.push(structuredClone(step)); + game = applyAction(game, step.actor, step.action); + } + throw Error('The original independent-card battle aftermath did not reach Collection'); +} diff --git a/tests/fixture-ix-independent-skills.ts b/tests/fixture-ix-independent-skills.ts new file mode 100644 index 00000000..f0660bf8 --- /dev/null +++ b/tests/fixture-ix-independent-skills.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, + type Action, type Game, type Player, +} from '../game/engine'; +import { treacheryDeck, type Card } from '../game/cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { traitorDeck } from '../game/traitors'; +import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { forceRevivalQuote } from '../game/revival'; +import { botActions } from '../game/bots'; +import type { Difficulty } from '../game/bot-profiles'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle'; + +export type IxIndependentSkillsStep = { actor: string; action: Action }; +export type IxIndependentSkillsOptions = { advanced?: boolean }; +export const ixIndependentSkillsPlayer = (game: Game, actor: string): Player => { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +}; +export const ixIndependentSkillsClean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; + +/** Scoped real shuffle entropy, restored even when a native action rejects. */ +function entropy(run: () => T, rolls: readonly number[] = []): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) { + for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff; + } else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function skillRolls(): number[] { + const source = LEADER_SKILL_CARDS.map(c => c.id); + const chosen: LeaderSkillId[] = ['suk-graduate', 'killer-medic', 'master-of-assassins']; + const rest = source.filter(c => !chosen.includes(c)); + const desired = chosen.flatMap(c => [c, rest.shift()!]).concat(rest); + const work = [...source], rolls: number[] = []; + for (let i = work.length - 1; i > 0; i--) { + const from = work.indexOf(desired[i]); assert.ok(from >= 0 && from <= i); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1))); + [work[i], work[from]] = [work[from], work[i]]; + } + return rolls; +} +export function stepIxIndependentSkills(game: Game, step: IxIndependentSkillsStep): Game { + return entropy(() => applyAction(game, step.actor, step.action)); +} +/** Original private projection and real policy; never alter the human lobby seat. */ +export function ixIndependentSkillsPolicyActions(game: Game, actor: string, difficulty: Difficulty): Action[] { + const view = viewGame(game, actor); + view.players.find(p => p.id === actor)!.bot = difficulty; + return entropy(() => botActions(view)); +} +/** Stop at allocations and rescue: these remain actual projected human controls. */ +export function nextIxIndependentSkillsStep(game: Game): IxIndependentSkillsStep | null { + if (!game.phaseOpening && !game.response) { + const d = game.decision; + if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, + action: { type: 'leaderSkillVisibility', event: d.event, hide: d.player === 'ixians' } }; + if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } }; + if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } }; + if (d?.kind === 'mentatQuestion') return { actor: d.player, + action: { type: 'decision', event: d.event, decline: true } }; + if (d?.kind === 'rihani') return { actor: d.player, + action: { type: 'decision', event: d.event, draw: false } }; + if (d?.kind === 'sukRescue' || d?.kind === 'harassWithdraw') return null; + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceIxIndependentSkills(state: Game, until: (game: Game) => boolean, + actions: IxIndependentSkillsStep[] = []): Game { + let game = structuredClone(state); + for (let i = 0; i < 1600; i++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextIxIndependentSkillsStep(game); assert.ok(step, 'Supply the original private plan, typed return or rescue'); + actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step); + } + throw Error('Native independent programme did not reach its requested boundary'); +} +/** Only physical card custody is staged; no stock, forces, wallets or receipts. */ +function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card { + const p = ixIndependentSkillsPlayer(game, actor), held = p.hand.find(c => c.id === id); + if (held) return held; + const zones = [{ label: 'remaining original deck', cards: game.deck }, + { label: 'original discard', cards: game.discard }, + ...game.players.filter(other => other.id !== actor).map(other => ({ label: other.id, cards: other.hand }))]; + const source = zones.find(zone => zone.cards.some(c => c.id === id)); assert.ok(source); + if (p.hand.length >= handLimit(p)) { + const returned = p.hand.shift()!; game.deck.push(returned); + staging.push(`Conserved ${returned.id}: ${actor} hand → original remaining deck for printed hand capacity`); + } + const card = source.cards.splice(source.cards.findIndex(c => c.id === id), 1)[0]; + p.hand.push(card); staging.push(`Conserved ${id}: ${source.label} → ${actor} hand; not a purchase or native deal`); + return card; +} +export function assertIxIndependentSkillsCustody(game: Game): void { + validateLeaderSkills(game.leaderSkills!, game.players); + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + assert.deepEqual(cards.map(c => c.id).sort(), + [...treacheryDeck(['ix']), ...ecazTreacheryCards()].map(c => c.id).sort()); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20); + assert.ok([p.reserves, p.tanks, ...Object.values(p.forces)].every(n => Number.isSafeInteger(n) && n >= 0)); + assert.ok(p.hand.length <= handLimit(p)); + if (p.elites) { + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), 7); + assert.ok([p.elites.reserves, p.elites.tanks, ...Object.values(p.elites.forces)] + .every(n => Number.isSafeInteger(n) && n >= 0)); + assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks); + for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0)); + } + } + if (game.status === 'playing') assert.deepEqual([ + ...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors), + ...game.players.flatMap(p => (p.faceDancers ?? []).map(c => c.leader)), + ].sort(), traitorDeck(game.players, true).sort()); +} +export type IxIndependentSkillsFixture = { + initial: Game; beforeIxCard: Game; ixCard: IxIndependentSkillsStep; offered: Game; afterSetup: Game; + beforeShipment: Game; shipment: IxIndependentSkillsStep; afterShipment: Game; + move: IxIndependentSkillsStep; afterMove: Game; opponentMove: IxIndependentSkillsStep; + beforeBattle: Game; battleStep: IxIndependentSkillsStep; + /** Original before-plan window, not a synthetic revealed Battle. */ + game: Game; plans: IxIndependentSkillsStep[]; returns: HarassWithdrawSelection; + ixians: string; tleilaxu: string; opponent: string; trainer: string; + first: string; second: string; actions: IxIndependentSkillsStep[]; staging: string[]; +}; + +/** Normal fresh lobby start → original Ix card-before-training → native armies → + * real desert shipment and split movement. Only the two singleton cards move zones. */ +export function createIxIndependentSkillsFixture(options: IxIndependentSkillsOptions = {}): IxIndependentSkillsFixture { + const advanced = options.advanced ?? false; + let game = createGame('IXINDEPENDENTSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); + joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + const initial = structuredClone(game), actions: IxIndependentSkillsStep[] = [], staging: string[] = []; + const record = (step: IxIndependentSkillsStep): Game => { + actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step); + return game; + }; + for (const option of ['leaderSkills', 'ecazTreachery'] as const) + record({ actor: game.host, action: { type: 'moduleOption', module: option, enabled: true } }); + for (const p of game.players) record({ actor: p.id, action: { type: 'ready' } }); + const start: IxIndependentSkillsStep = { actor: game.host, + action: { type: 'start', ...(advanced ? { advancedPreview: true } : {}) } }; + actions.push(start); game = entropy(() => applyAction(game, start.actor, start.action), skillRolls()); + staging.push('Scoped/restored original all14 lottery and canonical native setup shuffle; no offer or deck replacement'); + assert.equal(game.setupStage, 'skillTreachery'); assert.equal(game.decision?.kind, 'ixSetup'); + const beforeIxCard = structuredClone(game); + const ixCard: IxIndependentSkillsStep = { actor: 'ixians', action: { type: 'decision', card: game.ixSetupCards![0].id } }; + game = record(ixCard); const offered = structuredClone(game); + const chosen: Record = { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'master-of-assassins' }; + for (let i = 0; game.status === 'setup' && i < 120; i++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor]; + assert.ok(offer.cards.includes(chosen[actor])); + const view = viewGame(game, actor).leaderSkills!; + const leader = ixIndependentSkillsPlayer(game, actor).leaders + .filter(l => view.eligibleLeaders.some(e => e.id === l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader); record({ actor, action: { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id } }); + } else { const step = nextIxIndependentSkillsStep(game); assert.ok(step); record(step); } + } + assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game); + const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader; + game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions); + const beforeShipment = structuredClone(game), first = 'pasty_mesa:5', second = 'pasty_mesa:6'; + const shipment: IxIndependentSkillsStep = { actor: 'ixians', action: { + type: 'ship', territory: 'pasty_mesa', sector: 5, amount: 4, elite: 2, allyPayment: 0 } }; + record(shipment); + game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g) && !g.pendingShipment, actions); + const afterShipment = structuredClone(game); + const move: IxIndependentSkillsStep = { actor: 'ixians', action: { + type: 'move', from: first, amount: 2, elite: 1, territory: 'pasty_mesa', sector: 6 } }; + record(move); + game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions); + const afterMove = structuredClone(game); record({ actor: 'ixians', action: { type: 'endMovement' } }); + game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions); + const opponentMove: IxIndependentSkillsStep = { actor: 'guild', action: { + type: 'move', from: 'tueks_sietch:5', amount: 5, territory: 'pasty_mesa', sector: 6 } }; + record(opponentMove); + game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions); + record({ actor: 'guild', action: { type: 'endMovement' } }); + game = advanceIxIndependentSkills(game, g => g.phase === 6 && ixIndependentSkillsClean(g), actions); + conserveCard(game, 'ixians', REINFORCEMENTS_CARD, staging); + conserveCard(game, 'ixians', HARASS_WITHDRAW_CARD, staging); + const beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'pasty_mesa'); assert.ok(choice); + const battleStep: IxIndependentSkillsStep = { actor: choice.chooser, + action: { type: 'chooseBattle', territory: 'pasty_mesa', target: choice.defender } }; + record(battleStep); + game = advanceIxIndependentSkills(game, g => !!g.battle && !g.battle.revealed && ixIndependentSkillsClean(g), actions); + const enemy = ixIndependentSkillsPlayer(game, 'guild').leaders + .filter(l => !l.dead && l.id !== game.leaderSkills!.assignments.find(a => a.owner === 'guild')!.leader) + .sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy); + const plans: IxIndependentSkillsStep[] = [ + { actor: 'ixians', action: { type: 'battlePlan', dial: advanced ? 1.5 : 2.5, support: 0, + leader: trainer, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } }, + { actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } }, + ]; + const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 0, elite: 1 } }; + assertIxIndependentSkillsCustody(game); + return { initial, beforeIxCard, ixCard, offered, afterSetup, beforeShipment, shipment, afterShipment, + move, afterMove, opponentMove, beforeBattle, battleStep, game, plans, returns, + ixians: 'ixians', tleilaxu: 'tleilaxu', opponent: 'guild', trainer, first, second, actions, staging }; +} +export function revealIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = f.game): Game { + let game = structuredClone(state); + for (const step of f.plans) game = stepIxIndependentSkills(game, step); + assert.ok(game.battle?.revealed); return game; +} +export function quoteIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = revealIxIndependentSkillsBattle(f)) { + return quoteIndependentSkillsBattle(state, { returns: f.returns }); +} +export function finishIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, + options: { state?: Game; rescue?: 'save' | 'none'; stop?: 'rescue' | 'collection' } = {}): Game { + let game = structuredClone(options.state ?? revealIxIndependentSkillsBattle(f)); + for (let i = 0; i < 400; i++) { + if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game; + if (game.phase === 7 && ixIndependentSkillsClean(game)) return game; + let step: IxIndependentSkillsStep | null; + const d = game.decision; + if (d?.kind === 'harassWithdraw') step = { actor: d.player, + action: { type: 'decision', event: d.event, returns: f.returns } }; + else if (d?.kind === 'sukRescue') { + const choice = options.rescue === 'none' ? d.options.findIndex(o => o.normal + o.elite === 0) + : d.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'elite'); + assert.ok(choice >= 0, 'Choose the original Cyborg/Suboid rescue'); + step = { actor: d.player, action: { type: 'decision', event: d.event, choice } }; + } else step = nextIxIndependentSkillsStep(game); + assert.ok(step); game = stepIxIndependentSkills(game, step); + } + throw Error('Native independent battle did not reach Collection'); +} + +/** Real combat Tanks → real escrow bribe and Mentat → turn-two Revival. */ +export function createIxIndependentSkillsRevivalFixture(options: IxIndependentSkillsOptions = {}) { + const f = createIxIndependentSkillsFixture(options); + const afterBattle = finishIxIndependentSkillsBattle(f, { rescue: 'none' }); + const bribe: IxIndependentSkillsStep = { actor: f.opponent, action: { type: 'bribe', target: f.ixians, amount: 3 } }; + let game = stepIxIndependentSkills(afterBattle, bribe); + game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival); + const staging = [...f.staging]; conserveCard(game, f.ixians, RECRUITS_CARD_ID, staging); + const beforeRecruits = structuredClone(game); + const priorReturn: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'revive', amount: 1, elite: 0 } }; + game = stepIxIndependentSkills(game, priorReturn); + game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival); + const beforePlay = structuredClone(game); + const play: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'card', card: RECRUITS_CARD_ID } }; + game = stepIxIndependentSkills(game, play); + game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival); + const beforeRevival = structuredClone(game); + const revive: IxIndependentSkillsStep = { actor: f.ixians, + action: { type: 'revive', amount: 3, elite: 1, freeElite: 0 } }; + const price = forceRevivalQuote(game, ixIndependentSkillsPlayer(game, f.ixians), 3, 1, 0); + game = stepIxIndependentSkills(game, revive); + game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival); + return { ...f, afterBattle, bribe, beforeRecruits, priorReturn, beforePlay, play, + beforeRevival, revive, price, afterRevival: game, staging }; +} diff --git a/tests/fixture-sandmaster-worm.ts b/tests/fixture-sandmaster-worm.ts index dd84a2c3..ec20e847 100644 --- a/tests/fixture-sandmaster-worm.ts +++ b/tests/fixture-sandmaster-worm.ts @@ -2,8 +2,8 @@ import { smugglerShipmentGame } from './smuggler-shipment-fixture'; import type { Action } from '../game/engine'; /** Genuine Leader Skills setup, followed by a conserved post-Nexus ride position. */ -export function sandmasterWormGame(advanced = false, intrusion = false) { - const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild'); +export function sandmasterWormGame(advanced = false, intrusion = false, independent = false) { + const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild', [], independent); const own = g.players[0]; own.name = 'Sandmaster rider'; own.forces = { 'wind_pass:14': 2, 'wind_pass:15': 2 }; diff --git a/tests/ix-independent-skills-runtime.test.ts b/tests/ix-independent-skills-runtime.test.ts new file mode 100644 index 00000000..b6f218f9 --- /dev/null +++ b/tests/ix-independent-skills-runtime.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { + advanceIxIndependentSkills, assertIxIndependentSkillsCustody, createIxIndependentSkillsFixture, + createIxIndependentSkillsRevivalFixture, finishIxIndependentSkillsBattle, + ixIndependentSkillsClean, ixIndependentSkillsPlayer as player, ixIndependentSkillsPolicyActions, + quoteIxIndependentSkillsBattle, + revealIxIndependentSkillsBattle, stepIxIndependentSkills, +} from './fixture-ix-independent-skills'; + +void test('original Ix deal and paid shipment preserve Cyborg/Suboid custody through the typed Harass commitment', () => { + for (const advanced of [false, true]) { + const f = createIxIndependentSkillsFixture({ advanced }); + assert.equal(f.beforeIxCard.setupStage, 'skillTreachery'); + assert.ok(f.beforeIxCard.players.every(p => p.hand.length === 0)); + assert.equal(f.beforeIxCard.ixSetupCards!.length, 3); + assert.equal(f.offered.setupStage, 'leaderSkills'); + assert.equal(player(f.offered, f.ixians).hand[0].id, f.ixCard.action.card); + assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.ixians)!.skill, 'suk-graduate'); + const before = player(f.beforeShipment, f.ixians), shipped = player(f.afterShipment, f.ixians); + assert.equal(before.reserves, 14); assert.equal(before.elites!.reserves, 4); + assert.equal(shipped.reserves, 10); assert.equal(shipped.elites!.reserves, 2); + assert.equal(shipped.spice, before.spice - 8, 'four desert shipments cost two spice per physical force'); + assert.equal(player(f.afterShipment, f.opponent).spice, player(f.beforeShipment, f.opponent).spice + 8); + assert.equal(shipped.forces[f.first], 4); assert.equal(shipped.elites!.forces[f.first], 2); + const moved = player(f.afterMove, f.ixians); + for (const key of [f.first, f.second]) { + assert.equal(moved.forces[key], 2); assert.equal(moved.elites!.forces[key], 1); + } + const revealed = revealIxIndependentSkillsBattle(f), quote = quoteIxIndependentSkillsBattle(f, revealed); + assert.deepEqual(quote.reinforcements, + [{ player: f.ixians, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]); + assert.deepEqual(quote.scores, { attacker: advanced ? 8.5 : 9.5, defender: 1 }, + 'Suboids always fight at half strength; unsupported Advanced Cyborgs are half their Basic strength'); + const withdrawal = quote.harassWithdraw![0]; + assert.deepEqual(withdrawal.returned, { normal: 1, elite: 1 }); + assert.deepEqual(withdrawal.locations, f.returns); + assert.equal(withdrawal.remaining.normal, 1); assert.equal(withdrawal.remaining.elite, 1); + assert.equal(revealed.decision?.kind, 'harassWithdraw'); + if (revealed.decision?.kind !== 'harassWithdraw') throw Error('Missing original typed withdrawal control'); + const event = revealed.decision.event; + assert.throws(() => stepIxIndependentSkills(revealed, { actor: f.ixians, + action: { type: 'decision', event, + returns: { [f.first]: { normal: 2, elite: 0 } } } }), /undialed|physical|return|allocation/i); + const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' }); + const p = player(rescue, f.ixians); + assert.equal(p.reserves, 9); assert.equal(p.elites!.reserves, 3); + assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0, 'only Suboids paid the reserve fee'); + assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1); + assert.equal(p.forces[f.second], 1); assert.equal(p.elites!.forces[f.second] ?? 0, 0); + assertIxIndependentSkillsCustody(rescue); + } +}); + +void test('the real trained Suk rescues a committed Cyborg and Suboid without refunding reserve fees', () => { + for (const advanced of [false, true]) { + const f = createIxIndependentSkillsFixture({ advanced }); + const rescue = finishIxIndependentSkillsBattle(f, { stop: 'rescue' }); + assert.equal(rescue.decision?.kind, 'sukRescue'); + if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original native rescue controls'); + assert.equal(rescue.decision.mode, 'skilled'); + assert.ok(rescue.decision.options.some(o => o.normal === 1 && o.elite === 1 && + o.kept?.kind === 'elite' && o.kept.key === f.first)); + const saved = finishIxIndependentSkillsBattle(f, { state: rescue }); + assert.equal(saved.lastBattleContext!.winner, f.ixians); + assert.equal(saved.lastBattleContext!.sukRescue!.completed, true); + const p = player(saved, f.ixians); + assert.equal(p.reserves, 10); assert.equal(p.elites!.reserves, 3); + assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0); + assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1); + assert.equal(p.forces[f.second] ?? 0, 0); + assert.equal(p.spice, 2, 'unsupported Cyborgs do not invent a support charge'); + assert.equal(player(saved, f.opponent).tanks, 5); + for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) + assert.equal(saved.discard.filter(c => c.id === id).length, 1); + assertIxIndependentSkillsCustody(saved); + const lost = finishIxIndependentSkillsBattle(f, { state: rescue, rescue: 'none' }); + assert.equal(player(lost, f.ixians).reserves, 9); + assert.equal(player(lost, f.ixians).elites!.reserves, 3); + assert.equal(player(lost, f.ixians).tanks, 5); + assert.equal(player(lost, f.ixians).elites!.tanks, 1); + assert.equal(player(lost, f.ixians).forces[f.first] ?? 0, 0); + assertIxIndependentSkillsCustody(lost); + } +}); + +void test('Recruits counts prior native free returns, preserves the cap and charges the paid Cyborg surcharge', () => { + for (const advanced of [false, true]) { + const f = createIxIndependentSkillsRevivalFixture({ advanced }); + const original = viewGame(f.beforeRecruits, f.ixians).revival; + assert.equal(original.limit, 3); assert.equal(original.freeRemaining, 1); + assert.equal(player(f.beforeRecruits, f.ixians).tanks, 5); + assert.equal(player(f.beforeRecruits, f.ixians).elites!.tanks, 1); + assert.equal(player(f.beforeRecruits, f.ixians).spice, 5, 'real turn-one escrow settled at the real Mentat Pause'); + assert.equal(viewGame(f.beforePlay, f.ixians).revival.freeRemaining, 0); + assert.equal(player(f.beforePlay, f.ixians).spice, player(f.beforeRecruits, f.ixians).spice); + assert.equal(player(f.beforePlay, f.tleilaxu).spice, player(f.beforeRecruits, f.tleilaxu).spice + 1, + 'native Tleilaxu receive one spice from the Bank for the real free revival'); + const active = viewGame(f.beforeRevival, f.ixians).revival; + assert.equal(active.limit, 7); assert.equal(active.freeRemaining, 1); + assert.equal(viewGame(f.beforeRevival, f.tleilaxu).revival.limit, 20, + 'the global card never reduces native unlimited revival to seven'); + assert.deepEqual(f.price, { free: 1, normalCost: 5, cost: 5 }, + 'one free Suboid plus one paid Suboid at two and one paid Cyborg at three'); + const before = player(f.beforeRevival, f.ixians), after = player(f.afterRevival, f.ixians); + assert.equal(after.spice, before.spice - 5); + assert.equal(player(f.afterRevival, f.tleilaxu).spice, player(f.beforeRevival, f.tleilaxu).spice + 5, + 'the actual paid native revival reaches Tleilaxu, not an invented Bank sink'); + assert.equal(after.reserves, before.reserves + 3); assert.equal(after.elites!.reserves, before.elites!.reserves + 1); + assert.equal(after.tanks, before.tanks - 3); assert.equal(after.elites!.tanks, 0); + assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 2); + assert.equal(f.afterRevival.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1); + assert.equal(viewGame(f.afterRevival, f.ixians).revival.freeRemaining, 0); + assertIxIndependentSkillsCustody(f.afterRevival); + } +}); + +void test('all four minimal policies can seal native paid plans and execute projected typed withdrawal and rescue controls', () => { + const f = createIxIndependentSkillsFixture({ advanced: true }); + const revealed = revealIxIndependentSkillsBattle(f); + const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' }); + for (const difficulty of DIFFICULTIES) { + const plan = ixIndependentSkillsPolicyActions(f.game, f.ixians, difficulty).find(a => a.type === 'battlePlan'); assert.ok(plan); + const sealed = stepIxIndependentSkills(f.game, { actor: f.ixians, action: plan }); + assert.equal(player(sealed, f.ixians).reserves, player(f.game, f.ixians).reserves, + 'sealing alone cannot spend the fee or dial physical counters'); + assert.equal(player(sealed, f.ixians).spice, player(f.game, f.ixians).spice); + assert.equal(player(sealed, f.ixians).tanks, 0); + const returns = ixIndependentSkillsPolicyActions(revealed, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(returns); + const allocated = stepIxIndependentSkills(revealed, { actor: f.ixians, action: returns }); + const committed = finishIxIndependentSkillsBattle(f, { state: allocated, stop: 'rescue' }); + assert.equal(player(committed, f.ixians).reserves, 9); + assert.equal(player(committed, f.ixians).elites!.reserves, 3); + assert.equal(player(committed, f.ixians).tanks, 3); + const choice = ixIndependentSkillsPolicyActions(rescue, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(choice); + const saved = stepIxIndependentSkills(rescue, { actor: f.ixians, action: choice }); + const done = advanceIxIndependentSkills(saved, g => g.phase === 7 && ixIndependentSkillsClean(g)); + assert.equal(player(done, f.ixians).tanks, 3, 'policy rescue saves the commitment, never the fee'); + assert.equal(player(done, f.ixians).elites!.tanks, 0); + assertIxIndependentSkillsCustody(done); + } +}); diff --git a/tests/sandmaster-worm.test.ts b/tests/sandmaster-worm.test.ts index eceb386c..7480bad8 100644 --- a/tests/sandmaster-worm.test.ts +++ b/tests/sandmaster-worm.test.ts @@ -17,9 +17,9 @@ function conserved(g: Game) { if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((s, n) => s + n, 0), 3); } } -void test('Sandmaster collects once at a worm destination for multiple source sectors and preserves normal movement', () => { - for (const advanced of [false, true]) { - const g = sandmasterWormGame(advanced); +void test('Sandmaster collects once at a worm destination with multiple sources, with or without independent cards', () => { + for (const advanced of [false, true]) for (const independent of [false, true]) { + const g = sandmasterWormGame(advanced, false, independent); const action = sandmasterRide(); if (!advanced) delete action.eliteForces; const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action); @@ -122,7 +122,6 @@ void test('Sandmaster requires the owned living native trainer and the supported assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null); } for (const mutate of [ - (g: typeof view) => { g.ecazTreachery = true; }, (g: typeof view) => { g.players[1].faction = 'richese'; }, ]) { const unsupported = structuredClone(view); mutate(unsupported); diff --git a/tests/smuggler-shipment-fixture.ts b/tests/smuggler-shipment-fixture.ts index 5def455e..36da1b6f 100644 --- a/tests/smuggler-shipment-fixture.ts +++ b/tests/smuggler-shipment-fixture.ts @@ -20,6 +20,7 @@ export function smugglerShipmentGame( skill: 'smuggler' | 'sandmaster' = 'smuggler', opponentFaction?: FactionId, expansions: readonly string[] = [], + independent = false, ): Game { let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced, [...expansions]); joinGame( @@ -27,6 +28,7 @@ export function smugglerShipmentGame( newPlayer('h', 'Opponent', opponentFaction ?? (faction === 'guild' ? 'emperor' : 'guild')), ); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + if (independent) g.ecazTreachery = true; const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === skill); let cursor = LEADER_SKILL_CARDS.length - 1; mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => { diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index a55865b0..1b2da253 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -45,7 +45,7 @@ async function main() { '\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' + '\nLeader-skills preserves canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Mixed E3 families outside the documented Nexus envelopes, other modules and played-game conversion remain excluded.', '\nAll fresh supported all14 Skills compositions retain mandatory normal Banker income, including original Nexus, Discovery, World and Semuta envelopes. Actual bank payer legs, once-per-phase use and original Mentat collection remain unchanged. No existing game is converted or retrofitted.', - '\n--profile leader-skills --ecaz-treachery composes original all14 Skills with the independent three-card variant in fresh classic2–6-seat Basic/Advanced lobbies, without other modules. Original36 Treachery, actual private offers/training and mandatory normal Banker income remain. Recruits uses original current-rate/usage accounting; Reinforcements pays three own reserves before Harass returns undialed counters, adding score2 without physical dial/support/casualties. Printed Warmaster Worthless prerequisites and Suk rescue remain distinct. Expansions, Homeworlds, Nexus, Discovery, Tech, Strongholds, other previews, public starts and played-game conversion remain excluded.', + '\n--profile leader-skills --ecaz-treachery composes original all14 Skills with the independent three-card variant in fresh classic or selected Ix and/or CHOAM native/base2–6-seat Basic/Advanced lobbies, without other modules. Original36/38/50 ordinary cards and any separate Richese cache retain actual native private offers/training and mandatory normal Banker income. Recruits preserves native free rates, prior use, Cyborg prices and unlimited exceptions; Reinforcements pays three own reserves before Harass returns exact undialed counters, adding score2 without physical dial/support/casualties. Printed Warmaster Worthless prerequisites, typed Suk rescue, No-Field funding and Planetologist alternate roles remain distinct. E3, Homeworlds, Nexus, Discovery, Tech, Strongholds, other previews and played-game conversion remain excluded. Supported selections are also available through the labelled normal-lobby preview.', '\n--profile homeworld-occupation --leader-skills preserves fresh classic Basic/Advanced original Homeworld occupation and all14 Skills without Nexus, Discovery, Tech, Strongholds or expansion/Betrayal overlays. Only actual final Bank portions qualify for normal Banker income, including the same payer’s Bureaucrat diversion. Allied income rounds once for the whole eligible invoice; private rounding/diversion attribution preserves full payer debits and occupied player shares. Ordinary --profile leader-skills --homeworlds retains the original supported World selection without adding occupation preview flags.', '\nLeader-skills also preserves canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families outside documented Nexus envelopes, other overlays, public activation and save conversion stay excluded.', '\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.',