Preserve binding Truthtrance through Salusa Ghola

This commit is contained in:
Quintine committed 2026-10-27 16:16:30 +11:00
1 parent 53b37adf9f
commit b620cea873
15 files changed
+515 -33

No files matched your search

+53 -10
View File
@@ -228,6 +228,7 @@ import {
homeworldLowBonus,
homeworldSardaukarFreeSupport,
homeworldSardaukarGholaBlock,
homeworldSardaukarGholaCrossing,
snapshotHomeworldRevival,
tleilaxuHomeworldFreeIncomeBlocked,
HomeworldBenefitError,
@@ -28288,7 +28289,8 @@ function playResidualPoison(
if (b.preLeader && !b.preLeader.closed)
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
}
function reconcileChangedBattleInspections(g: Game, cause: string, renewExtraWithNative = false) {
function reconcileChangedBattleInspections(g: Game, cause: string, renewExtraWithNative = false,
voluntary?: { actor: string; action: Action }) {
const b = g.battle!;
// Selected provisional priority: preserve an independently feasible native
// answer first, then require the extra answer to be jointly feasible. This
@@ -28339,7 +28341,7 @@ function reconcileChangedBattleInspections(g: Game, cause: string, renewExtraWit
log(g, `After ${cause}, the inspected element became impossible. Its owner must answer the same element again; the earlier private observation is retained.`);
}
}
reconcileBattlePromises(g);
reconcileBattlePromises(g, voluntary);
}
function residualPoisonView(g: Game, p: Player) {
const card = p.hand.find(
@@ -28879,6 +28881,30 @@ function gholaLeaders(g: Game, p: Player) {
native.push(g.dukeVidal!.leader);
return native;
}
/** Probe only this actor's population-crossing force choices on detached
* states. Use the real effect, card disposal and existing promise solver;
* neither the view nor a bot reconstructs Truthtrance legality. */
function salusaGholaPromiseBlocks(g: Game, p: Player, cards: readonly string[],
maximum: number, eliteRemaining: number) {
const blocks: { source: 'ghola'; amount: number; elite: number; reason: string }[] = [];
if (!cards.length || !eliteRemaining || !homeworldSardaukarGholaCrossing(g, p.id) ||
homeworldSardaukarGholaBlock(g, p.id) ||
!g.battle!.truthPromises?.some(promise => promise.player === p.id && !promise.released)) return blocks;
for (const action of gholaForcePreparations({ cards: cards.slice(0, 1), tanks: p.tanks,
eliteTanks: p.elites?.tanks ?? 0, maxForces: maximum, eliteRemaining })) {
if (!action.elite) continue;
const trial = structuredClone(g), owner = getPlayer(trial, p.id);
try {
applyGholaEffect(trial, owner, action);
discard(trial, owner, action.card as string);
reconcileBattlePromises(trial, { actor: p.id, action });
} catch (error) {
if (!(error instanceof RuleError)) throw error;
blocks.push({ source: 'ghola', amount: Number(action.amount), elite: Number(action.elite), reason: error.message });
}
}
return blocks;
}
function gholaOptions(g: Game, p: Player) {
const cards = p.hand
.filter(
@@ -28910,15 +28936,25 @@ function gholaOptions(g: Game, p: Player) {
strength,
}));
const kwisatz = !!(g.advanced && p.faction === 'atreides' && p.kwisatz?.dead);
const eliteBlock = homeworldSardaukarGholaBlock(g, p.id);
const eliteRemaining = eliteBlock ? 0 : Math.min(
let eliteBlock = homeworldSardaukarGholaBlock(g, p.id);
let eliteRemaining = eliteBlock ? 0 : Math.min(
p.elites?.tanks ?? 0,
eliteRevivalRemaining(p, g.advanced),
);
const maxForces = Math.min(
let maxForces = Math.min(
5,
p.tanks - (p.elites?.tanks ?? 0) + eliteRemaining,
);
const promiseBlocks = salusaGholaPromiseBlocks(g, p, cards, maxForces, eliteRemaining);
if (promiseBlocks.length) {
const legal = [...gholaForcePreparations({ cards: cards.slice(0, 1), tanks: p.tanks,
eliteTanks: p.elites?.tanks ?? 0, maxForces, eliteRemaining })].filter(action =>
!promiseBlocks.some(block => block.amount === action.amount && block.elite === action.elite));
maxForces = Math.max(0, ...legal.map(action => Number(action.amount)));
const eligibleElite = Math.max(0, ...legal.map(action => Number(action.elite)));
if (eligibleElite < eliteRemaining) eliteBlock = promiseBlocks[0].reason;
eliteRemaining = eligibleElite;
}
const reason = !card
? 'No Ghola card is available in your hand.'
: g.pendingTreacheryDiscard
@@ -28928,7 +28964,7 @@ function gholaOptions(g: Game, p: Player) {
: timing && !timing.available
? timing.reason
: !leaders.length && !kwisatz && maxForces <= 0
? 'No eligible leader or force is available in your Tanks.'
? eliteBlock ?? 'No eligible leader or force is available in your Tanks.'
: null;
return {
cards,
@@ -29069,6 +29105,7 @@ function playMarketGhola(g: Game, p: Player, action: Action) {
}
if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event;
const used = discard(g, p, card.id);
reconcileBattlePromises(g, { actor: p.id, action });
guardTurnActionPrintedCardCost(g, p, used);
log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`,
{ faction: p.faction, name: 'Ghola revival' });
@@ -29077,7 +29114,7 @@ function playMarketGhola(g: Game, p: Player, action: Action) {
function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
requireRule(!revivalPrevented(g, p.id),
'Tleilaxu prevented this player’s revival, including Ghola, for the remaining Revival Phase.');
const salusaWasHigh = homeworldSardaukarFreeSupport(g, p.id);
const salusaCrossing = homeworldSardaukarGholaCrossing(g, p.id);
if (action.leader === 'kwisatz') {
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
@@ -29143,9 +29180,8 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
techIncome(g, 'axlotl', p);
collectRevivalIncome(g, p, 0, false, true);
recordNativePrintedCardPlay(g, p.id, 'ghola');
if (!salusaWasHigh && homeworldSardaukarFreeSupport(g, p.id) && g.battle &&
[g.battle.attacker, g.battle.defender].includes(p.id))
reconcileChangedBattleInspections(g, 'the Salusa population increase', true);
if (salusaCrossing && homeworldSardaukarFreeSupport(g, p.id))
reconcileChangedBattleInspections(g, 'the Salusa population increase', true, { actor: p.id, action });
}
/** Search complete legal plans without learning or consulting the opponent's sealed choices. */
function findLegalBattlePlan(
@@ -32934,6 +32970,10 @@ function projectedHomeworldRevivalReturn(g: Game, player: string) {
}
function homeworldRevivalChoiceBlocks(g: Game, p: Player) {
const blocks: { source: 'normal' | 'ghola'; amount: number; elite: number; reason: string }[] = [];
if (p.faction === 'emperor') return salusaGholaPromiseBlocks(g, p,
p.hand.filter(card => card.effect === 'ghola' &&
!(g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id)).map(card => card.id), Math.min(5, p.tanks),
Math.min(p.elites?.tanks ?? 0, eliteRevivalRemaining(p, g.advanced)));
if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return blocks;
for (const source of ['normal', 'ghola'] as const) {
const maximum = source === 'normal' ? forceRevivalRemaining(g, p) : Math.min(5, p.tanks);
@@ -43191,6 +43231,9 @@ function applyActionInner(
);
const removed = g.advanced && c.effect === 'atomics';
const used = removed ? removeUsedAtomics(g, p, c.id) : discard(g, p, c.id);
// Preserve the voluntary actor through the printed Ghola cost, before a
// later disposal response can attempt an uncontextual promise release.
if (c.effect === 'ghola') reconcileBattlePromises(g, { actor: id, action });
if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola' || isWindowCardEffect(c.effect)))
guardTurnActionPrintedCardCost(g, p, used);
log(
+19 -14
View File
@@ -59,15 +59,12 @@ export function homeworldSardaukarFreeSupport(
);
}
/** Current population can change before sealed plans. The caller renews an
* impossible inspection answer after the return. The remaining preview boundary
* protects sealed dials/support and binding Truthtrance promises from repricing;
* it does not freeze population or forbid Ghola for a whole battle.
* Inspect submission presence, never an opponent's sealed plan contents. */
export function homeworldSardaukarGholaBlock(
/** One returned star crosses Salusa's current threshold during its native
* player's battle. The caller still owns card timing and actual return count. */
export function homeworldSardaukarGholaCrossing(
context: HomeworldBenefitContext & Pick<Game, 'battle'>,
playerId: string,
): string | null {
): boolean {
if (
!context.advanced ||
!context.homeworlds?.custody ||
@@ -75,8 +72,8 @@ export function homeworldSardaukarGholaBlock(
![context.battle.attacker, context.battle.defender].includes(playerId) ||
!context.players.some((p) => p.id === playerId && p.faction === 'emperor')
)
return null;
const crossing = homeworldPopulations(
return false;
return homeworldPopulations(
homeworldContext(context),
context.homeworlds.custody,
).some(
@@ -85,12 +82,20 @@ export function homeworldSardaukarGholaBlock(
home.card === 'salusa_secundus' &&
home.population === 1,
);
if (!crossing) return null;
const battle = context.battle;
const committed = battle.revealed || Object.keys(battle.plans).length > 0 ||
battle.truthPromises?.some((promise) => !promise.released);
}
/** Current population can change before sealed plans. The caller renews
* impossible inspections and enforces voluntary Truthtrance promises after
* the physical return. Inspect submission presence, never hidden plan values. */
export function homeworldSardaukarGholaBlock(
context: HomeworldBenefitContext & Pick<Game, 'battle'>,
playerId: string,
): string | null {
if (!homeworldSardaukarGholaCrossing(context, playerId)) return null;
const battle = context.battle!;
const committed = battle.revealed || Object.keys(battle.plans).length > 0;
return committed
? 'Ghola raising Salusa to high population after a sealed plan or during a binding battle promise is not yet supported. Finish this battle first.'
? 'Ghola raising Salusa to high population after a sealed plan is not yet supported. Finish this battle first.'
: null;
}
+5 -2
View File
@@ -5445,7 +5445,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
"Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. Ghola can raise Salusa before full seals, retaining feasible inspections or reopening impossible original fields; sealed-plan and binding-Truthtrance timing remain guarded. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
"Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. Ghola can raise Salusa before full seals, retaining feasible inspections or reopening impossible original fields. Feasible Truthtrance Yes/No/grouped promises remain; self-impossible returns reject with exact force-group control reasons. Full-seal timing remains guarded. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/homeworld-module-profile.ts',
'game/faction-module-profile.ts',
@@ -5555,7 +5555,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'AI',
status: 'Partial',
detail:
"All four profiles share legal Southern portion/destination choices and the remaining-reserves option. Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect the remaining special-Karama and post-commitment Ghola timing boundaries; before full seals, a real Salusa return updates the shared free-support descriptor and the existing owned inspection-renewal path. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished. All four original profiles execute the immune full offered quote with its explicit acknowledgment. This is minimal legal participation, not strategy tuning.",
"All four profiles share legal Southern portion/destination choices and the remaining-reserves option. Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect the remaining special-Karama and full-seal Ghola timing boundaries; before full seals, a real Salusa return updates the shared free-support descriptor and owned inspection-renewal path. Exact native force-group blocks preserve binding promises without duplicating the solver in a policy. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Fresh occupied decisions supply legal bank/percentage allocation, one eligible real bonus receiver, displaced Ix/cache selection and native Richese terms. Concealed transport, remaining card-effect strategy and difficulty calibration are unfinished. All four original profiles execute the immune full offered quote with its explicit acknowledgment. This is minimal legal participation, not strategy tuning.",
evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
},
{
@@ -5623,6 +5623,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/fixture-salusa-ghola.ts',
'tests/salusa-ghola-runtime.test.ts',
'tests/salusa-ghola-inspection.test.ts',
'tests/fixture-salusa-ghola-truth.ts',
'tests/salusa-ghola-truth.test.ts',
'tests/salusa-ghola-truth-controls.test.ts',
'tests/homeworld-combat.test.ts',
'tests/homeworld-battle-resolution.test.ts',
'tests/homeworld-battle-engine.test.ts',