Files
Dune/game/advisor-preparation-window.ts
Quintine 660b0a0c25 Open native advisor preparation before the first shipment
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.
2026-10-24 18:36:55 +11:00

143 lines
5.8 KiB
TypeScript

import type { Game } from './engine';
import type { AdvisorRelease } from './board-resolution-quote';
import { quoteAdvisorBattleOffer } from './movement-phase-quote';
export type AdvisorPreparationWindow = {
turn: number;
firstShipment: null | {
player: string;
producer: 'ordinary' | 'guildTransport' | 'homeworld' | 'junction' | 'ambassador' | 'sneakAttack';
};
deniedTerritories: string[];
};
export type AdvisorPreparationSource = {
turn: number;
owner: string;
territory: string;
};
export type AdvisorPreparationContext = Pick<
Game,
| 'status'
| 'phase'
| 'turn'
| 'advanced'
| 'order'
| 'players'
| 'storm'
| 'mobileStronghold'
| 'homeworlds'
| 'advisorPreparation'
>;
export class AdvisorPreparationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AdvisorPreparationError';
}
}
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
const count = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const producers = ['ordinary', 'guildTransport', 'homeworld', 'junction', 'ambassador', 'sneakAttack'];
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function windowBlock(g: AdvisorPreparationContext, owner: string): string | null {
if (g.status !== 'playing' || g.advanced !== true ||
!count(g.turn) || g.turn < 1 || !count(g.phase) || g.phase < 2 || g.phase > 5)
return 'Prepare native advisors after Spice Blow and Nexus, before the first shipment.';
if (!id(owner) || !Array.isArray(g.players) ||
g.players.filter(player => player.id === owner && player.faction === 'beneGesserit').length !== 1 ||
g.players.filter(player => player.id === owner).length !== 1 ||
g.players.filter(player => player.faction === 'beneGesserit').length !== 1)
return 'Advisor preparation needs the current native Bene Gesserit owner.';
const window = g.advisorPreparation;
if (window === undefined)
return 'The current advisor-preparation window has not opened.';
if (!record(window) || !count(window.turn) || window.turn < 1 ||
window.turn !== g.turn || !Array.isArray(window.deniedTerritories) ||
window.deniedTerritories.some(territory => !id(territory)) ||
new Set(window.deniedTerritories).size !== window.deniedTerritories.length)
return 'The advisor-preparation window has no valid current-turn receipt.';
if (window.firstShipment !== null) {
const shipment = window.firstShipment;
if (!record(shipment) || !id(shipment.player) ||
g.players.filter(player => player.id === shipment.player).length !== 1 ||
!producers.includes(shipment.producer))
return 'The advisor-preparation window has an invalid shipment receipt.';
return 'The first shipment has closed advisor preparation for this turn.';
}
return null;
}
/** Source p28 G's clock wraps the existing native public-board eligibility.
* The caller owns live priority, receipt opening/closure and stance mutation.
* An absent receipt never infers a clock from movement or shipment flags. */
export function quoteAdvisorPreparationWindow(
g: AdvisorPreparationContext,
owner: string,
): { blocked: string | null; territories: string[]; released: AdvisorRelease[] } {
const blocked = windowBlock(g, owner);
if (blocked) return { blocked, territories: [], released: [] };
try {
const offer = quoteAdvisorBattleOffer(g);
const territories = offer.territories.filter(
territory => !g.advisorPreparation!.deniedTerritories.includes(territory),
);
return {
blocked: territories.length ? null : 'No eligible advisors can prepare for battle now.' +
(g.advisorPreparation!.deniedTerritories.length ? ' Earlier Karama prevention still applies to its territory.' : ''),
territories,
released: offer.released,
};
} catch (error) {
return {
blocked: error instanceof Error ? error.message : 'The advisor-preparation board is invalid.',
territories: [],
released: [],
};
}
}
export function quoteAdvisorPreparation(
g: AdvisorPreparationContext,
owner: string,
territory: string,
): AdvisorPreparationSource {
const quote = quoteAdvisorPreparationWindow(g, owner);
if (quote.blocked) throw new AdvisorPreparationError(quote.blocked);
if (!id(territory) || !quote.territories.includes(territory))
throw new AdvisorPreparationError('These advisors cannot prepare for battle in the current window.');
return { turn: g.turn, owner, territory };
}
/** Recheck the same current-turn whole-territory declaration before cost or
* either outcome; no board, denial or original shipment record is changed. */
export function validateAdvisorPreparation(
g: AdvisorPreparationContext,
source: AdvisorPreparationSource,
): void {
if (!record(source) || Object.keys(source).sort().join(',') !== 'owner,territory,turn' ||
!count(source.turn) || source.turn !== g.turn || !id(source.owner) || !id(source.territory))
throw new AdvisorPreparationError('Advisor preparation has lost its original source.');
quoteAdvisorPreparation(g, source.owner, source.territory);
}
/** Native owner-only choices, with no force counts, cards, wallet or receipt
* payload. Opponents use the original public response when one is declared. */
export function projectAdvisorPreparationWindow(
g: AdvisorPreparationContext,
owner: string,
): { blocked: string | null; territories: string[] } | null {
if (g.advanced !== true || !id(owner) || !Array.isArray(g.players) ||
g.players.filter(player => player.id === owner && player.faction === 'beneGesserit').length !== 1 ||
g.players.filter(player => player.id === owner).length !== 1)
return null;
const { blocked, territories } = quoteAdvisorPreparationWindow(g, owner);
return { blocked, territories };
}