Implement mixed Planetologist advisor gather continuation
This commit is contained in:
1 parent
bc37cd38fe
commit
c265f0638e
16 files changed
+512
-171
No files matched your search
@@ -97,7 +97,7 @@ import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal';
|
||||
import { OrnithopterMovement } from './ornithopter-movement';
|
||||
import { DiscoveryOrnithopterMovement } from './discovery-flight-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 { SandmasterWormChoice } from './sandmaster-worm';
|
||||
import { sandmasterWormCollection } from '@/game/sandmaster-worm';
|
||||
@@ -796,6 +796,7 @@ export function GameTable({
|
||||
sector,
|
||||
...(includesNoField ? {} : { amount }),
|
||||
};
|
||||
const advisorFlipRouteBlocked = ordinaryMovementAction.fighters === true && advisorFlipMovementBlocked(g, me);
|
||||
const transportAction: Action = {
|
||||
type: 'guildShip',
|
||||
...(useGuildSecret ? {nexus: guildSecretEvent} : {}),
|
||||
@@ -2583,6 +2584,14 @@ export function GameTable({
|
||||
unchanged.
|
||||
</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">
|
||||
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.
|
||||
@@ -4792,6 +4801,12 @@ export function GameTable({
|
||||
territory
|
||||
</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 && (
|
||||
<>
|
||||
{actionButton(
|
||||
@@ -4802,18 +4817,21 @@ export function GameTable({
|
||||
!source ||
|
||||
canceledFremenRouteBlocked ||
|
||||
ordinaryMovementRouteBlocked ||
|
||||
advisorFlipRouteBlocked ||
|
||||
selectedDestinationInStorm ||
|
||||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
|
||||
(includesNoField
|
||||
? !validMarkerGroup
|
||||
: (me.forces[source] ?? 0) === 0),
|
||||
selectedDestinationInStorm
|
||||
? 'movement-storm-unavailable'
|
||||
: ordinaryMovementRouteBlocked
|
||||
? 'movement-route-unavailable'
|
||||
: canceledFremenRouteBlocked
|
||||
? 'movement-range-unavailable'
|
||||
: undefined,
|
||||
advisorFlipRouteBlocked
|
||||
? 'movement-advisor-flip-unavailable'
|
||||
: selectedDestinationInStorm
|
||||
? 'movement-storm-unavailable'
|
||||
: ordinaryMovementRouteBlocked
|
||||
? 'movement-route-unavailable'
|
||||
: canceledFremenRouteBlocked
|
||||
? 'movement-range-unavailable'
|
||||
: undefined,
|
||||
)}
|
||||
{canceledFremenRouteBlocked &&
|
||||
!selectedDestinationInStorm && (
|
||||
@@ -4879,6 +4897,7 @@ export function GameTable({
|
||||
move={
|
||||
!source ||
|
||||
!movementAvailable ||
|
||||
advisorFlipRouteBlocked ||
|
||||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
|
||||
(includesNoField
|
||||
? !validMarkerGroup
|
||||
@@ -4901,7 +4920,7 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
move={
|
||||
!source || !movementAvailable ||
|
||||
!source || !movementAvailable || advisorFlipRouteBlocked ||
|
||||
(includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0)
|
||||
? null
|
||||
: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
planetologistLeader,
|
||||
planetologistMovementModeSupported,
|
||||
selectedOriginElites,
|
||||
planetologistGatherAdvisorEntry,
|
||||
type PlanetologistMoveMode,
|
||||
} from '@/game/planetologist-movement';
|
||||
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
||||
@@ -15,8 +16,6 @@ import {
|
||||
splitLocation,
|
||||
territory,
|
||||
} from '@/game/board';
|
||||
import { isAdvisor } from '@/game/advisors';
|
||||
import { presenceAt } from '@/game/force-presence';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
@@ -76,20 +75,8 @@ export function planetologistMoveDraft(
|
||||
? '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 entry = planetologistGatherAdvisorEntry(game, me, [...origins], destination);
|
||||
if (entry.blocked) return { action: null, blocked: entry.blocked };
|
||||
const target = location(destination, sector);
|
||||
if (
|
||||
selected.some(
|
||||
@@ -109,6 +96,7 @@ export function planetologistMoveDraft(
|
||||
action: {
|
||||
type: 'move',
|
||||
planetologist: mode,
|
||||
...(entry.requiresFlip ? { fighters: true } : {}),
|
||||
forces: Object.fromEntries(selected),
|
||||
...(me.elites
|
||||
? {
|
||||
@@ -196,6 +184,14 @@ export function PlanetologistMovement({
|
||||
Destination: {territory(destination).name}, sector {sector}. Select the
|
||||
physical forces to move below.
|
||||
</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' && (
|
||||
<p className="fine">
|
||||
Each origin uses its own selected cyborgs for movement range. Cyborgs
|
||||
|
||||
+23
-1
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
World/phase starts rejected before; all18 now start. Standalone/paired
|
||||
Ecaz/Moritani/classic Worlds retains optional all14/cards and original phase
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# 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
|
||||
|
||||
Fourteen of18 normal World/phase starts rejected before; all18 now start.
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
This supersedes the earlier17 October non-World E3 card boundary below.
|
||||
|
||||
+60
-10
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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),
|
||||
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.
|
||||
### Mixed advisor and fighter gather — 18 October 2026
|
||||
|
||||
The earlier [November FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
and [base rules](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
review supplies the original determined-destination stance. The authorized
|
||||
local revision `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, physical28 /
|
||||
logical26, likewise requires one BG stance per territory, matching existing
|
||||
BG forces, and permits moved advisors to flip in an otherwise enemy-held
|
||||
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),
|
||||
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,
|
||||
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
|
||||
|
||||
The final focused union passes 84 cases, including the 24-case Planetologist
|
||||
|
||||
+14
-1
@@ -1,6 +1,19 @@
|
||||
# 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
|
||||
|
||||
|
||||
+5
-8
@@ -97,7 +97,7 @@ import {
|
||||
fremenReserveEntry,
|
||||
guildTransportCost,
|
||||
} from './bot-mobility';
|
||||
import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
|
||||
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
@@ -3681,13 +3681,7 @@ function policyActions(g: GameView): Action[] {
|
||||
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;
|
||||
if (aTerritory === bTerritory) continue;
|
||||
for (const to of usefulTargets
|
||||
.filter(
|
||||
(target) =>
|
||||
@@ -3709,10 +3703,13 @@ function policyActions(g: GameView): Action[] {
|
||||
),
|
||||
)
|
||||
.slice(0, 24)) {
|
||||
const entry = planetologistGatherAdvisorEntry(g, me, [aTerritory, bTerritory], to.t);
|
||||
if (entry.blocked) continue;
|
||||
groundMoves.push({
|
||||
action: {
|
||||
type: 'move',
|
||||
planetologist: 'gather',
|
||||
...(entry.requiresFlip ? { fighters: true } : {}),
|
||||
forces: { [a.from]: a.amount, [b.from]: b.amount },
|
||||
...(me.elites
|
||||
? {
|
||||
|
||||
+49
-25
@@ -18,7 +18,7 @@ import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './t
|
||||
import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders';
|
||||
import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board';
|
||||
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 { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
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>;
|
||||
ixMovementBlocked?: { turn: number; move: number };
|
||||
fremenMovementBlocked?: { turn: number; move: number };
|
||||
advisorFlipMovementBlocked?: { turn: number; move: number };
|
||||
fremenNexusMovementBlockedTurn?: number;
|
||||
fremenNexusWormBlockedTurn?: number;
|
||||
/** 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. */
|
||||
guildRateEvent?: string;
|
||||
guildTransportEvent?: string;
|
||||
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
|
||||
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
|
||||
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorRemaining?: string[];
|
||||
@@ -8481,7 +8483,8 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
|
||||
!p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined &&
|
||||
p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === 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.atomicsHandLimitPenalty === undefined && p.tupileHandSlot === undefined && p.faceDancers === undefined &&
|
||||
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
||||
@@ -15623,20 +15626,22 @@ function movementOrigins(move: Pick<MovementOrder, 'origin' | 'origins'>): strin
|
||||
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 gather = planetologistGatherAdvisorEntry(g, p, origins, to);
|
||||
requireRule(!gather.blocked, gather.blocked ?? 'The mixed gather cannot flip its advisors.');
|
||||
requireRule(!gather.requiresFlip || fighters,
|
||||
'Declare a common fighter arrival for a mixed advisor/fighter gather.');
|
||||
const advisors = !gather.requiresFlip && 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 || !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) &&
|
||||
(!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))),
|
||||
'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) {
|
||||
if (move.discoveryFlight)
|
||||
@@ -15772,7 +15777,8 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
'The declared force group no longer matches its movement.',
|
||||
);
|
||||
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 &&
|
||||
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(
|
||||
g: Game,
|
||||
details: {
|
||||
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry';
|
||||
advisorResume?: ResponseWindow['advisorResume'];
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorRemaining?: string[];
|
||||
@@ -26271,19 +26277,31 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
response.charityHomeworld ?? 0,
|
||||
);
|
||||
} else if (response.kind === 'advisorFlip') {
|
||||
if (canceled) movementCancellationQuote(g, response);
|
||||
const bg = getPlayer(g, response.owner),
|
||||
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'}.`,
|
||||
);
|
||||
const bg = getPlayer(g, response.owner);
|
||||
if (response.advisorResume === 'planetologistGather') {
|
||||
movementCancellationQuote(g, response);
|
||||
const pending = response.advisorGatherMove!;
|
||||
if (canceled) {
|
||||
bg.advisorFlipMovementBlocked = { turn: pending.turn, move: pending.move };
|
||||
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.`);
|
||||
} else {
|
||||
validateMovementOrder(g, bg, pending.order);
|
||||
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') {
|
||||
const capture = g.pendingCapture!;
|
||||
g.pendingCapture = null;
|
||||
@@ -33676,7 +33694,7 @@ function applyActionInner(
|
||||
} = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1);
|
||||
const to = stringField(action.territory),
|
||||
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(
|
||||
origins.every(source => !balisetPrevents(g, id, source, to)),
|
||||
'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.`,
|
||||
);
|
||||
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 &&
|
||||
p.faction === 'ixians' &&
|
||||
elite > 0 &&
|
||||
@@ -35167,6 +35190,7 @@ export function viewGame(state: Game, id: string) {
|
||||
ixMovementBlocked:
|
||||
p.ixMovementBlocked?.turn === g.turn &&
|
||||
p.ixMovementBlocked.move === p.moved,
|
||||
advisorFlipMovementBlocked: advisorFlipMovementBlocked(g, p),
|
||||
revealedTraitors: p.revealedTraitors ?? [],
|
||||
revealedFaceDancers: (p.faceDancers ?? [])
|
||||
.filter((c) => c.revealed)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
MOBILE_STRONGHOLD,
|
||||
} from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { planetologistLeader } from './planetologist-movement';
|
||||
import { planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
|
||||
import { validateAmbassadors } from './ecaz-ambassadors';
|
||||
import {
|
||||
homeworldMobileStrongholdMovementBlock,
|
||||
@@ -53,7 +53,8 @@ export type MovementCancellationQuote =
|
||||
| 'advisorBattle'
|
||||
| 'movementTurn'
|
||||
| 'discoveryEntry'
|
||||
| 'ambassador';
|
||||
| 'ambassador'
|
||||
| 'planetologistGather';
|
||||
};
|
||||
|
||||
function sectorKey(g: Game, value: unknown): value is string {
|
||||
@@ -87,6 +88,7 @@ function movementDeclaration(
|
||||
g: Game,
|
||||
response: ResponseWindow,
|
||||
move: Move | null | undefined,
|
||||
territoryOnly = false,
|
||||
) {
|
||||
const owner = g.players.find((p) => p.id === response.owner)!;
|
||||
requireContext(
|
||||
@@ -97,7 +99,7 @@ function movementDeclaration(
|
||||
move.player === owner.id &&
|
||||
sectorKey(g, location(move.to, move.sector)) &&
|
||||
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 &&
|
||||
integer(move.total) &&
|
||||
move.total > 0 &&
|
||||
@@ -421,9 +423,28 @@ export function quoteMovementCancellation(
|
||||
response.advisorResume === 'declaration' ||
|
||||
response.advisorResume === 'wormRide' ||
|
||||
response.advisorResume === 'discoveryEntry' ||
|
||||
response.advisorResume === 'planetologistGather' ||
|
||||
response.advisorResume === 'ambassador'),
|
||||
'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') {
|
||||
requireContext(g.phase === 0 && g.discoveryEnabled && g.discoveries && g.discoveryEntry &&
|
||||
response.discoveryEntry && response.advisors === true &&
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { nativeExpansionLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import type { Game, GameView } from './engine';
|
||||
import type { Game, GameView, Player } from './engine';
|
||||
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 PlanetologistMovement = {
|
||||
leader: string;
|
||||
|
||||
+2
-2
@@ -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.',
|
||||
'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.',
|
||||
'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.',
|
||||
'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.',
|
||||
@@ -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.',
|
||||
'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.',
|
||||
'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(' ')}`),
|
||||
],
|
||||
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);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
@@ -9,10 +7,7 @@ import {
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
PlanetologistMovement,
|
||||
planetologistMoveDraft,
|
||||
} from '../components/planetologist-movement';
|
||||
import { planetologistMoveDraft } from '../components/planetologist-movement';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { location } from '../game/board';
|
||||
|
||||
@@ -74,34 +69,8 @@ function fixture(): Game {
|
||||
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', () => {
|
||||
void test('range and gather controls settle the selected physical normal and elite counters through the engine', () => {
|
||||
const g = fixture();
|
||||
const view = viewGame(g, 'p');
|
||||
const range = planetologistMoveDraft(
|
||||
@@ -113,15 +82,6 @@ void test('drafts send only the public mode and exact physical counts, and both
|
||||
{ [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);
|
||||
@@ -135,55 +95,8 @@ void test('drafts send only the public mode and exact physical counts, and both
|
||||
{ [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/,
|
||||
);
|
||||
});
|
||||
@@ -380,12 +380,6 @@ void test('Bene Gesserit gather resolves determined mixed-source stances and pre
|
||||
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 = {
|
||||
|
||||
Reference in new issue
Block a user