Implement mixed Planetologist advisor gather continuation

This commit is contained in:
Quintine committed 2026-10-18 00:35:36 +11:00
1 parent bc37cd38fe
commit c265f0638e
16 files changed
+512 -171

No files matched your search

+28 -9
View File
@@ -97,7 +97,7 @@ import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal';
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 { PlanetologistMovement } from './planetologist-movement';
import { selectedOriginElites } from '@/game/planetologist-movement'; import { selectedOriginElites, advisorFlipMovementBlocked } from '@/game/planetologist-movement';
import { SandmasterMovement } from './sandmaster-movement'; import { SandmasterMovement } from './sandmaster-movement';
import { SandmasterWormChoice } from './sandmaster-worm'; import { SandmasterWormChoice } from './sandmaster-worm';
import { sandmasterWormCollection } from '@/game/sandmaster-worm'; import { sandmasterWormCollection } from '@/game/sandmaster-worm';
@@ -796,6 +796,7 @@ export function GameTable({
sector, sector,
...(includesNoField ? {} : { amount }), ...(includesNoField ? {} : { amount }),
}; };
const advisorFlipRouteBlocked = ordinaryMovementAction.fighters === true && advisorFlipMovementBlocked(g, me);
const transportAction: Action = { const transportAction: Action = {
type: 'guildShip', type: 'guildShip',
...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...(useGuildSecret ? {nexus: guildSecretEvent} : {}),
@@ -2583,6 +2584,14 @@ export function GameTable({
unchanged. unchanged.
</p> </p>
)} )}
{g.response.kind === 'advisorFlip' && g.response.advisorResume === 'planetologistGather' && (
<p className="notice">
Provisional mixed Planetologist gather: allow one common fighter
arrival before either group moves, or cancel the native advisor
flip. Cancellation keeps both groups and the movement allowance
unchanged, with flipping blocked for that movement opportunity.
</p>
)}
{g.response.kind === 'nexusGuildCunning' && <p className="notice"> {g.response.kind === 'nexusGuildCunning' && <p className="notice">
Allow one second Guild shipment at its normal price, or cancel this extra opportunity. Allow one second Guild shipment at its normal price, or cancel this extra opportunity.
The completed first shipment and movement stay unchanged; the Nexus is already spent. The completed first shipment and movement stay unchanged; the Nexus is already spent.
@@ -4792,6 +4801,12 @@ export function GameTable({
territory territory
</label> </label>
)} )}
{advisorFlipRouteBlocked && (
<p className="notice" id="movement-advisor-flip-unavailable">
Advisor flipping was canceled for this movement. Turn off
the fighter request or choose a group that is already fighting.
</p>
)}
{!g.ornithopter?.active && ( {!g.ornithopter?.active && (
<> <>
{actionButton( {actionButton(
@@ -4802,18 +4817,21 @@ export function GameTable({
!source || !source ||
canceledFremenRouteBlocked || canceledFremenRouteBlocked ||
ordinaryMovementRouteBlocked || ordinaryMovementRouteBlocked ||
advisorFlipRouteBlocked ||
selectedDestinationInStorm || selectedDestinationInStorm ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) || (me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
(includesNoField (includesNoField
? !validMarkerGroup ? !validMarkerGroup
: (me.forces[source] ?? 0) === 0), : (me.forces[source] ?? 0) === 0),
selectedDestinationInStorm advisorFlipRouteBlocked
? 'movement-storm-unavailable' ? 'movement-advisor-flip-unavailable'
: ordinaryMovementRouteBlocked : selectedDestinationInStorm
? 'movement-route-unavailable' ? 'movement-storm-unavailable'
: canceledFremenRouteBlocked : ordinaryMovementRouteBlocked
? 'movement-range-unavailable' ? 'movement-route-unavailable'
: undefined, : canceledFremenRouteBlocked
? 'movement-range-unavailable'
: undefined,
)} )}
{canceledFremenRouteBlocked && {canceledFremenRouteBlocked &&
!selectedDestinationInStorm && ( !selectedDestinationInStorm && (
@@ -4879,6 +4897,7 @@ export function GameTable({
move={ move={
!source || !source ||
!movementAvailable || !movementAvailable ||
advisorFlipRouteBlocked ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) || (me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
(includesNoField (includesNoField
? !validMarkerGroup ? !validMarkerGroup
@@ -4901,7 +4920,7 @@ export function GameTable({
act={act} act={act}
busy={busy} busy={busy}
move={ move={
!source || !movementAvailable || !source || !movementAvailable || advisorFlipRouteBlocked ||
(includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0) (includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0)
? null ? null
: { : {
+12 -16
View File
@@ -6,6 +6,7 @@ import {
planetologistLeader, planetologistLeader,
planetologistMovementModeSupported, planetologistMovementModeSupported,
selectedOriginElites, selectedOriginElites,
planetologistGatherAdvisorEntry,
type PlanetologistMoveMode, type PlanetologistMoveMode,
} from '@/game/planetologist-movement'; } from '@/game/planetologist-movement';
import { botGroundMoveAllowed } from '@/game/bot-mobility'; import { botGroundMoveAllowed } from '@/game/bot-mobility';
@@ -15,8 +16,6 @@ import {
splitLocation, splitLocation,
territory, territory,
} from '@/game/board'; } from '@/game/board';
import { isAdvisor } from '@/game/advisors';
import { presenceAt } from '@/game/force-presence';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
@@ -76,20 +75,8 @@ export function planetologistMoveDraft(
? 'Select forces from one territory.' ? 'Select forces from one territory.'
: 'Select forces from exactly two different territories.', : 'Select forces from exactly two different territories.',
}; };
if ( const entry = planetologistGatherAdvisorEntry(game, me, [...origins], destination);
game.advanced && if (entry.blocked) return { action: null, blocked: entry.blocked };
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); const target = location(destination, sector);
if ( if (
selected.some( selected.some(
@@ -109,6 +96,7 @@ export function planetologistMoveDraft(
action: { action: {
type: 'move', type: 'move',
planetologist: mode, planetologist: mode,
...(entry.requiresFlip ? { fighters: true } : {}),
forces: Object.fromEntries(selected), forces: Object.fromEntries(selected),
...(me.elites ...(me.elites
? { ? {
@@ -196,6 +184,14 @@ export function PlanetologistMovement({
Destination: {territory(destination).name}, sector {sector}. Select the Destination: {territory(destination).name}, sector {sector}. Select the
physical forces to move below. physical forces to move below.
</p> </p>
{draft.action?.fighters === true && (
<p className="notice">
Provisional mixed-gather policy: both groups arrive as fighters. The
advisor flip is declared before either group moves. If canceled, both
groups stay in place and no movement is spent; flipping remains blocked
for that movement opportunity.
</p>
)}
{me.faction === 'ixians' && ( {me.faction === 'ixians' && (
<p className="fine"> <p className="fine">
Each origin uses its own selected cyborgs for movement range. Cyborgs Each origin uses its own selected cyborgs for movement range. Cyborgs
+23 -1
View File
@@ -1,6 +1,6 @@
# Current development status # Current development status
Updated 17 October 2026. **The full twelve-faction goal is unfinished. Basic Updated 18 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable. Advanced, expansion-faction and supported optional-module starts is playable. Advanced, expansion-faction and supported optional-module starts
are labelled previews; complete rules/publication acceptance remains gated.** are labelled previews; complete rules/publication acceptance remains gated.**
@@ -29,6 +29,28 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented. remain deferred until all rules are implemented.
**Planetologist mixed advisor gather — 18 October. Prototyped / Partial.**
A genuine turn-two Carthag fighter plus older Imperial Basin advisor reproduced
the enemy-Arrakeen guard despite ordinary city range. The explicit recommended
policy now declares one common fighter arrival before either source moves.
One native flip response carries the exact movement; allow commits once,
cancel leaves both groups/postures, spice, shipment and movement unchanged.
The current movement’s flip block prevents an immediate bypass; a legal
fighter-only replacement works. Fresh-advisor locks and original range,
occupancy, uniform/determined stances and old windows remain.
Related20-file **191/191**, types/lint/build and genuine controller programmes
pass. Original normal setup/shipments/accompaniment supply three regressions;
the only positioned card is one conserved original Karama. Four bot profiles
submit legal actions; three use mixed gathering into an empty destination,
not the new contested response. Actual390px GameTable controls execute the
declaration, native Allow/Cancel, blocked retry, ordinary replacement and fresh
lock against real engine actions in isolated memory, with no errors/overflow.
Owned QA routes/memory are released; no stored room/DB change or dev restart.
This is local changed-surface proof, not deployed, full-mode, calibrated AI or
deferred assurance acceptance.
[Source, provisional policy and current evidence](PLANETOLOGIST_RULES.md#mixed-advisor-and-fighter-gather--18-october-2026).
**Native E3 World Skills/cards — 17 October.** Fourteen of18 original normal **Native E3 World Skills/cards — 17 October.** Fourteen of18 original normal
World/phase starts rejected before; all18 now start. Standalone/paired World/phase starts rejected before; all18 now start. Standalone/paired
Ecaz/Moritani/classic Worlds retains optional all14/cards and original phase Ecaz/Moritani/classic Worlds retains optional all14/cards and original phase
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status # Dune implementation status
## 18 October 2026 — Mixed Bene Gesserit Planetologist gather
An original turn-two Carthag fighter plus older Imperial Basin advisor cannot
gather into enemy Arrakeen before this change. Source requires one stance and
allows native flip cancellation but gives no simultaneous mixed-origin order.
Under choose-recommended, the explicitly provisional common-fighter gather
declares one native flip before either source moves.
The response carries exact original trainer/groups/turn/movement. Allow
revalidates and commits one movement; one original Karama cancellation keeps
both sources/postures, spice, shipment and movement unchanged. A current-slot
native flip stamp prevents immediate replay while a fighter-only replacement
remains legal. Fresh advisors, existing destination stances and old ordinary
flip windows retain their rules. Shared controls/bot candidates use the quote;
wording, action-copy and obsolete software-exclusion pins are removed.
Related20-file **191/191**, types/lint/build and actual controllers pass. Three new
meaningful regressions use genuine normal setup and native shipments/
accompaniment, with one labelled original Karama input. All four policies act
legally; three mixed gathers reach empty destinations, not the contested flip
window. Actual390px GameTable controls execute declaration, native Allow/
Cancel, blocked retry, ordinary replacement and fresh lock against the real
engine in isolated memory with no browser errors/overflow. Owned routes/memory
are removed, without any stored-room/DB mutation or dev restart. Shared movement
types avoid a recursive inferred GameView dependency. No full-mode, deployed,
calibration or deferred assurance claim.
[Detailed authority and interpretation](PLANETOLOGIST_RULES.md#mixed-advisor-and-fighter-gather--18-october-2026),
[runtime scope](LEADER_SKILLS_RUNTIME.md#mixed-bg-planetologist-gather--18-october-2026).
## 17 October 2026 — Native E3 World Skills/cards ## 17 October 2026 — Native E3 World Skills/cards
Fourteen of18 normal World/phase starts rejected before; all18 now start. Fourteen of18 normal World/phase starts rejected before; all18 now start.
+42
View File
@@ -7,6 +7,48 @@ and fixture opt-ins are removed; existing games are not retrofitted.
Earlier dated references to a separate entry describe historical checkpoints. Earlier dated references to a separate entry describe historical checkpoints.
See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
## Mixed BG Planetologist gather — 18 October 2026
The actual checklist gap now has a connected prototype: mixed advisor/fighter
origins entering an otherwise enemy-held territory declare one common fighter
arrival before either group moves. Original native Karama may cancel that
flip. Both source groups, spice, shipment and movement remain unchanged while
waiting and after cancellation; the native flip stays blocked for the current
movement opportunity. A legal non-flipping replacement remains available.
Fresh advisors, ordinary route/range/occupancy and existing destination stances
retain their rules. Old single-origin and ordinary flip windows keep their timing.
This is an explicit [recommended provisional timing policy](PLANETOLOGIST_RULES.md#mixed-advisor-and-fighter-gather--18-october-2026),
not a newly retrieved combined FAQ. The original response carries the exact
living trainer and two-origin movement; its public current-slot block feeds
shared human/bot quotes. No selected source, ordinary range or physical counter
is replaced by a synthetic force. Existing game windows are not reopened.
`tests/fixture-planetologist-advisor-gather.ts` reaches its source position
through normal module/ready/start, actual training, paid Carthag shipping,
Imperial Basin accompaniment and a later native movement turn. The only card
positioning conserves one original Karama. Three meaningful consumer cases
exercise advisor-first allowance, whole-declaration cancellation/replacement
and an actual fresh arrival-turn lock. Old exclusion, wording and action-copy
pins are deleted, not re-pinned.
The related20-file **191/191**, types/lint/build pass. Actual original
controllers produce Arrakeen2 fighters/one movement when allowed, or unchanged
sources/zero movement plus one spent Karama when canceled; a normal fighter
replacement leaves its original advisor behind. All four bot profiles submit
legal actions; three choose mixed gathering into an empty destination, not the
new contested-entry response. That is participation proof, not calibration.
Actual390px GameTable proof uses the same original fixture and real engine
actions in isolated memory, not staged stored rooms: select both sources,
read the provisional warning, declare, allow/cancel with native controls,
observe a blocked retry and perform the ordinary replacement. Fresh-advisor
rejection also renders. No browser errors or root overflow; all owned QA
memory/routes are released and the healthy existing dev server is reused.
This is changed-surface proof, not normal HTTP/storage, deployed,
complete-mode or deferred assurance acceptance.
## Native E3 Worlds with independent cards — 17 October 2026 ## Native E3 Worlds with independent cards — 17 October 2026
This supersedes the earlier17 October non-World E3 card boundary below. This supersedes the earlier17 October non-World E3 card boundary below.
+60 -10
View File
@@ -1,6 +1,6 @@
# Planetologist rule contract # Planetologist rule contract
Updated 13 September 2026. This note audits the movement and battle effects of the Updated 18 October 2026. This note audits the movement and battle effects of the
Planetologist Leader Skill against its physical card, the common Leader Skills 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. 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.
@@ -169,15 +169,46 @@ retains that response, with both origins saved and revalidated before continuati
This applies the [existing cancellation contract](FREMEN_MOVEMENT_KARAMA_RULES.md), This applies the [existing cancellation contract](FREMEN_MOVEMENT_KARAMA_RULES.md),
not a new phase-wide restriction or an automatic shortened destination. 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) ### Mixed advisor and fighter gather — 18 October 2026
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, The earlier [November FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
or when existing BG forces determine the stance. The prototype supports mixed and [base rules](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
advisor/fighter origins in those cases, and uniform origins generally. Source review supplies the original determined-destination stance. The authorized
advisor locks carry forward. Mixed origins entering an enemy-only territory need local revision `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, physical28 /
a combined advisor-flip/cancellation continuation and remain explicitly guarded; logical26, likewise requires one BG stance per territory, matching existing
this is an implementation gap, not a rule that forbids every mixed move. No source BG forces, and permits moved advisors to flip in an otherwise enemy-held
was found granting a chosen group order that makes fighters arrive as advisors. territory. Physical17 / logical15 permits Karama against one token-group
flip; physical16 / logical14 permits a legal modified action after cancellation.
The current publisher FAQ URL redirects rather than returning that PDF;
no fresh web summary substitutes for the authorized local text.
Neither examined passage specifies an arrival order for a simultaneous mixed
Planetologist gather. Under the standing choose-recommended instruction,
the prototype adopts an explicit **common-fighter, precommit flip** policy:
- With no existing BG group and another faction present, mixed origins require
a declared fighter arrival. Before either group moves, open one original
native advisor-flip response. This grants no chosen order that turns incoming
fighters into advisors.
- Allowing revalidates the living original trainer, both groups, ordinary
ranges and destination, then moves them together as fighters for one
movement. Source positions and usage remain unchanged while the response waits.
- Canceling spends the original counter, leaves both groups and their postures
untouched and spends no movement or shipment. A turn/movement stamp prevents
repeating the canceled flip for the same opportunity. A legal replacement
without that flip remains available; this is not a phase-wide movement ban.
- Fresh accompanying advisors still cannot become fighters that turn. Empty
destinations and an existing BG destination group retain their original
compulsory common stance. Uniform origins and already-opened ordinary
flip windows keep their existing timing.
This is visibly provisional product composition, not a retrieved combined
FAQ or a separately answered numerical ruling. The shared quote drives the
engine, human gather control and legal bot candidates. The exact original
movement is carried by the native response; no counters or private cards are
duplicated. Original normal-lobby setup, paid Carthag shipment and Imperial
Basin accompaniment produce a genuine source-bound regression position.
The runtime now follows the [shared native skill profiles](LEADER_SKILLS_RUNTIME.md), The runtime now follows the [shared native skill profiles](LEADER_SKILLS_RUNTIME.md),
including per-origin Ixian cyborg range and its independent cancellation window. including per-origin Ixian cyborg range and its independent cancellation window.
@@ -198,6 +229,25 @@ and explains +2 and mandatory discard. Bot candidates use that same eligibility
pair validation. This is a connected prototype with partial combination coverage, pair validation. This is a connected prototype with partial combination coverage,
not complete Leader Skills compliance or a publication gate. not complete Leader Skills compliance or a publication gate.
## Changed-path evidence — 18 October 2026
Related20-file191/191 and types/lint/build pass. Original controllers and three
source-bound regressions cover common-fighter allowance before either source
moves, one-Karama whole-gather cancellation, a blocked immediate retry, a
legal fighter-only replacement and a fresh accompanying-advisor rejection.
The actual normal lobby/shipments/turns are not fabricated; one original
Karama is conserved from its deck and labelled, not called a natural purchase.
Actual390px GameTable source counts, provisional notice, native Allow/Cancel,
disabled retry/reason, ordinary replacement and fresh lock render and execute
against real engine actions in isolated memory, with no errors/root overflow.
Owned routes/memory are removed, with no stored room/DB change or dev restart.
Four legal bot-policy programmes include three mixed gathers into empty
destinations; they do not exercise the new contested response or certify
calibrated strategy. Compiler/lint and checkpoint evidence are recorded in
[current status](CURRENT_STATUS.md) and [runtime scope](LEADER_SKILLS_RUNTIME.md#mixed-bg-planetologist-gather--18-october-2026).
No full-mode, combination, deployed or deferred assurance claim follows.
## Verification evidence, 13 September 2026 ## Verification evidence, 13 September 2026
The final focused union passes 84 cases, including the 24-case Planetologist The final focused union passes 84 cases, including the 24-case Planetologist
+14 -1
View File
@@ -1,6 +1,19 @@
# Rule decision index # Rule decision index
Updated 17 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. Updated 18 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Recommended mixed Planetologist advisor gather — 18 October 2026
The supplied authorized revision, physical28/logical26 and physical17/logical15,
requires one BG stance and permits a native token-group flip counter, but gives
no simultaneous mixed-origin gather order. Under choose-recommended, declare
one common fighter arrival before either source moves. Allow revalidates and
commits one ordinary movement; cancel preserves both original groups/postures,
spice, shipment and movement, while blocking another flip for that movement
opportunity. A legal non-flipping replacement remains. Fresh advisors still
cannot flip; existing destination stances and ordinary windows remain.
This is explicit provisional composition, not a new publisher FAQ/user number.
[Detailed authority, timing and limits](PLANETOLOGIST_RULES.md#mixed-advisor-and-fighter-gather--18-october-2026).
## Physical Traitor roles — 15 October 2026 ## Physical Traitor roles — 15 October 2026
+5 -8
View File
@@ -97,7 +97,7 @@ import {
fremenReserveEntry, fremenReserveEntry,
guildTransportCost, guildTransportCost,
} from './bot-mobility'; } from './bot-mobility';
import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement'; import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement'; import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm'; import { sandmasterWormCollection } from './sandmaster-worm';
import { richeseCardDefinition } from './richese-cards'; import { richeseCardDefinition } from './richese-cards';
@@ -3681,13 +3681,7 @@ function policyActions(g: GameView): Action[] {
b = gatherSources[second], b = gatherSources[second],
aTerritory = splitLocation(a.from).territory, aTerritory = splitLocation(a.from).territory,
bTerritory = splitLocation(b.from).territory; bTerritory = splitLocation(b.from).territory;
if ( if (aTerritory === bTerritory) continue;
aTerritory === bTerritory ||
(g.advanced &&
me.faction === 'beneGesserit' &&
isAdvisor(me, aTerritory) !== isAdvisor(me, bTerritory))
)
continue;
for (const to of usefulTargets for (const to of usefulTargets
.filter( .filter(
(target) => (target) =>
@@ -3709,10 +3703,13 @@ function policyActions(g: GameView): Action[] {
), ),
) )
.slice(0, 24)) { .slice(0, 24)) {
const entry = planetologistGatherAdvisorEntry(g, me, [aTerritory, bTerritory], to.t);
if (entry.blocked) continue;
groundMoves.push({ groundMoves.push({
action: { action: {
type: 'move', type: 'move',
planetologist: 'gather', planetologist: 'gather',
...(entry.requiresFlip ? { fighters: true } : {}),
forces: { [a.from]: a.amount, [b.from]: b.amount }, forces: { [a.from]: a.amount, [b.from]: b.amount },
...(me.elites ...(me.elites
? { ? {
+49 -25
View File
@@ -18,7 +18,7 @@ import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './t
import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders'; import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders';
import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board'; import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer'; import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement'; import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, planetologistGatherAdvisorEntry, advisorFlipMovementBlocked, type PlanetologistMovement } from './planetologist-movement';
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field'; import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment'; import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle'; import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle';
@@ -642,6 +642,7 @@ export type Player = {
gholaBlocked?: Record<string, number>; gholaBlocked?: Record<string, number>;
ixMovementBlocked?: { turn: number; move: number }; ixMovementBlocked?: { turn: number; move: number };
fremenMovementBlocked?: { turn: number; move: number }; fremenMovementBlocked?: { turn: number; move: number };
advisorFlipMovementBlocked?: { turn: number; move: number };
fremenNexusMovementBlockedTurn?: number; fremenNexusMovementBlockedTurn?: number;
fremenNexusWormBlockedTurn?: number; fremenNexusWormBlockedTurn?: number;
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */ /** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
@@ -1032,7 +1033,8 @@ export type ResponseWindow = {
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */ /** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
guildRateEvent?: string; guildRateEvent?: string;
guildTransportEvent?: string; guildTransportEvent?: string;
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry'; advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
discoveryEntry?: DiscoveryEntryArrivalChild; discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string; advisorAmbassadorEvent?: string;
advisorRemaining?: string[]; advisorRemaining?: string[];
@@ -8481,7 +8483,8 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
!p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined && !p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined &&
p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined && p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined &&
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined && p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined && p.fremenMovementBlocked === undefined && p.advisorFlipMovementBlocked === undefined &&
p.fremenNexusMovementBlockedTurn === undefined &&
p.fremenNexusWormBlockedTurn === undefined && p.fremenNexusWormBlockedTurn === undefined &&
p.atomicsHandLimitPenalty === undefined && p.tupileHandSlot === undefined && p.faceDancers === undefined && p.atomicsHandLimitPenalty === undefined && p.tupileHandSlot === undefined && p.faceDancers === undefined &&
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined; p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
@@ -15623,20 +15626,22 @@ function movementOrigins(move: Pick<MovementOrder, 'origin' | 'origins'>): strin
return move.origins ?? [move.origin]; return move.origins ?? [move.origin];
} }
function movementStance(g: Game, p: Player, origins: string[], to: string, fighters: boolean) { function movementStance(g: Game, p: Player, origins: string[], to: string, fighters: boolean) {
requireRule(!g.advanced || p.faction !== 'beneGesserit' || const gather = planetologistGatherAdvisorEntry(g, p, origins, to);
at(p, to) > 0 || !g.players.some(other => other.id !== p.id && at(other, to)) || requireRule(!gather.blocked, gather.blocked ?? 'The mixed gather cannot flip its advisors.');
origins.every(origin => isAdvisor(p, origin) === isAdvisor(p, origins[0])), requireRule(!gather.requiresFlip || fighters,
'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.'); 'Declare a common fighter arrival for a mixed advisor/fighter gather.');
const advisors = arrivalAsAdvisor(g, p, to, origins[0]); const advisors = !gather.requiresFlip && arrivalAsAdvisor(g, p, to, origins[0]);
const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined; const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined;
requireRule(advisors || lockedTurn !== g.turn || requireRule(advisors || lockedTurn !== g.turn ||
!g.players.some(other => other.id !== p.id && at(other, to)), !g.players.some(other => other.id !== p.id && at(other, to)),
'New advisors cannot become fighters this turn.'); 'New advisors cannot become fighters this turn.');
const wantsFighters = advisors && fighters; const wantsFighters = advisors && fighters;
requireRule(!wantsFighters || !advisorFlipMovementBlocked(g, p),
'Advisor flipping was canceled for this movement. Choose a legal replacement without flipping advisors.');
requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) && requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) &&
(!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))), (!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))),
'These advisors cannot flip to fighters on arrival.'); 'These advisors cannot flip to fighters on arrival.');
return {advisors, lockedTurn, wantsFighters}; return {advisors, lockedTurn, wantsFighters, advisorGather: gather.requiresFlip};
} }
function validateFlightSelection(g: Game, p: Player, move: MovementOrder) { function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
if (move.discoveryFlight) if (move.discoveryFlight)
@@ -15772,7 +15777,8 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
'The declared force group no longer matches its movement.', 'The declared force group no longer matches its movement.',
); );
if (move.planetologist) { if (move.planetologist) {
const stance = movementStance(g, p, selected.origins, move.to, move.wantsFighters); const stance = movementStance(g, p, selected.origins, move.to, move.wantsFighters ||
planetologistGatherAdvisorEntry(g, p, selected.origins, move.to).requiresFlip);
requireRule(stance.advisors === move.advisors && stance.wantsFighters === move.wantsFighters && requireRule(stance.advisors === move.advisors && stance.wantsFighters === move.wantsFighters &&
stance.lockedTurn === move.lockedTurn, 'The declared Planetologist arrival stance is no longer current.'); stance.lockedTurn === move.lockedTurn, 'The declared Planetologist arrival stance is no longer current.');
} }
@@ -19452,7 +19458,7 @@ function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) {
function finishAdvisorReaction( function finishAdvisorReaction(
g: Game, g: Game,
details: { details: {
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry'; advisorResume?: ResponseWindow['advisorResume'];
discoveryEntry?: DiscoveryEntryArrivalChild; discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string; advisorAmbassadorEvent?: string;
advisorRemaining?: string[]; advisorRemaining?: string[];
@@ -26271,19 +26277,31 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
response.charityHomeworld ?? 0, response.charityHomeworld ?? 0,
); );
} else if (response.kind === 'advisorFlip') { } else if (response.kind === 'advisorFlip') {
if (canceled) movementCancellationQuote(g, response); const bg = getPlayer(g, response.owner);
const bg = getPlayer(g, response.owner), if (response.advisorResume === 'planetologistGather') {
to = response.location!; movementCancellationQuote(g, response);
if (!canceled) { const pending = response.advisorGatherMove!;
bg.advisors ??= {}; if (canceled) {
if (response.advisors) bg.advisors[to] = {}; bg.advisorFlipMovementBlocked = { turn: pending.turn, move: pending.move };
else delete bg.advisors[to]; log(g, `${bg.name}'s mixed advisor/fighter gather was canceled before movement. Both groups remain in place and no movement was spent; advisor flipping is blocked for this movement opportunity.`);
log( } else {
g, validateMovementOrder(g, bg, pending.order);
`${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`, completeMove(g, pending.order);
); }
} else {
if (canceled) movementCancellationQuote(g, response);
const to = response.location!;
if (!canceled) {
bg.advisors ??= {};
if (response.advisors) bg.advisors[to] = {};
else delete bg.advisors[to];
log(
g,
`${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`,
);
}
finishAdvisorReaction(g, response);
} }
finishAdvisorReaction(g, response);
} else if (response.kind === 'capture') { } else if (response.kind === 'capture') {
const capture = g.pendingCapture!; const capture = g.pendingCapture!;
g.pendingCapture = null; g.pendingCapture = null;
@@ -33676,7 +33694,7 @@ function applyActionInner(
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1); } = 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, lockedTurn, wantsFighters} = movementStance(g, p, origins, to, action.fighters === true); const {advisors, lockedTurn, wantsFighters, advisorGather} = movementStance(g, p, origins, to, action.fighters === true);
requireRule( requireRule(
origins.every(source => !balisetPrevents(g, id, source, to)), origins.every(source => !balisetPrevents(g, id, source, to)),
'Baliset prevents movement into this CHOAM territory; shipment remains possible.', 'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
@@ -33784,7 +33802,12 @@ function applyActionInner(
: `That destination is blocked or more than ${speed} territories away.`, : `That destination is blocked or more than ${speed} territories away.`,
); );
validateFlightSelection(g, p, move); validateFlightSelection(g, p, move);
if ( if (advisorGather) {
g.response = {
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
};
} else if (
!move.ornithopterRange && !move.discoveryFlight && !move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'ixians' && p.faction === 'ixians' &&
elite > 0 && elite > 0 &&
@@ -35167,6 +35190,7 @@ export function viewGame(state: Game, id: string) {
ixMovementBlocked: ixMovementBlocked:
p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked?.turn === g.turn &&
p.ixMovementBlocked.move === p.moved, p.ixMovementBlocked.move === p.moved,
advisorFlipMovementBlocked: advisorFlipMovementBlocked(g, p),
revealedTraitors: p.revealedTraitors ?? [], revealedTraitors: p.revealedTraitors ?? [],
revealedFaceDancers: (p.faceDancers ?? []) revealedFaceDancers: (p.faceDancers ?? [])
.filter((c) => c.revealed) .filter((c) => c.revealed)
+24 -3
View File
@@ -15,7 +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 { planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
import { validateAmbassadors } from './ecaz-ambassadors'; import { validateAmbassadors } from './ecaz-ambassadors';
import { import {
homeworldMobileStrongholdMovementBlock, homeworldMobileStrongholdMovementBlock,
@@ -53,7 +53,8 @@ export type MovementCancellationQuote =
| 'advisorBattle' | 'advisorBattle'
| 'movementTurn' | 'movementTurn'
| 'discoveryEntry' | 'discoveryEntry'
| 'ambassador'; | 'ambassador'
| 'planetologistGather';
}; };
function sectorKey(g: Game, value: unknown): value is string { function sectorKey(g: Game, value: unknown): value is string {
@@ -87,6 +88,7 @@ function movementDeclaration(
g: Game, g: Game,
response: ResponseWindow, response: ResponseWindow,
move: Move | null | undefined, move: Move | null | undefined,
territoryOnly = false,
) { ) {
const owner = g.players.find((p) => p.id === response.owner)!; const owner = g.players.find((p) => p.id === response.owner)!;
requireContext( requireContext(
@@ -97,7 +99,7 @@ function movementDeclaration(
move.player === owner.id && move.player === owner.id &&
sectorKey(g, location(move.to, move.sector)) && sectorKey(g, location(move.to, move.sector)) &&
territoryId(g, move.origin) && territoryId(g, move.origin) &&
response.location === location(move.to, move.sector) && response.location === (territoryOnly ? move.to : location(move.to, move.sector)) &&
response.amount === move.total && response.amount === move.total &&
integer(move.total) && integer(move.total) &&
move.total > 0 && move.total > 0 &&
@@ -421,9 +423,28 @@ export function quoteMovementCancellation(
response.advisorResume === 'declaration' || response.advisorResume === 'declaration' ||
response.advisorResume === 'wormRide' || response.advisorResume === 'wormRide' ||
response.advisorResume === 'discoveryEntry' || response.advisorResume === 'discoveryEntry' ||
response.advisorResume === 'planetologistGather' ||
response.advisorResume === 'ambassador'), response.advisorResume === 'ambassador'),
'This advisor cancellation has no valid owner, territory or continuation.', 'This advisor cancellation has no valid owner, territory or continuation.',
); );
if (response.advisorResume === 'planetologistGather') {
const pending = response.advisorGatherMove;
requireContext(pending && record(pending) && pending.turn === g.turn &&
pending.move === owner.moved && pending.order?.planetologist?.mode === 'gather' &&
response.advisors === false && pending.order.advisors === false &&
pending.order.wantsFighters === false && response.advisorFollowup === undefined &&
response.advisorRemaining === undefined && response.advisorAmbassadorEvent === undefined &&
response.discoveryEntry === undefined,
'The mixed advisor gather has lost its original movement and flip declaration.');
movementDeclaration(g, response, pending.order, true);
const entry = planetologistGatherAdvisorEntry(g, owner, pending.order.origins!, pending.order.to);
requireContext(entry.requiresFlip && !entry.blocked &&
pending.order.group.every(([key, count]) => count <= (owner.forces[key] ?? 0)),
entry.blocked ?? 'The mixed advisor gather no longer matches its original physical groups.');
return { kind: 'advisorFlip', successor: 'planetologistGather' };
}
requireContext(response.advisorGatherMove === undefined,
'Only the declared Planetologist gather may carry its original movement.');
if (response.advisorResume === 'discoveryEntry') { if (response.advisorResume === 'discoveryEntry') {
requireContext(g.phase === 0 && g.discoveryEnabled && g.discoveries && g.discoveryEntry && requireContext(g.phase === 0 && g.discoveryEnabled && g.discoveries && g.discoveryEntry &&
response.discoveryEntry && response.advisors === true && response.discoveryEntry && response.advisors === true &&
+40 -1
View File
@@ -1,8 +1,47 @@
import { FACTIONS } from './catalog'; import { FACTIONS } from './catalog';
import { nativeExpansionLeaderSkillsProfile } from './leader-skill-profile'; import { nativeExpansionLeaderSkillsProfile } from './leader-skill-profile';
import type { Game, GameView } from './engine'; import type { Game, GameView, Player } from './engine';
import { splitLocation } from './board'; import { splitLocation } from './board';
import { isAdvisor } from './advisors';
import { presenceAt, type ForcePresence } from './force-presence';
type MovementPlayer = Pick<Player, 'id' | 'faction' | 'advisors'> & ForcePresence & {
moved?: number;
advisorFlipMovementBlocked?: boolean | { turn: number; move: number };
};
type MovementGame = Pick<Game, 'advanced' | 'turn'> & {
players: readonly (Pick<Player, 'id'> & ForcePresence)[];
};
type AdvisorGatherEntry = Readonly<{ requiresFlip: boolean; blocked: string | null }>;
const noAdvisorGather: AdvisorGatherEntry = { requiresFlip: false, blocked: null };
const fighterAdvisorGather: AdvisorGatherEntry = { requiresFlip: true, blocked: null };
const freshAdvisorGather: AdvisorGatherEntry = {
requiresFlip: true, blocked: 'Fresh spiritual advisors cannot become fighters during this turn.',
};
const canceledAdvisorGather: AdvisorGatherEntry = {
requiresFlip: true, blocked: 'Advisor flipping was canceled for this movement. Choose a legal replacement without flipping advisors.',
};
/** The canceled native flip stays unavailable for this movement opportunity. */
export function advisorFlipMovementBlocked(game: Pick<Game, 'turn'>, player: MovementPlayer): boolean {
const blocked = player.advisorFlipMovementBlocked;
return typeof blocked === 'boolean' ? blocked :
blocked?.turn === game.turn && blocked.move === player.moved;
}
/** Provisional simultaneous gather: declare one common fighter arrival before
* either source moves; native cancellation keeps both original groups in place. */
export function planetologistGatherAdvisorEntry(game: MovementGame, player: MovementPlayer,
origins: readonly string[], destination: string): AdvisorGatherEntry {
if (!game.advanced || player.faction !== 'beneGesserit' || origins.length !== 2 ||
isAdvisor(player, origins[0]) === isAdvisor(player, origins[1]) ||
presenceAt(player, destination) ||
!game.players.some(other => other.id !== player.id && presenceAt(other, destination)))
return noAdvisorGather;
if (origins.some(origin => player.advisors?.[origin]?.lockedTurn === game.turn))
return freshAdvisorGather;
return advisorFlipMovementBlocked(game, player) ? canceledAdvisorGather : fighterAdvisorGather;
}
export type PlanetologistMoveMode = 'range' | 'gather'; export type PlanetologistMoveMode = 'range' | 'gather';
export type PlanetologistMovement = { export type PlanetologistMovement = {
leader: string; leader: string;
+2 -2
View File
@@ -4944,7 +4944,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 while you have no Skill Card, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Under the 13 October 2026 ruling the original faction keeps its assignment while its trained leader is captured, so reviving a different own leader is legal but draws no replacement while that card exists.', 'When reviving an own leader while you have no Skill Card, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Under the 13 October 2026 ruling the original faction keeps its assignment while its trained leader is captured, so reviving a different own leader is legal but draws no replacement while that card exists.',
'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.', '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. Provisional mixed BG advisor/fighter gather into an enemy-only territory declares one common fighter arrival before either group moves. Native Karama cancellation keeps both sources and the movement allowance unchanged, and blocks another advisor flip for that movement opportunity; fresh-advisor locks remain. Empty or already BG-occupied destinations keep their original common stance. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special without its ordinary effect. Other combined modules remain unfinished.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.', 'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.', 'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.', 'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
@@ -4958,7 +4958,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.', 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.',
'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.', 'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.',
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.', 'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income is mandatory in fresh supported ordinary Skills setups, with provisional original-earned-faction custody and no retrofit of existing games. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.', 'Physical Fremen Ambassador relocation offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement retain their own limits. Normal Banker income is mandatory in fresh supported ordinary Skills setups, with provisional original-earned-faction custody and no retrofit. Diplomat retreat retains its conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz coalition skills follow their bounded contracts; shared-Duke assignment, exceptional roles and other combined modules remain unfinished. Normal module starts are labelled previews, not complete-mode acceptance.',
...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: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { gameTerritories, location } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { botGroundMoveAllowed } from '../game/bot-mobility';
import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills';
import { nextHarassHomeworldStep, harassHomeworldClean } from './fixture-harass-homeworld';
export type AdvisorGatherStep = { actor: string; action: Action };
export const advisorGatherPlayer = (game: Game, actor = 'beneGesserit') => {
const player = game.players.find(player => player.id === actor);
assert.ok(player); return player;
};
/** Original normal lobby and native actions only. The lottery selects an actual
* offered Planetologist; no counters, posture, money, phases or usage is staged.
* The only conserved card input is one explicitly labelled original Karama. */
export function createPlanetologistAdvisorGatherFixture(options: { fresh?: boolean } = {}) {
const setup = createSelectedDeckSkillsSetup({ advanced: true,
factions: ['beneGesserit', 'emperor', 'atreides'], expansions: [], skill: 'planetologist' });
let game = structuredClone(setup.afterSetup);
const actions: AdvisorGatherStep[] = [...setup.actions], staging = [...setup.staging];
const owner = 'beneGesserit', canceler = 'emperor', opponent = 'atreides';
const targetTurn = options.fresh ? 1 : 2;
const until = () => game.turn === targetTurn && game.phase === 5 && game.active === owner &&
harassHomeworldClean(game) && (!options.fresh || advisorGatherPlayer(game).shipped);
for (let count = 0; count < 600 && !until(); count++) {
assert.equal(game.status, 'playing');
let step: AdvisorGatherStep | null = null;
const decision = !game.response && !game.phaseOpening ? game.decision : null;
if (decision?.kind === 'advisor') {
const accompany = game.turn === 1 && decision.shipment === canceler;
step = { actor: decision.player, action: accompany
? { type: 'decision', accept: true, accompany: true, sector: 9, amount: 1 }
: { type: 'decision', accept: false } };
} else if (decision?.kind === 'advisorBattle') {
step = { actor: decision.player, action: { type: 'decision', accept: false } };
} else if (game.turn === 1 && game.phase === 5 && harassHomeworldClean(game)) {
const actor = game.active!, player = advisorGatherPlayer(game, actor);
if (actor === owner && !player.shipped)
step = { actor, action: { type: 'ship', territory: 'carthag', sector: 11,
amount: 1, elite: 0, allyPayment: 0 } };
else if (actor === canceler && !player.shipped)
step = { actor, action: { type: 'ship', territory: 'imperial_basin', sector: 9,
amount: 1, elite: 0, allyPayment: 0 } };
else if (actor === opponent && !player.moved) {
const view = viewGame(game, actor), own = view.players.find(player => player.id === actor)!;
let destination: { territory: string; sector: number } | null = null;
for (const territory of gameTerritories(game)) {
if (territory.type !== 'rock' || game.players.some(player => presenceAt(player, territory.id))) continue;
const sector = territory.sectors.find(sector =>
botGroundMoveAllowed(view, own, 'arrakeen:10', location(territory.id, sector), 0));
if (sector !== undefined) { destination = { territory: territory.id, sector }; break; }
}
assert.ok(destination, 'The original Atreides army keeps one defender and moves nine to empty rock');
step = { actor, action: { type: 'move', from: 'arrakeen:10', amount: 9, ...destination } };
}
}
step ??= nextHarassHomeworldStep(game);
assert.ok(step, `The native gather chronology needs a legal action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
if (game.turn === 1 && step.action.type === 'stormDial')
step.action.amount = options.fresh && step.actor === owner ? 2 : 0;
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
assert.ok(until(), 'Reach the original BG movement turn with both physical source groups');
const me = advisorGatherPlayer(game);
assert.equal(me.forces['carthag:11'], 1);
assert.equal(me.forces['imperial_basin:9'], 1);
assert.equal(me.advisors?.imperial_basin.lockedTurn, 1);
assert.equal(advisorGatherPlayer(game, opponent).forces['arrakeen:10'], 1);
let karama = advisorGatherPlayer(game, canceler).hand.find(card => card.effect === 'karama');
if (!karama) {
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0, 'A conserved original Karama must remain in the ordinary deck');
assert.ok(advisorGatherPlayer(game, canceler).hand.length < 4);
karama = game.deck.splice(index, 1)[0];
advisorGatherPlayer(game, canceler).hand.push(karama);
staging.push(`Conserved ${karama.id}: original deck → Emperor hand; not a purchase or new card`);
}
const forces = { 'carthag:11': 1, 'imperial_basin:9': 1 };
const action: Action = { type: 'move', planetologist: 'gather', fighters: true,
forces, territory: 'arrakeen', sector: 10 };
return { initial: setup.initial, afterSetup: setup.afterSetup, game, owner, canceler, opponent,
forces, action, karama: karama.id, actions, staging };
}
/** The normal native response, including automatic passes, not a forced flip. */
export function allowPlanetologistAdvisorGather(game: Game): Game {
let state = structuredClone(game);
for (let count = 0; state.response && count < 12; count++) {
assert.equal(state.response.kind, 'advisorFlip');
const actor = state.players.find(player => !state.response!.passed.includes(player.id));
assert.ok(actor);
state = applyAction(state, actor.id, { type: 'passResponse' });
}
assert.equal(state.response, null);
return state;
}
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, RuleError, viewGame } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { planetologistMoveDraft } from '../components/planetologist-movement';
import {
advisorGatherPlayer, allowPlanetologistAdvisorGather, createPlanetologistAdvisorGatherFixture,
} from './fixture-planetologist-advisor-gather';
void test('a real mixed gather declares one native flip before either source moves, then arrives as fighters for one movement', () => {
const f = createPlanetologistAdvisorGatherFixture();
// Advisor first: simultaneous physical custody cannot depend on source order.
const draft = planetologistMoveDraft(viewGame(f.game, f.owner), 'gather', 'arrakeen', 10,
{ 'imperial_basin:9': 1, 'carthag:11': 1 }, {});
assert.equal(draft.blocked, null);
assert.ok(draft.action);
const before = advisorGatherPlayer(f.game), rival = advisorGatherPlayer(f.game, f.opponent);
const pending = applyAction(f.game, f.owner, draft.action);
const paused = advisorGatherPlayer(pending);
assert.equal(pending.response?.kind, 'advisorFlip');
assert.deepEqual(paused.forces, before.forces);
assert.deepEqual(paused.advisors, before.advisors);
assert.equal(paused.moved, before.moved);
assert.equal(paused.shipped, before.shipped);
assert.equal(paused.spice, before.spice);
const done = allowPlanetologistAdvisorGather(pending), after = advisorGatherPlayer(done);
assert.equal(after.forces['arrakeen:10'], 2);
assert.equal(after.forces['carthag:11'] ?? 0, 0);
assert.equal(after.forces['imperial_basin:9'] ?? 0, 0);
assert.equal(isAdvisor(after, 'arrakeen'), false);
assert.equal(after.moved, before.moved + 1);
assert.equal(after.shipped, true);
assert.equal(after.spice, before.spice);
assert.equal(after.reserves, before.reserves);
assert.equal(after.tanks, before.tanks);
assert.deepEqual(advisorGatherPlayer(done, f.opponent).forces, rival.forces);
assert.equal(advisorGatherPlayer(done, f.canceler).hand.filter(card => card.id === f.karama).length, 1,
'Allowing the native flip does not spend the opponent’s physical counter.');
});
void test('one original Karama cancels the entire uncommitted mixed gather and a legal fighter-only replacement retains the movement', () => {
const f = createPlanetologistAdvisorGatherFixture();
const before = advisorGatherPlayer(f.game);
const pending = applyAction(f.game, f.owner, f.action);
const canceled = applyAction(pending, f.canceler, { type: 'card', card: f.karama, mode: 'cancel' });
const stopped = advisorGatherPlayer(canceled);
assert.deepEqual(stopped.forces, before.forces);
assert.deepEqual(stopped.advisors, before.advisors);
assert.equal(stopped.moved, before.moved);
assert.equal(stopped.shipped, before.shipped);
assert.equal(stopped.spice, before.spice);
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
assert.ok(canceled.players.every(player => player.hand.every(card => card.id !== f.karama)));
const snapshot = structuredClone(canceled);
assert.throws(() => applyAction(canceled, f.owner, f.action), RuleError);
assert.deepEqual(canceled, snapshot, 'An immediate repeated flip cannot bypass the spent Karama.');
const retry = planetologistMoveDraft(viewGame(canceled, f.owner), 'gather', 'arrakeen', 10, f.forces, {});
assert.equal(retry.action, null);
const replacement = applyAction(canceled, f.owner, {
type: 'move', from: 'carthag:11', amount: 1, territory: 'arrakeen', sector: 10,
});
const after = advisorGatherPlayer(replacement);
assert.equal(after.forces['arrakeen:10'], 1);
assert.equal(after.forces['imperial_basin:9'], 1);
assert.equal(isAdvisor(after, 'imperial_basin'), true);
assert.equal(isAdvisor(after, 'arrakeen'), false);
assert.equal(after.moved, before.moved + 1);
assert.equal(after.spice, before.spice);
assert.equal(replacement.discard.filter(card => card.id === f.karama).length, 1);
});
void test('a genuine fresh accompanying advisor cannot use mixed gather to become a fighter during its arrival turn', () => {
const f = createPlanetologistAdvisorGatherFixture({ fresh: true });
const draft = planetologistMoveDraft(viewGame(f.game, f.owner), 'gather', 'arrakeen', 10, f.forces, {});
assert.equal(draft.action, null);
const snapshot = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.owner, f.action), RuleError);
assert.deepEqual(f.game, snapshot);
assert.equal(advisorGatherPlayer(f.game).forces['imperial_basin:9'], 1);
assert.equal(isAdvisor(advisorGatherPlayer(f.game), 'imperial_basin'), true);
assert.equal(advisorGatherPlayer(f.game).moved, 0);
assert.equal(advisorGatherPlayer(f.game, f.canceler).hand.filter(card => card.id === f.karama).length, 1);
});
+2 -89
View File
@@ -1,7 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { import {
applyAction, applyAction,
createGame, createGame,
@@ -9,10 +7,7 @@ import {
viewGame, viewGame,
type Game, type Game,
} from '../game/engine'; } from '../game/engine';
import { import { planetologistMoveDraft } from '../components/planetologist-movement';
PlanetologistMovement,
planetologistMoveDraft,
} from '../components/planetologist-movement';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { location } from '../game/board'; import { location } from '../game/board';
@@ -74,34 +69,8 @@ function fixture(): Game {
return g; 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', () => { void test('range and gather controls settle the selected physical normal and elite counters through the engine', () => {
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 g = fixture();
const view = viewGame(g, 'p'); const view = viewGame(g, 'p');
const range = planetologistMoveDraft( const range = planetologistMoveDraft(
@@ -113,15 +82,6 @@ void test('drafts send only the public mode and exact physical counts, and both
{ [rangeSource]: 1 }, { [rangeSource]: 1 },
); );
assert.equal(range.blocked, null); 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!); const ranged = applyAction(structuredClone(g), 'p', range.action!);
assert.equal(ranged.players[0].forces[`${destination}:0`], 2); assert.equal(ranged.players[0].forces[`${destination}:0`], 2);
assert.equal(ranged.players[0].elites!.forces[`${destination}:0`], 1); assert.equal(ranged.players[0].elites!.forces[`${destination}:0`], 1);
@@ -135,55 +95,8 @@ void test('drafts send only the public mode and exact physical counts, and both
{ [gatherSources[0]]: 1, [gatherSources[1]]: 2 }, { [gatherSources[0]]: 1, [gatherSources[1]]: 2 },
); );
assert.equal(gather.blocked, null); 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!); const gathered = applyAction(structuredClone(g), 'p', gather.action!);
assert.equal(gathered.players[0].forces[`${destination}:0`], 5); assert.equal(gathered.players[0].forces[`${destination}:0`], 5);
assert.equal(gathered.players[0].elites!.forces[`${destination}:0`], 3); 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/,
);
});
-6
View File
@@ -380,12 +380,6 @@ void test('Bene Gesserit gather resolves determined mixed-source stances and pre
assert.equal(next.players[0].forces[second], 1); assert.equal(next.players[0].forces[second], 1);
custody(next); 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 uniform = fixture('beneGesserit', true);
const draft = gather(uniform); const draft = gather(uniform);
uniform.players[0].advisors = { uniform.players[0].advisors = {