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.
This commit is contained in:
1 parent
0e3d6a250e
commit
660b0a0c25
18 files changed
+1269
-13
No files matched your search
@@ -0,0 +1,142 @@
|
||||
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 };
|
||||
}
|
||||
@@ -3084,6 +3084,14 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
|
||||
}
|
||||
// Reuse the native advisor-decision preference; eligibility comes only from
|
||||
// the shared current-window projection, including first actual shipment.
|
||||
if (level > 0 && g.advisorPreparation && !g.advisorPreparation.blocked) {
|
||||
const territory = g.advisorPreparation.territories.find(t => g.players
|
||||
.filter(p => p.id !== me.id && p.id !== me.ally)
|
||||
.every(p => fighterCount(p, t) < countAt(me, t)));
|
||||
if (territory) return [{ type: 'prepareAdvisors', territory }];
|
||||
}
|
||||
if (
|
||||
g.phase === 2 &&
|
||||
!me.charityClaimed &&
|
||||
|
||||
+83
-6
@@ -532,6 +532,7 @@ import { quoteHomeworldPaymentIncome, quoteHomeworldBankPayment } from './homewo
|
||||
import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
|
||||
import { resolveBattleWeapons } from './effective-weapons';
|
||||
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
|
||||
import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window';
|
||||
import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant';
|
||||
import {
|
||||
charityAmount,
|
||||
@@ -1061,6 +1062,7 @@ type SpiceBankerIncomePayment = {
|
||||
};
|
||||
|
||||
export type ResponseWindow = {
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
@@ -1080,7 +1082,7 @@ export type ResponseWindow = {
|
||||
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
|
||||
guildRateEvent?: string;
|
||||
guildTransportEvent?: string;
|
||||
advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
|
||||
advisorResume?: 'preparation' | 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
|
||||
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
@@ -1256,6 +1258,7 @@ type RicheseAllyOffer = {
|
||||
payer: string;
|
||||
};
|
||||
export type Game = {
|
||||
advisorPreparation?: AdvisorPreparationWindow;
|
||||
ecazOccupyPreview?: boolean;
|
||||
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
|
||||
guildBetrayalPreview?: boolean;
|
||||
@@ -14542,6 +14545,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
try {
|
||||
if (response.advisorResume === 'preparation') { currentAdvisorPreparationResponse(g, response); return null; }
|
||||
return quoteMovementCancellation(g, response);
|
||||
} catch (error) {
|
||||
if (error instanceof MovementCancellationError)
|
||||
@@ -14710,6 +14714,7 @@ function validateKaramaUse(
|
||||
'Karama cancels another faction’s power.',
|
||||
);
|
||||
karamaCanceledSource(g, use.response);
|
||||
currentAdvisorPreparationResponse(g, use.response);
|
||||
if (use.response.kind === 'fremenRevivalGrant') currentFremenRevivalGrant(g, use.response);
|
||||
validateAuctionContinuationCancellation(
|
||||
g,
|
||||
@@ -18141,6 +18146,7 @@ function commitAmbassadorMarker(g: Game, order: GuildAmbassadorShipment & {marke
|
||||
supplier.spice -= marker.ownerPayment;
|
||||
p.spice -= marker.recipientPayment;
|
||||
observeOccupation(g);
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'ambassador');
|
||||
delete entry.guildMarkerRequest;
|
||||
entry.stage = 'arrival';
|
||||
const guild = byFaction(g,'guild');
|
||||
@@ -18168,6 +18174,7 @@ function commitAmbassadorShipment(g: Game, shipment: PendingShipment) {
|
||||
if (p.elites) p.elites.reserves -= order.elite;
|
||||
place(p, order.territory, order.sector, order.amount, order.elite);
|
||||
observeOccupation(g);
|
||||
if (order.amount > 0) closeAdvisorPreparationOnShipment(g, p.id, 'ambassador');
|
||||
if (order.advisors) (p.advisors ??= {})[order.territory] ??= {};
|
||||
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
|
||||
entry.stage = 'arrival';
|
||||
@@ -20123,6 +20130,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
withdrawNativeReserves(g, p, amount, 0);
|
||||
place(p, entry.territory, entry.sector, amount);
|
||||
observeOccupation(g);
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'sneakAttack');
|
||||
// A positive Sneak Attack transfers Moritani's expressly off-planet
|
||||
// reserves onto the board, so it is classified as an off-planet
|
||||
// shipment for the Heighliners token. The token itself enforces its
|
||||
@@ -20824,6 +20832,49 @@ function movementPhaseQuote<T>(calculate: () => T): T {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function advisorPreparationRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch (error) {
|
||||
if (error instanceof AdvisorPreparationError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function openAdvisorPreparation(g: Game) {
|
||||
if (g.advanced && byFaction(g, 'beneGesserit') && g.advisorPreparation?.turn !== g.turn)
|
||||
g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
|
||||
}
|
||||
function closeAdvisorPreparationOnShipment(g: Game, player: string,
|
||||
producer: NonNullable<AdvisorPreparationWindow['firstShipment']>['producer']) {
|
||||
if (!g.advanced || !byFaction(g, 'beneGesserit') || g.phase < 2 || g.phase > 5) return;
|
||||
if (g.advisorPreparation?.turn !== g.turn)
|
||||
g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
|
||||
if (!g.advisorPreparation!.firstShipment)
|
||||
g.advisorPreparation!.firstShipment = { player, producer };
|
||||
}
|
||||
function advisorPreparationPriorityBlock(g: Game): string | null {
|
||||
return recruitsInteractionPending(g) || pendingNexusTraitors(g) || g.pendingShipment ||
|
||||
g.pendingHomeworldShipment || g.pendingGuildTransport || g.pendingTerrorEntry ||
|
||||
g.pendingNexusHarkonnenBetrayal || g.pendingNexusIxianBetrayal || g.pendingNexusIxianReplacement ||
|
||||
g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingNexusEmperorPayment ||
|
||||
g.pendingNexusEmperorSellerPurchase || g.pendingChoamMarketGhola || g.bureaucratPayments?.pending
|
||||
? 'Finish the current transaction or priority window before preparing advisors.' : null;
|
||||
}
|
||||
function advisorPreparationView(g: Game, viewer: string) {
|
||||
const offer = projectAdvisorPreparationWindow(g, viewer);
|
||||
return offer ? { ...offer, blocked: offer.blocked ?? advisorPreparationPriorityBlock(g) } : null;
|
||||
}
|
||||
function currentAdvisorPreparationResponse(g: Game, response: ResponseWindow) {
|
||||
if (response.kind !== 'advisorFlip' || !response.advisorPreparationSource) {
|
||||
requireRule(response.advisorResume !== 'preparation', 'The advisor preparation lost its original source.');
|
||||
return null;
|
||||
}
|
||||
requireRule((response.advisorResume === 'preparation' || response.advisorResume === 'declaration') &&
|
||||
response.owner === response.advisorPreparationSource.owner &&
|
||||
response.location === response.advisorPreparationSource.territory && response.advisors === false,
|
||||
'The advisor preparation lost its original owner or territory.');
|
||||
advisorPreparationRule(() => validateAdvisorPreparation(g, response.advisorPreparationSource!));
|
||||
return response.advisorPreparationSource;
|
||||
}
|
||||
function advisorBattleOptions(g: Game) {
|
||||
const quote = movementPhaseQuote(() => quoteAdvisorBattleOffer(g));
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -20863,11 +20914,7 @@ function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
|
||||
function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) {
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
if (bg && remaining.length)
|
||||
g.decision = {
|
||||
kind: 'advisorBattle',
|
||||
player: bg.id,
|
||||
territories: remaining,
|
||||
};
|
||||
g.decision = { kind: 'advisorBattle', player: bg.id, territories: remaining };
|
||||
else movementTurn(g);
|
||||
}
|
||||
function finishAdvisorReaction(
|
||||
@@ -20884,6 +20931,7 @@ function finishAdvisorReaction(
|
||||
if (details.advisorFollowup && spiritualAdvisorMaximum(g, bg.id) > 0)
|
||||
g.decision = { kind: 'advisor', player: bg.id, ...details.advisorFollowup };
|
||||
else if (details.advisorResume === 'wormRide') nextWormRide(g);
|
||||
else if (details.advisorResume === 'preparation') return;
|
||||
else if (details.advisorResume === 'declaration')
|
||||
beginAdvisorBattle(g, details.advisorRemaining ?? []);
|
||||
else if (details.advisorResume === 'ambassador')
|
||||
@@ -21242,6 +21290,7 @@ function beginPhase(g: Game) {
|
||||
g.auditorInsight = null;
|
||||
retireNexusChoamInsight(g);
|
||||
}
|
||||
if (g.phase === 2) openAdvisorPreparation(g);
|
||||
if (g.phase === 2 && g.choamCharity?.turn !== g.turn) {
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (choam && charityMultiplier(g) === 0) {
|
||||
@@ -27136,6 +27185,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
karamaConversionIntegrity(g);
|
||||
currentFactionPayment(g);
|
||||
const response = g.response!;
|
||||
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
|
||||
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
|
||||
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
@@ -28016,6 +28066,12 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
} else {
|
||||
if (canceled) movementCancellationQuote(g, response);
|
||||
const to = response.location!;
|
||||
if (canceled && advisorPreparationSource) {
|
||||
if (!g.advisorPreparation!.deniedTerritories.includes(to))
|
||||
g.advisorPreparation!.deniedTerritories.push(to);
|
||||
log(g, `${bg.name}'s advisor preparation in ${territory(to).name} was prevented for the remaining pre-shipment window. Other native flips and the current phase continue normally.`,
|
||||
{ faction: bg.faction, name: 'Advisor preparation prevented' });
|
||||
}
|
||||
if (!canceled) {
|
||||
bg.advisors ??= {};
|
||||
if (response.advisors) bg.advisors[to] = {};
|
||||
@@ -28493,6 +28549,7 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
|
||||
} else if (p.advisors) delete p.advisors[arrival.territory];
|
||||
}
|
||||
p.shipped = true;
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'junction');
|
||||
if (declaration.forfeitPurchasedRate) g.karamaShipping = null;
|
||||
g.junctionOffer = null;
|
||||
finishShipmentPromises(g, p, promise);
|
||||
@@ -28641,6 +28698,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
|
||||
}
|
||||
observeOccupation(g);
|
||||
p.shipped = true;
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'homeworld');
|
||||
g.pendingHomeworldShipment = null;
|
||||
// The E3 FAQ explicitly includes Homeworld shipment, including Fremen.
|
||||
techIncome(g, 'heighliners', p);
|
||||
@@ -29420,6 +29478,7 @@ function commitGuildTransport(
|
||||
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
|
||||
finishGuildCunningShipment(g, p.id, 'guild', frame);
|
||||
p.shipped = true;
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'guildTransport');
|
||||
g.karamaShipping = null;
|
||||
if (movementPreparation) return;
|
||||
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
|
||||
@@ -29613,6 +29672,8 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
}
|
||||
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
|
||||
p.shipped = true;
|
||||
if (n > 0 || shipment.noField || shipment.alliedNoField || shipment.smugglerCompanion)
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'ordinary');
|
||||
if (movementPreparation) { g.karamaShipping = null; return; }
|
||||
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
|
||||
const guild = byFaction(g, 'guild');
|
||||
@@ -32355,6 +32416,18 @@ function applyActionInner(
|
||||
offerDiplomatDefense(g);
|
||||
return g;
|
||||
}
|
||||
if (t === 'prepareAdvisors') {
|
||||
requireRule(Object.keys(action).sort().join(',') === 'territory,type',
|
||||
'Choose only the territory for native advisor preparation.');
|
||||
const blocked = advisorPreparationPriorityBlock(g);
|
||||
requireRule(!blocked, blocked ?? 'Finish the current interaction before preparing advisors.');
|
||||
const source = advisorPreparationRule(() => quoteAdvisorPreparation(g, id, stringField(action.territory)));
|
||||
const offer = quoteAdvisorPreparationWindow(g, id);
|
||||
applyAdvisorReleases(g, offer.released);
|
||||
g.response = { kind: 'advisorFlip', owner: id, location: source.territory,
|
||||
advisors: false, passed: [], advisorResume: 'preparation', advisorPreparationSource: source };
|
||||
return g;
|
||||
}
|
||||
if (t === 'battlePreparationReady') {
|
||||
const b = g.battle,
|
||||
opportunity = b?.preLeader;
|
||||
@@ -33770,6 +33843,8 @@ function applyActionInner(
|
||||
advisorBattleOptions(g).includes(to),
|
||||
'These advisors cannot prepare for battle.',
|
||||
);
|
||||
openAdvisorPreparation(g);
|
||||
const source = advisorPreparationRule(() => quoteAdvisorPreparation(g, id, to));
|
||||
g.response = {
|
||||
kind: 'advisorFlip',
|
||||
owner: id,
|
||||
@@ -33777,6 +33852,7 @@ function applyActionInner(
|
||||
advisors: false,
|
||||
passed: [],
|
||||
advisorResume: 'declaration',
|
||||
advisorPreparationSource: source,
|
||||
advisorRemaining: decision.territories.filter((t) => t !== to),
|
||||
};
|
||||
} else movementTurn(g);
|
||||
@@ -36823,6 +36899,7 @@ export function viewGame(state: Game, id: string) {
|
||||
freeRevival: g.freeRevival ?? [],
|
||||
recruitsPreview: recruitsPreview(g, me),
|
||||
fremenRevivalGrant: projectFremenRevivalGrant(g, id),
|
||||
advisorPreparation: advisorPreparationView(g, id),
|
||||
revivalRules: {
|
||||
...(g.revivalRules ?? newRevivalRules()),
|
||||
earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter(
|
||||
|
||||
@@ -179,7 +179,9 @@ const cancellationSources = {
|
||||
checks: g.battle?.powerChecks,
|
||||
}),
|
||||
advisorFlip: (g, response) =>
|
||||
response.advisorResume === 'ambassador'
|
||||
response.advisorPreparationSource
|
||||
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
|
||||
: response.advisorResume === 'ambassador'
|
||||
? {
|
||||
entry: g.pendingAmbassador ?? null,
|
||||
ambassadors: g.ecazAmbassadors ?? null,
|
||||
|
||||
@@ -304,6 +304,8 @@ export function quoteMovementCancellation(
|
||||
g: Game,
|
||||
response: ResponseWindow,
|
||||
): MovementCancellationQuote | null {
|
||||
// This native pre-shipment action has a phase-independent source validator.
|
||||
if (response.kind === 'advisorFlip' && response.advisorResume === 'preparation') return null;
|
||||
if (
|
||||
![
|
||||
'guildTiming',
|
||||
|
||||
@@ -28,8 +28,9 @@ type Context = Pick<
|
||||
| 'spiceDeck'
|
||||
| 'spiceDiscard'
|
||||
| 'homeworlds'
|
||||
| 'advisorPreparation'
|
||||
>;
|
||||
type AdvisorContext = BoardContext & { turn: number };
|
||||
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[] {
|
||||
@@ -95,7 +96,10 @@ export function quoteAdvisorBattleOffer(g: AdvisorContext) {
|
||||
b.territory === t && (b.attacker === bg.id || b.defender === bg.id || b.coalition?.includes(bg.id)),
|
||||
);
|
||||
});
|
||||
return { released: board.released, owner: bg.id, territories };
|
||||
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(
|
||||
|
||||
+61
-2
@@ -4251,6 +4251,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
title: 'Bene Gesserit advisors',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'Peaceful occupation, matching token types, intrusion and preparing for battle.',
|
||||
steps: [
|
||||
@@ -4261,10 +4262,68 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A normal shipment enters as fighters unless joining existing advisors. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.',
|
||||
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
|
||||
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
|
||||
'Before the first shipment, choose eligible advisor territories to prepare for battle. Each chosen flip has a Karama response. Advisors cannot prepare against an ally or where storm prevents the battle.',
|
||||
'The engine and these choices have focused tests. Advanced preview is available for the six classic factions; the full advisor timing audit, forced-flip cancellation and expansion interactions remain unfinished.',
|
||||
'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
|
||||
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or forced-flip cancellation behavior.',
|
||||
],
|
||||
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
|
||||
checklist: [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.",
|
||||
"evidence": [
|
||||
"game/advisor-preparation-window.ts",
|
||||
"game/engine.ts",
|
||||
"game/movement-phase-quote.ts",
|
||||
"game/karama-context.ts",
|
||||
"game/karama-movement-cancellation.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.",
|
||||
"evidence": [
|
||||
"components/advisor-preparation.tsx",
|
||||
"components/game-table.tsx",
|
||||
"tests/advisor-preparation-controls.test.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.",
|
||||
"evidence": [
|
||||
"game/bots.ts",
|
||||
"docs/ADVISOR_PREPARATION.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_PREPARATION.md",
|
||||
"docs/RULE_DECISIONS.md",
|
||||
"docs/ECAZ_OCCUPY_RULES.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.",
|
||||
"evidence": [
|
||||
"tests/fixture-advisor-preparation.ts",
|
||||
"tests/advisor-preparation-engine.test.ts",
|
||||
"tests/advisor-preparation-window.test.ts",
|
||||
"tests/advisor-preparation-controls.test.tsx",
|
||||
"tests/moritani-force-leader.test.ts",
|
||||
"tests/advisors.test.ts",
|
||||
"tests/movement-phase-quote.test.ts",
|
||||
"tests/karama-movement-cancellation.test.ts"
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'homeworlds',
|
||||
|
||||
Reference in new issue
Block a user