Prototype Planetologist movement and Special-card battles

Connect capped extra range, two-origin gathering, Fremen cancellation and
saved replacement choices. Add human controls and actual first-choice AI
paths for all four profiles. Admit the exact nine base green Specials in
the actual skilled leader's weapon slot for +2 on survival, with mandatory
physical discard and no native utility effect. Reject stale dead-disc plans.

Independent movement/battle review closed after the saved-dead-disc fix.
84 focused tests passed. Two four-profile Basic/Advanced samples completed
(194/186 accepted actions, six JSON restorations each, zero rejected actions).
Basic used both movement branches; Advanced used added range. Browser genuine
setup/gather/refresh and a separate targeted Special/traitor-loss fixture passed.

Required verification: types, lint, 4435 offline tests, build and 43 HTTP tests
passed. First attempt stopped at missing explicit test promise markers;
corrected before the successful broad run. Final check/build/HTTP durations:
144157/6638/25048 ms. Source fingerprint (1260 files):
bb7be69cf3e925bf7338ea444f77279ed7b37faadc3c44ed7db01b941f27b861
Private report: checkpoints/20260913-planetologist/checks-final/report.json.

Preserved all 325 opening games and all original application/seat/recovery
records; 359 rooms after 34 new QA/HTTP rooms. SQLite quick_check ok and zero
foreign-key violations. Healthy existing server reused; no reset/restart.

Boundaries: eight other Leader Skills, enemy-only mixed BG gather response,
expansion roster/green-card/optional-module combinations remain unfinished.
Winning traitor caller discard remains a documented card-specific inference.
No mode or publication gates opened.

Efficiency measure: aggregate goal counter 67321978 -> 68710948 (1388970),
including context and coordination; not billing tokens or a savings claim.
One skill's three connected effects, selective implementation/research/review,
one interrupted lint attempt followed by the required complete verification.
This commit is contained in:
Quintine committed 2026-09-13 18:16:36 +10:00
1 parent 6e6fecc9d4
commit 5082c81353
22 files changed
+2473 -93

No files matched your search

