Offer Semuta on Spiritual Advisor and relocation costs

This commit is contained in:
Quintine committed 2026-10-26 05:03:01 +11:00
1 parent 92f2e8125b
commit 81fbbca309
12 files changed
+1559 -10

No files matched your search

+43 -10
View File
@@ -2,6 +2,7 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival';
import { semutaSpiritualArrivalSupported } from './semuta-spiritual-arrival';
import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises,
reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise,
TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises';
@@ -5857,20 +5858,35 @@ function semutaAuctionAdvantageResponse(g: Game, response: ResponseWindow, playe
throw error;
}
}
/** Qualify only the original ordinary foreign shipment and its exact selected
* Spiritual packet. Native quotes retain the shipping/matching/arrival stage. */
function semutaSpiritualKaramaParent(g: Game, response: ResponseWindow): boolean {
if (!semutaSpiritualArrivalSupported(g)) return false;
try {
if (response.spiritualAdvisorSource) currentSpiritualAdvisorResponse(g,response);
else if (response.automaticAdvisorFlip?.producer === 'spiritualArrival') currentAutomaticAdvisorFlip(g,response);
else return false;
return true;
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
}
/** These denied native branches change only knowledge, an unused packet or
* the declared replacement attempt. Keep complex arrival/opening parents out. */
* the declared replacement attempt. Arrival parents require an exact source. */
function semutaTerminalKaramaResponse(g: Game, response: ResponseWindow, player: string,
decision: Game['decision'] = g.decision): boolean {
const spiritual = response.kind === 'advisor' && semutaSpiritualKaramaParent(g,response);
if (!['advisor','atreidesSpice','stormPeek','faceDancerReplacement'].includes(response.kind) ||
g.battle || g.phaseOpening || g.pendingKarama || g.pendingRevival ||
g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry ||
g.pendingArrivalOverlap || !semutaTerminalArrivalSupported(g,response) ||
(g.pendingSpiritualAdvisor && !spiritual) || g.pendingAmbassador || g.pendingTerrorEntry ||
g.pendingArrivalOverlap || (!spiritual && !semutaTerminalArrivalSupported(g,response)) ||
g.pendingIxMove || g.pendingFremenMove || g.pendingChoamMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
g.choamMarket || g.pendingChoamMarketGhola) return false;
if (response.kind === 'advisor' &&
(response.spiritualAdvisorSource || response.advisorResume ||
((response.spiritualAdvisorSource && !spiritual) || response.advisorResume ||
response.advisorFollowup || response.advisorRemaining !== undefined ||
response.advisorAmbassadorEvent !== undefined)) return false;
try {
@@ -6073,23 +6089,26 @@ function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, play
/** Ordinary Advanced advisor conversion sources retain their native timing:
* preparation, uncommitted matching or an already completed native producer. */
function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean {
const spiritual = response.kind === 'advisorFlip' && semutaSpiritualKaramaParent(g,response);
const relocation = response.automaticAdvisorFlip?.producer === 'specialRelocation';
const ambassador = relocation && g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador';
if (response.kind !== 'advisorFlip' || !g.advanced ||
response.owner !== byFaction(g,'beneGesserit')?.id ||
g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance ||
g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingRichesePurchaseIncome ||
g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment ||
g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove ||
g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
!semutaAdvisorArrivalSupported(g,response) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
(g.pendingSpiritualAdvisor && !spiritual) || (g.pendingAmbassador && !ambassador) || g.pendingTerrorEntry || g.pendingArrivalOverlap ||
(!spiritual && !semutaAdvisorArrivalSupported(g,response)) || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 ||
g.currentAuctionSale || g.choamMarket || g.pendingChoamMarketGhola || g.pendingIxAlly ||
response.advisorGatherMatchingSource || response.advisorTransportMatchingSource ||
response.advisorAmbassadorMatchingSource || response.spiritualAdvisorSource ||
response.advisorAmbassadorMatchingSource || (response.spiritualAdvisorSource && !spiritual) ||
(response.advisorResume !== undefined && !(response.advisorResume === 'preparation' && response.advisorPreparationSource)) ||
(!!g.pendingAdvisorMatching !== !!response.advisorMatchingSource) ||
(!!g.pendingAutomaticAdvisorFlips?.length !== !!response.automaticAdvisorFlip)) return false;
if ((g.auction || g.richeseAuction) && !response.advisorPreparationSource) return false;
if (response.automaticAdvisorFlip &&
g.pendingAutomaticAdvisorFlips?.some(source => !['groundMovement','guildTransport','gamont','storm','homeworldTransport','battleLosses','faceDance'].includes(source.producer))) return false;
g.pendingAutomaticAdvisorFlips?.some(source => !['groundMovement','guildTransport','gamont','storm','homeworldTransport','battleLosses','faceDance','specialRelocation','spiritualArrival'].includes(source.producer))) return false;
const context: Game = {...g,pendingTreacheryDiscard:null,response};
try {
// Native source quotes validate denial without requiring the spent card,
@@ -6367,6 +6386,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
faceDance:g.pendingAutomaticAdvisorFaceDance, context:g.lastBattleContext,
pending:g.pendingFaceDance, custody:g.homeworlds,
players:g.players.map(p => ({id:p.id, faceDancers:p.faceDancers, replaced:p.faceDancerReplacedTurn})),
} : advisorProducer === 'specialRelocation' ? {
relocation:g.pendingAutomaticAdvisorRelocation, ambassador:g.pendingAmbassador,
discovery:g.discoveryEntry, tokens:g.ecazAmbassadors,
} : undefined,
spiritualParent: (terminal || advisor) && (c.use.response.spiritualAdvisorSource ||
advisorProducer === 'spiritualArrival') ? {
frame:g.pendingSpiritualAdvisor, arrivals:g.pendingArrivalReaction,
tech:g.techTokens,
} : undefined,
order: g.order,
players: g.players.map(p => ({
@@ -6936,7 +6963,10 @@ function treacheryDiscardIntegrity(g: Game) {
continuation.entry.cause === 'ambassador';
const ambassadorCost =
(continuation?.kind === 'karamaBonusDiscard' && continuation.use.response.source === 'ambassador') ||
(continuation?.kind === 'karamaNoFieldDiscard' && continuation.use.response.source === 'ambassadorNoField');
(continuation?.kind === 'karamaNoFieldDiscard' && continuation.use.response.source === 'ambassadorNoField') ||
(continuation?.kind === 'karamaAdvisorFlipDiscard' &&
continuation.use.response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador');
requireRule(
(g.status === 'playing' || (truth && g.status === 'setup')) &&
pending.sequence === sequence &&
@@ -8593,7 +8623,10 @@ function semutaOfferSupported(
!g.pendingArrivalOverlap && !g.pendingRevival &&
!g.ornithopter && !g.ecologicalStorm)) &&
(richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy &&
!g.pendingAmbassador && (terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
(!g.pendingAmbassador || advisorDiscard &&
continuation.use.response.automaticAdvisorFlip?.producer === 'specialRelocation' &&
g.pendingAutomaticAdvisorRelocation?.resume.kind === 'ambassador') &&
(terrorAllianceDiscard || !g.pendingTerrorEntry))) &&
(richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' ||
bgDiscard ||
+7
View File
@@ -1490,6 +1490,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-advisor-battle-engine.test.ts',
'tests/fixture-semuta-advisor-face-dance.ts',
'tests/semuta-advisor-face-dance-engine.test.ts',
'game/semuta-spiritual-arrival.ts',
'tests/fixture-semuta-spiritual-cost.ts',
'tests/semuta-spiritual-cost-engine.test.ts',
'tests/fixture-semuta-spiritual-arrival.ts',
'tests/semuta-spiritual-arrival-engine.test.ts',
'tests/fixture-semuta-advisor-relocation.ts',
'tests/semuta-advisor-relocation-engine.test.ts',
'tests/fixture-semuta-spiritual-foresight.ts',
'tests/semuta-spiritual-foresight-engine.test.ts',
'tests/fixture-semuta-storm-dancer.ts',
+28
View File
@@ -0,0 +1,28 @@
import type { Game } from './engine';
import { location, validGameLocation } from './board';
/** The ordinary foreign shipment remains deposited behind its Spiritual
* child. Native frame/source validators separately bind the selected grant. */
export function semutaSpiritualArrivalSupported(g: Game): boolean {
const frame = g.pendingSpiritualAdvisor, arrivals = g.pendingArrivalReaction;
if (!g.advanced || g.phase !== 5 || !frame || frame.decision.ambassadorEvent ||
frame.decision.revivalEvent || g.pendingAmbassador || !Array.isArray(arrivals) ||
arrivals.length !== 1) return false;
const entry = arrivals[0];
if (!entry || Object.keys(entry).sort().join(',') !==
'amount,cause,elite,entrant,phase,resume,sector,territory,turn' ||
entry.turn !== g.turn || entry.phase !== g.phase || entry.cause !== 'shipment' ||
entry.resume !== 'none' || frame.decision.shipment !== entry.entrant ||
frame.decision.destination !== location(entry.territory,entry.sector) ||
!Number.isSafeInteger(entry.amount) || entry.amount <= 0 || entry.amount > 20 ||
!Number.isSafeInteger(entry.elite) || entry.elite < 0 || entry.elite > entry.amount ||
!validGameLocation(g,entry.territory,entry.sector)) return false;
const entrant = g.players.find(p => p.id === entry.entrant);
if (!entrant || entrant.id !== g.active || entrant.id === frame.decision.player ||
entrant.faction === 'beneGesserit' || entrant.faction === 'fremen' ||
entrant.shipped !== true || !g.movementRemaining?.includes(entrant.id)) return false;
const key = location(entry.territory,entry.sector), total = entrant.forces[key] ?? 0,
elite = entrant.elites?.forces[key] ?? 0;
return total >= entry.amount && elite >= entry.elite &&
total - elite >= entry.amount - entry.elite;
}