Connect owned Sandmaster destination pile choices

This commit is contained in:
Quintine committed 2026-10-09 04:51:12 +11:00
1 parent 24863480cb
commit 0040ed1921
17 files changed
+458 -89

No files matched your search

+44 -1
View File
@@ -31,6 +31,11 @@ export function AmbassadorMovement({
const [takeMarker, setTakeMarker] = useState(false);
const [fighters, setFighters] = useState(false);
const [collectSandmaster, setCollectSandmaster] = useState(true);
const [sandmasterSelection, setSandmasterSelection] = useState<{
source: string;
target: string;
key: string;
} | null>(null);
const entry = game.ambassadorEntry;
if (
entry?.stage !== 'move' ||
@@ -83,6 +88,12 @@ export function AmbassadorMovement({
const sandmaster = !includesMarker && destination
? sandmasterDestinationCollection(game, game.me, source.territory, destination.territory, destination.sector)
: null;
const sandmasterPile =
sandmasterSelection?.source === source.territory &&
sandmasterSelection.target === target &&
sandmaster?.piles.some((pile) => pile.key === sandmasterSelection.key)
? sandmasterSelection.key
: sandmaster?.key;
const reason =
destination?.blocked ??
(!destination
@@ -98,6 +109,7 @@ export function AmbassadorMovement({
setForces((old) => ({ ...old, [key]: 0 }));
setElites((old) => ({ ...old, [key]: 0 }));
setFighters(false);
setSandmasterSelection(null);
};
const targetTerritories = [...new Set(destinations.map((d) => d.territory))];
return (
@@ -123,6 +135,7 @@ export function AmbassadorMovement({
setElites({});
setTakeMarker(false);
setFighters(false);
setSandmasterSelection(null);
}}
>
{sources.map((s) => (
@@ -286,6 +299,31 @@ export function AmbassadorMovement({
{sandmaster.blocked ?? 'Collect 1 spice here with Sandmaster before arrival reactions'}
</label>
)}
{sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (
<label className="flex flex-col gap-2" htmlFor={`${id}-sandmaster-pile`}>
Sandmaster debit sector — provisional owner allocation
<select
id={`${id}-sandmaster-pile`}
className="min-h-11"
disabled={busy || !collectSandmaster}
value={sandmasterPile ?? ''}
onChange={(event) => setSandmasterSelection({
source: source.territory,
target,
key: event.target.value,
})}
>
{sandmaster.piles.map((pile) => (
<option key={pile.key} value={pile.key}>
Sector {splitLocation(pile.key).sector}: {pile.before} → {pile.before - 1}
</option>
))}
</select>
</label>
)}
{sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (
<p className="fine">You choose the one-spice debit sector. This provisional allocation policy is not a publisher ruling.</p>
)}
<p className="m-0 text-sm" aria-live="polite">
Selected: {total} physical forces
{includesMarker ? ' and one concealed No-Field' : ''}.
@@ -317,7 +355,12 @@ export function AmbassadorMovement({
}
: {}),
...(fighters && destination.canFight ? { fighters: true } : {}),
...(sandmaster?.blocked === null ? { sandmasterCollect: collectSandmaster } : {}),
...(sandmaster?.blocked === null
? {
sandmasterCollect: collectSandmaster,
...(collectSandmaster && sandmasterPile ? { sandmasterPile } : {}),
}
: {}),
});
}}
>
+12 -3
View File
@@ -493,7 +493,13 @@ export function GameTable({
const [moveForces, setMoveForces] = useState<Record<string, number>>({});
const [rideForces, setRideForces] = useState<Record<string, number>>({});
const [collectSandmasterRide, setCollectSandmasterRide] = useState(true);
const sandmasterRide = sandmasterWormCollection(g, me.id, selected, sector);
const [sandmasterRidePile, setSandmasterRidePile] = useState<{ target: string; key: string } | null>(null);
const sandmasterRideTarget = JSON.stringify([g.turn, g.decision?.kind === 'wormRide' ? g.decision : null, selected, sector]);
const baseSandmasterRide = sandmasterWormCollection(g, me.id, selected, sector);
const selectedRidePile = sandmasterRidePile?.target === sandmasterRideTarget
? baseSandmasterRide?.piles.find(pile => pile.key === sandmasterRidePile.key) : null;
const sandmasterRide = selectedRidePile && baseSandmasterRide
? { ...baseSandmasterRide, key: selectedRidePile.key, before: selectedRidePile.before } : baseSandmasterRide;
const [exchangeCards, setExchangeCards] = useState<Record<string, boolean>>(
{},
);
@@ -3646,10 +3652,13 @@ export function GameTable({
/>
))}
<SandmasterWormChoice quote={sandmasterRide} collect={collectSandmasterRide}
onChange={setCollectSandmasterRide} />
onChange={setCollectSandmasterRide}
onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} />
{actionButton('Ride Shai-Hulud', {
type: 'decision',
...(sandmasterRide?.blocked === null ? { sandmasterCollect: collectSandmasterRide } : {}),
...(sandmasterRide?.blocked === null
? { sandmasterCollect: collectSandmasterRide,
...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}),
eliteForces,
accept: true,
territory: selected,
+17 -3
View File
@@ -1,17 +1,31 @@
import { splitLocation } from '@/game/board';
import type { SandmasterDestinationCollection } from '@/game/sandmaster-movement';
export function SandmasterWormChoice({ quote, collect, onChange }: {
export function SandmasterWormChoice({ quote, collect, onChange, onPileChange }: {
quote: SandmasterDestinationCollection | null;
collect: boolean;
onChange: (collect: boolean) => void;
onPileChange: (key: string) => void;
}) {
if (!quote) return null;
return <div className="notice" aria-label="Sandmaster worm collection">
return <div className="notice space-y-3" aria-label="Sandmaster worm collection">
{quote.blocked ? <p>{quote.blocked} You may still ride without collecting.</p> : <>
<label><input type="checkbox" checked={collect}
<label className="flex min-h-11 items-start gap-2"><input className="mt-1 shrink-0" type="checkbox" checked={collect}
onChange={event => onChange(event.target.checked)} />
Collect 1 spice with Sandmaster at the destination
</label>
{quote.piles.length > 1 && <>
<label className="flex flex-col gap-2">Destination spice pile
<select className="min-h-11 w-full" disabled={!collect} value={quote.key ?? ''} onChange={event => onPileChange(event.target.value)}>
{quote.piles.map(pile => <option key={pile.key} value={pile.key}>
Sector {splitLocation(pile.key).sector}: {pile.before} → {pile.before - 1} spice
</option>)}
</select>
</label>
<p className="fine">Provisional multi-pile policy: you choose which sector supplies
the one spice; this is not a publisher allocation ruling.</p>
</>}
<p className="fine">Sector {splitLocation(quote.key!).sector}: {quote.before} → {quote.before - 1} spice if collected.</p>
<p className="fine">Collect once for this ride, even when several forces or source
sectors participate. The source and intervening territories earn nothing.
This does not use your normal movement.</p>
+14
View File
@@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Owned Sandmaster destination piles — 9 October.** Native worm rides and
physical Fremen Ambassador relocation now select an exact positive pile
when several exist. Collection remains optional and capped at one spice;
invalid/omitted ambiguous keys reject before transfer. Stored Ambassador
reconstruction retains the selected key. Basic/Advanced native smokes and
all four existing legal policies pass without destination-strategy changes.
At390px, actual worm controls debit Pasty Mesa6 while landing four forces
in Mesa5 (spice5→6); Ambassador controls reset on a destination change and
debit South Mesa5 while landing two forces in Mesa4 (spice20→21).
Types/lint and 91 affected cases pass. HMS multi-pile controls remain open.
See [worm](SANDMASTER_WORM.md#connected-behavior) and
[physical Ambassador](SANDMASTER_MOVEMENT.md#physical-ambassador-owned-destination-allocation--9-october-2026).
**Owned Sandmaster ground-pile choice — 9 October.** Ordinary route
collection now offers every positive pile, but permits only one selected
sector per entered territory. The checkbox group supports switching piles
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 9 October 2026 — owned worm and physical Ambassador destination piles
The shared destination quote exposes all legal positive piles and an exact
selection. Native worm and physical Fremen Ambassador declarations add
`sandmasterPile` when collecting; ambiguity requires a supplied key.
Single-pile boolean declarations remain valid. One ride/relocation earns
only one spice, regardless of forces or piles, before arrival reactions.
Owner allocation is visibly provisional, not publisher adjudication.
The actual UI selectors reset stale destination selection, retain optional
opt-out and show before/after quantities. Existing native/profile,
same-territory and concealed-marker guards stay. Stored Ambassador movement
reconstructs its exact frozen key through the original receipt.
Basic/Advanced native smoke chooses Pasty Mesa6 (3→2), preserves Mesa5 at2,
lands four forces in Mesa5 and credits5→6 without changing normal movement.
Physical Ambassador smoke chooses South Mesa5 (2→1), preserves Mesa4 at4,
lands two forces in Mesa4 and credits20→21 with moved0/shippedfalse.
All four existing policies retain legal continuation and send explicit keys
when they choose collection; optional destination strategy is unchanged.
Isolated 390px production controls reproduce both results, including
Ambassador destination reset and opt-out/reselection. No existing game store
or deployed acceptance is claimed.
`check:quick` passes; 91 affected native, quote, control, bot, cancellation
and movement cases pass. The selected-pile Intrusion/JSON cases settle once.
A duplicate control case that enabled an undealt card variant was removed;
existing valid profile guards remain covered. Temporary scripts and the
dedicated service were removed. No broad recovery/HTTP campaign or deployment.
## 9 October 2026 — owned Sandmaster ground-movement pile selection
The shared ground quote now groups all positive pile keys by entered
+1 -1
View File
@@ -21,7 +21,7 @@ They supersede earlier software-absence claims only within their named scope.
| Full-hand Nullentropy Box | Basic and Advanced permit an atomic retrieval/Box-disposal exchange with final hand within its actual limit; Box remains last/on top. Intermediate capacity is not adjudicated. | [Box runtime](NULLENTROPY_BOX_ENGINE_AUDIT.md) |
| Kulon with Richese Ornithopter | Both modes coexist in Basic/Advanced. The fixed one-group card maximum remains three; Kulon extends each normal-range group. Explicitly provisional, not a publisher ruling. | [Movement policy](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#provisional-kulon-coexistence--9-october-2026) |
| Multi-pile battle skills | In both modes, surviving Smuggler allocates the fixed unmodified-strength-capped collection among reveal-time positive piles; Sandmaster winner then selects one remaining pile for +3. Single-pile/all/none/void effects stay automatic. Owner sector selection is provisional, not a publisher allocation ruling. | [Smuggler](SMUGGLER_BATTLE.md#multi-pile-allocation-policy-and-pure-api--9-october-2026), [Sandmaster](LEADER_BATTLE_EFFECTS.md#source-contract) |
| Multi-pile Sandmaster ground movement | The owner chooses one existing positive debit sector per entered territory, or declines its one-spice collection. Duplicate territory collection rejects. Worm/Ambassador destination and HMS multi-pile paths remain separate. Explicitly provisional, not a publisher allocation ruling. | [Ground-route policy](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026) |
| Multi-pile Sandmaster movement | Ground movement chooses one existing positive debit sector per entered territory; worm and physical Fremen Ambassador relocation choose one at their entered destination, or decline collection. Duplicate territory collection rejects; destination multi-pile requests need an explicit key. HMS multi-pile controls remain separate. Provisional owner allocation, not a publisher ruling. | [Ground](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026), [Ambassador](SANDMASTER_MOVEMENT.md#physical-ambassador-owned-destination-allocation--9-october-2026), [worm](SANDMASTER_WORM.md#connected-behavior) |
| Basic occupied Homeworld lifecycle | Current sole foreign garrison controls occupied income, Bidding effects, defenses, percentage shares and the Ecaz-victory holding; the entitlement ends immediately on departure, contest or native repopulation. A recorded source observation is still required. Basic Tupile slot leasing keeps the conservative original-epoch gate. Visibly provisional, not a publisher or numerical user ruling. | [Occupation](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
No publication or faction/mode gate is opened. The exact 7 October user
+43 -4
View File
@@ -28,11 +28,50 @@ declines/reselects the territory, and commits the same move: personal
spice10→12, South Mesa4 remains4, South Mesa5 becomes1, Cielago East3
becomes3, and three forces arrive in Cielago East.
Worm/physical-Ambassador destination choices and native HMS relocation
retain their separate single-pile scope. No public start or broader
source adjudication is opened by this ground-route change.
Destination relocation choices are separate from these ground-route controls;
physical Fremen Ambassador allocation is recorded below and worm collection
in [its own contract](SANDMASTER_WORM.md). Native HMS relocation retains its
single-pile scope. No public start or broader source adjudication is opened
by this ground-route change.
## Physical Ambassador owned-destination allocation — 9 October 2026
Physical Fremen Ambassador relocation now offers every existing positive
destination-territory pile and collects at most one spice from exactly one
chosen sector before physical arrival and its reactions. The checkbox remains
optional. With multiple legal piles, the real relocation control shows their
sector quantities as before→before−1, defaults to the first legal pile, and
allows another sector. Changing source or destination resets the selection;
an unavailable selection cannot strand the draft. Declining sends no pile key
and transfers no spice.
`sandmasterCollect: true` with multiple positive piles requires an explicit
`sandmasterPile` key. A single-pile boolean declaration remains valid. Native
validation rejects missing, malformed, foreign, unknown or exhausted selections
before force or spice mutation. The shared `sandmasterDestinationCollection`
quote supplies the legal piles and the selected quantity; `ambassadorSandmaster`
retains the exact native trainer, key and original quantity as its frozen proof.
Stored continuation revalidates that same key, not a fresh default; a committed
arrival interruption resumes without collecting twice.
Choosing the debit sector is **provisional owner allocation**, not a publisher
ruling: the source grants one spice for entering the territory but does not
name its sector. Same-territory moves, living-native skill eligibility,
concealed-marker restrictions, typed forces, ordinary allowances and the
original Ambassador event remain unchanged. This does not change ground-route
or native HMS eligibility, add spice or invent intervening sectors.
Focused native-engine cases now cover Basic/Advanced alternate-pile allocation,
decline, invalid declarations without mutation, living-trainer restrictions and
collection before a JSON-restored Intrusion continuation. Integrated native
smoke in both modes chooses South Mesa5 (2→1), preserves Mesa4 at4,
lands two forces in Mesa4 and credits spice20→21 without ordinary
shipment/movement use. At390px, the actual control changes destination,
resets the pile selection, opts out/reselects and commits the same result.
The 8 October rejection evidence below remains a dated checkpoint, not
the current multi-pile policy.
## Physical Fremen Ambassador relocation — 8 October 2026
The Ambassador expressly relocates a board force group. The normal
@@ -129,7 +168,7 @@ Broad checkpoint results are recorded with the checkpoint. These checks do not c
The [worm-ride follow-up](SANDMASTER_WORM.md) connects native Fremen destination
collection, including saved arrival reactions. Physical Fremen Ambassador
relocation is connected above. Other nonordinary relocations, special cards,
concealed-marker collection, multi-pile destination/HMS collection and wider
concealed-marker collection, multi-pile HMS collection and wider
interaction/strategy acceptance remain. The lower battle band is in
[the battle-effects contract](LEADER_BATTLE_EFFECTS.md).
+54 -21
View File
@@ -24,35 +24,68 @@ the common skill-before-faction order. Subsequent arrival effects do not undo it
## Connected behavior
A shared public quote validates the player, destination, skill and single positive
pile. The checkbox defaults to collection but permits declining it. An empty or
ambiguous pile suppresses collection without blocking ordinary riding. Legacy
saved actions without the new field retain their original behavior. No invented
intermediate route or extra normal movement is consumed. Multi-sector and typed
elite selections transfer one spice total, not one per force or source sector.
A shared public quote validates the player, destination, skill and all positive
destination piles. Collection remains optional and capped at one spice for the
entire ride. With several valid piles, the owner chooses the exact sector supplying
that spice; placement of the riding forces does not constrain the chosen pile.
This allocation policy is **provisional**, not a publisher sector-allocation ruling.
It does not invent a pile, an intermediate route or extra normal movement.
Multi-sector and typed elite selections still transfer one spice total, not one
per force, source sector or destination pile.
The checkbox defaults to collection but permits declining it. A multi-pile
selector is controlled by the parent's selected quote key and shows each sector's
before → before − 1 quantity. A single pile needs no selector; an empty destination
or unsupported quote has no collection checkbox. None of these cases prevents
ordinary riding without collection, and there is no extra confirmation.
`sandmasterWormCollection(game, player, destination, sector, decision = game.decision,
selectedPile?: string)` retains its native Fremen, phase, decision, leader and
configuration guards. Its quote includes `piles: Array<{ key: string; before: number }>`,
the selected `key` and `before`, and any `blocked` reason. Without `selectedPile`,
the quote offers the first legal pile; this UI default is not action authorization.
An invalid selected key blocks the quote, and blocked quotes expose `piles: []`.
An accepted worm-ride action may set `sandmasterCollect: true` and
`sandmasterPile: "territory:sector"`. Collection from multiple positive destination
piles requires that explicit key; omitting it rejects the action without mutation.
Single-pile collection still accepts the legacy boolean-only action. Send the pile
field only when collecting. Declining collection or omitting both fields preserves
ordinary riding and leaves board/player spice unchanged.
`SandmasterWormChoice` receives `quote`, `collect`, `onChange(collect)` and the
required `onPileChange(key)` callback. The parent owns selection state, requotes
that exact key and includes it in the collecting action.
The engine validates every force before moving spice from the board to the
player, logs the automatic effect and then opens any Bene Gesserit arrival choice.
Existing versioned room writes commit that transfer once. A saved pending arrival
resumes only the remaining reaction and ride queue; no new receipt is needed.
All four AI profiles attach the optional collection flag to the existing legal
ride candidates. Destination policy and strength tuning are unchanged.
All four AI profiles attach the optional collection flag and exact quoted pile key
to the existing legal ride candidates. Destination policy and strength tuning are
unchanged.
## Verification and remaining work
Focused engine, controls and SQLite checks cover Basic/Advanced force custody,
source exclusion, decline, omitted legacy fields, empty and ambiguous piles,
illegal requests without mutation, native trainer ownership/visibility, public
mode guards, private views, duplicate concurrent requests and restart during a
Bene Gesserit reaction. Separate queued rides and a real special-Karama worm
through Nexus exercise the production continuation. The fixture preserves the
Fremen three-elite total rather than assuming the Emperor five-elite total.
Independent source and implementation review found no material defect in this
bounded contract. Final check/build/HTTP results, targeted browser continuation,
sample games, saved-game preservation and Git delivery belong to the private
source-bound checkpoint and commit message.
The focused suites include Basic/Advanced force custody, source exclusion, decline,
omitted legacy fields, empty destinations, explicit allocation between two positive
piles, ambiguous or invalid requests without mutation, native trainer
ownership/visibility, public mode guards, private views and JSON restoration during
a Bene Gesserit reaction. The selected pile loses only one spice, other piles remain
unchanged, and a pending arrival cannot collect a second time. Separate queued rides
and a real special-Karama worm through Nexus exercise the production continuation.
The fixture preserves the Fremen three-elite total rather than assuming the Emperor
five-elite total. Controls retain eligible-owner and blocked-state coverage rather
than pinning selector wording, HTML order or callback echoes.
Other expansion factions/modules, the Ecaz card variant and multiple positive
piles remain guarded. Other special relocation, full Leader Skills integration,
Integrated native smoke in both modes collects exactly one from selected
Pasty Mesa6 (3→2), leaves Mesa5 at2 and lands four forces in Mesa5 without
changing normal movement. An isolated 390px production control selects
Mesa6 and observes the same live-engine result, personal spice5→6.
All four existing policies retain legal continuation; collecting declarations
carry explicit pile keys without changing destination strategy.
Other expansion factions/modules and the Ecaz card variant remain guarded.
Other special relocation, full Leader Skills integration,
complete human games and final presentation remain unfinished. Strategy refinement
waits for the [AI feature-completion gate](AI_DEVELOPMENT_PLAN.md).
+11 -6
View File
@@ -409,6 +409,8 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
(presenceAt(me, destination.territory) ? 8 : 0) -
(abandons ? 25 : 0) -
(destination.advisors ? 3 : 0);
const sandmaster = !marker
? sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector) : null;
candidates.push({
score,
action: {
@@ -418,8 +420,8 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
eliteForces,
territory: destination.territory,
sector: destination.sector,
...(!marker && sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector)?.blocked === null
? { sandmasterCollect: true } : {}),
...(sandmaster?.blocked === null
? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}),
...(marker
? { noField: { tokenId: marker.tokenId, event: marker.event } }
: {}),
@@ -2805,13 +2807,15 @@ function policyActions(g: GameView): Action[] {
)
.slice(0, 16)
.map(
(to): Action => ({
(to): Action => {
const sandmaster = sandmasterWormCollection(g, me.id, to.t, to.s);
return {
type: 'decision',
accept: true,
territory: to.t,
sector: to.s,
...(sandmasterWormCollection(g, me.id, to.t, to.s)?.blocked === null
? { sandmasterCollect: true } : {}),
...(sandmaster?.blocked === null
? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}),
forces: Object.fromEntries(
Object.entries(me.forces).filter(
([key]) =>
@@ -2819,7 +2823,8 @@ function policyActions(g: GameView): Action[] {
splitLocation(key).sector !== g.storm,
),
),
}),
};
},
),
{ type: 'decision', accept: false },
];
+13 -4
View File
@@ -14162,7 +14162,8 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
territory: move.to,
sector: move.sector,
fighters: move.wantsFighters,
...(move.ambassadorSandmaster ? { sandmasterCollect: true } : {}),
...(move.ambassadorSandmaster
? { sandmasterCollect: true, sandmasterPile: move.ambassadorSandmaster.key } : {}),
...(move.noField
? {
noField: {
@@ -29775,6 +29776,9 @@ function applyActionInner(
);
requireRule(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean',
'Choose whether to collect spice with Sandmaster.');
requireRule(action.sandmasterPile === undefined ||
(action.sandmasterCollect === true && typeof action.sandmasterPile === 'string'),
'Choose a Sandmaster destination pile only when collecting.');
requireRule(action.accept || !action.sandmasterCollect,
'Sandmaster collection requires an accepted worm ride.');
if (action.accept) {
@@ -29785,9 +29789,14 @@ function applyActionInner(
'Choose another destination territory.',
);
allowedEntry(g, p, to, sector);
const sandmaster = action.sandmasterCollect ? sandmasterWormCollection(g, p.id, to, sector, decision) : null;
if (action.sandmasterCollect) requireRule(sandmaster && !sandmaster.blocked,
sandmaster?.blocked ?? 'Sandmaster collection needs the living native trainer.');
const sandmaster = action.sandmasterCollect
? sandmasterWormCollection(g, p.id, to, sector, decision, action.sandmasterPile as string | undefined) : null;
if (action.sandmasterCollect) {
requireRule(sandmaster && !sandmaster.blocked && sandmaster.key,
sandmaster?.blocked ?? 'Sandmaster collection needs the living native trainer.');
requireRule(sandmaster.piles.length <= 1 || typeof action.sandmasterPile === 'string',
'Choose the destination spice pile for Sandmaster collection.');
}
requireRule(
action.forces &&
typeof action.forces === 'object' &&
+8 -1
View File
@@ -330,13 +330,20 @@ export function quoteFremenAmbassadorMove(
);
requireMove(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean',
'Choose whether to collect spice with Sandmaster.');
requireMove(action.sandmasterPile === undefined ||
(action.sandmasterCollect === true && typeof action.sandmasterPile === 'string'),
'Choose a Sandmaster spice pile only when collecting.');
let ambassadorSandmaster: FremenAmbassadorMove['ambassadorSandmaster'];
if (action.sandmasterCollect) {
requireMove(physical > 0 && !noField,
'Sandmaster collection with concealed Ambassador relocation is still being integrated.');
const quote = sandmasterDestinationCollection(g, p.id, origin, to, sector);
const quote = sandmasterDestinationCollection(
g, p.id, origin, to, sector, action.sandmasterPile,
);
requireMove(quote && !quote.blocked && quote.key,
quote?.blocked ?? 'Sandmaster collection needs the living native trainer.');
requireMove(quote.piles.length <= 1 || typeof action.sandmasterPile === 'string',
'Choose the destination spice pile for Sandmaster collection.');
ambassadorSandmaster = { leader: quote.leader, key: quote.key, before: quote.before };
}
return {
+1 -1
View File
@@ -4730,7 +4730,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory, or decline collection; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before arrival reactions, without a ground route or normal movement use. Native Ixian HMS interior passengers count as entering the territories it points into and collect before faction spice. Multi-pile worm/Ambassador destination and HMS collection, and other special relocation, remain separate unfinished paths.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory, or decline collection; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides and physical Fremen Ambassador relocation likewise offer one selected destination pile before arrival reactions, without an invented ground route or normal movement use. These destination allocations are provisional. Native Ixian HMS passengers collect before faction spice, but its multi-pile controls and other special relocation remain unfinished.',
'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
+10 -8
View File
@@ -60,6 +60,7 @@ export type SandmasterDestinationCollection = {
leader: string;
key: string | null;
before: number;
piles: { key: string; before: number }[];
blocked: string | null;
};
@@ -70,18 +71,18 @@ export function sandmasterDestinationCollection(
origin: string,
destination: string,
sector: number,
selectedPile?: string,
): SandmasterDestinationCollection | null {
const leader = sandmasterLeader(game, player);
if (!leader) return null;
const blocked = (reason: string): SandmasterDestinationCollection =>
({ leader, key: null, before: 0, blocked: reason });
({ leader, key: null, before: 0, piles: [], blocked: reason });
if (!sandmasterModeSupported(game))
return blocked('Sandmaster collection with this configuration is still being integrated.');
if (origin === destination || !validGameLocation(game, destination, sector) ||
sector === game.storm)
return blocked('Choose a different legal destination territory for Sandmaster collection.');
let key: string | null = null;
let before = 0;
const piles: { key: string; before: number }[] = [];
for (const pile in game.spice) {
const at = splitLocation(pile);
if (at.territory !== destination) continue;
@@ -91,15 +92,16 @@ export function sandmasterDestinationCollection(
!Number.isSafeInteger(amount) || amount < 0)
return blocked('Sandmaster needs a valid destination spice pile.');
if (!amount) continue;
if (key) return blocked('Sandmaster collection among multiple spice piles awaits its allocation ruling.');
key = pile;
before = amount;
piles.push({ key: pile, before: amount });
}
if (!key) return blocked('There is no spice to collect at this destination.');
if (!piles.length) return blocked('There is no spice to collect at this destination.');
piles.sort((a, b) => a.key.localeCompare(b.key));
const chosen = selectedPile === undefined ? piles[0] : piles.find(pile => pile.key === selectedPile);
if (!chosen) return blocked('Choose an existing positive spice pile at this destination.');
const spice = game.players.find(p => p.id === player)?.spice;
if (!Number.isSafeInteger(spice) || !Number.isSafeInteger(spice! + 1))
return blocked('Sandmaster collection needs a valid spice balance.');
return { leader, key, before, blocked: null };
return { leader, key: chosen.key, before: chosen.before, piles, blocked: null };
}
export function sandmasterPathBlocked(
+3 -2
View File
@@ -11,16 +11,17 @@ export function sandmasterWormCollection(
destination: string,
sector: number,
decision: Game['decision'] | GameView['decision'] = game.decision,
selectedPile?: string,
): SandmasterDestinationCollection | null {
const leader = sandmasterLeader(game, player);
if (!leader || game.phase !== 1 || decision?.kind !== 'wormRide' ||
decision.player !== player ||
game.players.find(p => p.id === player)?.faction !== 'fremen') return null;
const blocked = (reason: string): SandmasterDestinationCollection =>
({ leader, key: null, before: 0, blocked: reason });
({ leader, key: null, before: 0, piles: [], blocked: reason });
if (!sandmasterModeSupported(game) || game.ecazTreachery ||
!(nativeExpansionLeaderSkillsProfile(game) ||
game.players.every(p => faction(p.faction).expansion === 'base')))
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
return sandmasterDestinationCollection(game, player, decision.territory, destination, sector);
return sandmasterDestinationCollection(game, player, decision.territory, destination, sector, selectedPile);
}
+114
View File
@@ -146,6 +146,19 @@ function trigger(g: Game, beneficiary = 'a', copy = false) {
return out;
}
function sandmasterFixture(advanced = true, extra: FactionId[] = []) {
const g = fixture('fremen', extra);
g.advanced = advanced;
g.expansions = ['ecaz'];
army(g, 'a', { 'red_chasm:7': 2 });
g.spice = { 'south_mesa:4': 4, 'south_mesa:5': 2 };
const skills = createLeaderSkills(() => 0.2);
skills.deck = skills.deck.filter(skill => skill !== 'sandmaster');
skills.assignments = [{ skill: 'sandmaster', owner: 'a', leader: p(g, 'a').leaders[0].id }];
g.leaderSkills = skills;
return g;
}
void test('physical Fremen Ambassador relocation collects optional Sandmaster spice without consuming ordinary allowances', () => {
for (const collect of [false, true]) {
const start = fixture();
@@ -171,6 +184,107 @@ void test('physical Fremen Ambassador relocation collects optional Sandmaster sp
assert.equal(done.pendingAmbassador, null);
}
});
void test('Basic and Advanced Ambassador relocation debit exactly the chosen destination pile or decline both', () => {
for (const advanced of [false, true]) {
const pending = trigger(enter(sandmasterFixture(advanced)));
for (const selected of [undefined, 'south_mesa:4', 'south_mesa:5']) {
const action: Action = {
type: 'decision',
event: pending.pendingAmbassador!.event,
forces: { 'red_chasm:7': 2 },
territory: 'south_mesa',
sector: 4,
sandmasterCollect: selected !== undefined,
...(selected ? { sandmasterPile: selected } : {}),
};
const done = applyAction(reload(pending), 'a', action);
assert.equal(p(done, 'a').forces['south_mesa:4'], 2);
assert.equal(p(done, 'a').forces['red_chasm:7'] ?? 0, 0);
assert.equal(p(done, 'a').spice, selected ? 21 : 20);
assert.equal(done.spice['south_mesa:4'], selected === 'south_mesa:4' ? 3 : 4);
assert.equal(done.spice['south_mesa:5'], selected === 'south_mesa:5' ? 1 : 2);
assert.equal(p(done, 'a').moved, 0);
assert.equal(p(done, 'a').shipped, false);
assert.equal(p(done, 'a').reserves, 18);
assert.equal(done.pendingAmbassador, null);
}
}
});
void test('Ambassador collecting declarations reject missing, malformed, foreign and exhausted pile keys before mutation', () => {
const start = sandmasterFixture();
start.spice['red_chasm:7'] = 3;
const pending = trigger(enter(start));
const action: Action = {
type: 'decision',
event: pending.pendingAmbassador!.event,
forces: { 'red_chasm:7': 2 },
territory: 'south_mesa',
sector: 4,
sandmasterCollect: true,
};
for (const key of [undefined, '', 'south_mesa:999', 'red_chasm:7', 5, null, ['south_mesa:5']])
reject(pending, { ...action, sandmasterPile: key } as Action);
reject(pending, { ...action, sandmasterCollect: false, sandmasterPile: 'south_mesa:5' });
const exhausted = reload(pending);
exhausted.spice['south_mesa:5'] = 0;
reject(exhausted, { ...action, sandmasterPile: 'south_mesa:5' });
const dead = reload(pending);
p(dead, 'a').leaders[0].dead = true;
reject(dead, { ...action, sandmasterPile: 'south_mesa:5' });
const captured = reload(pending);
p(captured, 'a').leaders[0].capturedBy = 'in';
reject(captured, { ...action, sandmasterPile: 'south_mesa:5' });
});
void test('selected Ambassador collection commits before Intrusion and a restored arrival resumes without a second debit', () => {
const start = sandmasterFixture(true, ['beneGesserit']);
army(start, 'beneGesserit', { 'south_mesa:4': 1 });
let g = trigger(enter(start));
const event = g.pendingAmbassador!.event;
g = move(g, {
forces: { 'red_chasm:7': 2 },
territory: 'south_mesa',
sector: 4,
sandmasterCollect: true,
sandmasterPile: 'south_mesa:5',
});
assert.equal(g.decision?.kind, 'intrusion');
assert.equal(g.pendingAmbassador?.stage, 'arrival');
assert.equal(g.pendingAmbassador?.event, event);
assert.equal(p(g, 'a').forces['south_mesa:4'], 2);
assert.equal(p(g, 'a').spice, 21);
assert.equal(g.spice['south_mesa:4'], 4);
assert.equal(g.spice['south_mesa:5'], 1);
g = applyAction(reload(g), 'beneGesserit', { type: 'decision', accept: true });
g = allow(reload(g));
assert.equal(p(g, 'a').forces['south_mesa:4'], 2);
assert.equal(p(g, 'a').spice, 21);
assert.equal(g.spice['south_mesa:4'], 4);
assert.equal(g.spice['south_mesa:5'], 1);
assert.equal(g.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length, 1);
settled(g);
});
void test('Ambassador sector relocation within its source territory earns no Sandmaster spice', () => {
const start = sandmasterFixture();
army(start, 'a', { 'wind_pass:14': 2 });
start.spice = { 'wind_pass:14': 4, 'wind_pass:15': 2 };
const pending = trigger(enter(start));
const action: Action = {
type: 'decision',
event: pending.pendingAmbassador!.event,
forces: { 'wind_pass:14': 2 },
territory: 'wind_pass',
sector: 15,
};
reject(pending, { ...action, sandmasterCollect: true, sandmasterPile: 'wind_pass:15' });
const done = applyAction(reload(pending), 'a', action);
assert.equal(p(done, 'a').forces['wind_pass:15'], 2);
assert.equal(p(done, 'a').spice, 20);
assert.deepEqual(done.spice, start.spice);
});
function move(g: Game, extra: Omit<Action, 'type'>, actor = 'a') {
return applyAction(g, actor, {
type: 'decision',
+8 -12
View File
@@ -7,18 +7,14 @@ import { sandmasterWormCollection } from '../game/sandmaster-worm';
import { viewGame } from '../game/engine';
import { sandmasterWormGame } from './fixture-sandmaster-worm';
void test('the ride control shows optional destination collection only for its eligible owner and preserves opt-out', () => {
void test('worm collection controls are restricted to the eligible owner and disappear when blocked', () => {
const g = sandmasterWormGame();
const quote = sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7);
const html = (collect: boolean) => renderToStaticMarkup(createElement(SandmasterWormChoice, { quote, collect, onChange() {} }));
assert.match(html(true), /checked=""/);
assert.doesNotMatch(html(false), /checked=""/);
assert.equal(renderToStaticMarkup(createElement(SandmasterWormChoice, {
quote: sandmasterWormCollection(viewGame(g, 'h'), 'h', 'red_chasm', 7), collect: true, onChange() {},
})), '');
g.spice['red_chasm:7'] = 0;
const blocked = renderToStaticMarkup(createElement(SandmasterWormChoice, {
quote: sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7), collect: true, onChange() {},
const render = (player: string) => renderToStaticMarkup(createElement(SandmasterWormChoice, {
quote: sandmasterWormCollection(viewGame(g, player), player, 'red_chasm', 7),
collect: true, onChange() {}, onPileChange() {},
}));
assert.doesNotMatch(blocked, /type="checkbox"/);
assert.match(render('p'), /type="checkbox"/);
assert.equal(render('h'), '');
g.spice['red_chasm:7'] = 0;
assert.doesNotMatch(render('p'), /<(?:input|select)\b/);
});
+75 -22
View File
@@ -36,7 +36,7 @@ void test('Sandmaster collects once at a worm destination for multiple source se
reject(next, action);
}
});
void test('optional decline, legacy actions and absent or ambiguous piles keep ordinary worm riding available', () => {
void test('optional decline and legacy actions keep ordinary worm riding available with or without spice', () => {
for (const amount of [0, 1, 2]) for (const collect of [false, undefined]) {
const g = sandmasterWormGame();
g.spice['red_chasm:7'] = amount;
@@ -50,14 +50,52 @@ void test('optional decline, legacy actions and absent or ambiguous piles keep o
const empty = sandmasterWormGame(); empty.spice['red_chasm:7'] = 0;
assert.match(sandmasterWormCollection(empty, 'p', 'red_chasm', 7)!.blocked!, /no spice/);
reject(empty, sandmasterRide());
const multiple = sandmasterWormGame();
multiple.spice = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
const ambiguous: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5 };
assert.match(sandmasterWormCollection(multiple, 'p', 'pasty_mesa', 5)!.blocked!, /multiple spice piles/);
reject(multiple, ambiguous);
delete ambiguous.eliteForces;
const next = applyAction(multiple, 'p', { ...ambiguous, sandmasterCollect: false });
});
void test('a selected destination pile supplies only one spice in Basic and Advanced worm rides', () => {
for (const advanced of [false, true]) for (const key of ['pasty_mesa:5', 'pasty_mesa:6']) {
const g = sandmasterWormGame(advanced);
g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
const action: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5, sandmasterPile: key };
if (!advanced) delete action.eliteForces;
const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action);
assert.equal(next.players[0].spice, 6);
assert.equal(next.spice[key], g.spice[key] - 1);
const other = key === 'pasty_mesa:5' ? 'pasty_mesa:6' : 'pasty_mesa:5';
assert.equal(next.spice[other], g.spice[other]);
assert.equal(next.spice['wind_pass:14'], 7);
assert.equal(next.players[0].forces['pasty_mesa:5'], 4);
if (advanced) assert.equal(next.players[0].elites!.forces['pasty_mesa:5'], 2);
assert.equal(next.players[0].moved, 1);
assert.equal(next.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1);
conserved(next);
}
});
void test('ambiguous collection needs an exact positive destination pile, but decline still rides', () => {
const g = sandmasterWormGame();
g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
const action: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5 };
delete action.eliteForces;
reject(g, action);
for (const key of ['wind_pass:14', 'pasty_mesa:9', '', 6])
reject(g, { ...action, sandmasterPile: key });
const emptied = structuredClone(g); emptied.spice['pasty_mesa:6'] = 0;
reject(emptied, { ...action, sandmasterPile: 'pasty_mesa:6' });
const next = applyAction(g, 'p', { ...action, sandmasterCollect: false });
assert.equal(next.players[0].spice, 5);
assert.deepEqual(next.spice, g.spice);
assert.equal(next.players[0].forces['pasty_mesa:5'], 4);
conserved(next);
});
void test('destination quoting requires current native ride ownership and phase even with a selected pile', () => {
const g = sandmasterWormGame();
g.spice = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
assert.equal(sandmasterWormCollection(g, 'p', 'pasty_mesa', 5, null, 'pasty_mesa:6'), null);
assert.equal(sandmasterWormCollection(g, 'p', 'pasty_mesa', 5,
{ kind: 'wormRide', player: 'h', territory: 'wind_pass' }, 'pasty_mesa:6'), null);
const wrongPhase = structuredClone(g); wrongPhase.phase = 5;
assert.equal(sandmasterWormCollection(wrongPhase, 'p', 'pasty_mesa', 5, g.decision, 'pasty_mesa:6'), null);
const nonNative = structuredClone(g); nonNative.players[0].faction = 'guild';
assert.equal(sandmasterWormCollection(nonNative, 'p', 'pasty_mesa', 5, g.decision, 'pasty_mesa:6'), null);
});
void test('failed force selection, storm entry and collecting without a ride cannot debit spice', () => {
const g = sandmasterWormGame(true);
@@ -77,6 +115,8 @@ void test('Sandmaster requires the owned living native trainer and the supported
(g: typeof view) => { g.leaderSkills!.assignments.find(a => a.owner === 'p')!.faceUp = false; },
(g: typeof view) => { g.leaderSkills!.assignments.find(a => a.owner === 'p')!.controller = 'h'; },
(g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.dead = true; },
(g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.capturedBy = 'h'; },
(g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.gholaBy = 'h'; },
]) {
const bad = structuredClone(view); mutate(bad);
assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null);
@@ -86,23 +126,36 @@ void test('Sandmaster requires the owned living native trainer and the supported
(g: typeof view) => { g.players[1].faction = 'richese'; },
]) {
const unsupported = structuredClone(view); mutate(unsupported);
assert.match(sandmasterWormCollection(unsupported, 'p', 'red_chasm', 7)!.blocked!, /other optional modules/);
const quote = sandmasterWormCollection(unsupported, 'p', 'red_chasm', 7)!;
assert.ok(quote.blocked);
assert.deepEqual(quote.piles, []);
}
});
void test('collection is committed before an interrupted BG arrival and is not repeated after JSON restoration', () => {
const g = sandmasterWormGame(true, true);
g.spice['red_chasm:7'] = 1;
const pending = applyAction(g, 'p', sandmasterRide());
assert.equal(pending.decision?.kind, 'intrusion');
assert.equal(pending.players[0].spice, 6);
assert.equal(pending.spice['red_chasm:7'], 0);
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(pending)));
assert.deepEqual(restored, pending);
const done = applyAction(restored, 'h', { type: 'decision', accept: false });
assert.equal(done.players[0].spice, 6);
assert.equal(done.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1);
assert.equal(done.players[0].moved, 1);
conserved(done);
for (const multiple of [false, true]) {
const g = sandmasterWormGame(true, true);
const action = sandmasterRide();
if (multiple) {
g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 3, 'pasty_mesa:6': 1 };
g.players[1].forces = { 'pasty_mesa:5': 1 };
Object.assign(action, { territory: 'pasty_mesa', sector: 5, sandmasterPile: 'pasty_mesa:6' });
} else g.spice['red_chasm:7'] = 1;
const key = multiple ? 'pasty_mesa:6' : 'red_chasm:7';
const pending = applyAction(g, 'p', action);
assert.equal(pending.decision?.kind, 'intrusion');
assert.equal(pending.players[0].spice, 6);
assert.equal(pending.spice[key], 0);
if (multiple) assert.equal(pending.spice['pasty_mesa:5'], 3);
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(pending)));
assert.deepEqual(restored, pending);
const done = applyAction(restored, 'h', { type: 'decision', accept: false });
assert.equal(done.players[0].spice, 6);
assert.equal(done.spice[key], 0);
if (multiple) assert.equal(done.spice['pasty_mesa:5'], 3);
assert.equal(done.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1);
assert.equal(done.players[0].moved, 1);
conserved(done);
}
});
void test('all profiles attach collection to legal rides without changing the existing destination policy', () => {
for (const difficulty of DIFFICULTIES) {