From 06f46c6f79d5b8d5996aa04c425520a35646f372 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:45:29 +1000 Subject: [PATCH] Connect Leader Skill battle effects and private Rihani exchanges Prototype Mentat/Bureaucrat score effects, automatic Sandmaster board spice, and Rihani inspection plus committed exchange. Preserve private views, physical custody, saved winner cleanup and captive/Suk sequencing. Remaining normal bands, missing skill families and combined modules remain gated. Validation: 45 focused tests; typecheck, lint, 4465 offline tests, production build and 43 HTTP tests passed. Independent rules/privacy/persistence review completed. New isolated browser setup exercised inspection, draw, pending refresh, exchange and completed refresh. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not trigger Rihani. Preserved all 393 opening rooms and original seat/recovery rows (427 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported outage; no restart or reset. Verified source tree: c64ce9537269de94c0d785bc08b17346e826c5797352a636771e1dc6ce5e4859 --- components/game-table.tsx | 8 + components/leader-skill-battle-guide.tsx | 23 +++ components/leader-skills.tsx | 3 +- components/rihani-decipherer.tsx | 65 ++++++ docs/COMPONENT_INVENTORY.md | 4 +- docs/CURRENT_STATUS.md | 13 +- docs/IMPLEMENTATION_STATUS.md | 21 ++ docs/LEADER_BATTLE_EFFECTS.md | 86 ++++++++ docs/LEADER_SKILLS_RUNTIME.md | 17 +- docs/RULE_DECISIONS.md | 1 + game/battle-resolution-quote.ts | 26 ++- game/bots.ts | 23 ++- game/engine.ts | 147 ++++++++++++++ game/leader-skill-battle-board.ts | 26 +++ game/leader-skill-combat.ts | 29 ++- game/reference.ts | 17 +- game/richese-cancellation.ts | 1 + game/rihani-decipherer.ts | 133 +++++++++++++ tests/leader-battle-effects-controls.test.tsx | 135 +++++++++++++ tests/leader-battle-effects-recovery.test.ts | 153 ++++++++++++++ tests/leader-battle-effects.test.ts | 187 ++++++++++++++++++ tests/leader-skill-battle-fixture.ts | 101 ++++++++++ tests/leader-skill-combat.test.ts | 17 ++ tests/leader-skills-controls.test.ts | 8 +- tests/leader-skills-recovery.test.ts | 11 ++ tests/suk-graduate-fixture.ts | 97 +-------- 26 files changed, 1230 insertions(+), 122 deletions(-) create mode 100644 components/leader-skill-battle-guide.tsx create mode 100644 components/rihani-decipherer.tsx create mode 100644 docs/LEADER_BATTLE_EFFECTS.md create mode 100644 game/leader-skill-battle-board.ts create mode 100644 game/rihani-decipherer.ts create mode 100644 tests/leader-battle-effects-controls.test.tsx create mode 100644 tests/leader-battle-effects-recovery.test.ts create mode 100644 tests/leader-battle-effects.test.ts create mode 100644 tests/leader-skill-battle-fixture.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 8658dd1e..2df6e29b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -48,6 +48,8 @@ import { RicheseGift } from './richese-gift'; import { Distrans } from './distrans'; import { JuiceOfSapho } from './juice-of-sapho'; import { LeaderSkillsPanel } from './leader-skills'; +import { LeaderSkillBattleGuide } from './leader-skill-battle-guide'; +import { RihaniChoice, RihaniHistory } from './rihani-decipherer'; import { SukGraduatePanel } from './suk-graduate'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { OrnithopterMovement } from './ornithopter-movement'; @@ -1464,6 +1466,7 @@ export function GameTable({ + @@ -2081,6 +2084,8 @@ export function GameTable({ ? 'Position your skilled leader' : g.decision.kind === 'leaderSkillRevival' ? 'Choose a skill for the revived leader' + : g.decision.kind === 'rihani' + ? g.decision.stage === 'offer' ? 'Rihani Decipherer — optional draw' : 'Choose your Traitor exchange' : g.decision.kind === 'sukRescue' ? 'Save your battle casualties' : g.decision.kind === 'homeworldRevivalDeployment' @@ -2883,6 +2888,8 @@ export function GameTable({ ) : g.decision.kind === 'sukRescue' ? ( + ) : g.decision.kind === 'rihani' ? ( + ) : g.decision.kind === 'battleLosses' ? ( <>

@@ -4282,6 +4289,7 @@ export function GameTable({ ) : ( <> + {g.battle.insight && (

{committed[g.battle.insight.field] diff --git a/components/leader-skill-battle-guide.tsx b/components/leader-skill-battle-guide.tsx new file mode 100644 index 00000000..87c565d1 --- /dev/null +++ b/components/leader-skill-battle-guide.tsx @@ -0,0 +1,23 @@ +import type { GameView } from '@/game/engine'; +import { usesSurvivingSkilledLeader } from '@/game/leader-skill-combat'; +import { leaderSkillStrongholdCount } from '@/game/leader-skill-battle-board'; + +/** Guidance uses only the viewer's selected disc and public board occupation. */ +export function LeaderSkillBattleGuide({ game, leader }: { game: GameView; leader: string }) { + const battle = game.battle; + if (!battle || ![battle.attacker, battle.defender].includes(game.me)) return null; + const assignments = game.leaderSkills?.assignments.filter((a) => a.controller === game.me) ?? []; + const opponent = game.players.find((p) => p.id === (battle.attacker === game.me ? battle.defender : battle.attacker))!; + const mentat = usesSurvivingSkilledLeader(assignments, 'mentat', leader, true); + const bureaucrat = usesSurvivingSkilledLeader(assignments, 'bureaucrat', leader, true); + const sandmaster = usesSurvivingSkilledLeader(assignments, 'sandmaster', leader, true); + const rihani = usesSurvivingSkilledLeader(assignments, 'rihani-decipherer', leader, true); + if (!mentat && !bureaucrat && !sandmaster && !rihani) return null; + return

+ {mentat &&

Mentat adds 2 to this leader’s battle strength if the leader survives.

} + {bureaucrat &&

Bureaucrat subtracts {leaderSkillStrongholdCount(game, opponent)} from your opponent’s battle total if your leader survives. Count each occupied stronghold once, including contested strongholds. Advisors do not count.

} + {sandmaster &&

If this Sandmaster survives and wins, add 3 spice to the territory’s existing spice pile automatically. No spice is added when the territory has no spice.

} + {rihani &&

If this Rihani Decipherer survives and wins, you may draw two Traitors and exchange an unused old card for one of them. A native Rihani also privately inspects two deck cards first.

} + {(mentat || bureaucrat) &&

These score effects resolve automatically. They do not change the leader’s printed value or spice bounty.

} +
; +} diff --git a/components/leader-skills.tsx b/components/leader-skills.tsx index b66b6e00..ae1e3b8d 100644 --- a/components/leader-skills.tsx +++ b/components/leader-skills.tsx @@ -112,7 +112,8 @@ export function LeaderSkillsPanel({ This development-only module currently connects the card-role strength modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept, plus Planetologist movement and - Special-card battle use and Suk Graduate force rescue. Other Leader Skill effects are + Special-card battle use, Suk Graduate force rescue, Mentat and Bureaucrat + battle scores, Sandmaster victory spice and Rihani inspections/exchanges. Other Leader Skill effects are unfinished.

diff --git a/components/rihani-decipherer.tsx b/components/rihani-decipherer.tsx new file mode 100644 index 00000000..322b066b --- /dev/null +++ b/components/rihani-decipherer.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { faction } from '@/game/catalog'; +import { leaderStrengthLabel } from '@/game/cards'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { LeaderInspector, type LeaderDisplayIdentity } from './leader-inspector'; +import { Button } from './ui/button'; + +function identity(game: GameView, id: string): LeaderDisplayIdentity | null { + if (id === CHEAP_HERO_TRAITOR) return { name: 'Cheap Hero / Heroine', factionName: 'Any faction', strength: 0, cheapHero: true }; + const leader = game.allLeaders.find((l) => l.id === id); + return leader ? { id: leader.id, name: leader.name, factionName: faction(leader.faction).name, strength: leaderStrengthLabel(leader) } : null; +} +function Card({ game, id }: { game: GameView; id: string }) { + const card = identity(game, id); + return card ?
{card.name}
:

Card identity unavailable.

; +} + +export function RihaniHistory({ game }: { game: GameView }) { + const history = game.rihani?.history; + if (!history?.length) return null; + return
+ Your private Rihani inspections +

A peek records the deck at that moment. Later draws can change who holds those cards.

+ {[...history].reverse().map((r) =>
+

Turn {r.turn}

+ {r.peeked.length > 0 && <>

Normal inspection — returned to the shuffled deck

{r.peeked.map((id) => )}} + {r.drawn.length > 0 && <>

Separate skilled draw

{r.drawn.map((id) => )}} + {r.kept &&

Kept {identity(game, r.kept)?.name}; revealed and returned {identity(game, r.given!)?.name}.

} +
)} +
; +} + +export function RihaniChoice({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) { + const [kept, setKept] = useState(''), [given, setGiven] = useState(''); + const pending = game.rihani?.pending; + if (!pending || pending.owner !== game.me) return null; + const blocked = busy || !!game.truthtrance; + if (pending.stage === 'offer') return
+

You may draw two Traitor Cards. Once drawn, you must keep one new card by publicly revealing and returning one unused card you already hold.

+

The other drawn card returns secretly. Your normal inspection, when available, has already returned its cards to the shuffled deck.

+ + +
; + return
+

Keep one newly drawn card and reveal one unused card from your old hand. The revealed card and the other new card return to the shuffled deck.

+
Keep one new Traitor — private + {pending.drawn?.map((id) =>
+ + +
)} +
+
Reveal and return one unused old Traitor — public + {pending.eligible?.map((id) =>
+ + +
)} +
+ +
; +} diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index 6ee30195..40153e1f 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -6,7 +6,9 @@ inspectors, private two-card offers and one-card assignment custody. Five role bonuses, public skilled capture, death return and own revival are connected; Planetologist now also has movement and base green-Special battle controls; [Suk Graduate](SUK_GRADUATE_RULES.md) redirects actual ordinary/elite casualties -through readable rescue choices without creating counters. Seven effects remain +through readable rescue choices without creating counters. [Battle effects](LEADER_BATTLE_EFFECTS.md) +now connect Rihani inspection/exchange, Mentat/Bureaucrat scoring and Sandmaster +victory spice. Their other bands and Spice Banker, Diplomat and Smuggler remain missing. This supersedes the historical missing-inventory rows below only for Leader Skills. See [source inventory](LEADER_SKILLS_RULES.md), [capture interpretation](LEADER_SKILLS_CAPTURE.md) and [prototype evidence](LEADER_SKILLS_RUNTIME.md). diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 23d4ec99..3d2148bc 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,14 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- [Leader battle effects](LEADER_BATTLE_EFFECTS.md) now connect both Rihani bands + and lower Mentat, Bureaucrat and Sandmaster effects. Private controls, all four + AI profiles, JSON/SQLite continuation and concurrent exchanges are covered. + A new browser room exercised separate inspection, draw, public return and refresh. + Three normal bands and Spice Banker, Diplomat and Smuggler remain missing. + The server responded after the outage; a fresh backup captured 393 rooms. + Broad check results and final preservation are recorded in the checkpoint commit. + - [Suk Graduate](SUK_GRADUATE_RULES.md) now connects both casualty-rescue bands, ordinary/elite counter routing, player controls, all four AI profiles and saved continuation. Targeted browser rescue and refresh preserved two board forces, @@ -29,8 +37,9 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid - The [first Leader Skills prototype](LEADER_SKILLS_RUNTIME.md) connects all fourteen physical cards, private setup, five battle disciplines, skilled capture, death and own revival through controls, legal AI and saved choices. The [Planetologist follow-up](PLANETOLOGIST_RULES.md) adds movement and - Special-card battle controls, and Suk Graduate adds casualty rescue; the other - seven skills remain **Missing**. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md) + Special-card battle controls, and Suk Graduate adds casualty rescue. The + battle-effects follow-up adds Rihani and three lower bands; remaining work is + listed in its contract. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md) resolves publicity through the known physical card; captured replacement and broader combinations remain pending. No mode gate is opened. - Following the reported power outage, the existing server responded normally, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9ac57bc6..e450f7dd 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,26 @@ # Dune implementation status +## 13 September 2026 — Leader battle effects prototype + +[Battle effects](LEADER_BATTLE_EFFECTS.md) connects lower Mentat +2 strength, +Bureaucrat occupation penalties and automatic Sandmaster board spice, plus both +Rihani private inspection and committed optional exchange. Shared quotes, owner +controls, all four AI profiles and saved continuation form usable prototypes. +Three normal bands and Spice Banker, Diplomat and Smuggler remain missing. + +Independent review found a completed Rihani receipt was not bound to current +custody during battle cleanup. Decline/exchange now preserve and validate exact +deck order; regressions cover corruption, replay and SQLite concurrent writes. +A fresh genuine browser setup followed by a conserved staged battle exercised +private inspection, separate draw, pending refresh, public return and completion. +Two Basic/Advanced samples finished with no rejected candidates but did not +trigger Rihani; they do not certify its integrated full-game behavior. + +The server remained healthy after the outage report. An online backup preserved +393 opening rooms; no restart or reset was needed. Broad results and final +preservation are in the checkpoint commit and private source-bound report. +No rules-mode or publication gate is opened. + ## 13 September 2026 — Suk Graduate casualty rescue prototype [Suk Graduate](SUK_GRADUATE_RULES.md) connects both printed bands through shared diff --git a/docs/LEADER_BATTLE_EFFECTS.md b/docs/LEADER_BATTLE_EFFECTS.md new file mode 100644 index 00000000..3e45f158 --- /dev/null +++ b/docs/LEADER_BATTLE_EFFECTS.md @@ -0,0 +1,86 @@ +# Leader Skill battle effects + +13 September 2026. **Prototyped, with Partial rules coverage.** This follow-up +connects both Rihani Decipherer bands and the lower battle bands of Mentat, +Bureaucrat and Sandmaster. Normal Mentat questioning, Bureaucrat payments and +Sandmaster movement collection remain missing, as do Spice Banker, Diplomat and +Smuggler. Public module starts and publication remain gated. + +## Source contract + +The [GF9 CHOAM & Richese rulebook, page 9](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) +provides the common skill procedure. The printed cards and publisher designer +walkthrough supply the individual effects; see the [component inventory](LEADER_SKILLS_RULES.md). + +- [Mentat, 17:37](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1057s): the + surviving selected skilled disc gains two battle strength. Its printed value, + bounty and Zoal source value stay unchanged. +- [Bureaucrat, 15:01](https://www.youtube.com/watch?v=XT_azRVLq_0&t=901s): the + surviving selected skilled disc subtracts one from its opponent's total per + distinct stronghold that opponent occupies. Contested and storm-covered + occupation count; advisors and virtual victory control do not. There is no + implicit zero clamp. The shared board helper recognizes HMS and Jacurutu, + though combined-module play remains gated. +- [Sandmaster, 15:48](https://www.youtube.com/watch?v=XT_azRVLq_0&t=948s): a + surviving skilled winner adds three board spice to an existing pile in the + battle territory automatically. This grants no direct income and does not + require surviving forces. No pile means no addition. Multiple positive piles + are guarded before the final battle vote pending a sector-placement ruling. +- [Rihani, 12:49](https://www.youtube.com/watch?v=XT_azRVLq_0&t=769s): a face-up + native trainer grants the normal private inspection after a victory using + another leader. A surviving selected native skilled winner receives both + bands. A surviving captured skilled winner receives only the lower band. + +Rihani first privately inspects two random physical Traitor Deck cards and +reshuffles them. It then offers the separate optional two-card draw. Choosing +to draw commits the player to keep one newly drawn card, publicly reveal and +return one unused old card, and secretly return the unkept new card. Those two +returns are shuffled into the actual deck. There is no post-draw decline or +return-both-new alternative. The two samples can contain the same identity. + +**Ordering inference:** normal-before-lower follows the printed order and +designer narration; no explicit priority sentence was found. **Unused** means +not actually called, rather than merely known or publicly disclosed. Returning +an old card announces its name without adding it to the actual-use register. +A Traitor Card used to win this battle is already excluded from the exchange. +No eligible old card means no optional draw. There is no partial draw fallback +when fewer than two physical deck cards are available. + +The [Ixian/Tleilaxu rules](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf) +place skill resolution before faction capture and Face Dance. Tleilaxu Rihani +must instead exchange an unrevealed Face Dancer. That combination, foreign +gholas and the skill lifecycle after non-kill Face Dance remain unimplemented. +The connected entry supports base factions in Basic/Advanced with Leader Skills +alone. These boundaries are implementation limits, not prohibitions in the game. + +## Connected behavior and checks + +Shared battle quotes calculate score changes; bots use the same public +stronghold count. Sandmaster resolves automatically. Rihani offers owned +draw/decline controls, then separate inspectable keep-new and reveal-old +selections. All four AI profiles use only their permitted view. Only the owner +receives inspection history and drawn identities. The public log names the +returned old card, never the kept or unkept new identity. + +Physical snapshots, an event-bound obligation and saved stages preserve Rihani +through casualties, winner cleanup and captive return. Independent review found +that completed receipts were not bound to custody during cleanup; this now +rejects changed deck order after either decline or exchange. JSON and SQLite +restart tests cover both choices, concurrent writes, stale replays, normal versus +captured eligibility, actual-use exclusion and native Suk rescue preceding a +captured Rihani exchange. Focused checks also cover score-boundary winners, +dead/bluffed skills, Sandmaster without surviving forces and UI privacy. + +Fresh browser room `HNGSR8GB` used genuine module setup, then a separately +staged conserved battle. The human inspected two private cards, drew two +separate cards, refreshed the pending exchange and selected a keep and public +return. Completion and refresh restored the kept Traitor and winner card cleanup. +This is targeted browser evidence, not a naturally reached complete game. +Two genuine four-profile Basic/Advanced samples completed in 92/188 actions, +with 3/6 JSON continuations and no rejected candidates; neither reached a Rihani +effect. They establish sample continuation, not comprehensive skill acceptance. + +The running server responded after the reported outage. A fresh online backup +captured all 393 opening rooms. No restart or reset was needed. Required broad +checks and final saved-game preservation are recorded in the checkpoint commit +and private source-bound report. diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 63890be8..aa470b89 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -49,10 +49,11 @@ publisher instruction to announce a name. ## Missing and guarded work -Seven cards still have readable inventory entries but **missing effects**: -Bureaucrat, Spice Banker, Diplomat, Mentat, Rihani Decipherer, Sandmaster and -Smuggler. They are not completed features. Planetologist and Suk Graduate now -have connected prototypes described below. +[Battle-effects follow-up](LEADER_BATTLE_EFFECTS.md) connects both Rihani bands +and the lower Mentat, Bureaucrat and Sandmaster effects. Their normal questioning, +payments and movement collection remain missing, along with all Spice Banker, +Diplomat and Smuggler effects. Planetologist and Suk Graduate have the connected +prototypes described below. Captured-card replacement entitlement remains unresolved. The prototype rejects own-leader revival while its original skill is captured, before beginning a @@ -63,8 +64,8 @@ Before enabling them, add response-driven automatic revival continuation coverage; the current before/after revival observer does not certify deferred Tleilaxu response chains. The other source-contract timing questions also remain open. Planetologist now has bounded movement and battle controls as described below. -The other seven skills still need their payments, information, rescues, retreats -and other effects connected. +Remaining normal-band payments, questions, collection and the three missing +skill families still need connected effects. ## Planetologist follow-up @@ -78,8 +79,8 @@ continuation and revalidate source custody before a response is spent. Six base factions remain the supported roster. Mixed BG origins are supported where ordinary rules determine the destination stance. Enemy-only mixed arrival still needs a combined flip response. Expansion faction/card/module combinations -and full integrated acceptance remain unfinished. Seven other skill effects are -still missing; this is partial prototype coverage. +and full integrated acceptance remain unfinished. The remaining bands and skill families are +listed above; this is partial prototype coverage. ## Suk Graduate follow-up diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 52d6f7f8..6fae52b7 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -6,6 +6,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Topic | Decision to preserve | Detailed authority and evidence | | --- | --- | --- | +| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) | | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | | Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) | diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts index 6a41d72a..49a02517 100644 --- a/game/battle-resolution-quote.ts +++ b/game/battle-resolution-quote.ts @@ -29,6 +29,7 @@ import { import { matchingTraitor } from './traitors'; import { auditCount } from './choam-auditor'; import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate'; +import { rihaniVictorySkill, type RihaniSkill } from './rihani-decipherer'; import { quoteHomeworldBattleRules, type HomeworldBattleRules, @@ -39,6 +40,8 @@ import { canUsePlanetologistBattleSpecial, isPlanetologistBattleSpecialCard, leaderSkillBattleBonus, + bureaucratBattlePenalty, + usesSurvivingSkilledLeader, type BattleLeaderSkill, type LeaderSkillBattleBonus, } from './leader-skill-combat'; @@ -79,6 +82,8 @@ export type ResolutionCombatant = ResolutionParticipant & { stoneMode?: 'kill' | 'ignore'; /** Current authorized assignments only; captured assignments grant no normal band. */ leaderSkills?: readonly BattleLeaderSkill[]; + /** Public distinct occupation, including contested strongholds but excluding advisors. */ + occupiedStrongholds?: number; /** The current aidFor result: this escrow has already left the donor's balance. */ aid?: { donor: string; amount: number }; }; @@ -141,7 +146,10 @@ export type BattleResolutionQuote = { homeworldExplosion?: HomeworldExplosionLosses; /** Basic ordinary winners lose their dial immediately; Advanced/Ix defer typed losses. */ basicWinnerLosses: number | null; + leaderSkillPenalties?: { attacker: number; defender: number }; sukGraduate?: SukGraduateSkill; + rihani?: RihaniSkill; + sandmaster?: string; casualties: { forces: CombatForces; dial: number; @@ -473,6 +481,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { let scores: BattleResolutionQuote['scores'] = null; let deaths = { attacker: false, defender: false }; let bounty: BattleResolutionQuote['bounty'] = null; + let leaderSkillPenalties: BattleResolutionQuote['leaderSkillPenalties']; let leaderSkillBonuses: BattleResolutionQuote['leaderSkillBonuses'] = { attacker: { bonus: 0, applied: [] }, defender: { bonus: 0, applied: [] }, @@ -524,9 +533,15 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { skilledLeaderSurvives: !deaths.defender, }), }; + const attackerPenalty = bureaucratBattlePenalty(d.leaderSkills ?? [], d.leader?.id, + !deaths.defender, a.occupiedStrongholds); + const defenderPenalty = bureaucratBattlePenalty(a.leaderSkills ?? [], a.leader?.id, + !deaths.attacker, d.occupiedStrongholds); + if (attackerPenalty || defenderPenalty) + leaderSkillPenalties = { attacker: attackerPenalty, defender: defenderPenalty }; scores = { attacker: - a.plan.dial + + a.plan.dial - attackerPenalty + (a.id === homeworld?.native ? homeworld.strength : 0) + (deaths.attacker || effects.stunned ? 0 @@ -534,7 +549,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { leaderSkillBonuses.attacker.bonus + (a.plan.kwisatz ? 2 : 0)), defender: - d.plan.dial + + d.plan.dial - defenderPenalty + (d.id === homeworld?.native ? homeworld.strength : 0) + (deaths.defender || effects.stunned ? 0 @@ -576,6 +591,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { ? sukGraduateSkill(winner.leaderSkills ?? [], winner.leader, !(winner === a ? deaths.attacker : deaths.defender)) : null; + const rihani = winner ? rihaniVictorySkill(winner.leaderSkills ?? [], winner.leader?.id, + !(winner === a ? deaths.attacker : deaths.defender)) : null; + const sandmaster = winner && usesSurvivingSkilledLeader(winner.leaderSkills ?? [], 'sandmaster', winner.leader?.id, + !(winner === a ? deaths.attacker : deaths.defender)) ? winner.leader!.id : null; if (winner && result === 'normal') { if ( input.advanced || @@ -751,7 +770,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { ? { homeworldExplosion: homeworld.explosion } : {}), basicWinnerLosses, + ...(leaderSkillPenalties ? { leaderSkillPenalties } : {}), ...(sukGraduate ? { sukGraduate } : {}), + ...(rihani ? { rihani } : {}), + ...(sandmaster ? { sandmaster } : {}), casualties, played, discarded, diff --git a/game/bots.ts b/game/bots.ts index ecd93281..96aceeef 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1,6 +1,7 @@ import { discoveryBotActions } from './discovery-options'; import type { Card } from './cards'; -import { canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat'; +import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat'; +import { leaderSkillStrongholdCount } from './leader-skill-battle-board'; import { discoveryFlightBotActions } from './discovery-flight-options'; import { discoveryEntryBotActions } from './discovery-entry-options'; import { discoveryStormActions } from './discovery-storm-options'; @@ -817,6 +818,10 @@ function plans(g: GameView): Action[] { effects.stunned || (inspected && effects.defenderDead) ? 0 : expectedEnemyStrength; + const opponentPenalty = stoneBattle ? 0 : bureaucratBattlePenalty( + battleSkills, myLeader?.id, !effects.attackerDead, + leaderSkillStrongholdCount(g, other), + ); const ideal = stoneBattle ? 0 : level === 0 @@ -825,7 +830,7 @@ function plans(g: GameView): Action[] { ownStrength, Math.max( 0, - enemySurvivingStrength + + enemySurvivingStrength - opponentPenalty + expectedDial - ownSurvivingStrength + enemyNativeBonus - @@ -932,7 +937,7 @@ function plans(g: GameView): Action[] { (effectiveDefense ? 2 : 0) : level === 0 ? variation(g, `${leader}-${weapon}-${defense}-${dial}`) * 100 - : ownSurvivingStrength * 2 + + : ownSurvivingStrength * 2 + opponentPenalty * 2 + (inspected && enemySurvivingStrength === 0 ? 10 : 0) + effectiveWeapon + effectiveDefense + @@ -2401,6 +2406,18 @@ function policyActions(g: GameView): Action[] { })).sort((a, b) => b.score - a.score) .map(({ choice }) => ({ type: 'decision', event: d.event, choice })); } + if (d.kind === 'rihani') { + if (d.stage === 'offer') return [{ type: 'decision', event: d.event, draw: true }]; + const pending = g.rihani?.pending; + if (!pending || pending.owner !== me.id || pending.event !== d.event || !pending.drawn?.length || !pending.eligible?.length) return []; + const value = (id: string) => { + const leader = g.allLeaders.find((l) => l.id === id); + return (leader?.strength ?? 0) + (leader?.faction === me.faction ? -10 : 10); + }; + const drawn = level === 0 ? pending.drawn : [...pending.drawn].sort((a,b) => value(b) - value(a)); + const old = level === 0 ? pending.eligible : [...pending.eligible].sort((a,b) => value(a) - value(b)); + return [{ type: 'decision', event: d.event, cards: [drawn[0], old[0]] }]; + } if (d.kind === 'battleLosses') { const choices = d.options .map((o, choice) => ({ diff --git a/game/engine.ts b/game/engine.ts index 6324dc89..80fbc774 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -7,6 +7,8 @@ import { type BattleLeaderSkill, } from './leader-skill-combat'; import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate'; +import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board'; +import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer'; import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement'; import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions'; import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; @@ -711,6 +713,7 @@ export type Decision = | { kind: 'poisonTooth'; player: string } | { kind: 'stoneBurner'; player: string; event: string } | { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] } + | { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' } | { kind: 'fullPlanOffer'; player: string } | { kind: 'fullPlanRead'; player: string; target: string } | { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string } @@ -1255,6 +1258,7 @@ export type Game = { ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[]; pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null; pendingSukRescue?: SukRescueReceipt | null; + rihaniHistory?: RihaniReceipt[]; pendingChoamMarketGhola?: ChoamMarketGhola | null; choamTradeTurn?: number; inflation?: Inflation | null; @@ -1469,6 +1473,8 @@ export type Game = { cardRolesSignature?: string; winnerDiscards?: { cards: string[]; completed: boolean; signature: string }; sukRescue?: { signature: string; completed: boolean }; + rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean }; + sandmaster?: { leader: string; key: string; before: number; after: number }; caladanReinforcement?: HomeworldVictoryObligation; nexusSardaukarCasualties?: string; } | null; @@ -4907,6 +4913,7 @@ function leaderSkillAssignmentUnavailable( } function leaderSkillsIntegrity(g: Game) { sukRescueIntegrity(g); + rihaniIntegrity(g); if (!g.leaderSkills) return; requireRule(g.leaderSkills.assignments.every((assignment) => g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)), @@ -14054,6 +14061,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { plan, leader: controlledLeaders(g, p).find((l) => l.id === plan.leader), leaderSkills: battleLeaderSkills(g, p), + occupiedStrongholds: leaderSkillStrongholdCount(g, p), forces: combatForces(g, p, b.territory, opponent), stronghold: strongholdEffect(g, p.id), lateDefense: b.lateDefense?.[p.id], @@ -14095,6 +14103,17 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length && g.players.every((p) => faction(p.faction).expansion === 'base'), 'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.'); + if (quote.rihani || quote.sandmaster) requireRule( + !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length && + g.players.every((p) => faction(p.faction).expansion === 'base'), + 'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.'); + if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, g.spice)); + if (quote.rihani) { + const winner = getPlayer(g, quote.winner!); + nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), + { event: b.event!, turn: g.turn, owner: winner.id, skill: quote.rihani! }, + [...new Set([...(winner.revealedTraitors ?? []), ...quote.revelations.filter((r) => r.player === winner.id).map((r) => r.identity)])], () => 0)); + } for (const id of quote.destroyedArmies) if (b.territory.startsWith('homeworld:')) quoteHomeworldLoss(g, id, b.territory, combatArmy(g, id, b.territory)); else validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity); @@ -14252,6 +14271,9 @@ function resolveBattle(g: Game) { if (dk) dead(dl); const av = quote.scores!.attacker, dv = quote.scores!.defender; + if (!stoneResult && quote.leaderSkillPenalties) + for (const [target, amount] of [[a, quote.leaderSkillPenalties.attacker], [d, quote.leaderSkillPenalties.defender]] as const) + if (amount) log(g, `${target.name}'s battle total fell by ${amount} because the opposing Bureaucrat counted ${amount} occupied strongholds. Advisors do not count; the leader's printed strength and bounty are unchanged.`, { faction: (target === a ? d : a).faction, name: 'Bureaucrat' }); if (!stoneResult && !quote.effects?.stunned) for (const [player, receipt, died] of [[a, quote.leaderSkillBonuses.attacker, ak], [d, quote.leaderSkillBonuses.defender, dk]] as const) for (const bonus of died ? [] : receipt.applied) @@ -14376,6 +14398,18 @@ function resolveBattle(g: Game) { ...(playedCardRoles ? { cardRoles: playedCardRoles } : {}), ...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}), }; + if (winner && quote.sandmaster) { + const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice)); + if (placement) { + g.spice[placement.key] = placement.after; + g.lastBattleContext.sandmaster = { leader: quote.sandmaster, ...placement }; + log(g, `${winner.name}'s surviving Sandmaster added 3 spice to the existing pile in ${combatLocationName(g, b.territory)}, sector ${splitLocation(placement.key).sector}. The territory now contains ${placement.after} spice; this is board spice, not income.`, { faction: winner.faction, name: 'Sandmaster' }); + } + } + if (winner && quote.rihani) { + g.lastBattleContext.rihani = { skill: quote.rihani, cards: [...quote.winnerCards], completed: false, signature: '' }; + g.lastBattleContext.rihani.signature = rihaniObligationSignature(g.lastBattleContext); + } if (winner && quote.sukGraduate && casualtyCommitment) { requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.'); g.pendingSukRescue = { @@ -14646,7 +14680,117 @@ function winnerDiscardsIntegrity(g: Game) { [...pending.cards, ...pending.optional].every((id) => typeof id === 'string' && owner.hand.some((card) => card.id === id)), 'The saved mandatory winner discards no longer match their battle or physical cards.'); } +function rihaniObligationSignature(context: NonNullable) { + return JSON.stringify([context.event, context.turn, context.winner, context.territory, + context.rihani?.skill, context.rihani?.cards, context.rihani?.completed]); +} +function pendingRihani(g: Game) { + return g.rihaniHistory?.find((r) => r.stage !== 'complete') ?? null; +} +function rihaniIntegrity(g: Game) { + const context = g.lastBattleContext, obligation = context?.rihani; + const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'rihani'); + const history = g.rihaniHistory ?? [], pending = pendingRihani(g); + if (history.length) { + requireRule(g.leaderSkills && new Set(history.map((r) => r.event)).size === history.length, + 'Rihani history requires its original skill module and distinct battles.'); + const universe = nexusTraitorUniverse(g); + for (const receipt of history) nexusRule(() => validateRihani(receipt, universe)); + } + requireRule(history.filter((r) => r.stage !== 'complete').length <= 1, + 'Finish the preceding Rihani exchange.'); + if (pending) { + requireRule(obligation && !obligation.completed && context.event === pending.event && + context.winner === pending.owner && context.turn === pending.turn && g.turn === pending.turn && + g.status === 'playing' && g.phase === 6 && !g.battle && + JSON.stringify(obligation.skill) === JSON.stringify(pending.skill) && + JSON.stringify(getPlayer(g, pending.owner).revealedTraitors ?? []) === JSON.stringify(pending.used) && + decisions.length === 1 && decisions[0].event === pending.event && decisions[0].player === pending.owner && + decisions[0].stage === pending.stage, 'The saved Rihani exchange lost its battle or owned choice.'); + nexusRule(() => validateRihani(pending, nexusTraitorUniverse(g), nexusTraitorSnapshot(g))); + } else requireRule(!decisions.length, 'The Rihani choice has lost its physical draw receipt.'); + if (!obligation) return; + requireRule(obligation.signature === rihaniObligationSignature(context!), 'The saved Rihani victory obligation changed.'); + const receipt = history.find((r) => r.event === context!.event); + if (obligation.completed) { + requireRule(receipt?.stage === 'complete' && receipt.owner === context!.winner && + receipt.turn === context!.turn && JSON.stringify(receipt.skill) === JSON.stringify(obligation.skill), + 'The completed Rihani victory lost its receipt.'); + if (g.phase === 6 && context!.turn === g.turn && !g.battle) + nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g))); + } else { + const winner = g.players.find((p) => p.id === context!.winner); + requireRule(winner && g.status === 'playing' && g.phase === 6 && !g.battle && context!.turn === g.turn && + g.leaderSkills?.assignments.some((a) => a.skill === 'rihani-decipherer' && a.leader === obligation.skill.leader) && + obligation.cards.every((id) => winner.hand.some((c) => c.id === id)) && + (receipt ? receipt === pending : !!g.pendingTreacheryDiscard || !!g.pendingSukRescue || + homeworldSavedDecisions(g).some((d) => d.kind === 'battleLosses' && d.player === winner.id)), + 'The Rihani victory lost its skilled leader, cards or preceding casualty settlement.'); + } +} +function completeRihaniObligation(g: Game) { + g.lastBattleContext!.rihani!.completed = true; + g.lastBattleContext!.rihani!.signature = rihaniObligationSignature(g.lastBattleContext!); +} +function startRihaniVictory(g: Game, winner: Player, territory: string, cards: string[]): boolean { + const context = g.lastBattleContext, obligation = context?.rihani; + if (!obligation || obligation.completed) return false; + requireRule(context.winner === winner.id && context.territory === territory && + JSON.stringify(obligation.cards) === JSON.stringify(cards) && !g.rihaniHistory?.some((r) => r.event === context.event), + 'Resume the original Rihani victory before winner card cleanup.'); + const receipt = nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), + { event: context.event, turn: g.turn, owner: winner.id, skill: obligation.skill }, winner.revealedTraitors ?? [], random)); + (g.rihaniHistory ??= []).push(receipt); + applyNexusTraitorSnapshot(g, receipt.state); + if (receipt.peeked.length) log(g, `${winner.name}'s Rihani Decipherer privately inspected two random Traitor Deck cards and shuffled them back. The identities are in that player's private skill history.`, { faction: winner.faction, name: 'Rihani Decipherer' }); + if (receipt.stage === 'complete') { + completeRihaniObligation(g); + if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no unused held Traitor Card to exchange, so Rihani offers no draw.`); + return false; + } + g.decision = { kind: 'rihani', event: receipt.event, player: winner.id, stage: 'offer' }; + return true; +} +function actRihani(g: Game, player: Player, decision: Extract, action: Action) { + const receipt = pendingRihani(g), context = g.lastBattleContext!; + requireRule(receipt?.owner === player.id && receipt.event === action.event && receipt.event === decision.event, + 'Choose only the current Rihani exchange.'); + let next: RihaniReceipt; + if (decision.stage === 'offer') { + requireRule(typeof action.draw === 'boolean' && Object.keys(action).every((key) => ['type', 'event', 'draw'].includes(key)), + 'Decide whether to draw before seeing the two new Traitors.'); + next = nexusRule(() => chooseRihaniDraw(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), action.draw as boolean)); + } else { + requireRule(Array.isArray(action.cards) && action.cards.length === 2 && action.cards.every((id) => typeof id === 'string') && + Object.keys(action).every((key) => ['type', 'event', 'cards'].includes(key)), 'Choose one newly drawn and one unused old Traitor.'); + const cards = action.cards as string[]; + next = nexusRule(() => finishRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), cards[0], cards[1], random)); + } + g.rihaniHistory![g.rihaniHistory!.findIndex((r) => r.event === receipt.event)] = next; + applyNexusTraitorSnapshot(g, next.state); + if (next.stage === 'return') { + g.decision = { kind: 'rihani', event: next.event, player: player.id, stage: 'return' }; + log(g, `${player.name} drew two private Traitor Cards through Rihani and must keep one by revealing an unused old card.`); + } else { + completeRihaniObligation(g); + if (next.given) { + const name = next.given === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : g.players.flatMap((p) => p.leaders).find((l) => l.id === next.given)?.name; + log(g, `${player.name} revealed the unused ${name ?? next.given} Traitor Card for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`); + } else log(g, `${player.name} declined Rihani's optional Traitor exchange.`); + finishWinner(g, player, context.territory, context.rihani!.cards); + } +} +function projectedRihani(g: Game, owner: string) { + if (!g.leaderSkills) return null; + const pending = pendingRihani(g); + return { history: (g.rihaniHistory ?? []).filter((r) => r.owner === owner).map((r) => ({ + event: r.event, turn: r.turn, peeked: [...r.peeked], drawn: [...r.drawn], kept: r.kept, given: r.given, + })), pending: pending ? { event: pending.event, owner: pending.owner, stage: pending.stage, + ...(pending.owner === owner && pending.stage === 'return' ? { drawn: [...pending.drawn], eligible: [...pending.eligible] } : {}), + } : null }; +} function finishWinner(g: Game, winner: Player, t: string, cards: string[]) { + if (startRihaniVictory(g, winner, t, cards)) return; if (g.pendingWinnerDiscards) { winnerDiscardsIntegrity(g); const pending = g.pendingWinnerDiscards; @@ -20210,6 +20354,8 @@ function applyActionInner( 'Choose only the current Suk Graduate rescue.'); const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')]; settleSukRescue(g, option, false); + } else if (decision.kind === 'rihani') { + actRihani(g, p, decision, action); } else if (decision.kind === 'battleLosses') { const choice = decision.options[ @@ -22446,6 +22592,7 @@ export function viewGame(state: Game, id: string) { setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null, setupPending: setupPending(g), leaderSkills: projectedLeaderSkills(g, id), + rihani: projectedRihani(g, id), advanced: g.advanced, dukeVidal: g.dukeVidal ? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } diff --git a/game/leader-skill-battle-board.ts b/game/leader-skill-battle-board.ts new file mode 100644 index 00000000..788dc543 --- /dev/null +++ b/game/leader-skill-battle-board.ts @@ -0,0 +1,26 @@ +import { gameTerritories, splitLocation, type MobileBoard } from './board'; +import { fighterCount } from './advisors'; +import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries'; +import type { ForcePresence } from './force-presence'; +import type { FactionId } from './catalog'; + +/** Occupation is physical public presence, independent of victory control or storm. */ +export function leaderSkillStrongholdCount( + board: MobileBoard, + player: ForcePresence & { faction: FactionId; advisors?: Record }, +): number { + return gameTerritories(board).filter((t) => t.type === 'stronghold' && + (!isDiscoveryLocationId(t.id) || t.id === JACURUTU_SIETCH) && + fighterCount(player, t.id) > 0).length; +} + +/** The card adds board spice to an existing pile; it grants no direct faction income. */ +export function sandmasterVictorySpice(territory: string, spice: Readonly>) { + const piles = Object.entries(spice).filter(([key, amount]) => amount > 0 && splitLocation(key).territory === territory); + if (piles.length > 1) throw new Error('Sandmaster placement among multiple spice piles awaits its sector ruling.'); + if (!piles.length) return null; + const [key, before] = piles[0]; + if (!Number.isSafeInteger(before) || before < 1 || !Number.isSafeInteger(before + 3)) + throw new Error('Sandmaster requires a valid existing spice pile.'); + return { key, before, after: before + 3 }; +} diff --git a/game/leader-skill-combat.ts b/game/leader-skill-combat.ts index 39a66832..80c28480 100644 --- a/game/leader-skill-combat.ts +++ b/game/leader-skill-combat.ts @@ -24,7 +24,7 @@ export type BattleLeaderSkill = { }; export type AppliedLeaderSkill = { - skill: DirectLeaderSkillId | 'planetologist'; + skill: DirectLeaderSkillId | 'planetologist' | 'mentat'; amount: 1 | 2 | 3; mode: 'normal' | 'skilled'; }; @@ -114,6 +114,30 @@ function isDirectSkill(skill: string): skill is DirectLeaderSkillId { return directSkills.has(skill as LeaderSkillId); } +/** A captive grants only this actual disc's lower effect, never a normal effect. */ +export function usesSurvivingSkilledLeader( + assignments: readonly BattleLeaderSkill[], + skill: LeaderSkillId, + leader: string | undefined | null, + survives: boolean, +): boolean { + return survives && !!leader && assignments.some((a) => + a.skill === skill && a.leader === leader && (!a.faceUp || a.captured)); +} + +/** Bureaucrat changes the opponent's total, not either leader disc or bounty. */ +export function bureaucratBattlePenalty( + assignments: readonly BattleLeaderSkill[], + leader: string | undefined | null, + survives: boolean, + opponentStrongholds: number | undefined, +): number { + if (!usesSurvivingSkilledLeader(assignments, 'bureaucrat', leader, survives)) return 0; + if (opponentStrongholds === undefined || !Number.isSafeInteger(opponentStrongholds) || opponentStrongholds < 0) + throw new Error('Bureaucrat needs a valid occupied stronghold count.'); + return opponentStrongholds; +} + function qualifies( skill: DirectLeaderSkillId, weapon: Card | undefined, @@ -144,6 +168,9 @@ export function leaderSkillBattleBonus(input: { }): LeaderSkillBattleBonus { if (!input.selectedLeader) return { bonus: 0, applied: [] }; const applied: AppliedLeaderSkill[] = []; + if (input.selectedLeader.kind === 'disc' && usesSurvivingSkilledLeader( + input.assignments, 'mentat', input.selectedLeader.id, input.skilledLeaderSurvives, + )) applied.push({ skill: 'mentat', amount: 2, mode: 'skilled' }); if ( input.selectedLeader.kind === 'disc' && input.skilledLeaderSurvives && diff --git a/game/reference.ts b/game/reference.ts index d2cb8edd..fe50f314 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues in development tables. Other effects and combined-module acceptance remain unfinished.', + summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice and Rihani exchanges in development tables. Other effects and combined-module acceptance remain unfinished.', steps: [ 'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.', 'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.', @@ -3572,16 +3572,19 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.', 'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.', 'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.', - 'Bureaucrat, Spice Banker, Diplomat, Mentat, Rihani Decipherer, Sandmaster and Smuggler have readable cards but their effects are still missing. The complete deck remains present; this development prototype does not enable public module starts.', + 'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.', + 'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.', + 'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.', + 'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, plus all Spice Banker, Diplomat and Smuggler effects 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: ['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. Other seven skills, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.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. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/game-table.tsx'] }, - { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, - { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md'] }, - { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts'] }, + { 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. Their other bands, Spice Banker, Diplomat, Smuggler and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.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. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/game-table.tsx'] }, + { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges and shared battle scoring. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, + { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md'] }, + { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery and battle-effect regressions cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx'] }, ], }, ...[ diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index 9aeb1c0c..882eb4de 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -7,6 +7,7 @@ const DECISIONS = { leaderSkillVisibility: true, leaderSkillRevival: true, sukRescue: true, + rihani: true, choamAudit: true, choamAuditPayment: true, strongholdCopy: true, diff --git a/game/rihani-decipherer.ts b/game/rihani-decipherer.ts new file mode 100644 index 00000000..98ee1bfc --- /dev/null +++ b/game/rihani-decipherer.ts @@ -0,0 +1,133 @@ +import type { BattleLeaderSkill } from './leader-skill-combat'; +import { usesSurvivingSkilledLeader } from './leader-skill-combat'; +import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange'; + +export type RihaniSkill = { leader: string; normal: boolean; skilled: boolean }; +export type RihaniReceipt = { + event: string; turn: number; owner: string; skill: RihaniSkill; + stage: 'offer' | 'return' | 'complete'; + before: NexusTraitorSnapshot; state: NexusTraitorSnapshot; + deckAfterPeek: string[]; + peeked: string[]; drawn: string[]; eligible: string[]; used: string[]; + kept: string | null; given: string | null; + signature: string; +}; +function requireRihani(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const members = (a: readonly string[], b: readonly string[]) => + a.length === b.length && same([...a].sort(), [...b].sort()); +function shuffled(values: readonly T[], rng: () => number): T[] { + const result = [...values]; + for (let i = result.length - 1; i > 0; i--) { + const value = rng(); + requireRihani(value >= 0 && value < 1, 'Invalid Traitor Deck shuffle.'); + const j = Math.floor(value * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} +export function rihaniSignature(receipt: RihaniReceipt): string { + const { signature: _signature, ...body } = receipt; + return JSON.stringify(body); +} +function signed(receipt: RihaniReceipt): RihaniReceipt { + receipt.signature = rihaniSignature(receipt); + return receipt; +} +export function rihaniVictorySkill(assignments: readonly BattleLeaderSkill[], leader: string | undefined, survives: boolean): RihaniSkill | null { + const a = assignments.find((skill) => skill.skill === 'rihani-decipherer'); + if (!a) return null; + const skilled = usesSurvivingSkilledLeader(assignments, 'rihani-decipherer', leader, survives); + const normal = !a.captured && (a.faceUp || skilled) && (a.leader !== leader || survives); + return normal || skilled ? { leader: a.leader, normal, skilled } : null; +} + +/** Complete the compulsory private peek before offering the separately optional draw. */ +export function beginRihani(snapshot: NexusTraitorSnapshot, universe: readonly string[], + identity: Pick, used: readonly string[], rng: () => number): RihaniReceipt { + validateNexusTraitorSnapshot(snapshot, universe); + const owner = snapshot.players.find((p) => p.id === identity.owner); + requireRihani(owner && owner.faction !== 'tleilaxu', 'Rihani Face Dancer exchanges are still being integrated.'); + const eligible = owner.traitors.filter((id) => !used.includes(id)); + const optional = identity.skill.skilled && eligible.length > 0; + requireRihani(!(identity.skill.normal || optional) || snapshot.reserve.length >= 2, + 'Rihani requires two physical cards in the Traitor Deck.'); + const state = structuredClone(snapshot); + const peeked = identity.skill.normal ? shuffled(snapshot.reserve, rng).slice(0, 2) : []; + if (identity.skill.normal) state.reserve = shuffled(state.reserve, rng); + return signed({ ...identity, stage: optional ? 'offer' : 'complete', before: structuredClone(snapshot), state, + deckAfterPeek: [...state.reserve], peeked, eligible, used: [...used], drawn: [], kept: null, given: null, signature: '' }); +} + +/** Validate recorded physical transitions, then bind a pending choice to current custody. */ +export function validateRihani(receipt: RihaniReceipt, universe: readonly string[], current?: NexusTraitorSnapshot) { + requireRihani(receipt.signature === rihaniSignature(receipt) && + ['offer', 'return', 'complete'].includes(receipt.stage) && + Number.isSafeInteger(receipt.turn) && receipt.turn > 0 && receipt.event && + typeof receipt.skill.normal === 'boolean' && typeof receipt.skill.skilled === 'boolean' && + (receipt.skill.normal || receipt.skill.skilled), 'The saved Rihani receipt is invalid.'); + validateNexusTraitorSnapshot(receipt.before, universe); + validateNexusTraitorSnapshot(receipt.state, universe); + const old = receipt.before.players.find((p) => p.id === receipt.owner); + const owner = receipt.state.players.find((p) => p.id === receipt.owner); + requireRihani(old && owner && old.faction !== 'tleilaxu' && owner.faction === old.faction && + same(receipt.before.players.filter((p) => p.id !== receipt.owner), receipt.state.players.filter((p) => p.id !== receipt.owner)), + 'Rihani changed another player’s physical Traitors.'); + requireRihani(Array.isArray(receipt.used) && new Set(receipt.used).size === receipt.used.length && + members(receipt.deckAfterPeek, receipt.before.reserve) && + (receipt.skill.normal || same(receipt.deckAfterPeek, receipt.before.reserve)) && + same(receipt.eligible, old.traitors.filter((id) => !receipt.used.includes(id))) && + receipt.peeked.length === (receipt.skill.normal ? 2 : 0) && + new Set(receipt.peeked).size === receipt.peeked.length && + receipt.peeked.every((id) => receipt.before.reserve.includes(id)), 'Rihani lost its original inspection or unused cards.'); + requireRihani((receipt.drawn.length === 0 || receipt.drawn.length === 2) && + new Set(receipt.drawn).size === receipt.drawn.length && receipt.drawn.every((id) => receipt.before.reserve.includes(id)), + 'Rihani must draw two distinct physical cards.'); + if (!receipt.drawn.length) { + requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && same(owner.traitors, old.traitors) && + same(receipt.state.reserve, receipt.deckAfterPeek), 'The undrawn Rihani offer changed hand custody.'); + } else { + requireRihani(receipt.skill.skilled && receipt.eligible.length > 0, 'This Rihani cannot exchange an unused card.'); + requireRihani(same(receipt.drawn, receipt.deckAfterPeek.slice(0, 2)), 'Rihani did not draw from the saved deck order.'); + const remaining = receipt.deckAfterPeek.slice(2); + if (receipt.stage === 'return') requireRihani(!receipt.kept && !receipt.given && + same(owner.traitors, [...old.traitors, ...receipt.drawn]) && same(receipt.state.reserve, remaining), + 'The pending Rihani return lost its drawn cards.'); + else requireRihani(receipt.stage === 'complete' && receipt.kept && receipt.drawn.includes(receipt.kept) && + receipt.given && receipt.eligible.includes(receipt.given) && + members(owner.traitors, [...old.traitors.filter((id) => id !== receipt.given), receipt.kept]) && + members(receipt.state.reserve, [...remaining, receipt.given, ...receipt.drawn.filter((id) => id !== receipt.kept)]), + 'The completed Rihani exchange lost its exact new or returned card.'); + } + if (receipt.stage === 'offer') requireRihani(receipt.skill.skilled && receipt.eligible.length > 0 && !receipt.drawn.length, + 'The Rihani draw offer is no longer available.'); + if (current) requireRihani(same(current, receipt.state), 'The Rihani choice no longer matches current Traitor custody.'); +} + +export function chooseRihaniDraw(receipt: RihaniReceipt, universe: readonly string[], current: NexusTraitorSnapshot, draw: boolean): RihaniReceipt { + validateRihani(receipt, universe, current); + requireRihani(receipt.stage === 'offer', 'This Rihani draw has already been answered.'); + const next = structuredClone(receipt); + if (!draw) next.stage = 'complete'; + else { + requireRihani(next.state.reserve.length >= 2, 'Rihani requires two physical cards in the Traitor Deck.'); + next.drawn = next.state.reserve.splice(0, 2); + next.state.players.find((p) => p.id === next.owner)!.traitors.push(...next.drawn); + next.stage = 'return'; + } + signed(next); validateRihani(next, universe); return next; +} +export function finishRihani(receipt: RihaniReceipt, universe: readonly string[], current: NexusTraitorSnapshot, + kept: string, given: string, rng: () => number): RihaniReceipt { + validateRihani(receipt, universe, current); + requireRihani(receipt.stage === 'return' && receipt.drawn.includes(kept) && receipt.eligible.includes(given), + 'Keep one newly drawn Traitor and reveal one unused Traitor held before the draw.'); + const next = structuredClone(receipt), other = next.drawn.find((id) => id !== kept)!; + const owner = next.state.players.find((p) => p.id === next.owner)!; + owner.traitors = owner.traitors.filter((id) => id !== given && id !== other); + next.state.reserve = shuffled([...next.state.reserve, given, other], rng); + next.kept = kept; next.given = given; next.stage = 'complete'; + signed(next); validateRihani(next, universe); return next; +} diff --git a/tests/leader-battle-effects-controls.test.tsx b/tests/leader-battle-effects-controls.test.tsx new file mode 100644 index 00000000..e193577b --- /dev/null +++ b/tests/leader-battle-effects-controls.test.tsx @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type GameView } from '../game/engine'; +import { + leaderSkillBattle, + resolveLeaderSkillBattle, +} from './leader-skill-battle-fixture'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next( + specifier === 'next/image' ? 'vinext/shims/image' : specifier, + context, + ); + }, +}); +const { RihaniChoice, RihaniHistory } = + await import('../components/rihani-decipherer'); +aliases.deregister(); + +function choiceHtml(game: GameView, busy = false) { + return renderToStaticMarkup( + createElement(RihaniChoice, { game, busy, act() {} }), + ); +} +function historyHtml(game: GameView) { + return renderToStaticMarkup(createElement(RihaniHistory, { game })); +} +function identityName(game: GameView, id: string) { + return game.allLeaders.find((leader) => leader.id === id)?.name ?? id; +} + +void test('Rihani offer and private inspection render only for the owning seat', () => { + const pending = resolveLeaderSkillBattle( + leaderSkillBattle({ skill: 'rihani-decipherer' }), + ); + const owner = viewGame(pending, 'a'); + const observer = viewGame(pending, 'd'); + const receipt = pending.rihaniHistory![0]; + const offer = choiceHtml(owner); + + assert.match(offer, /aria-label="Rihani draw offer"/); + assert.match(offer, /Once drawn, you must keep one new card/); + assert.match(offer, />Draw two Traitors<\/button>/); + assert.match(offer, />Decline exchange<\/button>/); + assert.equal(choiceHtml(observer), ''); + assert.deepEqual(observer.rihani?.history, []); + assert.equal('drawn' in observer.rihani!.pending!, false); + assert.equal('eligible' in observer.rihani!.pending!, false); + + const privateHistory = historyHtml(owner); + assert.match(privateHistory, /Your private Rihani inspections/); + assert.match( + privateHistory, + /Normal inspection — returned to the shuffled deck/, + ); + for (const id of receipt.peeked) { + assert.ok(privateHistory.includes(identityName(owner, id))); + assert.ok( + privateHistory.includes(`Inspect traitor: ${identityName(owner, id)}`), + ); + } + assert.equal(historyHtml(observer), ''); + assert.equal( + (choiceHtml(owner, true).match(/]*disabled=""/g) ?? []).length, + 2, + ); +}); + +void test('Rihani return controls separate new-card keeps from unused old-card reveals and require both selections', () => { + const offered = resolveLeaderSkillBattle( + leaderSkillBattle({ skill: 'rihani-decipherer' }), + ); + const event = offered.rihaniHistory![0].event; + const drawn = applyAction(offered, 'a', { + type: 'decision', + event, + draw: true, + }); + const receipt = drawn.rihaniHistory![0]; + const owner = viewGame(drawn, 'a'); + const observer = viewGame(drawn, 'd'); + const controls = choiceHtml(owner); + + assert.match(controls, /aria-label="Rihani Traitor exchange"/); + assert.equal( + (controls.match(/name="rihani-keep"/g) ?? []).length, + receipt.drawn.length, + ); + assert.equal( + (controls.match(/name="rihani-give"/g) ?? []).length, + receipt.eligible.length, + ); + assert.doesNotMatch(controls, /checked=""/); + assert.match( + controls, + /]*disabled=""[^>]*>Keep and reveal selected cards<\/button>/, + ); + for (const id of receipt.drawn) { + assert.ok(controls.includes(`value="${id}"`)); + assert.ok(controls.includes(`Keep ${identityName(owner, id)}`)); + } + for (const id of receipt.eligible) { + assert.ok(controls.includes(`value="${id}"`)); + assert.ok(controls.includes(`Reveal ${identityName(owner, id)}`)); + } + assert.equal(choiceHtml(observer), ''); + assert.equal('drawn' in observer.rihani!.pending!, false); + assert.equal('eligible' in observer.rihani!.pending!, false); + + const busy = choiceHtml(owner, true); + assert.equal( + (busy.match(/]*disabled=""/g) ?? []).length, + receipt.drawn.length + receipt.eligible.length, + ); + assert.match( + busy, + /]*disabled=""[^>]*>Keep and reveal selected cards<\/button>/, + ); + + const kept = receipt.drawn[1]; + const given = receipt.eligible[0]; + const done = applyAction(drawn, 'a', { + type: 'decision', + event, + cards: [kept, given], + }); + const completed = historyHtml(viewGame(done, 'a')); + assert.ok(completed.includes(`Kept ${identityName(owner, kept)}`)); + assert.ok(completed.includes(`returned ${identityName(owner, given)}`)); + assert.equal(historyHtml(viewGame(done, 'd')), ''); +}); diff --git a/tests/leader-battle-effects-recovery.test.ts b/tests/leader-battle-effects-recovery.test.ts new file mode 100644 index 00000000..61cabd08 --- /dev/null +++ b/tests/leader-battle-effects-recovery.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DatabaseSync } from 'node:sqlite'; +import type { RoomsClock } from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; +import { + leaderSkillBattle, + resolveLeaderSkillBattle, +} from './leader-skill-battle-fixture'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; + +void test('SQLite restart preserves both Rihani choices and concurrent submissions change physical Traitors exactly once', async () => { + const f = unitStore(); + const sqlite: DatabaseSync = f.sqlite; + try { + const created = await f.rooms.createRoom( + 'Decipherer', + 'emperor', + false, + [], + ); + const code = created.view.code; + const joined = await f.rooms.joinRoom(code, 'Opponent', 'guild'); + const tokens = [created.token!, joined.token!]; + const old = await Promise.all( + tokens.map((token) => f.rooms.authenticate(code, token)), + ); + for (const [index, auth] of old.entries()) + sqlite + .prepare( + 'UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?', + ) + .run(index ? 'd' : 'a', code, auth.playerId); + const auths = await Promise.all( + tokens.map((token) => f.restart().authenticate(code, token)), + ); + + const state = resolveLeaderSkillBattle( + leaderSkillBattle({ skill: 'rihani-decipherer' }), + ); + state.code = code; + state.version = (await f.rooms.readRoom(code)).version; + sqlite + .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') + .run(JSON.stringify(state), state.version, code); + + const ownerOffer = await f.restart().readSeatView(code, auths[0]); + const observerOffer = await f.restart().readSeatView(code, auths[1]); + assert.equal(ownerOffer.decision?.kind, 'rihani'); + assert.equal(ownerOffer.rihani?.history[0].peeked.length, 2); + assert.deepEqual(observerOffer.rihani?.history, []); + assert.equal('rihaniHistory' in observerOffer, false); + assert.equal('drawn' in observerOffer.rihani!.pending!, false); + assert.equal('eligible' in observerOffer.rihani!.pending!, false); + assert.deepEqual(await f.restart().readRoom(code), state); + + const event = state.rihaniHistory![0].event; + const draw = { type: 'decision' as const, event, draw: true }; + let arrivals = 0; + let release!: () => void; + let barrier = new Promise((resolve) => { + release = resolve; + }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await barrier; + }; + const drawResults = await Promise.allSettled([ + f.rooms.act(code, auths[0], state.version, draw, clock), + f.restart().act(code, auths[0], state.version, draw, clock), + ]); + delete f.hooks.beforeWrite; + assert.equal(arrivals, 2); + assert.equal( + drawResults.filter((result) => result.status === 'fulfilled').length, + 1, + ); + assert.equal( + drawResults.filter((result) => result.status === 'rejected').length, + 1, + ); + + const drawn = await f.restart().readRoom(code); + const receipt = drawn.rihaniHistory![0]; + assert.equal(drawn.version, state.version + 1); + assert.equal(receipt.stage, 'return'); + assert.equal(receipt.drawn.length, 2); + assert.equal( + drawn.players[0].traitors.length, + state.players[0].traitors.length + 2, + ); + assert.equal( + drawn.traitorReserve!.length, + state.traitorReserve!.length - 2, + ); + const observerReturn = await f.restart().readSeatView(code, auths[1]); + assert.equal(observerReturn.rihani?.pending?.stage, 'return'); + assert.equal('drawn' in observerReturn.rihani!.pending!, false); + assert.equal('eligible' in observerReturn.rihani!.pending!, false); + + const kept = receipt.drawn[0]; + const given = receipt.eligible[0]; + const returned = receipt.drawn[1]; + const finish = { + type: 'decision' as const, + event, + cards: [kept, given], + }; + arrivals = 0; + barrier = new Promise((resolve) => { + release = resolve; + }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await barrier; + }; + const finishResults = await Promise.allSettled([ + f.rooms.act(code, auths[0], drawn.version, finish, clock), + f.restart().act(code, auths[0], drawn.version, finish, clock), + ]); + delete f.hooks.beforeWrite; + assert.equal(arrivals, 2); + assert.equal( + finishResults.filter((result) => result.status === 'fulfilled').length, + 1, + ); + assert.equal( + finishResults.filter((result) => result.status === 'rejected').length, + 1, + ); + + const done = await f.restart().readRoom(code); + assert.equal(done.version, state.version + 2); + assert.equal(done.rihaniHistory![0].stage, 'complete'); + assert.equal(done.lastBattleContext?.rihani?.completed, true); + assert.equal( + done.players[0].traitors.length, + state.players[0].traitors.length, + ); + assert.ok(done.players[0].traitors.includes(kept)); + assert.ok(!done.players[0].traitors.includes(given)); + assert.ok(done.traitorReserve!.includes(given)); + assert.ok(done.traitorReserve!.includes(returned)); + assert.equal(done.traitorReserve!.length, state.traitorReserve!.length); + await assert.rejects(() => + f.restart().act(code, auths[0], drawn.version, finish, clock), + ); + assert.deepEqual(await f.restart().readRoom(code), done); + } finally { + sqlite.close(); + } +}); diff --git a/tests/leader-battle-effects.test.ts b/tests/leader-battle-effects.test.ts new file mode 100644 index 00000000..218b065f --- /dev/null +++ b/tests/leader-battle-effects.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine'; +import { leaderSkillBattle, resolveLeaderSkillBattle } from './leader-skill-battle-fixture'; +import { leaderSkillStrongholdCount } from '../game/leader-skill-battle-board'; +import { leaderSkillBattleBonus, bureaucratBattlePenalty } from '../game/leader-skill-combat'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { rihaniVictorySkill } from '../game/rihani-decipherer'; + +function reject(g: Game, id: string, action: Action) { + const before = JSON.stringify(g); assert.throws(() => applyAction(g, id, action)); assert.equal(JSON.stringify(g), before); +} +function conserved(g: Game) { + validateLeaderSkills(g.leaderSkills!, g.players); + for (const p of g.players) assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a,b) => a+b, 0), 20); + assert.equal(new Set([...(g.traitorReserve ?? []), ...g.players.flatMap((p) => p.traitors)]).size, g.players.length * 5); +} + +void test('Mentat adds only surviving selected disc strength and Bureaucrat reduces the opposing total', () => { + for (const captured of [false, true]) { + const assignments = [{ skill: 'mentat' as const, leader: 'm', faceUp: captured, captured }]; + assert.equal(leaderSkillBattleBonus({ assignments, selectedLeader: { id: 'm', kind: 'disc' }, weapon: undefined, defense: undefined, skilledLeaderSurvives: true }).bonus, 2); + assert.equal(leaderSkillBattleBonus({ assignments, selectedLeader: { id: 'm', kind: 'disc' }, weapon: undefined, defense: undefined, skilledLeaderSurvives: false }).bonus, 0); + assert.equal(leaderSkillBattleBonus({ assignments, selectedLeader: { id: 'other', kind: 'disc' }, weapon: undefined, defense: undefined, skilledLeaderSurvives: true }).bonus, 0); + } + const bureau = [{ skill: 'bureaucrat' as const, leader: 'b', faceUp: false, captured: false }]; + assert.equal(bureaucratBattlePenalty(bureau, 'b', true, 3), 3); + assert.equal(bureaucratBattlePenalty(bureau, 'b', false, 3), 0); + assert.throws(() => bureaucratBattlePenalty(bureau, 'b', true, undefined)); + const mentat = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'mentat' })); + assert.ok(mentat.log.some((l) => l.text.includes('gained 2 battle strength from Mentat'))); + conserved(mentat); + const game = leaderSkillBattle({ skill: 'bureaucrat' }); + game.players[1].forces['tueks_sietch:4'] = 1; game.players[1].reserves--; + const resolved = resolveLeaderSkillBattle(game); + assert.ok(resolved.log.some((l) => l.text.includes('battle total fell by 2'))); + conserved(resolved); + const killed = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'bureaucrat', weaponKills: true })); + assert.ok(!killed.log.some((l) => l.text.includes('battle total fell'))); +}); + +void test('Bureaucrat counts distinct occupation and excludes advisors and unoccupied virtual control', () => { + const player = { faction: 'beneGesserit' as const, forces: { 'arrakeen:10': 2, 'carthag:11': 1, 'tueks_sietch:4': 1 }, advisors: { carthag: { lockedTurn: 1 } } }; + assert.equal(leaderSkillStrongholdCount({}, player), 2); + assert.equal(leaderSkillStrongholdCount({}, { ...player, forces: {} }), 0); +}); + +void test('Sandmaster adds board spice once after a surviving victory even when every dialed force dies', () => { + const game = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14, dial: 5 }); + game.spice = { 'wind_pass:14': 2 }; + const done = resolveLeaderSkillBattle(game); + assert.equal(done.spice['wind_pass:14'], 5); + assert.equal(done.players[0].spice, 20); + assert.equal(done.players[0].forces['wind_pass:14'] ?? 0, 0); + assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(done))), done); + reject(done, 'd', { type: 'traitorCall', call: false }); + for (const options of [{ hide: false }, { weaponKills: true }]) { + const other = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14, ...options }); + other.spice = { 'wind_pass:14': 2 }; + assert.equal(resolveLeaderSkillBattle(other).spice['wind_pass:14'], 2); + } + const empty = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'sandmaster' })); + assert.equal(empty.lastBattleContext?.sandmaster, undefined); + const ambiguous = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14 }); + ambiguous.spice = { 'wind_pass:14': 2, 'wind_pass:15': 3 }; + reject(ambiguous, 'd', { type: 'traitorCall', call: false }); +}); + +void test('native Rihani has a compulsory private peek then commits its separate optional draw before seeing cards', () => { + const pending = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer' })); + const receipt = pending.rihaniHistory![0], beforeHand = [...pending.players[0].traitors]; + assert.equal(receipt.peeked.length, 2); assert.equal(receipt.drawn.length, 0); + assert.equal(pending.decision?.kind, 'rihani'); + const own = viewGame(pending, 'a'), other = viewGame(pending, 'd'); + assert.equal(own.rihani!.history[0].peeked.length, 2); + assert.deepEqual(other.rihani!.history, []); + assert.equal('drawn' in other.rihani!.pending!, false); + assert.equal('rihaniHistory' in other, false); + assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); + reject(pending, 'd', { type: 'decision', event: receipt.event, draw: true }); + reject(pending, 'a', { type: 'decision', event: 'stale', draw: true }); + const decline = applyAction(pending, 'a', { type: 'decision', event: receipt.event, draw: false }); + assert.deepEqual(decline.players[0].traitors, beforeHand); + assert.equal(decline.decision?.kind, 'battleCards'); + const drawn = applyAction(pending, 'a', { type: 'decision', event: receipt.event, draw: true }); + assert.equal(drawn.players[0].traitors.length, beforeHand.length + 2); + assert.equal(drawn.rihaniHistory![0].drawn.length, 2); + reject(drawn, 'a', { type: 'decision', event: receipt.event, draw: false }); + const r = drawn.rihaniHistory![0]; + reject(drawn, 'a', { type: 'decision', event: r.event, cards: [r.eligible[0], r.drawn[0]] }); + const done = applyAction(JSON.parse(JSON.stringify(drawn)), 'a', { type: 'decision', event: r.event, cards: [r.drawn[0], r.eligible[0]] }); + assert.equal(done.players[0].traitors.length, beforeHand.length); + assert.ok(done.players[0].traitors.includes(r.drawn[0])); + assert.ok(done.traitorReserve!.includes(r.eligible[0])); + assert.ok(done.traitorReserve!.includes(r.drawn[1])); + assert.ok(!done.players[0].revealedTraitors?.includes(r.eligible[0]), 'exchange disclosure is not a traitor call'); + assert.equal(done.lastBattleContext?.rihani?.completed, true); + reject(done, 'a', { type: 'decision', event: r.event, cards: [r.drawn[0], r.eligible[0]] }); + for (const complete of [decline, done]) { + const bad = structuredClone(complete); bad.traitorReserve!.reverse(); + assert.throws(() => viewGame(bad, 'a')); + assert.throws(() => normalizeAutomaticGame(bad)); + reject(bad, 'a', { type: 'decision', cards: [] }); + } + for (const state of [pending, decline, drawn, done]) conserved(state); +}); + +void test('Rihani normal, bluff, killed, used-traitor and captured bands keep their distinct eligibility', () => { + const trainer = [{ skill: 'rihani-decipherer' as const, leader: 'r', faceUp: true, captured: false }]; + assert.equal(rihaniVictorySkill(trainer, 'r', false), null); + assert.equal(rihaniVictorySkill(trainer, 'other', false)?.normal, true); + const normal = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', hide: false })); + assert.equal(normal.rihaniHistory![0].peeked.length, 2); + assert.equal(normal.rihaniHistory![0].stage, 'complete'); + for (const options of [{ bluff: true }, { weaponKills: true }]) { + const noSkill = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', ...options })); + assert.equal(noSkill.rihaniHistory, undefined); + } + const used = leaderSkillBattle({ skill: 'rihani-decipherer' }); + used.players[0].revealedTraitors = [...used.players[0].traitors]; + const noExchange = resolveLeaderSkillBattle(used); + assert.equal(noExchange.rihaniHistory![0].stage, 'complete'); + assert.equal(noExchange.rihaniHistory![0].peeked.length, 2); + const captive = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', captured: true })); + assert.equal(captive.rihaniHistory![0].peeked.length, 0); + assert.equal(captive.rihaniHistory![0].stage, 'offer'); + assert.equal(captive.players[1].leaders[0].capturedBy, undefined); + conserved(captive); +}); + +void test('Rihani rejects changed physical custody, detached assignment or missing choice without mutation', () => { + const good = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer' })); + for (const corrupt of [ + (g: Game) => { g.rihaniHistory = []; }, + (g: Game) => { g.rihaniHistory![0].peeked.reverse(); }, + (g: Game) => { g.traitorReserve!.reverse(); }, + (g: Game) => { g.decision = null; }, + (g: Game) => { g.lastBattleContext!.rihani = undefined; }, + (g: Game) => { const a = g.leaderSkills!.assignments.find((a) => a.skill === 'rihani-decipherer')!; [a.skill, g.leaderSkills!.deck[0]] = [g.leaderSkills!.deck[0], a.skill]; }, + ]) { + const bad = structuredClone(good); corrupt(bad); const before = JSON.stringify(bad); + assert.throws(() => viewGame(bad, 'a')); assert.throws(() => normalizeAutomaticGame(bad)); + reject(bad, 'a', { type: 'advanceBots' }); assert.equal(JSON.stringify(bad), before); + } +}); + +void test('all four Rihani bots draw and complete the owned exchange through saved public/private views', () => { + const pending = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer' })); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, 'a'); view.players[0].bot = difficulty; + const drawn = applyAction(pending, 'a', botActions(view)[0]); + const returnView = viewGame(JSON.parse(JSON.stringify(drawn)), 'a'); returnView.players[0].bot = difficulty; + const done = applyAction(drawn, 'a', botActions(returnView)[0]); + assert.equal(done.rihaniHistory![0].stage, 'complete', difficulty); conserved(done); + } +}); + +void test('native Suk casualty rescue completes before a returned captive grants its frozen Rihani exchange', () => { + const done = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', captured: true, capturedNativeSkill: 'suk-graduate', hide: false })); + assert.equal(done.players[0].reserves, 16); + assert.equal(done.players[0].tanks, 3); + assert.equal(done.decision?.kind, 'rihani'); + assert.equal(done.rihaniHistory![0].peeked.length, 0); + assert.equal(done.players[1].leaders[0].capturedBy, undefined); + assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(done))), done); + conserved(done); +}); + +void test('a Rihani traitor victory inspects normally but cannot exchange the card just used to win', () => { + let game = leaderSkillBattle({ skill: 'rihani-decipherer' }); + const target = game.battle!.plans.d.leader!, old = game.players[0].traitors[0]; + if (target !== old) { + const source = [game.traitorReserve!, game.players[1].traitors].find((cards) => cards.includes(target))!; + source[source.indexOf(target)] = old; game.players[0].traitors[0] = target; + } + delete game.battle!.traitorCalls.a; + game = applyAction(game, 'a', { type: 'traitorCall', call: true }); + const done = resolveLeaderSkillBattle(game); + assert.equal(done.lastBattleContext?.result, 'traitor'); + assert.deepEqual(done.players[0].revealedTraitors, [target]); + assert.equal(done.rihaniHistory![0].peeked.length, 2); + assert.deepEqual(done.rihaniHistory![0].eligible, []); + assert.equal(done.rihaniHistory![0].stage, 'complete'); + conserved(done); +}); diff --git a/tests/leader-skill-battle-fixture.ts b/tests/leader-skill-battle-fixture.ts new file mode 100644 index 00000000..5945f0da --- /dev/null +++ b/tests/leader-skill-battle-fixture.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; +import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; + +function dealtSkillGame(native: 'emperor' | 'atreides' | 'harkonnen', advanced: boolean, skill: LeaderSkillId = 'suk-graduate'): Game { + let game = createGame('SUKTEST', newPlayer('a', 'Rescuer', native), advanced); + joinGame(game, newPlayer('d', 'Opponent', 'guild')); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === skill); + let shuffleIndex = LEADER_SKILL_CARDS.length - 1; + mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => { + array[0] = shuffleIndex-- === index ? 0 : 0xffffffff; + return array; + }); + try { game = initializeLeaderSkillsGameForAudit(game); } + finally { mock.restoreAll(); } + assert.equal(game.leaderSkills!.offers.a.cards[0], skill); + return game; +} + +export function advancedAtreidesSukOffer(): Game { + return dealtSkillGame('atreides', true); +} + +/** Genuine module setup, then a conserved, explicitly staged battle position. */ +export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game { + const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor'; + let game = dealtSkillGame(native, advanced, skill); + for (const id of ['a', 'd']) { + const offer = game.leaderSkills!.offers[id]; + const selected = id === 'a' && advanced && atreides && skill === 'suk-graduate' ? offer.cards[1] : offer.cards[0]; + game = applyAction(game, id, { type: 'leaderSkill', event: offer.event, + skill: selected, leader: id === 'a' ? `${native}-0` : 'guild-0' }); + } + if (advanced && atreides && skill === 'suk-graduate') { + // Stage an old saved assignment only after setup legally returned Suk to the deck. + const assignment = game.leaderSkills!.assignments.find((a) => a.owner === 'a')!; + const sukIndex = game.leaderSkills!.deck.indexOf('suk-graduate'); + assert.ok(sukIndex >= 0); + game.leaderSkills!.deck[sukIndex] = assignment.skill; + assignment.skill = 'suk-graduate'; + validateLeaderSkills(game.leaderSkills!, game.players); + } + for (let i = 0; game.status === 'setup' && i < 10; i++) { + const owner = game.players.find((p) => p.traitorChoices.length); + assert.ok(owner); + game = applyAction(game, owner.id, { type: 'traitor', leader: owner.traitorChoices[0] }); + } + assert.equal(game.status, 'playing'); + if (captured) { + const [own, other] = game.leaderSkills!.assignments; + [own.skill, other.skill] = [other.skill, own.skill]; + game.players[1].leaders[0].capturedBy = 'a'; + if (capturedNativeSkill && own.skill !== capturedNativeSkill) { + const index = game.leaderSkills!.deck.indexOf(capturedNativeSkill); + assert.ok(index >= 0); + [own.skill, game.leaderSkills!.deck[index]] = [game.leaderSkills!.deck[index], own.skill]; + } + } + const battleKey = `${territory}:${sector}`; + for (const player of game.players) { + game.deck.push(...player.hand); player.hand = []; + player.forces = { [battleKey]: 5 }; player.reserves = 15; player.tanks = 0; + player.spice = 20; player.battleLosses = 0; + if (player.elites) player.elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 }; + } + if (elite) game.players[0].elites = { forces: { [battleKey]: 2 }, reserves: 3, tanks: 0, revived: 0 }; + const cardIndex = game.deck.findIndex((card) => card.kind === 'worthless'); + assert.ok(cardIndex >= 0); + const [played] = game.deck.splice(cardIndex, 1); + game.players[0].hand.push(played); + let enemyWeapon: string | undefined; + if (weaponKills) { + const index = game.deck.findIndex((card) => card.kind === 'projectile'); + assert.ok(index >= 0); + const [weapon] = game.deck.splice(index, 1); + game.players[1].hand.push(weapon); enemyWeapon = weapon.id; + } + Object.assign(game, { phase: 6, storm: 18, order: ['a', 'd'], active: 'a', ready: [], decision: null, response: null, phaseOpening: null }); + game = applyAction(game, 'a', { type: 'chooseBattle', territory, target: 'd' }); + while (game.decision?.kind === 'leaderSkillVisibility') { + const decision = game.decision; + game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event, + hide: decision.player === 'a' ? hide : false }); + } + while (game.battle?.preparation) + game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' }); + if (game.decision?.kind === 'fullPlanOffer') + game = applyAction(game, game.decision.player, { type: 'decision', decline: true }); + game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0, + leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id }); + game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon }); + game = applyAction(game, 'a', { type: 'traitorCall', call: false }); + return game; +} + +export function resolveLeaderSkillBattle(game: Game): Game { + return applyAction(game, 'd', { type: 'traitorCall', call: false }); +} diff --git a/tests/leader-skill-combat.test.ts b/tests/leader-skill-combat.test.ts index 8461f402..8adbfaac 100644 --- a/tests/leader-skill-combat.test.ts +++ b/tests/leader-skill-combat.test.ts @@ -187,6 +187,23 @@ function play( return card; } +void test('Mentat and Bureaucrat change the winner at the score boundary without changing printed strength', () => { + const mentat = battle(); + assert.equal(quoteBattleResolution(mentat).winner, 'a'); + mentat.defender.leaderSkills = [{ skill: 'mentat', leader: mentat.defender.leader!.id, faceUp: false, captured: false }]; + const boosted = quoteBattleResolution(mentat); + assert.equal(boosted.winner, 'd'); + assert.deepEqual(boosted.scores, { attacker: 5, defender: 6 }); + assert.deepEqual(boosted.leaderStrengths, { attacker: 3, defender: 2 }); + const bureau = battle(); + bureau.defender.leaderSkills = [{ skill: 'bureaucrat', leader: bureau.defender.leader!.id, faceUp: false, captured: false }]; + bureau.attacker.occupiedStrongholds = 2; + const penalized = quoteBattleResolution(bureau); + assert.equal(penalized.winner, 'd'); + assert.deepEqual(penalized.scores, { attacker: 3, defender: 4 }); + assert.deepEqual(penalized.leaderStrengths, { attacker: 3, defender: 2 }); +}); + void test('battle quote adds only surviving skill receipts to scores while preserving printed strength and bounty', () => { const input = battle(); const worthless = play( diff --git a/tests/leader-skills-controls.test.ts b/tests/leader-skills-controls.test.ts index f8595716..9361cc18 100644 --- a/tests/leader-skills-controls.test.ts +++ b/tests/leader-skills-controls.test.ts @@ -66,7 +66,11 @@ void test('renders nothing when the projected module is absent and never invents publicOnly, /Your private offer|Choose one private skill/, ); - assert.doesNotMatch(publicOnly, /Bureaucrat|Spice Banker|Diplomat/); + assert.deepEqual( + [...publicOnly.matchAll(/aria-label="([^"]+) Leader Skill card"/g)].map((match) => match[1]), + ['Warmaster'], + 'General guidance may name skills; only the public assignment gets a card face.', + ); }); void test('every public assignment card exposes its complete canonical normal and battle text', () => { @@ -160,7 +164,7 @@ void test('revival asks before preview and does not offer a decline after cards assert.match(undrawn, /before seeing either private card/); assert.match(undrawn, /Draw two skills/); assert.match(undrawn, /Continue without a skill/); - assert.doesNotMatch(undrawn, /Bureaucrat|Diplomat|Your private offer/); + assert.doesNotMatch(undrawn, /Leader Skill card"|Your private offer/); assert.equal( disabledControls( markup( diff --git a/tests/leader-skills-recovery.test.ts b/tests/leader-skills-recovery.test.ts index 8e5aea52..8cbb49db 100644 --- a/tests/leader-skills-recovery.test.ts +++ b/tests/leader-skills-recovery.test.ts @@ -9,6 +9,7 @@ import { } from '../game/engine'; import type { RoomsClock } from '../db/rooms'; import { unitStore } from './fixture-nexus-room-store'; +import { botActions } from '../game/bots'; const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; @@ -409,6 +410,16 @@ void test('battle death and Ghola revival preserve one private replacement skill 1, ); assertSkillCustody(game); + // The randomly assigned winning skill can require casualty or Traitor choices + // before card cleanup. Resolve those real choices before testing Ghola revival. + for (let step = 0; step < 3 && ['rihani', 'sukRescue'].includes(game.decision?.kind ?? ''); step++) { + const owner = f.auths.findIndex((auth) => auth.playerId === game.decision!.player); + assert.ok(owner >= 0); + const seat = await f.restart().readSeatView(f.code, f.auths[owner]); + const [choice] = botActions(seat); + assert.ok(choice, 'the winning skill must have a legal saved continuation'); + game = await act(f, owner, choice); + } if (game.decision?.kind === 'battleCards') game = await act( f, diff --git a/tests/suk-graduate-fixture.ts b/tests/suk-graduate-fixture.ts index 5658b0e0..701d4492 100644 --- a/tests/suk-graduate-fixture.ts +++ b/tests/suk-graduate-fixture.ts @@ -1,95 +1,2 @@ -import assert from 'node:assert/strict'; -import { mock } from 'node:test'; -import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine'; -import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; -import { validateLeaderSkills } from '../game/leader-skills'; - -function dealtSukGame(native: 'emperor' | 'atreides' | 'harkonnen', advanced: boolean): Game { - let game = createGame('SUKTEST', newPlayer('a', 'Rescuer', native), advanced); - joinGame(game, newPlayer('d', 'Opponent', 'guild')); - for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); - const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'suk-graduate'); - let shuffleIndex = LEADER_SKILL_CARDS.length - 1; - mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => { - array[0] = shuffleIndex-- === index ? 0 : 0xffffffff; - return array; - }); - try { game = initializeLeaderSkillsGameForAudit(game); } - finally { mock.restoreAll(); } - assert.equal(game.leaderSkills!.offers.a.cards[0], 'suk-graduate'); - return game; -} - -export function advancedAtreidesSukOffer(): Game { - return dealtSukGame('atreides', true); -} - -/** Genuine module setup, then a conserved, explicitly staged battle position. */ -export function sukBattle({ hide = true, advanced = false, elite = false, bluff = false, captured = false, weaponKills = false, dial = 4, atreides = false } = {}): Game { - const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor'; - let game = dealtSukGame(native, advanced); - for (const id of ['a', 'd']) { - const offer = game.leaderSkills!.offers[id]; - const skill = id === 'a' && advanced && atreides ? offer.cards[1] : offer.cards[0]; - game = applyAction(game, id, { type: 'leaderSkill', event: offer.event, - skill, leader: id === 'a' ? `${native}-0` : 'guild-0' }); - } - if (advanced && atreides) { - // Stage an old saved assignment only after setup legally returned Suk to the deck. - const assignment = game.leaderSkills!.assignments.find((a) => a.owner === 'a')!; - const sukIndex = game.leaderSkills!.deck.indexOf('suk-graduate'); - assert.ok(sukIndex >= 0); - game.leaderSkills!.deck[sukIndex] = assignment.skill; - assignment.skill = 'suk-graduate'; - validateLeaderSkills(game.leaderSkills!, game.players); - } - for (let i = 0; game.status === 'setup' && i < 10; i++) { - const owner = game.players.find((p) => p.traitorChoices.length); - assert.ok(owner); - game = applyAction(game, owner.id, { type: 'traitor', leader: owner.traitorChoices[0] }); - } - assert.equal(game.status, 'playing'); - if (captured) { - const [own, other] = game.leaderSkills!.assignments; - [own.skill, other.skill] = [other.skill, own.skill]; - game.players[1].leaders[0].capturedBy = 'a'; - } - for (const player of game.players) { - game.deck.push(...player.hand); player.hand = []; - player.forces = { 'arrakeen:10': 5 }; player.reserves = 15; player.tanks = 0; - player.spice = 20; player.battleLosses = 0; - if (player.elites) player.elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 }; - } - if (elite) game.players[0].elites = { forces: { 'arrakeen:10': 2 }, reserves: 3, tanks: 0, revived: 0 }; - const cardIndex = game.deck.findIndex((card) => card.kind === 'worthless'); - assert.ok(cardIndex >= 0); - const [played] = game.deck.splice(cardIndex, 1); - game.players[0].hand.push(played); - let enemyWeapon: string | undefined; - if (weaponKills) { - const index = game.deck.findIndex((card) => card.kind === 'projectile'); - assert.ok(index >= 0); - const [weapon] = game.deck.splice(index, 1); - game.players[1].hand.push(weapon); enemyWeapon = weapon.id; - } - Object.assign(game, { phase: 6, storm: 18, order: ['a', 'd'], active: 'a', ready: [], decision: null, response: null, phaseOpening: null }); - game = applyAction(game, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'd' }); - while (game.decision?.kind === 'leaderSkillVisibility') { - const decision = game.decision; - game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event, - hide: decision.player === 'a' ? hide : false }); - } - while (game.battle?.preparation) - game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' }); - if (game.decision?.kind === 'fullPlanOffer') - game = applyAction(game, game.decision.player, { type: 'decision', decline: true }); - game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0, - leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id }); - game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon }); - game = applyAction(game, 'a', { type: 'traitorCall', call: false }); - return game; -} - -export function resolveSuk(game: Game): Game { - return applyAction(game, 'd', { type: 'traitorCall', call: false }); -} +/** Keep the earlier Suk fixture API while sharing genuine setup with later skills. */ +export { leaderSkillBattle as sukBattle, resolveLeaderSkillBattle as resolveSuk, advancedAtreidesSukOffer } from './leader-skill-battle-fixture';