Connect owned Sandmaster stronghold pile allocation

This commit is contained in:
Quintine committed 2026-10-09 05:24:33 +11:00
1 parent 0040ed1921
commit eb63967b5c
10 files changed
+269 -37

No files matched your search

+10 -3
View File
@@ -99,7 +99,7 @@ import {
guildTransportCost,
} from './bot-mobility';
import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement';
import { sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
@@ -1452,15 +1452,18 @@ function policyActions(g: GameView): Action[] {
),
}))
.sort((a, b) => b.value - a.value)[0];
if (route && route.value >= [4, 5, 6, 6][level])
if (route && route.value >= [4, 5, 6, 6][level]) {
const sandmaster = quoteSandmasterStronghold(g, me.id, route.route);
return [
{
type: 'card',
mode: 'special',
card: specialCard.id,
route: route.route,
...(sandmaster ? { sandmasterPiles: sandmaster.collect } : {}),
},
];
}
}
if (
specialCard &&
@@ -2209,7 +2212,11 @@ function policyActions(g: GameView): Action[] {
return [
...routes
.filter((r) => r.score > 0)
.map((r) => ({ type: 'decision', route: r.route })),
.map(r => {
const sandmaster = quoteSandmasterStronghold(g, me.id, r.route);
return { type: 'decision', route: r.route,
...(sandmaster ? { sandmasterPiles: sandmaster.collect } : {}) };
}),
{ type: 'decision', decline: true },
];
}
+21 -10
View File
@@ -21,7 +21,7 @@ import { planetologistMovementModeSupported, planetologistLeader, planetologistR
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, validateSandmasterStronghold, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
@@ -1829,6 +1829,7 @@ export type Game = {
player: string;
route: string[];
collect: boolean;
sandmasterPiles?: string[];
} | null;
revivalRules?: RevivalRules;
revivalRequests?: Record<
@@ -25771,23 +25772,24 @@ function validateMobileMove(g: Game, p: Player, input: unknown, max: number) {
}
function relocateMobileStronghold(
g: Game,
move: { player: string; route: string[]; collect: boolean },
move: { player: string; route: string[]; collect: boolean; sandmasterPiles?: string[] },
) {
const p = getPlayer(g, move.player);
const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id));
requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.');
const skillPiles = move.sandmasterPiles !== undefined
? nexusRule(() => validateSandmasterStronghold(g, p.id, move.route, move.sandmasterPiles, move.collect))
: sandmasterLeader(g, p.id)
? Object.values(sandmasterCollectionPiles(g, { interior: move.route })).filter(piles => piles.length === 1).map(piles => piles[0])
: [];
let collected = 0;
if (move.collect) {
// User ruling 7 October 2026: passengers remaining inside a relocated HMS
// interior count as entering the outside territories the stronghold points
// into, so the Sandmaster skill collects there before the faction's own
// traversed-sector collection.
if (sandmasterLeader(g, move.player))
for (const piles of Object.values(sandmasterCollectionPiles(g, { interior: move.route }))) {
// HMS relocation keeps its existing single-pile scope until its
// separate route controls can select the Sandmaster debit sector.
if (piles.length !== 1) continue;
const pile = piles[0];
// Unselected older queued moves retain their original unambiguous-pile path.
for (const pile of skillPiles) {
g.spice[pile]--;
if (!g.spice[pile]) delete g.spice[pile];
p.spice++;
@@ -26760,7 +26762,7 @@ export type SpecialKaramaIntent = {
} & (
| { kind: 'choam'; cards: string[] }
| { kind: 'richese'; acquire: string }
| { kind: 'ixians'; route: string[]; collect: boolean }
| { kind: 'ixians'; route: string[]; collect: boolean; sandmasterPiles?: string[] }
| { kind: 'tleilaxu'; target: string; revival: PendingRevival }
| { kind: 'fremen'; territory: string }
| {
@@ -26907,11 +26909,14 @@ export function prepareSpecialKaramaIntent(
'Move the stronghold with special Karama during your own Shipment and Movement turn.',
);
const route = validateMobileMove(g, p, action.route, 2);
const sandmasterPiles = nexusRule(() => validateSandmasterStronghold(
g, p.id, route, action.sandmasterPiles, action.collect !== false));
return {
...base,
kind: 'ixians',
route: [...route],
collect: action.collect !== false,
...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}),
};
} else if (p.faction === 'tleilaxu') {
requireRule(
@@ -27082,7 +27087,8 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action {
case 'choam':
return { ...action, cards: [...intent.cards] };
case 'ixians':
return { ...action, route: [...intent.route], collect: intent.collect };
return { ...action, route: [...intent.route], collect: intent.collect,
...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: [...intent.sandmasterPiles] } : {}) };
case 'tleilaxu':
case 'atreides':
case 'guild':
@@ -27241,6 +27247,7 @@ export function executeSpecialKaramaIntent(
player: p.id,
route,
collect: intent.collect,
...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: intent.sandmasterPiles } : {}),
});
} else if (intent.kind === 'tleilaxu') {
const target = intent.target;
@@ -29089,10 +29096,13 @@ function applyActionInner(
} else if (action.decline === true) beginStormTurn(g);
else {
const route = validateMobileMove(g, p, action.route, 3);
const sandmasterPiles = nexusRule(() => validateSandmasterStronghold(
g, p.id, route, action.sandmasterPiles, action.collect !== false));
g.pendingMobileMove = {
player: p.id,
route,
collect: action.collect !== false,
...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}),
};
g.response = {
kind: 'mobileStronghold',
@@ -32366,6 +32376,7 @@ export function viewGame(state: Game, id: string) {
? {
route: g.pendingMobileMove.route,
collect: g.pendingMobileMove.collect,
sandmasterPiles: g.pendingMobileMove.sandmasterPiles,
}
: null,
techTokens: g.techTokens ?? null,
+1 -1
View File
@@ -4730,7 +4730,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory, or decline collection; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides and physical Fremen Ambassador relocation likewise offer one selected destination pile before arrival reactions, without an invented ground route or normal movement use. These destination allocations are provisional. Native Ixian HMS passengers collect before faction spice, but its multi-pile controls and other special relocation remain unfinished.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory or decline; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate opportunities. Native worm rides and physical Fremen Ambassador relocation offer one selected destination pile before arrival reactions, without an invented route or normal movement use. Native Ixian HMS passengers choose one pile in each entered territory before faction collection, including supplied Advanced special Karama; its route preview reflects both transfers. These allocations are provisional. Other special relocation remains unfinished.',
'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
+35
View File
@@ -178,6 +178,41 @@ export function sandmasterCollectionPiles(
}
return choices;
}
export function quoteSandmasterStronghold(game: Game | GameView, player: string, route: string[]) {
const seat = game.players.find(p => p.id === player);
const leader = sandmasterLeader(game, player);
if (!leader || !sandmasterModeSupported(game) || seat?.faction !== 'ixians' ||
!(seat.forces[MOBILE_LOCATION] > 0)) return null;
const piles = sandmasterCollectionPiles(game, { interior: route });
return { leader, piles, collect: Object.values(piles).map(keys => keys[0]) };
}
/** Native collection remains separate; this selects only the skill's one spice per territory. */
export function validateSandmasterStronghold(
game: Game | GameView, player: string, route: string[], selection: unknown, enabled = true,
): string[] {
if (!enabled) {
requireMove(selection === undefined || (Array.isArray(selection) && selection.length === 0),
'Declining stronghold collection cannot take Sandmaster spice.');
return [];
}
const quote = quoteSandmasterStronghold(game, player, route);
if (selection === undefined) {
requireMove(!quote || Object.values(quote.piles).every(keys => keys.length === 1),
'Choose the Sandmaster debit pile for each entered territory, or decline its collection.');
return quote?.collect ?? [];
}
requireMove(Array.isArray(selection) && selection.every(key => typeof key === 'string'),
'Choose Sandmaster pile keys for this stronghold route.');
requireMove(selection.length === 0 || !!quote,
'Stronghold Sandmaster collection needs its living native trainer and passengers.');
requireMove(selection.every(key => quote?.piles[splitLocation(key).territory]?.includes(key) &&
Number.isSafeInteger(game.spice[key]) && game.spice[key] > 0) &&
new Set(selection.map(key => splitLocation(key).territory)).size === selection.length,
'Collect from at most one positive pile per entered territory.');
return [...selection];
}
export function quoteSandmasterMovement(
g: Game | GameView,
player: string,