+41 -3
View File
@@ -51,6 +51,7 @@ import { LeaderSkillsPanel } from './leader-skills';
import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { OrnithopterMovement } from './ornithopter-movement'; import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison'; import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
import { PortableSnooper } from './portable-snooper'; import { PortableSnooper } from './portable-snooper';
import { import {
@@ -141,6 +142,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation'; import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation';
import { PrivateBattlePlan } from './private-battle-plan'; 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 { RevealedBattle } from './revealed-battle';
import { EliteCount } from './elite-count'; import { EliteCount } from './elite-count';
import { HelpTip } from './help-tip'; import { HelpTip } from './help-tip';
@@ -404,6 +407,12 @@ export function GameTable({
const [discardCards, setDiscardCards] = useState<Record<string, boolean>>({}); const [discardCards, setDiscardCards] = useState<Record<string, boolean>>({});
const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null; const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
const committed = battlePlanCommitments(g); 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( const selectedStone = me.hand?.find(
(c) => (c) =>
c.id === (committed.weapon ? committed.weapon.value : weapon) && c.id === (committed.weapon ? committed.weapon.value : weapon) &&
@@ -4010,6 +4019,28 @@ export function GameTable({
} }
} }
/> />
<PlanetologistMovement
game={g}
act={act}
busy={busy}
destination={selected}
sector={sector}
unavailableReason={
!movementAvailable
? 'No ordinary movement is currently available.'
: (me.moved ?? 0) >= (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
}
/>
<p className="fine"> <p className="fine">
{guildCunning?.stage === 'secondShipment' {guildCunning?.stage === 'secondShipment'
? 'Choose or decline the second shipment before any Hajr movement.' ? 'Choose or decline the second shipment before any Hajr movement.'
@@ -4430,17 +4461,24 @@ export function GameTable({
{me.hand {me.hand
?.filter((c) => ?.filter((c) =>
name === 'Defense' name === 'Defense'
? isDefenseCard(c) ? isDefenseCard(c) && (!selectedBattleWeapon || !planetologistSpecial(selectedBattleWeapon) || validLeaderSkillBattleCardPair(selectedBattleWeapon,c,true))
: isWeaponCard(c), : isWeaponCard(c) || planetologistSpecial(c),
) )
.map((c) => ( .map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} {c.name}{name === 'Weapon' && planetologistSpecial(c) ? ' · Planetologist +2, discard after battle' : ''}
</option> </option>
))} ))}
</select> </select>
</label> </label>
))} ))}
{selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && (
<p className="notice">
{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.
</p>
)}
{selectedStone && ( {selectedStone && (
<div className="my-3 flex flex-col gap-3"> <div className="my-3 flex flex-col gap-3">
<p> <p>
+2 -1
View File
@@ -109,7 +109,8 @@ export function LeaderSkillsPanel({
<p className="fine"> <p className="fine">
This development-only module currently connects the card-role strength This development-only module currently connects the card-role strength
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz, 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. unfinished.
</p> </p>
</div> </div>
+269
View File
@@ -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<string, number>;
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<PlanetologistMoveMode>('range');
const [forces, setForces] = useState<Counts>({});
const [elites, setElites] = useState<Counts>({});
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 (
<section
className="my-4 flex min-w-0 flex-col gap-3"
aria-labelledby={`${id}-heading`}
>
<h3 id={`${id}-heading`}>Planetologist movement</h3>
<p>
{leaderName} may add one territory to one movement, up to three, or
gather forces from exactly two territories into the selected
destination.
</p>
<label htmlFor={`${id}-mode`}>Movement mode</label>
<select
id={`${id}-mode`}
value={mode}
disabled={busy}
onChange={(event) =>
setMode(event.target.value as PlanetologistMoveMode)
}
>
<option value="range">One territory farther, maximum three</option>
<option value="gather">Gather from two territories</option>
</select>
<p className="fine">
Destination: {territory(destination).name}, sector {sector}. Select the
physical forces to move below.
</p>
{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 (
<div className="grid gap-2" key={from}>
<label htmlFor={`${id}-${from}-forces`}>
{sourceName}, sector {source.sector} · {owned} forces
<Input
id={`${id}-${from}-forces`}
type="number"
min={0}
max={owned}
value={selected}
disabled={busy || source.sector === game.storm}
onChange={(event) =>
setForces({ ...forces, [from]: Number(event.target.value) })
}
/>
</label>
{me.elites && (
<label htmlFor={`${id}-${from}-elites`}>
Elite from this group · {ownedElite} available
<Input
id={`${id}-${from}-elites`}
type="number"
min={minimumElite}
max={Math.min(selected, ownedElite)}
value={elites[from] ?? 0}
disabled={busy || selected === 0}
onChange={(event) =>
setElites({
...elites,
[from]: Number(event.target.value),
})
}
/>
</label>
)}
</div>
);
})
) : (
<p className="fine">No board forces are available to move.</p>
)}
{blocked && (
<output className="notice" id={`${id}-reason`}>
{blocked}
</output>
)}
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy || !!blocked || !draft.action}
aria-describedby={blocked ? `${id}-reason` : undefined}
onClick={() => {
if (!busy && !blocked && draft.action) act(draft.action);
}}
>
Move with Planetologist
</Button>
<p className="fine">
This does not combine with a No-Field or either Ornithopter movement.
Normal movement and arrival rules still apply.
</p>
</section>
);
}
+2 -1
View File
@@ -4,7 +4,8 @@
now have sourced names and complete paraphrased normal/battle text, shared now have sourced names and complete paraphrased normal/battle text, shared
inspectors, private two-card offers and one-card assignment custody. Five role inspectors, private two-card offers and one-card assignment custody. Five role
bonuses, public skilled capture, death return and own revival are connected; 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), 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). [capture interpretation](LEADER_SKILLS_CAPTURE.md) and [prototype evidence](LEADER_SKILLS_RUNTIME.md).
+9 -2
View File
@@ -4,6 +4,13 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
## Evidence and source of truth ## 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) - [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 now connects first among remaining unstarted movement turns after earlier
turns finish, including after Advanced Guild completes its turn. Existing 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. review and focused checks pass; broad results are in the checkpoint commit.
- The [first Leader Skills prototype](LEADER_SKILLS_RUNTIME.md) connects all - The [first Leader Skills prototype](LEADER_SKILLS_RUNTIME.md) connects all
fourteen physical cards, private setup, five battle disciplines, skilled capture, fourteen physical cards, private setup, five battle disciplines, skilled capture,
death and own revival through controls, legal AI and saved choices. The other death and own revival through controls, legal AI and saved choices. The [Planetologist follow-up](PLANETOLOGIST_RULES.md) adds movement and
nine effects remain **Missing**. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md) 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 resolves publicity through the known physical card; captured replacement and
broader combinations remain pending. No mode gate is opened. broader combinations remain pending. No mode gate is opened.
- Following the reported power outage, the existing server responded normally, - Following the reported power outage, the existing server responded normally,
+41
View File
@@ -1,5 +1,46 @@
# Dune implementation status # 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 ## 13 September 2026 — Leader Skills setup, five disciplines and skilled capture
The [first connected prototype](LEADER_SKILLS_RUNTIME.md) adds all fourteen The [first connected prototype](LEADER_SKILLS_RUNTIME.md) adds all fourteen
+19 -3
View File
@@ -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. 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 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 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. simple policy is not calibrated skill strategy.
Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and 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 Before enabling them, add response-driven automatic revival continuation
coverage; the current before/after revival observer does not certify deferred coverage; the current before/after revival observer does not certify deferred
Tleilaxu response chains. The other source-contract timing questions also remain Tleilaxu response chains. The other source-contract timing questions also remain
open. Skill-specific movement, payments, information, rescues and retreats need open. Planetologist now has bounded movement and battle controls as described below.
their own connected controls and saved choices. 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 ## Local prototype entry and evidence
+216
View File
@@ -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.
+1 -1
View File
@@ -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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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) |
+58 -9
View File
@@ -34,6 +34,9 @@ import {
type HomeworldExplosionLosses, type HomeworldExplosionLosses,
} from './homeworld-battle-rules'; } from './homeworld-battle-rules';
import { import {
PLANETOLOGIST_BASE_SPECIALS,
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
leaderSkillBattleBonus, leaderSkillBattleBonus,
type BattleLeaderSkill, type BattleLeaderSkill,
type LeaderSkillBattleBonus, type LeaderSkillBattleBonus,
@@ -213,6 +216,35 @@ function validateCombatant(
(side.leader.id === p.leader && Number.isFinite(side.leader.strength)), (side.leader.id === p.leader && Number.isFinite(side.leader.strength)),
'The controlled leader must match the revealed plan.', '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; return played;
} }
/** /**
@@ -614,16 +646,30 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
auditCount(other.hand, auditor.usedCards, auditor.survived); auditCount(other.hand, auditor.usedCards, auditor.survived);
} }
let retention: MoritaniRetention | null = null; 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'); const moritani = input.participants.find((p) => p.faction === 'moritani');
if (winner && moritani) { if (winner && moritani) {
const loser = winner === a ? d : a, const loser = winner === a ? d : a,
loserPlayed = loser === a ? played.attacker : played.defender; loserPlayed = loser === a ? played.attacker : played.defender;
const eligible = loserPlayed.filter((selected) => const retainablePlayed = loserPlayed.filter(
canRetainBattleCard( (selected) => !mandatoryPlanetologistDiscard(loser, selected),
card(loser, selected)!, );
ac || dc, const eligible = retainablePlayed.filter(
!!loser.poisonTooth, (selected) =>
), canRetainBattleCard(
card(loser, selected)!,
ac || dc,
!!loser.poisonTooth,
),
); );
if ( if (
loser.id !== moritani.id && loser.id !== moritani.id &&
@@ -640,7 +686,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
player: loser.id, player: loser.id,
territory: input.territory, territory: input.territory,
turn: input.turn, turn: input.turn,
played: [...loserPlayed], played: [...retainablePlayed],
eligible, eligible,
stage: 'choose', stage: 'choose',
}; };
@@ -651,9 +697,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
.filter( .filter(
(selected) => (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( !canRetainBattleCard(
card(s, selected)!, card(s, selected)!,
ac || dc, ac || dc,
+17 -3
View File
@@ -3,6 +3,11 @@ import { guildShipmentCost } from './shipment-price';
import { fighterCount, isAdvisor } from './advisors'; import { fighterCount, isAdvisor } from './advisors';
import { presenceAt } from './force-presence'; import { presenceAt } from './force-presence';
import { EcazOccupancyError } from './ecaz-occupy'; import { EcazOccupancyError } from './ecaz-occupy';
import {
planetologistLeader,
planetologistRange,
type PlanetologistMoveMode,
} from './planetologist-movement';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy'; import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
import { import {
distance, distance,
@@ -66,7 +71,12 @@ export function botEntryAllowed(
throw error; throw error;
} }
} }
export function botMovementRange(g: GameView, p: Seat, elite: number) { export function botMovementRange(
g: GameView,
p: Seat,
elite: number,
planetologist?: PlanetologistMoveMode,
) {
const base = const base =
fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag') fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
? 3 ? 3
@@ -74,7 +84,10 @@ export function botMovementRange(g: GameView, p: Seat, elite: number) {
(p.faction === 'ixians' && elite > 0 && !p.ixMovementBlocked) (p.faction === 'ixians' && elite > 0 && !p.ixMovementBlocked)
? 2 ? 2
: 1; : 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( export function botGroundMoveAllowed(
g: GameView, g: GameView,
@@ -82,6 +95,7 @@ export function botGroundMoveAllowed(
from: string, from: string,
to: string, to: string,
elite: number, elite: number,
planetologist?: PlanetologistMoveMode,
) { ) {
const source = splitLocation(from), const source = splitLocation(from),
target = splitLocation(to); target = splitLocation(to);
@@ -117,7 +131,7 @@ export function botGroundMoveAllowed(
(loc.sector !== 0 && loc.sector === g.storm) || (loc.sector !== 0 && loc.sector === g.storm) ||
strongholdPathBlocked(g.players, p.id, loc.territory, advisors) 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. */ /** Fremen reinforcement radius is static territory geometry, not a ground route. */
+146 -11
View File
@@ -1,4 +1,6 @@
import { discoveryBotActions } from './discovery-options'; 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 { discoveryFlightBotActions } from './discovery-flight-options';
import { discoveryEntryBotActions } from './discovery-entry-options'; import { discoveryEntryBotActions } from './discovery-entry-options';
import { discoveryStormActions } from './discovery-storm-options'; import { discoveryStormActions } from './discovery-storm-options';
@@ -48,7 +50,6 @@ import {
isWeaponCard, isWeaponCard,
isStoneBurner, isStoneBurner,
isDefenseCard, isDefenseCard,
validBattleCardPair,
defaultVoiceMatch, defaultVoiceMatch,
playedVoiceMatch, playedVoiceMatch,
weaponKills, weaponKills,
@@ -72,6 +73,8 @@ import {
fremenReserveEntry, fremenReserveEntry,
guildTransportCost, guildTransportCost,
} from './bot-mobility'; } from './bot-mobility';
import { planetologistLeader } from './planetologist-movement';
import { faction } from './catalog';
import { richeseCardDefinition } from './richese-cards'; import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence'; import { presenceAt } from './force-presence';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter'; import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
@@ -670,9 +673,13 @@ function plans(g: GameView): Action[] {
commitments.every( commitments.every(
(element) => element.field !== field || element.value === value, (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 = [ const weapons = [
null, 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)); ].filter((id) => fixed('weapon', id));
const defenses = [ const defenses = [
null, null,
@@ -752,9 +759,10 @@ function plans(g: GameView): Action[] {
) + (kwisatz ? 2 : 0); ) + (kwisatz ? 2 : 0);
const w = me.hand?.find((c) => c.id === weapon); const w = me.hand?.find((c) => c.id === weapon);
const d = me.hand?.find((c) => c.id === defense); const d = me.hand?.find((c) => c.id === defense);
if (w && !isWeaponCard(w) && !specialForLeader(w,leader)) continue;
const stoneBattle = const stoneBattle =
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon)); isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
if ((!leader && (weapon || defense)) || !validBattleCardPair(w, d)) if ((!leader && (weapon || defense)) || !validLeaderSkillBattleCardPair(w, d, specialForLeader(w,leader)))
continue; continue;
if (voice) { if (voice) {
const used = const used =
@@ -800,7 +808,11 @@ function plans(g: GameView): Action[] {
b.strongholdEffects[other.id], b.strongholdEffects[other.id],
); );
const ownSurvivingStrength = 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 = const enemySurvivingStrength =
effects.stunned || (inspected && effects.defenderDead) effects.stunned || (inspected && effects.defenderDead)
? 0 ? 0
@@ -869,7 +881,7 @@ function plans(g: GameView): Action[] {
)) ))
) )
continue; 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 effectiveDefense = d && d.kind !== 'worthless' ? 4 : 0;
const selfExplosion = battleWeaponsExplode(w, d); const selfExplosion = battleWeaponsExplode(w, d);
const correctDefense = inspected const correctDefense = inspected
@@ -3088,6 +3100,13 @@ function policyActions(g: GameView): Action[] {
return { from, amount, elite }; return { from, amount, elite };
}) })
.filter((source) => source.amount > 0); .filter((source) => source.amount > 0);
const planetologist = planetologistLeader(g, me.id);
const groundMoves: {
action: Action;
destinationScore: number;
amount: number;
gather: boolean;
}[] = [];
if (movementAvailable) if (movementAvailable)
for (const to of targets for (const to of targets
.filter( .filter(
@@ -3100,15 +3119,131 @@ function policyActions(g: GameView): Action[] {
.slice(0, 24)) .slice(0, 24))
for (const { from, amount, elite } of movingSources) { for (const { from, amount, elite } of movingSources) {
if (botGroundMoveAllowed(g, me, from, to.key, elite)) if (botGroundMoveAllowed(g, me, from, to.key, elite))
actions.push({ groundMoves.push({
type: 'move', action: {
from, type: 'move',
territory: to.t, from,
sector: to.s, territory: to.t,
sector: to.s,
amount,
elite,
},
destinationScore: to.score,
amount, 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 (reveal && !actions.length) actions.push(reveal);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip')) if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g)); actions.unshift(...emperorHomeworldMoveActions(g));
+149 -47
View File
@@ -1,5 +1,12 @@
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard } from './leader-skill-cards'; 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 { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; 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'; import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
@@ -459,7 +466,6 @@ import {
isDefenseCard, isDefenseCard,
isPortableSnooper, isPortableSnooper,
VOICE_KINDS, VOICE_KINDS,
validBattleCardPair,
defaultVoiceMatch, defaultVoiceMatch,
playedVoiceMatch, playedVoiceMatch,
} from './battle-cards'; } from './battle-cards';
@@ -4919,6 +4925,32 @@ function leaderSkillController(
)!.capturedBy ?? assignment.owner )!.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) { function nativeLeaderSkill(g: Game, owner: string) {
return g.leaderSkills?.assignments.find( return g.leaderSkills?.assignments.find(
(a) => a.owner === owner && leaderSkillController(g, a) === owner, (a) => a.owner === owner && leaderSkillController(g, a) === owner,
@@ -7997,7 +8029,7 @@ function eliteChoice(
'Elite forces', 'Elite forces',
); );
} }
function forceGroup(g: Game, p: Player, action: Action) { function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) {
let entries: [string, unknown][]; let entries: [string, unknown][];
if (action.forces !== undefined) { if (action.forces !== undefined) {
requireRule( requireRule(
@@ -8011,6 +8043,7 @@ function forceGroup(g: Game, p: Player, action: Action) {
const group: [string, number][] = []; const group: [string, number][] = [];
const eliteGroup: Record<string, number> = {}; const eliteGroup: Record<string, number> = {};
let origin: string | undefined; let origin: string | undefined;
const origins = new Set<string>();
let noField: { tokenId: string; event: string; from: string } | undefined; let noField: { tokenId: string; event: string; from: string } | undefined;
if (action.noField !== undefined) { if (action.noField !== undefined) {
const marker = p.noField?.deployed; 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.', 'This concealed No-Field movement selection is stale.',
); );
origin = marker.location.territory; origin = marker.location.territory;
origins.add(origin);
noField = { noField = {
tokenId: marker.tokenId, tokenId: marker.tokenId,
event: p.noFieldEvent!, 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'); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces');
if (!n) continue; if (!n) continue;
requireRule( origins.add(src.territory);
!origin || origin === src.territory, requireRule(origins.size <= maxOrigins,
'A force group must come from one territory.', maxOrigins === 1 ? 'A force group must come from one territory.' : 'Planetologist gathers forces from exactly two territories.');
); origin ??= src.territory;
origin = src.territory;
group.push([key, n]); group.push([key, n]);
const selectedElite = const selectedElite =
action.forces !== undefined action.forces !== undefined
@@ -8062,6 +8095,7 @@ function forceGroup(g: Game, p: Player, action: Action) {
eliteGroup, eliteGroup,
elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0), elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0),
origin: origin!, origin: origin!,
origins: [...origins],
total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0), total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0),
noField, noField,
sourceKeys: [ sourceKeys: [
@@ -8284,7 +8318,45 @@ function ornithopterView(g: Game, p: Player) {
}; };
} }
function movementOrderRange(g: Game, p: Player, move: MovementOrder) { 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<MovementOrder, 'planetologist' | 'origins' | 'origin' | 'noField' | 'ornithopterEvent' | 'discoveryFlight'>) {
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<MovementOrder, 'origin' | 'origins'>): 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) { function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
if (move.discoveryFlight) if (move.discoveryFlight)
@@ -8355,7 +8427,7 @@ function offerChoamMovement(g: Game, move: MovementOrder) {
g.phase === 5 && g.phase === 5 &&
choam && choam &&
move.player !== choam.id && move.player !== choam.id &&
move.origin !== move.to && movementOrigins(move).some(origin => origin !== move.to) &&
at(choam, move.to) > 0 at(choam, move.to) > 0
) { ) {
g.pendingChoamMove = move; g.pendingChoamMove = move;
@@ -8410,6 +8482,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
'The movement is no longer available.', 'The movement is no longer available.',
); );
validateFlightSelection(g, p, move); validateFlightSelection(g, p, move);
validatePlanetologistMove(g, p, move);
const selected = forceGroup(g, p, { const selected = forceGroup(g, p, {
type: 'move', type: 'move',
forces: Object.fromEntries(move.group), forces: Object.fromEntries(move.group),
@@ -8417,16 +8490,22 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
...(move.noField ...(move.noField
? { noField: move.noField.tokenId, event: move.noField.event } ? { noField: move.noField.tokenId, event: move.noField.event }
: {}), : {}),
}); }, move.planetologist?.mode === 'gather' ? 2 : 1);
requireRule( requireRule(
selected.origin === move.origin && selected.origin === move.origin &&
selected.total === move.total && selected.total === move.total &&
selected.elite === move.elite && 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.', '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( requireRule(
!balisetPrevents(g, p.id, move.origin, move.to), movementOrigins(move).every(origin => !balisetPrevents(g, p.id, origin, move.to)),
'Baliset prevents that movement.', 'Baliset prevents that movement.',
); );
allowedEntry( allowedEntry(
@@ -8444,7 +8523,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
].every( ].every(
(key) => (key) =>
gameDistance(g, key, location(move.to, move.sector), (k) => 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), ) <= movementOrderRange(g, p, move),
), ),
'The movement route is no longer available.', 'The movement route is no longer available.',
@@ -8537,6 +8616,7 @@ function completeMove(g: Game, move: MovementOrder) {
player: id, player: id,
} = move; } = move;
const p = getPlayer(g, id); const p = getPlayer(g, id);
validatePlanetologistMove(g, p, move);
validateFlightSelection(g, p, move); validateFlightSelection(g, p, move);
validateMovementArrival(g, move); validateMovementArrival(g, move);
const flight = g.ornithopter; const flight = g.ornithopter;
@@ -8606,6 +8686,7 @@ function completeMove(g: Game, move: MovementOrder) {
const completed: CompletedMovement = { const completed: CompletedMovement = {
player: id, player: id,
origin, origin,
...(move.origins ? {origins: move.origins} : {}),
total: n, total: n,
to, to,
sector: s, sector: s,
@@ -8630,10 +8711,10 @@ function finishMovedGroup(g: Game, move: CompletedMovement) {
g, g,
move.noField 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 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); 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. */ /** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) { function finishMovementTurn(g: Game, id: string) {
@@ -12668,9 +12749,15 @@ function validatePlan(
); );
const w = cardOf(p, plan.weapon), const w = cardOf(p, plan.weapon),
d = cardOf(p, plan.defense); d = cardOf(p, plan.defense);
const planetologistWeapon = canUsePlanetologistWeapon(
g,
p,
plan.leader,
w,
);
requireRule( requireRule(
!plan.weapon || (w && isWeaponCard(w)), !plan.weapon || (w && (isWeaponCard(w) || planetologistWeapon)),
'Choose a weapon or worthless card.', 'Choose a weapon, worthless card, or eligible Planetologist Special.',
); );
requireRule( requireRule(
!plan.defense || (d && isDefenseCard(d)), !plan.defense || (d && isDefenseCard(d)),
@@ -12685,7 +12772,7 @@ function validatePlan(
'Without a leader, no battle cards can be played.', 'Without a leader, no battle cards can be played.',
); );
requireRule( requireRule(
validBattleCardPair(w, d), validLeaderSkillBattleCardPair(w, d, planetologistWeapon),
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.', 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
); );
if (isStoneBurner(w)) { if (isStoneBurner(w)) {
@@ -13094,7 +13181,15 @@ function findLegalBattlePlan(
.map((l) => l.id), .map((l) => l.id),
...p.hand.filter((c) => c.kind === 'hero').map((c) => c.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 = [ const defenses = [
null, null,
...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id), ...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
@@ -13106,7 +13201,11 @@ function findLegalBattlePlan(
for (const defense of defenses) { for (const defense of defenses) {
if ( if (
!accepts({ leader, weapon, defense }) || !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; continue;
for (const kwisatz of [false, true]) { for (const kwisatz of [false, true]) {
@@ -13657,7 +13756,12 @@ function feasiblePrescience(
]; ];
const weapons: (string | null)[] = [ const weapons: (string | null)[] = [
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)[] = [ const defenses: (string | null)[] = [
null, null,
@@ -13920,7 +14024,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
hand: p.hand, hand: p.hand,
plan, plan,
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader), 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), forces: combatForces(g, p, b.territory, opponent),
stronghold: strongholdEffect(g, p.id), stronghold: strongholdEffect(g, p.id),
lateDefense: b.lateDefense?.[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 }; player.fremenMovementBlocked = { turn: quote.turn, move: quote.move };
log( log(
g, 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 { } else {
log( log(
@@ -18396,7 +18500,7 @@ function richeseSpecialKaramaView(g: Game, p: Player) {
type CompletedMovement = Pick< type CompletedMovement = Pick<
MovementOrder, MovementOrder,
'player' | 'origin' | 'total' | 'to' | 'sector' | 'elite' 'player' | 'origin' | 'origins' | 'total' | 'to' | 'sector' | 'elite'
> & { noField: boolean }; > & { noField: boolean };
type MovementOrder = { type MovementOrder = {
source?: 'ambassador'; source?: 'ambassador';
@@ -18406,6 +18510,8 @@ type MovementOrder = {
eliteGroup: Record<string, number>; eliteGroup: Record<string, number>;
elite: number; elite: number;
origin: string; origin: string;
origins?: string[];
planetologist?: PlanetologistMovement;
total: number; total: number;
to: string; to: string;
sector: number; sector: number;
@@ -21515,36 +21621,28 @@ function applyActionInner(
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p), g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
'Your movement is not available.', '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 { const {
group, group,
eliteGroup, eliteGroup,
elite, elite,
origin, origin,
origins,
total: n, total: n,
noField, noField,
sourceKeys, sourceKeys,
} = forceGroup(g, p, action); } = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
const to = stringField(action.territory), const to = stringField(action.territory),
s = integer(action.sector, 0, 18, 'Sector'); s = integer(action.sector, 0, 18, 'Sector');
const advisors = arrivalAsAdvisor(g, p, to, origin); const {advisors, lockedTurn, wantsFighters} = movementStance(g, p, origins, to, action.fighters === true);
const lockedTurn = p.advisors?.[origin]?.lockedTurn;
requireRule( requireRule(
advisors || origins.every(source => !balisetPrevents(g, id, source, to)),
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),
'Baliset prevents movement into this CHOAM territory; shipment remains possible.', 'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
); );
allowedEntry(g, p, to, s, false, advisors && !wantsFighters); allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
@@ -21608,6 +21706,8 @@ function applyActionInner(
eliteGroup, eliteGroup,
elite, elite,
origin, origin,
...(planetologist ? {planetologist} : {}),
...(planetologist?.mode === 'gather' ? {origins} : {}),
total: n, total: n,
to, to,
sector: s, sector: s,
@@ -21624,12 +21724,13 @@ function applyActionInner(
}; };
if (action.discoveryOrnithopter !== undefined) if (action.discoveryOrnithopter !== undefined)
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move)); move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
validatePlanetologistMove(g, p, move);
const speed = movementOrderRange(g, p, move); const speed = movementOrderRange(g, p, move);
requireRule( requireRule(
sourceKeys.every( sourceKeys.every(
(key) => (key) =>
gameDistance(g, key, location(to, s), (k) => gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin)), pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
) <= speed, ) <= speed,
), ),
`That destination is blocked or more than ${speed} territories away.`, `That destination is blocked or more than ${speed} territories away.`,
@@ -21657,14 +21758,15 @@ function applyActionInner(
passed: [], passed: [],
}; };
} else if ( } else if (
!move.ornithopterRange && !move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'fremen' && p.faction === 'fremen' &&
speed === 2 && !(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
group.some( group.some(
([key]) => ([key]) =>
gameDistance(g, key, location(to, s), (k) => gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin)), pathBlocked(g, p, k, isAdvisor(p, origin)),
) > 1, ) > planetologistRange(1, planetologist?.mode),
) )
) { ) {
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move }; g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
+14 -1
View File
@@ -15,6 +15,7 @@ import {
MOBILE_STRONGHOLD, MOBILE_STRONGHOLD,
} from './board'; } from './board';
import { presenceAt } from './force-presence'; import { presenceAt } from './force-presence';
import { planetologistLeader } from './planetologist-movement';
import { validateAmbassadors } from './ecaz-ambassadors'; import { validateAmbassadors } from './ecaz-ambassadors';
import { import {
homeworldMobileStrongholdMovementBlock, homeworldMobileStrongholdMovementBlock,
@@ -115,7 +116,7 @@ function movementDeclaration(
Array.isArray(entry) && Array.isArray(entry) &&
entry.length === 2 && entry.length === 2 &&
sectorKey(g, entry[0]) && sectorKey(g, entry[0]) &&
splitLocation(entry[0]).territory === move.origin && (move.origins ?? [move.origin]).includes(splitLocation(entry[0]).territory) &&
integer(entry[1]) && integer(entry[1]) &&
entry[1] > 0, entry[1] > 0,
) && ) &&
@@ -131,6 +132,18 @@ function movementDeclaration(
move.total, move.total,
'The canceled movement has invalid declared physical groups.', '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) if (move.noField)
requireContext( requireContext(
record(move.noField) && record(move.noField) &&
+2 -1
View File
@@ -27,6 +27,7 @@ export type CompletedMovementArrivalInput = Readonly<{
order: Readonly<{ order: Readonly<{
player: string; player: string;
origin: string; origin: string;
origins?: readonly string[];
to: string; to: string;
advisors: boolean; advisors: boolean;
wantsFighters: boolean; wantsFighters: boolean;
@@ -91,7 +92,7 @@ export function quoteCompletedMovementArrival(
); );
const base = { intrusion, retiresOrnithopter }; const base = { intrusion, retiresOrnithopter };
// Intrusion also runs between sectors of one territory; entry does not. // 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 ecaz = players.find((p) => p.faction === 'ecaz');
const ambassador = input.ambassadors.find( const ambassador = input.ambassadors.find(
+90 -3
View File
@@ -1,5 +1,10 @@
import type { Card } from './cards'; 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'; import type { LeaderSkillId } from './leader-skill-cards';
export type DirectLeaderSkillId = export type DirectLeaderSkillId =
@@ -19,8 +24,8 @@ export type BattleLeaderSkill = {
}; };
export type AppliedLeaderSkill = { export type AppliedLeaderSkill = {
skill: DirectLeaderSkillId; skill: DirectLeaderSkillId | 'planetologist';
amount: 1 | 3; amount: 1 | 2 | 3;
mode: 'normal' | 'skilled'; mode: 'normal' | 'skilled';
}; };
@@ -37,6 +42,74 @@ const directSkills = new Set<LeaderSkillId>([
'prana-bindu-adept', '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<Card, 'id' | 'name' | 'kind' | 'effect'> | 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<Card, 'id' | 'name' | 'kind' | 'effect'> | 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 { function isDirectSkill(skill: string): skill is DirectLeaderSkillId {
return directSkills.has(skill as LeaderSkillId); return directSkills.has(skill as LeaderSkillId);
} }
@@ -71,6 +144,20 @@ export function leaderSkillBattleBonus(input: {
}): LeaderSkillBattleBonus { }): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] }; if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = []; 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) { for (const assignment of input.assignments) {
if (!isDirectSkill(assignment.skill)) continue; if (!isDirectSkill(assignment.skill)) continue;
if (!qualifies(assignment.skill, input.weapon, input.defense)) continue; if (!qualifies(assignment.skill, input.weapon, input.defense)) continue;
+37
View File
@@ -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;
}
+8 -7
View File
@@ -3563,23 +3563,24 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions', category: 'Advanced & expansions',
coverage: 'Partial', coverage: 'Partial',
developmentStage: 'Prototyped', 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: [ 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.', '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.', '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.', '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.', '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.', '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(' ')}`), ...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
], ],
related: ['setup','choam-modules','implementation-checklist'], related: ['setup','choam-modules','implementation-checklist'],
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:'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 controls are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx'] }, { 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 and legal visibility/revival actions. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, { 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'] }, { 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 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:'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'] },
], ],
}, },
...[ ...[
+514
View File
@@ -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/,
);
});
+239
View File
@@ -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<string, number>)[source]) > 0,
),
);
assert.ok(gather, `${difficulty} omitted the two-origin branch`);
assert.deepEqual(
new Set(
Object.keys(gather.forces as Record<string, number>).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<string, number>)),
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,
);
});
@@ -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/,
);
});
+410
View File
@@ -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);
});