Connect Sandmaster route collection and saved movement cancellation
Prototype optional one-spice collection from distinct entered territories along exact legal ground routes. Share route and collection checks between engine, controls and all four AI profiles. Preserve per-pile opt-out, multiple source sectors, physical force/spice custody and unchanged legacy moves. Apply the documented per-movement Hajr scope inference; special relocation, combined modules and ambiguous multiple-pile collection remain unfinished. Bind Fremen declarations to their actual chosen route even when a shorter route reaches the endpoint. Preserve that commitment across saved responses, collect only on successful movement, and hide its internal proof from every player projection. Independent source, rules and persistence review completed. Validation: 52 focused tests; typecheck, lint, 4504 offline tests, production build and 43 HTTP tests passed. SQLite restart, exact concurrent settlement, cancellation and legacy continuation passed. Browser exercised genuine assignment, route editing, collection opt-out/reselect, three moved forces, two collected spice, automatic notices and refresh with unchanged private cards. Two genuine four-profile Basic/Advanced samples completed in 196/89 actions with 6/3 JSON restorations and no rejected candidates; neither used Sandmaster collection, so targeted tests establish the effect. Preserved all 495 opening rooms and original seat/recovery rows (529 rooms after QA/HTTP). SQLite integrity and foreign keys passed. The healthy server was reused; no restart, reset or automation. Verified source tree: aec472f2dacdd49a13216a92bb4fb715af9d7ee028de6dd007cd496142eb06b1
This commit is contained in:
1 parent
1380271bb6
commit
bf8036dfc1
18 files changed
+1573
-25
No files matched your search
+35
-10
@@ -55,6 +55,7 @@ import { NullentropyBox, NullentropySearch } from './nullentropy-box';
|
||||
import { OrnithopterMovement } from './ornithopter-movement';
|
||||
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
|
||||
import { PlanetologistMovement } from './planetologist-movement';
|
||||
import { SandmasterMovement } from './sandmaster-movement';
|
||||
import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
|
||||
import { PortableSnooper } from './portable-snooper';
|
||||
import {
|
||||
@@ -644,6 +645,16 @@ export function GameTable({
|
||||
event: g.richeseNoField!.event,
|
||||
}
|
||||
: physicalMovementGroup;
|
||||
const ordinaryMovementAction: Action = {
|
||||
type: 'move',
|
||||
fighters:
|
||||
moveAsFighters &&
|
||||
isAdvisor(me, splitLocation(source).territory),
|
||||
...movementGroup,
|
||||
territory: selected,
|
||||
sector,
|
||||
...(includesNoField ? {} : { amount }),
|
||||
};
|
||||
const transportAction: Action = {
|
||||
type: 'guildShip',
|
||||
...(useGuildSecret ? {nexus: guildSecretEvent} : {}),
|
||||
@@ -3979,16 +3990,7 @@ export function GameTable({
|
||||
includesNoField
|
||||
? 'Move selected forces and No-Field'
|
||||
: 'Move forces',
|
||||
{
|
||||
type: 'move',
|
||||
fighters:
|
||||
moveAsFighters &&
|
||||
isAdvisor(me, splitLocation(source).territory),
|
||||
...movementGroup,
|
||||
territory: selected,
|
||||
sector,
|
||||
...(includesNoField ? {} : { amount }),
|
||||
},
|
||||
ordinaryMovementAction,
|
||||
!source ||
|
||||
canceledFremenRouteBlocked ||
|
||||
selectedDestinationInStorm ||
|
||||
@@ -4025,6 +4027,29 @@ export function GameTable({
|
||||
different sector or territory.
|
||||
</p>
|
||||
)}
|
||||
<SandmasterMovement
|
||||
game={g}
|
||||
move={source ? ordinaryMovementAction : null}
|
||||
act={act}
|
||||
busy={busy}
|
||||
unavailableReason={
|
||||
!movementAvailable
|
||||
? 'No ordinary movement is currently available.'
|
||||
: (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||||
? 'No ordinary movement remains this turn.'
|
||||
: canceledFremenRouteBlocked
|
||||
? 'Choose an ordinary route within your available range.'
|
||||
: selectedDestinationInStorm
|
||||
? 'Choose a destination outside the storm.'
|
||||
: liveShipmentPromises(
|
||||
g.shipmentPromises,
|
||||
me.id,
|
||||
g.turn,
|
||||
).some((promise) => promise.answer)
|
||||
? 'Complete the promised shipment before moving.'
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<OrnithopterMovement
|
||||
|
||||
@@ -113,7 +113,7 @@ export function LeaderSkillsPanel({
|
||||
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
|
||||
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
|
||||
Special-card battle use, Suk Graduate force rescue, Mentat and Bureaucrat
|
||||
battle scores, Sandmaster victory spice, Rihani inspections/exchanges, optional Smuggler reserve shipping and Spice Banker battle spending. Other Leader Skill effects are
|
||||
battle scores, Sandmaster ground-route collection and victory spice, Rihani inspections/exchanges, optional Smuggler reserve shipping and Spice Banker battle spending. Other Leader Skill effects are
|
||||
unfinished.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
GRAPH,
|
||||
gameTerritories,
|
||||
location,
|
||||
mobileRouteDistance,
|
||||
splitLocation,
|
||||
} from '@/game/board';
|
||||
import {
|
||||
quoteSandmasterMovement,
|
||||
sandmasterActionOrder,
|
||||
sandmasterCollectible,
|
||||
sandmasterDefaultChoice,
|
||||
sandmasterLeader,
|
||||
sandmasterModeSupported,
|
||||
sandmasterPathBlocked,
|
||||
type SandmasterChoice,
|
||||
} from '@/game/sandmaster-movement';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
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 collectible = sandmasterCollectible(game, selectedRoutes);
|
||||
const choice = {
|
||||
routes: selectedRoutes,
|
||||
collect: collectible.filter((key) => !declined.includes(key)),
|
||||
};
|
||||
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 =
|
||||
gameTerritories(game).find((candidate) => candidate.id === at.territory)
|
||||
?.name ?? at.territory;
|
||||
return `${name}, sector ${at.sector}`;
|
||||
}
|
||||
|
||||
export function SandmasterMovement({
|
||||
game,
|
||||
move,
|
||||
act,
|
||||
busy,
|
||||
unavailableReason,
|
||||
}: {
|
||||
game: GameView;
|
||||
move: Action | null;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
unavailableReason?: string | null;
|
||||
}) {
|
||||
const id = useId();
|
||||
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 draftKey = JSON.stringify([
|
||||
game.turn,
|
||||
me?.moved ?? 0,
|
||||
move,
|
||||
defaults?.routes,
|
||||
]);
|
||||
const [saved, setSaved] = useState<RouteDraft | null>(null);
|
||||
if (
|
||||
!me ||
|
||||
!leader ||
|
||||
!sandmasterModeSupported(game) ||
|
||||
!move ||
|
||||
!defaults ||
|
||||
!order
|
||||
)
|
||||
return null;
|
||||
const routes = saved?.key === draftKey ? saved.routes : defaults.routes;
|
||||
const declined = saved?.key === draftKey ? saved.declined : [];
|
||||
const draft = sandmasterMoveDraft(game, move, routes, declined);
|
||||
const updateRoutes = (next: Record<string, string[]>) =>
|
||||
setSaved({ key: draftKey, routes: next, declined });
|
||||
const blocked = unavailableReason ?? draft.blocked;
|
||||
const leaderName =
|
||||
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
|
||||
leader;
|
||||
const destination = location(order.to, order.sector);
|
||||
return (
|
||||
<section
|
||||
className="my-4 flex min-w-0 flex-col gap-3"
|
||||
aria-labelledby={`${id}-heading`}
|
||||
>
|
||||
<h3 id={`${id}-heading`}>Sandmaster movement</h3>
|
||||
<p>
|
||||
{leaderName} may collect 1 spice from one unambiguous pile in each
|
||||
distinct territory these forces enter or pass through. Choose the exact
|
||||
sector route for every moving source.
|
||||
</p>
|
||||
{order.group.map(([from]) => {
|
||||
const route = routes[from] ?? [from];
|
||||
const last = route.at(-1)!;
|
||||
const extensions =
|
||||
last === destination
|
||||
? []
|
||||
: (GRAPH[last] ?? []).filter(
|
||||
(next) =>
|
||||
!route.includes(next) &&
|
||||
!sandmasterPathBlocked(game, game.me, from, next) &&
|
||||
mobileRouteDistance([...route, next]) <= order.range,
|
||||
);
|
||||
return (
|
||||
<fieldset className="space-y-2" key={from}>
|
||||
<legend>{placeName(game, from)} route</legend>
|
||||
<p className="fine">
|
||||
{route.map((key) => placeName(game, key)).join(' → ')} ·{' '}
|
||||
{mobileRouteDistance(route)} / {order.range} territories
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || route.length === 1}
|
||||
onClick={() =>
|
||||
updateRoutes({ ...routes, [from]: route.slice(0, -1) })
|
||||
}
|
||||
>
|
||||
Remove last route step
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || route.length === 1}
|
||||
onClick={() => updateRoutes({ ...routes, [from]: [from] })}
|
||||
>
|
||||
Reset route to source
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy ||
|
||||
JSON.stringify(route) ===
|
||||
JSON.stringify(defaults.routes[from])
|
||||
}
|
||||
onClick={() =>
|
||||
updateRoutes({ ...routes, [from]: defaults.routes[from] })
|
||||
}
|
||||
>
|
||||
Use shortest route
|
||||
</Button>
|
||||
</div>
|
||||
{last !== destination && (
|
||||
<label htmlFor={`${id}-${from}-next`}>
|
||||
Add connected sector
|
||||
<select
|
||||
id={`${id}-${from}-next`}
|
||||
value=""
|
||||
disabled={busy || extensions.length === 0}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return;
|
||||
updateRoutes({
|
||||
...routes,
|
||||
[from]: [...route, event.target.value],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Choose the next sector</option>
|
||||
{extensions.map((next) => (
|
||||
<option key={next} value={next}>
|
||||
{placeName(game, next)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
{draft.collectible.length ? (
|
||||
<fieldset className="space-y-2">
|
||||
<legend>Optional spice collection</legend>
|
||||
{draft.collectible.map((key) => (
|
||||
<label className="decision-checkbox" key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!declined.includes(key)}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setSaved({
|
||||
key: draftKey,
|
||||
routes,
|
||||
declined: event.target.checked
|
||||
? declined.filter((candidate) => candidate !== key)
|
||||
: [...new Set([...declined, key])],
|
||||
})
|
||||
}
|
||||
/>
|
||||
Collect 1 spice in {placeName(game, key)} ({game.spice[key]} on
|
||||
the pile)
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : (
|
||||
<p className="fine">
|
||||
This route enters no territory with one unambiguous positive spice
|
||||
pile. You may still declare the route without collecting.
|
||||
</p>
|
||||
)}
|
||||
{blocked && (
|
||||
<output className="notice" id={`${id}-reason`}>
|
||||
{blocked}
|
||||
</output>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!blocked || !draft.action}
|
||||
aria-describedby={blocked ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !blocked && draft.action) act(draft.action);
|
||||
}}
|
||||
>
|
||||
Move with Sandmaster
|
||||
</Button>
|
||||
<p className="fine">
|
||||
Use the normal Move forces button to decline Sandmaster. A canceled
|
||||
movement collects no spice.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -309,3 +309,7 @@ Root browser review in isolated table QPNV5XJJ confirmed the full Guild row5/3/3
|
||||
## Bene Gesserit portrait checkpoint (2026-09-06)
|
||||
|
||||
Four original portraits complete Alia, Wanna Marcus, Reverend Mother Ramallo and Margot Lady Fenring alongside the existing Princess Irulan. All six base-faction rosters now have portraits, totaling30/60 ordinary identities. Thirty ordinary expansion portraits and separate special-disc artwork remain. The new1254px opaqueRGB files, exact prompts/corrections and hashes are in BENE_GESSERIT_PORTRAITS.md and the generation manifest. Root inspected all four final images; the agent inspected full,192px and80px circular views. The four existing asset tests are now registered and verify all six base rosters, identity privacy, physical files and manifest consistency. The final combined1258-case suite passes; this artwork checkpoint does not certify unfinished expansion mechanics.
|
||||
|
||||
The [Sandmaster ground-movement follow-up](SANDMASTER_MOVEMENT.md) adds explicit
|
||||
routes, optional collection, human/AI controls and saved cancellation. Special
|
||||
relocation and combined modules remain unfinished.
|
||||
@@ -4,6 +4,11 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
|
||||
|
||||
## Evidence and source of truth
|
||||
|
||||
- [Sandmaster movement collection](SANDMASTER_MOVEMENT.md) connects explicit
|
||||
ground routes, optional board-spice collection, controls, legal AI and saved
|
||||
Fremen cancellation. Special relocation and combined modules remain missing;
|
||||
multiple-pile collection stays guarded. No mode gate is opened.
|
||||
|
||||
- [Spice Banker battle spending](SPICE_BANKER_RUNTIME.md) connects sealed own
|
||||
spice, separate support funding, survivor strength, traitor payment precedence,
|
||||
private controls, all four AI profiles and saved resolution. Normal income
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 September 2026 — Sandmaster movement collection prototype
|
||||
|
||||
[Sandmaster](SANDMASTER_MOVEMENT.md) now collects optional spice from distinct
|
||||
territories entered along explicit legal ground routes. Shared human/AI choices
|
||||
retain each source sector, path and selected pile. A Fremen movement response
|
||||
preserves the original declaration; cancellation collects nothing and successful
|
||||
completion transfers each board spice once before arrival reactions. Internal
|
||||
saved proofs remain outside player projections.
|
||||
|
||||
Independent rules and persistence review, focused legality/custody/privacy tests
|
||||
and SQLite continuation cover this boundary. Broad checks, browser and sample-game
|
||||
evidence and saved-game preservation are recorded in the checkpoint commit.
|
||||
Special relocation, combined modules and multiple-pile collection remain missing;
|
||||
no public mode or publication gate is opened.
|
||||
|
||||
## 13 September 2026 — Spice Banker battle commitment prototype
|
||||
|
||||
[Spice Banker](SPICE_BANKER_RUNTIME.md) connects one through three sealed own
|
||||
|
||||
@@ -149,3 +149,7 @@ game restoration was performed.
|
||||
Checkpoint-wide check/build/HTTP results and the final source fingerprint are
|
||||
recorded in the commit and private verification report. Full module and combined
|
||||
game acceptance remain unfinished.
|
||||
|
||||
The [Sandmaster ground-movement follow-up](SANDMASTER_MOVEMENT.md) adds explicit
|
||||
routes, optional collection, human/AI controls and saved cancellation. Special
|
||||
relocation and combined modules remain unfinished.
|
||||
@@ -8,6 +8,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
| --- | --- | --- |
|
||||
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
|
||||
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. No-Field plus a separate free force is officially permitted but still awaits integration. Legacy unstamped declarations keep their quoted full price. | [Source contract and prototype](SMUGGLER_SHIPMENT.md) |
|
||||
| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) |
|
||||
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) |
|
||||
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
|
||||
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Sandmaster movement collection
|
||||
|
||||
13 September 2026. **Prototyped**, with **Partial** rules coverage. Ordinary
|
||||
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.
|
||||
|
||||
## Source contract
|
||||
|
||||
The physical card and [Jack Reda's designer walkthrough, 15:48–16:08](https://www.youtube.com/watch?v=XT_azRVLq_0&t=948s)
|
||||
allow immediate collection of one spice when forces move into or through a
|
||||
territory containing spice, once per territory. The [GF9 CHOAM & Richese rules,
|
||||
pages 8–9 and 12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
|
||||
require a living native trainer for this normal band; a captive supplies only
|
||||
the lower battle band. Common skill resolution precedes faction abilities.
|
||||
The [existing source archive](LEADER_SKILLS_RULES.md#sources-and-local-evidence)
|
||||
contains the checked physical face and timestamped designer transcript.
|
||||
|
||||
The route's initial territory earns nothing merely for being the source.
|
||||
Crossing into an intermediate or final territory qualifies, independently of
|
||||
the number of moving forces. A later legal re-entry into the source can qualify;
|
||||
each distinct entered territory still grants at most one collection. Moving
|
||||
between sectors without leaving the source territory earns nothing. The card
|
||||
says territory, so the route need not touch the spice pile's particular sector.
|
||||
Storm and stronghold passage restrictions still apply.
|
||||
|
||||
**Recorded scope inference:** the once-per-territory limit belongs to the
|
||||
movement that triggers it. A distinct Hajr movement starts another collection
|
||||
opportunity. No publisher or designer clarification specifically discussing
|
||||
Hajr was located; this follows the card's movement-triggered grammar rather
|
||||
than introducing a once-per-phase limit that the card does not state.
|
||||
|
||||
Collection commits when the legal move completes, before arrival reactions.
|
||||
Canceled or rejected declarations move neither forces nor spice. If several
|
||||
positive sector piles exist in a territory, that collection remains unavailable
|
||||
because the source does not specify which pile to debit. Movement and collection
|
||||
from other eligible territories remain available.
|
||||
|
||||
## Implementation and verification boundary
|
||||
|
||||
The optional movement choice declares a connected sector route from every
|
||||
selected source sector and the individual territories to collect from. The
|
||||
engine validates the exact paths against actual range, storm and occupancy.
|
||||
It records the native skilled leader, original movement and pile quantities.
|
||||
The same shared helper supplies default routes and collection choices to humans
|
||||
and bots. Players may edit each route and decline any or all collection;
|
||||
ordinary Move retains its existing behavior with no Sandmaster collection.
|
||||
|
||||
A Fremen route using its two-territory advantage keeps the choice in the saved
|
||||
movement response, even when a shorter direct route reaches the same endpoint.
|
||||
The response is bound to the original choice across interruptions. Changing or
|
||||
removing the commitment, response or original board spice fails validation.
|
||||
Internal proofs are excluded from player projections. Existing versioned writes
|
||||
settle the physical board-to-player transfer once, with automatic explanatory
|
||||
history. Legacy declarations without a choice continue as ordinary movements.
|
||||
|
||||
Focused tests cover Basic/Advanced collection, per-territory opt-out, malformed
|
||||
and blocked routes, custody, Fremen cancellation and recovery, saved corruption,
|
||||
private views and legal candidates across four AI profiles. SQLite checks include concurrent final allowance, exact payment, canceled
|
||||
declarations and legacy movement. Two genuine four-profile Basic/Advanced
|
||||
samples finished in 196/89 actions with 6/3 JSON restorations and no rejected
|
||||
candidates; neither encountered Sandmaster collection. Targeted tests establish
|
||||
that effect. A fresh browser room assigned the offered skill through genuine
|
||||
setup, then used a conserved staged movement. The player edited the route from
|
||||
Red Chasm through South Mesa to Pasty Mesa, exercised per-pile opt-out and moved
|
||||
three forces. Both selected piles lost one spice; own spice rose from five to
|
||||
seven. Refresh retained three Pasty Mesa forces, one Arrakeen force, sixteen
|
||||
reserves, no Tanks and the same private Crysknife and Caid. Movement completion
|
||||
awaited the human. This is targeted browser evidence, not a complete human game.
|
||||
Broad checkpoint results are recorded with the checkpoint. These checks do not certify every skill or combined module.
|
||||
|
||||
Remaining work includes worm riding and other nonordinary relocation, special
|
||||
movement cards, expansion/module integration, multiple-pile adjudication and
|
||||
wider interaction and strategy acceptance. The lower battle band remains in
|
||||
[the battle-effects contract](LEADER_BATTLE_EFFECTS.md).
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
guildTransportCost,
|
||||
} from './bot-mobility';
|
||||
import { planetologistLeader } from './planetologist-movement';
|
||||
import { sandmasterDefaultChoice } from './sandmaster-movement';
|
||||
import { faction } from './catalog';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
import { presenceAt } from './force-presence';
|
||||
@@ -3303,6 +3304,31 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
const sandmasterMoves = groundMoves.flatMap((candidate) => {
|
||||
const action = candidate.action;
|
||||
if (
|
||||
action.type !== 'move' ||
|
||||
typeof action.from !== 'string' ||
|
||||
action.noField !== undefined ||
|
||||
action.planetologist !== undefined ||
|
||||
action.movementCard !== undefined ||
|
||||
action.ornithopterEvent !== undefined ||
|
||||
action.discoveryOrnithopter !== undefined
|
||||
)
|
||||
return [];
|
||||
const choice = sandmasterDefaultChoice(g, me.id, action);
|
||||
return choice?.collect.length
|
||||
? [
|
||||
{
|
||||
...candidate,
|
||||
action: { ...action, sandmaster: choice },
|
||||
destinationScore:
|
||||
candidate.destinationScore + choice.collect.length,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
groundMoves.push(...sandmasterMoves);
|
||||
groundMoves.sort(
|
||||
(a, b) =>
|
||||
b.destinationScore - a.destinationScore ||
|
||||
|
||||
+54
-6
@@ -11,6 +11,7 @@ import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-ski
|
||||
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
||||
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
|
||||
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
|
||||
@@ -793,6 +794,8 @@ export type Decision =
|
||||
}
|
||||
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
|
||||
export type ResponseWindow = {
|
||||
/** Original optional collection binds a pending ground movement, including saved responses. */
|
||||
sandmasterProof?: string;
|
||||
/** Original eligible contributor amounts; private routing evidence, not a new payment. */
|
||||
guildContributions?: number[];
|
||||
guildPaymentProof?: string;
|
||||
@@ -4918,6 +4921,7 @@ function leaderSkillAssignmentUnavailable(
|
||||
: null;
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
sandmasterIntegrity(g);
|
||||
sukRescueIntegrity(g);
|
||||
rihaniIntegrity(g);
|
||||
for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) {
|
||||
@@ -8375,6 +8379,35 @@ function ornithopterView(g: Game, p: Player) {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
function sandmasterOrder(g: Game, p: Player, move: MovementOrder): SandmasterOrder {
|
||||
return { group: move.group, eliteGroup: move.eliteGroup, elite: move.elite, origin: move.origin,
|
||||
to: move.to, sector: move.sector, range: movementOrderRange(g, p, move) };
|
||||
}
|
||||
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.');
|
||||
nexusRule(() => validateSandmasterMovement(g, p.id, sandmasterOrder(g, p, move), move.sandmaster!));
|
||||
}
|
||||
function sandmasterIntegrity(g: Game) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
|
||||
}).filter(r => r?.kind === 'fremenMovement');
|
||||
const move = g.pendingFremenMove?.order;
|
||||
if (move?.sandmaster !== undefined || responses.some(r => r?.sandmasterProof !== undefined)) {
|
||||
requireRule(move && responses.length > 0 && responses.every(r => r!.owner === move.player &&
|
||||
r!.sandmasterProof === JSON.stringify(move.sandmaster)),
|
||||
'The saved Sandmaster route lost its original Fremen response.');
|
||||
validateMovementOrder(g, getPlayer(g, move.player), move);
|
||||
}
|
||||
for (const other of [g.pendingIxMove, g.pendingChoamMove])
|
||||
if (other?.sandmaster !== undefined) validateSandmasterOrder(g, getPlayer(g, other.player), other);
|
||||
}
|
||||
function movementOrderRange(g: Game, p: Player, move: MovementOrder) {
|
||||
return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(movementRange(g, p, move.elite), move.planetologist?.mode);
|
||||
}
|
||||
@@ -8541,6 +8574,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
);
|
||||
validateFlightSelection(g, p, move);
|
||||
validatePlanetologistMove(g, p, move);
|
||||
validateSandmasterOrder(g, p, move);
|
||||
const selected = forceGroup(g, p, {
|
||||
type: 'move',
|
||||
forces: Object.fromEntries(move.group),
|
||||
@@ -8677,6 +8711,7 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
validatePlanetologistMove(g, p, move);
|
||||
validateFlightSelection(g, p, move);
|
||||
validateMovementArrival(g, move);
|
||||
validateSandmasterOrder(g, p, move);
|
||||
const flight = g.ornithopter;
|
||||
if (
|
||||
flight?.player === id &&
|
||||
@@ -8714,6 +8749,13 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
);
|
||||
p.noFieldEvent = crypto.randomUUID();
|
||||
}
|
||||
for (const pile of move.sandmaster?.piles ?? []) {
|
||||
g.spice[pile.key]--;
|
||||
if (!g.spice[pile.key]) delete g.spice[pile.key];
|
||||
p.spice++;
|
||||
log(g, `${p.name}'s Sandmaster collected 1 spice while moving into or through ${territory(splitLocation(pile.key).territory).name}. This territory grants at most one collection during this movement.`,
|
||||
{ faction: p.faction, name: 'Sandmaster collection' });
|
||||
}
|
||||
removeGroup(p, group, eliteGroup);
|
||||
if (n > (move.noField ? 1 : 0))
|
||||
place(p, to, s, n - (move.noField ? 1 : 0), elite);
|
||||
@@ -18841,6 +18883,7 @@ type CompletedMovement = Pick<
|
||||
'player' | 'origin' | 'origins' | 'total' | 'to' | 'sector' | 'elite'
|
||||
> & { noField: boolean };
|
||||
type MovementOrder = {
|
||||
sandmaster?: SandmasterMovement;
|
||||
source?: 'ambassador';
|
||||
ambassadorEvent?: string;
|
||||
player: string;
|
||||
@@ -22082,6 +22125,10 @@ function applyActionInner(
|
||||
move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move));
|
||||
validatePlanetologistMove(g, p, move);
|
||||
const speed = movementOrderRange(g, p, move);
|
||||
if (action.sandmaster !== undefined) {
|
||||
move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster));
|
||||
validateSandmasterOrder(g, p, move);
|
||||
}
|
||||
requireRule(
|
||||
sourceKeys.every(
|
||||
(key) =>
|
||||
@@ -22118,16 +22165,16 @@ function applyActionInner(
|
||||
p.faction === 'fremen' &&
|
||||
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
|
||||
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
|
||||
group.some(
|
||||
([key]) =>
|
||||
gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin)),
|
||||
) > planetologistRange(1, planetologist?.mode),
|
||||
)
|
||||
(move.sandmaster
|
||||
? Object.values(move.sandmaster.routes).some(route => mobileRouteDistance(route) > 1)
|
||||
: group.some(
|
||||
([key]) => gameDistance(g, key, location(to, s), (k) =>
|
||||
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
|
||||
) {
|
||||
g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
|
||||
g.response = {
|
||||
kind: 'fremenMovement',
|
||||
...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}),
|
||||
owner: id,
|
||||
location: location(to, s),
|
||||
amount: n,
|
||||
@@ -23105,6 +23152,7 @@ export function viewGame(state: Game, id: string) {
|
||||
...g.response,
|
||||
...(g.response.guildContributions ? { guildContributions: undefined } : {}),
|
||||
...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
|
||||
...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}),
|
||||
passed: g.response.passed.includes(id) ? [id] : [],
|
||||
...(g.response.kind === 'revivalIncome' &&
|
||||
![g.response.owner, g.response.recipient].includes(id)
|
||||
|
||||
+7
-6
@@ -3563,7 +3563,7 @@ 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, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and sealed Spice Banker battle spending 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, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping, Sandmaster route collection and sealed Spice Banker battle spending in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
@@ -3573,20 +3573,21 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
|
||||
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.',
|
||||
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
|
||||
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Multiple piles and special relocation remain unfinished.',
|
||||
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
|
||||
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
|
||||
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
|
||||
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income remains unfinished.',
|
||||
'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, Smuggler battle collection, Spice Banker normal income and all Diplomat effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and all Diplomat effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping is connected. Spice Banker lower spending is connected; normal income, other remaining bands, Diplomat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/spice-banker.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'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 an explicit shipping opt-out and total-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'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 shipments and funded Banker commitments. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'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 normal reserve shipping and optional Sandmaster ground-route collection are connected. Spice Banker lower spending is connected; normal income, other remaining bands, Diplomat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'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 an explicit shipping opt-out and total-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'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 shipments, Sandmaster route collection and funded Banker commitments. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { Action, Game, GameView } from './engine';
|
||||
import { GRAPH, location, mobileRouteDistance, splitLocation } from './board';
|
||||
import { isAdvisor } from './advisors';
|
||||
import { strongholdPathBlocked } from './occupancy';
|
||||
import { botMovementRange } from './bot-mobility';
|
||||
|
||||
export class SandmasterMovementError extends Error {}
|
||||
export type SandmasterChoice = {
|
||||
routes: Record<string, string[]>;
|
||||
collect: string[];
|
||||
};
|
||||
export type SandmasterOrder = {
|
||||
group: [string, number][];
|
||||
eliteGroup: Record<string, number>;
|
||||
elite: number;
|
||||
origin: string;
|
||||
to: string;
|
||||
sector: number;
|
||||
range: number;
|
||||
};
|
||||
export type SandmasterMovement = SandmasterChoice & {
|
||||
leader: string;
|
||||
turn: number;
|
||||
move: number;
|
||||
order: string;
|
||||
piles: { key: string; before: number }[];
|
||||
};
|
||||
function requireMove(ok: unknown, message: string): asserts ok {
|
||||
if (!ok) throw new SandmasterMovementError(message);
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export function sandmasterModeSupported(g: Game | GameView): boolean {
|
||||
return (
|
||||
!g.expansions.length &&
|
||||
!g.homeworlds &&
|
||||
!g.nexusCards &&
|
||||
!g.discoveries &&
|
||||
!('discoveryEnabled' in g && g.discoveryEnabled) &&
|
||||
!g.strongholdCards &&
|
||||
!g.techTokens
|
||||
);
|
||||
}
|
||||
export function sandmasterLeader(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
): string | null {
|
||||
const skill = g.leaderSkills?.assignments.find(
|
||||
(a) => a.owner === player && a.skill === 'sandmaster',
|
||||
);
|
||||
if (
|
||||
!skill ||
|
||||
('controller' in skill && skill.controller !== player) ||
|
||||
('faceUp' in skill && !skill.faceUp)
|
||||
)
|
||||
return null;
|
||||
const leader = g.players
|
||||
.find((p) => p.id === player)
|
||||
?.leaders.find((l) => l.id === skill.leader);
|
||||
return leader && !leader.dead && !leader.capturedBy && !leader.gholaBy
|
||||
? leader.id
|
||||
: null;
|
||||
}
|
||||
export function sandmasterPathBlocked(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
from: string,
|
||||
key: string,
|
||||
): boolean {
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (!p || !Object.hasOwn(GRAPH, key)) return true;
|
||||
const at = splitLocation(key);
|
||||
return (
|
||||
(!!at.sector && at.sector === g.storm) ||
|
||||
strongholdPathBlocked(
|
||||
g.players,
|
||||
player,
|
||||
at.territory,
|
||||
isAdvisor(p, splitLocation(from).territory),
|
||||
)
|
||||
);
|
||||
}
|
||||
/** Only crossing a territory boundary enters a new collection opportunity. */
|
||||
export function sandmasterEnteredTerritories(
|
||||
routes: Record<string, string[]>,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
Object.values(routes).flatMap((route) =>
|
||||
route.slice(1).flatMap((key, i) => {
|
||||
const id = splitLocation(key).territory;
|
||||
return id !== splitLocation(route[i]).territory ? [id] : [];
|
||||
}),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
/** Multiple piles retain the existing unresolved physical-pile boundary. */
|
||||
export function sandmasterCollectible(
|
||||
g: Game | GameView,
|
||||
routes: Record<string, string[]>,
|
||||
): string[] {
|
||||
return sandmasterEnteredTerritories(routes).flatMap((id) => {
|
||||
const piles = Object.entries(g.spice).filter(
|
||||
([key, n]) => splitLocation(key).territory === id && n > 0,
|
||||
);
|
||||
return piles.length === 1 ? [piles[0][0]] : [];
|
||||
});
|
||||
}
|
||||
export function quoteSandmasterMovement(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
move: SandmasterOrder,
|
||||
value: unknown,
|
||||
): SandmasterMovement {
|
||||
const p = g.players.find((p) => p.id === player),
|
||||
leader = sandmasterLeader(g, player);
|
||||
requireMove(
|
||||
sandmasterModeSupported(g) &&
|
||||
leader &&
|
||||
p &&
|
||||
g.phase === 5 &&
|
||||
g.active === player,
|
||||
'Sandmaster collection needs its living native trainer and an ordinary supported movement.',
|
||||
);
|
||||
requireMove(
|
||||
record(value) &&
|
||||
Object.keys(value).sort().join(',') === 'collect,routes' &&
|
||||
record(value.routes) &&
|
||||
Array.isArray(value.collect),
|
||||
'Choose Sandmaster routes and optional collection territories.',
|
||||
);
|
||||
const sources = move.group.map(([key]) => key);
|
||||
requireMove(
|
||||
sources.length > 0 &&
|
||||
new Set(sources).size === sources.length &&
|
||||
JSON.stringify(Object.keys(value.routes).sort()) ===
|
||||
JSON.stringify([...sources].sort()),
|
||||
'Every moving source sector needs its own Sandmaster route.',
|
||||
);
|
||||
const routes: Record<string, string[]> = {};
|
||||
for (const from of sources) {
|
||||
const route = value.routes[from];
|
||||
requireMove(
|
||||
Array.isArray(route) &&
|
||||
route.every((k) => typeof k === 'string') &&
|
||||
route[0] === from &&
|
||||
route.at(-1) === location(move.to, move.sector) &&
|
||||
mobileRouteDistance(route) <= move.range &&
|
||||
route.every((key) => !sandmasterPathBlocked(g, player, from, key)),
|
||||
'Sandmaster needs a connected route within movement range, outside blocked sectors.',
|
||||
);
|
||||
routes[from] = [...route];
|
||||
}
|
||||
const collect = value.collect;
|
||||
const available = sandmasterCollectible(g, routes);
|
||||
requireMove(
|
||||
collect.every(
|
||||
(key) => typeof key === 'string' && available.includes(key),
|
||||
) && new Set(collect).size === collect.length,
|
||||
'Collect once per entered territory with one unambiguous spice pile, or decline that collection.',
|
||||
);
|
||||
return {
|
||||
leader,
|
||||
turn: g.turn,
|
||||
move: p.moved ?? 0,
|
||||
order: JSON.stringify(move),
|
||||
routes,
|
||||
collect: [...collect],
|
||||
piles: collect.map((key) => ({ key, before: g.spice[key] })),
|
||||
};
|
||||
}
|
||||
export function validateSandmasterMovement(
|
||||
g: Game,
|
||||
player: string,
|
||||
move: SandmasterOrder,
|
||||
saved: SandmasterMovement,
|
||||
): void {
|
||||
requireMove(record(saved), 'The saved Sandmaster movement is missing.');
|
||||
const current = quoteSandmasterMovement(g, player, move, {
|
||||
routes: saved.routes,
|
||||
collect: saved.collect,
|
||||
});
|
||||
requireMove(
|
||||
JSON.stringify(saved) === JSON.stringify(current),
|
||||
'The saved Sandmaster movement or board spice has changed.',
|
||||
);
|
||||
}
|
||||
|
||||
/** A deterministic shortest route is a default; humans can choose a different legal route. */
|
||||
export function sandmasterShortestRoute(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
from: string,
|
||||
to: string,
|
||||
range: number,
|
||||
): string[] | null {
|
||||
if (sandmasterPathBlocked(g, player, from, from)) return null;
|
||||
const queue = [{ route: [from], cost: 0 }],
|
||||
best = new Map([[from, 0]]);
|
||||
while (queue.length) {
|
||||
queue.sort((a, b) => a.cost - b.cost || a.route.length - b.route.length);
|
||||
const { route, cost } = queue.shift()!,
|
||||
last = route.at(-1)!;
|
||||
if (cost !== best.get(last)) continue;
|
||||
if (last === to) return route;
|
||||
for (const key of GRAPH[last] ?? []) {
|
||||
if (sandmasterPathBlocked(g, player, from, key)) continue;
|
||||
const next =
|
||||
cost +
|
||||
Number(splitLocation(last).territory !== splitLocation(key).territory);
|
||||
if (next <= range && next < (best.get(key) ?? Infinity)) {
|
||||
best.set(key, next);
|
||||
queue.push({ route: [...route, key], cost: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function sandmasterActionOrder(
|
||||
g: GameView,
|
||||
player: string,
|
||||
action: Action,
|
||||
): SandmasterOrder | null {
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (
|
||||
!p ||
|
||||
action.type !== 'move' ||
|
||||
action.noField !== undefined ||
|
||||
action.planetologist !== undefined ||
|
||||
action.movementCard !== undefined ||
|
||||
action.ornithopterEvent !== undefined ||
|
||||
action.discoveryOrnithopter !== undefined ||
|
||||
g.ornithopter?.active
|
||||
)
|
||||
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 elite = Object.values(eliteGroup).reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin: splitLocation(group[0][0]).territory,
|
||||
to: action.territory,
|
||||
sector: action.sector,
|
||||
range: botMovementRange(g, p, elite),
|
||||
};
|
||||
}
|
||||
export function sandmasterDefaultChoice(
|
||||
g: GameView,
|
||||
player: string,
|
||||
action: Action,
|
||||
): SandmasterChoice | null {
|
||||
if (!sandmasterModeSupported(g) || !sandmasterLeader(g, player)) return null;
|
||||
const move = sandmasterActionOrder(g, player, action);
|
||||
if (!move) return null;
|
||||
const routes: Record<string, string[]> = {};
|
||||
for (const [from] of move.group) {
|
||||
const route = sandmasterShortestRoute(
|
||||
g,
|
||||
player,
|
||||
from,
|
||||
location(move.to, move.sector),
|
||||
move.range,
|
||||
);
|
||||
if (!route) return null;
|
||||
routes[from] = route;
|
||||
}
|
||||
return { routes, collect: sandmasterCollectible(g, routes) };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
SandmasterMovement,
|
||||
sandmasterMoveDraft,
|
||||
} from '../components/sandmaster-movement';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { GRAPH, location } from '../game/board';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
|
||||
const source = location('false_wall_west', 16);
|
||||
const destination = location('wind_pass', 16);
|
||||
const spicePile = location('wind_pass', 15);
|
||||
const move: Action = {
|
||||
type: 'move',
|
||||
from: source,
|
||||
amount: 2,
|
||||
elite: 0,
|
||||
territory: 'wind_pass',
|
||||
sector: 16,
|
||||
};
|
||||
|
||||
function fixture(advanced = false): Game {
|
||||
const game = createGame(
|
||||
'SANDUI',
|
||||
newPlayer('p', 'Mover', 'emperor'),
|
||||
advanced,
|
||||
);
|
||||
joinGame(game, newPlayer('o', 'Observer', 'atreides'));
|
||||
Object.assign(game, {
|
||||
status: 'playing',
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
order: ['p', 'o'],
|
||||
active: 'p',
|
||||
movementRemaining: ['p', 'o'],
|
||||
spice: { [spicePile]: 3 },
|
||||
});
|
||||
for (const player of game.players) {
|
||||
player.hand = [];
|
||||
player.forces = {};
|
||||
player.reserves = 20;
|
||||
player.shipped = true;
|
||||
}
|
||||
game.players[0].forces[source] = 2;
|
||||
game.players[0].reserves = 18;
|
||||
game.leaderSkills = {
|
||||
deck: LEADER_SKILL_CARDS.map((card) => card.id).filter(
|
||||
(id) => id !== 'sandmaster',
|
||||
),
|
||||
offers: {},
|
||||
assignments: [{ skill: 'sandmaster', leader: 'emperor-0', owner: 'p' }],
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
||||
function markup(game: Game, viewer = 'p', busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(SandmasterMovement, {
|
||||
game: viewGame(game, viewer),
|
||||
move,
|
||||
act() {},
|
||||
busy,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('the owner sees exact default routes, per-pile opt-out and explicit route editing controls', () => {
|
||||
const game = fixture();
|
||||
const html = markup(game);
|
||||
assert.match(html, /Sandmaster movement/);
|
||||
assert.match(
|
||||
html,
|
||||
/False Wall West, sector 16 → Wind Pass, sector 16 · 1 \/ 1 territories/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/Collect 1 spice in Wind Pass, sector 15 \(3 on the pile\)/,
|
||||
);
|
||||
assert.match(html, /type="checkbox" checked=""/);
|
||||
assert.match(html, /Remove last route step/);
|
||||
assert.match(html, /Reset route to source/);
|
||||
assert.match(html, /Use shortest route/);
|
||||
assert.match(html, />Move with Sandmaster<\/button>/);
|
||||
assert.match(html, /normal Move forces button to decline Sandmaster/);
|
||||
assert.equal(markup(game, 'o'), '');
|
||||
assert.ok((markup(game, 'p', true).match(/disabled=""/g) ?? []).length >= 4);
|
||||
|
||||
const combined = fixture();
|
||||
combined.expansions = ['ix'];
|
||||
assert.equal(markup(combined), '');
|
||||
});
|
||||
|
||||
void test('drafts preserve the ordinary move, accept a connected alternate and allow collecting or declining', () => {
|
||||
const game = fixture();
|
||||
const view = viewGame(game, 'p');
|
||||
const standard = sandmasterMoveDraft(view, move);
|
||||
assert.equal(standard.blocked, null);
|
||||
assert.deepEqual(standard.choice, {
|
||||
routes: { [source]: [source, destination] },
|
||||
collect: [spicePile],
|
||||
});
|
||||
assert.deepEqual(standard.action, {
|
||||
...move,
|
||||
sandmaster: standard.choice,
|
||||
});
|
||||
const collected = applyAction(structuredClone(game), 'p', standard.action!);
|
||||
assert.equal(collected.players[0].spice, game.players[0].spice + 1);
|
||||
assert.equal(collected.spice[spicePile], 2);
|
||||
|
||||
assert.ok(GRAPH[source].includes(location('wind_pass', 17)));
|
||||
assert.ok(GRAPH[location('wind_pass', 17)].includes(destination));
|
||||
const alternate = sandmasterMoveDraft(view, move, {
|
||||
[source]: [source, location('wind_pass', 17), destination],
|
||||
});
|
||||
assert.equal(alternate.blocked, null);
|
||||
assert.deepEqual(alternate.choice?.collect, [spicePile]);
|
||||
|
||||
const declined = sandmasterMoveDraft(view, move, undefined, [spicePile]);
|
||||
assert.deepEqual(declined.choice?.collect, []);
|
||||
const noCollection = applyAction(
|
||||
structuredClone(game),
|
||||
'p',
|
||||
declined.action!,
|
||||
);
|
||||
assert.equal(noCollection.players[0].spice, game.players[0].spice);
|
||||
assert.equal(noCollection.spice[spicePile], 3);
|
||||
|
||||
assert.match(
|
||||
sandmasterMoveDraft(view, move, {
|
||||
[source]: [source, location('arrakeen', 10), destination],
|
||||
}).blocked!,
|
||||
/connected route/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('all four bot profiles add legal collection only to ordinary single-source candidates', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = fixture();
|
||||
const view = viewGame(game, 'p');
|
||||
view.players.find((player) => player.id === 'p')!.bot = difficulty;
|
||||
const candidates = botActions(view).filter(
|
||||
(action) => action.type === 'move' && action.sandmaster,
|
||||
);
|
||||
assert.ok(candidates.length, `${difficulty} omitted Sandmaster collection`);
|
||||
assert.ok(
|
||||
candidates.every(
|
||||
(action) =>
|
||||
typeof action.from === 'string' &&
|
||||
action.planetologist === undefined &&
|
||||
action.movementCard === undefined &&
|
||||
action.ornithopterEvent === undefined,
|
||||
),
|
||||
);
|
||||
assert.doesNotThrow(
|
||||
() => applyAction(game, 'p', candidates[0]),
|
||||
`${difficulty} emitted an illegal Sandmaster move`,
|
||||
);
|
||||
|
||||
const combined = viewGame(fixture(), 'p');
|
||||
combined.players.find((player) => player.id === 'p')!.bot = difficulty;
|
||||
combined.expansions = ['ix'];
|
||||
assert.ok(
|
||||
botActions(combined).every((action) => !action.sandmaster),
|
||||
`${difficulty} emitted Sandmaster in a gated module combination`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import { viewGame } from '../game/engine';
|
||||
import {
|
||||
GRAPH,
|
||||
mobileRouteDistance,
|
||||
splitLocation,
|
||||
territory,
|
||||
} from '../game/board';
|
||||
import {
|
||||
sandmasterDefaultChoice,
|
||||
sandmasterEnteredTerritories,
|
||||
sandmasterShortestRoute,
|
||||
} from '../game/sandmaster-movement';
|
||||
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
|
||||
|
||||
export function sandmasterMovementGame(
|
||||
faction: FactionId = 'emperor',
|
||||
advanced = false,
|
||||
): Game {
|
||||
const g = smugglerShipmentGame(faction, advanced, 'sandmaster');
|
||||
g.players[0].shipped = true;
|
||||
g.players[0].forces = { 'red_chasm:7': 3 };
|
||||
g.players[0].reserves = 17;
|
||||
if (faction !== 'fremen') {
|
||||
g.players[0].forces['arrakeen:10'] = 1;
|
||||
g.players[0].reserves--;
|
||||
}
|
||||
g.spice = {};
|
||||
return g;
|
||||
}
|
||||
export function sandmasterMove(g: Game, steps = 2): Action {
|
||||
const from = 'red_chasm:7';
|
||||
const route = Object.keys(GRAPH)
|
||||
.map((to) => sandmasterShortestRoute(g, 'p', from, to, steps))
|
||||
.find(
|
||||
(route) =>
|
||||
route &&
|
||||
mobileRouteDistance(route) === steps &&
|
||||
sandmasterEnteredTerritories({ [from]: route }).length === steps &&
|
||||
sandmasterEnteredTerritories({ [from]: route }).every(
|
||||
(id) => territory(id).type === 'sand',
|
||||
),
|
||||
);
|
||||
assert.ok(route);
|
||||
for (const id of sandmasterEnteredTerritories({ [from]: route })) {
|
||||
const sector = territory(id).sectors.find((s) => s !== g.storm)!;
|
||||
g.spice[`${id}:${sector}`] = 4;
|
||||
}
|
||||
const target = splitLocation(route.at(-1)!);
|
||||
const action: Action = {
|
||||
type: 'move',
|
||||
from,
|
||||
amount: 3,
|
||||
territory: target.territory,
|
||||
sector: target.sector,
|
||||
};
|
||||
action.sandmaster = sandmasterDefaultChoice(viewGame(g, 'p'), 'p', action);
|
||||
return action;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { gameDistance, location, mobileRouteDistance } from '../game/board';
|
||||
import type { SandmasterChoice } from '../game/sandmaster-movement';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
sandmasterMove,
|
||||
sandmasterMovementGame,
|
||||
} from './sandmaster-movement-fixture';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture(detour = false) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
'Fremen Sandmaster',
|
||||
'fremen',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const joined = await store.rooms.joinRoom(
|
||||
created.view.code,
|
||||
'Guild responder',
|
||||
'guild',
|
||||
);
|
||||
const code = created.view.code;
|
||||
const tokens = [created.token!, joined.token!];
|
||||
const original = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
for (const [index, seat] of original.entries())
|
||||
sqlite
|
||||
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
|
||||
.run(index === 0 ? 'p' : 'h', code, seat.playerId);
|
||||
const seats = await Promise.all(
|
||||
tokens.map((token) => store.restart().authenticate(code, token)),
|
||||
);
|
||||
|
||||
const state = sandmasterMovementGame('fremen');
|
||||
state.code = code;
|
||||
state.version = (await store.rooms.readRoom(code)).version;
|
||||
const karama = [
|
||||
...state.deck,
|
||||
...state.discard,
|
||||
...state.players.flatMap((player) => player.hand),
|
||||
].find((card) => card.effect === 'karama')!;
|
||||
assert.ok(karama);
|
||||
placeFixtureHand(state, 1, [karama]);
|
||||
const action = sandmasterMove(state);
|
||||
if (detour) {
|
||||
const from = 'red_chasm:7';
|
||||
const route = [from, 'pasty_mesa:6', 'pasty_mesa:5', 'south_mesa:4'];
|
||||
state.spice = { 'pasty_mesa:6': 4, 'south_mesa:4': 4 };
|
||||
Object.assign(action, {
|
||||
territory: 'south_mesa',
|
||||
sector: 4,
|
||||
sandmaster: {
|
||||
routes: { [from]: route },
|
||||
collect: ['pasty_mesa:6', 'south_mesa:4'],
|
||||
} satisfies SandmasterChoice,
|
||||
});
|
||||
assert.equal(mobileRouteDistance(route), 2);
|
||||
assert.equal(
|
||||
gameDistance(state, from, location('south_mesa', 4), (key) =>
|
||||
key.endsWith(`:${state.storm}`),
|
||||
),
|
||||
1,
|
||||
'The response must be bound to the chosen two-territory route, not only the endpoint distance.',
|
||||
);
|
||||
}
|
||||
sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(state), state.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, sqlite, code, seats, state, action, karama };
|
||||
}
|
||||
|
||||
function spiceTotal(g: Game) {
|
||||
return (
|
||||
Object.values(g.spice).reduce((sum, spice) => sum + spice, 0) +
|
||||
g.players.reduce((sum, player) => sum + player.spice, 0)
|
||||
);
|
||||
}
|
||||
|
||||
function barrier() {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrivals = 0;
|
||||
return async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await ready;
|
||||
};
|
||||
}
|
||||
|
||||
void test('SQLite restart keeps a Fremen Sandmaster route pending and concurrent allowance collects each pile once', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
const saved = pending.pendingFremenMove?.order.sandmaster;
|
||||
assert.equal(pending.response?.kind, 'fremenMovement');
|
||||
assert.ok(saved);
|
||||
assert.deepEqual(pending.players[0].forces, f.state.players[0].forces);
|
||||
assert.equal(pending.players[0].spice, f.state.players[0].spice);
|
||||
assert.deepEqual(pending.spice, f.state.spice);
|
||||
assert.equal(spiceTotal(pending), spiceTotal(f.state));
|
||||
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.equal(view.response?.kind, 'fremenMovement');
|
||||
assert.equal(view.response?.sandmasterProof, undefined);
|
||||
assert.equal(
|
||||
JSON.stringify(view.response).includes('sandmasterProof'),
|
||||
false,
|
||||
);
|
||||
assert.equal('pendingFremenMove' in view, false);
|
||||
const other = view.players.find((player) => player.id !== seat.playerId)!;
|
||||
assert.equal('spice' in other, false);
|
||||
assert.equal('hand' in other, false);
|
||||
}
|
||||
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const allow: Action = { type: 'passResponse' };
|
||||
const attempts = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[1], pending.version, allow, clock),
|
||||
f.restart().act(f.code, f.seats[1], pending.version, allow, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
assert.equal(done.pendingFremenMove, null);
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
assert.equal(
|
||||
done.players[0].spice,
|
||||
f.state.players[0].spice + saved.collect.length,
|
||||
);
|
||||
for (const pile of saved.piles)
|
||||
assert.equal(done.spice[pile.key] ?? 0, pile.before - 1);
|
||||
assert.equal(
|
||||
done.log.filter(
|
||||
(entry) => entry.automatic?.name === 'Sandmaster collection',
|
||||
).length,
|
||||
saved.collect.length,
|
||||
);
|
||||
assert.equal(spiceTotal(done), spiceTotal(f.state));
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored Karama cancellation releases the Fremen Sandmaster route without moving forces or spice', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
pending.version,
|
||||
{ type: 'card', mode: 'cancel', card: f.karama.id },
|
||||
clock,
|
||||
);
|
||||
const canceled = await f.restart().readRoom(f.code);
|
||||
assert.equal(canceled.pendingFremenMove, null);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.players[0].moved, 0);
|
||||
assert.deepEqual(canceled.players[0].forces, f.state.players[0].forces);
|
||||
assert.equal(canceled.players[0].spice, f.state.players[0].spice);
|
||||
assert.deepEqual(canceled.spice, f.state.spice);
|
||||
assert.deepEqual(canceled.players[0].fremenMovementBlocked, {
|
||||
turn: f.state.turn,
|
||||
move: 0,
|
||||
});
|
||||
assert.equal(
|
||||
canceled.log.some(
|
||||
(entry) => entry.automatic?.name === 'Sandmaster collection',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(spiceTotal(canceled), spiceTotal(f.state));
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored movement without a Sandmaster selection remains an ordinary movement', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const { sandmaster: _unused, ...ordinary } = f.action;
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, ordinary, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.response?.kind, 'fremenMovement');
|
||||
assert.equal(pending.pendingFremenMove?.order.sandmaster, undefined);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[1],
|
||||
pending.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
assert.equal(done.players[0].spice, f.state.players[0].spice);
|
||||
assert.deepEqual(done.spice, f.state.spice);
|
||||
assert.equal(
|
||||
done.log.some(
|
||||
(entry) => entry.automatic?.name === 'Sandmaster collection',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(spiceTotal(done), spiceTotal(f.state));
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
sandmasterDefaultChoice,
|
||||
type SandmasterChoice,
|
||||
} from '../game/sandmaster-movement';
|
||||
import { splitLocation, territory } from '../game/board';
|
||||
import {
|
||||
sandmasterMove,
|
||||
sandmasterMovementGame,
|
||||
} from './sandmaster-movement-fixture';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
|
||||
const restored = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function unchanged(
|
||||
g: Game,
|
||||
action: Action,
|
||||
error = /Sandmaster|route|collection|source|spice/i,
|
||||
) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'p', action), error);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function conserved(g: Game, before: Game) {
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
const sum = (game: Game) =>
|
||||
Object.values(game.spice).reduce((a, b) => a + b, 0) +
|
||||
game.players.reduce((a, p) => a + p.spice, 0);
|
||||
assert.equal(sum(g), sum(before));
|
||||
}
|
||||
void test('Sandmaster collects once in every selected traversed territory and preserves JSON continuation', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = sandmasterMovementGame('emperor', advanced),
|
||||
action = sandmasterMove(g, 3);
|
||||
const choice = action.sandmaster as SandmasterChoice;
|
||||
assert.equal(choice.collect.length, 3);
|
||||
g.spice['red_chasm:7'] = 6;
|
||||
const next = applyAction(restored(g), 'p', action);
|
||||
assert.equal(next.players[0].spice, g.players[0].spice + 3);
|
||||
assert.equal(next.spice['red_chasm:7'], 6);
|
||||
assert.equal(next.players[0].moved, 1);
|
||||
assert.equal(
|
||||
next.log.filter((l) => l.automatic?.name === 'Sandmaster collection')
|
||||
.length,
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(
|
||||
viewGame(restored(next), 'p').players[0].hand,
|
||||
viewGame(next, 'p').players[0].hand,
|
||||
);
|
||||
assert.equal(
|
||||
viewGame(next, 'h').players.find((p) => p.id === 'p')!.spice,
|
||||
undefined,
|
||||
);
|
||||
conserved(next, g);
|
||||
}
|
||||
});
|
||||
void test('Sandmaster can decline each territory or the entire effect; legacy movement collects nothing', () => {
|
||||
const g = sandmasterMovementGame(),
|
||||
action = sandmasterMove(g);
|
||||
const choice = action.sandmaster as SandmasterChoice;
|
||||
for (const collect of [[], choice.collect.slice(0, 1)]) {
|
||||
const next = applyAction(g, 'p', {
|
||||
...action,
|
||||
sandmaster: { ...choice, collect },
|
||||
});
|
||||
assert.equal(next.players[0].spice, g.players[0].spice + collect.length);
|
||||
conserved(next, g);
|
||||
}
|
||||
const { sandmaster: _unused, ...legacy } = action;
|
||||
assert.equal(
|
||||
applyAction(g, 'p', legacy).players[0].spice,
|
||||
g.players[0].spice,
|
||||
);
|
||||
});
|
||||
void test('Sandmaster rejects disconnected, overlong, storm, duplicate and ambiguous collection choices without mutation', () => {
|
||||
const g = sandmasterMovementGame(),
|
||||
action = sandmasterMove(g),
|
||||
choice = action.sandmaster as SandmasterChoice;
|
||||
const from = 'red_chasm:7',
|
||||
route = choice.routes[from];
|
||||
unchanged(g, { ...action, sandmaster: { ...choice, routes: {} } });
|
||||
unchanged(g, {
|
||||
...action,
|
||||
sandmaster: {
|
||||
...choice,
|
||||
routes: { [from]: [from, 'polar_sink:0', route.at(-1)] },
|
||||
},
|
||||
});
|
||||
unchanged(g, {
|
||||
...action,
|
||||
sandmaster: { ...choice, collect: [choice.collect[0], choice.collect[0]] },
|
||||
});
|
||||
unchanged(g, {
|
||||
...action,
|
||||
sandmaster: { ...choice, collect: ['red_chasm:7'] },
|
||||
});
|
||||
unchanged(g, { ...action, sandmaster: null });
|
||||
const blocked = restored(g);
|
||||
blocked.storm = splitLocation(route.at(-1)!).sector;
|
||||
unchanged(blocked, action, /storm|blocked|Sandmaster/i);
|
||||
const tooFar = restored(g);
|
||||
delete tooFar.players[0].forces['arrakeen:10'];
|
||||
tooFar.players[0].reserves++;
|
||||
unchanged(tooFar, action);
|
||||
const id = splitLocation(choice.collect[0]).territory;
|
||||
const second = territory(id).sectors.find(
|
||||
(s) => `${id}:${s}` !== choice.collect[0],
|
||||
);
|
||||
if (second !== undefined) {
|
||||
const ambiguous = restored(g);
|
||||
ambiguous.spice[`${id}:${second}`] = 2;
|
||||
unchanged(ambiguous, action);
|
||||
const decline = {
|
||||
...choice,
|
||||
collect: choice.collect.filter((key) => key !== choice.collect[0]),
|
||||
};
|
||||
assert.doesNotThrow(() =>
|
||||
applyAction(ambiguous, 'p', { ...action, sandmaster: decline }),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('Fremen Sandmaster waits for movement permission and cancellation releases untouched spice', () => {
|
||||
const g = sandmasterMovementGame('fremen'),
|
||||
action = sandmasterMove(g);
|
||||
const karama = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
|
||||
(c) => c.effect === 'karama',
|
||||
)!;
|
||||
placeFixtureHand(g, 1, [karama]);
|
||||
const pending = applyAction(g, 'p', action);
|
||||
assert.equal(pending.response?.kind, 'fremenMovement');
|
||||
assert.equal(pending.players[0].spice, g.players[0].spice);
|
||||
assert.deepEqual(pending.spice, g.spice);
|
||||
const allowed = applyAction(restored(pending), 'h', { type: 'passResponse' });
|
||||
assert.equal(allowed.players[0].spice, g.players[0].spice + 2);
|
||||
conserved(allowed, g);
|
||||
const canceled = applyAction(restored(pending), 'h', {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: karama.id,
|
||||
});
|
||||
assert.equal(canceled.players[0].spice, g.players[0].spice);
|
||||
assert.deepEqual(canceled.spice, g.spice);
|
||||
assert.equal(canceled.players[0].moved, 0);
|
||||
for (const mutate of [
|
||||
(saved: Game) => {
|
||||
delete saved.pendingFremenMove!.order.sandmaster;
|
||||
},
|
||||
(saved: Game) => {
|
||||
saved.response = null;
|
||||
},
|
||||
(saved: Game) => {
|
||||
saved.pendingFremenMove!.order.sandmaster!.piles[0].before++;
|
||||
},
|
||||
(saved: Game) => {
|
||||
saved.spice[(action.sandmaster as SandmasterChoice).collect[0]]++;
|
||||
},
|
||||
]) {
|
||||
const saved = restored(pending);
|
||||
mutate(saved);
|
||||
assert.throws(() => viewGame(saved, 'p'), /Sandmaster/i);
|
||||
assert.throws(() => normalizeAutomaticGame(saved), /Sandmaster/i);
|
||||
}
|
||||
});
|
||||
void test('All four AI profiles can submit legal funded Sandmaster movement with only public route information', () => {
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const g = sandmasterMovementGame();
|
||||
sandmasterMove(g);
|
||||
g.players[0].autopilot = difficulty;
|
||||
const view = viewGame(g, 'p');
|
||||
const action = botActions(view).find(
|
||||
(a) => a.type === 'move' && a.sandmaster,
|
||||
);
|
||||
assert.ok(action, difficulty);
|
||||
const next = applyAction(g, 'p', action);
|
||||
assert.ok(next.players[0].spice > g.players[0].spice);
|
||||
conserved(next, g);
|
||||
}
|
||||
});
|
||||
void test('Dead, captive and combined-module Sandmaster cannot grant normal collection', () => {
|
||||
const g = sandmasterMovementGame(),
|
||||
action = sandmasterMove(g);
|
||||
const skill = g.leaderSkills!.assignments.find((a) => a.owner === 'p')!;
|
||||
const captive = restored(g);
|
||||
captive.players[0].leaders.find((l) => l.id === skill.leader)!.capturedBy =
|
||||
'h';
|
||||
unchanged(captive, action);
|
||||
const combined = restored(g);
|
||||
combined.techTokens = createTechTokens(combined.players);
|
||||
unchanged(combined, action);
|
||||
assert.equal(
|
||||
sandmasterDefaultChoice(viewGame(combined, 'p'), 'p', action),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Sandmaster combines source sectors without duplicate collection and sector-only repositioning earns nothing', () => {
|
||||
const g = sandmasterMovementGame();
|
||||
g.players[0].forces = {
|
||||
'pasty_mesa:6': 2,
|
||||
'pasty_mesa:7': 1,
|
||||
'arrakeen:10': 1,
|
||||
};
|
||||
g.spice = { 'red_chasm:7': 4, 'pasty_mesa:8': 5 };
|
||||
const action: Action = {
|
||||
type: 'move',
|
||||
forces: { 'pasty_mesa:6': 2, 'pasty_mesa:7': 1 },
|
||||
territory: 'red_chasm',
|
||||
sector: 7,
|
||||
};
|
||||
const choice = sandmasterDefaultChoice(viewGame(g, 'p'), 'p', action)!;
|
||||
assert.equal(Object.keys(choice.routes).length, 2);
|
||||
assert.deepEqual(choice.collect, ['red_chasm:7']);
|
||||
const next = applyAction(g, 'p', { ...action, sandmaster: choice });
|
||||
assert.equal(next.players[0].spice, g.players[0].spice + 1);
|
||||
assert.equal(next.players[0].forces['red_chasm:7'], 3);
|
||||
assert.equal(next.spice['pasty_mesa:8'], 5);
|
||||
conserved(next, g);
|
||||
const reposition: Action = {
|
||||
type: 'move',
|
||||
from: 'pasty_mesa:6',
|
||||
amount: 2,
|
||||
territory: 'pasty_mesa',
|
||||
sector: 7,
|
||||
};
|
||||
const sectorChoice = sandmasterDefaultChoice(
|
||||
viewGame(g, 'p'),
|
||||
'p',
|
||||
reposition,
|
||||
)!;
|
||||
assert.deepEqual(sectorChoice.collect, []);
|
||||
assert.equal(
|
||||
applyAction(g, 'p', { ...reposition, sandmaster: sectorChoice }).players[0]
|
||||
.spice,
|
||||
g.players[0].spice,
|
||||
);
|
||||
});
|
||||
|
||||
void test('A separate Hajr movement can collect again and only a later re-entry qualifies its starting territory', () => {
|
||||
const g = sandmasterMovementGame();
|
||||
const hajr = g.deck.find((c) => c.effect === 'hajr')!;
|
||||
assert.ok(hajr);
|
||||
placeFixtureHand(g, 0, [hajr]);
|
||||
g.spice = { 'pasty_mesa:8': 4, 'red_chasm:7': 5 };
|
||||
const first: Action = {
|
||||
type: 'move',
|
||||
from: 'red_chasm:7',
|
||||
amount: 3,
|
||||
territory: 'pasty_mesa',
|
||||
sector: 6,
|
||||
};
|
||||
let next = applyAction(g, 'p', {
|
||||
...first,
|
||||
sandmaster: sandmasterDefaultChoice(viewGame(g, 'p'), 'p', first),
|
||||
});
|
||||
assert.equal(next.spice['pasty_mesa:8'], 3);
|
||||
next = applyAction(next, 'p', { type: 'card', card: hajr.id });
|
||||
const second: Action = {
|
||||
type: 'move',
|
||||
from: 'pasty_mesa:6',
|
||||
amount: 3,
|
||||
territory: 'pasty_mesa',
|
||||
sector: 7,
|
||||
sandmaster: {
|
||||
routes: {
|
||||
'pasty_mesa:6': ['pasty_mesa:6', 'red_chasm:7', 'pasty_mesa:7'],
|
||||
},
|
||||
collect: ['pasty_mesa:8'],
|
||||
},
|
||||
};
|
||||
next = applyAction(restored(next), 'p', second);
|
||||
assert.equal(next.spice['pasty_mesa:8'], 2);
|
||||
assert.equal(next.spice['red_chasm:7'], 5);
|
||||
assert.equal(next.players[0].spice, g.players[0].spice + 2);
|
||||
assert.equal(next.players[0].moved, 2);
|
||||
conserved(next, g);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { botActions } from '../game/bots';
|
||||
export function smugglerShipmentGame(
|
||||
faction: FactionId = 'emperor',
|
||||
advanced = false,
|
||||
skill: 'smuggler' | 'sandmaster' = 'smuggler',
|
||||
): Game {
|
||||
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced);
|
||||
joinGame(
|
||||
@@ -24,7 +25,7 @@ export function smugglerShipmentGame(
|
||||
newPlayer('h', 'Opponent', faction === 'guild' ? 'emperor' : 'guild'),
|
||||
);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'smuggler');
|
||||
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === skill);
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = cursor-- === index ? 0 : 0xffffffff;
|
||||
@@ -46,7 +47,7 @@ export function smugglerShipmentGame(
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: skills.offer.event,
|
||||
skill: p.id === 'p' ? 'smuggler' : skills.offer.cards[0],
|
||||
skill: p.id === 'p' ? skill : skills.offer.cards[0],
|
||||
leader: skills.eligibleLeaders[0].id,
|
||||
},
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user