Enable Sandmaster collection on flight and marker routes
This commit is contained in:
1 parent
4af90e547c
commit
ec549028ba
20 files changed
+1623
-165
No files matched your search
@@ -4977,14 +4977,10 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
unavailableReason={
|
||||
!movementAvailable
|
||||
!movementAvailable && !g.ornithopter?.active
|
||||
? 'No ordinary movement is currently available.'
|
||||
: (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||||
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||||
? 'No ordinary movement remains this turn.'
|
||||
: ordinaryMovementRouteBlocked
|
||||
? 'Choose an unblocked source and route outside the storm.'
|
||||
: canceledFremenRouteBlocked
|
||||
? 'Choose an ordinary route within your available range.'
|
||||
: selectedDestinationInStorm
|
||||
? 'Choose a destination outside the storm.'
|
||||
: liveShipmentPromises(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
quoteSandmasterMovement, sandmasterActionOrder, sandmasterCollectionPiles, sandmasterDefaultChoice,
|
||||
type SandmasterChoice,
|
||||
} from '@/game/sandmaster-movement';
|
||||
import {
|
||||
planetologistFlightSelection as movementFlightSelection,
|
||||
type PlanetologistFlight as MovementFlight,
|
||||
} from '@/game/planetologist-flight';
|
||||
import { nexusGuildMovementAvailable } from '@/game/nexus-guild-cunning-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type { PlanetologistFlight as MovementFlight } from '@/game/planetologist-flight';
|
||||
const flightFields = ['movementCard', 'ornithopter', 'ornithopterEvent', 'discoveryOrnithopter'] as const;
|
||||
|
||||
/** Apply only the currently projected selected transport to the parent's group. */
|
||||
export function sandmasterMovementSelection(game: GameView, move: Action | null, flight?: MovementFlight) {
|
||||
const movementMode = move?.ornithopter;
|
||||
const selectedFlight: MovementFlight = flight ?? (move?.discoveryOrnithopter !== undefined ? 'discovery'
|
||||
: movementMode === 'range3' || movementMode === 'twoGroups' ? movementMode : 'ordinary');
|
||||
const transport = movementFlightSelection(game, selectedFlight);
|
||||
const fail = (blocked: string) => ({ move: null, flight: selectedFlight, transport, blocked });
|
||||
if (!move || move.type !== 'move') return fail('Select a current movement group and destination.');
|
||||
if (move.planetologist !== undefined) return fail('Use Sandmaster without combining it with Planetologist.');
|
||||
if (transport.blocked) return fail(transport.blocked);
|
||||
if (flightFields.some(key => move[key] !== undefined && move[key] !== transport.fields[key]))
|
||||
return fail('Choose one current flight source, card mode or active movement event.');
|
||||
const selected: Action = { ...move, ...transport.fields };
|
||||
delete selected.sandmaster;
|
||||
return { move: selected, flight: selectedFlight, transport, blocked: null };
|
||||
}
|
||||
|
||||
export function sandmasterMoveDraft(game: GameView, move: Action,
|
||||
routes?: Record<string, string[]>, declined: readonly string[] = [], flight?: MovementFlight): {
|
||||
action: Action | null; choice: SandmasterChoice | null; collectible: string[]; blocked: string | null;
|
||||
} {
|
||||
const selected = sandmasterMovementSelection(game, move, flight);
|
||||
if (!selected.move) return { action: null, choice: null, collectible: [], blocked: selected.blocked };
|
||||
const defaults = sandmasterDefaultChoice(game, game.me, selected.move);
|
||||
const order = sandmasterActionOrder(game, game.me, selected.move);
|
||||
if (!defaults || !order) return { action: null, choice: null, collectible: [],
|
||||
blocked: 'Sandmaster needs a current legal force or owned marker group, route and living native trainer.' };
|
||||
const selectedRoutes = routes ?? defaults.routes;
|
||||
const offered = sandmasterCollectionPiles(game, selectedRoutes);
|
||||
const collectible = Object.values(offered).flat();
|
||||
const choice = { routes: selectedRoutes,
|
||||
collect: Object.values(offered).map(piles => piles.find(key => !declined.includes(key)))
|
||||
.filter((key): key is string => key !== undefined) };
|
||||
try {
|
||||
quoteSandmasterMovement(game, game.me, order, choice);
|
||||
return { action: { ...selected.move, sandmaster: choice }, choice, collectible, blocked: null };
|
||||
} catch (error) {
|
||||
return { action: null, choice, collectible,
|
||||
blocked: error instanceof Error ? error.message : 'Choose a legal Sandmaster route.' };
|
||||
}
|
||||
}
|
||||
|
||||
export function sandmasterMovementBlocked(game: GameView,
|
||||
draft: ReturnType<typeof sandmasterMoveDraft>, unavailableReason?: string | null,
|
||||
flight: MovementFlight = 'ordinary'): string | null {
|
||||
const me = game.players.find(player => player.id === game.me);
|
||||
const transport = movementFlightSelection(game, flight);
|
||||
const currentCard = !transport.blocked && typeof transport.fields.ornithopterEvent === 'string';
|
||||
return unavailableReason ||
|
||||
(!me || game.status !== 'playing' || game.phase !== 5 || game.active !== me.id
|
||||
? 'Sandmaster movement is available only during your movement turn.'
|
||||
: game.response || game.decision || game.phaseOpening || game.truthtrance ||
|
||||
game.nexusTraitors?.pending || game.nexusCards?.waiting.length
|
||||
? 'Resolve the current response or decision first.'
|
||||
: me.autopilot || game.automaticContinuationPending
|
||||
? 'Wait for the current automatic action to finish.'
|
||||
: transport.blocked
|
||||
? transport.blocked
|
||||
: !currentCard && !nexusGuildMovementAvailable(game)
|
||||
? 'No movement remains this turn.' : draft.blocked);
|
||||
}
|
||||
|
||||
export interface SandmasterMovementActionProps {
|
||||
game: GameView;
|
||||
move: Action | null;
|
||||
routes?: Record<string, string[]>;
|
||||
declined?: readonly string[];
|
||||
flight?: MovementFlight;
|
||||
busy: boolean;
|
||||
unavailableReason?: string | null;
|
||||
reasonId?: string;
|
||||
act: (action: Action) => void;
|
||||
}
|
||||
|
||||
function receipt(game: GameView, draft: ReturnType<typeof sandmasterMoveDraft>) {
|
||||
if (!draft.action || !draft.choice) return null;
|
||||
const order = sandmasterActionOrder(game, game.me, draft.action);
|
||||
if (!order) return null;
|
||||
try { return JSON.stringify(quoteSandmasterMovement(game, game.me, order, draft.choice)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/** Hook-free actual submit leaf, including current route/pile/frame proof. */
|
||||
export function SandmasterMovementAction({ game, move, routes, declined = [], flight,
|
||||
busy, unavailableReason, reasonId, act }: SandmasterMovementActionProps) {
|
||||
const selected = sandmasterMovementSelection(game, move, flight);
|
||||
const draft = move ? sandmasterMoveDraft(game, move, routes, declined, flight)
|
||||
: { action: null, choice: null, collectible: [], blocked: selected.blocked };
|
||||
const blocked = sandmasterMovementBlocked(game, draft, unavailableReason, selected.flight);
|
||||
const snapshot = receipt(game, draft);
|
||||
return <Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
data-testid="sandmaster-move"
|
||||
disabled={busy || !!blocked || !draft.action || !snapshot}
|
||||
aria-describedby={blocked ? reasonId : undefined}
|
||||
onClick={() => {
|
||||
if (busy || blocked || !move || !draft.action || !snapshot) return;
|
||||
const current = sandmasterMoveDraft(game, move, routes, declined, flight);
|
||||
if (sandmasterMovementBlocked(game, current, unavailableReason, selected.flight) || !current.action) return;
|
||||
const next = sandmasterMovementSelection(game, move, flight);
|
||||
if (JSON.stringify(next.transport.fields) !== JSON.stringify(selected.transport.fields) ||
|
||||
receipt(game, current) !== snapshot) return;
|
||||
act(current.action);
|
||||
}}
|
||||
>Move with Sandmaster</Button>;
|
||||
}
|
||||
@@ -8,75 +8,31 @@ import {
|
||||
splitLocation,
|
||||
} from '@/game/board';
|
||||
import {
|
||||
quoteSandmasterMovement,
|
||||
sandmasterActionOrder,
|
||||
sandmasterCollectionPiles,
|
||||
sandmasterDefaultChoice,
|
||||
sandmasterLeader,
|
||||
sandmasterModeSupported,
|
||||
sandmasterPathBlocked,
|
||||
sandmasterAdjacent,
|
||||
sandmasterRouteDistance,
|
||||
type SandmasterChoice,
|
||||
} from '@/game/sandmaster-movement';
|
||||
import {
|
||||
planetologistFlightSelection as movementFlightSelection,
|
||||
type PlanetologistFlight as MovementFlight,
|
||||
} from '@/game/planetologist-flight';
|
||||
import {
|
||||
SandmasterMovementAction, sandmasterMoveDraft, sandmasterMovementBlocked, sandmasterMovementSelection,
|
||||
} from './sandmaster-movement-action';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export { sandmasterMoveDraft } from './sandmaster-movement-action';
|
||||
|
||||
type RouteDraft = {
|
||||
key: string;
|
||||
routes: Record<string, string[]>;
|
||||
declined: string[];
|
||||
};
|
||||
|
||||
export function sandmasterMoveDraft(
|
||||
game: GameView,
|
||||
move: Action,
|
||||
routes?: Record<string, string[]>,
|
||||
declined: readonly string[] = [],
|
||||
): {
|
||||
action: Action | null;
|
||||
choice: SandmasterChoice | null;
|
||||
collectible: string[];
|
||||
blocked: string | null;
|
||||
} {
|
||||
const defaults = sandmasterDefaultChoice(game, game.me, move);
|
||||
const order = sandmasterActionOrder(game, game.me, move);
|
||||
if (!defaults || !order)
|
||||
return {
|
||||
action: null,
|
||||
choice: null,
|
||||
collectible: [],
|
||||
blocked:
|
||||
'Sandmaster needs a supported ordinary force movement and its living native trainer.',
|
||||
};
|
||||
const selectedRoutes = routes ?? defaults.routes;
|
||||
const offered = sandmasterCollectionPiles(game, selectedRoutes);
|
||||
const collectible = Object.values(offered).flat();
|
||||
const choice = {
|
||||
routes: selectedRoutes,
|
||||
collect: Object.values(offered).map(piles => piles.find(key => !declined.includes(key)))
|
||||
.filter((key): key is string => key !== undefined),
|
||||
};
|
||||
try {
|
||||
quoteSandmasterMovement(game, game.me, order, choice);
|
||||
return {
|
||||
action: { ...move, sandmaster: choice },
|
||||
choice,
|
||||
collectible,
|
||||
blocked: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
action: null,
|
||||
choice,
|
||||
collectible,
|
||||
blocked:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Choose a legal Sandmaster route.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function placeName(game: GameView, key: string) {
|
||||
const at = splitLocation(key);
|
||||
const name =
|
||||
@@ -85,28 +41,43 @@ function placeName(game: GameView, key: string) {
|
||||
return `${name}, sector ${at.sector}`;
|
||||
}
|
||||
|
||||
export function SandmasterMovement({
|
||||
game,
|
||||
move,
|
||||
act,
|
||||
busy,
|
||||
unavailableReason,
|
||||
}: {
|
||||
type Props = {
|
||||
game: GameView;
|
||||
move: Action | null;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
unavailableReason?: string | null;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function SandmasterMovement(props: Props) {
|
||||
const me = props.game.players.find(player => player.id === props.game.me);
|
||||
return <SandmasterMovementDraft
|
||||
key={JSON.stringify([props.game.me, props.game.turn, me?.moved,
|
||||
props.game.ornithopter?.card?.id, props.game.ornithopter?.active?.event,
|
||||
props.game.ornithopter?.active?.completed, props.game.discoveryOrnithopter?.token,
|
||||
props.game.richeseNoField?.owner === props.game.me ? props.game.richeseNoField.event : null])}
|
||||
{...props}
|
||||
/>;
|
||||
}
|
||||
|
||||
function SandmasterMovementDraft({
|
||||
game,
|
||||
move,
|
||||
act,
|
||||
busy,
|
||||
unavailableReason,
|
||||
}: Props) {
|
||||
const id = useId();
|
||||
const [flight, setFlight] = useState<MovementFlight | undefined>(undefined);
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const leader = sandmasterLeader(game, game.me);
|
||||
const defaults = move ? sandmasterDefaultChoice(game, game.me, move) : null;
|
||||
const order = move ? sandmasterActionOrder(game, game.me, move) : null;
|
||||
const selected = sandmasterMovementSelection(game, move, flight);
|
||||
const defaults = selected.move ? sandmasterDefaultChoice(game, game.me, selected.move) : null;
|
||||
const order = selected.move ? sandmasterActionOrder(game, game.me, selected.move) : null;
|
||||
const draftKey = JSON.stringify([
|
||||
game.turn,
|
||||
me?.moved ?? 0,
|
||||
move,
|
||||
selected.move,
|
||||
defaults?.routes,
|
||||
]);
|
||||
const [saved, setSaved] = useState<RouteDraft | null>(null);
|
||||
@@ -114,21 +85,19 @@ export function SandmasterMovement({
|
||||
!me ||
|
||||
!leader ||
|
||||
!sandmasterModeSupported(game) ||
|
||||
!move ||
|
||||
!defaults ||
|
||||
!order
|
||||
!move
|
||||
)
|
||||
return null;
|
||||
const routes = saved?.key === draftKey ? saved.routes : defaults.routes;
|
||||
const routes = saved?.key === draftKey ? saved.routes : defaults?.routes ?? {};
|
||||
const declined = saved?.key === draftKey ? saved.declined : [];
|
||||
const draft = sandmasterMoveDraft(game, move, routes, declined);
|
||||
const draft = sandmasterMoveDraft(game, move, Object.keys(routes).length ? routes : undefined, declined, flight);
|
||||
const updateRoutes = (next: Record<string, string[]>) =>
|
||||
setSaved({ key: draftKey, routes: next, declined });
|
||||
const blocked = unavailableReason ?? draft.blocked;
|
||||
const blocked = sandmasterMovementBlocked(game, draft, unavailableReason, selected.flight);
|
||||
const leaderName =
|
||||
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
|
||||
leader;
|
||||
const destination = location(order.to, order.sector);
|
||||
const destination = order ? location(order.to, order.sector) : null;
|
||||
return (
|
||||
<section
|
||||
className="my-4 flex min-w-0 flex-col gap-3"
|
||||
@@ -141,7 +110,42 @@ export function SandmasterMovement({
|
||||
that collection, and select the exact sector route for every source.
|
||||
</p>
|
||||
<p className="fine">Provisional multi-pile policy: you choose which sector supplies the one spice; this is not a publisher allocation ruling.</p>
|
||||
{order.group.map(([from]) => {
|
||||
{(game.ornithopter || game.discoveryOrnithopter) && (
|
||||
<>
|
||||
<label htmlFor={`${id}-flight`}>Movement transport</label>
|
||||
<select id={`${id}-flight`} value={game.ornithopter?.active?.mode ?? selected.flight}
|
||||
disabled={busy || !!game.ornithopter?.active}
|
||||
onChange={event => setFlight(event.target.value as MovementFlight)}>
|
||||
{(['ordinary', 'range3', 'twoGroups', 'discovery'] as const).map(choice => {
|
||||
const offer = movementFlightSelection(game, choice);
|
||||
const label = choice === 'ordinary' ? 'Ordinary movement' : choice === 'range3'
|
||||
? 'Ornithopter card: fixed range three' : choice === 'twoGroups'
|
||||
? 'Ornithopter card: two different movements' : 'Carried Discovery Ornithopter';
|
||||
return <option key={choice} value={choice} disabled={!!offer.blocked}>
|
||||
{label}{offer.blocked ? ' — unavailable' : ''}
|
||||
</option>;
|
||||
})}
|
||||
</select>
|
||||
<p className="fine" aria-label="Provisional Sandmaster flight composition">
|
||||
Provisional flight composition: the fixed-range card and Discovery
|
||||
flight allow routes up to three territories. Each two-group card
|
||||
movement keeps normal range and uses different original forces.
|
||||
Choose each actual route and optional spice collection before moving.
|
||||
Only one flight source may be used; Planetologist remains separate.
|
||||
</p>
|
||||
{game.ornithopter?.active && !selected.transport.blocked && <p className="notice">
|
||||
{game.ornithopter.active.completed} card movements completed ·{' '}
|
||||
{game.ornithopter.active.remaining} remaining. Use the original unmoved cohort.
|
||||
</p>}
|
||||
</>
|
||||
)}
|
||||
{order?.noField && <p className="fine" aria-label="Provisional Sandmaster concealed marker">
|
||||
Provisional marker composition: your concealed No-Field moves as one
|
||||
force, including zero, and may collect along its route without revealing
|
||||
its value. A marker and physical forces in the same source sector share
|
||||
one route and collect only once per entered territory.
|
||||
</p>}
|
||||
{order && Object.keys(defaults?.routes ?? routes).map((from) => {
|
||||
const route = routes[from] ?? [from];
|
||||
const last = route.at(-1)!;
|
||||
const extensions =
|
||||
@@ -185,10 +189,10 @@ export function SandmasterMovement({
|
||||
disabled={
|
||||
busy ||
|
||||
JSON.stringify(route) ===
|
||||
JSON.stringify(defaults.routes[from])
|
||||
JSON.stringify(defaults?.routes[from])
|
||||
}
|
||||
onClick={() =>
|
||||
updateRoutes({ ...routes, [from]: defaults.routes[from] })
|
||||
defaults && updateRoutes({ ...routes, [from]: defaults.routes[from] })
|
||||
}
|
||||
>
|
||||
Use shortest route
|
||||
@@ -259,16 +263,9 @@ export function SandmasterMovement({
|
||||
{blocked}
|
||||
</output>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!blocked || !draft.action}
|
||||
aria-describedby={blocked ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !blocked && draft.action) act(draft.action);
|
||||
}}
|
||||
>
|
||||
Move with Sandmaster
|
||||
</Button>
|
||||
<SandmasterMovementAction game={game} move={move} routes={Object.keys(routes).length ? routes : undefined}
|
||||
declined={declined} flight={flight} busy={busy} unavailableReason={unavailableReason}
|
||||
reasonId={`${id}-reason`} act={act} />
|
||||
<p className="fine">
|
||||
Use the normal Move forces button to decline Sandmaster. A canceled
|
||||
movement collects no spice.
|
||||
|
||||
+41
-9
@@ -16,6 +16,40 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Sandmaster special-route collection — 26 October. Prototyped / Partial.**
|
||||
Physical range3/twoGroups, carried Discovery and the own concealed marker now use
|
||||
the existing signed route/pile choice. Fixed range stays3; different normal card
|
||||
groups use current range and each gets its existing collection opportunity.
|
||||
Declared legal detours and current positive piles survive the quote; original
|
||||
typed/marker cohorts, flight identities and one disposal/credit remain. Marker0/
|
||||
3/5 stays concealed without materialization. Shared transport choices, editable
|
||||
routes, pile/decline controls and guarded callbacks feed minimal legal policy.
|
||||
Existing allocation/reset composition remains provisional; no mode gate opens.
|
||||
|
||||
Three disjoint native/control lanes and root integration pass **14 files,130/130**,
|
||||
types/lint/build and **18 engine/GameTable SSR/actual selected-props callbacks**
|
||||
on unchanged source. Eight public E/R/A +choam/Skills paths earn card1, shipment8
|
||||
and real turn-two Spice blows (Basic73 / Advanced79 source actions); four public
|
||||
G/E/H Skills/Discovery paths acquire the token through actual Collection and use
|
||||
it with a real later blow (Basic65 / Advanced71). Collection/decline and current
|
||||
second-group piles finish in two-to-four continuation actions. Physical paths
|
||||
retain45 Treachery, Discovery33; each retains14 Skills/15 Traitors. Six labelled
|
||||
controlled marker paths supplement12 native paths; their positions/piles have
|
||||
no played chronology claim. Eight physical, seven Discovery, fifteen controlled
|
||||
and thirteen marker engine cases cover the slice. Feedback corrects an assumed
|
||||
native source-spice remainder, nullable projection narrowing and a test name.
|
||||
|
||||
A bounded inventory also finds first versions of all28 independent Skill bands
|
||||
connected. Stale absence claims are corrected; full interactions, polished AI
|
||||
and complete-mode acceptance remain due. [Inventory](LEADER_SKILLS_RUNTIME.md#independent-band-inventory--26-october-2026).
|
||||
|
||||
Frozen v3 report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-sandmaster-flight-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-sandmaster-flight-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
|
||||
SSR/selected-props callbacks do not certify DOM/hydration, browser/mobile/deployed
|
||||
acceptance or full modes/combinations. [Source and execution](SANDMASTER_MOVEMENT.md#flight-and-concealed-marker-routes--26-october-2026).
|
||||
|
||||
**Planetologist flight composition — 26 October. Prototyped / Partial.**
|
||||
Both skill alternatives now compose with physical range3/twoGroups and carried
|
||||
Discovery. Fixed3 stays3; twoGroups applies normal+1/cap3 or ordinary gathering
|
||||
@@ -166,14 +200,12 @@ deployed acceptance. Full Truthtrance, modes/combinations and final assurance
|
||||
remain open. [Battle controls](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
|
||||
[shipment controls](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
|
||||
|
||||
Next source-supported interaction: Sandmaster collection along physical
|
||||
Ornithopter range3/twoGroups and carried Discovery routes. Its printed moving-
|
||||
forces band excludes none of these items. The live `validateSandmasterOrder`
|
||||
and `game/sandmaster-movement.ts` adapters still refuse them. Preserve chosen
|
||||
paths, exact piles, one collection per entered territory per movement, current
|
||||
range, original cohorts and one commitment/disposal. A second card movement
|
||||
requotes current spice; income never enlarges unmoved quotas. Use actual trained
|
||||
sources and produced Spice blows, not changed played assignments or staged piles.
|
||||
Next delivery inventory: remaining standalone Richese card effects, six Terror
|
||||
effects and the independent Ecaz card variant named in the readiness row below.
|
||||
Use their current canonical checklist/handlers/controls to distinguish a missing
|
||||
primitive from a stale absence claim or unfinished combination, then deliver
|
||||
real independent gaps before more Skill combinations. All28 Skill bands have
|
||||
connected first versions; that finding does not establish all Dune rules complete.
|
||||
Bounded allied-marker review found an existing legal voluntary-reveal→endMovement
|
||||
route; the supplied
|
||||
physical15/36 text does not expressly add alliance separation as a forced reveal
|
||||
@@ -8504,7 +8536,7 @@ These links define bounded working behavior, not complete module certification.
|
||||
| --- | --- |
|
||||
| Basic core | Broader truthful commitments, unresolved special timing and integrated rules/human/AI acceptance. Ordinary Guild rate cancellation now exists: the `guildRate` prepayment response and `guildRateBlocked` turn window cover ordinary reserve shipment, eligible cross-planet transport and Guild return through the normalized `guildTransport`/shipment intents (`game/engine.ts`, `docs/GUILD_RATE_KARAMA_RULES.md`); the older 7 September readiness note that called it absent is superseded. |
|
||||
| Six-faction Advanced | Base gaps, provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. The user-authorized unfinished preview is available; setup matrices and complete samples do not certify full compliance. |
|
||||
| Twelve factions and all optional modules | Every missing effect, remaining skill bands, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. The 7 October 2026 rulings settled CHOAM's low-population opening income, the Richese exhausted/canceled cache count, Suk/Kwisatz loss counting, the Guild Secret Ally Homeworld reserve return and Sandmaster HMS-relocation eligibility; the preserved captures can be resumed. Detailed boundaries remain in the checklist/decision index. |
|
||||
| Twelve factions and all optional modules | Every missing effect, remaining skill interactions and integrated acceptance, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. The 7 October 2026 rulings settled CHOAM's low-population opening income, the Richese exhausted/canceled cache count, Suk/Kwisatz loss counting, the Guild Secret Ally Homeworld reserve return and Sandmaster HMS-relocation eligibility; the preserved captures can be resumed. Detailed boundaries remain in the checklist/decision index. |
|
||||
| AI | Maintain minimal legal participation and critical correctness fixes while game features are unfinished. Full implementation, strategy refinement and calibration wait until all non-AI features are complete; then target approximately 75% higher-tier wins for Medium/Easy, Hard/Medium and Brutal/Hard. See the [AI development plan](AI_DEVELOPMENT_PLAN.md). These targets remain unverified. |
|
||||
| Multiplayer | Broader disconnected/abandoned-seat recovery, unattended continuation and network-failure acceptance, while preserving authoritative versioning, custody, privacy and saves. |
|
||||
| Administration | Complete the [admin panel](ADMIN_PANEL.md): permissions, room directory and creation/removal, lifecycle controls, participant support, backup/restore and operational audit tools. Required before full AI refinement. |
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Sandmaster flight and concealed-marker routes
|
||||
|
||||
**Prototyped / Partial.** Physical range3/twoGroups, carried Discovery and the
|
||||
own No-Field use exact source routes and current chosen piles. Fixed3 remains3;
|
||||
each different card movement uses current normal range and its own one-per-
|
||||
entered-territory opportunity. Source/flight identity, current trainer, typed
|
||||
cohort and exact pile amounts bind the receipt. Chosen detours remain intact;
|
||||
declines earn0. The marker counts as one without materializing0/3/5. Route/pile
|
||||
editing and a guarded actual submit leaf share projected transport; minimal
|
||||
policies retain original destination priority. Legacy ordinary order fields stay.
|
||||
|
||||
Frozen **14-file130/130**, types/lint/build and **18 engine/GameTable SSR/actual
|
||||
selected-props callback paths** pass. Eight public physical sources earn card1,
|
||||
shipment8 and actual later Spice (Basic73 / Advanced79 actions); four public
|
||||
Discovery sources earn original Collection acquisition and real later Spice
|
||||
(Basic65 / Advanced71). Two-to-four continuation actions finish Collection,
|
||||
without replaying bonuses/disposal. Physical45/Discovery33 Treachery,14 Skills
|
||||
and15 Traitors remain. Six controlled marker callback paths make no played
|
||||
source claim. Eight physical, seven Discovery, fifteen control and thirteen
|
||||
marker engine cases cover the slice. Feedback repairs the native residual-spice
|
||||
assumption, nullable projection narrowing and reserved test variable name.
|
||||
|
||||
Bounded delivery inventory finds all28 independent Skill bands connected, while
|
||||
retaining Partial/Prototyped classifications and full acceptance gates; current
|
||||
absence wording is reconciled with the existing evidence.
|
||||
[Band inventory](LEADER_SKILLS_RUNTIME.md#independent-band-inventory--26-october-2026).
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-sandmaster-flight-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-sandmaster-flight-runtime/`. No saved-room/D1/
|
||||
server, reset/restart, deployment or recovery operation. SSR/selected-props
|
||||
callbacks do not prove DOM/hydration, browser/mobile/deployed or complete-mode
|
||||
acceptance. Full rules, interactions and final assurance remain open.
|
||||
[Source and contract](SANDMASTER_MOVEMENT.md#flight-and-concealed-marker-routes--26-october-2026).
|
||||
|
||||
## 26 October 2026 — Planetologist physical and Discovery flight matrix
|
||||
|
||||
**Prototyped / Partial.** Connect both skill alternatives to physical range3,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Leader Skill battle effects
|
||||
|
||||
13 September 2026. **Prototyped, with Partial rules coverage.** This follow-up
|
||||
connects both Rihani Decipherer bands and the lower battle bands of Mentat,
|
||||
Bureaucrat and Sandmaster. Normal Mentat questioning, Bureaucrat payments and
|
||||
Sandmaster movement collection remain missing, as do Spice Banker, Diplomat and
|
||||
Smuggler. Public module starts and publication remain gated.
|
||||
13 September 2026 checkpoint. **Prototyped, with Partial rules coverage.**
|
||||
This dated follow-up connected both Rihani bands and the lower battle bands of
|
||||
Mentat, Bureaucrat and Sandmaster. Its earlier absence claims for normal Mentat,
|
||||
payments, movement collection, Banker, Diplomat and Smuggler are superseded:
|
||||
the [26 October inventory](LEADER_SKILLS_RUNTIME.md#independent-band-inventory--26-october-2026)
|
||||
records connected first versions of all28 independent bands. Full interactions,
|
||||
mode acceptance and publication remain gated.
|
||||
|
||||
## Source contract
|
||||
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Leader Skills first functional prototype
|
||||
|
||||
## Independent band inventory — 26 October 2026
|
||||
|
||||
A bounded delivery inventory finds connected first versions of all28 independent
|
||||
normal/battle bands on the fourteen physical faces. This supersedes older
|
||||
absence claims below; it does not certify every interaction, complete modes,
|
||||
comprehensive verification, polished controls or AI strategy.
|
||||
|
||||
| Faces | Connected normal and battle effects |
|
||||
| --- | --- |
|
||||
| Warmaster and four weapon/defense disciplines | Shared role scoring and actual battle-plan/posture controls |
|
||||
| Bureaucrat and Spice Banker | Payment redirection, normal deferred income, stronghold penalty and sealed battle spending |
|
||||
| Mentat, Rihani, Diplomat and Suk Graduate | Private questioning, traitor inspection/exchange, defense copying/retreat and physical rescue |
|
||||
| Sandmaster, Smuggler and Planetologist | Route/victory collection, shipment/battle collection and movement/Special substitution |
|
||||
|
||||
The [physical faces](LEADER_SKILLS_RULES.md),
|
||||
[canonical implementation/control/AI checklist](../game/reference.ts) and its
|
||||
linked current helpers are the evidence. Current Sandmaster flight/marker
|
||||
delivery completes the previously guarded route adapters; no wholly absent
|
||||
independent band was identified. Keep `Prototyped` / `Partial` classifications.
|
||||
Recorded edge interpretations, including [empty-hand Mentat](MENTAT_QUESTION.md),
|
||||
valid module combinations, full games and final assurance remain open.
|
||||
|
||||
**17 October default cutover:** normal Spice Banker income is mandatory in
|
||||
fresh supported ordinary all-fourteen Skills setups, including their existing
|
||||
module and native-overlay envelopes. The separate income initializer/profile
|
||||
@@ -1373,9 +1395,11 @@ Other expansion rosters and combined optional modules remain gated. Native
|
||||
Advanced foreign gholas now follow the bounded Tleilaxu contract above; the
|
||||
recorded source-contract timing questions remain open. Planetologist has bounded
|
||||
movement and battle controls as described below.
|
||||
Remaining normal-band payments, ordinary Mentat activation, modified Smuggler collection,
|
||||
and the other guarded skill combinations still need connected effects. Diplomat
|
||||
retreat is a bounded prototype, not a complete simultaneous-effect ruling.
|
||||
The earlier missing normal-payment, ordinary Mentat and modified Smuggler
|
||||
claims are superseded by their connected current contracts above and the
|
||||
26 October independent-band inventory. Guarded interactions still require
|
||||
integration and acceptance. Diplomat retreat remains a bounded prototype,
|
||||
not a complete simultaneous-effect ruling.
|
||||
|
||||
## Planetologist follow-up
|
||||
|
||||
@@ -1389,8 +1413,9 @@ continuation and revalidate source custody before a response is spent.
|
||||
Six base factions remain the supported roster. Mixed BG origins are supported
|
||||
where ordinary rules determine the destination stance. Enemy-only mixed arrival
|
||||
still needs a combined flip response. Expansion faction/card/module combinations
|
||||
and full integrated acceptance remain unfinished. The remaining bands and skill families are
|
||||
listed above; this is partial prototype coverage.
|
||||
and full integrated acceptance remain unfinished. This dated roster/arrival
|
||||
boundary is superseded by current native and flight contracts above; all
|
||||
independent bands now have connected first versions, with partial coverage.
|
||||
|
||||
## Suk Graduate follow-up
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Sandmaster flight and concealed-marker collection — 26 October 2026
|
||||
|
||||
Compose the moving-forces normal band with both physical Ornithopter modes,
|
||||
carried Discovery and an own concealed No-Field. Fixed flight remains3; twoGroups
|
||||
retains current normal range and different original cohorts. Each accepted
|
||||
movement gets the existing provisional one-per-entered-territory collection,
|
||||
selecting one current positive pile or declining. Preserve chosen legal detours,
|
||||
actual source and flight identities, exact pile bounds and original disposal.
|
||||
Marker0/3/5 stays concealed without materialization. The reset and sector
|
||||
allocation remain explicit selected policy, not a joint publisher FAQ.
|
||||
[Source and execution](SANDMASTER_MOVEMENT.md#flight-and-concealed-marker-routes--26-october-2026).
|
||||
|
||||
## Planetologist physical and Discovery flight — 26 October 2026
|
||||
|
||||
Under 14 October choose-recommended authority, compose both skill alternatives
|
||||
|
||||
@@ -5,6 +5,69 @@ ground movement now connects the normal band through explicit routes, optional
|
||||
collection, player controls, all four AI profiles and saved continuation.
|
||||
This does not open public Leader Skills starts or publication.
|
||||
|
||||
## Flight and concealed-marker routes — 26 October 2026
|
||||
|
||||
Sandmaster's physical normal band applies when moving owned forces into or
|
||||
through a territory with spice; it excludes none of the three flight sources.
|
||||
The [physical Ornithopter](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#authority) changes
|
||||
range or grants two different normal movements, and Discovery E3p13 changes
|
||||
range for one later movement action. Neither removes route collection. The
|
||||
[No-Field contract](RICHESE_NO_FIELD_RULES.md#verified-lifecycle) counts a
|
||||
concealed marker as one force, including0. Compose these effects under existing
|
||||
14 October authority; no joint publisher FAQ is claimed.
|
||||
|
||||
Physical range3 and carried Discovery use a maximum of3. Physical twoGroups
|
||||
uses each group's current normal range. Each accepted movement gets its own
|
||||
one-per-entered-territory opportunity under the existing provisional reset
|
||||
policy, so a second different group can collect there again if spice remains.
|
||||
The source territory grants no bonus without actually re-entering it. Each
|
||||
territory selects one current positive pile or declines, retaining the existing
|
||||
provisional sector-allocation policy. Declining collection does not decline
|
||||
movement or preserve a spent card/token entitlement.
|
||||
|
||||
The original chosen route is authoritative, including a legal detour longer
|
||||
than the shortest path. Current route bounds, storm/entry and typed force/marker
|
||||
cohorts still apply. A physical flight fingerprint binds original card, event,
|
||||
mode and completed-group index; Discovery binds token/acquisition turn alongside
|
||||
its original parent receipt. The signed Sandmaster order retains exact source,
|
||||
range, route, pile amounts, trainer and move. Legacy ordinary orders add no new
|
||||
fields. No circular signature or public receipt is required in the action.
|
||||
|
||||
Marker-only routes remain concealed and do not materialize or debit reserves.
|
||||
Selected physical counters at the same marker sector share one declared route.
|
||||
Its value supplies no extra range or collection. First-flight arrivals do not
|
||||
enlarge the remaining original cohort, and spice income adds no counters or
|
||||
movement credit. The second movement requotes current piles. Original card/
|
||||
token disposal and earned allowance occur once through their existing flow.
|
||||
|
||||
The form offers ordinary/card/token transport before validating its source
|
||||
route, keeps route editing and selected-pile/decline controls, and guards the
|
||||
actual callback with current transport and signed quote. Minimal policy adds
|
||||
the same deterministic legal route choice while retaining original candidates
|
||||
and destination priority. Other normal-band skills and special relocations
|
||||
retain separate adapters. No saved root field, action kind or module gate opens.
|
||||
|
||||
**Frozen evidence:** v3 passes14 files,130/130, types/lint/build and18 engine/
|
||||
GameTable SSR/actual selected-props callback paths. Eight genuine public physical
|
||||
paths earn cache card1, four-counter shipment8 and actual produced Spice,
|
||||
using Basic73 / Advanced79 source actions. Four public Discovery paths earn
|
||||
original Collection acquisition and a later real Spice blow, using Basic65 /
|
||||
Advanced71 actions. Two-to-four continuations finish Collection, with45 physical-
|
||||
family or33 classic Treachery,14 Skills and15 Traitors retained. Six explicitly
|
||||
controlled marker paths supplement the12 native paths; no played positions,
|
||||
shipment or blow provenance is claimed for those supplements. Eight physical,
|
||||
seven Discovery, fifteen control and thirteen marker cases cover the slice and
|
||||
minimal policies. Positive source-pile exclusion in the Discovery seam is a
|
||||
controlled supplement; native source spice is preserved at its actual value,
|
||||
including0. Feedback corrects that earlier remainder assumption, projection
|
||||
narrowing and a reserved test variable.
|
||||
|
||||
Report/logs: private `20261026-sandmaster-flight-proof-v3/` under
|
||||
`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent
|
||||
`20261026-sandmaster-flight-runtime/`. No saved-room/D1/server, reset/restart,
|
||||
deployment or recovery operation. SSR/selected-props callbacks do not certify
|
||||
DOM/hydration, browser/mobile/deployed acceptance, full combinations or modes.
|
||||
|
||||
## Owned ground-route pile selection — 9 October 2026
|
||||
|
||||
Ordinary ground movement now offers every existing positive pile in each
|
||||
|
||||
+22
-6
@@ -105,7 +105,7 @@ import {
|
||||
} from './bot-mobility';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
|
||||
import { botPlanetologistFlightActions } from './bot-planetologist-flight';
|
||||
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
|
||||
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection, sandmasterLeader } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm';
|
||||
import { fremenCunningPublicDestinationBlock } from './nexus-fremen-cunning';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
@@ -598,6 +598,21 @@ function ornithopterMoves(g: GameView): Action[] {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** Route collection is optional; retain the original legal candidate alongside
|
||||
* its existing deterministic route/pile choice, without tuning destination rank. */
|
||||
function sandmasterMovementCandidates(game: GameView, actions: Action[]): Action[] {
|
||||
if (!sandmasterLeader(game, game.me)) return actions;
|
||||
const seen = new Set<string>();
|
||||
return actions.flatMap(action => {
|
||||
const choice = action.sandmaster === undefined ? sandmasterDefaultChoice(game, game.me, action) : null;
|
||||
const candidates = choice?.collect.length ? [{ ...action, sandmaster: choice }, action] : [action];
|
||||
return candidates.filter(candidate => {
|
||||
const key = JSON.stringify(candidate);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key); return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
function candidatePlan(a: Action): Plan {
|
||||
const plan: Plan = {
|
||||
...(Number(a.bankerSpice ?? 0)
|
||||
@@ -3375,10 +3390,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const flightTargets = destinations(g).filter(target => target.score > -1000)
|
||||
.map(target => ({ territory: target.t, sector: target.s }));
|
||||
const skillCardMoves = botPlanetologistFlightActions(g, flightTargets, ['range3', 'twoGroups']);
|
||||
const cardMoves = [...skillCardMoves, ...ornithopterMoves(g)];
|
||||
const cardMoves = sandmasterMovementCandidates(g, [...skillCardMoves, ...ornithopterMoves(g)]);
|
||||
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked && !g.ornithopter?.active
|
||||
? [...botPlanetologistFlightActions(g, flightTargets, ['discovery']),
|
||||
...discoveryFlightBotActions(g, flightTargets)]
|
||||
? sandmasterMovementCandidates(g, [...botPlanetologistFlightActions(g, flightTargets, ['discovery']),
|
||||
...discoveryFlightBotActions(g, flightTargets)])
|
||||
: [];
|
||||
if (discoveryMoves.length && (me.shipped || me.reserves === 0))
|
||||
return discoveryMoves;
|
||||
@@ -3832,6 +3847,7 @@ function policyActions(g: GameView): Action[] {
|
||||
// Moving before an unused shipment is legal; retain these candidates after
|
||||
// the existing shipment choices without changing their ordinary priority.
|
||||
actions.push(...skillCardMoves);
|
||||
actions.push(...cardMoves.filter(action => action.sandmaster !== undefined));
|
||||
if (reveal && !actions.length) actions.push(reveal);
|
||||
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
|
||||
actions.unshift(...emperorHomeworldMoveActions(g));
|
||||
@@ -3845,9 +3861,9 @@ function policyActions(g: GameView): Action[] {
|
||||
const cunning = g.nexusGuildCunning?.offer && nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event);
|
||||
if (cunning && (me.reserves > 0 || Object.values(me.forces).some(n => n > 0))) actions.push(cunning);
|
||||
actions.push({ type: 'endMovement' });
|
||||
return g.ornithopter?.active
|
||||
return sandmasterMovementCandidates(g, g.ornithopter?.active
|
||||
? actions.filter((action) => action.type !== 'move')
|
||||
: actions;
|
||||
: actions);
|
||||
}
|
||||
if (g.phase === 6) {
|
||||
const b = g.battle;
|
||||
|
||||
+14
-3
@@ -17320,13 +17320,24 @@ function ornithopterView(g: Game, p: Player) {
|
||||
};
|
||||
}
|
||||
function sandmasterOrder(g: Game, p: Player, move: MovementOrder): SandmasterOrder {
|
||||
const frame = move.ornithopterEvent ? g.ornithopter : null;
|
||||
const flight: SandmasterOrder['flight'] = move.discoveryFlight
|
||||
? { kind: 'discovery', token: move.discoveryFlight.token, acquiredTurn: move.discoveryFlight.acquiredTurn }
|
||||
: frame ? { kind: 'physical', card: frame.card.id, mode: frame.mode,
|
||||
completed: frame.completed, event: frame.event } : undefined;
|
||||
return { group: move.group, eliteGroup: move.eliteGroup, elite: move.elite, origin: move.origin,
|
||||
to: move.to, sector: move.sector, range: movementOrderRange(g, p, move) };
|
||||
to: move.to, sector: move.sector, range: movementOrderRange(g, p, move),
|
||||
...(move.noField ? { noField: move.noField } : {}), ...(flight ? { flight } : {}) };
|
||||
}
|
||||
function validateSandmasterOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
if (move.sandmaster === undefined) return;
|
||||
requireRule(!move.source && !move.noField && !move.planetologist && !move.ornithopterEvent && !move.discoveryFlight,
|
||||
'Sandmaster with special movement routes is still being integrated.');
|
||||
requireRule(!move.source && !move.planetologist,
|
||||
'Sandmaster with this special relocation or another normal-band skill is still being integrated.');
|
||||
requireRule(!(move.ornithopterEvent && move.discoveryFlight),
|
||||
'Choose one Ornithopter flight source for this Sandmaster movement.');
|
||||
requireRule(!move.ornithopterEvent ||
|
||||
(g.ornithopter?.event === move.ornithopterEvent && g.ornithopter.player === p.id),
|
||||
'The Sandmaster route lost its original Ornithopter frame.');
|
||||
nexusRule(() => validateSandmasterMovement(g, p.id, sandmasterOrder(g, p, move), move.sandmaster!));
|
||||
}
|
||||
function sandmasterIntegrity(g: Game) {
|
||||
|
||||
+7
-6
@@ -5733,8 +5733,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. All twenty-eight independent bands have connected first versions; full interaction review, polish and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'Sandmaster may collect along physical Ornithopter and carried Discovery routes using their actual range, exact declared path and current piles. Each different card movement has its own one-per-entered-territory opportunity; a concealed No-Field counts as one moving force without materializing its value. Choose one pile or decline per entered territory. Original source cohorts and disposal remain; allocation/reset are provisional.',
|
||||
'Planetologist may provisionally compose either movement alternative with physical Ornithopter or carried Discovery. Fixed flight stays three, including both gather origins. In two-group card mode, each action chooses boosted normal range or ordinary-range gathering; a gather counts as one card movement. Different original counters must supply the second movement, even after merging. Current trainer, source events, earlier-turn token ownership and one disposal remain.',
|
||||
'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.',
|
||||
'Classic Nexus core with base33 or selected36 permits optional all14, original Tech3+ and Advanced Strongholds2+. Offers, starting-card order, mandatory normal Banker and native reactions remain. Actual third-party Bureaucrat sees the real Guild-card recipient. Tech pays only at its matching phase end; Banker waits for Mentat. Earned local support does not become personal money or reserve fees. Selection/printed roles are public capabilities; other native/module envelopes stay separate.',
|
||||
@@ -5775,11 +5776,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
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'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Planetologist flight composes all six alternatives with exact original cohorts and signed Discovery origins; ordinary no-skill receipt bytes remain. Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/planetologist-flight.ts','game/bot-planetologist-flight.ts','game/discovery-flight.ts','components/planetologist-movement-action.tsx','tests/planetologist-flight-controls.test.tsx','tests/planetologist-flight-profile.test.ts','tests/fixture-planetologist-ornithopter.ts','tests/planetologist-ornithopter-engine.test.ts','tests/fixture-planetologist-discovery-flight.ts','tests/planetologist-discovery-flight-engine.test.ts','game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Shared owned-flight selection supplies current fields, fixed ranges and whole-map cohorts to the existing form and guarded actual submit. Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'Minimal policies offer legal skill flights from the same projected selection and original quotas, without strategic tuning. All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'Gather-as-one-card-movement and fixed-three overrides are explicitly provisional; no joint FAQ or complete modes are claimed. All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{area:'Verification',status:'Partial',detail:"Native paid-card and earlier-turn Discovery paths, controlled callbacks and receipt regressions cover the selected flight matrix. Browser/deployed and final assurance remain due. Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
{ area:'Implementation',status:'Partial',detail:'Sandmaster flight/marker orders bind actual transport and source while preserving exact declared routes, live piles and original cohorts. Planetologist flight composes all six alternatives with exact original cohorts and signed Discovery origins; ordinary no-skill receipt bytes remain. Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/sandmaster-movement.ts','components/sandmaster-movement-action.tsx','tests/fixture-sandmaster-ornithopter.ts','tests/sandmaster-ornithopter-engine.test.ts','tests/fixture-sandmaster-discovery-flight.ts','tests/sandmaster-discovery-flight-engine.test.ts','tests/fixture-sandmaster-marker.ts','tests/sandmaster-marker-engine.test.ts','tests/sandmaster-flight-controls.test.tsx','docs/SANDMASTER_MOVEMENT.md','game/planetologist-flight.ts','game/bot-planetologist-flight.ts','game/discovery-flight.ts','components/planetologist-movement-action.tsx','tests/planetologist-flight-controls.test.tsx','tests/planetologist-flight-profile.test.ts','tests/fixture-planetologist-ornithopter.ts','tests/planetologist-ornithopter-engine.test.ts','tests/fixture-planetologist-discovery-flight.ts','tests/planetologist-discovery-flight-engine.test.ts','game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Sandmaster shares projected transport selection, editable routes, pile/decline choices and a guarded actual submit leaf. Shared owned-flight selection supplies current fields, fixed ranges and whole-map cohorts to the existing form and guarded actual submit. Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'Minimal Sandmaster candidates retain deterministic legal collection on existing normal/card/token moves without tuning destination priorities. Minimal policies offer legal skill flights from the same projected selection and original quotas, without strategic tuning. All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'Sandmaster flight eligibility, concealed-one-force composition, existing provisional reset/allocation and legacy ordinary order compatibility are recorded. Gather-as-one-card-movement and fixed-three overrides are explicitly provisional; no joint FAQ or complete modes are claimed. All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{area:'Verification',status:'Partial',detail:"Native trained card/token sources use actual produced Spice piles; controlled marker and callback supplements cover the new route. Complete modes and final assurance remain open. Native paid-card and earlier-turn Discovery paths, controlled callbacks and receipt regressions cover the selected flight matrix. Browser/deployed and final assurance remain due. Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+71
-41
@@ -4,6 +4,8 @@ import { GRAPH, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION, v
|
||||
import { isAdvisor } from './advisors';
|
||||
import { strongholdPathBlocked } from './occupancy';
|
||||
import { botMovementRange } from './bot-mobility';
|
||||
import { planetologistFlightSelection as movementFlightSelection } from './planetologist-flight';
|
||||
import { validateCohortSelection, type MovementMarker, type OrnithopterMode } from './ornithopter';
|
||||
|
||||
export class SandmasterMovementError extends Error {}
|
||||
export type SandmasterChoice = {
|
||||
@@ -18,7 +20,17 @@ export type SandmasterOrder = {
|
||||
to: string;
|
||||
sector: number;
|
||||
range: number;
|
||||
noField?: MovementMarker;
|
||||
flight?: SandmasterFlight;
|
||||
};
|
||||
export type SandmasterFlight =
|
||||
| { kind: 'physical'; card: string; mode: OrnithopterMode; completed: number; event?: string }
|
||||
| { kind: 'discovery'; token: string; acquiredTurn: number };
|
||||
|
||||
/** A marker and physical counters in the same sector share one declared route. */
|
||||
export function sandmasterOrderSources(order: SandmasterOrder): string[] {
|
||||
return [...new Set([...order.group.map(([key]) => key), ...(order.noField ? [order.noField.from] : [])])];
|
||||
}
|
||||
export type SandmasterMovement = SandmasterChoice & {
|
||||
leader: string;
|
||||
turn: number;
|
||||
@@ -236,10 +248,11 @@ export function quoteSandmasterMovement(
|
||||
Array.isArray(value.collect),
|
||||
'Choose Sandmaster routes and optional collection territories.',
|
||||
);
|
||||
const sources = move.group.map(([key]) => key);
|
||||
const physical = move.group.map(([key]) => key);
|
||||
const sources = sandmasterOrderSources(move);
|
||||
requireMove(
|
||||
sources.length > 0 &&
|
||||
new Set(sources).size === sources.length &&
|
||||
new Set(physical).size === physical.length &&
|
||||
JSON.stringify(Object.keys(value.routes).sort()) ===
|
||||
JSON.stringify([...sources].sort()),
|
||||
'Every moving source sector needs its own Sandmaster route.',
|
||||
@@ -330,53 +343,70 @@ export function sandmasterActionOrder(
|
||||
): SandmasterOrder | null {
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (
|
||||
!p ||
|
||||
!p || player !== g.me ||
|
||||
action.type !== 'move' ||
|
||||
action.noField !== undefined ||
|
||||
action.planetologist !== undefined ||
|
||||
action.movementCard !== undefined ||
|
||||
action.ornithopterEvent !== undefined ||
|
||||
action.discoveryOrnithopter !== undefined ||
|
||||
g.ornithopter?.active
|
||||
action.planetologist !== undefined
|
||||
)
|
||||
return null;
|
||||
const group: [string, number][] = record(action.forces)
|
||||
? (Object.entries(action.forces).filter(
|
||||
([, n]) => typeof n === 'number' && n > 0,
|
||||
) as [string, number][])
|
||||
: typeof action.from === 'string' && typeof action.amount === 'number'
|
||||
? [[action.from, action.amount]]
|
||||
: [];
|
||||
if (
|
||||
!group.length ||
|
||||
group.some(
|
||||
([key, n]) =>
|
||||
!Number.isSafeInteger(n) || n <= 0 || n > (p.forces[key] ?? 0),
|
||||
) ||
|
||||
new Set(group.map(([key]) => splitLocation(key).territory)).size !== 1 ||
|
||||
typeof action.territory !== 'string' ||
|
||||
typeof action.sector !== 'number'
|
||||
)
|
||||
return null;
|
||||
const eliteGroup = Object.fromEntries(
|
||||
group.map(([key]) => [
|
||||
key,
|
||||
Number(
|
||||
record(action.eliteForces)
|
||||
? (action.eliteForces[key] ?? 0)
|
||||
: (action.elite ?? 0),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const kind = action.discoveryOrnithopter !== undefined ? 'discovery'
|
||||
: action.movementCard !== undefined ? action.ornithopter : 'ordinary';
|
||||
if (kind !== 'ordinary' && kind !== 'discovery' && kind !== 'range3' && kind !== 'twoGroups') return null;
|
||||
const transport = movementFlightSelection(g, kind);
|
||||
if (transport.blocked || ['movementCard', 'ornithopter', 'ornithopterEvent', 'discoveryOrnithopter']
|
||||
.some(key => action[key] !== transport.fields[key])) return null;
|
||||
const entries = action.forces !== undefined
|
||||
? record(action.forces) ? Object.entries(action.forces) : null
|
||||
: typeof action.from === 'string' ? [[action.from, action.amount] as const] : null;
|
||||
if (!entries || typeof action.territory !== 'string' || !Number.isSafeInteger(action.sector) ||
|
||||
!validGameLocation(g, action.territory, action.sector as number)) return null;
|
||||
const group: [string, number][] = [], eliteGroup: Record<string, number> = {};
|
||||
for (const [key, count] of entries) {
|
||||
const at = splitLocation(key), owned = p.forces[key] ?? 0, ownedElite = p.elites?.forces[key] ?? 0;
|
||||
if (!Number.isSafeInteger(count) || (count as number) < 0 || (count as number) > owned ||
|
||||
key !== location(at.territory, at.sector) || !validGameLocation(g, at.territory, at.sector)) return null;
|
||||
if (!count) continue;
|
||||
const minimum = Math.max(0, (count as number) - (owned - ownedElite));
|
||||
const elite = (action.forces !== undefined
|
||||
? record(action.eliteForces) ? action.eliteForces[key] : undefined : action.elite) ?? minimum;
|
||||
if (!Number.isSafeInteger(elite) || (elite as number) < minimum ||
|
||||
(elite as number) > Math.min(count as number, ownedElite)) return null;
|
||||
group.push([key, count as number]); eliteGroup[key] = elite as number;
|
||||
}
|
||||
const info = g.richeseNoField?.owner === player ? g.richeseNoField : null;
|
||||
const marker = info?.private?.deployed;
|
||||
const noField: MovementMarker | undefined = action.noField === undefined ? undefined
|
||||
: info && marker?.controller === player && marker.tokenId === action.noField && info.event === action.event
|
||||
? { tokenId: marker.tokenId, event: info.event,
|
||||
from: location(marker.location.territory, marker.location.sector) } : undefined;
|
||||
if (action.noField !== undefined && (!noField || g.homeworldMobility?.noFieldMovementBlocked)) return null;
|
||||
const origins = new Set([...group.map(([key]) => splitLocation(key).territory),
|
||||
...(noField ? [splitLocation(noField.from).territory] : [])]);
|
||||
if (origins.size !== 1) return null;
|
||||
if (transport.cohort) {
|
||||
try { validateCohortSelection(transport.cohort, p.forces, p.elites?.forces ?? {},
|
||||
Object.fromEntries(group), eliteGroup, noField); }
|
||||
catch { return null; }
|
||||
}
|
||||
const elite = Object.values(eliteGroup).reduce((a, b) => a + b, 0);
|
||||
const frame = g.ornithopter;
|
||||
const flight: SandmasterFlight | undefined = typeof transport.fields.discoveryOrnithopter === 'string'
|
||||
? { kind: 'discovery', token: transport.fields.discoveryOrnithopter,
|
||||
acquiredTurn: g.discoveryOrnithopter!.acquiredTurn }
|
||||
: typeof transport.fields.movementCard === 'string' || typeof transport.fields.ornithopterEvent === 'string'
|
||||
? { kind: 'physical', card: frame!.card.id,
|
||||
mode: frame!.active?.mode ?? kind as OrnithopterMode,
|
||||
completed: frame!.active?.completed ?? 0,
|
||||
...(frame!.active ? { event: frame!.active.event } : {}) } : undefined;
|
||||
return {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin: splitLocation(group[0][0]).territory,
|
||||
origin: [...origins][0],
|
||||
to: action.territory,
|
||||
sector: action.sector,
|
||||
range: botMovementRange(g, p, elite),
|
||||
sector: action.sector as number,
|
||||
range: transport.rangeOverride ?? botMovementRange(g, p, elite),
|
||||
...(noField ? { noField } : {}),
|
||||
...(flight ? { flight } : {}),
|
||||
};
|
||||
}
|
||||
export function sandmasterDefaultChoice(
|
||||
@@ -388,7 +418,7 @@ export function sandmasterDefaultChoice(
|
||||
const move = sandmasterActionOrder(g, player, action);
|
||||
if (!move) return null;
|
||||
const routes: Record<string, string[]> = {};
|
||||
for (const [from] of move.group) {
|
||||
for (const from of sandmasterOrderSources(move)) {
|
||||
const route = sandmasterShortestRoute(
|
||||
g,
|
||||
player,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { gameTerritories, location, splitLocation } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
sandmasterCollectionPiles, sandmasterEnteredTerritories, sandmasterLeader,
|
||||
sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice,
|
||||
} from '../game/sandmaster-movement';
|
||||
import { skillOfferRolls } from './fixture-independent-card-modules';
|
||||
import {
|
||||
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
|
||||
revealClassicDiscoverySkills,
|
||||
} from './fixture-discovery-classic-skills';
|
||||
import {
|
||||
planetologistDiscoveryFlightInventory, planetologistDiscoveryFlightPhysical,
|
||||
} from './fixture-planetologist-discovery-flight';
|
||||
|
||||
export type SandmasterDiscoveryFlightStep = { actor: string; action: Action };
|
||||
export const sandmasterDiscoveryFlightClean = classicDiscoverySkillsClean;
|
||||
export const sandmasterDiscoveryFlightInventory = planetologistDiscoveryFlightInventory;
|
||||
export const sandmasterDiscoveryFlightPhysical = planetologistDiscoveryFlightPhysical;
|
||||
export function sandmasterDiscoveryFlightPlayer(game: Game, actor = 'guild') {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export function actSandmasterDiscoveryFlight(game: Game, actor: string, action: Action,
|
||||
actions: SandmasterDiscoveryFlightStep[] = []): Game {
|
||||
const next = applyAction(game, actor, action);
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
export function advanceSandmasterDiscoveryFlight(game: Game, until: (state: Game) => boolean,
|
||||
actions: SandmasterDiscoveryFlightStep[] = []): Game {
|
||||
return advanceClassicDiscoverySkills(game, until, actions);
|
||||
}
|
||||
/** Select the first original skill lottery, before any technique is trained. */
|
||||
function originalOffers<T>(game: Game, run: () => T): T {
|
||||
const rolls = skillOfferRolls(game, { guild: 'sandmaster', emperor: 'killer-medic', harkonnen: 'prana-bindu-adept' });
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
/** Plan against the actual second Storm. Only one original still-unplayed
|
||||
* positive Spice Card is selected; it must fund a genuinely entered territory. */
|
||||
function selectBlowRoute(game: Game, actor: string, source: string) {
|
||||
for (const place of gameTerritories(game)) {
|
||||
if (place.type !== 'rock' || game.players.some(player => presenceAt(player, place.id))) continue;
|
||||
for (const sector of place.sectors) {
|
||||
const targetLocation = location(place.id, sector), route = sandmasterShortestRoute(game, actor, source, targetLocation, 3);
|
||||
if (!route || sandmasterRouteDistance(game, route) !== 3) continue;
|
||||
const entered = sandmasterEnteredTerritories({ [source]: route });
|
||||
const card = game.spiceDeck.find(card => 'territory' in card && !card.discovery &&
|
||||
card.sector !== game.storm && entered.includes(card.territory));
|
||||
if (card && 'territory' in card) return { route, targetLocation, card: structuredClone(card), pile: location(card.territory, card.sector) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Public G/E/H Skills + Discoveries, actual Sandmaster offer/training and an
|
||||
* actual turn-one paid collector acquire the original token in Collection.
|
||||
* Native Storm and the next real Spice blow supply the later positive route.
|
||||
* Only original unopened lotteries/unplayed cards are selected. No played
|
||||
* token, army, wallet, trainer, phase, receipt or spice pile is installed. */
|
||||
export function createSandmasterDiscoveryFlightFixture(options: { advanced?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false, actor = 'guild', observer = 'emperor', opponent = 'harkonnen';
|
||||
let game = createGame('SANDMASTERDISCOVERYFLIGHT', newPlayer(actor, 'Guild', 'guild'), advanced);
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer(opponent, 'Harkonnen', 'harkonnen'));
|
||||
const actions: SandmasterDiscoveryFlightStep[] = [], initial = structuredClone(game);
|
||||
const act = (owner: string, action: Action) => { game = actSandmasterDiscoveryFlight(game, owner, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSandmasterDiscoveryFlight(game, until, actions); };
|
||||
act(actor, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
|
||||
act(actor, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const ready = structuredClone(game);
|
||||
originalOffers(game, () => act(actor, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }));
|
||||
const started = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.ok(game.leaderSkills && game.discoveries);
|
||||
const native = createClassicDiscoverySkillsFixture({ initial: game, skill: 'sandmaster', face: 'ornithopter', amount: 1 });
|
||||
actions.push(...native.actions.map(step => structuredClone(step)));
|
||||
// Retain the original successful deal and actions; never replay setup's RNG.
|
||||
game = native.game; const collection = structuredClone(game);
|
||||
const source = location(native.parent, native.parentSector), sourcePile = source;
|
||||
game = revealClassicDiscoverySkills(native, actions);
|
||||
const acquired = structuredClone(game), originalToken = structuredClone(game.discoveries!.tokens.find(token => token.id === native.token)!);
|
||||
assert.equal(originalToken.status, 'carried'); assert.equal(originalToken.owner, actor); assert.equal(originalToken.acquiredTurn, 1);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && sandmasterDiscoveryFlightClean(state));
|
||||
const beforeBlowSelection = structuredClone(game), selected = selectBlowRoute(game, actor, source);
|
||||
assert.ok(selected, 'A real unplayed normal Spice Card must fund an entered territory on an exact-three route to empty rock');
|
||||
const index = game.spiceDeck.findIndex(card => JSON.stringify(card) === JSON.stringify(selected.card)); assert.ok(index >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
||||
if (advanced) {
|
||||
const second = game.spiceDeck.findIndex((card, index) => index > 0 && 'territory' in card && !card.discovery &&
|
||||
card.territory !== splitLocation(source).territory && card.territory !== selected.card.territory);
|
||||
assert.ok(second > 0); game.spiceDeck.splice(1, 0, game.spiceDeck.splice(second, 1)[0]);
|
||||
}
|
||||
const beforeBlow = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && sandmasterDiscoveryFlightClean(state));
|
||||
assert.ok(game.spice[selected.pile] > 0, 'The selected real next-turn Spice blow must remain available before movement');
|
||||
const beforeShipment = structuredClone(game), shipmentStep: SandmasterDiscoveryFlightStep = { actor,
|
||||
action: { type: 'ship', ...splitLocation(source), amount: 1, elite: 0, allyPayment: 0 } };
|
||||
act(actor, shipmentStep.action); reach(sandmasterDiscoveryFlightClean);
|
||||
const afterShipment = structuredClone(game), sourceCount = sandmasterDiscoveryFlightPlayer(game, actor).forces[source];
|
||||
assert.equal(sourceCount, 2);
|
||||
assert.equal(sandmasterDiscoveryFlightPlayer(beforeShipment, actor).spice - sandmasterDiscoveryFlightPlayer(game, actor).spice, 1);
|
||||
assert.equal(sandmasterDiscoveryFlightPlayer(beforeShipment, actor).reserves - sandmasterDiscoveryFlightPlayer(game, actor).reserves, 1);
|
||||
const trainer = sandmasterLeader(game, actor); assert.ok(trainer);
|
||||
const routes = { [source]: selected.route }, offeredPiles = sandmasterCollectionPiles(game, routes);
|
||||
const choice: SandmasterChoice = { routes, collect: Object.values(offeredPiles).map(piles => piles[0]) };
|
||||
assert.ok(choice.collect.includes(selected.pile)); assert.ok(!choice.collect.includes(sourcePile));
|
||||
const target = splitLocation(selected.targetLocation);
|
||||
const action: Action = { type: 'move', from: source, amount: sourceCount, elite: 0,
|
||||
discoveryOrnithopter: native.token, sandmaster: choice, ...target };
|
||||
const declineChoice: SandmasterChoice = { routes: structuredClone(routes), collect: [] };
|
||||
const declineAction: Action = { ...action, sandmaster: declineChoice };
|
||||
const ordinary: Action = { ...action }; delete ordinary.discoveryOrnithopter;
|
||||
const plainAction: Action = { ...action }; delete plainAction.sandmaster;
|
||||
const originalInventory = sandmasterDiscoveryFlightInventory(game);
|
||||
return { game, rawGame: structuredClone(game), sourceGame: structuredClone(game), advanced, actor, owner: actor,
|
||||
observer, opponent, trainer, leader: trainer, token: native.token, originalToken, acquiredTurn: originalToken.acquiredTurn!,
|
||||
initial, ready, started, offered: native.setup, collection, acquired, native,
|
||||
beforeBlowSelection, beforeBlow, blowCard: selected.card, beforeShipment, shipmentStep, afterShipment,
|
||||
source, sourcePile, sourceSpice: game.spice[sourcePile] ?? 0, sourceCount,
|
||||
route: selected.route, routes, target, targetLocation: selected.targetLocation,
|
||||
pile: selected.pile, piles: choice.collect, offeredPiles, choice, declineChoice, action, declineAction, ordinary, plainAction,
|
||||
originalInventory, originalPhysical: sandmasterDiscoveryFlightPhysical(game), originalSpice: structuredClone(game.spice),
|
||||
actions, publicActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original public G/E/H Basic/Advanced Skills + Discoveries module selections, readiness and start; original all14 offer lottery selects Sandmaster before actual private training. Advanced keeps its explicit preview start. No post-hoc setup replay occurs.',
|
||||
...native.staging,
|
||||
'Actual turn-one one-force paid Guild shipment reaches the real Discovery parent. Actual inspect/reveal acquires the original physical Ornithopter on turn one; native Collection retains its actual source-spice result, which may be zero. Native Mentat, next Storm and the real phase-one opening follow.',
|
||||
'Only a still-unplayed ordinary Spice Card is reordered at that real second-turn Spice opening to fund a territory entered by the selected exact-three route; Advanced also puts a separate original ordinary card second. Actual native Spice resolution creates the positive pile. No spice, force, wallet, phase or token is installed.',
|
||||
'Actual turn-two paid one-force Guild shipment joins the original source counter. Stop before the fixed-three Discovery movement to empty rock; optional collection, one native movement/token consumption and the later Collection continuation remain caller-owned. This empty-rock route has no natural opposing movement-response frame.',
|
||||
] };
|
||||
}
|
||||
export type SandmasterDiscoveryFlightFixture = ReturnType<typeof createSandmasterDiscoveryFlightFixture>;
|
||||
export function finishSandmasterDiscoveryFlight(fixture: SandmasterDiscoveryFlightFixture, game: Game,
|
||||
actions: SandmasterDiscoveryFlightStep[] = []) {
|
||||
return advanceSandmasterDiscoveryFlight(game, state => state.turn === fixture.game.turn &&
|
||||
state.phase === 7 && sandmasterDiscoveryFlightClean(state), actions);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
import { sandmasterDefaultChoice } from '../game/sandmaster-movement';
|
||||
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
|
||||
import { sandmasterMove } from './sandmaster-movement-fixture';
|
||||
|
||||
/** Controlled conserved movement/pile supplement, not a played shipment source.
|
||||
* Native flight fixtures separately earn their actual sources and Spice blows. */
|
||||
export function createSandmasterMarkerFixture(value: NoFieldValue = 0, physical = false, advanced = false) {
|
||||
const game = smugglerShipmentGame('richese', advanced, 'sandmaster', 'guild', ['choam']);
|
||||
const actor = 'p', player = game.players.find(seat => seat.id === actor)!;
|
||||
player.forces = { 'red_chasm:7': 3, 'arrakeen:10': 1 };
|
||||
player.reserves = 16; player.tanks = 0; player.shipped = true; player.moved = 0;
|
||||
const state = player.noField ?? createRicheseNoField(['controlled-marker-0', 'controlled-marker-3', 'controlled-marker-5']);
|
||||
const token = state.tokens.find(candidate => candidate.value === value); assert.ok(token);
|
||||
player.noField = deployRicheseNoField(state, { tokenId: token.id, controller: actor,
|
||||
location: { territory: 'red_chasm', sector: 7 } });
|
||||
player.noFieldEvent = 'controlled-sandmaster-marker-event';
|
||||
game.spice = {};
|
||||
const route = sandmasterMove(game, 2);
|
||||
const action: Action = { type: 'move', forces: physical ? { 'red_chasm:7': 3 } : {},
|
||||
noField: token.id, event: player.noFieldEvent, territory: route.territory, sector: route.sector };
|
||||
const choice = sandmasterDefaultChoice(viewGame(game, actor), actor, action); assert.ok(choice);
|
||||
action.sandmaster = choice;
|
||||
assert.equal(Object.keys(choice.routes).length, 1, 'Mixed marker/physical source shares one declared route');
|
||||
assert.equal(choice.collect.length, 2);
|
||||
return { game, actor, token, value, physical, advanced, action, choice,
|
||||
source: 'red_chasm:7', target: `${String(action.territory)}:${String(action.sector)}`,
|
||||
originalNoField: structuredClone(player.noField), originalEvent: player.noFieldEvent,
|
||||
originalSpice: structuredClone(game.spice), originalWallet: player.spice,
|
||||
staging: 'Explicitly controlled physical positions, deployed original token and positive piles; no native shipment/Spice chronology claimed.' };
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameTerritories, location, splitLocation } from '../game/board';
|
||||
import { groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { sandmasterLeader, sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice } from '../game/sandmaster-movement';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills';
|
||||
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
|
||||
import {
|
||||
actPlanetologistOrnithopter, planetologistOrnithopterClean, planetologistOrnithopterDestination,
|
||||
planetologistOrnithopterInventory, planetologistOrnithopterPlayer, planetologistOrnithopterStocks,
|
||||
type PlanetologistOrnithopterStep,
|
||||
} from './fixture-planetologist-ornithopter';
|
||||
|
||||
export const SANDMASTER_ORNITHOPTER = 'richese-ornithopter';
|
||||
export type SandmasterOrnithopterStep = PlanetologistOrnithopterStep;
|
||||
export const actSandmasterOrnithopter = actPlanetologistOrnithopter;
|
||||
export const sandmasterOrnithopterClean = planetologistOrnithopterClean;
|
||||
export const sandmasterOrnithopterPlayer = planetologistOrnithopterPlayer;
|
||||
export const sandmasterOrnithopterStocks = planetologistOrnithopterStocks;
|
||||
export const sandmasterOrnithopterDestination = planetologistOrnithopterDestination;
|
||||
export function sandmasterOrnithopterInventory(game: Game) {
|
||||
return { ...planetologistOrnithopterInventory(game),
|
||||
spice: [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort() };
|
||||
}
|
||||
/** Real cache producer and original private bid; no hand/card substitution. */
|
||||
export function nextSandmasterOrnithopterStep(game: Game): SandmasterOrnithopterStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === SANDMASTER_ORNITHOPTER))
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: SANDMASTER_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
|
||||
if (!decision && game.richeseAuction?.cardId === SANDMASTER_ORNITHOPTER &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
}
|
||||
return nextChoamIndependentSkillsStep(game);
|
||||
}
|
||||
export function advanceSandmasterOrnithopter(game: Game, until: (state: Game) => boolean,
|
||||
actions: SandmasterOrnithopterStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Sandmaster/card chronology ended before its owned boundary');
|
||||
const step = nextSandmasterOrnithopterStep(game);
|
||||
assert.ok(step, `Supply the real ${game.turn}/${game.phase}/${game.decision?.kind} control before continuing`);
|
||||
game = actSandmasterOrnithopter(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Sandmaster/Ornithopter chronology did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** A legal, deliberately chosen three-territory route through the real pile.
|
||||
* Prefer a destination with a shorter direct route to prove route retention. */
|
||||
function rangeThreeRoute(game: Game, actor: string, source: string, pile: string) {
|
||||
const prefix = sandmasterShortestRoute(game, actor, source, pile, 1);
|
||||
if (!prefix || sandmasterRouteDistance(game, prefix) !== 1) return null;
|
||||
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
|
||||
const candidates: { target: string; route: string[]; direct: number }[] = [];
|
||||
for (const place of gameTerritories(game)) {
|
||||
if (place.type !== 'rock' || place.id === splitLocation(source).territory ||
|
||||
place.id === splitLocation(pile).territory || game.players.some(player => presenceAt(player, place.id)) ||
|
||||
Object.entries(game.spice).some(([key, amount]) => amount > 0 && splitLocation(key).territory === place.id)) continue;
|
||||
for (const sector of place.sectors) {
|
||||
const target = location(place.id, sector), tail = sandmasterShortestRoute(game, actor, pile, target, 2);
|
||||
if (!tail || sandmasterRouteDistance(game, tail) !== 2) continue;
|
||||
const route = [...prefix, ...tail.slice(1)];
|
||||
if (new Set(route).size !== route.length || sandmasterRouteDistance(game, route) !== 3) continue;
|
||||
candidates.push({ target, route, direct: groundMoveRouteDistance(view, me, source, target) });
|
||||
}
|
||||
}
|
||||
return candidates.sort((a, b) => Number(b.direct < 3) - Number(a.direct < 3) || a.target.localeCompare(b.target))[0] ?? null;
|
||||
}
|
||||
|
||||
/** Original public E/R/A, living native Sandmaster, actual cache1 purchase and
|
||||
* shipment4 for8. Only the still-unopened turn-two Spice order is selected;
|
||||
* its real blow creates the positive pile. No played stock or clock is installed. */
|
||||
export function createSandmasterOrnithopterFixture(options: { advanced?: boolean; mode?: OrnithopterMode } = {}) {
|
||||
const advanced = options.advanced ?? false, mode = options.mode ?? 'twoGroups';
|
||||
const setup = createSelectedDeckSkillsSetup({ advanced, factions: ['emperor', 'richese', 'atreides'],
|
||||
expansions: ['choam'], skill: 'sandmaster' });
|
||||
const actor = setup.owner, actions: SandmasterOrnithopterStep[] = [...setup.actions];
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const act = (owner: string, action: Action) => { game = actSandmasterOrnithopter(game, owner, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSandmasterOrnithopter(game, until, actions); };
|
||||
const originalInventory = sandmasterOrnithopterInventory(game);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache' &&
|
||||
!!state.richeseCache?.some(card => card.id === SANDMASTER_ORNITHOPTER));
|
||||
const beforeAcquisition = structuredClone(game), acquisitionStart = actions.length;
|
||||
reach(state => sandmasterOrnithopterClean(state) &&
|
||||
sandmasterOrnithopterPlayer(state, actor).hand.some(card => card.id === SANDMASTER_ORNITHOPTER));
|
||||
const acquisition = { before: beforeAcquisition, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
|
||||
assert.equal(sandmasterOrnithopterPlayer(beforeAcquisition, actor).spice - sandmasterOrnithopterPlayer(game, actor).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && sandmasterOrnithopterClean(state));
|
||||
let selected: { source: string; card: Extract<Game['spiceDeck'][number], { territory: string }> } | null = null;
|
||||
for (const place of gameTerritories(game)) {
|
||||
if (place.type !== 'rock' || game.players.some(player => presenceAt(player, place.id)) ||
|
||||
Object.entries(game.spice).some(([key, amount]) => amount > 0 && splitLocation(key).territory === place.id)) continue;
|
||||
for (const sector of place.sectors) {
|
||||
if (sector < 7 || sector > 16 || sector === game.storm) continue;
|
||||
const source = location(place.id, sector);
|
||||
for (const card of game.spiceDeck) {
|
||||
if (!('territory' in card) || card.discovery || card.sector < 7 || card.sector > 16 ||
|
||||
card.territory === place.id || !sandmasterShortestRoute(game, actor, source, location(card.territory, card.sector), 1)) continue;
|
||||
const ranged = rangeThreeRoute(game, actor, source, location(card.territory, card.sector));
|
||||
if (!ranged || ranged.direct >= 3) continue;
|
||||
selected = { source, card: structuredClone(card) }; break;
|
||||
}
|
||||
if (selected) break;
|
||||
}
|
||||
if (selected) break;
|
||||
}
|
||||
assert.ok(selected, 'A real still-unopened positive Spice blow must be adjacent to an affordable safe rock source');
|
||||
const selectedCard = selected.card;
|
||||
const source = selected.source, sourceLocation = splitLocation(source), pile = location(selectedCard.territory, selectedCard.sector);
|
||||
const beforeShipment = structuredClone(game), shipmentStep: SandmasterOrnithopterStep = { actor,
|
||||
action: { type: 'ship', ...sourceLocation, amount: 4, elite: 0, allyPayment: 0 } };
|
||||
act(actor, shipmentStep.action); reach(sandmasterOrnithopterClean);
|
||||
const shipment = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) };
|
||||
assert.equal(sandmasterOrnithopterPlayer(beforeShipment, actor).spice - sandmasterOrnithopterPlayer(game, actor).spice, 8);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && sandmasterOrnithopterClean(state));
|
||||
const beforeBlow = structuredClone(game), spiceOriginal = structuredClone(game.spiceDeck);
|
||||
const at = game.spiceDeck.findIndex(card => JSON.stringify(card) === JSON.stringify(selectedCard));
|
||||
assert.ok(at >= 0, 'The selected printed Spice card is still unopened');
|
||||
const first = game.spiceDeck.splice(at, 1)[0];
|
||||
const front: Game['spiceDeck'] = [first];
|
||||
if (advanced) {
|
||||
const next = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
|
||||
card.territory !== selectedCard.territory && card.territory !== sourceLocation.territory);
|
||||
assert.ok(next >= 0); front.push(game.spiceDeck.splice(next, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
const spiceOrder = { original: spiceOriginal, selected: structuredClone(front), after: structuredClone(game.spiceDeck) };
|
||||
assert.deepEqual(spiceOrder.after.map(card => JSON.stringify(card)).sort(), spiceOriginal.map(card => JSON.stringify(card)).sort());
|
||||
const blowStart = actions.length;
|
||||
reach(state => state.turn === 2 && state.phase === 2 && sandmasterOrnithopterClean(state));
|
||||
const blow = { before: beforeBlow, steps: structuredClone(actions.slice(blowStart)), after: structuredClone(game), card: selectedCard };
|
||||
assert.ok(game.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(selectedCard)));
|
||||
assert.equal(game.spice[pile], (beforeBlow.spice[pile] ?? 0) + selectedCard.amount,
|
||||
'The real printed Spice card creates this pile before any caller-owned flight');
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && sandmasterOrnithopterClean(state));
|
||||
const trainer = sandmasterLeader(game, actor); assert.ok(trainer);
|
||||
assert.deepEqual(sandmasterOrnithopterPlayer(game, actor).forces, { [source]: 4 });
|
||||
assert.equal(sandmasterOrnithopterPlayer(game, actor).moved, 0);
|
||||
const ranged = rangeThreeRoute(game, actor, source, pile); assert.ok(ranged);
|
||||
const ordinaryRoute = sandmasterShortestRoute(game, actor, source, pile, 1); assert.ok(ordinaryRoute);
|
||||
const targetLocation = mode === 'range3' ? ranged.target : pile;
|
||||
const route = mode === 'range3' ? ranged.route : ordinaryRoute;
|
||||
const choice: SandmasterChoice = { routes: { [source]: route }, collect: [pile] };
|
||||
const action: Action = { type: 'move', forces: { [source]: 2 }, movementCard: SANDMASTER_ORNITHOPTER,
|
||||
ornithopter: mode, sandmaster: choice, ...splitLocation(targetLocation) };
|
||||
assert.deepEqual(sandmasterOrnithopterInventory(game), originalInventory);
|
||||
return { game, sourceGame: structuredClone(game), initial: setup.initial, started: setup.started,
|
||||
offered: setup.offered, afterSetup: setup.afterSetup, setup, actor, owner: actor, emperor: actor,
|
||||
richese: setup.seats.richese!, observer: setup.seats.atreides!, advanced, mode,
|
||||
card: SANDMASTER_ORNITHOPTER, trainer, source, sourceLocation, target: splitLocation(targetLocation), targetLocation,
|
||||
route: structuredClone(route), routes: structuredClone(choice.routes), choice, action,
|
||||
pile, pileBefore: game.spice[pile], originalPiles: structuredClone(game.spice),
|
||||
ranged, acquisition, shipment, beforeShipment, shipmentStep, afterShipment: shipment.after,
|
||||
beforeBlow, blow, spiceOrder, originalInventory, originalStocks: sandmasterOrnithopterStocks(game),
|
||||
originalSandmasterLogCount: game.log.filter(entry => entry.text.includes("Sandmaster collected 1 spice while moving")).length,
|
||||
actions, sourceActionCount: actions.length, endMovement: { type: 'endMovement' } satisfies Action,
|
||||
staging: [...setup.staging,
|
||||
'Original public Emperor/Richese/Atreides +choam/Leader Skills module, actual original Sandmaster offer/training and real cache Ornithopter1 sale. Four normal counters actually ship to empty non-city rock for8 and remain there across native turn progression. No played hand, force, wallet, leader, pile, phase or receipt is installed; Advanced remains preview evidence.',
|
||||
'Only original unopened setup lotteries and the still-unopened turn-two ordinary Spice order are selected. The actual printed Spice blow creates a positive pile adjacent to the real four-counter army; native Storm/Spice/Charity/Bidding/Revival controls reach the next original owned movement turn.',
|
||||
'Selected composition permits the printed moving-forces Sandmaster trigger during both physical card modes. Fixed card range remains3; each twoGroups action uses its own normal range and a distinct original cohort. One spice per entered territory resets per accepted movement under the existing explicit provisional interpretation; owner pile allocation stays provisional.',
|
||||
'The caller owns route collection, each actual movement, original residual cohort, card disposal and Collection continuation. A legal explicit three-territory route through the real pile may be longer than the shortest direct route. The twoGroups case uses two original two-counter subsets and may revisit the same still-positive pile for its separate second movement.',
|
||||
] };
|
||||
}
|
||||
export type SandmasterOrnithopterFixture = ReturnType<typeof createSandmasterOrnithopterFixture>;
|
||||
export function moveSandmasterOrnithopter(fixture: SandmasterOrnithopterFixture,
|
||||
game = structuredClone(fixture.game), actions: SandmasterOrnithopterStep[] = [], collect = true) {
|
||||
const action: Action = collect ? fixture.action : { ...fixture.action, sandmaster: { routes: fixture.choice.routes, collect: [] } };
|
||||
game = actSandmasterOrnithopter(game, fixture.actor, action, actions);
|
||||
return advanceSandmasterOrnithopter(game, sandmasterOrnithopterClean, actions);
|
||||
}
|
||||
export function sandmasterOrnithopterSecondAction(fixture: SandmasterOrnithopterFixture, game: Game, collect = true): Action {
|
||||
assert.equal(game.ornithopter?.mode, 'twoGroups'); assert.equal(game.ornithopter.completed, 1);
|
||||
assert.equal(sandmasterOrnithopterPlayer(game, fixture.actor).forces[fixture.source], 2);
|
||||
const route = sandmasterShortestRoute(game, fixture.actor, fixture.source, fixture.pile, 1); assert.ok(route);
|
||||
return { type: 'move', forces: { [fixture.source]: 2 }, ornithopterEvent: game.ornithopter.event,
|
||||
sandmaster: { routes: { [fixture.source]: route }, collect: collect ? [fixture.pile] : [] }, ...splitLocation(fixture.pile) };
|
||||
}
|
||||
export function finishSandmasterOrnithopter(fixture: SandmasterOrnithopterFixture, game: Game,
|
||||
actions: SandmasterOrnithopterStep[] = []) {
|
||||
return advanceSandmasterOrnithopter(game, state => state.turn === fixture.game.turn &&
|
||||
state.phase === 7 && sandmasterOrnithopterClean(state), actions);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location, splitLocation } from '../game/board';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { discoveryFlightMove } from '../game/discovery-flight-options';
|
||||
import { quoteDiscoveryFlight, validateDiscoveryFlight } from '../game/discovery-flight';
|
||||
import {
|
||||
quoteSandmasterMovement, sandmasterActionOrder, sandmasterDefaultChoice,
|
||||
sandmasterEnteredTerritories, sandmasterLeader, sandmasterRouteDistance,
|
||||
validateSandmasterMovement, type SandmasterChoice,
|
||||
} from '../game/sandmaster-movement';
|
||||
import {
|
||||
actSandmasterDiscoveryFlight as act,
|
||||
createSandmasterDiscoveryFlightFixture as fixture,
|
||||
finishSandmasterDiscoveryFlight as finish,
|
||||
sandmasterDiscoveryFlightInventory as inventory,
|
||||
sandmasterDiscoveryFlightPhysical as physical,
|
||||
sandmasterDiscoveryFlightPlayer as player,
|
||||
type SandmasterDiscoveryFlightFixture as Fixture,
|
||||
type SandmasterDiscoveryFlightStep as Step,
|
||||
} from './fixture-sandmaster-discovery-flight';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function source(f: Fixture) {
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.ready.status, 'lobby'); assert.equal(f.started.status, 'setup');
|
||||
assert.equal(f.collection.status, 'playing'); assert.equal(f.collection.turn, 1); assert.equal(f.collection.phase, 7);
|
||||
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.actor);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.response ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.game.players.map(p => p.faction), ['guild', 'emperor', 'harkonnen']);
|
||||
assert.ok(f.game.leaderSkills && f.game.discoveryEnabled && f.game.discoveries);
|
||||
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery);
|
||||
for (const selectedModule of ['leaderSkills', 'discoveries']) assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === selectedModule));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start' && (!f.advanced || step.action.advancedPreview === true)));
|
||||
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
|
||||
step.action.skill === 'sandmaster' && step.action.leader === f.trainer));
|
||||
assert.equal(sandmasterLeader(f.game, f.actor), f.trainer);
|
||||
assert.equal(f.originalToken.face, 'ornithopter'); assert.equal(f.originalToken.owner, f.actor);
|
||||
assert.equal(f.originalToken.status, 'carried'); assert.equal(f.originalToken.acquiredTurn, 1);
|
||||
assert.equal(f.originalToken.revealedTurn, 1);
|
||||
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
|
||||
assert.match(viewGame(f.acquired, f.actor).discoveryOrnithopter!.blocked!, /after/);
|
||||
assert.equal(viewGame(f.game, f.actor).discoveryOrnithopter!.blocked, null);
|
||||
assert.equal(f.beforeBlow.turn, 2); assert.equal(f.beforeBlow.phase, 1); assert.deepEqual(f.beforeBlow.spiceDeck[0], f.blowCard);
|
||||
assert.ok(f.game.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(f.blowCard)), 'The selected physical Spice Card actually resolved');
|
||||
assert.ok(f.game.spice[f.pile] >= f.blowCard.amount, 'The real next-turn blow funds the entered pile');
|
||||
assert.equal(f.sourceSpice, f.originalSpice[f.sourcePile] ?? 0);
|
||||
assert.equal(player(f.collection, f.actor).forces[f.source], 1);
|
||||
assert.equal(f.sourceCount, 2); assert.equal(player(f.game, f.actor).forces[f.source], 2);
|
||||
assert.equal(player(f.game, f.actor).forces['tueks_sietch:5'], 5);
|
||||
assert.equal(f.shipmentStep.action.type, 'ship'); assert.equal(f.shipmentStep.action.amount, 1);
|
||||
assert.equal(player(f.beforeShipment, f.actor).spice - player(f.afterShipment, f.actor).spice, 1);
|
||||
assert.equal(player(f.beforeShipment, f.actor).reserves - player(f.afterShipment, f.actor).reserves, 1);
|
||||
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(player(f.game, f.actor).shipped, true);
|
||||
assert.equal(f.route[0], f.source); assert.equal(f.route.at(-1), f.targetLocation);
|
||||
assert.equal(sandmasterRouteDistance(f.game, f.route), 3);
|
||||
assert.ok(sandmasterEnteredTerritories(f.routes).includes(splitLocation(f.pile).territory));
|
||||
assert.equal(sandmasterEnteredTerritories(f.routes).includes(splitLocation(f.source).territory), false);
|
||||
assert.ok(f.choice.collect.includes(f.pile)); assert.equal(f.choice.collect.includes(f.sourcePile), false);
|
||||
assert.equal(Object.hasOwn(f.action, 'origins'), false); assert.equal(Object.hasOwn(f.action, 'discoveryFlight'), false);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.ok(f.sourceActionCount > 20);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.treachery.length, 33);
|
||||
assert.equal(f.originalInventory.skills.length, 14); assert.equal(f.originalInventory.discoveries.length, 8);
|
||||
}
|
||||
function moved(f: Fixture, game: Game, action: Action) {
|
||||
const before = player(f.game, f.actor), after = player(game, f.actor);
|
||||
const forces = action.forces !== undefined ? action.forces as Record<string, number>
|
||||
: { [action.from as string]: action.amount as number };
|
||||
const expected = { ...before.forces }, destination = location(action.territory as string, action.sector as number);
|
||||
for (const [from, amount] of Object.entries(forces)) { expected[from] -= amount; if (!expected[from]) delete expected[from]; }
|
||||
expected[destination] = (expected[destination] ?? 0) + Object.values(forces).reduce((sum, amount) => sum + amount, 0);
|
||||
const choice = action.sandmaster as SandmasterChoice | undefined, selected = choice?.collect ?? [];
|
||||
assert.deepEqual(after.forces, expected); assert.equal(after.moved, before.moved + 1);
|
||||
assert.equal(after.shipped, before.shipped); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.spice, before.spice + selected.length);
|
||||
assert.deepEqual(after.elites, before.elites); assert.deepEqual(after.leaders, before.leaders); assert.deepEqual(after.hand, before.hand);
|
||||
assert.deepEqual(game.players.filter(p => p.id !== f.actor), f.game.players.filter(p => p.id !== f.actor));
|
||||
const spice = { ...f.originalSpice };
|
||||
for (const key of selected) spice[key]--;
|
||||
assert.deepEqual(game.spice, spice);
|
||||
assert.deepEqual(game.leaderSkills, f.game.leaderSkills); assert.equal(sandmasterLeader(game, f.actor), f.trainer);
|
||||
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token)!.status, 'removed');
|
||||
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token)!.owner, null);
|
||||
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token)!.acquiredTurn, 1);
|
||||
assert.equal(viewGame(game, f.actor).discoveryOrnithopter, null);
|
||||
assert.deepEqual(game.discoveries!.tokens.filter(token => token.id !== f.token), f.game.discoveries!.tokens.filter(token => token.id !== f.token));
|
||||
assert.equal(game.log.slice(f.game.log.length).filter(entry => entry.automatic?.name === 'Sandmaster collection').length, selected.length);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const band = advanced ? 'Advanced preview' : 'Basic';
|
||||
void test(`public ${band} Sandmaster uses one acquired Discovery flight to collect real entered-territory spice at fixed three`, () => {
|
||||
const f = fixture({ advanced }); source(f);
|
||||
rejected(f.game, f.actor, f.ordinary);
|
||||
const view = viewGame(f.game, f.actor), quote = discoveryFlightMove(view, f.ordinary);
|
||||
assert.equal(quote.blocked, null); assert.deepEqual(quote.action, f.action);
|
||||
assert.deepEqual(sandmasterDefaultChoice(view, f.actor, f.action), f.choice);
|
||||
const game = act(f.game, f.actor, f.action); moved(f, game, f.action);
|
||||
assert.equal(game.spice[f.sourcePile], f.originalSpice[f.sourcePile], 'The actual source-spice quantity stays unchanged');
|
||||
assert.equal(game.spice[f.pile], f.originalSpice[f.pile] - 1);
|
||||
rejected(game, f.actor, f.action);
|
||||
const actions: Step[] = [], done = finish(f, game, actions);
|
||||
assert.equal(done.turn, 2); assert.equal(done.phase, 7); assert.equal(player(done, f.actor).moved, 1);
|
||||
assert.equal(player(done, f.actor).forces[f.targetLocation], f.sourceCount);
|
||||
assert.equal(done.spice[f.sourcePile], f.originalSpice[f.sourcePile]);
|
||||
assert.equal(done.discoveries!.tokens.find(token => token.id === f.token)!.status, 'removed');
|
||||
assert.ok(actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`public ${band} Sandmaster can decline collection while its original Discovery token still funds exactly one movement`, () => {
|
||||
const f = fixture({ advanced }); source(f);
|
||||
for (const action of [f.declineAction, f.plainAction]) {
|
||||
const game = act(f.game, f.actor, action); moved(f, game, action);
|
||||
assert.deepEqual(game.spice, f.originalSpice); assert.equal(player(game, f.actor).spice, player(f.game, f.actor).spice);
|
||||
const done = finish(f, game); assert.equal(done.turn, 2); assert.equal(done.phase, 7);
|
||||
assert.equal(player(done, f.actor).moved, 1); assert.deepEqual(inventory(done), f.originalInventory);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('native Sandmaster Discovery declarations reject same-turn, stale token, foreign caller, route and collection errors immutably', () => {
|
||||
const f = fixture(); source(f);
|
||||
const sameTurn = discoveryFlightMove(viewGame(f.acquired, f.actor), f.action);
|
||||
assert.equal(sameTurn.action, null); assert.match(sameTurn.blocked!, /after/);
|
||||
rejected(f.acquired, f.actor, f.action); rejected(f.game, f.observer, f.action); rejected(f.game, f.opponent, f.action);
|
||||
for (const action of [
|
||||
{ ...f.action, discoveryOrnithopter: 'discovery-token-99' },
|
||||
{ ...f.action, amount: f.sourceCount + 1 },
|
||||
{ ...f.action, sandmaster: { ...f.choice, collect: [...f.choice.collect, f.sourcePile] } },
|
||||
{ ...f.action, sandmaster: { ...f.choice, collect: [...f.choice.collect, f.pile] } },
|
||||
{ ...f.action, sandmaster: { ...f.choice, collect: ['not_a_spice_territory:0'] } },
|
||||
{ ...f.action, sandmaster: { ...f.choice, routes: { [f.source]: [f.source, f.targetLocation] } } },
|
||||
{ ...f.action, sandmaster: { ...f.choice, routes: {} } },
|
||||
{ ...f.action, discoveryFlight: { token: f.token } },
|
||||
{ ...f.action, origins: [splitLocation(f.source).territory] },
|
||||
]) rejected(f.game, f.actor, action);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical); assert.deepEqual(f.game.spice, f.originalSpice);
|
||||
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
|
||||
});
|
||||
|
||||
void test('controlled native-trainer, live-pile and pending-quote supplements retain the original Discovery-bound Sandmaster order', () => {
|
||||
const f = fixture(); source(f); const view = viewGame(f.game, f.actor);
|
||||
const order = sandmasterActionOrder(view, f.actor, f.action); assert.ok(order);
|
||||
assert.deepEqual(order.flight, { kind: 'discovery', token: f.token, acquiredTurn: 1 });
|
||||
const receipt = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
|
||||
validateSandmasterMovement(f.game, f.actor, order, receipt);
|
||||
// CONTROLLED helper supplements: the positive empty-rock chronology has no
|
||||
// native opposing response. These do not claim a played death, capture,
|
||||
// pile debit or pending engine frame in that chronology.
|
||||
const changedPile = structuredClone(f.game); changedPile.spice[f.pile]--;
|
||||
assert.throws(() => validateSandmasterMovement(changedPile, f.actor, order, receipt));
|
||||
// CONTROLLED positive-source exclusion only: the native source may be empty.
|
||||
// This does not claim that another native Spice blow or Collection left spice.
|
||||
const positiveSource = structuredClone(f.game);
|
||||
positiveSource.spice[f.sourcePile] = (positiveSource.spice[f.sourcePile] ?? 0) + 1;
|
||||
rejected(positiveSource, f.actor, { ...f.action,
|
||||
sandmaster: { ...f.choice, collect: [...f.choice.collect, f.sourcePile] } });
|
||||
const changedOrder = structuredClone(order); changedOrder.group[0][1]--;
|
||||
assert.throws(() => validateSandmasterMovement(f.game, f.actor, changedOrder, receipt));
|
||||
const noFlight = structuredClone(order); delete noFlight.flight;
|
||||
assert.throws(() => validateSandmasterMovement(f.game, f.actor, noFlight, receipt));
|
||||
for (const flight of [
|
||||
{ kind: 'discovery' as const, token: 'discovery-token-99', acquiredTurn: 1 },
|
||||
{ kind: 'discovery' as const, token: f.token, acquiredTurn: 2 },
|
||||
{ kind: 'physical' as const, card: 'foreign-card', mode: 'range3' as const, completed: 0 },
|
||||
]) assert.throws(() => validateSandmasterMovement(f.game, f.actor, { ...order, flight }, receipt));
|
||||
assert.throws(() => validateSandmasterMovement(f.game, f.actor, order, { ...receipt, move: receipt.move + 1 }));
|
||||
assert.throws(() => validateSandmasterMovement(f.game, f.actor, order, { ...receipt, leader: 'foreign-trainer' }));
|
||||
for (const state of ['dead', 'captured'] as const) {
|
||||
const controlled = structuredClone(f.game), trainer = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
|
||||
if (state === 'dead') trainer.dead = true; else trainer.capturedBy = f.observer;
|
||||
assert.equal(sandmasterLeader(controlled, f.actor), null);
|
||||
rejected(controlled, f.actor, f.action);
|
||||
assert.throws(() => validateSandmasterMovement(controlled, f.actor, order, receipt));
|
||||
}
|
||||
const exhausted = structuredClone(f.game); exhausted.spice[f.pile] = 0;
|
||||
rejected(exhausted, f.actor, f.action);
|
||||
const movement = { ...order, player: f.actor, total: f.sourceCount, advisors: false, wantsFighters: false };
|
||||
const flight = quoteDiscoveryFlight(f.game, f.token, movement);
|
||||
validateDiscoveryFlight(f.game, flight, movement);
|
||||
for (const state of ['removed', 'foreign', 'sameTurn'] as const) {
|
||||
const controlled = structuredClone(f.game), token = controlled.discoveries!.tokens.find(token => token.id === f.token)!;
|
||||
if (state === 'removed') { token.status = 'removed'; token.owner = null; }
|
||||
else if (state === 'foreign') token.owner = f.observer;
|
||||
else { token.acquiredTurn = controlled.turn; token.revealedTurn = controlled.turn; }
|
||||
assert.throws(() => validateDiscoveryFlight(controlled, flight, movement));
|
||||
}
|
||||
const movedSource = structuredClone(f.game); player(movedSource, f.actor).forces[f.source]--;
|
||||
player(movedSource, f.actor).tanks++;
|
||||
rejected(movedSource, f.actor, f.action);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles offer an owned Discovery movement with legal real-pile Sandmaster collection', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }); source(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'move' && action.discoveryOrnithopter === f.token &&
|
||||
(action.sandmaster as SandmasterChoice | undefined)?.collect.length);
|
||||
assert.ok(action, `${difficulty} offers an owned Discovery/Sandmaster route through actual positive spice`);
|
||||
assert.equal(Object.hasOwn(action, 'discoveryFlight'), false);
|
||||
const game = act(f.game, f.actor, action); moved(f, game, action);
|
||||
assert.equal(player(game, f.actor).moved, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SandmasterMovement, sandmasterMoveDraft } from '../components/sandmaster-movement';
|
||||
import { SandmasterMovementAction, type SandmasterMovementActionProps, type MovementFlight } from '../components/sandmaster-movement-action';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { planetologistFlightSelection as movementFlightSelection } from '../game/planetologist-flight';
|
||||
import { gameTerritories, location, splitLocation } from '../game/board';
|
||||
import { groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
import { sandmasterActionOrder, sandmasterDefaultChoice, sandmasterOrderSources, sandmasterRouteDistance } from '../game/sandmaster-movement';
|
||||
|
||||
const source = 'false_wall_west:16', otherSource = 'false_wall_west:17';
|
||||
const destination = 'wind_pass:16', firstPile = 'wind_pass:15', secondPile = 'wind_pass:16';
|
||||
const ordinary: Action = { type: 'move', forces: { [source]: 2 }, territory: 'wind_pass', sector: 16 };
|
||||
|
||||
// Controlled personalized capabilities supplement the independent native flight
|
||||
// and Richese marker sources. No trainer/card/token/army/receipt is installed in
|
||||
// an authoritative Game. React renders the stateful route/pile parent; only the
|
||||
// hook-free actual submit leaf is entered directly.
|
||||
function projected(richese = false): GameView {
|
||||
const sourceGame = createGame('SANDMASTERFLIGHTCONTROLS', newPlayer('p', 'Pilot', richese ? 'richese' : 'emperor'), false, ['choam']);
|
||||
joinGame(sourceGame, newPlayer('a', 'Atreides', 'atreides'));
|
||||
const game = viewGame(sourceGame, 'p');
|
||||
game.status = 'playing'; game.phase = 5; game.turn = 3; game.storm = 18; game.active = 'p';
|
||||
game.order = ['p', 'a']; game.movementRemaining = ['p', 'a'];
|
||||
const me = game.players[0];
|
||||
me.forces = { [source]: 3, [otherSource]: 2 }; me.moved = 0; me.movesAllowed = 1; me.shipped = true;
|
||||
game.spice = { [firstPile]: 4, [secondPile]: 3 };
|
||||
game.leaderSkills = { offer: null, eligibleLeaders: [], battleChoice: null,
|
||||
assignments: [{ skill: 'sandmaster', owner: me.id, controller: me.id, leader: me.leaders[0].id, captured: false, faceUp: true }] };
|
||||
const card = richeseCards().find(candidate => candidate.effect === 'ornithopter'); assert.ok(card);
|
||||
game.ornithopter = { card, blocked: null, active: null,
|
||||
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }] };
|
||||
return game;
|
||||
}
|
||||
function carried(game: GameView) {
|
||||
game.discoveryEnabled = true;
|
||||
game.discoveries = { blocked: null, canInspect: [], canReveal: [], supplyCount: 0, newlyRevealed: [],
|
||||
tokens: [{ id: 'discovery-token-1', type: 'smuggler', face: 'ornithopter', status: 'carried',
|
||||
territory: 'funeral_plain', sector: 2, revealedTurn: 1, owner: game.me, acquiredTurn: 1 }] };
|
||||
game.discoveryOrnithopter = { token: 'discovery-token-1', acquiredTurn: 1, range: 3, blocked: null };
|
||||
return game;
|
||||
}
|
||||
function active(game: GameView) {
|
||||
const card = game.ornithopter!;
|
||||
card.active = { event: 'controlled-sandmaster-flight', mode: 'twoGroups', completed: 1, remaining: 1,
|
||||
cohort: { forces: { [otherSource]: 1 }, elites: {} } };
|
||||
game.playedOrnithopter = { player: game.me, card: card.card, mode: 'twoGroups', completed: 1 };
|
||||
game.players[0].moved = 1;
|
||||
return game;
|
||||
}
|
||||
function marker(game: GameView, value: NoFieldValue) {
|
||||
const tokens = createRicheseNoField(['sand-zero', 'sand-three', 'sand-five']);
|
||||
const model = deployRicheseNoField(tokens, { tokenId: tokens.tokens.find(token => token.value === value)!.id,
|
||||
controller: game.me, location: splitLocation(source) });
|
||||
const publicMarker = projectRicheseNoField(model, false);
|
||||
game.players[0].noField = publicMarker;
|
||||
game.richeseNoField = { owner: game.me, event: 'controlled-sandmaster-marker', public: publicMarker, private: model } as NonNullable<GameView['richeseNoField']>;
|
||||
return game;
|
||||
}
|
||||
function markerMove(game: GameView, physical = false): Action {
|
||||
return { type: 'move', forces: physical ? { [source]: 2 } : {},
|
||||
noField: game.richeseNoField!.private!.deployed!.tokenId, event: game.richeseNoField!.event,
|
||||
territory: 'wind_pass', sector: 16 };
|
||||
}
|
||||
function propsFor(game: GameView, flight?: MovementFlight, move: Action = ordinary): SandmasterMovementActionProps {
|
||||
return { game, move, flight, busy: false, reasonId: 'controlled-sandmaster-reason', act() {} };
|
||||
}
|
||||
function click(props: SandmasterMovementActionProps): Action[] {
|
||||
const actions: Action[] = [];
|
||||
SandmasterMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick();
|
||||
return actions;
|
||||
}
|
||||
function markup(game: GameView, move: Action = ordinary, busy = false) {
|
||||
return renderToStaticMarkup(createElement(SandmasterMovement, { game, move, busy, act() {} }));
|
||||
}
|
||||
function targetAtDistance(game: GameView, distance: number) {
|
||||
const found = gameTerritories(game).flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
||||
.find(to => groundMoveRouteDistance(game, game.players[0], source, to) === distance);
|
||||
assert.ok(found, `A controlled canonical target at distance ${distance} exists`);
|
||||
return splitLocation(found);
|
||||
}
|
||||
|
||||
void test('existing four-argument drafts and actual ordinary callbacks preserve the exact move routes and pile choice', () => {
|
||||
const game = projected(), before = structuredClone(game);
|
||||
const draft = sandmasterMoveDraft(game, ordinary);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(draft.choice?.collect, [firstPile]);
|
||||
assert.deepEqual(click(propsFor(game)), [draft.action]);
|
||||
assert.deepEqual(draft.action, { ...ordinary, sandmaster: draft.choice });
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('actual range3 two-group and Discovery callbacks carry only the offered flight source and original Sandmaster choice', () => {
|
||||
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) {
|
||||
const game = flight === 'discovery' ? carried(projected()) : projected(), before = structuredClone(game);
|
||||
const draft = sandmasterMoveDraft(game, ordinary, undefined, [], flight);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(click(propsFor(game, flight)), [{ ...ordinary,
|
||||
...movementFlightSelection(game, flight).fields, sandmaster: draft.choice }]);
|
||||
assert.deepEqual(click({ ...propsFor(game, flight), busy: true }), []);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('fixed three-territory card and Discovery routes retain the cap while ordinary and two-group flight keep normal range', () => {
|
||||
const game = projected(), far = targetAtDistance(game, 3), beyond = targetAtDistance(game, 4);
|
||||
const farMove = { ...ordinary, ...far };
|
||||
for (const flight of ['ordinary', 'twoGroups'] as const) assert.deepEqual(click(propsFor(game, flight, farMove)), []);
|
||||
for (const flight of ['range3', 'discovery'] as const) {
|
||||
const view = flight === 'discovery' ? carried(projected()) : projected();
|
||||
assert.equal(click(propsFor(view, flight, farMove)).length, 1);
|
||||
assert.deepEqual(click(propsFor(view, flight, { ...ordinary, ...beyond })), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the declared connected non-shortest route is submitted unchanged rather than replaced by a shortest route', () => {
|
||||
const game = projected(), routes = { [source]: [source, 'wind_pass:17', 'polar_sink:0', destination] };
|
||||
const defaults = sandmasterDefaultChoice(game, game.me, { ...ordinary, ...movementFlightSelection(game, 'range3').fields });
|
||||
assert.ok(defaults); assert.notDeepEqual(defaults.routes, routes);
|
||||
assert.equal(sandmasterRouteDistance(game, routes[source]), 3);
|
||||
const actions = click({ ...propsFor(game, 'range3'), routes });
|
||||
assert.deepEqual(actions, [{ ...ordinary, ...movementFlightSelection(game, 'range3').fields,
|
||||
sandmaster: { routes, collect: [firstPile] } }]);
|
||||
assert.deepEqual(click({ ...propsFor(game, 'range3'), routes: { [source]: [source, 'arrakeen:10', destination] } }), []);
|
||||
});
|
||||
|
||||
void test('actual pile choices select one positive sector per entered territory or explicitly decline all peers', () => {
|
||||
const game = projected(), routes = { [source]: [source, 'wind_pass:17', destination] };
|
||||
for (const [declined, collect] of [[[], [firstPile]], [[firstPile], [secondPile]], [[firstPile, secondPile], []]] as const) {
|
||||
assert.deepEqual(click({ ...propsFor(game, 'range3'), routes, declined }), [
|
||||
{ ...ordinary, ...movementFlightSelection(game, 'range3').fields, sandmaster: { routes, collect: [...collect] } },
|
||||
]);
|
||||
}
|
||||
const html = markup(game);
|
||||
assert.match(html, /Collect 1 spice in Wind Pass, sector 15/);
|
||||
assert.match(html, /Collect 1 spice in Wind Pass, sector 16/);
|
||||
});
|
||||
|
||||
void test('active second groups use their current event and remaining original cohort after the ordinary allowance is spent', () => {
|
||||
const game = active(projected()), move = { ...ordinary, forces: { [otherSource]: 1 } };
|
||||
const draft = sandmasterMoveDraft(game, move);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(click(propsFor(game, undefined, move)), [draft.action]);
|
||||
assert.equal(draft.action?.ornithopterEvent, 'controlled-sandmaster-flight');
|
||||
assert.equal(draft.action?.movementCard, undefined);
|
||||
assert.deepEqual(click(propsFor(game, undefined, ordinary)), []);
|
||||
assert.deepEqual(click(propsFor(game, 'discovery', move)), []);
|
||||
const html = markup(game, move);
|
||||
assert.match(html, /1 card movements completed · 1 remaining/);
|
||||
});
|
||||
|
||||
void test('second-group quotas distinguish current physical normal and elite counters from the unmoved cohort', () => {
|
||||
const game = active(projected());
|
||||
game.players[0].elites = { forces: { [otherSource]: 1 }, reserves: 0, tanks: 0, revived: 0 };
|
||||
const move = { ...ordinary, forces: { [otherSource]: 1 }, eliteForces: { [otherSource]: 1 } };
|
||||
assert.deepEqual(click(propsFor(game, undefined, move)), []);
|
||||
assert.equal(click(propsFor(game, undefined, { ...move, eliteForces: { [otherSource]: 0 } })).length, 1);
|
||||
assert.deepEqual(click(propsFor(game, undefined, { ...move, forces: { [otherSource]: 2 } })), []);
|
||||
});
|
||||
|
||||
void test('marker-only zero three and five use one moving source without reading or materializing the denomination', () => {
|
||||
for (const value of [0, 3, 5] as const) for (const flight of ['ordinary', 'range3', 'twoGroups', 'discovery'] as const) {
|
||||
const game = marker(flight === 'discovery' ? carried(projected(true)) : projected(true), value);
|
||||
const move = markerMove(game), before = structuredClone(game);
|
||||
const draft = sandmasterMoveDraft(game, move, undefined, [], flight);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(Object.keys(draft.choice!.routes), [source]);
|
||||
assert.deepEqual(draft.choice!.collect, [firstPile]);
|
||||
assert.deepEqual(click(propsFor(game, flight, move)), [draft.action]);
|
||||
assert.deepEqual(game, before);
|
||||
const html = markup(game, move);
|
||||
assert.match(html, /aria-label="Provisional Sandmaster concealed marker"/);
|
||||
assert.match(html, /including zero/);
|
||||
assert.doesNotMatch(html, /sand-zero|sand-three|sand-five/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a same-sector marker and physical group share one route and collect once for the entered territory', () => {
|
||||
const game = marker(projected(true), 5), move = markerMove(game, true);
|
||||
const draft = sandmasterMoveDraft(game, move, undefined, [], 'range3');
|
||||
const order = sandmasterActionOrder(game, game.me, draft.action!);
|
||||
assert.ok(order);
|
||||
assert.deepEqual(sandmasterOrderSources(order), [source]);
|
||||
assert.deepEqual(Object.keys(draft.choice!.routes), [source]);
|
||||
assert.deepEqual(draft.choice!.collect, [firstPile]);
|
||||
assert.deepEqual(click(propsFor(game, 'range3', move)), [draft.action]);
|
||||
assert.equal((markup(game, move).match(/<legend>False Wall West, sector 16 route<\/legend>/g) ?? []).length, 1);
|
||||
const separate = { ...markerMove(game), forces: { [otherSource]: 1 } };
|
||||
const twoRoutes = sandmasterMoveDraft(game, separate, undefined, [], 'range3');
|
||||
assert.equal(Object.keys(twoRoutes.choice!.routes).length, 2);
|
||||
assert.deepEqual(twoRoutes.choice!.collect, [firstPile]);
|
||||
assert.deepEqual(click(propsFor(game, 'range3', separate)), [twoRoutes.action]);
|
||||
});
|
||||
|
||||
void test('a marker in the active frame must remain an original unmoved cohort member', () => {
|
||||
const game = active(marker(projected(true), 0)), move = markerMove(game);
|
||||
assert.deepEqual(click(propsFor(game, undefined, move)), []);
|
||||
game.ornithopter!.active!.cohort!.noField = { tokenId: 'sand-zero', event: game.richeseNoField!.event, from: source };
|
||||
assert.equal(click(propsFor(game, undefined, move)).length, 1);
|
||||
game.ornithopter!.active!.cohort!.noField!.event = 'old-marker-event';
|
||||
assert.deepEqual(click(propsFor(game, undefined, move)), []);
|
||||
});
|
||||
|
||||
void test('stale card token active event selected source trainer or selected pile invalidates an already-rendered callback', () => {
|
||||
const checks: { flight: MovementFlight; change: (game: GameView) => void }[] = [
|
||||
{ flight: 'range3', change: game => { game.ornithopter!.card = { ...game.ornithopter!.card, id: 'different-card' }; } },
|
||||
{ flight: 'discovery', change: game => { game.discoveries!.tokens[0].id = 'discovery-token-2'; game.discoveryOrnithopter!.token = 'discovery-token-2'; } },
|
||||
{ flight: 'range3', change: game => { game.players[0].forces[source] = 0; } },
|
||||
{ flight: 'range3', change: game => { game.leaderSkills!.assignments[0].leader = game.players[0].leaders[1].id; } },
|
||||
{ flight: 'range3', change: game => { game.spice[firstPile] = 2; } },
|
||||
{ flight: 'range3', change: game => { game.spice[firstPile] = 0; } },
|
||||
];
|
||||
for (const { flight, change } of checks) {
|
||||
const game = flight === 'discovery' ? carried(projected()) : projected(), actions: Action[] = [];
|
||||
const leaf = SandmasterMovementAction({ ...propsFor(game, flight), act(action) { actions.push(action); } });
|
||||
assert.equal(leaf.props.disabled, false);
|
||||
change(game); leaf.props.onClick(); assert.deepEqual(actions, []);
|
||||
}
|
||||
const game = active(projected()), actions: Action[] = [];
|
||||
const leaf = SandmasterMovementAction({ ...propsFor(game, undefined, { ...ordinary, forces: { [otherSource]: 1 } }), act(action) { actions.push(action); } });
|
||||
game.ornithopter!.active!.event = 'changed-flight-event'; leaf.props.onClick(); assert.deepEqual(actions, []);
|
||||
});
|
||||
|
||||
void test('foreign marker ownership current marker event and current token custody guard actual callbacks', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.richeseNoField!.owner = 'a'; },
|
||||
(game: GameView) => { game.richeseNoField!.event = 'new-marker-event'; },
|
||||
(game: GameView) => { game.richeseNoField!.private!.deployed = null; },
|
||||
(game: GameView) => { game.richeseNoField!.private!.deployed!.controller = 'a'; },
|
||||
]) {
|
||||
const game = marker(projected(true), 0), move = markerMove(game); change(game);
|
||||
assert.deepEqual(click(propsFor(game, 'range3', move)), []);
|
||||
}
|
||||
const game = marker(projected(true), 0), actions: Action[] = [];
|
||||
const leaf = SandmasterMovementAction({ ...propsFor(game, 'range3', markerMove(game)),
|
||||
act(action) { actions.push(action); } });
|
||||
assert.equal(leaf.props.disabled, false);
|
||||
game.richeseNoField!.event = 'changed-marker-event';
|
||||
leaf.props.onClick(); assert.deepEqual(actions, []);
|
||||
});
|
||||
|
||||
void test('empty optional notices cannot bypass current ownership phase response automatic trainer or allowance guards', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.phase = 6; }, game => { game.active = 'a'; },
|
||||
game => { game.response = {} as NonNullable<GameView['response']>; },
|
||||
game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; },
|
||||
game => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
|
||||
game => { game.automaticContinuationPending = true; }, game => { game.players[0].autopilot = 'Easy'; },
|
||||
game => { game.players[0].moved = 1; }, game => { game.players[0].leaders[0].dead = true; },
|
||||
game => { game.me = 'a'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.deepEqual(click({ ...propsFor(game, 'range3'), unavailableReason: '' }), []);
|
||||
}
|
||||
const game = projected();
|
||||
assert.deepEqual(click({ ...propsFor(game, 'range3'), busy: true }), []);
|
||||
assert.deepEqual(click({ ...propsFor(game, 'range3'), unavailableReason: 'Finish the promised shipment first.' }), []);
|
||||
assert.deepEqual(click(propsFor(game, 'range3', { ...ordinary, planetologist: 'range' })), []);
|
||||
});
|
||||
|
||||
void test('the form retains all transport choices when the ordinary route is too long and explains provisional exact-route collection', () => {
|
||||
const game = carried(projected()), far = { ...ordinary, ...targetAtDistance(game, 3) };
|
||||
const html = markup(game, far);
|
||||
assert.match(html, /Sandmaster movement/);
|
||||
assert.match(html, /Movement transport/);
|
||||
for (const choice of ['ordinary', 'range3', 'twoGroups', 'discovery']) assert.match(html, new RegExp(`<option value="${choice}"`));
|
||||
assert.match(html, /aria-label="Provisional Sandmaster flight composition"/);
|
||||
assert.match(html, /fixed-range card and Discovery flight allow routes up to three territories/);
|
||||
assert.match(html, /Each two-group card movement keeps normal range and uses different original forces/);
|
||||
assert.match(html, /Only one flight source may be used; Planetologist remains separate/);
|
||||
assert.match(markup(projected()), /Provisional multi-pile policy/);
|
||||
});
|
||||
|
||||
void test('marker routes and actual callbacks never read hidden denominations raw marker events or rival private inventories', () => {
|
||||
const game = marker(projected(true), 0), move = markerMove(game);
|
||||
for (const token of game.richeseNoField!.private!.tokens)
|
||||
Object.defineProperty(token, 'value', { get() { throw Error('Sandmaster controls read hidden denomination'); } });
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'noFieldEvent'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Sandmaster controls read unrelated ${player.id}.${field}`); } });
|
||||
assert.equal(click(propsFor(game, 'range3', move)).length, 1);
|
||||
assert.match(markup(game, move), /Provisional Sandmaster concealed marker/);
|
||||
const rival = marker(projected(true), 3), rivalMove = markerMove(rival); rival.richeseNoField!.owner = 'a';
|
||||
Object.defineProperty(rival.richeseNoField!, 'private', { get() { throw Error('Sandmaster controls read foreign marker identity'); } });
|
||||
assert.deepEqual(click(propsFor(rival, 'range3', rivalMove)), []);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action } from '../game/engine';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { sandmasterActionOrder, sandmasterOrderSources, quoteSandmasterMovement, validateSandmasterMovement } from '../game/sandmaster-movement';
|
||||
import { createSandmasterMarkerFixture } from './fixture-sandmaster-marker';
|
||||
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) for (const physical of [false, true]) {
|
||||
void test(`controlled ${advanced ? 'Advanced' : 'Basic'} Sandmaster marker${value} ${physical ? 'with physical companions' : 'alone'} collects without materialization`, () => {
|
||||
const f = createSandmasterMarkerFixture(value, physical, advanced), before = structuredClone(f.game);
|
||||
const order = sandmasterActionOrder(viewGame(f.game, f.actor), f.actor, f.action); assert.ok(order);
|
||||
assert.deepEqual(sandmasterOrderSources(order), [f.source]);
|
||||
assert.equal(order.group.length, physical ? 1 : 0); assert.equal(order.noField!.tokenId, f.token.id);
|
||||
const next = applyAction(f.game, f.actor, f.action), owner = next.players.find(p => p.id === f.actor)!;
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.equal(owner.reserves, 16); assert.equal(owner.tanks, 0); assert.equal(owner.moved, 1);
|
||||
assert.equal(owner.forces[f.source] ?? 0, physical ? 0 : 3);
|
||||
assert.equal(owner.forces[f.target] ?? 0, physical ? 3 : 0);
|
||||
assert.equal(presenceAt(owner, splitLocation(f.target).territory), physical ? 4 : 1);
|
||||
assert.deepEqual(owner.noField!.tokens, f.originalNoField.tokens);
|
||||
assert.equal(owner.noField!.lastShipped, f.originalNoField.lastShipped);
|
||||
assert.deepEqual(owner.noField!.deployed!.location, splitLocation(f.target));
|
||||
assert.notEqual(owner.noFieldEvent, f.originalEvent); assert.equal(owner.spice, f.originalWallet + 2);
|
||||
for (const pile of f.choice.collect) assert.equal(next.spice[pile], f.originalSpice[pile] - 1);
|
||||
const unchanged = structuredClone(next);
|
||||
assert.throws(() => applyAction(next, f.actor, f.action)); assert.deepEqual(next, unchanged);
|
||||
});
|
||||
}
|
||||
|
||||
void test('marker route receipts bind current source and event while stale and foreign declarations reject immutably', () => {
|
||||
const f = createSandmasterMarkerFixture(), view = viewGame(f.game, f.actor);
|
||||
const order = sandmasterActionOrder(view, f.actor, f.action); assert.ok(order);
|
||||
const receipt = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
|
||||
assert.doesNotThrow(() => validateSandmasterMovement(f.game, f.actor, order, receipt));
|
||||
assert.throws(() => validateSandmasterMovement(f.game, f.actor,
|
||||
{ ...order, noField: { ...order.noField!, event: 'changed-event' } }, receipt));
|
||||
const before = structuredClone(f.game);
|
||||
for (const [actor, action] of [[f.actor, { ...f.action, event: 'stale-event' }],
|
||||
[f.actor, { ...f.action, noField: 'unowned-marker' }], ['h', f.action]] as [string, Action][]) {
|
||||
assert.throws(() => applyAction(f.game, actor, action)); assert.deepEqual(f.game, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
quoteSandmasterMovement, sandmasterActionOrder, sandmasterCollectionPiles,
|
||||
sandmasterLeader, sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice,
|
||||
} from '../game/sandmaster-movement';
|
||||
import {
|
||||
actSandmasterOrnithopter as act, advanceSandmasterOrnithopter as advance,
|
||||
createSandmasterOrnithopterFixture as fixture, finishSandmasterOrnithopter as finish,
|
||||
moveSandmasterOrnithopter as move, sandmasterOrnithopterClean as clean,
|
||||
sandmasterOrnithopterDestination as destination, sandmasterOrnithopterInventory as inventory,
|
||||
sandmasterOrnithopterPlayer as player, sandmasterOrnithopterSecondAction as secondAction,
|
||||
sandmasterOrnithopterStocks as stocks, type SandmasterOrnithopterFixture,
|
||||
} from './fixture-sandmaster-ornithopter';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const ownView = (game: Game, actor: string) => viewGame(game, actor).players.find(seat => seat.id === actor)!;
|
||||
const bonusLogs = (game: Game) => game.log.filter(entry => entry.text.includes('Sandmaster collected 1 spice while moving')).length;
|
||||
function withBonus(f: SandmasterOrnithopterFixture, game: Game, bonus: number) {
|
||||
const expected = structuredClone(f.originalStocks);
|
||||
expected.find(seat => seat.id === f.actor)!.spice += bonus;
|
||||
assert.deepEqual(stocks(game), expected);
|
||||
assert.equal(bonusLogs(game) - f.originalSandmasterLogCount, bonus);
|
||||
assert.equal(game.spice[f.pile] ?? 0, f.pileBefore - bonus);
|
||||
for (const [key, amount] of Object.entries(f.originalPiles))
|
||||
if (key !== f.pile) assert.equal(game.spice[key], amount, 'Unselected entered piles are not additional compulsory income');
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
function source(f: SandmasterOrnithopterFixture) {
|
||||
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.actor);
|
||||
assert.deepEqual(f.game.players.map(seat => seat.faction), ['emperor', 'richese', 'atreides']);
|
||||
assert.deepEqual(f.game.expansions, ['choam']); assert.ok(f.game.leaderSkills);
|
||||
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens &&
|
||||
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
|
||||
assert.ok(clean(f.game)); assert.equal(sandmasterLeader(f.game, f.actor), f.trainer);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
|
||||
step.action.skill === 'sandmaster' && step.action.leader === f.trainer));
|
||||
assert.ok(f.acquisition.steps.some(step => step.actor === f.actor && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.equal(player(f.acquisition.before, f.actor).spice - player(f.acquisition.after, f.actor).spice, 1);
|
||||
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
|
||||
assert.equal(f.shipment.before.turn, 1); assert.equal(f.shipment.step.action.type, 'ship');
|
||||
assert.equal(f.shipment.step.action.amount, 4); assert.equal(f.shipment.step.action.elite, 0);
|
||||
assert.equal(player(f.shipment.before, f.actor).spice - player(f.shipment.after, f.actor).spice, 8);
|
||||
assert.equal(player(f.shipment.before, f.actor).reserves - player(f.shipment.after, f.actor).reserves, 4);
|
||||
assert.deepEqual(player(f.game, f.actor).forces, { [f.source]: 4 });
|
||||
assert.equal(player(f.game, f.actor).reserves, 16); assert.equal(player(f.game, f.actor).tanks, 0);
|
||||
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(ownView(f.game, f.actor).movesAllowed, 1);
|
||||
assert.equal(f.game.ornithopter ?? null, null);
|
||||
assert.equal(f.beforeBlow.turn, 2); assert.equal(f.beforeBlow.phase, 0);
|
||||
assert.deepEqual(f.spiceOrder.after.map(card => JSON.stringify(card)).sort(), f.spiceOrder.original.map(card => JSON.stringify(card)).sort());
|
||||
assert.ok(f.blow.steps.some(step => step.action.type === 'ready' || step.action.type === 'stormDial'));
|
||||
assert.ok(f.blow.after.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(f.blow.card)));
|
||||
assert.equal(f.blow.after.spice[f.pile], (f.beforeBlow.spice[f.pile] ?? 0) + f.blow.card.amount);
|
||||
assert.equal(f.game.spice[f.pile], f.pileBefore); assert.ok(f.pileBefore >= 2);
|
||||
assert.equal(f.choice.routes[f.source][0], f.source);
|
||||
assert.equal(f.choice.routes[f.source].at(-1), f.targetLocation);
|
||||
assert.deepEqual(f.choice.collect, [f.pile]);
|
||||
assert.equal(sandmasterRouteDistance(f.game, f.route), f.mode === 'range3' ? 3 : 1);
|
||||
const view = viewGame(f.game, f.actor), order = sandmasterActionOrder(view, f.actor, f.action);
|
||||
assert.ok(order, 'The real held-card action has an owned Sandmaster order');
|
||||
assert.equal(order.range, f.mode === 'range3' ? 3 : 1);
|
||||
const quote = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
|
||||
assert.deepEqual(quote.routes, f.choice.routes);
|
||||
assert.deepEqual(quote.piles, [{ key: f.pile, before: f.pileBefore }]);
|
||||
if (f.mode === 'range3') {
|
||||
const shortest = sandmasterShortestRoute(f.game, f.actor, f.source, f.targetLocation, 3); assert.ok(shortest);
|
||||
assert.ok(sandmasterRouteDistance(f.game, shortest) < 3,
|
||||
'The owned original choice deliberately retains a longer legal collecting route');
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function retired(f: SandmasterOrnithopterFixture, game: Game, movements: 1 | 2, bonus: number) {
|
||||
withBonus(f, game, bonus);
|
||||
assert.equal(player(game, f.actor).moved, movements);
|
||||
assert.equal(ownView(game, f.actor).movesAllowed, movements);
|
||||
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, movements - 1);
|
||||
assert.equal(game.ornithopter ?? null, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
void test(`public ${advanced ? 'Advanced' : 'Basic'} Sandmaster ${mode} collects from actual Spice blows once per accepted physical movement`, () => {
|
||||
const f = fixture({ advanced, mode }); source(f);
|
||||
const before = structuredClone(f.game);
|
||||
let game = move(f); assert.deepEqual(f.game, before);
|
||||
withBonus(f, game, 1);
|
||||
assert.equal(player(game, f.actor).forces[f.source], 2);
|
||||
assert.equal(player(game, f.actor).forces[f.targetLocation], 2);
|
||||
assert.equal(player(game, f.actor).moved, 1);
|
||||
if (mode === 'twoGroups') {
|
||||
assert.equal(game.ornithopter?.completed, 1);
|
||||
assert.deepEqual(game.ornithopter.cohort!.forces, { [f.source]: 2 });
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
|
||||
rejected(game, f.actor, { type: 'move', forces: { [f.targetLocation]: 1 },
|
||||
ornithopterEvent: game.ornithopter.event, ...f.sourceLocation });
|
||||
const action = secondAction(f, game), order = sandmasterActionOrder(viewGame(game, f.actor), f.actor, action);
|
||||
assert.ok(order);
|
||||
const quote = quoteSandmasterMovement(game, f.actor, order, action.sandmaster);
|
||||
assert.deepEqual(quote.piles, [{ key: f.pile, before: f.pileBefore - 1 }], 'The second original cohort requotes the actual remaining spice');
|
||||
game = act(game, f.actor, action); game = advance(game, clean);
|
||||
assert.deepEqual(player(game, f.actor).forces, { [f.targetLocation]: 4 });
|
||||
retired(f, game, 2, 2);
|
||||
} else retired(f, game, 1, 1);
|
||||
rejected(game, f.actor, f.action);
|
||||
const bonus = bonusLogs(game), forces = structuredClone(player(game, f.actor).forces);
|
||||
const steps: { actor: string; action: Action }[] = [];
|
||||
game = finish(f, game, steps);
|
||||
assert.equal(game.phase, 7); assert.equal(game.turn, 2);
|
||||
assert.ok(steps.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
|
||||
assert.equal(bonusLogs(game), bonus, 'Ordinary Collection does not repeat any movement bonus');
|
||||
assert.deepEqual(player(game, f.actor).forces, forces);
|
||||
assert.equal(player(game, f.actor).reserves, 16); assert.equal(player(game, f.actor).tanks, 0);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test('each actual card movement can independently decline Sandmaster without altering its original force route or disposal', () => {
|
||||
for (const advanced of [false, true]) for (const firstCollect of [false, true]) {
|
||||
const f = fixture({ advanced, mode: 'twoGroups' }); source(f);
|
||||
let game = move(f, structuredClone(f.game), [], firstCollect);
|
||||
withBonus(f, game, Number(firstCollect));
|
||||
game = act(game, f.actor, secondAction(f, game, !firstCollect)); game = advance(game, clean);
|
||||
retired(f, game, 2, 1);
|
||||
assert.deepEqual(player(game, f.actor).forces, { [f.pile]: 4 });
|
||||
}
|
||||
const f = fixture({ mode: 'range3' }); source(f);
|
||||
const game = move(f, structuredClone(f.game), [], false);
|
||||
retired(f, game, 1, 0);
|
||||
assert.equal(player(game, f.actor).forces[f.targetLocation], 2);
|
||||
});
|
||||
|
||||
void test('invalid physical-flight Sandmaster routes and pile choices reject before card, board spice or original forces move', () => {
|
||||
const f = fixture({ mode: 'range3' }); source(f);
|
||||
rejected(f.game, f.observer, f.action);
|
||||
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: {}, collect: [f.pile] } });
|
||||
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: f.routes, collect: [f.pile, f.pile] } });
|
||||
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: f.routes, collect: [f.source] } });
|
||||
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: { [f.source]: [f.source, f.targetLocation] }, collect: [f.pile] } });
|
||||
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'stale-card-flight' });
|
||||
const changed = structuredClone(f.game); changed.spice[f.pile] = 0;
|
||||
rejected(changed, f.actor, f.action); // Labelled changed-pile supplement; not a played source.
|
||||
assert.deepEqual(stocks(f.game), f.originalStocks);
|
||||
assert.deepEqual(f.game.spice, f.originalPiles);
|
||||
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('early ending retains one committed Sandmaster payment and discards the abandoned second flight once', () => {
|
||||
const f = fixture({ mode: 'twoGroups' }); source(f);
|
||||
let game = move(f);
|
||||
game = act(game, f.actor, f.endMovement); game = advance(game, state => clean(state) && state.active !== f.actor);
|
||||
assert.equal(bonusLogs(game) - f.originalSandmasterLogCount, 1);
|
||||
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
|
||||
assert.equal(player(game, f.actor).moved, 1);
|
||||
assert.equal(player(game, f.actor).forces[f.source], 2);
|
||||
assert.equal(player(game, f.actor).forces[f.pile], 2);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
const bonus = bonusLogs(game); game = finish(f, game);
|
||||
assert.equal(game.phase, 7); assert.equal(bonusLogs(game), bonus);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles offer a collected original physical flight and its remaining original group', () => {
|
||||
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
const f = fixture({ advanced, mode }); source(f);
|
||||
const sources = [f.game, ...(mode === 'twoGroups' ? [move(f)] : [])];
|
||||
for (const game of sources) for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.actor); view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const candidates = botActions(view).filter(action => {
|
||||
if (action.type !== 'move' || !action.sandmaster || typeof action.sandmaster !== 'object' || Array.isArray(action.sandmaster)) return false;
|
||||
const choice = action.sandmaster as Partial<SandmasterChoice>;
|
||||
return Array.isArray(choice.collect) && choice.collect.length > 0 &&
|
||||
(game.ornithopter ? action.ornithopterEvent === game.ornithopter.event
|
||||
: action.movementCard === f.card && action.ornithopter === mode);
|
||||
});
|
||||
assert.ok(candidates.length, `${difficulty} offers original ${mode}/${game.ornithopter ? 'second' : 'first'} Sandmaster collection`);
|
||||
const action = candidates[0], choice = action.sandmaster as SandmasterChoice;
|
||||
const offered = sandmasterCollectionPiles(game, choice.routes);
|
||||
assert.ok(choice.collect.every(key => offered[splitLocation(key).territory]?.includes(key)));
|
||||
const before = player(game, f.actor), moved = before.moved;
|
||||
let accepted = act(game, f.actor, action); accepted = advance(accepted, clean);
|
||||
assert.equal(player(accepted, f.actor).moved, moved + 1);
|
||||
assert.equal(player(accepted, f.actor).spice, before.spice + choice.collect.length);
|
||||
assert.equal(player(accepted, f.actor).reserves, before.reserves); assert.equal(player(accepted, f.actor).tanks, before.tanks);
|
||||
for (const key of choice.collect) assert.equal(accepted.spice[key] ?? 0, game.spice[key] - 1);
|
||||
assert.equal(typeof destination(action), 'string');
|
||||
assert.deepEqual(inventory(accepted), f.originalInventory);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user