diff --git a/components/game-table.tsx b/components/game-table.tsx index 22070e13..a364a79a 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -51,6 +51,7 @@ import { LeaderSkillsPanel } from './leader-skills'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { OrnithopterMovement } from './ornithopter-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; +import { PlanetologistMovement } from './planetologist-movement'; import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison'; import { PortableSnooper } from './portable-snooper'; import { @@ -141,6 +142,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation'; import { PrivateBattlePlan } from './private-battle-plan'; +import { canUsePlanetologistBattleSpecial, validLeaderSkillBattleCardPair } from '../game/leader-skill-combat'; +import type { Card } from '../game/cards'; import { RevealedBattle } from './revealed-battle'; import { EliteCount } from './elite-count'; import { HelpTip } from './help-tip'; @@ -404,6 +407,12 @@ export function GameTable({ const [discardCards, setDiscardCards] = useState>({}); const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null; const committed = battlePlanCommitments(g); + const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({ + assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [], + selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader, + card, + }); + const selectedBattleWeapon = me.hand?.find(c => c.id === (committed.weapon ? committed.weapon.value : weapon)); const selectedStone = me.hand?.find( (c) => c.id === (committed.weapon ? committed.weapon.value : weapon) && @@ -4010,6 +4019,28 @@ export function GameTable({ } } /> + = (me.movesAllowed ?? 1) + ? 'No ordinary movement remains this turn.' + : selectedDestinationInStorm + ? 'Choose a destination outside the storm.' + : liveShipmentPromises( + g.shipmentPromises, + me.id, + g.turn, + ).some((promise) => promise.answer) + ? 'Complete the promised shipment before moving.' + : null + } + />

{guildCunning?.stage === 'secondShipment' ? 'Choose or decline the second shipment before any Hajr movement.' @@ -4430,17 +4461,24 @@ export function GameTable({ {me.hand ?.filter((c) => name === 'Defense' - ? isDefenseCard(c) - : isWeaponCard(c), + ? isDefenseCard(c) && (!selectedBattleWeapon || !planetologistSpecial(selectedBattleWeapon) || validLeaderSkillBattleCardPair(selectedBattleWeapon,c,true)) + : isWeaponCard(c) || planetologistSpecial(c), ) .map((c) => ( ))} ))} + {selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && ( +

+ {selectedBattleWeapon.name} adds 2 to the surviving Planetologist leader. + It makes no weapon attack and does not use its normal effect. + Discard it after this battle, even if you win. +

+ )} {selectedStone && (

diff --git a/components/leader-skills.tsx b/components/leader-skills.tsx index 9f1108d5..ff567ad6 100644 --- a/components/leader-skills.tsx +++ b/components/leader-skills.tsx @@ -109,7 +109,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. Other Leader Skill effects are + Killer Medic and Prana-Bindu Adept, plus Planetologist movement and + Special-card battle use. Other Leader Skill effects are unfinished.

diff --git a/components/planetologist-movement.tsx b/components/planetologist-movement.tsx new file mode 100644 index 00000000..b40278a0 --- /dev/null +++ b/components/planetologist-movement.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useId, useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { + planetologistLeader, + type PlanetologistMoveMode, +} from '@/game/planetologist-movement'; +import { botGroundMoveAllowed } from '@/game/bot-mobility'; +import { + gameTerritories, + location, + splitLocation, + territory, +} from '@/game/board'; +import { isAdvisor } from '@/game/advisors'; +import { presenceAt } from '@/game/force-presence'; +import { faction } from '@/game/catalog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +type Counts = Record; + +export function planetologistMoveDraft( + game: GameView, + mode: PlanetologistMoveMode, + destination: string, + sector: number, + forces: Counts, + elites: Counts, +): { action: Action | null; blocked: string | null } { + const me = game.players.find((player) => player.id === game.me); + if (!me || !planetologistLeader(game, me.id)) + return { + action: null, + blocked: 'A living, uncaptured Planetologist is required.', + }; + if ( + game.players.some((player) => faction(player.faction).expansion !== 'base') + ) + return { + action: null, + blocked: + 'Planetologist movement with expansion factions awaits its movement audit.', + }; + const selected = Object.entries(forces).filter(([, count]) => count > 0); + for (const [from, count] of selected) { + const owned = me.forces[from] ?? 0; + if (!Number.isSafeInteger(count) || count < 1 || count > owned) + return { action: null, blocked: 'Choose available whole force counts.' }; + const elite = elites[from] ?? 0; + const ownedElite = me.elites?.forces[from] ?? 0; + const minimumElite = Math.max(0, count - (owned - ownedElite)); + if ( + !Number.isSafeInteger(elite) || + elite < minimumElite || + elite > Math.min(count, ownedElite) + ) + return { + action: null, + blocked: 'Choose an available normal and elite mix for every source.', + }; + } + const origins = new Set( + selected.map(([from]) => splitLocation(from).territory), + ); + if ( + (mode === 'range' && origins.size !== 1) || + (mode === 'gather' && origins.size !== 2) + ) + return { + action: null, + blocked: + mode === 'range' + ? 'Select forces from one territory.' + : 'Select forces from exactly two different territories.', + }; + if ( + game.advanced && + me.faction === 'beneGesserit' && + new Set([...origins].map((origin) => isAdvisor(me, origin))).size > 1 && + !presenceAt(me, destination) && + game.players.some( + (player) => player.id !== me.id && presenceAt(player, destination), + ) + ) + return { + action: null, + blocked: + 'Gathering advisors and fighters into an enemy-only territory is still being implemented.', + }; + const target = location(destination, sector); + if ( + selected.some( + ([from]) => + !botGroundMoveAllowed(game, me, from, target, elites[from] ?? 0, mode), + ) + ) + return { + action: null, + blocked: + mode === 'range' + ? 'The selected route is beyond the boosted range or is blocked.' + : 'Each selected source must reach the destination using its normal range.', + }; + return { + action: { + type: 'move', + planetologist: mode, + forces: Object.fromEntries(selected), + ...(me.elites + ? { + eliteForces: Object.fromEntries( + selected.map(([from]) => [from, elites[from] ?? 0]), + ), + } + : {}), + territory: destination, + sector, + }, + blocked: null, + }; +} + +export function PlanetologistMovement({ + game, + act, + busy, + destination, + sector, + unavailableReason, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; + destination: string; + sector: number; + unavailableReason?: string | null; +}) { + const id = useId(); + const [mode, setMode] = useState('range'); + const [forces, setForces] = useState({}); + const [elites, setElites] = useState({}); + const me = game.players.find((player) => player.id === game.me); + const leader = me && planetologistLeader(game, me.id); + if (!me || !leader) return null; + const sources = Object.entries(me.forces).filter(([, count]) => count > 0); + const draft = planetologistMoveDraft( + game, + mode, + destination, + sector, + forces, + elites, + ); + const blocked = + unavailableReason ?? + (game.phase !== 5 || game.active !== me.id + ? 'Planetologist movement is available only during your movement turn.' + : game.ornithopter?.active + ? 'Finish the current Ornithopter movement first.' + : game.truthtrance || game.nexusTraitors?.pending + ? 'Resolve the current private response first.' + : me.autopilot || game.automaticContinuationPending + ? 'Wait for the current automatic action to finish.' + : draft.blocked); + const leaderName = + game.allLeaders.find((candidate) => candidate.id === leader)?.name ?? + leader; + return ( +
+

Planetologist movement

+

+ {leaderName} may add one territory to one movement, up to three, or + gather forces from exactly two territories into the selected + destination. +

+ + +

+ Destination: {territory(destination).name}, sector {sector}. Select the + physical forces to move below. +

+ {sources.length ? ( + sources.map(([from, owned]) => { + const source = splitLocation(from); + const sourceName = + gameTerritories(game).find( + (candidate) => candidate.id === source.territory, + )?.name ?? source.territory; + const selected = forces[from] ?? 0; + const ownedElite = me.elites?.forces[from] ?? 0; + const minimumElite = Math.max(0, selected - (owned - ownedElite)); + return ( +
+ + {me.elites && ( + + )} +
+ ); + }) + ) : ( +

No board forces are available to move.

+ )} + {blocked && ( + + {blocked} + + )} + +

+ This does not combine with a No-Field or either Ornithopter movement. + Normal movement and arrival rules still apply. +

+
+ ); +} diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index e465c213..cfa96fac 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -4,7 +4,8 @@ now have sourced names and complete paraphrased normal/battle text, shared inspectors, private two-card offers and one-card assignment custody. Five role bonuses, public skilled capture, death return and own revival are connected; -nine effects remain missing. This supersedes the historical missing-inventory +Planetologist now also has movement and base green-Special battle controls; +eight effects 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 b4a0037f..d71a1092 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,13 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- [Planetologist](PLANETOLOGIST_RULES.md) now has bounded movement and Special-card + battle prototypes. Independent review and **84 focused tests** pass. Two genuine + four-profile Basic/Advanced samples finished with saved continuations and no + rejected actions; the Basic sample used both movement alternatives. Browser + gather, Special substitution and refresh were exercised in a new isolated room. + All 325 opening games remain preserved. The existing server was healthy after + the outage report, so it was reused. Broad results are in the checkpoint commit. - [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md#13-september-first-at-a-later-clean-movement-boundary) now connects first among remaining unstarted movement turns after earlier turns finish, including after Advanced Guild completes its turn. Existing @@ -12,8 +19,8 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid review and focused checks pass; broad results are in the checkpoint commit. - 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 other - nine effects remain **Missing**. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md) + 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; the other eight skills remain **Missing**. 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 4ffbab0e..f08dc186 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,46 @@ # Dune implementation status +## 13 September 2026 — Planetologist movement and green-Special battle prototype + +[Planetologist](PLANETOLOGIST_RULES.md) now connects capped extra range, a single +movement from two territories to one destination, and the surviving skilled +leader's +2 green-Special battle substitution. Fremen cancellation preserves the +independent skill bonus and both saved origins. Human controls, all four bot +profiles and authoritative saved continuations use shared eligibility/range rules. +The exact nine base Specials retain physical custody and never activate their +normal effects through the battle slot. Mandatory discard includes traitor wins; +that later-card-specific reading remains explicitly identified as an inference. + +Independent review fixed stale saved battle plans accepting an already-dead skill +leader; the original reproducer now rejects without mutation. Another integration +check found appended skill moves were bypassed by ordinary candidates: they now +share the existing public destination ordering. All four profiles actually choose +and legally execute range and gather moves in targeted first-choice fixtures. +The final focused union passed **84 tests**. The first broad run stopped at +lint because six new test declarations lacked the required explicit promise +marker; those declarations were corrected before repeating the checkpoint check. Two four-profile sample games finished: +Basic seed 20260916, 194 accepted actions and six JSON restorations (range and gather +both executed); Advanced seed 20260917, 186 accepted actions and six restorations +(range executed). Neither rejected a proposed action. These are bounded samples, +not AI calibration or complete module/combination acceptance. + +Fresh browser room `TQWK5AEY` used genuine Fremen/Emperor setup and selected +Planetologist/Stilgar. The range-three control became available, then a real gather +moved two Sietch Tabr forces and one False Wall West force to Wind Pass using one +movement. Refresh restored 5/2/3 forces, the private Crysknife/Burseg and the skill. +A separate targeted battle fixture in this new QA room relocated one physical +Family Atomics from the deck and placed Emperor reserve forces. The actual weapon +selector, plan reveal, traitor loss and mandatory discard were exercised. The +Shield Wall remained intact; dead Stilgar returned the skill, and refresh preserved +the remaining hand and losses. This fixture is not a naturally reached full game. + +The server remained healthy after the user's outage report; no restart was needed. +All 325 opening rooms were privately backed up and unchanged after browser QA; +one new test room was added. Required broad checks and final preservation counts +are recorded in the checkpoint commit and source-bound private report. Eight +other skill effects, enemy-only mixed BG gather arrival, expansion rosters/cards +and optional-module combinations remain unfinished. No public gate is opened. + ## 13 September 2026 — Leader Skills setup, five disciplines and skilled capture The [first connected prototype](LEADER_SKILLS_RUNTIME.md) adds all fourteen diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 9f905685..8dcbe4dc 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -18,7 +18,7 @@ Human controls show each permitted card's normal and battle text, select its leader, choose battle posture, and handle an optional own-leader revival draw. An undrawn revival offer shows no preview; drawing obliges a selection. All four AI profiles follow the same private offers and legal actions, prioritizing the -five connected disciplines and hiding the assigned leader for battle. This +five battle disciplines during skill selection and hiding the assigned leader for battle. This simple policy is not calibrated skill strategy. Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and @@ -61,8 +61,24 @@ Foreign gholas, expansion rosters and combined optional modules remain gated. 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. Skill-specific movement, payments, information, rescues and retreats need -their own connected controls and saved choices. +open. Planetologist now has bounded movement and battle controls as described below. +The other eight skills still need their payments, information, rescues, retreats +and other effects connected. + +## Planetologist follow-up + +[Planetologist](PLANETOLOGIST_RULES.md) connects the explicit capped range bonus, +two-origin movement to one destination, Fremen cancellation/replacement and a +surviving skilled leader's +2 green-Special substitution with mandatory discard. +The movement panel and weapon selector expose those choices; all four AI profiles +can use the same physical action paths. Pending movement receipts survive JSON +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. Eight other skill effects are +still missing; this is partial prototype coverage. ## Local prototype entry and evidence diff --git a/docs/PLANETOLOGIST_RULES.md b/docs/PLANETOLOGIST_RULES.md new file mode 100644 index 00000000..2fe5ea8e --- /dev/null +++ b/docs/PLANETOLOGIST_RULES.md @@ -0,0 +1,216 @@ +# Planetologist rule contract + +Updated 13 September 2026. This note audits the movement and battle effects of the +Planetologist Leader Skill against its physical card, the common Leader Skills +rules, the GF9 base battle rules and designer Jack Reda's walkthrough. The connected prototype is described below; other Leader Skill effects remain separate work. + +## Printed effect and legal card role + +The physical Planetologist card instructs its owner to reveal one green +Special card, other than a Cheap Hero, as part of the Battle Plan “in place of +a weapon.” It adds 2 to that skilled leader disc and says to discard the +Special after the battle. The card therefore creates a distinct legal role for +the physical Special card: + +- it occupies the Battle Plan's weapon slot; +- it is revealed as a Special used in place of a weapon, rather than converted + into a Weapon Treachery Card; +- it supplies no projectile, poison, lasgun or other attack and does not satisfy + a skill or rule that requires an actual named Weapon type; +- its printed utility effect is not activated; and +- the exact physical card is committed to that plan and cannot also be spent + for its ordinary effect, Karama, Truthtrance or another pending use. + +The designer gives Hajr and Thumper as examples, then specifically describes +using Family Atomics for +2 without blowing the Shield Wall. This confirms +that Planetologist grants an alternate battle use despite the Special card's +ordinary phase or trigger. [Jack Reda walkthrough, +10:36–11:05](https://www.youtube.com/watch?v=XT_azRVLq_0&t=636s) + +The substitution is available only when the assigned Planetologist leader is +the leader actually used in the Battle Plan. Moving the skilled leader and +skill card behind the shield as a bluff, then selecting another leader, turns +off both skill bands. The other leader cannot place a green Special in the +weapon slot through Planetologist. [GF9 *CHOAM & Richese* rulebook, +p. 9](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) + +## Current base-deck inventory + +The current 33-card base deck contains these eligible printed green Special +cards: + +| Printed card | Physical copies | Current identities | +|---|---:|---| +| Family Atomics | 1 | `effect: atomics` | +| Weather Control | 1 | `effect: weather` | +| Hajr | 1 | `effect: hajr` | +| Tleilaxu Ghola | 1 | `effect: ghola` | +| Harvester | 1 | `effect: harvester` | +| Karama | 2 | `effect: karama` | +| Truthtrance | 2 | `effect: truthtrance` | + +All nine physical copies may take the Planetologist weapon-slot role. Their +ordinary timing does not need to be legal because that printed effect is not +being played. A pending or already committed physical card remains unavailable +because Planetologist does not create a second copy. + +Cheap Hero and Cheap Heroine are printed green Special components but are +excluded. “Cheap Hero” is the rules' generic component class for both named +copies; each instead occupies the leader position with strength zero. The base +FAQ also says a Cheap Hero cannot substitute for a weapon or defense. +[GF9 base rulebook and Q&A, pp. 10, +23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) + +For today's `baseDeck()`, `kind === 'special'` happens to select the nine +eligible non-Hero copies. That coincidence is not the printed rule. Eligibility +should be bound to a canonical printed-green-Special classification or explicit +physical identities, because the code's generic `special` kind is also used by +later expansion modules. The designer's Thumper example proves that expansion +cards can qualify, but it does not make every future card encoded as `special` +green or eligible. + +## Survival and scoring + +The common Leader Skills rule says a skill applies in the current battle unless +its assigned leader is killed in that battle. Planetologist adds 2 to the leader +disc, so the receipt is conditional on that exact leader surviving weapon, +traitor and explosion resolution. If the leader dies, neither its printed +strength nor the Planetologist +2 contributes to the battle total. +[GF9 *CHOAM & Richese* rulebook, p. +9](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) + +A captured skilled leader follows the expansion's general Harkonnen Q&A: the +captor has only the lower battle band and only while actually using that +captured leader. A surviving captured Planetologist may therefore use the same +green-Special substitution and +2; the original faction has no normal movement +band while the leader and skill are captured. + +## Mandatory discard + +The Special card's own final instruction is unconditional once it is revealed +through this substitution: discard it after the battle. This is more specific +than the base rule allowing an ordinary winner to keep cards played in its +plan. The physical outcome is: + +| Battle outcome | Planetologist Special disposition | +|---|---| +| Ordinary win | Discard; the winner has no retention choice for this card. | +| Ordinary loss | Discard under both the base loser rule and the skill card. | +| Opponent reveals the Planetologist leader as a traitor | Discard with the traitored player's played cards; the leader dies and supplies no +2. | +| Both leaders are traitors | Discard with the played cards; the Planetologist leader dies and supplies no +2. | +| Lasgun–shield explosion | Discard after the battle; a killed Planetologist supplies no +2. | +| Planetologist owner reveals the opposing leader as a traitor | Discard under Planetologist's later, card-specific instruction even though the base traitor winner ordinarily loses nothing. | + +The last row is a rules-specificity inference. No located publisher Q&A asks +about Planetologist versus a successful traitor call. The physical Leader Skill +plainly says to discard after the battle and prints no winner or traitor +exception, so retaining it would require adding an exception absent from the +later card. The [GF9 November 2020 +FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +confirms the general winner-retention rule, while the Planetologist card supplies +the narrower override. + +“Unused bluff” needs a physical distinction. A green Special actually revealed +in the weapon slot through Planetologist has been used in that alternate role +and is discarded even when +2 cannot affect the result or the winner is decided +by a traitor. A Special merely carried behind the shield but omitted from the +Battle Plan remains in hand; no rule reveals or discards it. A plan using a +different leader cannot legally commit the Special in Planetologist's role. + +## Persistent evidence + +The source archive at `/home/quintine/.local/share/dune/sources` contains: + +- `leader-skills-tabletopfinder-all-upscaled.png`, on which the complete + Planetologist face is readable; +- `leader-skills-designer-video-auto-transcript.txt`, with the Planetologist + explanation at transcript seconds 594–666; +- `choam-richese-rulebook-en.pdf` and its layout-preserving text extraction; + and +- `dune-rulebook-gf9-mirror.pdf` and its extraction, preserving the common + battle disposition and traitor rules. + +The designer's [official expansion +page](https://futurepastimes.com/dune-choam-richese) links the GF9 rulebook. +The full local source hashes and retrieval URLs are recorded in +`leader-skills-source-provenance.txt`. + +## Implementation boundary + +A focused implementation can expose the nine base physical copies as weapon +slot choices only for the selected, concealed Planetologist leader. It should +record the selected physical identity and alternate role in the sealed plan, +apply +2 only after confirming that leader survives, suppress the card's native +effect, and force exactly one discard after every revealed outcome. Rejected or +canceled plan changes must release the reservation without discarding the card. + +Expansion eligibility should be added from each component's printed category, +not inferred from the shared TypeScript `special` bucket. The traitor-winner +discard row should remain documented as the explicit later-card reading because +the sources contain no direct Q&A for that collision. + +## Normal movement and supported prototype + +The first band gives a choice for one movement: add one to normal range with a +maximum of three, or gather physical forces from exactly two different territories +to one shared destination using each group's ordinary range. These alternatives +do not stack. Moving multiple selected sectors of the same territory remains one +origin. Both groups retain normal storm, path, occupancy and force-selection rules; +the combined move spends one movement. Hajr can provide another normal movement. +See the [physical-card contract](LEADER_SKILLS_RULES.md) and the designer's +[example](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1153s). + +Fremen's native range and Planetologist are independent. With the range alternative, +a distance-two move needs only ordinary range one plus Planetologist. Distance +three without city ornithopters uses the Fremen advantage and opens its real +Karama response. Cancellation leaves forces and the movement opportunity in place; +the replacement may still use Planetologist. A gather using Fremen range two also +retains that response, with both origins saved and revalidated before continuation. +This applies the [existing cancellation contract](FREMEN_MOVEMENT_KARAMA_RULES.md), +not a new phase-wide restriction or an automatic shortened destination. + +The [November 2020 FAQ, p. 4](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +and [base rules, p. 18](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +determine a common Bene Gesserit arrival stance when no other faction is present, +or when existing BG forces determine the stance. The prototype supports mixed +advisor/fighter origins in those cases, and uniform origins generally. Source +advisor locks carry forward. Mixed origins entering an enemy-only territory need +a combined advisor-flip/cancellation continuation and remain explicitly guarded; +this is an implementation gap, not a rule that forbids every mixed move. No source +was found granting a chosen group order that makes fighters arrive as advisors. + +The current runtime supports only the six base factions and the nine base green +Special copies above. Expansion rosters, other optional modules, No-Field and +Discovery/Richese flight combinations remain gated. Before enabling Ix, calculate +range for each selected origin's own cyborgs and extend its cancellation trigger +for Planetologist's added range. Aggregate elite counts must not lend one group's +cyborg speed to a separate suboid-only origin. The expansion green-Special inventory +must also be audited before admitting those physical cards. + +The movement control builds ordinary authoritative actions with an explicit range +or gather choice. Bots use the same range helper and submit physical source/elite +counts. Saved Fremen continuations bind the selected skill leader, choice and exact +two-origin list; stale custody or receipts reject before spending a response card. +The weapon selector exposes eligible Specials only for the selected skilled leader +and explains +2 and mandatory discard. Bot candidates use that same eligibility and +pair validation. This is a connected prototype with partial combination coverage, +not complete Leader Skills compliance or a publication gate. + +## Verification evidence, 13 September 2026 + +The final focused union passes 84 cases, including the 24-case Planetologist +union and existing skills, Fremen cancellation and battle quote regressions. +Independent movement and battle review closed after the stale already-dead disc +reproducer was fixed. First-choice tests prove all four bot profiles actually use +both alternatives in suitable fixtures. Two source-bound full-game samples finish +with zero rejected actions and six saved restorations each; Basic executes both +movement choices, Advanced executes boosted range. These do not establish full +combination acceptance or calibrated difficulty ordering. + +The browser journey verifies genuine setup, range availability, physical gather, +private refresh and the weapon selector's Special role. A separately staged battle +in that new QA room verifies traitor-loss cleanup, one Special discard, skill death +return and the unchanged Shield Wall. [The checkpoint record](IMPLEMENTATION_STATUS.md) +separates those fixtures from the complete-game samples. Required broad check +results, source fingerprints and saved-game comparisons are retained privately +outside the checkout and reported in the verified commit. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d537fdef..a30c876a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -16,7 +16,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | | Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) | -| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines and common lifecycle are prototyped; nine effects and combinations remain missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md) | +| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution and common lifecycle are prototyped; eight skills and combinations remain missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Planetologist](PLANETOLOGIST_RULES.md) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts index 180db803..689d256d 100644 --- a/game/battle-resolution-quote.ts +++ b/game/battle-resolution-quote.ts @@ -34,6 +34,9 @@ import { type HomeworldExplosionLosses, } from './homeworld-battle-rules'; import { + PLANETOLOGIST_BASE_SPECIALS, + canUsePlanetologistBattleSpecial, + isPlanetologistBattleSpecialCard, leaderSkillBattleBonus, type BattleLeaderSkill, type LeaderSkillBattleBonus, @@ -213,6 +216,35 @@ function validateCombatant( (side.leader.id === p.leader && Number.isFinite(side.leader.strength)), 'The controlled leader must match the revealed plan.', ); + const selectedPlanetologistCards = [ + p.weapon, + p.defense, + p.leader, + side.lateDefense, + ].filter( + (selectedId): selectedId is string => + !!selectedId && + PLANETOLOGIST_BASE_SPECIALS.some( + (card) => card.id === selectedId, + ), + ); + requireQuote( + selectedPlanetologistCards.every((selectedId) => { + const selectedCard = side.hand.find((card) => card.id === selectedId); + return ( + selectedId === p.weapon && + side.leader?.id === p.leader && + !side.leader.dead && + isPlanetologistBattleSpecialCard(selectedCard) && + canUsePlanetologistBattleSpecial({ + assignments: side.leaderSkills ?? [], + selectedLeader: p.leader, + card: selectedCard, + }) + ); + }), + 'A green Special may replace only the assigned Planetologist leader’s weapon.', + ); return played; } /** @@ -614,16 +646,30 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { auditCount(other.hand, auditor.usedCards, auditor.survived); } let retention: MoritaniRetention | null = null; + const mandatoryPlanetologistDiscard = ( + side: ResolutionCombatant, + selected: string, + ) => + selected === side.plan.weapon && + canUsePlanetologistBattleSpecial({ + assignments: side.leaderSkills ?? [], + selectedLeader: side.plan.leader, + card: card(side, selected), + }); const moritani = input.participants.find((p) => p.faction === 'moritani'); if (winner && moritani) { const loser = winner === a ? d : a, loserPlayed = loser === a ? played.attacker : played.defender; - const eligible = loserPlayed.filter((selected) => - canRetainBattleCard( - card(loser, selected)!, - ac || dc, - !!loser.poisonTooth, - ), + const retainablePlayed = loserPlayed.filter( + (selected) => !mandatoryPlanetologistDiscard(loser, selected), + ); + const eligible = retainablePlayed.filter( + (selected) => + canRetainBattleCard( + card(loser, selected)!, + ac || dc, + !!loser.poisonTooth, + ), ); if ( loser.id !== moritani.id && @@ -640,7 +686,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { player: loser.id, territory: input.territory, turn: input.turn, - played: [...loserPlayed], + played: [...retainablePlayed], eligible, stage: 'choose', }; @@ -651,9 +697,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { .filter( (selected) => !( - retention?.player === s.id && retention.played.includes(selected) + retention?.player === s.id && + retention.played.includes(selected) && + !mandatoryPlanetologistDiscard(s, selected) ) && - (winner !== s || + (mandatoryPlanetologistDiscard(s, selected) || + winner !== s || !canRetainBattleCard( card(s, selected)!, ac || dc, diff --git a/game/bot-mobility.ts b/game/bot-mobility.ts index 924a0862..b2b68405 100644 --- a/game/bot-mobility.ts +++ b/game/bot-mobility.ts @@ -3,6 +3,11 @@ import { guildShipmentCost } from './shipment-price'; import { fighterCount, isAdvisor } from './advisors'; import { presenceAt } from './force-presence'; import { EcazOccupancyError } from './ecaz-occupy'; +import { + planetologistLeader, + planetologistRange, + type PlanetologistMoveMode, +} from './planetologist-movement'; import { territoryEntryBlock, strongholdPathBlocked } from './occupancy'; import { distance, @@ -66,7 +71,12 @@ export function botEntryAllowed( throw error; } } -export function botMovementRange(g: GameView, p: Seat, elite: number) { +export function botMovementRange( + g: GameView, + p: Seat, + elite: number, + planetologist?: PlanetologistMoveMode, +) { const base = fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag') ? 3 @@ -74,7 +84,10 @@ export function botMovementRange(g: GameView, p: Seat, elite: number) { (p.faction === 'ixians' && elite > 0 && !p.ixMovementBlocked) ? 2 : 1; - return base + (p.faction === 'choam' ? g.choamMovementBonus : 0); + const ordinary = base + (p.faction === 'choam' ? g.choamMovementBonus : 0); + return planetologist && planetologistLeader(g, p.id) + ? planetologistRange(ordinary, planetologist) + : ordinary; } export function botGroundMoveAllowed( g: GameView, @@ -82,6 +95,7 @@ export function botGroundMoveAllowed( from: string, to: string, elite: number, + planetologist?: PlanetologistMoveMode, ) { const source = splitLocation(from), target = splitLocation(to); @@ -117,7 +131,7 @@ export function botGroundMoveAllowed( (loc.sector !== 0 && loc.sector === g.storm) || strongholdPathBlocked(g.players, p.id, loc.territory, advisors) ); - }) <= botMovementRange(g, p, elite) + }) <= botMovementRange(g, p, elite, planetologist) ); } /** Fremen reinforcement radius is static territory geometry, not a ground route. */ diff --git a/game/bots.ts b/game/bots.ts index 40036620..0af937d8 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1,4 +1,6 @@ import { discoveryBotActions } from './discovery-options'; +import type { Card } from './cards'; +import { canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat'; import { discoveryFlightBotActions } from './discovery-flight-options'; import { discoveryEntryBotActions } from './discovery-entry-options'; import { discoveryStormActions } from './discovery-storm-options'; @@ -48,7 +50,6 @@ import { isWeaponCard, isStoneBurner, isDefenseCard, - validBattleCardPair, defaultVoiceMatch, playedVoiceMatch, weaponKills, @@ -72,6 +73,8 @@ import { fremenReserveEntry, guildTransportCost, } from './bot-mobility'; +import { planetologistLeader } from './planetologist-movement'; +import { faction } from './catalog'; import { richeseCardDefinition } from './richese-cards'; import { presenceAt } from './force-presence'; import { validateCohortSelection, type OrnithopterMode } from './ornithopter'; @@ -670,9 +673,13 @@ function plans(g: GameView): Action[] { commitments.every( (element) => element.field !== field || element.value === value, ); + const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? []; + const specialForLeader = (card: Card | undefined, leader: string | null) => + canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card}); const weapons = [ null, - ...(me.hand ?? []).filter(isWeaponCard).map((c) => c.id), + ...(me.hand ?? []).filter(c => isWeaponCard(c) || + me.leaders.some(l => !l.dead && controlsLeader(me,l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c,l.id))).map(c => c.id), ].filter((id) => fixed('weapon', id)); const defenses = [ null, @@ -752,9 +759,10 @@ function plans(g: GameView): Action[] { ) + (kwisatz ? 2 : 0); const w = me.hand?.find((c) => c.id === weapon); const d = me.hand?.find((c) => c.id === defense); + if (w && !isWeaponCard(w) && !specialForLeader(w,leader)) continue; const stoneBattle = isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon)); - if ((!leader && (weapon || defense)) || !validBattleCardPair(w, d)) + if ((!leader && (weapon || defense)) || !validLeaderSkillBattleCardPair(w, d, specialForLeader(w,leader))) continue; if (voice) { const used = @@ -800,7 +808,11 @@ function plans(g: GameView): Action[] { b.strongholdEffects[other.id], ); const ownSurvivingStrength = - effects.stunned || effects.attackerDead ? 0 : strength; + effects.stunned || effects.attackerDead ? 0 : strength + leaderSkillBattleBonus({ + assignments:battleSkills, + selectedLeader:leader ? {id:leader,kind:myLeader?'disc':'hero'} : undefined, + weapon:w,defense:d,skilledLeaderSurvives:true, + }).bonus; const enemySurvivingStrength = effects.stunned || (inspected && effects.defenderDead) ? 0 @@ -869,7 +881,7 @@ function plans(g: GameView): Action[] { )) ) continue; - const effectiveWeapon = w && w.kind !== 'worthless' ? 6 : 0; + const effectiveWeapon = w && w.kind !== 'worthless' && !specialForLeader(w,leader) ? 6 : 0; const effectiveDefense = d && d.kind !== 'worthless' ? 4 : 0; const selfExplosion = battleWeaponsExplode(w, d); const correctDefense = inspected @@ -3088,6 +3100,13 @@ function policyActions(g: GameView): Action[] { return { from, amount, elite }; }) .filter((source) => source.amount > 0); + const planetologist = planetologistLeader(g, me.id); + const groundMoves: { + action: Action; + destinationScore: number; + amount: number; + gather: boolean; + }[] = []; if (movementAvailable) for (const to of targets .filter( @@ -3100,15 +3119,131 @@ function policyActions(g: GameView): Action[] { .slice(0, 24)) for (const { from, amount, elite } of movingSources) { if (botGroundMoveAllowed(g, me, from, to.key, elite)) - actions.push({ - type: 'move', - from, - territory: to.t, - sector: to.s, + groundMoves.push({ + action: { + type: 'move', + from, + territory: to.t, + sector: to.s, + amount, + elite, + }, + destinationScore: to.score, amount, - elite, + gather: false, }); } + if ( + movementAvailable && + planetologist && + !g.ornithopter?.active && + g.players.every((player) => faction(player.faction).expansion === 'base') + ) { + const usefulTargets = targets + .filter( + (to) => + marker?.location.territory !== to.t && to.score > -1000, + ); + for (const to of usefulTargets + .filter((target) => + movingSources.some( + ({ from, elite }) => + botGroundMoveAllowed( + g, + me, + from, + target.key, + elite, + 'range', + ) && !botGroundMoveAllowed(g, me, from, target.key, elite), + ), + ) + .slice(0, 24)) + for (const { from, amount, elite } of movingSources) { + if ( + botGroundMoveAllowed(g, me, from, to.key, elite, 'range') && + !botGroundMoveAllowed(g, me, from, to.key, elite) + ) + groundMoves.push({ + action: { + type: 'move', + planetologist: 'range', + from, + territory: to.t, + sector: to.s, + amount, + elite, + }, + destinationScore: to.score, + amount, + gather: false, + }); + } + const gatherSources = movingSources.slice(0, 12); + for (let first = 0; first < gatherSources.length; first += 1) + for (let second = first + 1; second < gatherSources.length; second += 1) { + const a = gatherSources[first], + b = gatherSources[second], + aTerritory = splitLocation(a.from).territory, + bTerritory = splitLocation(b.from).territory; + if ( + aTerritory === bTerritory || + (g.advanced && + me.faction === 'beneGesserit' && + isAdvisor(me, aTerritory) !== isAdvisor(me, bTerritory)) + ) + continue; + for (const to of usefulTargets + .filter( + (target) => + botGroundMoveAllowed( + g, + me, + a.from, + target.key, + a.elite, + 'gather', + ) && + botGroundMoveAllowed( + g, + me, + b.from, + target.key, + b.elite, + 'gather', + ), + ) + .slice(0, 24)) { + groundMoves.push({ + action: { + type: 'move', + planetologist: 'gather', + forces: { [a.from]: a.amount, [b.from]: b.amount }, + ...(me.elites + ? { + eliteForces: { + [a.from]: a.elite, + [b.from]: b.elite, + }, + } + : {}), + territory: to.t, + sector: to.s, + }, + destinationScore: to.score, + amount: a.amount + b.amount, + gather: true, + }); + } + } + } + groundMoves.sort( + (a, b) => + b.destinationScore - a.destinationScore || + b.amount - a.amount || + Number(b.gather) - Number(a.gather), + ); + actions.push(...groundMoves.map((candidate) => candidate.action)); if (reveal && !actions.length) actions.push(reveal); if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip')) actions.unshift(...emperorHomeworldMoveActions(g)); diff --git a/game/engine.ts b/game/engine.ts index 7fb7f0df..701032e3 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,5 +1,12 @@ import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; import { leaderSkillCard } from './leader-skill-cards'; +import { + canUsePlanetologistBattleSpecial, + isPlanetologistBattleSpecialCard, + validLeaderSkillBattleCardPair, + type BattleLeaderSkill, +} from './leader-skill-combat'; +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'; import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight'; @@ -459,7 +466,6 @@ import { isDefenseCard, isPortableSnooper, VOICE_KINDS, - validBattleCardPair, defaultVoiceMatch, playedVoiceMatch, } from './battle-cards'; @@ -4919,6 +4925,32 @@ function leaderSkillController( )!.capturedBy ?? assignment.owner ); } +function battleLeaderSkills(g: Game, player: Player): BattleLeaderSkill[] { + return ( + g.leaderSkills?.assignments + .filter((assignment) => leaderSkillController(g, assignment) === player.id) + .map((assignment) => ({ + skill: assignment.skill, + leader: assignment.leader, + faceUp: + assignment.owner === player.id && + !g.battle?.leaderSkillHidden?.[player.id], + captured: assignment.owner !== player.id, + })) ?? [] + ); +} +function canUsePlanetologistWeapon( + g: Game, + player: Player, + leader: string | null, + card: Card | undefined, +) { + return canUsePlanetologistBattleSpecial({ + assignments: battleLeaderSkills(g, player), + selectedLeader: leader, + card, + }); +} function nativeLeaderSkill(g: Game, owner: string) { return g.leaderSkills?.assignments.find( (a) => a.owner === owner && leaderSkillController(g, a) === owner, @@ -7997,7 +8029,7 @@ function eliteChoice( 'Elite forces', ); } -function forceGroup(g: Game, p: Player, action: Action) { +function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) { let entries: [string, unknown][]; if (action.forces !== undefined) { requireRule( @@ -8011,6 +8043,7 @@ function forceGroup(g: Game, p: Player, action: Action) { const group: [string, number][] = []; const eliteGroup: Record = {}; let origin: string | undefined; + const origins = new Set(); let noField: { tokenId: string; event: string; from: string } | undefined; if (action.noField !== undefined) { const marker = p.noField?.deployed; @@ -8022,6 +8055,7 @@ function forceGroup(g: Game, p: Player, action: Action) { 'This concealed No-Field movement selection is stale.', ); origin = marker.location.territory; + origins.add(origin); noField = { tokenId: marker.tokenId, event: p.noFieldEvent!, @@ -8036,11 +8070,10 @@ function forceGroup(g: Game, p: Player, action: Action) { ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); if (!n) continue; - requireRule( - !origin || origin === src.territory, - 'A force group must come from one territory.', - ); - origin = src.territory; + origins.add(src.territory); + requireRule(origins.size <= maxOrigins, + maxOrigins === 1 ? 'A force group must come from one territory.' : 'Planetologist gathers forces from exactly two territories.'); + origin ??= src.territory; group.push([key, n]); const selectedElite = action.forces !== undefined @@ -8062,6 +8095,7 @@ function forceGroup(g: Game, p: Player, action: Action) { eliteGroup, elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0), origin: origin!, + origins: [...origins], total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0), noField, sourceKeys: [ @@ -8284,7 +8318,45 @@ function ornithopterView(g: Game, p: Player) { }; } function movementOrderRange(g: Game, p: Player, move: MovementOrder) { - return move.discoveryFlight || move.ornithopterRange ? 3 : movementRange(g, p, move.elite); + return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(movementRange(g, p, move.elite), move.planetologist?.mode); +} +function validatePlanetologistMove(g: Game, p: Player, move: Pick) { + const skill = move.planetologist; + if (!skill) { + requireRule(move.origins === undefined, 'Two movement origins require Planetologist.'); + return; + } + requireRule(g.phase === 5 && skill && typeof skill === 'object' && + (skill.mode === 'range' || skill.mode === 'gather') && + skill.leader === planetologistLeader(g, p.id), + 'This Planetologist movement no longer has its living, uncaptured skilled leader.'); + requireRule(g.players.every(player => faction(player.faction).expansion === 'base'), + 'Planetologist movement combined with expansion factions is still being implemented.'); + requireRule(!move.noField && !move.ornithopterEvent && !move.discoveryFlight, + 'Planetologist combined with special movement cards or No-Field is still being implemented.'); + requireRule(skill.mode === 'gather' + ? Array.isArray(move.origins) && move.origins.length === 2 && new Set(move.origins).size === 2 && move.origins[0] === move.origin + : move.origins === undefined, + 'Choose the extra range or exactly two origins, not both.'); +} +function movementOrigins(move: Pick): string[] { + return move.origins ?? [move.origin]; +} +function movementStance(g: Game, p: Player, origins: string[], to: string, fighters: boolean) { + requireRule(!g.advanced || p.faction !== 'beneGesserit' || + at(p, to) > 0 || !g.players.some(other => other.id !== p.id && at(other, to)) || + origins.every(origin => isAdvisor(p, origin) === isAdvisor(p, origins[0])), + 'Gathering advisors and fighters into an enemy-only territory still needs the combined flip response. Choose sources with the same stance or a destination with a determined stance.'); + const advisors = arrivalAsAdvisor(g, p, to, origins[0]); + const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined; + requireRule(advisors || lockedTurn !== g.turn || + !g.players.some(other => other.id !== p.id && at(other, to)), + 'New advisors cannot become fighters this turn.'); + const wantsFighters = advisors && fighters; + requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) && + (!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))), + 'These advisors cannot flip to fighters on arrival.'); + return {advisors, lockedTurn, wantsFighters}; } function validateFlightSelection(g: Game, p: Player, move: MovementOrder) { if (move.discoveryFlight) @@ -8355,7 +8427,7 @@ function offerChoamMovement(g: Game, move: MovementOrder) { g.phase === 5 && choam && move.player !== choam.id && - move.origin !== move.to && + movementOrigins(move).some(origin => origin !== move.to) && at(choam, move.to) > 0 ) { g.pendingChoamMove = move; @@ -8410,6 +8482,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { 'The movement is no longer available.', ); validateFlightSelection(g, p, move); + validatePlanetologistMove(g, p, move); const selected = forceGroup(g, p, { type: 'move', forces: Object.fromEntries(move.group), @@ -8417,16 +8490,22 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { ...(move.noField ? { noField: move.noField.tokenId, event: move.noField.event } : {}), - }); + }, move.planetologist?.mode === 'gather' ? 2 : 1); requireRule( selected.origin === move.origin && selected.total === move.total && selected.elite === move.elite && - selected.group.length === move.group.length, + selected.group.length === move.group.length && + JSON.stringify(selected.origins) === JSON.stringify(movementOrigins(move)), 'The declared force group no longer matches its movement.', ); + if (move.planetologist) { + const stance = movementStance(g, p, selected.origins, move.to, move.wantsFighters); + requireRule(stance.advisors === move.advisors && stance.wantsFighters === move.wantsFighters && + stance.lockedTurn === move.lockedTurn, 'The declared Planetologist arrival stance is no longer current.'); + } requireRule( - !balisetPrevents(g, p.id, move.origin, move.to), + movementOrigins(move).every(origin => !balisetPrevents(g, p.id, origin, move.to)), 'Baliset prevents that movement.', ); allowedEntry( @@ -8444,7 +8523,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { ].every( (key) => gameDistance(g, key, location(move.to, move.sector), (k) => - pathBlocked(g, p, k, isAdvisor(p, move.origin)), + pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) <= movementOrderRange(g, p, move), ), 'The movement route is no longer available.', @@ -8537,6 +8616,7 @@ function completeMove(g: Game, move: MovementOrder) { player: id, } = move; const p = getPlayer(g, id); + validatePlanetologistMove(g, p, move); validateFlightSelection(g, p, move); validateMovementArrival(g, move); const flight = g.ornithopter; @@ -8606,6 +8686,7 @@ function completeMove(g: Game, move: MovementOrder) { const completed: CompletedMovement = { player: id, origin, + ...(move.origins ? {origins: move.origins} : {}), total: n, to, sector: s, @@ -8630,10 +8711,10 @@ function finishMovedGroup(g: Game, move: CompletedMovement) { g, move.noField ? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.` - : `${p.name} moved ${n} forces from ${territory(origin).name} to ${territory(to).name}.`, + : `${p.name} moved ${n} forces from ${movementOrigins(move).map(source => territory(source).name).join(' and ')} to ${territory(to).name}.`, ); intrusion(g, p, to); - if (origin !== to) openTerritoryEntry(g, p, to, s, n, elite, 'movement'); + if (movementOrigins(move).some(source => source !== to)) openTerritoryEntry(g, p, to, s, n, elite, 'movement'); } /** A played movement card is already retired before queue advancement. */ function finishMovementTurn(g: Game, id: string) { @@ -12668,9 +12749,15 @@ function validatePlan( ); const w = cardOf(p, plan.weapon), d = cardOf(p, plan.defense); + const planetologistWeapon = canUsePlanetologistWeapon( + g, + p, + plan.leader, + w, + ); requireRule( - !plan.weapon || (w && isWeaponCard(w)), - 'Choose a weapon or worthless card.', + !plan.weapon || (w && (isWeaponCard(w) || planetologistWeapon)), + 'Choose a weapon, worthless card, or eligible Planetologist Special.', ); requireRule( !plan.defense || (d && isDefenseCard(d)), @@ -12685,7 +12772,7 @@ function validatePlan( 'Without a leader, no battle cards can be played.', ); requireRule( - validBattleCardPair(w, d), + validLeaderSkillBattleCardPair(w, d, planetologistWeapon), 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.', ); if (isStoneBurner(w)) { @@ -13094,7 +13181,15 @@ function findLegalBattlePlan( .map((l) => l.id), ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id), ]; - const weapons = [null, ...p.hand.filter(isWeaponCard).map((c) => c.id)]; + const weapons = [ + null, + ...p.hand + .filter( + (card) => + isWeaponCard(card) || isPlanetologistBattleSpecialCard(card), + ) + .map((card) => card.id), + ]; const defenses = [ null, ...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id), @@ -13106,7 +13201,11 @@ function findLegalBattlePlan( for (const defense of defenses) { if ( !accepts({ leader, weapon, defense }) || - !validBattleCardPair(cardOf(p, weapon), cardOf(p, defense)) + !validLeaderSkillBattleCardPair( + cardOf(p, weapon), + cardOf(p, defense), + canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon)), + ) ) continue; for (const kwisatz of [false, true]) { @@ -13657,7 +13756,12 @@ function feasiblePrescience( ]; const weapons: (string | null)[] = [ null, - ...p.hand.filter(isWeaponCard).map((c) => c.id), + ...p.hand + .filter( + (card) => + isWeaponCard(card) || isPlanetologistBattleSpecialCard(card), + ) + .map((card) => card.id), ]; const defenses: (string | null)[] = [ null, @@ -13920,7 +14024,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { hand: p.hand, plan, leader: controlledLeaders(g, p).find((l) => l.id === plan.leader), - leaderSkills: g.leaderSkills?.assignments.filter((assignment) => leaderSkillController(g,assignment) === p.id).map((assignment) => ({skill:assignment.skill,leader:assignment.leader,faceUp:assignment.owner === p.id && !b.leaderSkillHidden?.[p.id],captured:assignment.owner !== p.id})), + leaderSkills: battleLeaderSkills(g, p), forces: combatForces(g, p, b.territory, opponent), stronghold: strongholdEffect(g, p.id), lateDefense: b.lateDefense?.[p.id], @@ -15924,7 +16028,7 @@ function finishResponse(g: Game, canceled: boolean) { player.fremenMovementBlocked = { turn: quote.turn, move: quote.move }; log( g, - `${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a one-territory move; available ornithopters still apply.`, + `${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a legal replacement; independent ornithopters and Planetologist still apply.`, ); } else { log( @@ -18396,7 +18500,7 @@ function richeseSpecialKaramaView(g: Game, p: Player) { type CompletedMovement = Pick< MovementOrder, - 'player' | 'origin' | 'total' | 'to' | 'sector' | 'elite' + 'player' | 'origin' | 'origins' | 'total' | 'to' | 'sector' | 'elite' > & { noField: boolean }; type MovementOrder = { source?: 'ambassador'; @@ -18406,6 +18510,8 @@ type MovementOrder = { eliteGroup: Record; elite: number; origin: string; + origins?: string[]; + planetologist?: PlanetologistMovement; total: number; to: string; sector: number; @@ -21515,36 +21621,28 @@ function applyActionInner( g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p), 'Your movement is not available.', ); + const skillLeader = planetologistLeader(g, id); + requireRule(action.origins === undefined && + (action.planetologist === undefined || (skillLeader && + (action.planetologist === 'range' || action.planetologist === 'gather'))), + 'Choose an available Planetologist movement mode.'); + const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined + ? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']}; const { group, eliteGroup, elite, origin, + origins, total: n, noField, sourceKeys, - } = forceGroup(g, p, action); + } = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1); const to = stringField(action.territory), s = integer(action.sector, 0, 18, 'Sector'); - const advisors = arrivalAsAdvisor(g, p, to, origin); - const lockedTurn = p.advisors?.[origin]?.lockedTurn; + const {advisors, lockedTurn, wantsFighters} = movementStance(g, p, origins, to, action.fighters === true); requireRule( - advisors || - lockedTurn !== g.turn || - !g.players.some((other) => other.id !== id && at(other, to)), - 'New advisors cannot become fighters this turn.', - ); - const wantsFighters = advisors && action.fighters === true; - requireRule( - !wantsFighters || - (lockedTurn !== g.turn && - !at(p, to) && - (!(p.ally && at(getPlayer(g, p.ally), to)) || - sharesEcazOccupation(g, p, to))), - 'These advisors cannot flip to fighters on arrival.', - ); - requireRule( - !balisetPrevents(g, id, origin, to), + origins.every(source => !balisetPrevents(g, id, source, to)), 'Baliset prevents movement into this CHOAM territory; shipment remains possible.', ); allowedEntry(g, p, to, s, false, advisors && !wantsFighters); @@ -21608,6 +21706,8 @@ function applyActionInner( eliteGroup, elite, origin, + ...(planetologist ? {planetologist} : {}), + ...(planetologist?.mode === 'gather' ? {origins} : {}), total: n, to, sector: s, @@ -21624,12 +21724,13 @@ function applyActionInner( }; if (action.discoveryOrnithopter !== undefined) move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move)); + validatePlanetologistMove(g, p, move); const speed = movementOrderRange(g, p, move); requireRule( sourceKeys.every( (key) => gameDistance(g, key, location(to, s), (k) => - pathBlocked(g, p, k, isAdvisor(p, origin)), + pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) <= speed, ), `That destination is blocked or more than ${speed} territories away.`, @@ -21657,14 +21758,15 @@ function applyActionInner( passed: [], }; } else if ( - !move.ornithopterRange && + !move.ornithopterRange && !move.discoveryFlight && p.faction === 'fremen' && - speed === 2 && + !(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) && + !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') && group.some( ([key]) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, origin)), - ) > 1, + ) > planetologistRange(1, planetologist?.mode), ) ) { g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move }; diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts index 286a1813..084dd068 100644 --- a/game/karama-movement-cancellation.ts +++ b/game/karama-movement-cancellation.ts @@ -15,6 +15,7 @@ import { MOBILE_STRONGHOLD, } from './board'; import { presenceAt } from './force-presence'; +import { planetologistLeader } from './planetologist-movement'; import { validateAmbassadors } from './ecaz-ambassadors'; import { homeworldMobileStrongholdMovementBlock, @@ -115,7 +116,7 @@ function movementDeclaration( Array.isArray(entry) && entry.length === 2 && sectorKey(g, entry[0]) && - splitLocation(entry[0]).territory === move.origin && + (move.origins ?? [move.origin]).includes(splitLocation(entry[0]).territory) && integer(entry[1]) && entry[1] > 0, ) && @@ -131,6 +132,18 @@ function movementDeclaration( move.total, 'The canceled movement has invalid declared physical groups.', ); + if (move.planetologist || move.origins) { + requireContext(move.planetologist && + move.planetologist.leader === planetologistLeader(g, owner.id) && + !move.noField && !move.ornithopterEvent && !move.discoveryFlight && + (move.planetologist.mode === 'range' + ? move.origins === undefined + : move.planetologist.mode === 'gather' && Array.isArray(move.origins) && + move.origins.length === 2 && new Set(move.origins).size === 2 && + move.origins[0] === move.origin && + JSON.stringify([...new Set(move.group.map(([key]) => splitLocation(key).territory))]) === JSON.stringify(move.origins)), + 'The canceled Planetologist movement lost its skilled leader or exact origins.'); + } if (move.noField) requireContext( record(move.noField) && diff --git a/game/karama-movement-preflight.ts b/game/karama-movement-preflight.ts index 340b1772..0b2b0167 100644 --- a/game/karama-movement-preflight.ts +++ b/game/karama-movement-preflight.ts @@ -27,6 +27,7 @@ export type CompletedMovementArrivalInput = Readonly<{ order: Readonly<{ player: string; origin: string; + origins?: readonly string[]; to: string; advisors: boolean; wantsFighters: boolean; @@ -91,7 +92,7 @@ export function quoteCompletedMovementArrival( ); const base = { intrusion, retiresOrnithopter }; // Intrusion also runs between sectors of one territory; entry does not. - if (order.origin === order.to) return { ...base, reaction: null }; + if ((order.origins ?? [order.origin]).every(origin => origin === order.to)) return { ...base, reaction: null }; const ecaz = players.find((p) => p.faction === 'ecaz'); const ambassador = input.ambassadors.find( diff --git a/game/leader-skill-combat.ts b/game/leader-skill-combat.ts index 209a2307..39a66832 100644 --- a/game/leader-skill-combat.ts +++ b/game/leader-skill-combat.ts @@ -1,5 +1,10 @@ import type { Card } from './cards'; -import { defenseTypes, weaponTypes } from './battle-cards'; +import { + defenseTypes, + isDefenseCard, + validBattleCardPair, + weaponTypes, +} from './battle-cards'; import type { LeaderSkillId } from './leader-skill-cards'; export type DirectLeaderSkillId = @@ -19,8 +24,8 @@ export type BattleLeaderSkill = { }; export type AppliedLeaderSkill = { - skill: DirectLeaderSkillId; - amount: 1 | 3; + skill: DirectLeaderSkillId | 'planetologist'; + amount: 1 | 2 | 3; mode: 'normal' | 'skilled'; }; @@ -37,6 +42,74 @@ const directSkills = new Set([ 'prana-bindu-adept', ]); +/** + * Canonical GF9 base-deck green Special cards. Persisted IDs distinguish these + * physical components from expansion cards sharing the engine's broad + * `special` bucket. Cheap Hero and Cheap Heroine occupy the leader slot and + * are deliberately absent. + */ +export const PLANETOLOGIST_BASE_SPECIALS = [ + { id: 'treachery-24', name: 'Family Atomics', effect: 'atomics' }, + { id: 'treachery-25', name: 'Weather Control', effect: 'weather' }, + { id: 'treachery-26', name: 'Hajr', effect: 'hajr' }, + { id: 'treachery-27', name: 'Tleilaxu Ghola', effect: 'ghola' }, + { id: 'treachery-28', name: 'Harvester', effect: 'harvester' }, + { id: 'treachery-29', name: 'Karama', effect: 'karama' }, + { id: 'treachery-30', name: 'Karama', effect: 'karama' }, + { id: 'treachery-31', name: 'Truthtrance', effect: 'truthtrance' }, + { id: 'treachery-32', name: 'Truthtrance', effect: 'truthtrance' }, +] as const; + +const planetologistBaseSpecialById = new Map< + string, + (typeof PLANETOLOGIST_BASE_SPECIALS)[number] +>( + PLANETOLOGIST_BASE_SPECIALS.map((card) => [card.id, card]), +); + +/** Shared engine/UI/bot eligibility: exact printed base component, no native use. */ +export function isPlanetologistBattleSpecialCard( + card: Pick | undefined, +): boolean { + if (!card || card.kind !== 'special') return false; + const canonical = planetologistBaseSpecialById.get(card.id); + return ( + canonical?.name === card.name && + canonical.effect === card.effect + ); +} + +/** The substitute role belongs only to the actual assigned leader in battle. */ +export function canUsePlanetologistBattleSpecial(input: { + assignments: readonly BattleLeaderSkill[]; + selectedLeader: string | null | undefined; + card: Pick | undefined; +}): boolean { + return ( + isPlanetologistBattleSpecialCard(input.card) && + !!input.selectedLeader && + input.assignments.some( + (assignment) => + assignment.skill === 'planetologist' && + assignment.leader === input.selectedLeader && + (!assignment.faceUp || assignment.captured), + ) + ); +} + +/** Planetologist fills the slot without becoming an actual Weapon card. */ +export function validLeaderSkillBattleCardPair( + weapon: Card | undefined, + defense: Card | undefined, + planetologistWeapon: boolean, +): boolean { + if (!planetologistWeapon) return validBattleCardPair(weapon, defense); + return ( + (!defense || isDefenseCard(defense)) && + defense?.kind !== 'weirdingWay' + ); +} + function isDirectSkill(skill: string): skill is DirectLeaderSkillId { return directSkills.has(skill as LeaderSkillId); } @@ -71,6 +144,20 @@ export function leaderSkillBattleBonus(input: { }): LeaderSkillBattleBonus { if (!input.selectedLeader) return { bonus: 0, applied: [] }; const applied: AppliedLeaderSkill[] = []; + if ( + input.selectedLeader.kind === 'disc' && + input.skilledLeaderSurvives && + canUsePlanetologistBattleSpecial({ + assignments: input.assignments, + selectedLeader: input.selectedLeader.id, + card: input.weapon, + }) + ) + applied.push({ + skill: 'planetologist', + amount: 2, + mode: 'skilled', + }); for (const assignment of input.assignments) { if (!isDirectSkill(assignment.skill)) continue; if (!qualifies(assignment.skill, input.weapon, input.defense)) continue; diff --git a/game/planetologist-movement.ts b/game/planetologist-movement.ts new file mode 100644 index 00000000..926a0393 --- /dev/null +++ b/game/planetologist-movement.ts @@ -0,0 +1,37 @@ +import type { Game, GameView } from './engine'; + +export type PlanetologistMoveMode = 'range' | 'gather'; +export type PlanetologistMovement = { + leader: string; + mode: PlanetologistMoveMode; +}; + +/** Public component ownership suffices; a captive never grants the upper band. */ +export function planetologistLeader( + g: Game | GameView, + player: string, +): string | null { + const assignment = g.leaderSkills?.assignments.find( + (a) => a.owner === player && a.skill === 'planetologist', + ); + if ( + !assignment || + ('controller' in assignment && assignment.controller !== player) || + ('faceUp' in assignment && assignment.faceUp === false) + ) + return null; + const leader = g.players + .find((p) => p.id === player) + ?.leaders.find((l) => l.id === assignment.leader); + if (!leader || leader.dead || leader.capturedBy || leader.gholaBy) + return null; + return leader.id; +} + +/** The cap belongs to the extra-range alternative, not the two-origin one. */ +export function planetologistRange( + base: number, + mode?: PlanetologistMoveMode, +): number { + return mode === 'range' ? Math.min(3, base + 1) : base; +} diff --git a/game/reference.ts b/game/reference.ts index 08908786..e4c27e0a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3563,23 +3563,24 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Fourteen physical skills, private assignment choices and five connected battle disciplines in development tables. Other effects and combined-module acceptance remain unfinished.', + summary: 'Fourteen physical skills, private assignment choices, five battle disciplines and Planetologist movement/battle effects 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.', 'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.', 'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.', '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.', - 'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster, Smuggler and Planetologist have readable cards but their effects are still missing. The complete deck remains present; this development prototype does not enable public module starts.', + '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.', + 'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, 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.', ...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. Other nine effects, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/engine.ts'] }, - { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline controls are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx'] }, - { area:'AI',status:'Partial',detail:'All four profiles use private offers and legal visibility/revival actions. 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'] }, - { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup and revival 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'] }, + { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses and Planetologist movement/Special substitution. Other eight 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/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 and Special weapon selection are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.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. 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'] }, + { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival and Planetologist movement/cancellation 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'] }, ], }, ...[ diff --git a/tests/planetologist-battle.test.ts b/tests/planetologist-battle.test.ts new file mode 100644 index 00000000..1f9e8e8e --- /dev/null +++ b/tests/planetologist-battle.test.ts @@ -0,0 +1,514 @@ +import assert from 'node:assert/strict'; +import test, { mock } from 'node:test'; +import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards'; +import { + PLANETOLOGIST_BASE_SPECIALS, + canUsePlanetologistBattleSpecial, + isPlanetologistBattleSpecialCard, + type BattleLeaderSkill, +} from '../game/leader-skill-combat'; +import { + BattleResolutionQuoteError, + quoteBattleResolution, + type BattleResolutionInput, + type ResolutionCombatant, +} from '../game/battle-resolution-quote'; +import { + applyAction, + createGame, + initializeLeaderSkillsGameForAudit, + joinGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; + +function special(effect = 'atomics') { + const card = baseDeck().find((candidate) => candidate.effect === effect); + assert.ok(card); + return card; +} + +const assignment = ( + leader: string, + faceUp = false, + captured = false, +): BattleLeaderSkill => ({ + skill: 'planetologist', + leader, + faceUp, + captured, +}); + +void test('the alternate battle role is bound to the nine exact base green Specials', () => { + const eligible = baseDeck().filter(isPlanetologistBattleSpecialCard); + assert.deepEqual( + eligible.map((card) => card.id), + PLANETOLOGIST_BASE_SPECIALS.map((card) => card.id), + ); + assert.deepEqual( + eligible.map((card) => card.name), + [ + 'Family Atomics', + 'Weather Control', + 'Hajr', + 'Tleilaxu Ghola', + 'Harvester', + 'Karama', + 'Karama', + 'Truthtrance', + 'Truthtrance', + ], + ); + assert.equal( + baseDeck() + .filter((card) => card.kind === 'hero') + .some(isPlanetologistBattleSpecialCard), + false, + ); + assert.equal(ixBattleCards().some(isPlanetologistBattleSpecialCard), false); + assert.equal( + isPlanetologistBattleSpecialCard({ + ...special(), + id: 'forged-green-special', + }), + false, + ); +}); + +void test('only the actual concealed or captured assigned leader may use the physical substitute', () => { + const card = special(); + assert.equal( + canUsePlanetologistBattleSpecial({ + assignments: [assignment('leader')], + selectedLeader: 'leader', + card, + }), + true, + ); + assert.equal( + canUsePlanetologistBattleSpecial({ + assignments: [assignment('leader', false, true)], + selectedLeader: 'leader', + card, + }), + true, + ); + for (const input of [ + { + assignments: [assignment('leader')], + selectedLeader: 'other', + card, + }, + { + assignments: [assignment('leader', true)], + selectedLeader: 'leader', + card, + }, + { + assignments: [ + { + ...assignment('leader'), + skill: 'warmaster' as const, + }, + ], + selectedLeader: 'leader', + card, + }, + ]) + assert.equal(canUsePlanetologistBattleSpecial(input), false); +}); + +function battle(): BattleResolutionInput { + const side = ( + id: string, + faction: 'emperor' | 'guild', + ): ResolutionCombatant => { + const leader = { ...leaders(faction)[0] }; + return { + id, + faction, + spice: 10, + hand: [], + leader, + plan: { + dial: id === 'a' ? 1 : 0, + support: 0, + leader: leader.id, + weapon: null, + defense: null, + }, + forces: { + normal: 5, + elite: 0, + eliteStrength: 2, + freeSupport: false, + }, + }; + }; + return { + advanced: false, + turn: 2, + territory: 'arrakeen', + attacker: side('a', 'emperor'), + defender: side('d', 'guild'), + voters: [ + { id: 'a', beneficiary: 'a', called: false, traitors: [] }, + { id: 'd', beneficiary: 'd', called: false, traitors: [] }, + ], + participants: [ + { id: 'a', faction: 'emperor' }, + { id: 'd', faction: 'guild' }, + ], + physicalCards: baseDeck(), + pendingAuditorPresent: false, + pendingRetentionPresent: false, + }; +} + +function playPlanetologist(input: BattleResolutionInput, card = special()) { + input.attacker.hand = [card]; + input.attacker.plan.weapon = card.id; + input.attacker.leaderSkills = [assignment(input.attacker.plan.leader!)]; + return card; +} + +void test('a surviving skilled disc gains two without activating the Special and discards it after winning', () => { + const input = battle(); + const card = playPlanetologist(input); + const before = structuredClone(input); + const quote = quoteBattleResolution(input); + assert.deepEqual(quote.leaderSkillBonuses.attacker, { + bonus: 2, + applied: [{ skill: 'planetologist', amount: 2, mode: 'skilled' }], + }); + assert.equal(quote.effects?.defenderDead, false); + assert.equal(quote.winner, 'a'); + assert.deepEqual(quote.discarded, [{ player: 'a', card: card.id }]); + assert.deepEqual(quote.winnerCards, []); + assert.deepEqual(input, before); +}); + +void test('leader death suppresses the two-point band but never releases the revealed Special', () => { + const input = battle(); + const card = playPlanetologist(input, special('weather')); + const poison = baseDeck().find((candidate) => candidate.kind === 'poison')!; + input.defender.hand = [poison]; + input.defender.plan.weapon = poison.id; + const quote = quoteBattleResolution(input); + assert.equal(quote.leaderDeaths.attacker, true); + assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0); + assert.ok( + quote.discarded.some( + (entry) => entry.player === 'a' && entry.card === card.id, + ), + ); +}); + +void test('the later discard instruction also binds a successful traitor caller', () => { + const input = battle(); + const card = playPlanetologist(input, special('hajr')); + input.voters = [ + { + id: 'a', + beneficiary: 'a', + called: true, + traitors: [input.defender.plan.leader!], + }, + input.voters[1], + ]; + const quote = quoteBattleResolution(input); + assert.equal(quote.result, 'traitor'); + assert.equal(quote.winner, 'a'); + assert.deepEqual(quote.discarded, [{ player: 'a', card: card.id }]); + assert.deepEqual(quote.winnerCards, []); +}); + +void test('Moritani cleanup cannot retain or reserve the mandatory Planetologist discard', () => { + const input = battle(); + const card = playPlanetologist(input, special('truthtrance')); + const worthless = baseDeck().find( + (candidate) => candidate.kind === 'worthless', + )!; + const poison = baseDeck().find( + (candidate) => candidate.kind === 'poison', + )!; + input.attacker.ally = 'm'; + input.attacker.hand = [card, worthless]; + input.attacker.plan.defense = worthless.id; + input.defender.hand = [poison]; + input.defender.plan.weapon = poison.id; + input.participants = [ + ...input.participants, + { id: 'm', faction: 'moritani', ally: 'a' }, + ]; + const quote = quoteBattleResolution(input); + assert.equal(quote.winner, 'd'); + assert.deepEqual(quote.retention?.played, [worthless.id]); + assert.deepEqual(quote.retention?.eligible, [worthless.id]); + assert.ok( + quote.discarded.some( + (entry) => entry.player === 'a' && entry.card === card.id, + ), + ); +}); + +void test('quote validation rejects the Special in another role or with another leader without mutation', () => { + for (const mutate of [ + (input: BattleResolutionInput, card: Card) => { + input.attacker.plan.weapon = null; + input.attacker.plan.defense = card.id; + }, + (input: BattleResolutionInput) => { + input.attacker.leaderSkills = [assignment('some-other-leader')]; + }, + (input: BattleResolutionInput) => { + input.attacker.leaderSkills = [assignment(input.attacker.plan.leader!, true)]; + }, + (input: BattleResolutionInput, card: Card) => { + input.attacker.hand = [{ ...card, name: 'Forged Special' }]; + }, + (input: BattleResolutionInput) => { + input.attacker.leader!.dead = true; + }, + (input: BattleResolutionInput) => { + delete input.attacker.leader; + }, + ]) { + const input = battle(); + const card = playPlanetologist(input); + mutate(input, card); + const before = structuredClone(input); + assert.throws( + () => quoteBattleResolution(input), + BattleResolutionQuoteError, + ); + assert.deepEqual(input, before); + } +}); + +function finishSetup(state: Game) { + let game = state; + for (let step = 0; game.status === 'setup' && step < 10; step += 1) { + const owner = game.players.find((player) => player.traitorChoices.length); + assert.ok(owner, `setup stalled at ${game.setupStage}`); + game = applyAction(game, owner.id, { + type: 'traitor', + leader: owner.traitorChoices[0], + }); + } + assert.equal(game.status, 'playing'); + return game; +} + +function engineBattle(hide: boolean) { + let game = createGame('PLANETBATTLE', newPlayer('a', 'A', 'emperor')); + joinGame(game, newPlayer('d', 'D', 'guild')); + for (const player of game.players) + game = applyAction(game, player.id, { type: 'ready' }); + const targetIndex = LEADER_SKILL_CARDS.findIndex( + (card) => card.id === 'planetologist', + ); + let shuffleIndex = LEADER_SKILL_CARDS.length - 1; + mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => { + array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff; + return array; + }); + try { + game = initializeLeaderSkillsGameForAudit(game); + } finally { + mock.restoreAll(); + } + const attackerOffer = game.leaderSkills!.offers.a; + assert.equal(attackerOffer.cards[0], 'planetologist'); + game = applyAction(game, 'a', { + type: 'leaderSkill', + event: attackerOffer.event, + skill: 'planetologist', + leader: 'emperor-0', + }); + const defenderOffer = game.leaderSkills!.offers.d; + game = applyAction(game, 'd', { + type: 'leaderSkill', + event: defenderOffer.event, + skill: defenderOffer.cards[0], + leader: 'guild-0', + }); + game = finishSetup(game); + for (const player of game.players) { + game.deck.push(...player.hand); + player.hand = []; + player.forces = { 'arrakeen:10': 5 }; + player.reserves = 15; + } + const index = game.deck.findIndex((card) => card.effect === 'atomics'); + assert.ok(index >= 0); + const [card] = game.deck.splice(index, 1); + game.players[0].hand.push(card); + 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', + }); + return { game, card }; +} + +function unchanged(game: Game, player: string, action: Action, error: RegExp) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, player, action), error); + assert.deepEqual(game, before); +} + +void test('the real engine accepts only a concealed Planetologist plan and preserves actual-Weapon Voice', () => { + const publicSkill = engineBattle(false); + unchanged( + publicSkill.game, + 'a', + { + type: 'battlePlan', + dial: 0, + leader: 'emperor-0', + weapon: publicSkill.card.id, + }, + /behind the shield/, + ); + + const { game, card } = engineBattle(true); + unchanged( + game, + 'a', + { + type: 'battlePlan', + dial: 0, + leader: 'emperor-1', + weapon: card.id, + }, + /eligible Planetologist Special/, + ); + const poisonIndex = game.deck.findIndex( + (candidate) => candidate.kind === 'poison', + ); + assert.ok(poisonIndex >= 0); + const [poison] = game.deck.splice(poisonIndex, 1); + game.players[0].hand.push(poison); + game.battle!.voice = { target: 'a', kind: 'poison', must: true }; + unchanged( + game, + 'a', + { + type: 'battlePlan', + dial: 0, + leader: 'emperor-0', + weapon: card.id, + }, + /comply with the Voice/, + ); + delete game.battle!.voice; + const planned = applyAction(game, 'a', { + type: 'battlePlan', + dial: 0, + leader: 'emperor-0', + weapon: card.id, + }); + assert.equal(planned.battle!.plans.a.weapon, card.id); + assert.equal(planned.storm, game.storm, 'Family Atomics utility did not fire'); +}); + +void test('all four bot profiles offer a legal physical Planetologist battle plan', () => { + const { game, card } = engineBattle(true); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'a'); + view.players.find((player) => player.id === 'a')!.bot = difficulty; + const plans = botActions(view).filter( + (action) => + action.type === 'battlePlan' && + action.leader === 'emperor-0' && + action.weapon === card.id, + ); + assert.ok(plans.length, `${difficulty} omitted the physical Special`); + assert.doesNotThrow( + () => applyAction(game, 'a', plans[0]), + `${difficulty} emitted an invalid Planetologist plan`, + ); + } +}); + +void test('the real engine resolves and discards a winning Planetologist Special exactly once', () => { + const { game: battleState, card } = engineBattle(true); + let game = applyAction(battleState, 'a', { + type: 'battlePlan', + dial: 0, + leader: 'emperor-0', + weapon: card.id, + }); + game = applyAction(game, 'd', { + type: 'battlePlan', + dial: 0, + leader: 'guild-1', + }); + game = applyAction(game, 'a', { type: 'traitorCall', call: false }); + game = applyAction(game, 'd', { type: 'traitorCall', call: false }); + assert.equal( + game.discard.filter((candidate) => candidate.id === card.id).length, + 1, + ); + assert.equal( + game.players[0].hand.some((candidate) => candidate.id === card.id), + false, + ); + assert.equal(game.pendingWinnerDiscards, null); + assert.equal(game.lastBattleContext?.winnerDiscards?.completed, true); +}); + +void test('a skilled disc that dies between traitor votes cannot supply a stale Planetologist plan', () => { + const { game: battleState, card } = engineBattle(true); + let game = applyAction(battleState, 'a', { + type: 'battlePlan', + dial: 0, + leader: 'emperor-0', + weapon: card.id, + }); + game = applyAction(game, 'd', { + type: 'battlePlan', + dial: 0, + leader: 'guild-1', + }); + game = applyAction(game, 'a', { type: 'traitorCall', call: false }); + const skilled = game.players[0].leaders.find( + (leader) => leader.id === 'emperor-0', + )!; + skilled.dead = true; + skilled.deaths += 1; + unchanged( + game, + 'd', + { type: 'traitorCall', call: false }, + /assigned Planetologist leader’s weapon/, + ); +}); diff --git a/tests/planetologist-bots.test.ts b/tests/planetologist-bots.test.ts new file mode 100644 index 00000000..6995ad19 --- /dev/null +++ b/tests/planetologist-bots.test.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, + createGame, + newPlayer, + viewGame, + type Game, + type GameView, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { gameDistance, location, splitLocation } from '../game/board'; + +const destination = location('polar_sink', 0); +const rangeSource = location('false_wall_west', 16); +const gatherSources = [ + location('wind_pass', 14), + location('wind_pass_north', 17), +] as const; + +function fixture(code = 'PLANETBOT'): Game { + const g = createGame(code, newPlayer('p', 'Pilot', 'emperor'), true); + g.players.push(newPlayer('o', 'Observer', 'atreides')); + Object.assign(g, { + status: 'playing', + phase: 5, + turn: 2, + storm: 18, + order: ['p', 'o'], + active: 'p', + movementRemaining: ['p', 'o'], + }); + for (const player of g.players) { + player.hand = []; + player.forces = {}; + player.reserves = 20; + player.spice = 10; + } + const pilot = g.players[0]; + pilot.forces = { + [rangeSource]: 2, + [gatherSources[0]]: 2, + [gatherSources[1]]: 3, + }; + pilot.reserves = 13; + pilot.shipped = true; + pilot.elites = { + forces: { + [rangeSource]: 1, + [gatherSources[0]]: 1, + [gatherSources[1]]: 2, + }, + reserves: 1, + tanks: 0, + revived: 0, + }; + g.spice[destination] = 100; + g.leaderSkills = { + deck: LEADER_SKILL_CARDS.map((card) => card.id).filter( + (id) => id !== 'planetologist', + ), + offers: {}, + assignments: [ + { + skill: 'planetologist', + leader: 'emperor-0', + owner: 'p', + }, + ], + }; + return g; +} + +function rangeFixture(code: string) { + const g = fixture(code); + g.players[0].forces = { [rangeSource]: 2 }; + g.players[0].reserves = 18; + g.players[0].elites!.forces = { [rangeSource]: 1 }; + g.players[0].elites!.reserves = 4; + return g; +} + +function gatherFixture(code: string) { + const g = fixture(code); + delete g.players[0].forces[rangeSource]; + g.players[0].reserves = 15; + delete g.players[0].elites!.forces[rangeSource]; + g.players[0].elites!.reserves = 2; + return g; +} + +function projection(g: Game, difficulty: Difficulty): GameView { + const view = viewGame(g, 'p'); + view.players[0].bot = difficulty; + return view; +} + +void test('all profiles propose and can execute both Planetologist movement alternatives', () => { + for (const difficulty of DIFFICULTIES) { + const g = fixture(); + const view = projection(g, difficulty); + const before = structuredClone(view); + const moves = botActions(view).filter( + (action) => + action.type === 'move' && + (action.planetologist === 'range' || action.planetologist === 'gather'), + ); + assert.deepEqual(view, before, `${difficulty} mutated its private view`); + + const range = moves.find( + (action) => + action.planetologist === 'range' && + action.from === rangeSource && + gameDistance( + g, + rangeSource, + location(String(action.territory), Number(action.sector)), + (key) => splitLocation(key).sector === g.storm, + ) === 2, + ); + assert.ok(range, `${difficulty} omitted the boosted-range branch`); + const rangeDestination = location( + String(range.territory), + Number(range.sector), + ); + const ranged = applyAction(structuredClone(g), 'p', range); + assert.equal(ranged.players[0].forces[rangeDestination], 2); + assert.equal(ranged.players[0].elites!.forces[rangeDestination], 1); + + const gather = moves.find( + (action) => + action.planetologist === 'gather' && + location(String(action.territory), Number(action.sector)) === + destination && + gatherSources.every( + (source) => + Number((action.forces as Record)[source]) > 0, + ), + ); + assert.ok(gather, `${difficulty} omitted the two-origin branch`); + assert.deepEqual( + new Set( + Object.keys(gather.forces as Record).map( + (source) => splitLocation(source).territory, + ), + ), + new Set(['wind_pass', 'wind_pass_north']), + ); + const gathered = applyAction(structuredClone(g), 'p', gather); + assert.equal(gathered.players[0].forces[destination], 5); + assert.equal(gathered.players[0].elites!.forces[destination], 3); + assert.equal( + Object.values(gathered.players[0].forces).reduce( + (total, count) => total + count, + 0, + ), + 7, + ); + assert.equal( + Object.values(gathered.players[0].elites!.forces).reduce( + (total, count) => total + count, + 0, + ), + 4, + ); + } +}); + +void test('each profile actually chooses a useful Planetologist move before ordinary ground moves', () => { + for (const difficulty of DIFFICULTIES) { + const rangeGame = rangeFixture(difficulty === 'Easy' ? 'PR3' : 'PR0'); + const range = botActions(projection(rangeGame, difficulty))[0]; + assert.equal(range.type, 'move', difficulty); + assert.equal(range.planetologist, 'range', difficulty); + assert.equal(range.from, rangeSource, difficulty); + assert.equal( + location(String(range.territory), Number(range.sector)), + destination, + difficulty, + ); + assert.doesNotThrow(() => applyAction(rangeGame, 'p', range)); + + const gatherGame = gatherFixture(difficulty === 'Easy' ? 'PG16' : 'PG0'); + const gather = botActions(projection(gatherGame, difficulty))[0]; + assert.equal(gather.type, 'move', difficulty); + assert.equal(gather.planetologist, 'gather', difficulty); + assert.equal( + location(String(gather.territory), Number(gather.sector)), + destination, + difficulty, + ); + assert.deepEqual( + new Set(Object.keys(gather.forces as Record)), + new Set(gatherSources), + difficulty, + ); + assert.doesNotThrow(() => applyAction(gatherGame, 'p', gather)); + } +}); + +void test('ordinary callers keep ordinary range and an unavailable skill creates no special bot actions', () => { + const g = fixture(); + g.leaderSkills!.assignments = []; + g.leaderSkills!.deck.push('planetologist'); + for (const difficulty of DIFFICULTIES) { + const actions = botActions(projection(g, difficulty)); + assert.equal( + actions.some((action) => action.planetologist !== undefined), + false, + difficulty, + ); + assert.equal( + actions.some( + (action) => + action.type === 'move' && + action.from === rangeSource && + location(String(action.territory), Number(action.sector)) === + destination, + ), + false, + `${difficulty} received the unavailable extra range`, + ); + } +}); + +void test('bots do not propose the movement profile with an expansion faction present', () => { + const g = fixture(); + g.players[1].faction = 'ixians'; + for (const difficulty of DIFFICULTIES) + assert.equal( + botActions(projection(g, difficulty)).some( + (action) => action.planetologist !== undefined, + ), + false, + difficulty, + ); +}); diff --git a/tests/planetologist-movement-controls.test.tsx b/tests/planetologist-movement-controls.test.tsx new file mode 100644 index 00000000..35a0a196 --- /dev/null +++ b/tests/planetologist-movement-controls.test.tsx @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + applyAction, + createGame, + newPlayer, + viewGame, + type Game, +} from '../game/engine'; +import { + PlanetologistMovement, + planetologistMoveDraft, +} from '../components/planetologist-movement'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { location } from '../game/board'; + +const destination = 'polar_sink'; +const rangeSource = location('false_wall_west', 16); +const gatherSources = [ + location('wind_pass', 14), + location('wind_pass_north', 17), +] as const; + +function fixture(): Game { + const g = createGame('PLANETUI', newPlayer('p', 'Pilot', 'emperor'), true); + g.players.push(newPlayer('o', 'Observer', 'atreides')); + Object.assign(g, { + status: 'playing', + phase: 5, + turn: 2, + storm: 18, + order: ['p', 'o'], + active: 'p', + movementRemaining: ['p', 'o'], + }); + for (const player of g.players) { + player.hand = []; + player.forces = {}; + player.reserves = 20; + } + const pilot = g.players[0]; + pilot.forces = { + [rangeSource]: 2, + [gatherSources[0]]: 2, + [gatherSources[1]]: 3, + }; + pilot.reserves = 13; + pilot.shipped = true; + pilot.elites = { + forces: { + [rangeSource]: 1, + [gatherSources[0]]: 1, + [gatherSources[1]]: 2, + }, + reserves: 1, + tanks: 0, + revived: 0, + }; + g.leaderSkills = { + deck: LEADER_SKILL_CARDS.map((card) => card.id).filter( + (id) => id !== 'planetologist', + ), + offers: {}, + assignments: [ + { + skill: 'planetologist', + leader: 'emperor-0', + owner: 'p', + }, + ], + }; + return g; +} + +function markup(g: Game, viewer = 'p', busy = false) { + return renderToStaticMarkup( + createElement(PlanetologistMovement, { + game: viewGame(g, viewer), + act() {}, + busy, + destination, + sector: 0, + }), + ); +} + +void test('the owner sees both modes, physical source controls and the shared board destination', () => { + const g = fixture(); + const html = markup(g); + assert.match(html, /Planetologist movement/); + assert.match(html, /One territory farther, maximum three/); + assert.match(html, /Gather from two territories/); + assert.match(html, /Destination: Polar Sink, sector 0/); + assert.match(html, /False Wall West, sector 16 · 2 forces/); + assert.match(html, /Elite from this group · 1 available/); + assert.match(html, /Select forces from one territory/); + assert.match(html, /disabled=""/); + assert.equal(markup(g, 'o'), ''); + assert.ok((markup(g, 'p', true).match(/disabled=""/g) ?? []).length >= 2); +}); + +void test('drafts send only the public mode and exact physical counts, and both settle through the engine', () => { + const g = fixture(); + const view = viewGame(g, 'p'); + const range = planetologistMoveDraft( + view, + 'range', + destination, + 0, + { [rangeSource]: 2 }, + { [rangeSource]: 1 }, + ); + assert.equal(range.blocked, null); + assert.deepEqual(range.action, { + type: 'move', + planetologist: 'range', + forces: { [rangeSource]: 2 }, + eliteForces: { [rangeSource]: 1 }, + territory: destination, + sector: 0, + }); + assert.equal(Object.hasOwn(range.action!, 'leader'), false); + const ranged = applyAction(structuredClone(g), 'p', range.action!); + assert.equal(ranged.players[0].forces[`${destination}:0`], 2); + assert.equal(ranged.players[0].elites!.forces[`${destination}:0`], 1); + + const gather = planetologistMoveDraft( + view, + 'gather', + destination, + 0, + { [gatherSources[0]]: 2, [gatherSources[1]]: 3 }, + { [gatherSources[0]]: 1, [gatherSources[1]]: 2 }, + ); + assert.equal(gather.blocked, null); + assert.deepEqual(gather.action, { + type: 'move', + planetologist: 'gather', + forces: { [gatherSources[0]]: 2, [gatherSources[1]]: 3 }, + eliteForces: { [gatherSources[0]]: 1, [gatherSources[1]]: 2 }, + territory: destination, + sector: 0, + }); + assert.equal(Object.hasOwn(gather.action!, 'leader'), false); + const gathered = applyAction(structuredClone(g), 'p', gather.action!); + assert.equal(gathered.players[0].forces[`${destination}:0`], 5); + assert.equal(gathered.players[0].elites!.forces[`${destination}:0`], 3); +}); + +void test('the client rejects wrong origin counts and unavailable elite custody before sending', () => { + const view = viewGame(fixture(), 'p'); + assert.match( + planetologistMoveDraft( + view, + 'gather', + destination, + 0, + { [gatherSources[0]]: 1 }, + { [gatherSources[0]]: 0 }, + ).blocked!, + /exactly two different territories/, + ); + assert.match( + planetologistMoveDraft( + view, + 'range', + destination, + 0, + { [rangeSource]: 2 }, + { [rangeSource]: 0 }, + ).blocked!, + /normal and elite mix/, + ); + const expansion = fixture(); + expansion.players[1].faction = 'ixians'; + assert.match( + planetologistMoveDraft( + viewGame(expansion, 'p'), + 'range', + destination, + 0, + { [rangeSource]: 2 }, + { [rangeSource]: 1 }, + ).blocked!, + /expansion factions awaits/, + ); +}); diff --git a/tests/planetologist-movement.test.ts b/tests/planetologist-movement.test.ts new file mode 100644 index 00000000..ea449dce --- /dev/null +++ b/tests/planetologist-movement.test.ts @@ -0,0 +1,410 @@ +import assert from 'node:assert/strict'; +import test, { mock } from 'node:test'; +import { + applyAction, + createGame, + newPlayer, + joinGame, + initializeLeaderSkillsGameForAudit, + viewGame, + normalizeAutomaticGame, + type Game, + type Action, +} from '../game/engine'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import type { FactionId } from '../game/catalog'; +import { botActions } from '../game/bots'; +import { TERRITORIES, distance, splitLocation, location } from '../game/board'; +import { baseDeck } from '../game/cards'; +import { placeFixtureHand } from './fixture-hand'; +import { planetologistLeader } from '../game/planetologist-movement'; + +const source = 'red_chasm:7'; +const restore = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const route = (from: string, to: string) => + distance(from, to, (key) => splitLocation(key).sector === 18); +const sand = TERRITORIES.filter((t) => t.type === 'sand').flatMap((t) => + t.sectors.filter((s) => s !== 18).map((s) => location(t.id, s)), +); +function target(range: number, from = source) { + const to = sand.find((key) => route(from, key) === range); + assert.ok(to, `route ${range} from ${from}`); + return to; +} +function move(range: number, mode?: 'range' | 'gather'): Action { + const to = splitLocation(target(range)); + return { + type: 'move', + from: source, + amount: 3, + territory: to.territory, + sector: to.sector, + ...(mode ? { planetologist: mode } : {}), + }; +} +function fixture(faction: FactionId = 'emperor', advanced = false): Game { + let g = createGame( + 'PLANETMOVE', + newPlayer('p', 'Planetologist', faction), + advanced, + ); + joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'planetologist'); + let cursor = LEADER_SKILL_CARDS.length - 1; + mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => { + array[0] = cursor-- === index ? 0 : 0xffffffff; + return array; + }); + try { + g = initializeLeaderSkillsGameForAudit(g); + } finally { + mock.restoreAll(); + } + for (let i = 0; g.status === 'setup' && i < 60; i++) { + let accepted = false; + for (const p of g.players) { + const view = viewGame(g, p.id); + const skills = view.leaderSkills!; + const options = + g.setupStage === 'leaderSkills' && skills.offer + ? [ + { + type: 'leaderSkill', + event: skills.offer.event, + skill: p.id === 'p' ? 'planetologist' : skills.offer.cards[0], + leader: skills.eligibleLeaders[0].id, + }, + ] + : g.setupStage === 'traitors' && p.traitorChoices.length + ? [{ type: 'traitor', leader: p.traitorChoices[0] }] + : g.setupStage === 'prediction' && p.faction === 'beneGesserit' + ? [{ type: 'predict', faction: 'harkonnen', turn: 3 }] + : g.setupStage === 'forces' && + p.faction === 'fremen' && + p.reserves === 20 + ? [ + { + type: 'fremenSetup', + placements: { + sietch_tabr: 10, + false_wall_south: 0, + false_wall_west: 0, + }, + }, + ] + : g.setupStage === 'forces' && + p.faction === 'beneGesserit' && + !p.advisorSetup + ? [ + { + type: 'advisorSetup', + territory: 'arrakeen', + sector: 10, + }, + ] + : botActions(view); + for (const a of options) { + try { + g = applyAction(g, p.id, a); + accepted = true; + break; + } catch { + /* Other seats can be waiting. */ + } + } + if (accepted) break; + } + assert.ok(accepted, `setup ${g.setupStage}`); + } + assert.equal(g.status, 'playing'); + assert.ok(planetologistLeader(g, 'p')); + Object.assign(g, { + phase: 5, + turn: 2, + storm: 18, + active: 'p', + order: ['p', 'h'], + movementRemaining: ['p', 'h'], + ready: [], + response: null, + decision: null, + phaseOpening: null, + }); + for (const p of g.players) { + g.deck.push(...p.hand); + p.hand = []; + p.forces = {}; + p.reserves = 20; + p.tanks = 0; + p.shipped = true; + p.moved = 0; + p.advisors = {}; + if (p.elites) p.elites = { ...p.elites, forces: {}, reserves: 3, tanks: 0 }; + } + g.players[0].forces = { [source]: 3 }; + g.players[0].reserves = 17; + return g; +} +function unchanged(g: Game, a: Action, error: RegExp) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, 'p', a), error); + assert.deepEqual(g, before); +} +function custody(g: Game) { + for (const p of g.players) + assert.equal( + p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), + 20, + ); + const ids = [ + ...g.deck, + ...g.discard, + ...g.players.flatMap((p) => p.hand), + ].map((c) => c.id); + assert.equal(ids.length, 33); + assert.equal(new Set(ids).size, 33); +} +function gather(g: Game, destinationKey?: string, range = 1) { + const to = + destinationKey ?? + sand.find( + (dest) => + new Set( + sand + .filter( + (key) => + route(key, dest) === range && + splitLocation(key).territory !== splitLocation(dest).territory, + ) + .map((key) => splitLocation(key).territory), + ).size >= 2, + )!; + const origins = sand.filter( + (key) => + route(key, to) === range && + splitLocation(key).territory !== splitLocation(to).territory, + ); + const first = origins[0]; + const second = origins.find( + (key) => splitLocation(key).territory !== splitLocation(first).territory, + )!; + assert.ok(first && second); + g.players[0].forces = { [first]: 3, [second]: 2 }; + g.players[0].reserves = 15; + const destination = splitLocation(to); + return { + a: { + type: 'move', + planetologist: 'gather', + forces: { [first]: 2, [second]: 1 }, + territory: destination.territory, + sector: destination.sector, + } as Action, + first, + second, + to, + }; +} + +void test('Planetologist range is explicit, capped at three, and moves physical forces once', () => { + for (const advanced of [false, true]) { + const g = fixture('emperor', advanced); + unchanged(g, move(2), /more than 1/); + unchanged(g, move(3, 'range'), /more than 2/); + const next = applyAction(restore(g), 'p', move(2, 'range')); + assert.equal(next.players[0].forces[target(2)], 3); + assert.equal(next.players[0].moved, 1); + custody(next); + assert.deepEqual(normalizeAutomaticGame(restore(next)), next); + const city = fixture(); + city.players[0].forces['arrakeen:10'] = 1; + city.players[0].reserves--; + unchanged(city, move(4, 'range'), /more than 3/); + assert.equal(applyAction(city, 'p', move(3, 'range')).players[0].moved, 1); + } +}); + +void test('two-origin movement keeps separate sources and elites, one shared arrival and one movement use', () => { + const g = fixture('emperor', true); + const { a, first, second, to } = gather(g); + g.players[0].elites!.forces = { [first]: 1, [second]: 1 }; + g.players[0].elites!.reserves = 1; + a.eliteForces = { [first]: 1, [second]: 1 }; + const next = applyAction(restore(g), 'p', a); + assert.equal(next.players[0].forces[to], 3); + assert.equal(next.players[0].forces[first], 1); + assert.equal(next.players[0].forces[second], 1); + assert.equal(next.players[0].elites!.forces[to], 2); + assert.equal(next.players[0].elites!.reserves, 1); + assert.equal(next.players[0].moved, 1); + custody(next); + assert.match(next.log.map((e) => e.text).join('\n'), / and /); + const without = { ...a }; + delete without.planetologist; + unchanged(g, without, /one territory/); + unchanged(g, { ...a, planetologist: 'range' }, /one territory/); + unchanged(g, { ...a, forces: { [first]: 2 } }, /exactly two/); + const far = fixture(); + const selected = gather(far, undefined, 2); + unchanged(far, selected.a, /more than 1/); +}); + +void test('Fremen extra range preserves a real Karama response and replacement keeps Planetologist', () => { + for (const advanced of [false, true]) { + const g = fixture('fremen', advanced); + const karama = baseDeck().find((c) => c.effect === 'karama')!; + placeFixtureHand(g, 1, [karama]); + const ordinaryBonus = applyAction(g, 'p', move(2, 'range')); + assert.equal(ordinaryBonus.response, null); + assert.equal(ordinaryBonus.players[0].moved, 1); + const pending = applyAction(g, 'p', move(3, 'range')); + assert.equal(pending.response?.kind, 'fremenMovement'); + assert.equal(pending.players[0].moved, 0); + assert.deepEqual(pending.pendingFremenMove?.order.planetologist, { + leader: planetologistLeader(g, 'p'), + mode: 'range', + }); + const allowed = applyAction(restore(pending), 'h', { + type: 'passResponse', + }); + assert.equal(allowed.players[0].forces[target(3)], 3); + assert.equal(allowed.players[0].moved, 1); + custody(allowed); + const canceled = applyAction(restore(pending), 'h', { + type: 'card', + card: karama.id, + mode: 'cancel', + }); + assert.deepEqual(canceled.players[0].forces, g.players[0].forces); + assert.equal(canceled.players[0].moved, 0); + unchanged(canceled, move(3, 'range'), /more than 2/); + const replacement = applyAction(restore(canceled), 'p', move(2, 'range')); + assert.equal(replacement.players[0].forces[target(2)], 3); + assert.equal(replacement.players[0].moved, 1); + custody(replacement); + assert.equal( + replacement.discard.filter((c) => c.id === karama.id).length, + 1, + ); + } +}); + +void test('Fremen gather response restores both origins and rejects stale or forged skill receipts before payment', () => { + const g = fixture('fremen', true); + const { a, to } = gather(g, undefined, 2); + const karama = baseDeck().find((c) => c.effect === 'karama')!; + placeFixtureHand(g, 1, [karama]); + const pending = applyAction(g, 'p', a); + assert.equal(pending.response?.kind, 'fremenMovement'); + assert.equal(pending.pendingFremenMove?.order.origins?.length, 2); + const saved = restore(pending); + const allowed = applyAction(saved, 'h', { type: 'passResponse' }); + assert.equal(allowed.players[0].forces[to], 3); + assert.equal(allowed.players[0].moved, 1); + custody(allowed); + for (const corrupt of [ + (s: Game) => { + s.pendingFremenMove!.order.planetologist!.leader = 'harkonnen-0'; + }, + (s: Game) => { + s.pendingFremenMove!.order.origins!.pop(); + }, + (s: Game) => { + s.pendingFremenMove!.order.planetologist!.mode = 'range'; + }, + ]) { + const stale = restore(pending); + corrupt(stale); + const before = structuredClone(stale); + assert.throws(() => + applyAction(stale, 'h', { + type: 'card', + card: karama.id, + mode: 'cancel', + }), + ); + assert.deepEqual(stale, before); + } +}); + +void test('captured or dead Planetologist gives neither side a native movement benefit; invalid modes are immutable', () => { + const g = fixture(); + const leader = g.players[0].leaders.find( + (l) => l.id === planetologistLeader(g, 'p'), + )!; + unchanged( + g, + { + ...move(2, 'range'), + planetologist: { leader: leader.id, mode: 'range' }, + }, + /available Planetologist/, + ); + unchanged( + g, + { ...move(2, 'range'), origins: ['red_chasm'] }, + /available Planetologist/, + ); + leader.capturedBy = 'h'; + assert.equal(planetologistLeader(g, 'p'), null); + assert.equal(planetologistLeader(g, 'h'), null); + unchanged(g, move(2, 'range'), /available Planetologist/); + delete leader.capturedBy; + leader.dead = true; + assert.equal(planetologistLeader(g, 'p'), null); + unchanged(g, move(2, 'range'), /available Planetologist/); +}); + +void test('Bene Gesserit gather resolves determined mixed-source stances and preserves advisor locks', () => { + for (const existingAdvisors of [false, true]) { + const g = fixture('beneGesserit', true); + const { a, first, second, to } = gather(g); + const origin = splitLocation(first).territory, + destination = splitLocation(to).territory; + g.players[0].advisors = { [origin]: {} }; + // Keep the source advisors beside another faction so normalization cannot retire their stance. + g.players[1].forces = { [first]: 1 }; + g.players[1].reserves = 19; + if (existingAdvisors) { + g.players[0].forces[to] = 1; + g.players[0].reserves--; + g.players[0].advisors[destination] = {}; + g.players[1].forces[to] = 1; + g.players[1].reserves--; + } + const next = applyAction(restore(g), 'p', a); + assert.equal(!!next.players[0].advisors?.[destination], existingAdvisors); + assert.equal(next.players[0].forces[to], existingAdvisors ? 4 : 3); + assert.equal(next.players[0].forces[first], 1); + assert.equal(next.players[0].forces[second], 1); + custody(next); + } + const mixed = fixture('beneGesserit', true); + const { a, first, to } = gather(mixed); + mixed.players[0].advisors = { [splitLocation(first).territory]: {} }; + mixed.players[1].forces = { [first]: 1, [to]: 1 }; + mixed.players[1].reserves = 18; + unchanged(mixed, a, /combined flip response/); + const uniform = fixture('beneGesserit', true); + const draft = gather(uniform); + uniform.players[0].advisors = { + [splitLocation(draft.first).territory]: { lockedTurn: 2 }, + [splitLocation(draft.second).territory]: {}, + }; + uniform.players[1].forces = { + [draft.first]: 1, + [draft.second]: 1, + [draft.to]: 1, + }; + uniform.players[1].reserves = 17; + unchanged(uniform, { ...draft.a, fighters: true }, /cannot flip/); + const advisors = applyAction(restore(uniform), 'p', draft.a); + assert.equal( + advisors.players[0].advisors?.[splitLocation(draft.to).territory] + ?.lockedTurn, + 2, + ); + assert.equal(advisors.players[0].forces[draft.to], 3); + custody(advisors); +});