From d1bf33d0bc5ebeb887af66e5a048a0805a594d53 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:10:29 +1000 Subject: [PATCH] Connect Basic CHOAM skills and preserve market Ghola choices Connect ordinary Leader Skill paths to Basic CHOAM with base opponents, the real 35-card Treachery deck and all fourteen skills. Retain wider development initializer behavior and existing mode/publication gates. Pause a declared sale until its Ghola revival and private skill choice finish. Bind the actual revived leader, offer event and completion to the saved interruption. Preserve nested Truthtrance disposal and exact sale responses; canceling a resumed sale leaves the free revival completed. Keep force-only and retained-skill revivals free of replacement offers. Add genuine setup, native card-sale, mandatory disposal, private custody, corruption and concurrent authenticated recovery regressions plus a two-through-six-player complete-game sample profile. Record the unresolved Sandmaster/native-HMS eligibility question without inventing a bonus. Validation: types, lint, 5101 offline tests and production build passed. Five genuine 2-6 player CHOAM Skills games completed with zero rejected actions. Independent review and 50 focused compatibility tests passed. Browser Y2UJGMCS exercised real setup, Ghola draw, exact private refresh, mobile assignment and resumed sale paying two spice once. Screenshot capture timed out; full visual acceptance remains open. All 1,583 prior rooms, 3,351 seats and auxiliary records remain intact; one QA room and human seat were added. No restart, reset or automation. Full AI implementation and tuning remain deferred until all non-AI features are complete, then target roughly 75% for each adjacent tier. --- README.md | 4 +- docs/CHOAM_LEADER_SKILLS.md | 99 ++++++ docs/CURRENT_STATUS.md | 32 +- docs/IMPLEMENTATION_STATUS.md | 9 + docs/IX_LEADER_SKILLS.md | 4 +- docs/LEADER_SKILLS_RUNTIME.md | 12 +- docs/RULE_DECISIONS.md | 2 + docs/SANDMASTER_MOVEMENT.md | 23 ++ docs/VERIFICATION_WORKFLOW.md | 4 + game/choam-market-ghola.ts | 67 ++++ game/engine.ts | 24 +- game/leader-skill-profile.ts | 11 +- game/reference.ts | 27 +- tests/choam-skills-fixture.ts | 184 ++++++++++ tests/choam-skills-ghola-fixture.ts | 89 +++++ tests/choam-skills-ghola.test.ts | 322 ++++++++++++++++++ tests/choam-skills-integration.test.ts | 451 +++++++++++++++++++++++++ tests/choam-skills-recovery.test.ts | 101 ++++++ tests/faction-games-cli.test.ts | 20 ++ tools/faction-games.ts | 25 +- 20 files changed, 1479 insertions(+), 31 deletions(-) create mode 100644 docs/CHOAM_LEADER_SKILLS.md create mode 100644 tests/choam-skills-fixture.ts create mode 100644 tests/choam-skills-ghola-fixture.ts create mode 100644 tests/choam-skills-ghola.test.ts create mode 100644 tests/choam-skills-integration.test.ts create mode 100644 tests/choam-skills-recovery.test.ts diff --git a/README.md b/README.md index 0fcc1181..b5101c35 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ and remaining boundaries](docs/MORITANI_LEADER_SKILLS.md). Basic Tleilaxu also supports the full deck, native Rihani exchanges and saved revival choices; see [its integration boundary](docs/TLEILAXU_LEADER_SKILLS.md). Basic Ixians now connect cyborg movement, HMS routes and battle aftermath through the same full deck; see -[Ixian integration](docs/IX_LEADER_SKILLS.md). +[Ixian integration](docs/IX_LEADER_SKILLS.md). The existing Basic CHOAM setup now +connects ordinary skill actions and saved market/revival choices; see +[CHOAM integration](docs/CHOAM_LEADER_SKILLS.md). Above the board, **Treachery draw pile** and **Spice draw pile** show live card counts separately from hands, auction cards and discards. See diff --git a/docs/CHOAM_LEADER_SKILLS.md b/docs/CHOAM_LEADER_SKILLS.md new file mode 100644 index 00000000..de2b1008 --- /dev/null +++ b/docs/CHOAM_LEADER_SKILLS.md @@ -0,0 +1,99 @@ +# Basic CHOAM with Leader Skills + +Development integration, 21 September 2026. Genuine CHOAM/Skills setup already +existed. This follow-up connects the ordinary skill action paths for Basic CHOAM +with base opponents and checks native card sales and revival interruptions. +It does not certify the full expansion or open public starts/publication. + +## Configuration and components + +The shared ordinary-skill profile requires Basic rules, exactly CHOAM & Richese, +two through six players including CHOAM, and no other optional modules. Other +seats belong to the six base factions. The existing initializer's wider Richese +and Advanced development admission is retained; those action combinations keep +their separate implementation boundaries. + +Setup keeps all fourteen physical skill cards and the real 35-card Treachery +deck: the base cards plus Poison Tooth and Artillery. Neither added battle card +is a green Special for Planetologist. CHOAM has its five ordinary Basic leaders; +Auditor is an additional Advanced disc and never an eligible skill assignment. +Starting cards precede private skill choices, public assignments and Traitors. + +## Connected paths + +The shared profile now admits ordinary Planetologist, Sandmaster, Smuggler, +Suk Graduate, Rihani, Diplomat copying and the existing Banker battle-spending +path. Existing missing bands stay explicit. Common rule helpers still supply +human controls and minimal legal AI choices; no strategy tuning is performed. + +CHOAM's native Worthless-card sales and powers retain their physical card +ownership and response windows. A card committed to a sale cannot also be spent +as a skill substitute. Planetologist uses a qualifying Special solely in its +battle role, without executing its native effect, then discards it through +mandatory winner cleanup. CHOAM cannot sell that already discarded card. + +Bureaucrat's existing qualifying third-party auction, shipment and bribe +payments remain available. The skill does not redirect its own owner's payments +or turn a small bank-funded market sale into a qualifying player-to-player +payment. Existing Richese-sale support is preserved. + +A real own-leader death returns its skill once. Ghola may revive that leader +while a declared CHOAM sale waits; the revived leader receives the normal +optional replacement offer. The Ghola discard, private draw/decline and any +required skill selection must finish before the exact original sale resumes. +The sale's card, quoted amount, response passes and physical ownership remain +bound throughout. Revival is free; canceling the resumed sale does not undo +the completed revival or duplicate its spent Ghola. + +New skill-enabled interruptions also bind the actual revived leader and private +offer event. Altered targets, omitted offers or lost owned decisions reject +without committing an action. A nested card disposal may temporarily hold that +same decision in its saved continuation. Ordinary non-Skills receipts retain +their existing format. A read-only scan before the new QA revival found no +existing skill-enabled market interruption requiring conversion. + +The [Leader Skills source contract](LEADER_SKILLS_RULES.md) supplies assignment, +death and own-revival rules. The existing [market rules](CHOAM_REMAINING_RULES.md) +and implemented market/Ghola continuation supply native timing. These are +composed existing effects, not a new way to cancel a skill or a new revival fee. +Later implementation evidence supersedes historical missing-code observations. + +## Entry and focused verification + +Create and ready a fresh Basic CHOAM lobby with the expansion selected, retaining +its room cookie. Use the backed-up, version-checked +[prototype entry](IX_PROTOTYPE.md) with profile `leader-skills`. + +```sh +npm test -- choam-skills +node --import tsx tools/faction-games.ts --profile choam-skills --out /private/new-choam-skills-samples +``` + +The sample profile adds Emperor, Guild, Harkonnen, Fremen and Bene Gesserit in +order for two through six players, with stable ordinals 31–35. Tests also use +Atreides. The normal/all sample defaults remain unchanged. + +Focused regressions use genuine full-deck setup and explicitly conserved +positions for skill/battle/market interactions, strict rejected-action +immutability, JSON/private-view recovery and physical force/card custody. +Authenticated in-memory recovery exercises concurrent private choices and +the interrupted-sale continuation. Final source-bound reports record actual +check/build, complete sample, browser and preservation results. + +The browser check completed genuine skill/Traitor setup, then used a backed-up +conserved battle position to kill the assigned leader and declare a sale. Real +controls revived Frankos Aru, drew two private skills, restored the identical +saved offer after refresh and assigned Killer Medic. The sale resumed, paid two +spice once and discarded its card once. Mobile controls fit a 390-pixel viewport +without horizontal overflow. Screenshot capture timed out; full visual acceptance +remains open. + +## Remaining scope + +Normal Banker income, ordinary Mentat questioning, Diplomat retreat, modified +Smuggler collection, captured replacement entitlement and other recorded skill +questions remain unfinished. Advanced Auditor rules and mixed Richese/other +faction or module combinations remain separate work. See the +[runtime boundaries](LEADER_SKILLS_RUNTIME.md) and [decision register](RULE_DECISIONS.md). +Full AI implementation and difficulty calibration wait until every non-AI +feature is complete. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6fb55891..827e704b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,22 +6,26 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered checkpoint: `d3559f6`, [Basic Tleilaxu with Leader Skills](TLEILAXU_LEADER_SKILLS.md). -Types, lint, **5,058 offline tests** and the production build passed; five +Latest delivered checkpoint: `3efda21`, [Basic Ixians with Leader Skills](IX_LEADER_SKILLS.md). +Types, lint, **5,082 offline tests** and the production build passed; five complete two-through-six-player samples had no rejected actions. Browser setup, -Rihani exchange, mobile controls and refresh were checked. All 1,581 prior rooms -and 3,349 seats remained intact; one QA room/seat brought the baseline to 1,582 -rooms and 3,350 human seats. Screenshot capture timed out; full visual acceptance -remains open. +first Storm/HMS placement, mobile Suk rescue and substitution, and same-state +refresh were checked. All 1,582 prior rooms and 3,350 seats remained intact; +one QA room/seat brought the baseline to 1,583 rooms and 3,351 human seats. +Screenshot capture timed out; full visual acceptance remains open. -Current work: [Basic Ixians with Leader Skills](IX_LEADER_SKILLS.md) connects -genuine starting-card/skill setup, per-origin cyborg movement, Sandmaster HMS -entry/exit, Suk rescue before actual cyborg substitution, Rihani precedence and -mandatory green-Special disposal. Saved receipts bind physical losses and choices. -Focused tests, authenticated setup recovery and a two-through-six-player sample -profile accompany final reports. Existing missing skill bands, Advanced and -other module combinations remain separate work. The Bureaucrat/Emperor gift -question is still pending. +Current work [connects ordinary Leader Skill actions to Basic CHOAM](CHOAM_LEADER_SKILLS.md), +using its existing genuine setup. Exact 35-card Treachery and fourteen-skill inventories, +native Worthless-card powers, mandatory battle disposal and interrupted market +Ghola revival are receiving integration and saved-continuation checks. Advanced +Auditor and Richese/other combinations retain their existing separate contracts. + +The [Sandmaster/HMS source comparison](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) +found no official clarification that passengers remaining in the interior +trigger collection from outside territories during native relocation. A ruling +question was sent; ordinary HMS relocation and force entry/exit remain working. +No extra collection is silently applied. The Bureaucrat/Emperor gift question +and the other recorded skill boundaries remain pending. The reviewed remaining native Ecaz/Moritani effects, Duke revival and Richese empty-cache auction arithmetic still cross recorded source questions. Native diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index faae01cd..1d508259 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,14 @@ # Dune implementation status +## 21 September 2026 — Basic CHOAM ordinary Leader Skill integration + +The existing genuine CHOAM setup now shares ordinary skill action guards for +its Basic/base roster. Full-deck, card-sale, mandatory battle cleanup and saved +market/Ghola replacement choices are covered by the [integration contract](CHOAM_LEADER_SKILLS.md). +Auditor, Richese and other mode/module boundaries retain their separate gates. +The Sandmaster/native-HMS source audit records an unresolved eligibility +question rather than assuming an extra collection power. + ## 21 September 2026 — Basic Ixians with Leader Skills Genuine full-deck setup connects cyborg movement per origin, Sandmaster HMS entry diff --git a/docs/IX_LEADER_SKILLS.md b/docs/IX_LEADER_SKILLS.md index aaeab70b..fa4c74bc 100644 --- a/docs/IX_LEADER_SKILLS.md +++ b/docs/IX_LEADER_SKILLS.md @@ -77,8 +77,8 @@ starting-card choice, subsequent private setup and unchanged seats. Final source-bound check/build, samples, browser and preservation reports remain private. These are playability checks, not AI strength studies. -Advanced Ixians, additional faction/module combinations, Sandmaster's extra -native HMS relocation band and the other missing Leader Skill effects remain +Advanced Ixians, additional faction/module combinations, unresolved Sandmaster +eligibility during native HMS relocation and other missing Leader Skill effects remain separate work. Existing Banker, Mentat, Diplomat and modified-Smuggler questions remain in the [decision register](RULE_DECISIONS.md). Full AI implementation and difficulty tuning wait until all non-AI game features are complete. diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 426ded4b..ab5cb2ad 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -185,4 +185,14 @@ setup, per-origin cyborg range, Sandmaster HMS entry/exit, explicit Suk cyborg rescue origins, Rihani-before-substitution and mandatory green-Special disposal. Tleilaxu/base opponents are supported through the complete deck; Advanced and other combinations remain separate. This supersedes earlier Ix admission gaps, -not the documented missing skill bands or Sandmaster native HMS relocation. +not the documented missing skill bands or unresolved Sandmaster eligibility +during native HMS relocation. + +## Basic CHOAM ordinary action integration + +[Basic CHOAM with Leader Skills](CHOAM_LEADER_SKILLS.md) connects ordinary +skill actions to the already available genuine setup. It retains the complete +fourteen-card skill and 35-card Treachery inventories, native card sales and +private own-revival replacement through interrupted market timing. Existing +Advanced/Richese setup admission remains, without extending this action profile +or certifying its separate effects. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index e251bc78..8846ca86 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -33,6 +33,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | | Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) | +| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) | | Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) | | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) | | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | @@ -57,6 +58,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Reinforcements cost | A user question asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost. No answer is assumed. Advanced Atreides loss accounting and same-plan Harass ordering are separate unresolved combinations. Eligibility must not depend on concealed matching Traitors. | [Fresh cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) | | Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) | | Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) | +| Sandmaster / native HMS relocation | No official source located establishes that passengers remaining inside the HMS enter outside territories for this skill. Extra collection eligibility is pending a user ruling; native sector collection and ordinary force entry/exit remain available. | [Source comparison and question](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) | | Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) | | Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) | | Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered. Every genuine Richese setup includes those cards and Stone Burner; an ordinary-copy-only admission gate cannot depend on concealed custody or alter the printed deck. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) | diff --git a/docs/SANDMASTER_MOVEMENT.md b/docs/SANDMASTER_MOVEMENT.md index adfcf0fc..258c7ead 100644 --- a/docs/SANDMASTER_MOVEMENT.md +++ b/docs/SANDMASTER_MOVEMENT.md @@ -74,3 +74,26 @@ collection, including saved arrival reactions. Remaining work includes other nonordinary relocation, special movement cards, expansion/module integration, multiple-pile adjudication and wider interaction and strategy acceptance. The lower battle band remains in [the battle-effects contract](LEADER_BATTLE_EFFECTS.md). + +## 21 September: native HMS relocation eligibility + +The [Basic Ixian integration](IX_LEADER_SKILLS.md) connects ordinary force entry +and exit through the current HMS pointer. Native relocation is a different event: +it changes the stronghold's pointing sector while its forces remain in the +separate interior territory. Calling a further Sandmaster collection a known +missing bonus was too strong; eligibility is unresolved. + +The [publisher-authored Ix rules, pages 9 and 11](https://cdn.1j1ju.com/medias/20/16/31-dune-ixians-tleilaxu-rulebook.pdf) +give native collection of two spice per force from traversed spice-containing +sectors, including departure, and prohibit movement into, out of or through the +storm. Sandmaster instead requires forces moving into or through a territory +and grants one total spice per entered territory. The +[official November FAQ, page 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +allows Karama to prevent HMS relocation and collection together. + +The checked card, designer walkthrough and available official rules did not +clarify whether passengers staying in the interior count as entering outside +territories. Skill-before-faction precedence settles ordering only if eligibility +is established. A user ruling question was sent; no answer is assumed. Native +relocation and ordinary Sandmaster entry/exit remain available. No extra +collection is silently applied, and full combined compliance remains unverified. diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 9f0da09b..76b2177b 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -117,6 +117,10 @@ Old room IDs in historical browser evidence no longer identify active local game Future games must be preserved. This was a one-time user-authorized reset, not a maintenance strategy or permission to clear future games. +The explicit `--profile choam-skills` runs Basic CHOAM and base opponents for +two through six players (ordinals 31–35), retaining the full skill module and +native 35-card Treachery deck; see [CHOAM integration](CHOAM_LEADER_SKILLS.md). + The explicit `--profile ix-skills` runs genuine Basic Ixian full-deck setup for two through six players, with Tleilaxu and base opponents. It preserves ordinals 26–30 and full custody/private saved continuation; see [Ix integration](IX_LEADER_SKILLS.md). diff --git a/game/choam-market-ghola.ts b/game/choam-market-ghola.ts index 64a55ee2..82ddbed0 100644 --- a/game/choam-market-ghola.ts +++ b/game/choam-market-ghola.ts @@ -1,6 +1,7 @@ import type { Game, ResponseWindow } from './engine'; import type { ChoamMarket } from './choam-market'; import { quoteChoamSaleCancellation } from './choam-sale-cancellation'; +import { CHOAM_AUDITOR_ID } from './cards'; /** The sale is suspended, never replayed. Its physical card stays in its hand. */ export type ChoamMarketGhola = { @@ -13,6 +14,13 @@ export type ChoamMarketGhola = { stage: 'discard' | 'income' | 'complete'; response: ResponseWindow; market: ChoamMarket; + /** New Skills interruptions bind their committed revival through the optional choice. */ + leaderSkill?: { + leader: string | null; + offer: string | null; + completed: boolean; + signature: string; + }; }; export function choamGholaEvent(pending: Pick player.id === pending.player)!; + const leader = owner.leaders.find(candidate => candidate.id === record.leader); + if (record.leader !== null && (!leader || leader.dead || leader.capturedBy || leader.gholaBy)) + return 'The market Ghola no longer has its own revived leader.'; + const offer = g.leaderSkills.offers[pending.player]; + if (record.offer === null) { + if (!record.completed || offer || (record.leader !== null && record.leader !== CHOAM_AUDITOR_ID && + !g.leaderSkills.assignments.some(assignment => assignment.owner === pending.player))) + return 'This market Ghola cannot omit its optional Leader Skill offer.'; + return null; + } + if (record.leader === null) + return 'A market Ghola skill offer must name its revived leader.'; + if (record.offer !== choamGholaSkillOfferEvent(pending, record.leader)) + return 'The market Ghola skill event no longer matches its committed revival.'; + if (record.completed) { + if (offer) return 'The completed market Ghola still has an unresolved skill offer.'; + } else { + if (!offer || offer.event !== record.offer || offer.leader !== record.leader) + return 'The market Ghola lost its original leader or optional skill offer.'; + // An ordinary-card/Truthtrance disposal may temporarily hold this owned + // decision in its continuation. Its exact event remains required there. + if (pending.stage === 'complete' && + !decisions.some(decision => decision?.kind === 'leaderSkillRevival' && + decision.player === pending.player && decision.event === record.offer)) + return 'The completed Ghola is waiting for its owned Leader Skill decision.'; + } + return null; +} + /** Public timing shared by the server and the hand's ordinary-card controls. */ export function choamSaleGholaTiming(state: { status: string; @@ -38,6 +98,10 @@ export function choamSaleGholaTiming(state: { * move the sale card or change Tupile before the original declaration resumes. */ export function choamMarketGholaError(g: Game): string | null { const pending = g.pendingChoamMarketGhola; + const marketOffers = Object.entries(g.leaderSkills?.offers ?? {}) + .filter(([, offer]) => typeof offer?.event === 'string' && offer.event.startsWith('["choamGholaSkillOffer",')); + if (marketOffers.length && (!pending || marketOffers.length !== 1 || marketOffers[0][0] !== pending.player)) + return 'The Ghola Leader Skill offer lost its suspended market.'; if (!pending) return null; if ( g.status !== 'playing' || pending.turn !== g.turn || pending.phase !== g.phase || @@ -87,6 +151,9 @@ export function choamMarketGholaError(g: Game): string | null { if (incomes.some((income) => income!.recipient !== pending.player || income!.amount !== 1 || !g.players.some((p) => p.id === income!.owner && p.faction === 'tleilaxu'))) return 'The saved Ghola income no longer matches the revived faction.'; + const skillError = skillRevivalError(g, pending, + contexts.map(context => context && 'decision' in context ? context.decision : undefined)); + if (skillError) return skillError; try { quoteChoamSaleCancellation(g, pending.response); } catch (error) { diff --git a/game/engine.ts b/game/engine.ts index 43d82cea..7c129ea5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -449,7 +449,7 @@ import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type Bidding import { quoteHomeworldPaymentIncome, quoteGuildPaymentRounding } from './homeworld-payment-income'; import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income'; import { resolveBattleWeapons } from './effective-weapons'; -import { choamGholaEvent, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; +import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; import { charityAmount, charityQuote, @@ -14384,6 +14384,7 @@ function resumeMarketGhola(g: Game) { if (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') return; const pending = g.pendingChoamMarketGhola; if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy || + g.leaderSkills?.offers[pending.player] || g.response || g.decision || g.pendingKarama || g.truthtrance || g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return; nexusChoamTradeIntegrity(g); @@ -14412,6 +14413,17 @@ function playMarketGhola(g: Game, p: Player, action: Action) { g.pendingChoamMarketGhola.event = choamGholaEvent(g.pendingChoamMarketGhola); g.response = null; applyGholaEffect(g, p, action, card.id); + if (g.leaderSkills) { + const pending = g.pendingChoamMarketGhola; + const offer = g.leaderSkills.offers[p.id]; + pending.leaderSkill = { + leader: p.leaders.find(leader => leader.id === action.leader)?.id ?? null, + offer: offer?.event ?? null, + completed: !offer, + signature: '', + }; + pending.leaderSkill.signature = choamGholaSkillSignature(pending); + } if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event; const used = discard(g, p, card.id); log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`, @@ -14445,6 +14457,10 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) { // GF9 November 2020 FAQ p.9 permits a Ghola return in another battle // this turn. Death history remains; the earlier battle location does not. delete l.usedAt; + if (g.pendingChoamMarketGhola && g.leaderSkills && !isAuditorLeader(l) && + !l.gholaBy && p.leaders.some(leader => leader.id === l.id)) + g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( + g.leaderSkills!, p.id, l.id, choamGholaSkillOfferEvent(g.pendingChoamMarketGhola!, l.id))); log( g, `${p.name} revived ${l.name} with Ghola. The leader may fight again this turn; no spice or normal leader-revival allowance was spent.`, @@ -21275,6 +21291,12 @@ function applyActionInner( g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random)); log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'}); } + const marketGhola = g.pendingChoamMarketGhola; + if (marketGhola?.player === id && marketGhola.leaderSkill?.offer === decision.event && + !g.leaderSkills.offers[id]) { + marketGhola.leaderSkill.completed = true; + marketGhola.leaderSkill.signature = choamGholaSkillSignature(marketGhola); + } g.decision = null; if (g.battle && !g.battle.revealed && !g.battle.plans[id] && [g.battle.attacker,g.battle.defender].includes(id) && g.leaderSkills.assignments.some((a) => a.owner === id) && g.battle.leaderSkillHidden?.[id] === undefined) g.decision = {kind:'leaderSkillVisibility',player:id,event:g.battle.event!,resumePowers:false}; diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index cb17b328..0932dc7b 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -46,9 +46,18 @@ export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean { FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')); } +/** Basic CHOAM's ordinary leaders and economy; Auditor belongs to Advanced. */ +export function basicChoamLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + return game.advanced === false && game.expansions.length === 1 && + game.expansions[0] === 'choam' && noOtherLeaderSkillModules(game) && + !!game.players?.some(player => player.faction === 'choam') && + game.players.every(player => player.faction === 'choam' || + FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')); +} + export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game) || - basicIxLeaderSkillsProfile(game); + basicIxLeaderSkillsProfile(game) || basicChoamLeaderSkillsProfile(game); } /** Shared by rule quotes, controls and minimal legal bot participation. */ diff --git a/game/reference.ts b/game/reference.ts index c44ece89..dbb98d6a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4000,6 +4000,29 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] }, ], }, + { + id: 'choam-leader-skills', + title: 'CHOAM with Leader Skills', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Basic CHOAM connects ordinary skill actions and private revival choices during a suspended sale.', + steps: [ + 'This development combination uses Basic CHOAM and base opponents, the full fourteen-skill deck and no other optional modules. Starting cards precede skills and Traitors. Its 35-card Treachery deck includes Poison Tooth and Artillery; neither is a green Special. Auditor belongs to Advanced and cannot receive a skill.', + 'Connected movement, shipping, payment and battle skills share their normal controls and legal choices. CHOAM cannot spend a card already committed to a sale or sell a Special already discarded after Planetologist battle use.', + 'Ghola may revive an eligible own leader while a declared sale waits. Finish the spent Ghola, optional private replacement draw or decline, and any required assignment before the original sale resumes. The replacement belongs to the actual revived leader.', + 'The sale resumes with its original card, price and response passes. Canceling that sale preserves the completed free revival and does not return the spent Ghola.', + 'Advanced Auditor, Richese and further combinations retain separate implementation boundaries. Remaining skill effects are unfinished; this prototype does not certify complete expansion rules or AI strength.', + ], + related: ['leader-skills', 'choam-modules', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Existing genuine setup now connects ordinary skill actions, native card sales, mandatory disposal and exact interrupted-sale revival continuation.', evidence: ['game/leader-skill-profile.ts', 'game/choam-market-ghola.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Implemented', detail: 'Existing private draw, decline and assignment controls complete the revival before returning to the sale response.', evidence: ['components/leader-skills.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All profiles follow current private offers and legal action lists. Full strategy and calibration wait for feature completion.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Exact configuration, inventory, source composition and remaining boundaries are recorded.', evidence: ['docs/CHOAM_LEADER_SKILLS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Full-deck setup, native powers, skill controls, card custody, saved-sale corruption, private concurrent recovery and bounded complete games have focused checks. Final acceptance remains open.', evidence: ['tests/choam-skills-integration.test.ts', 'tests/choam-skills-ghola.test.ts', 'tests/choam-skills-recovery.test.ts', 'tools/faction-games.ts'] }, + ], + }, { id: 'ixian-leader-skills', title: 'Ixians with Leader Skills', @@ -4012,7 +4035,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Planetologist calculates each gathered origin separately using its selected cyborgs. Canceling native cyborg movement leaves the independent skill range available. Sandmaster routes can enter or leave the HMS through its current pointer.', 'Suk Graduate rescues finish before substitution. Select the original sectors of rescued cyborgs where needed; only cyborgs actually sent to the Tanks can return through substitution. Rihani also finishes before native substitution.', 'Planetologist may substitute the green Thumper, Harvester or Amal in its weapon role. Their normal effects do not execute, and mandatory disposal still follows allowed, declined or canceled substitution.', - 'Missing skill bands, extra Sandmaster HMS relocation and other combined configurations remain unfinished. This prototype does not certify complete rules or AI strength.', + 'Missing skill bands, Sandmaster eligibility during native HMS relocation and other combined configurations remain unresolved or unfinished. This prototype does not certify complete rules or AI strength.', ], related: ['leader-skills', 'tleilaxu-leader-skills', 'implementation-checklist'], checklist: [ @@ -4096,7 +4119,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.', ...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`), ], - related: ['ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], + related: ['choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], checklist: [ { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] }, { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] }, diff --git a/tests/choam-skills-fixture.ts b/tests/choam-skills-fixture.ts new file mode 100644 index 00000000..9c315b64 --- /dev/null +++ b/tests/choam-skills-fixture.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; +import { treacheryDeck } from '../game/cards'; +import { + applyAction, + createGame, + initializeLeaderSkillsGameForAudit, + joinGame, + newPlayer, + type Action, + type Game, + type Player, +} from '../game/engine'; +import { + LEADER_SKILL_CARDS, + type LeaderSkillId, +} from '../game/leader-skill-cards'; +import { traitorDeck } from '../game/traitors'; + +export const CHOAM_SKILLS_SEATS = { + choam: 'c', + atreides: 'a', + emperor: 'e', +} as const; + +export type ChoamSkillsFaction = keyof typeof CHOAM_SKILLS_SEATS; +export type ChoamSkillsFixtureOptions = { + requestedSkill?: LeaderSkillId; + skillOwner?: ChoamSkillsFaction; + seats?: Readonly>; + code?: string; +}; + +const names: Record = { + choam: 'CHOAM', + atreides: 'Atreides', + emperor: 'Emperor', +}; + +export const reloadChoamSkillsGame = (game: Game): Game => + JSON.parse(JSON.stringify(game)) as Game; + +export function choamSkillsPlayer(game: Game, id: string): Player { + const player = game.players.find((candidate) => candidate.id === id); + assert.ok(player); + return player; +} + +export function rejectChoamSkillsAction( + game: Game, + id: string, + action: Action, + pattern?: RegExp, +): void { + const before = structuredClone(game); + if (pattern) assert.throws(() => applyAction(game, id, action), pattern); + else assert.throws(() => applyAction(game, id, action)); + assert.deepEqual(game, before); +} + +/** Count only physical inventories; projections and history are not custody. */ +export function assertChoamSkillsCustody(game: Game): void { + const skills = game.leaderSkills!; + const physicalSkills = [ + ...skills.deck, + ...Object.values(skills.offers).flatMap((offer) => offer.cards), + ...skills.assignments.map((assignment) => assignment.skill), + ]; + const canonicalSkills = LEADER_SKILL_CARDS.map((card) => card.id); + assert.equal(physicalSkills.length, 14); + assert.equal(new Set(physicalSkills).size, 14); + assert.deepEqual([...physicalSkills].sort(), [...canonicalSkills].sort()); + + const physicalTreachery = [ + ...game.deck, + ...game.discard, + ...game.players.flatMap((player) => player.hand), + ...(game.auction?.cards.slice( + game.auction.index + (game.currentAuctionSale ? 1 : 0), + ) ?? []), + ].map((card) => card.id); + const canonicalTreachery = treacheryDeck(['choam']).map((card) => card.id); + assert.equal(physicalTreachery.length, 35); + assert.equal(new Set(physicalTreachery).size, 35); + assert.deepEqual(physicalTreachery.sort(), canonicalTreachery.sort()); + + for (const player of game.players) { + const forces = + player.reserves + + player.tanks + + Object.values(player.forces).reduce((sum, count) => sum + count, 0); + assert.equal( + forces, + 20, + `${player.name} must retain all twenty physical forces`, + ); + } + + if (game.status === 'playing' || game.status === 'finished') { + const physicalTraitors = [ + ...game.traitorReserve!, + ...game.players.flatMap((player) => player.traitors), + ]; + const canonicalTraitors = traitorDeck(game.players, game.advanced); + assert.equal(physicalTraitors.length, canonicalTraitors.length); + assert.equal(new Set(physicalTraitors).size, physicalTraitors.length); + assert.deepEqual(physicalTraitors.sort(), canonicalTraitors.sort()); + } +} + +/** Real ready lobby and setup deal; only the production skill shuffle is controlled. */ +export function initializedChoamSkillsOffers( + options: ChoamSkillsFixtureOptions = {}, +): Game { + const requestedSkill = options.requestedSkill ?? 'planetologist'; + const skillOwner = options.skillOwner ?? 'choam'; + const seats = options.seats ?? CHOAM_SKILLS_SEATS; + const factions = ( + [skillOwner, 'choam', 'atreides', 'emperor'] as ChoamSkillsFaction[] + ).filter((faction, index, all) => all.indexOf(faction) === index); + const first = factions[0]; + let game = createGame( + options.code ?? 'CHOSKILL', + newPlayer(seats[first], names[first], first), + false, + ['choam'], + ); + for (const faction of factions.slice(1)) + joinGame(game, newPlayer(seats[faction], names[faction], faction)); + for (const player of game.players) + game = applyAction(game, player.id, { type: 'ready' }); + + const targetIndex = LEADER_SKILL_CARDS.findIndex( + (card) => card.id === requestedSkill, + ); + assert.ok(targetIndex >= 0); + let shuffleIndex = LEADER_SKILL_CARDS.length - 1; + mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => { + array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff; + return array; + }); + try { + game = initializeLeaderSkillsGameForAudit(game); + } finally { + mock.restoreAll(); + } + assert.equal(game.status, 'setup'); + assert.equal(game.setupStage, 'leaderSkills'); + assert.equal( + game.leaderSkills!.offers[seats[skillOwner]].cards[0], + requestedSkill, + ); + assertChoamSkillsCustody(game); + return game; +} + +/** Complete genuine skill assignment and ordinary Traitor selection. */ +export function completedChoamSkillsGame( + options: ChoamSkillsFixtureOptions = {}, +): Game { + const requestedSkill = options.requestedSkill ?? 'planetologist'; + const seats = options.seats ?? CHOAM_SKILLS_SEATS; + const requestedOwner = seats[options.skillOwner ?? 'choam']; + let game = initializedChoamSkillsOffers(options); + for (const player of game.players) { + const offer = game.leaderSkills!.offers[player.id]; + game = applyAction(game, player.id, { + type: 'leaderSkill', + event: offer.event, + skill: player.id === requestedOwner ? requestedSkill : offer.cards[0], + leader: player.leaders[0].id, + }); + } + assert.equal(game.setupStage, 'traitors'); + for (const player of game.players) { + const choice = choamSkillsPlayer(game, player.id).traitorChoices[0]; + assert.ok(choice); + game = applyAction(game, player.id, { type: 'traitor', leader: choice }); + } + assert.equal(game.status, 'playing'); + assert.equal(game.setupStage, undefined); + assertChoamSkillsCustody(game); + return game; +} diff --git a/tests/choam-skills-ghola-fixture.ts b/tests/choam-skills-ghola-fixture.ts new file mode 100644 index 00000000..70544fbc --- /dev/null +++ b/tests/choam-skills-ghola-fixture.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { applyAction, type Action, type Game } from '../game/engine'; +import { + assertChoamSkillsCustody, + choamSkillsPlayer, + completedChoamSkillsGame, + reloadChoamSkillsGame, +} from './choam-skills-fixture'; + +export const actChoamGhola = (game: Game, owner: string, action: Action): Game => + reloadChoamSkillsGame(applyAction(reloadChoamSkillsGame(game), owner, action)); + +export function passChoamGholaResponses(game: Game): Game { + for (let step = 0; game.response && step < 20; step++) { + const player = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); + assert.ok(player); + game = actChoamGhola(game, player.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); + return game; +} + +/** Genuine setup, conserved staged positions, then an actual lethal battle and phase-end sale. */ +export function stagedMarketGhola( + initial = completedChoamSkillsGame({ requestedSkill: 'warmaster' }), + keepAssignedSkill = false, +) { + let game = reloadChoamSkillsGame(initial); + const owner = game.players.find(player => player.faction === 'choam')!.id; + const opponent = game.players.find(player => player.faction === 'emperor')!.id; + const assignment = game.leaderSkills!.assignments.find(candidate => candidate.owner === owner)!; + assert.equal(assignment.skill, 'warmaster'); + const leader = keepAssignedSkill + ? choamSkillsPlayer(game, owner).leaders.find(candidate => candidate.id !== assignment.leader)!.id + : assignment.leader; + const lostSkill = assignment.skill; + for (const player of game.players) { + game.deck.push(...player.hand); + player.hand = []; + player.forces = [owner, opponent].includes(player.id) ? { 'wind_pass:14': 8 } : {}; + player.reserves = [owner, opponent].includes(player.id) ? 12 : 20; + player.tanks = 0; + player.battleLosses = 0; + player.spice = 20; + } + Object.assign(game, { phase: 6, active: owner, storm: 18, ready: [], + order: [owner, opponent, ...game.players.map(player => player.id).filter(id => ![owner, opponent].includes(id))], + decision: null, response: null, phaseOpening: null, spice: {} }); + const take = (id: string, predicate: (card: Game['deck'][number]) => boolean) => { + const index = game.deck.findIndex(predicate); + assert.ok(index >= 0); + const card = game.deck.splice(index, 1)[0]; + choamSkillsPlayer(game, id).hand.push(card); + return card.id; + }; + const ghola = take(owner, card => card.effect === 'ghola'); + const sale = take(owner, card => card.kind === 'worthless'); + const poison = take(opponent, card => card.kind === 'poison'); + const karama = take(opponent, card => card.effect === 'karama'); + assertChoamSkillsCustody(game); + game = actChoamGhola(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent }); + for (let step = 0; step < 30; step++) { + if (game.response) game = passChoamGholaResponses(game); + else if (game.decision?.kind === 'leaderSkillVisibility') + game = actChoamGhola(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true }); + else if (game.battle?.preparation) + game = actChoamGhola(game, game.battle.preparation.owner, { type: 'declineBattlePower' }); + else break; + } + assert.equal(game.decision, null); + assert.equal(game.battle?.preparation, undefined); + game = actChoamGhola(game, owner, { type: 'battlePlan', dial: 0, leader }); + const opponentLeader = choamSkillsPlayer(game, opponent).leaders.find(candidate => + !candidate.dead && !game.leaderSkills!.assignments.some(skill => skill.leader === candidate.id))!; + game = actChoamGhola(game, opponent, { type: 'battlePlan', dial: 4, leader: opponentLeader.id, weapon: poison }); + game = actChoamGhola(game, owner, { type: 'traitorCall', call: false }); + game = actChoamGhola(game, opponent, { type: 'traitorCall', call: false }); + assert.equal(game.decision?.kind, 'battleCards'); + game = actChoamGhola(game, opponent, { type: 'decision', discard: [] }); + assert.equal(choamSkillsPlayer(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, true); + assert.equal(game.leaderSkills!.assignments.some(candidate => candidate.owner === owner), keepAssignedSkill); + assert.equal(game.leaderSkills!.deck.filter(skill => skill === lostSkill).length, keepAssignedSkill ? 0 : 1); + assert.equal(game.phase, 6); + assert.equal(game.decision?.kind, 'choamMarket'); + game = actChoamGhola(game, owner, { type: 'decision', mode: 'sell', card: sale }); + assert.equal(game.response?.kind, 'choamSale'); + assertChoamSkillsCustody(game); + return { game, owner, opponent, leader, ghola, sale, karama, lostSkill }; +} diff --git a/tests/choam-skills-ghola.test.ts b/tests/choam-skills-ghola.test.ts new file mode 100644 index 00000000..0315cb2b --- /dev/null +++ b/tests/choam-skills-ghola.test.ts @@ -0,0 +1,322 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { runInNewContext } from 'node:vm'; +import { webcrypto } from 'node:crypto'; +import ts from 'typescript'; +import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + assertChoamSkillsCustody as custody, + choamSkillsPlayer as player, + reloadChoamSkillsGame as reload, + rejectChoamSkillsAction as reject, +} from './choam-skills-fixture'; +import { actChoamGhola as act, passChoamGholaResponses, stagedMarketGhola } from './choam-skills-ghola-fixture'; + +function stable(game: Game) { + custody(game); + assert.deepEqual(normalizeAutomaticGame(reload(game)), game); +} + +void test('market Ghola waits for the own revived leader skill offer before restoring its declared sale', () => { + const staged = stagedMarketGhola(); + const { owner, opponent, leader, ghola, sale } = staged; + let game = staged.game; + stable(game); + const originalResponse = structuredClone(game.response); + const originalMarket = structuredClone(game.choamMarket); + const spice = player(game, owner).spice; + game = act(game, owner, { type: 'card', card: ghola, leader }); + assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, false); + assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.usedAt, undefined); + assert.equal(player(game, owner).spice, spice, 'Ghola is free.'); + assert.equal(game.discard.filter(card => card.id === ghola).length, 1); + assert.equal(game.response, null, 'The sale waits while the replacement offer is undecided.'); + assert.equal(game.decision?.kind, 'leaderSkillRevival'); + assert.deepEqual(game.pendingChoamMarketGhola?.response, originalResponse); + assert.deepEqual(game.choamMarket, originalMarket); + assert.equal(game.pendingChoamMarketGhola?.stage, 'complete'); + const offer = game.leaderSkills!.offers[owner]; + assert.deepEqual(offer.cards, []); + assert.equal(offer.leader, leader); + assert.deepEqual(viewGame(game, owner).leaderSkills!.offer, offer); + assert.equal(viewGame(game, opponent).leaderSkills!.offer, null); + stable(game); + reject(game, opponent, { type: 'leaderSkill', event: offer.event, mode: 'draw' }); + game = act(game, owner, { type: 'leaderSkill', event: offer.event, mode: 'draw' }); + assert.equal(game.response, null); + const drawn = game.leaderSkills!.offers[owner].cards; + assert.equal(drawn.length, 2); + assert.equal(viewGame(game, opponent).leaderSkills!.offer, null); + reject(game, owner, { type: 'leaderSkill', event: offer.event, mode: 'decline' }); + reject(game, owner, { type: 'leaderSkill', event: offer.event, skill: drawn[0], leader: player(game, owner).leaders.find(candidate => candidate.id !== leader)!.id }); + stable(game); + game = act(game, owner, { type: 'leaderSkill', event: offer.event, skill: drawn[0], leader }); + assert.deepEqual(game.response, originalResponse); + assert.equal(game.pendingChoamMarketGhola, null); + assert.equal(game.leaderSkills!.offers[owner], undefined); + assert.equal(game.leaderSkills!.assignments.find(assignment => assignment.owner === owner)!.leader, leader); + stable(game); + reject(game, owner, { type: 'leaderSkill', event: offer.event, mode: 'draw' }); + game = passChoamGholaResponses(game); + assert.equal(game.decision?.kind, 'choamMarket'); + assert.equal(player(game, owner).spice, spice + 2); + assert.equal(game.discard.filter(card => card.id === sale).length, 1); + assert.equal(game.discard.filter(card => card.id === ghola).length, 1); + stable(game); +}); + +void test('declining the Ghola replacement and canceling its restored sale preserve the sunk Ghola and living unskilled leader', () => { + const staged = stagedMarketGhola(); + const { owner, opponent, leader, ghola, sale, karama, lostSkill } = staged; + let game = staged.game; + // A response pass predating the interruption must not be forgotten on restore. + game = act(game, owner, { type: 'passResponse' }); + const originalResponse = structuredClone(game.response); + const spice = Object.fromEntries(game.players.map(candidate => [candidate.id, candidate.spice])); + const forces = Object.fromEntries(game.players.map(candidate => [candidate.id, { + forces: candidate.forces, reserves: candidate.reserves, tanks: candidate.tanks, + }])); + game = act(game, owner, { type: 'card', card: ghola, leader }); + assert.equal(game.decision?.kind, 'leaderSkillRevival'); + const event = game.leaderSkills!.offers[owner].event; + stable(game); + game = act(game, owner, { type: 'leaderSkill', event, mode: 'decline' }); + assert.deepEqual(game.response, originalResponse); + assert.equal(game.pendingChoamMarketGhola, null); + assert.equal(game.leaderSkills!.offers[owner], undefined); + assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.owner === owner), false); + assert.equal(game.leaderSkills!.deck.filter(skill => skill === lostSkill).length, 1); + stable(game); + game = act(game, opponent, { type: 'card', card: karama, mode: 'cancel' }); + game = passChoamGholaResponses(game); + assert.equal(game.decision?.kind, 'choamMarket'); + assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, false); + assert.equal(game.discard.filter(card => card.id === ghola).length, 1); + assert.equal(game.discard.filter(card => card.id === karama).length, 1); + assert.equal(game.discard.some(card => card.id === sale), false); + assert.equal(player(game, owner).hand.some(card => card.id === sale), true); + assert.deepEqual(Object.fromEntries(game.players.map(candidate => [candidate.id, candidate.spice])), spice); + assert.deepEqual(Object.fromEntries(game.players.map(candidate => [candidate.id, { + forces: candidate.forces, reserves: candidate.reserves, tanks: candidate.tanks, + }])), forces); + assert.equal(game.log.filter(entry => entry.automatic?.name === 'Leader Skill lost').length, 1); + stable(game); + reject(game, owner, { type: 'leaderSkill', event, mode: 'decline' }); + reject(game, owner, { type: 'decision', mode: 'sell', card: sale }); + game = act(game, owner, { type: 'decision', done: true }); + assert.equal(game.phase, 7); + assert.equal(game.choamMarket, null); + assert.equal(game.pendingChoamMarketGhola, null); + assert.equal(game.leaderSkills!.offers[owner], undefined); + stable(game); +}); + +void test('invalid Ghola leader targets cannot consume the card, create a replacement offer or alter the pending sale', () => { + const { game, owner, opponent, leader, ghola } = stagedMarketGhola(); + const targets = ['missing-leader', player(game, owner).leaders.find(candidate => !candidate.dead)!.id, + player(game, opponent).leaders[0].id]; + for (const target of targets) + reject(game, owner, { type: 'card', card: ghola, leader: target }, /dead leader|eligible leader/i); + reject(game, opponent, { type: 'card', card: ghola, leader }, /Karama|card|response/i); + const captured = reload(game); + player(captured, owner).leaders.find(candidate => candidate.id === leader)!.capturedBy = opponent; + // A malformed saved captured target is rejected before any physical disposal. + reject(captured, owner, { type: 'card', card: ghola, leader }); + assert.equal(game.leaderSkills!.offers[owner], undefined); + assert.equal(game.pendingChoamMarketGhola, undefined); + assert.equal(player(game, owner).hand.some(card => card.id === ghola), true); + stable(game); +}); + +void test('the interrupted sale receipt stays bound to its actor and physical Ghola through the private skill choice', () => { + const staged = stagedMarketGhola(); + const { owner, opponent, leader, ghola, sale } = staged; + const game = act(staged.game, owner, { type: 'card', card: ghola, leader }); + assert.equal(game.decision?.kind, 'leaderSkillRevival'); + const event = game.leaderSkills!.offers[owner].event; + const mutations: ((saved: Game) => void)[] = [ + saved => { saved.pendingChoamMarketGhola!.player = opponent; }, + saved => { saved.pendingChoamMarketGhola!.card = sale; }, + saved => { saved.pendingChoamMarketGhola!.discardSequence = 0; }, + saved => { saved.pendingChoamMarketGhola!.market.sale!.price++; }, + ]; + for (const mutate of mutations) { + const saved = reload(game); + mutate(saved); + const before = structuredClone(saved); + reject(saved, owner, { type: 'leaderSkill', event, mode: 'decline' }); + assert.throws(() => viewGame(saved, owner)); + assert.throws(() => normalizeAutomaticGame(saved)); + assert.deepEqual(saved, before); + } + stable(game); +}); + +void test('all four bot profiles can finish both private replacement stages without duplicating market settlement', () => { + const staged = stagedMarketGhola(); + const { owner, leader, ghola, sale } = staged; + const interrupted = act(staged.game, owner, { type: 'card', card: ghola, leader }); + for (const difficulty of DIFFICULTIES) { + let game = reload(interrupted); + for (let stage = 0; stage < 2; stage++) { + assert.equal(game.decision?.kind, 'leaderSkillRevival'); + const view = viewGame(game, owner); + view.players.find(candidate => candidate.id === owner)!.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'leaderSkill'); + assert.ok(action, `${difficulty} has a legal replacement action at stage ${stage}.`); + for (const other of game.players.filter(candidate => candidate.id !== owner)) { + const otherView = viewGame(game, other.id); + otherView.players.find(candidate => candidate.id === other.id)!.bot = difficulty; + assert.equal(otherView.leaderSkills!.offer, null); + assert.equal(botActions(otherView).some(candidate => candidate.type === 'leaderSkill'), false); + } + game = act(game, owner, action); + stable(game); + } + assert.equal(game.response?.kind, 'choamSale'); + assert.equal(game.pendingChoamMarketGhola, null); + game = passChoamGholaResponses(game); + assert.equal(game.discard.filter(card => card.id === ghola).length, 1); + assert.equal(game.discard.filter(card => card.id === sale).length, 1); + assert.equal(player(game, owner).spice, player(interrupted, owner).spice + 2); + stable(game); + } +}); + +void test('the market Ghola cannot change its revived leader, replace its skill event or omit the pending choice', () => { + const staged = stagedMarketGhola(); + const { owner, leader, ghola } = staged; + const game = act(staged.game, owner, { type: 'card', card: ghola, leader }); + const event = game.leaderSkills!.offers[owner].event; + assert.equal(game.pendingChoamMarketGhola!.leaderSkill!.leader, leader); + assert.equal(game.pendingChoamMarketGhola!.leaderSkill!.offer, event); + assert.equal(game.pendingChoamMarketGhola!.leaderSkill!.completed, false); + const wrongLeader = player(game, owner).leaders.find(candidate => candidate.id !== leader)!.id; + const mutations: ((saved: Game) => void)[] = [ + saved => { saved.leaderSkills!.offers[owner].leader = wrongLeader; }, + saved => { + saved.leaderSkills!.offers[owner].event = 'changed-skill-event'; + if (saved.decision?.kind === 'leaderSkillRevival') saved.decision.event = 'changed-skill-event'; + }, + saved => { saved.decision = null; }, + saved => { delete saved.leaderSkills!.offers[owner]; }, + saved => { delete saved.leaderSkills!.offers[owner]; saved.decision = null; }, + saved => { delete saved.pendingChoamMarketGhola!.leaderSkill; }, + saved => { saved.pendingChoamMarketGhola = null; }, + saved => { saved.pendingChoamMarketGhola!.leaderSkill!.leader = wrongLeader; }, + saved => { saved.pendingChoamMarketGhola!.leaderSkill!.offer = 'changed-skill-event'; }, + saved => { saved.pendingChoamMarketGhola!.leaderSkill!.completed = true; }, + ]; + for (const mutate of mutations) { + const saved = reload(game); + mutate(saved); + const before = structuredClone(saved); + reject(saved, owner, { type: 'leaderSkill', event, mode: 'draw' }); + assert.throws(() => viewGame(saved, owner)); + assert.throws(() => normalizeAutomaticGame(saved)); + assert.deepEqual(saved, before); + } + const drawn = act(game, owner, { type: 'leaderSkill', event, mode: 'draw' }); + const missing = reload(drawn); + // Preserve all fourteen physical cards while trying to erase the required choice. + missing.leaderSkills!.deck.push(...missing.leaderSkills!.offers[owner].cards); + delete missing.leaderSkills!.offers[owner]; + missing.decision = null; + custody(missing); + reject(missing, owner, { type: 'passResponse' }); + assert.throws(() => normalizeAutomaticGame(missing)); + stable(drawn); +}); + +void test('market Ghola reviving an unskilled own leader preserves the existing assignment and creates no replacement offer', () => { + const staged = stagedMarketGhola(undefined, true); + const { owner, leader, ghola, sale } = staged; + const skills = structuredClone(staged.game.leaderSkills); + const response = structuredClone(staged.game.response); + let game = act(staged.game, owner, { type: 'card', card: ghola, leader }); + assert.deepEqual(game.leaderSkills, skills); + assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, false); + assert.equal(game.pendingChoamMarketGhola, null); + assert.deepEqual(game.response, response); + assert.equal(game.decision, null); + stable(game); + game = passChoamGholaResponses(game); + assert.equal(game.discard.filter(card => card.id === ghola).length, 1); + assert.equal(game.discard.filter(card => card.id === sale).length, 1); + assert.deepEqual(game.leaderSkills, skills); + stable(game); +}); + +void test('market Ghola force revival creates no Leader Skill offer and resumes the exact sale', () => { + const staged = stagedMarketGhola(); + const { owner, ghola, leader } = staged; + const before = player(staged.game, owner); + const skills = structuredClone(staged.game.leaderSkills); + const game = act(staged.game, owner, { type: 'card', card: ghola, amount: 2, elite: 0 }); + assert.equal(player(game, owner).tanks, before.tanks - 2); + assert.equal(player(game, owner).reserves, before.reserves + 2); + assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, true); + assert.deepEqual(game.leaderSkills, skills); + assert.deepEqual(game.response, staged.game.response); + assert.equal(game.pendingChoamMarketGhola, null); + stable(game); +}); + +void test('a saved Truthtrance disposal restores its held Ghola skill choice before the original sale', () => { + const staged = stagedMarketGhola(); + const { owner, opponent, leader, ghola, sale } = staged; + let game = staged.game; + const index = game.deck.findIndex(card => card.effect === 'truthtrance'); + assert.ok(index >= 0); + const truth = game.deck.splice(index, 1)[0]; + player(game, opponent).hand.push(truth); + const saleName = player(game, owner).hand.find(card => card.id === sale)!.name; + const originalResponse = structuredClone(game.response); + game = act(game, owner, { type: 'card', card: ghola, leader }); + const event = game.leaderSkills!.offers[owner].event; + game = act(game, opponent, { type: 'card', card: truth.id }); + while (game.truthtrance?.stage === 'priority') { + const voter = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!; + game = act(game, voter.id, { type: 'truthPass' }); + } + game = act(game, opponent, { type: 'truthAsk', question: { + kind: 'fact', target: owner, fact: { kind: 'hand', name: saleName }, + } }); + stable(game); + // Observe the unchanged production dispatcher before its wrapper retires the + // mandatory physical discard; this is a real continuation, not invented history. + const observed: { applyActionInner?: (state: Game, id: string, action: Action) => Game } = {}; + runInNewContext(ts.transpileModule( + readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') + '\nexport { applyActionInner };\n', + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }, + ).outputText, { exports: observed, require: createRequire(new URL('../game/engine.ts', import.meta.url)), + crypto: webcrypto, structuredClone, TextEncoder, JSON }); + const saved = reload(observed.applyActionInner!(game, owner, { type: 'truthAnswer', answer: 'yes' })); + assert.equal(saved.decision, null); + assert.equal(saved.pendingTreacheryDiscard!.continuation.kind, 'truthtranceDiscard'); + assert.equal(saved.pendingChoamMarketGhola!.stage, 'complete'); + assert.equal(viewGame(saved, owner).leaderSkills!.offer!.event, event); + const missing = reload(saved); + const continuation = missing.pendingTreacheryDiscard!.continuation; + assert.equal(continuation.kind, 'truthtranceDiscard'); + if (continuation.kind === 'truthtranceDiscard') continuation.resume.decision = null; + assert.throws(() => normalizeAutomaticGame(missing)); + game = reload(normalizeAutomaticGame(saved)); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.truthtrance, null); + assert.deepEqual(game.decision, { kind: 'leaderSkillRevival', player: owner, event }); + assert.equal(game.response, null); + assert.equal(game.discard.filter(card => card.id === truth.id).length, 1); + assert.equal(game.discard.filter(card => card.id === ghola).length, 1); + stable(game); + game = act(game, owner, { type: 'leaderSkill', event, mode: 'decline' }); + assert.deepEqual(game.response, originalResponse); + game = passChoamGholaResponses(game); + assert.equal(player(game, owner).spice, player(saved, owner).spice + 2); + assert.equal(game.discard.filter(card => card.id === sale).length, 1); + stable(game); +}); diff --git a/tests/choam-skills-integration.test.ts b/tests/choam-skills-integration.test.ts new file mode 100644 index 00000000..ece0cca7 --- /dev/null +++ b/tests/choam-skills-integration.test.ts @@ -0,0 +1,451 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TERRITORIES, location, splitLocation } from '../game/board'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { + basicChoamLeaderSkillsProfile, + ordinaryLeaderSkillModeSupported, +} from '../game/leader-skill-profile'; +import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment'; +import { planetologistMovementModeSupported } from '../game/planetologist-movement'; +import { sandmasterModeSupported } from '../game/sandmaster-movement'; +import { smugglerBattleModeSupported } from '../game/smuggler-battle'; +import { + applyAction, + createGame, + initializeLeaderSkillsGameForAudit, + joinGame, + newPlayer, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { planetologistMoveDraft } from '../components/planetologist-movement'; +import { sandmasterMoveDraft } from '../components/sandmaster-movement'; +import { + assertChoamSkillsCustody, + choamSkillsPlayer as player, + completedChoamSkillsGame, + initializedChoamSkillsOffers, + reloadChoamSkillsGame as reload, + rejectChoamSkillsAction as reject, +} from './choam-skills-fixture'; + +function movementPosition(game: Game): Game { + for (const participant of game.players) { + participant.forces = {}; + participant.reserves = 20; + participant.tanks = 0; + participant.spice = 20; + participant.moved = 0; + participant.shipped = true; + } + Object.assign(game, { + phase: 5, + active: 'c', + order: ['c', 'a', 'e'], + storm: 18, + ready: [], + decision: null, + response: null, + phaseOpening: null, + }); + return game; +} + +function passResponses(state: Game): Game { + let game = state; + while (game.response) { + const passer = game.players.find((candidate) => { + const controls = viewGame(game, candidate.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + assert.ok(passer); + game = applyAction(game, passer.id, { type: 'passResponse' }); + } + return game; +} + +function holdByName(game: Game, owner: string, name: string) { + const index = game.deck.findIndex((card) => card.name === name); + assert.ok(index >= 0, name); + const [card] = game.deck.splice(index, 1); + player(game, owner).hand.push(card); + return card; +} + +void test('all fourteen production skill faces deal through genuine Basic CHOAM setup', () => { + for (const skill of LEADER_SKILL_CARDS) { + const game = completedChoamSkillsGame({ requestedSkill: skill.id }); + assert.equal( + game.leaderSkills!.assignments.find( + (assignment) => assignment.owner === 'c', + )!.skill, + skill.id, + ); + assert.equal( + player(game, 'c').leaders.some((leader) => leader.id === 'choam-auditor'), + false, + ); + assert.equal(basicChoamLeaderSkillsProfile(game), true); + for (const participant of game.players) { + const view = viewGame(game, participant.id); + assert.equal(view.leaderSkills!.offer, null); + assert.equal(view.leaderSkills!.assignments.length, 3); + assert.deepEqual(viewGame(reload(game), participant.id), view); + } + assertChoamSkillsCustody(game); + } +}); + +void test('all bot profiles make a legal private skill choice and exact CHOAM profile gates are shared', () => { + const offer = initializedChoamSkillsOffers({ + requestedSkill: 'planetologist', + }); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(reload(offer), 'c'); + view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; + const action = botActions(view).find( + (candidate) => candidate.type === 'leaderSkill', + ); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(offer, 'c', action)); + } + + const game = completedChoamSkillsGame(); + for (const gate of [ + basicChoamLeaderSkillsProfile, + ordinaryLeaderSkillModeSupported, + bureaucratPaymentModeSupported, + planetologistMovementModeSupported, + sandmasterModeSupported, + smugglerBattleModeSupported, + ]) { + assert.equal(gate(game), true, gate.name); + assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`); + } +}); + +void test('Advanced CHOAM and Basic Richese retain audit admission without entering the exact normal profile', () => { + for (const variant of ['advanced', 'richese'] as const) { + let game = createGame( + variant === 'advanced' ? 'CHOADVAN' : 'CHORICHE', + newPlayer('c', 'CHOAM', 'choam'), + variant === 'advanced', + ['choam'], + ); + joinGame( + game, + variant === 'richese' + ? newPlayer('r', 'Richese', 'richese') + : newPlayer('a', 'Atreides', 'atreides'), + ); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + for (const participant of game.players) + game = applyAction(game, participant.id, { type: 'ready' }); + game = initializeLeaderSkillsGameForAudit(game); + assert.equal(game.setupStage, 'leaderSkills'); + assert.equal(basicChoamLeaderSkillsProfile(game), false); + if (variant === 'advanced') + assert.equal( + player(game, 'c').leaders.some( + (leader) => leader.id === 'choam-auditor', + ), + true, + ); + } +}); + +void test('CHOAM Planetologist and Sandmaster controls and bots submit legal shared movement actions', () => { + let game = movementPosition( + completedChoamSkillsGame({ requestedSkill: 'planetologist' }), + ); + const from = 'red_chasm:7'; + player(game, 'c').forces = { [from]: 3 }; + player(game, 'c').reserves = 17; + const target = TERRITORIES.filter((territory) => territory.type === 'sand') + .flatMap((territory) => + territory.sectors.map((sector) => location(territory.id, sector)), + ) + .map(splitLocation) + .find((at) => { + const draft = planetologistMoveDraft( + viewGame(game, 'c'), + 'range', + at.territory, + at.sector, + { [from]: 3 }, + {}, + ); + return draft.action && at.territory !== 'red_chasm'; + }); + assert.ok(target); + const draft = planetologistMoveDraft( + viewGame(game, 'c'), + 'range', + target.territory, + target.sector, + { [from]: 3 }, + {}, + ); + assert.equal(draft.blocked, null); + assert.ok(draft.action); + game.spice[location(target.territory, target.sector)] = 8; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'c'); + view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; + const action = botActions(view).find( + (candidate) => + candidate.type === 'move' && candidate.planetologist === 'range', + ); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(game, 'c', action)); + } + const moved = applyAction(reload(game), 'c', draft.action); + assert.equal( + player(moved, 'c').forces[location(target.territory, target.sector)], + 3, + ); + + game = movementPosition( + completedChoamSkillsGame({ requestedSkill: 'sandmaster' }), + ); + const sandFrom = 'wind_pass_north:17'; + player(game, 'c').forces = { [sandFrom]: 3 }; + player(game, 'c').reserves = 17; + for (const territory of TERRITORIES) + for (const sector of territory.sectors) + game.spice[location(territory.id, sector)] = 2; + const candidates = TERRITORIES.flatMap((territory) => + territory.sectors.map((sector) => ({ territory: territory.id, sector })), + ); + const control = candidates + .map((to) => + sandmasterMoveDraft(viewGame(game, 'c'), { + type: 'move', + territory: to.territory, + sector: to.sector, + forces: { [sandFrom]: 3 }, + }), + ) + .find((candidate) => candidate.action && candidate.collectible.length > 0); + assert.ok(control?.action); + assert.doesNotThrow(() => applyAction(reload(game), 'c', control.action!)); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'c'); + view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; + const action = botActions(view).find( + (candidate) => candidate.type === 'move' && candidate.sandmaster, + ); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(game, 'c', action)); + } +}); + +void test('CHOAM Smuggler ships its physical companion and Bureaucrat distinguishes qualifying payments', () => { + let game = movementPosition( + completedChoamSkillsGame({ requestedSkill: 'smuggler' }), + ); + player(game, 'c').shipped = false; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'c'); + view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; + const action = botActions(view).find( + (candidate) => candidate.type === 'ship' && candidate.smuggler === true, + ); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(game, 'c', action)); + } + const ship: Action = { + type: 'ship', + territory: 'carthag', + sector: 11, + amount: 3, + smuggler: true, + }; + game = applyAction(game, 'c', ship); + assert.equal(player(game, 'c').forces['carthag:11'], 3); + assert.equal(player(game, 'c').reserves, 17); + assert.equal(player(game, 'c').spice, 18); + assert.deepEqual(normalizeAutomaticGame(reload(game)), game); + assertChoamSkillsCustody(game); + + game = movementPosition( + completedChoamSkillsGame({ requestedSkill: 'bureaucrat' }), + ); + const own = applyAction(game, 'c', { type: 'bribe', target: 'e', amount: 5 }); + assert.notEqual(own.decision?.kind, 'bureaucratPayment'); + const small = applyAction(game, 'a', { + type: 'bribe', + target: 'e', + amount: 4, + }); + assert.notEqual(small.decision?.kind, 'bureaucratPayment'); + const bank = reload(game); + bank.active = 'a'; + player(bank, 'a').shipped = false; + const smallBankPayment = applyAction(bank, 'a', { + type: 'ship', + territory: 'carthag', + sector: 11, + amount: 1, + }); + assert.notEqual(smallBankPayment.decision?.kind, 'bureaucratPayment'); + game = applyAction(game, 'a', { type: 'bribe', target: 'e', amount: 5 }); + assert.equal(game.decision?.kind, 'bureaucratPayment'); + assert.equal(game.decision.player, 'c'); + const event = game.bureaucratPaymentEvent; + const done = applyAction(reload(game), 'c', { + type: 'decision', + event, + redirect: true, + }); + assert.equal(player(done, 'a').spice, 15); + assert.equal(player(done, 'e').bribes, 3); + assert.equal(done.bureaucratPayments!.used.length, 1); + reject(done, 'c', { type: 'decision', event, redirect: true }); + assertChoamSkillsCustody(done); +}); + +void test('native Worthless power executes through its response and preserves physical custody', () => { + let game = movementPosition( + completedChoamSkillsGame({ requestedSkill: 'warmaster' }), + ); + player(game, 'c').shipped = false; + const kulon = holdByName(game, 'c', 'Kulon'); + holdByName(game, 'e', 'Karama'); + game = applyAction(game, 'c', { + type: 'card', + mode: 'choam', + card: kulon.id, + }); + assert.equal(game.response?.kind, 'choamWorthless'); + assert.equal( + player(game, 'c').hand.some((card) => card.id === kulon.id), + true, + ); + game = passResponses(game); + assert.equal(game.choamMovement?.bonus, 1); + assert.equal(game.discard.filter((card) => card.id === kulon.id).length, 1); + assertChoamSkillsCustody(game); +}); + +void test('native Worthless market sale pays from the bank and preserves the 35-card skill game', () => { + let game = completedChoamSkillsGame({ requestedSkill: 'warmaster' }); + const worthless = holdByName(game, 'c', 'Baliset'); + holdByName(game, 'e', 'Karama'); + const spiceBefore = player(game, 'c').spice; + Object.assign(game, { + phase: 2, + active: null, + ready: [], + decision: null, + response: null, + phaseOpening: null, + choamCharity: { turn: game.turn, canceled: false }, + }); + for (const participant of game.players) + game = applyAction(game, participant.id, { type: 'ready' }); + assert.equal(game.decision?.kind, 'choamMarket'); + game = applyAction(game, 'c', { + type: 'decision', + mode: 'sell', + card: worthless.id, + }); + assert.equal(game.response?.kind, 'choamSale'); + assert.equal( + player(game, 'c').hand.some((card) => card.id === worthless.id), + true, + ); + game = passResponses(game); + assert.equal(player(game, 'c').spice, spiceBefore + 2); + assert.equal( + game.discard.filter((card) => card.id === worthless.id).length, + 1, + ); + assert.equal(game.bureaucratPayments?.pending, undefined); + assertChoamSkillsCustody(game); +}); + +void test('a real concealed Planetologist green Special is discarded once after battle', () => { + let game = completedChoamSkillsGame({ requestedSkill: 'planetologist' }); + const assignment = game.leaderSkills!.assignments.find( + (candidate) => candidate.owner === 'c', + )!; + for (const participant of game.players) { + game.deck.push(...participant.hand); + participant.hand = []; + participant.forces = { 'arrakeen:10': 5 }; + participant.reserves = 15; + } + for (const name of ['Poison Tooth', 'Artillery Strike']) { + const ordinary = game.deck.find((card) => card.name === name); + assert.ok(ordinary); + assert.equal(isPlanetologistBattleSpecialCard(ordinary), false); + } + const specialIndex = game.deck.findIndex(isPlanetologistBattleSpecialCard); + assert.ok(specialIndex >= 0); + const [special] = game.deck.splice(specialIndex, 1); + player(game, 'c').hand.push(special); + assert.equal( + ['Poison Tooth', 'Artillery Strike'].includes(special.name), + false, + ); + Object.assign(game, { + phase: 6, + storm: 18, + order: ['c', 'a', 'e'], + active: 'c', + ready: [], + decision: null, + response: null, + phaseOpening: null, + }); + game = applyAction(game, 'c', { + type: 'chooseBattle', + territory: 'arrakeen', + target: 'a', + }); + while (game.decision?.kind === 'leaderSkillVisibility') { + const decision = game.decision; + game = applyAction(game, decision.player, { + type: 'leaderSkillVisibility', + event: decision.event, + hide: decision.player === 'c', + }); + } + while (game.battle?.preparation) + game = applyAction(game, game.battle.preparation.owner, { + type: 'declineBattlePower', + }); + game = applyAction(game, 'c', { + type: 'battlePlan', + dial: 4, + leader: assignment.leader, + weapon: special.id, + }); + game = applyAction(game, 'a', { + type: 'battlePlan', + dial: 0, + leader: player(game, 'a').leaders.find( + (leader) => + leader.id !== + game.leaderSkills!.assignments.find( + (candidate) => candidate.owner === 'a', + )!.leader, + )!.id, + }); + game = applyAction(game, 'c', { type: 'traitorCall', call: false }); + game = applyAction(game, 'a', { type: 'traitorCall', call: false }); + assert.equal(game.discard.filter((card) => card.id === special.id).length, 1); + assert.equal( + player(game, 'c').hand.some((card) => card.id === special.id), + false, + ); + assert.equal(game.pendingWinnerDiscards, null); + assertChoamSkillsCustody(game); +}); diff --git a/tests/choam-skills-recovery.test.ts b/tests/choam-skills-recovery.test.ts new file mode 100644 index 00000000..0eddcf70 --- /dev/null +++ b/tests/choam-skills-recovery.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test, { mock } from 'node:test'; +import type { DatabaseSync } from 'node:sqlite'; +import type { Action } from '../game/engine'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { startPrototypeRoom } from '../tools/prototype-room'; +import { unitStore } from './fixture-nexus-room-store'; +import { assertChoamSkillsCustody as custody } from './choam-skills-fixture'; +import { stagedMarketGhola } from './choam-skills-ghola-fixture'; + +void test('authenticated CHOAM setup and market-Ghola replacement commit once across concurrent restarted writes', async () => { + const f = unitStore(), db: DatabaseSync = f.sqlite; + const clock = { now: () => 10000, sleep: async () => {} }; + try { + const created = await f.rooms.createRoom('CHOAM', 'choam', false, ['choam']); + const code = created.view.code; + const others = [await f.rooms.joinRoom(code, 'Atreides', 'atreides'), await f.rooms.joinRoom(code, 'Emperor', 'emperor')]; + const auths = await Promise.all([created.token!, ...others.map(p => p.token!)].map(token => f.restart().authenticate(code, token))); + const act = async (index: number, action: Action) => { + const before = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], before.version, action, clock); + return f.restart().readRoom(code); + }; + const concurrent = async (action: Action) => { + const before = await f.restart().readRoom(code); + let arrivals = 0; let release!: () => void; + const barrier = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; }; + const results = await Promise.allSettled([ + f.rooms.act(code, auths[0], before.version, action, clock), + f.restart().act(code, auths[0], before.version, action, clock), + ]); + delete f.hooks.beforeWrite; + assert.equal(arrivals, 2); + assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); + assert.equal(results.filter(r => r.status === 'rejected').length, 1); + const after = await f.restart().readRoom(code); + assert.equal(after.version, before.version + 1); + custody(after); + return after; + }; + for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' }); + const ready = await f.restart().readRoom(code), seats = db.prepare('SELECT * FROM seats').all(); + let cursor = LEADER_SKILL_CARDS.length - 1; + const target = LEADER_SKILL_CARDS.findIndex(c => c.id === 'warmaster'); + mock.method(crypto, 'getRandomValues', (array: Uint32Array) => { + array[0] = cursor-- === target ? 0 : 0xffffffff; return array; + }); + try { startPrototypeRoom(db, code, ready.version, 'leader-skills'); } + finally { mock.restoreAll(); } + let game = await f.restart().readRoom(code); + const own = auths[0].playerId, offer = game.leaderSkills!.offers[own]; + assert.ok(offer.cards.includes('warmaster')); custody(game); + game = await concurrent({ type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader: 'choam-0' }); + for (const i of [1, 2]) { + const p = game.players.find(p => p.id === auths[i].playerId)!, next = game.leaderSkills!.offers[p.id]; + game = await act(i, { type: 'leaderSkill', event: next.event, skill: next.cards[0], leader: p.leaders[0].id }); + } + for (let i = 0; i < auths.length; i++) + game = await act(i, { type: 'traitor', leader: game.players.find(p => p.id === auths[i].playerId)!.traitorChoices[0] }); + assert.equal(game.status, 'playing'); custody(game); + + // Preserve authenticated seats; only this disposable fixture's game is staged. + const prepared = stagedMarketGhola(game), originalResponse = structuredClone(prepared.game.response); + prepared.game.version = game.version + 1; + assert.equal(db.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(prepared.game), prepared.game.version, code, game.version).changes, 1); + const money = prepared.game.players.find(p => p.id === own)!.spice; + game = await act(0, { type: 'card', card: prepared.ghola, leader: prepared.leader }); + assert.equal(game.response, null); assert.equal(game.decision?.kind, 'leaderSkillRevival'); + const revival = game.leaderSkills!.offers[own]; + assert.deepEqual(revival.cards, []); + for (const i of [1, 2]) { + const view = await f.restart().readSeatView(code, auths[i]); + assert.equal(view.leaderSkills!.offer, null); + assert.equal('pendingChoamMarketGhola' in view, false); + } + game = await act(0, { type: 'leaderSkill', event: revival.event, mode: 'draw' }); + const drawn = game.leaderSkills!.offers[own].cards; + assert.equal(drawn.length, 2); assert.equal(game.response, null); + assert.equal((await f.restart().readSeatView(code, auths[1])).leaderSkills!.offer, null); + game = await concurrent({ type: 'leaderSkill', event: revival.event, skill: drawn[0], leader: prepared.leader }); + assert.deepEqual(game.response, originalResponse); + assert.equal(game.pendingChoamMarketGhola, null); + assert.equal(game.leaderSkills!.assignments.filter(a => a.owner === own).length, 1); + assert.equal(game.discard.filter(c => c.id === prepared.ghola).length, 1); + game = await act(2, { type: 'card', card: prepared.karama, mode: 'cancel' }); + for (let step = 0; game.response && step < 10; step++) { + const i = auths.findIndex(a => !game.response!.passed.includes(a.playerId)); + assert.ok(i >= 0); game = await act(i, { type: 'passResponse' }); + } + assert.equal(game.response, null); + const owner = game.players.find(p => p.id === own)!; + assert.equal(owner.spice, money); + assert.ok(owner.hand.some(c => c.id === prepared.sale)); + assert.equal(owner.leaders.find(l => l.id === prepared.leader)!.dead, false); + assert.equal(game.discard.filter(c => c.id === prepared.ghola).length, 1); + custody(game); + assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); + } finally { mock.restoreAll(); db.close(); } +}); diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index af881510..d5501e04 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -155,6 +155,26 @@ void test('Ixian Skills samples preserve their full module on saved continuation assert.equal(existsSync(unsupported), false); }); +void test('CHOAM Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => { + const area = temporary(t); + const out = join(area, 'skills'); + const result = run(out, '--profile', 'choam-skills', '--players', '2', '--seed', '1000', '--max-actions', '1'); + assert.equal(result.status, 1); + assertFailedEvidence(out, 'choam-skills-2-basic', 1031, false); + const snapshot = join(out, 'failed-choam-skills-2-basic.json'); + const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record; assignments: { skill: string }[] } }>(snapshot); + assert.equal(game.advanced, false); + const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)]; + assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14); + const resumed = join(area, 'skills-resumed'); + assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(resumed, 'choam-skills-2-basic', 1031, true); + const unsupported = join(area, 'unsupported'); + const bad = run(unsupported, '--profile', 'choam-skills', '--rules', 'advanced'); + assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/); + assert.equal(existsSync(unsupported), false); +}); + void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => { const area = temporary(t); const first = join(area, 'first'); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index d9cc8292..a82cd8a7 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -26,7 +26,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; -type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills'; +type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -126,7 +126,14 @@ const IX_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => expansions: ['ix'], roster: (['ixians', 'tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players), })); -const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills'; +const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ + ordinal: 31 + players - 2, + profile: 'choam-skills', + rules: 'basic', + expansions: ['choam'], + roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), +})); +const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills'; type TraceEntry = { attempt: number; @@ -164,10 +171,10 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills|tleilaxu-skills|ix-skills] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + - 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills, tleilaxu-skills or ix-skills. Output must be a new private directory outside the checkout.' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills, tleilaxu-skills, ix-skills or choam-skills. Output must be a new private directory outside the checkout.' ); } @@ -205,8 +212,8 @@ function scenarioName(scenario: Scenario) { function parseProfile(value: string | undefined) { const profile = value ?? 'all'; - if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills', 'ix-skills'].includes(profile)) - throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills, tleilaxu-skills or ix-skills.'); + if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile)) + throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); return profile as Profile | 'all'; } @@ -306,7 +313,7 @@ function resumedGame(path: string) { game.moritaniAssassinateCallEvents ) throw new Error('--resume sample scenarios exclude optional modules.'); - const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS].find( + const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find( (candidate) => skillProfile(candidate.profile) === !!game.leaderSkills && candidate.rules === (game.advanced ? 'advanced' : 'basic') && @@ -502,13 +509,13 @@ async function main() { const rules = parseRules(values.rules); const players = parsePlayers(values.players); if (supplied('players') && profile !== 'base' && !skillProfile(profile)) - throw new Error('--players requires --profile base, moritani-skills, tleilaxu-skills or ix-skills.'); + throw new Error('--players requires --profile base, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.'); if (skillProfile(profile) && rules === 'advanced') throw new Error('Expansion Leader Skills profiles currently support only Basic samples.'); const resume = values.resume ? resumedGame(values.resume) : null; const selected = resume ? [resume.scenario] - : (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : SCENARIOS).filter( + : (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS).filter( (scenario) => (profile === 'all' || scenario.profile === profile) && (rules === 'both' || scenario.rules === rules) &&