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
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user