Enable Sandmaster collection on flight and marker routes
This commit is contained in:
1 parent
4af90e547c
commit
ec549028ba
20 files changed
+1623
-165
No files matched your search
+22
-6
@@ -105,7 +105,7 @@ import {
|
||||
} from './bot-mobility';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
|
||||
import { botPlanetologistFlightActions } from './bot-planetologist-flight';
|
||||
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
|
||||
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection, sandmasterLeader } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm';
|
||||
import { fremenCunningPublicDestinationBlock } from './nexus-fremen-cunning';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
@@ -598,6 +598,21 @@ function ornithopterMoves(g: GameView): Action[] {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** Route collection is optional; retain the original legal candidate alongside
|
||||
* its existing deterministic route/pile choice, without tuning destination rank. */
|
||||
function sandmasterMovementCandidates(game: GameView, actions: Action[]): Action[] {
|
||||
if (!sandmasterLeader(game, game.me)) return actions;
|
||||
const seen = new Set<string>();
|
||||
return actions.flatMap(action => {
|
||||
const choice = action.sandmaster === undefined ? sandmasterDefaultChoice(game, game.me, action) : null;
|
||||
const candidates = choice?.collect.length ? [{ ...action, sandmaster: choice }, action] : [action];
|
||||
return candidates.filter(candidate => {
|
||||
const key = JSON.stringify(candidate);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key); return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
function candidatePlan(a: Action): Plan {
|
||||
const plan: Plan = {
|
||||
...(Number(a.bankerSpice ?? 0)
|
||||
@@ -3375,10 +3390,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const flightTargets = destinations(g).filter(target => target.score > -1000)
|
||||
.map(target => ({ territory: target.t, sector: target.s }));
|
||||
const skillCardMoves = botPlanetologistFlightActions(g, flightTargets, ['range3', 'twoGroups']);
|
||||
const cardMoves = [...skillCardMoves, ...ornithopterMoves(g)];
|
||||
const cardMoves = sandmasterMovementCandidates(g, [...skillCardMoves, ...ornithopterMoves(g)]);
|
||||
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked && !g.ornithopter?.active
|
||||
? [...botPlanetologistFlightActions(g, flightTargets, ['discovery']),
|
||||
...discoveryFlightBotActions(g, flightTargets)]
|
||||
? sandmasterMovementCandidates(g, [...botPlanetologistFlightActions(g, flightTargets, ['discovery']),
|
||||
...discoveryFlightBotActions(g, flightTargets)])
|
||||
: [];
|
||||
if (discoveryMoves.length && (me.shipped || me.reserves === 0))
|
||||
return discoveryMoves;
|
||||
@@ -3832,6 +3847,7 @@ function policyActions(g: GameView): Action[] {
|
||||
// Moving before an unused shipment is legal; retain these candidates after
|
||||
// the existing shipment choices without changing their ordinary priority.
|
||||
actions.push(...skillCardMoves);
|
||||
actions.push(...cardMoves.filter(action => action.sandmaster !== undefined));
|
||||
if (reveal && !actions.length) actions.push(reveal);
|
||||
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
|
||||
actions.unshift(...emperorHomeworldMoveActions(g));
|
||||
@@ -3845,9 +3861,9 @@ function policyActions(g: GameView): Action[] {
|
||||
const cunning = g.nexusGuildCunning?.offer && nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event);
|
||||
if (cunning && (me.reserves > 0 || Object.values(me.forces).some(n => n > 0))) actions.push(cunning);
|
||||
actions.push({ type: 'endMovement' });
|
||||
return g.ornithopter?.active
|
||||
return sandmasterMovementCandidates(g, g.ornithopter?.active
|
||||
? actions.filter((action) => action.type !== 'move')
|
||||
: actions;
|
||||
: actions);
|
||||
}
|
||||
if (g.phase === 6) {
|
||||
const b = g.battle;
|
||||
|
||||
+14
-3
@@ -17320,13 +17320,24 @@ function ornithopterView(g: Game, p: Player) {
|
||||
};
|
||||
}
|
||||
function sandmasterOrder(g: Game, p: Player, move: MovementOrder): SandmasterOrder {
|
||||
const frame = move.ornithopterEvent ? g.ornithopter : null;
|
||||
const flight: SandmasterOrder['flight'] = move.discoveryFlight
|
||||
? { kind: 'discovery', token: move.discoveryFlight.token, acquiredTurn: move.discoveryFlight.acquiredTurn }
|
||||
: frame ? { kind: 'physical', card: frame.card.id, mode: frame.mode,
|
||||
completed: frame.completed, event: frame.event } : undefined;
|
||||
return { group: move.group, eliteGroup: move.eliteGroup, elite: move.elite, origin: move.origin,
|
||||
to: move.to, sector: move.sector, range: movementOrderRange(g, p, move) };
|
||||
to: move.to, sector: move.sector, range: movementOrderRange(g, p, move),
|
||||
...(move.noField ? { noField: move.noField } : {}), ...(flight ? { flight } : {}) };
|
||||
}
|
||||
function validateSandmasterOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
if (move.sandmaster === undefined) return;
|
||||
requireRule(!move.source && !move.noField && !move.planetologist && !move.ornithopterEvent && !move.discoveryFlight,
|
||||
'Sandmaster with special movement routes is still being integrated.');
|
||||
requireRule(!move.source && !move.planetologist,
|
||||
'Sandmaster with this special relocation or another normal-band skill is still being integrated.');
|
||||
requireRule(!(move.ornithopterEvent && move.discoveryFlight),
|
||||
'Choose one Ornithopter flight source for this Sandmaster movement.');
|
||||
requireRule(!move.ornithopterEvent ||
|
||||
(g.ornithopter?.event === move.ornithopterEvent && g.ornithopter.player === p.id),
|
||||
'The Sandmaster route lost its original Ornithopter frame.');
|
||||
nexusRule(() => validateSandmasterMovement(g, p.id, sandmasterOrder(g, p, move), move.sandmaster!));
|
||||
}
|
||||
function sandmasterIntegrity(g: Game) {
|
||||
|
||||
+7
-6
@@ -5733,8 +5733,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. All twenty-eight independent bands have connected first versions; full interaction review, polish and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'Sandmaster may collect along physical Ornithopter and carried Discovery routes using their actual range, exact declared path and current piles. Each different card movement has its own one-per-entered-territory opportunity; a concealed No-Field counts as one moving force without materializing its value. Choose one pile or decline per entered territory. Original source cohorts and disposal remain; allocation/reset are provisional.',
|
||||
'Planetologist may provisionally compose either movement alternative with physical Ornithopter or carried Discovery. Fixed flight stays three, including both gather origins. In two-group card mode, each action chooses boosted normal range or ordinary-range gathering; a gather counts as one card movement. Different original counters must supply the second movement, even after merging. Current trainer, source events, earlier-turn token ownership and one disposal remain.',
|
||||
'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.',
|
||||
'Classic Nexus core with base33 or selected36 permits optional all14, original Tech3+ and Advanced Strongholds2+. Offers, starting-card order, mandatory normal Banker and native reactions remain. Actual third-party Bureaucrat sees the real Guild-card recipient. Tech pays only at its matching phase end; Banker waits for Mentat. Earned local support does not become personal money or reserve fees. Selection/printed roles are public capabilities; other native/module envelopes stay separate.',
|
||||
@@ -5775,11 +5776,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Planetologist flight composes all six alternatives with exact original cohorts and signed Discovery origins; ordinary no-skill receipt bytes remain. Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/planetologist-flight.ts','game/bot-planetologist-flight.ts','game/discovery-flight.ts','components/planetologist-movement-action.tsx','tests/planetologist-flight-controls.test.tsx','tests/planetologist-flight-profile.test.ts','tests/fixture-planetologist-ornithopter.ts','tests/planetologist-ornithopter-engine.test.ts','tests/fixture-planetologist-discovery-flight.ts','tests/planetologist-discovery-flight-engine.test.ts','game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Shared owned-flight selection supplies current fields, fixed ranges and whole-map cohorts to the existing form and guarded actual submit. Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'Minimal policies offer legal skill flights from the same projected selection and original quotas, without strategic tuning. All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'Gather-as-one-card-movement and fixed-three overrides are explicitly provisional; no joint FAQ or complete modes are claimed. All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{area:'Verification',status:'Partial',detail:"Native paid-card and earlier-turn Discovery paths, controlled callbacks and receipt regressions cover the selected flight matrix. Browser/deployed and final assurance remain due. Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
{ area:'Implementation',status:'Partial',detail:'Sandmaster flight/marker orders bind actual transport and source while preserving exact declared routes, live piles and original cohorts. Planetologist flight composes all six alternatives with exact original cohorts and signed Discovery origins; ordinary no-skill receipt bytes remain. Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/sandmaster-movement.ts','components/sandmaster-movement-action.tsx','tests/fixture-sandmaster-ornithopter.ts','tests/sandmaster-ornithopter-engine.test.ts','tests/fixture-sandmaster-discovery-flight.ts','tests/sandmaster-discovery-flight-engine.test.ts','tests/fixture-sandmaster-marker.ts','tests/sandmaster-marker-engine.test.ts','tests/sandmaster-flight-controls.test.tsx','docs/SANDMASTER_MOVEMENT.md','game/planetologist-flight.ts','game/bot-planetologist-flight.ts','game/discovery-flight.ts','components/planetologist-movement-action.tsx','tests/planetologist-flight-controls.test.tsx','tests/planetologist-flight-profile.test.ts','tests/fixture-planetologist-ornithopter.ts','tests/planetologist-ornithopter-engine.test.ts','tests/fixture-planetologist-discovery-flight.ts','tests/planetologist-discovery-flight-engine.test.ts','game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Sandmaster shares projected transport selection, editable routes, pile/decline choices and a guarded actual submit leaf. Shared owned-flight selection supplies current fields, fixed ranges and whole-map cohorts to the existing form and guarded actual submit. Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'Minimal Sandmaster candidates retain deterministic legal collection on existing normal/card/token moves without tuning destination priorities. Minimal policies offer legal skill flights from the same projected selection and original quotas, without strategic tuning. All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'Sandmaster flight eligibility, concealed-one-force composition, existing provisional reset/allocation and legacy ordinary order compatibility are recorded. Gather-as-one-card-movement and fixed-three overrides are explicitly provisional; no joint FAQ or complete modes are claimed. All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{area:'Verification',status:'Partial',detail:"Native trained card/token sources use actual produced Spice piles; controlled marker and callback supplements cover the new route. Complete modes and final assurance remain open. Native paid-card and earlier-turn Discovery paths, controlled callbacks and receipt regressions cover the selected flight matrix. Browser/deployed and final assurance remain due. Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+71
-41
@@ -4,6 +4,8 @@ import { GRAPH, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION, v
|
||||
import { isAdvisor } from './advisors';
|
||||
import { strongholdPathBlocked } from './occupancy';
|
||||
import { botMovementRange } from './bot-mobility';
|
||||
import { planetologistFlightSelection as movementFlightSelection } from './planetologist-flight';
|
||||
import { validateCohortSelection, type MovementMarker, type OrnithopterMode } from './ornithopter';
|
||||
|
||||
export class SandmasterMovementError extends Error {}
|
||||
export type SandmasterChoice = {
|
||||
@@ -18,7 +20,17 @@ export type SandmasterOrder = {
|
||||
to: string;
|
||||
sector: number;
|
||||
range: number;
|
||||
noField?: MovementMarker;
|
||||
flight?: SandmasterFlight;
|
||||
};
|
||||
export type SandmasterFlight =
|
||||
| { kind: 'physical'; card: string; mode: OrnithopterMode; completed: number; event?: string }
|
||||
| { kind: 'discovery'; token: string; acquiredTurn: number };
|
||||
|
||||
/** A marker and physical counters in the same sector share one declared route. */
|
||||
export function sandmasterOrderSources(order: SandmasterOrder): string[] {
|
||||
return [...new Set([...order.group.map(([key]) => key), ...(order.noField ? [order.noField.from] : [])])];
|
||||
}
|
||||
export type SandmasterMovement = SandmasterChoice & {
|
||||
leader: string;
|
||||
turn: number;
|
||||
@@ -236,10 +248,11 @@ export function quoteSandmasterMovement(
|
||||
Array.isArray(value.collect),
|
||||
'Choose Sandmaster routes and optional collection territories.',
|
||||
);
|
||||
const sources = move.group.map(([key]) => key);
|
||||
const physical = move.group.map(([key]) => key);
|
||||
const sources = sandmasterOrderSources(move);
|
||||
requireMove(
|
||||
sources.length > 0 &&
|
||||
new Set(sources).size === sources.length &&
|
||||
new Set(physical).size === physical.length &&
|
||||
JSON.stringify(Object.keys(value.routes).sort()) ===
|
||||
JSON.stringify([...sources].sort()),
|
||||
'Every moving source sector needs its own Sandmaster route.',
|
||||
@@ -330,53 +343,70 @@ export function sandmasterActionOrder(
|
||||
): SandmasterOrder | null {
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (
|
||||
!p ||
|
||||
!p || player !== g.me ||
|
||||
action.type !== 'move' ||
|
||||
action.noField !== undefined ||
|
||||
action.planetologist !== undefined ||
|
||||
action.movementCard !== undefined ||
|
||||
action.ornithopterEvent !== undefined ||
|
||||
action.discoveryOrnithopter !== undefined ||
|
||||
g.ornithopter?.active
|
||||
action.planetologist !== undefined
|
||||
)
|
||||
return null;
|
||||
const group: [string, number][] = record(action.forces)
|
||||
? (Object.entries(action.forces).filter(
|
||||
([, n]) => typeof n === 'number' && n > 0,
|
||||
) as [string, number][])
|
||||
: typeof action.from === 'string' && typeof action.amount === 'number'
|
||||
? [[action.from, action.amount]]
|
||||
: [];
|
||||
if (
|
||||
!group.length ||
|
||||
group.some(
|
||||
([key, n]) =>
|
||||
!Number.isSafeInteger(n) || n <= 0 || n > (p.forces[key] ?? 0),
|
||||
) ||
|
||||
new Set(group.map(([key]) => splitLocation(key).territory)).size !== 1 ||
|
||||
typeof action.territory !== 'string' ||
|
||||
typeof action.sector !== 'number'
|
||||
)
|
||||
return null;
|
||||
const eliteGroup = Object.fromEntries(
|
||||
group.map(([key]) => [
|
||||
key,
|
||||
Number(
|
||||
record(action.eliteForces)
|
||||
? (action.eliteForces[key] ?? 0)
|
||||
: (action.elite ?? 0),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const kind = action.discoveryOrnithopter !== undefined ? 'discovery'
|
||||
: action.movementCard !== undefined ? action.ornithopter : 'ordinary';
|
||||
if (kind !== 'ordinary' && kind !== 'discovery' && kind !== 'range3' && kind !== 'twoGroups') return null;
|
||||
const transport = movementFlightSelection(g, kind);
|
||||
if (transport.blocked || ['movementCard', 'ornithopter', 'ornithopterEvent', 'discoveryOrnithopter']
|
||||
.some(key => action[key] !== transport.fields[key])) return null;
|
||||
const entries = action.forces !== undefined
|
||||
? record(action.forces) ? Object.entries(action.forces) : null
|
||||
: typeof action.from === 'string' ? [[action.from, action.amount] as const] : null;
|
||||
if (!entries || typeof action.territory !== 'string' || !Number.isSafeInteger(action.sector) ||
|
||||
!validGameLocation(g, action.territory, action.sector as number)) return null;
|
||||
const group: [string, number][] = [], eliteGroup: Record<string, number> = {};
|
||||
for (const [key, count] of entries) {
|
||||
const at = splitLocation(key), owned = p.forces[key] ?? 0, ownedElite = p.elites?.forces[key] ?? 0;
|
||||
if (!Number.isSafeInteger(count) || (count as number) < 0 || (count as number) > owned ||
|
||||
key !== location(at.territory, at.sector) || !validGameLocation(g, at.territory, at.sector)) return null;
|
||||
if (!count) continue;
|
||||
const minimum = Math.max(0, (count as number) - (owned - ownedElite));
|
||||
const elite = (action.forces !== undefined
|
||||
? record(action.eliteForces) ? action.eliteForces[key] : undefined : action.elite) ?? minimum;
|
||||
if (!Number.isSafeInteger(elite) || (elite as number) < minimum ||
|
||||
(elite as number) > Math.min(count as number, ownedElite)) return null;
|
||||
group.push([key, count as number]); eliteGroup[key] = elite as number;
|
||||
}
|
||||
const info = g.richeseNoField?.owner === player ? g.richeseNoField : null;
|
||||
const marker = info?.private?.deployed;
|
||||
const noField: MovementMarker | undefined = action.noField === undefined ? undefined
|
||||
: info && marker?.controller === player && marker.tokenId === action.noField && info.event === action.event
|
||||
? { tokenId: marker.tokenId, event: info.event,
|
||||
from: location(marker.location.territory, marker.location.sector) } : undefined;
|
||||
if (action.noField !== undefined && (!noField || g.homeworldMobility?.noFieldMovementBlocked)) return null;
|
||||
const origins = new Set([...group.map(([key]) => splitLocation(key).territory),
|
||||
...(noField ? [splitLocation(noField.from).territory] : [])]);
|
||||
if (origins.size !== 1) return null;
|
||||
if (transport.cohort) {
|
||||
try { validateCohortSelection(transport.cohort, p.forces, p.elites?.forces ?? {},
|
||||
Object.fromEntries(group), eliteGroup, noField); }
|
||||
catch { return null; }
|
||||
}
|
||||
const elite = Object.values(eliteGroup).reduce((a, b) => a + b, 0);
|
||||
const frame = g.ornithopter;
|
||||
const flight: SandmasterFlight | undefined = typeof transport.fields.discoveryOrnithopter === 'string'
|
||||
? { kind: 'discovery', token: transport.fields.discoveryOrnithopter,
|
||||
acquiredTurn: g.discoveryOrnithopter!.acquiredTurn }
|
||||
: typeof transport.fields.movementCard === 'string' || typeof transport.fields.ornithopterEvent === 'string'
|
||||
? { kind: 'physical', card: frame!.card.id,
|
||||
mode: frame!.active?.mode ?? kind as OrnithopterMode,
|
||||
completed: frame!.active?.completed ?? 0,
|
||||
...(frame!.active ? { event: frame!.active.event } : {}) } : undefined;
|
||||
return {
|
||||
group,
|
||||
eliteGroup,
|
||||
elite,
|
||||
origin: splitLocation(group[0][0]).territory,
|
||||
origin: [...origins][0],
|
||||
to: action.territory,
|
||||
sector: action.sector,
|
||||
range: botMovementRange(g, p, elite),
|
||||
sector: action.sector as number,
|
||||
range: transport.rangeOverride ?? botMovementRange(g, p, elite),
|
||||
...(noField ? { noField } : {}),
|
||||
...(flight ? { flight } : {}),
|
||||
};
|
||||
}
|
||||
export function sandmasterDefaultChoice(
|
||||
@@ -388,7 +418,7 @@ export function sandmasterDefaultChoice(
|
||||
const move = sandmasterActionOrder(g, player, action);
|
||||
if (!move) return null;
|
||||
const routes: Record<string, string[]> = {};
|
||||
for (const [from] of move.group) {
|
||||
for (const from of sandmasterOrderSources(move)) {
|
||||
const route = sandmasterShortestRoute(
|
||||
g,
|
||||
player,
|
||||
|
||||
Reference in new issue
Block a user