Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving original phase/auction/readiness and native eligibility. Close a current-turn receipt only on committed shipments, including marker and Sneak Attack entry. Retain the legacy phase5 queue and qualify the per-territory prevention policy. Connect persistent controls, minimal bot support and focused native evidence. Complete modes and browser/deployed acceptance remain open.
233 lines
7.5 KiB
TypeScript
233 lines
7.5 KiB
TypeScript
import type { Game, Decision } from './engine';
|
|
import { SPICE_CARDS, type SpiceCard } from './cards';
|
|
import { gameTerritories } from './board';
|
|
import { presenceAt } from './force-presence';
|
|
import { ecazOccupancyRelation } from './ecaz-occupy';
|
|
import { homeworldMovementForesightBlock } from './homeworld-mobility';
|
|
import {
|
|
quoteBattleBoard,
|
|
type BoardContext,
|
|
type AdvisorRelease,
|
|
type BoardSeat,
|
|
} from './board-resolution-quote';
|
|
|
|
export class MovementPhaseQuoteError extends Error {}
|
|
function requireMovement(value: unknown, message: string): asserts value {
|
|
if (!value) throw new MovementPhaseQuoteError(message);
|
|
}
|
|
type Context = Pick<
|
|
Game,
|
|
| 'status'
|
|
| 'phase'
|
|
| 'turn'
|
|
| 'advanced'
|
|
| 'storm'
|
|
| 'order'
|
|
| 'players'
|
|
| 'mobileStronghold'
|
|
| 'spiceDeck'
|
|
| 'spiceDiscard'
|
|
| 'homeworlds'
|
|
| 'advisorPreparation'
|
|
>;
|
|
type AdvisorContext = BoardContext & { turn: number } & Pick<Game, 'advisorPreparation'>;
|
|
const whole = (n: unknown): n is number =>
|
|
Number.isSafeInteger(n) && Number(n) >= 0;
|
|
function boardSeats(g: BoardContext, released: AdvisorRelease[]): BoardSeat[] {
|
|
return g.players.map((p) => {
|
|
const advisors = p.advisors ? { ...p.advisors } : undefined;
|
|
for (const r of released)
|
|
if (r.player === p.id && advisors) delete advisors[r.territory];
|
|
return {
|
|
id: p.id,
|
|
faction: p.faction,
|
|
ally: p.ally,
|
|
advisors,
|
|
forces: p.forces,
|
|
noField: p.noField,
|
|
};
|
|
});
|
|
}
|
|
/** Consider existing advisors as fighters on a readonly public board. No
|
|
* shipment, movement, arrival effect, or whole-game simulation is performed. */
|
|
export function quoteAdvisorBattleOffer(g: AdvisorContext) {
|
|
try {
|
|
const board = quoteBattleBoard(g);
|
|
const players = boardSeats(g, board.released),
|
|
bg = players.find((p) => p.faction === 'beneGesserit');
|
|
if (!g.advanced || !bg)
|
|
return {
|
|
released: board.released,
|
|
owner: null,
|
|
territories: [] as string[],
|
|
};
|
|
const ally = bg.ally ? players.find((p) => p.id === bg.ally) : null;
|
|
requireMovement(
|
|
!bg.ally || ally,
|
|
'The advisor opportunity needs its seated ally.',
|
|
);
|
|
const territories = Object.keys(bg.advisors ?? {}).filter((t) => {
|
|
const stance = bg.advisors![t];
|
|
requireMovement(
|
|
!!stance &&
|
|
typeof stance === 'object' &&
|
|
gameTerritories(g).some((x) => x.id === t) &&
|
|
(stance.lockedTurn === undefined || whole(stance.lockedTurn)),
|
|
'The advisor opportunity needs valid territory stances.',
|
|
);
|
|
if (
|
|
stance.lockedTurn === g.turn ||
|
|
(ally &&
|
|
presenceAt(ally, t) &&
|
|
ecazOccupancyRelation(players, bg.id, ally.id, {
|
|
kind: 'territory',
|
|
id: t,
|
|
}) !== 'ecazAlliance')
|
|
)
|
|
return false;
|
|
const advisors = { ...bg.advisors };
|
|
delete advisors[t];
|
|
const candidate = quoteBattleBoard({
|
|
...g,
|
|
players: players.map((p) => (p.id === bg.id ? { ...p, advisors } : p)),
|
|
});
|
|
return candidate.battles.some(
|
|
(b) =>
|
|
b.territory === t && (b.attacker === bg.id || b.defender === bg.id || b.coalition?.includes(bg.id)),
|
|
);
|
|
});
|
|
const window = g.advisorPreparation?.turn === g.turn ? g.advisorPreparation : undefined;
|
|
return { released: board.released, owner: bg.id,
|
|
territories: window?.firstShipment ? [] : territories.filter(t => !window?.deniedTerritories.includes(t)) };
|
|
|
|
} catch (error) {
|
|
if (error instanceof MovementPhaseQuoteError) throw error;
|
|
throw new MovementPhaseQuoteError(
|
|
error instanceof Error ? error.message : 'Malformed advisor opportunity.',
|
|
);
|
|
}
|
|
}
|
|
function validSpice(c: unknown): c is SpiceCard {
|
|
if (!c || typeof c !== 'object' || Array.isArray(c)) return false;
|
|
const card = c as Record<string, unknown>;
|
|
if ('worm' in card)
|
|
return (
|
|
card.worm === true &&
|
|
(card.thumper === undefined || typeof card.thumper === 'boolean')
|
|
);
|
|
if ('sandtrout' in card) return card.sandtrout === true;
|
|
return SPICE_CARDS.some(
|
|
([territory, amount, sector]) =>
|
|
card.territory === territory &&
|
|
card.amount === amount &&
|
|
card.sector === sector,
|
|
);
|
|
}
|
|
type StartDecision = Extract<
|
|
Decision,
|
|
{ kind: 'advisorBattle' | 'guildTiming' }
|
|
>;
|
|
export type MovementPhaseQuote =
|
|
| { kind: 'opening'; phaseOpening: { passed: string[]; initialize: true } }
|
|
| {
|
|
kind: 'initialize';
|
|
remaining: string[];
|
|
released: AdvisorRelease[];
|
|
spice: { owner: string | null; refill: boolean; hasCards: boolean };
|
|
decision: StartDecision | null;
|
|
active: string | null;
|
|
};
|
|
/** Reset one genuine complete movement order. Atreides refill is a request to
|
|
* real execution, never a shuffled preview. Ix opening is an explicit boundary. */
|
|
export function quoteMovementPhaseStart(
|
|
g: Context,
|
|
opening = false,
|
|
): MovementPhaseQuote {
|
|
try {
|
|
requireMovement(
|
|
g.status === 'playing' &&
|
|
g.phase === 5 &&
|
|
whole(g.turn) &&
|
|
g.turn > 0 &&
|
|
Array.isArray(g.order) &&
|
|
Array.isArray(g.players) &&
|
|
g.players.length >= 2 &&
|
|
g.players.length <= 6 &&
|
|
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
|
new Set(g.order).size === g.order.length &&
|
|
g.order.length === g.players.length &&
|
|
g.players.every(
|
|
(p) =>
|
|
typeof p.id === 'string' &&
|
|
p.id.length > 0 &&
|
|
g.order.includes(p.id),
|
|
),
|
|
'Shipment and Movement needs its complete unique seated turn order.',
|
|
);
|
|
if (opening)
|
|
return {
|
|
kind: 'opening',
|
|
phaseOpening: { passed: [], initialize: true },
|
|
};
|
|
const advisor = quoteAdvisorBattleOffer(g);
|
|
const atreides = g.players.find(
|
|
(p) =>
|
|
p.faction === 'atreides' && !homeworldMovementForesightBlock(g, p.id),
|
|
),
|
|
guild = g.players.find((p) => p.faction === 'guild');
|
|
let refill = false,
|
|
hasCards = false;
|
|
if (atreides) {
|
|
requireMovement(
|
|
Array.isArray(g.spiceDeck) && g.spiceDeck.every(validSpice),
|
|
'Atreides movement-phase preview needs a readable spice deck.',
|
|
);
|
|
refill = g.spiceDeck.length === 0;
|
|
if (refill) {
|
|
requireMovement(
|
|
Array.isArray(g.spiceDiscard) &&
|
|
g.spiceDiscard.length === 2 &&
|
|
g.spiceDiscard.every(
|
|
(pile) => Array.isArray(pile) && pile.every(validSpice),
|
|
),
|
|
'Atreides spice refill needs two readable discard piles.',
|
|
);
|
|
hasCards = g.spiceDiscard
|
|
.flat()
|
|
.some((c) => !('worm' in c && c.thumper));
|
|
} else hasCards = true;
|
|
}
|
|
let decision: StartDecision | null = null,
|
|
active: string | null = null;
|
|
if (advisor.owner && advisor.territories.length)
|
|
decision = {
|
|
kind: 'advisorBattle',
|
|
player: advisor.owner,
|
|
territories: advisor.territories,
|
|
};
|
|
else if (g.advanced && guild)
|
|
decision = {
|
|
kind: 'guildTiming',
|
|
player: guild.id,
|
|
next: g.order[0],
|
|
following: g.order.find((id) => id !== guild.id)!,
|
|
};
|
|
else active = g.order[0];
|
|
return {
|
|
kind: 'initialize',
|
|
remaining: [...g.order],
|
|
released: advisor.released,
|
|
spice: { owner: atreides?.id ?? null, refill, hasCards },
|
|
decision,
|
|
active,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof MovementPhaseQuoteError) throw error;
|
|
throw new MovementPhaseQuoteError(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Malformed movement-phase initialization.',
|
|
);
|
|
}
|
|
}
|