diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e877588b..f69d3c33 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,28 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Salusa Ghola / live Truthtrance — 27 October. Prototyped / Partial.** +Before either full seal, feasible Yes, whole-No and grouped promises survive the +actual return. A return that makes the actor's own promise impossible rejects +without cost or release. Obsolete inspections renew before truth feasibility; +the real actor context survives effect and card disposal. Exact force-group +blocks feed current human controls, force maxima and minimal AI. The voluntary +self-impossibility policy remains an existing product interpretation; full +sealed-plan timing stays guarded. [Contract](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026). + +**Sixteen files145/145**, types/lint and three native runtime programmes pass. +Original public Advanced E/A/G base33/Homeworlds earn a real first-battle star +loss and use the originally dealt Atreides Truthtrance for actual questions/ +definite answers. Prefixes total169 actions;16 further accepted actions compare +Yes2 retention, immutable rejected Yes1 then native completion/later Ghola, and +Prescience1 plus TruthYes2 renewal/completion. One rejection,11 GameTable/two +BattlePreparation SSR renders,3 group-block adapters and1 answer-adapter packet +are observed. Tests additionally cover whole-No, grouped OR, positive support +and all four minimal policies. Stateful Ghola/answer/plan forms are not operated +in a browser. No full-seal, played Cunning/Ghola, native-Salusa battle, complete +mode, mobile, deployed or final assurance acceptance is certified. No build was +repeated for this focused rule change. + **Salusa Ghola inspection renewal — 27 October. Prototyped / Partial.** Before either full plan seals, an actual Salusa-crossing Ghola return now keeps a feasible inspection answer or reopens an impossible original question. The diff --git a/docs/HOMEWORLD_BATTLE_RUNTIME.md b/docs/HOMEWORLD_BATTLE_RUNTIME.md index 8d5931df..29af4e0a 100644 --- a/docs/HOMEWORLD_BATTLE_RUNTIME.md +++ b/docs/HOMEWORLD_BATTLE_RUNTIME.md @@ -16,7 +16,7 @@ Ixian winners may exchange surviving Suboids for Cyborgs lost at that same Homew ## Explicit unfinished interactions -The [replacement source audit](HOMEWORLD_REPLACEMENT_RULES.md) does not resolve the Advanced Emperor’s returned-army allocation between Kaitain and Salusa. That Face Dance return is gated with the same server, AI and interface reason; declining remains available. The Salusa Ghola crossing remains gated after full seals or during active Truthtrance battle promises; [inspection renewal](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-renewed-inspection-answers--27-october-2026) before full seals uses actual current population and the original question. These restrictions are development boundaries, not official prohibitions. Other Homeworld face effects, occupation rights and full interaction coverage are still required. +The [replacement source audit](HOMEWORLD_REPLACEMENT_RULES.md) does not resolve the Advanced Emperor’s returned-army allocation between Kaitain and Salusa. That Face Dance return is gated with the same server, AI and interface reason; declining remains available. The Salusa Ghola crossing remains gated after a full seal; [live-promise feasibility](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026) before seals uses current population, original inspection renewal and actual voluntary actor context. These restrictions are development boundaries, not official prohibitions. Other Homeworld face effects, occupation rights and full interaction coverage are still required. ## Evidence diff --git a/docs/HOMEWORLD_BENEFITS_RULES.md b/docs/HOMEWORLD_BENEFITS_RULES.md index ea350833..75602933 100644 --- a/docs/HOMEWORLD_BENEFITS_RULES.md +++ b/docs/HOMEWORLD_BENEFITS_RULES.md @@ -2,6 +2,68 @@ Source audit, 9 September 2026. This separates publisher wording, rule composition, and remaining decisions. It does not enable Homeworld games or replace the outstanding Ambassador/Duke revival rulings. +## Salusa Ghola and live Truthtrance promises — 27 October 2026 + +Before either full plan seals, a current battle promise no longer forbids every +population-crossing Ghola. Preserve a feasible Yes, whole-No or grouped claim. +Reject the actor's return if the resulting High support regime makes that +actor's own promise impossible, including an odd dial with only free double +stars or a positive support promise with no payable ordinary counter. A rejected +action consumes no card, force, revival allowance or binding. + +The actual physical return and any obsolete inspection renewal occur before +Truthtrance feasibility. Carry the real Ghola actor/action through that check +and its printed card disposal. An uncontextual release must not retire the +actor's promise first. Feasible answers remain fixed; an opposing promise that +actually becomes impossible follows the existing release policy. No new promise +parser or battle solver is introduced. + +The server quotes each real force group using a detached copy, the native Ghola +effect/disposal and the existing reachable-plan solver. Current force maxima, +elite allowance and exact `homeworldRevivalBlocks` use those results. Human +controls and minimal AI consume the existing shared block adapter; they do not +reconstruct claim legality or inspect opponents' plans. + +The supplied PDF physical39 / printed37 requires Truthtrance compliance while +possible. [Official November2020 FAQp8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8) +permits current-turn and AND/OR questions. Neither separately defines deliberate +self-impossibility; rejecting the actor's voluntary destruction is the existing +[product interpretation](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#where-promises-currently-bind-actions), +not a new printed Ghola restriction. Full sealed-plan repricing remains an +unfinished preview boundary. Earlier inspection/promise guards below are dated +history where superseded by this follow-up. + +### Live-promise evidence + +Sixteen selected files pass145/145 with types/lint. Native tests use actual +initial Atreides Truthtrance, priority passes, current battle questions and +definite answers. They cover feasible Yes2, whole-No1, grouped OR1/2, own +impossible Yes1 and positive support, later use after completing the original +promise, and Prescience1 plus TruthYes2 renewal. Exact control-block reasons +match authoritative rejections and maxima. All four minimal profiles submit +legal required plans and omit blocked/spent Ghola. + +The separate runtime has three original public Advanced E/A/G base33/Homeworld +prefixes totaling169 actions. A real paid first-battle star loss creates the +Tanks source; no played resource, phase, inspection, promise or receipt is +installed. Only unopened initial card/Spice positions and native scalar +lotteries are selected. Sixteen accepted continuation actions compare feasible +return, one immutable rejection followed by actual original-plan completion/ +later Ghola, and original inspection renewal while preserving TruthYes2. + +Eleven GameTable/two BattlePreparation SSR renders, three existing group-block +adapter checks and one shared answer-adapter packet connect current controls. +Stateful Ghola/answer/plan inputs are not operated in a browser. Full seals, +new played Cunning/Ghola pairs, native-Salusa battle, complete modes, mobile, +deployed and final recovery/privacy/custody acceptance remain separate. +No build was repeated for this focused rule change. + +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-salusa-ghola-truth-proof-v1/`; +runtime: `20261027-salusa-ghola-truth-runtime/run-v1/` beside it. Frozen tree +`d713902330ddca9084ab9a2d9e02b239ee7344b2d33d97eb9f040131a5f1c65c` +contains3354 files. Final prose overlays are limited to this contract, the +dashboard and implementation log. + ## Salusa Ghola and renewed inspection answers — 27 October 2026 The earlier commitment-free window below now includes completed inspection diff --git a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md index 717c9e48..3825fc8a 100644 --- a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md @@ -2,7 +2,8 @@ Current follow-up: [Ghola inspection renewal](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-renewed-inspection-answers--27-october-2026) can raise Salusa before either full plan seals, retaining feasible answers or -renewing impossible original fields. Full-seal/promise repricing remains guarded. +renewing impossible original fields. The [live-promise follow-up](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026) +retains feasible claims and rejects self-impossible returns; full seals remain guarded. 9 September 2026. This extends the native-reserve runtime. Homeworld invasion and live off-planet battle actions remain unavailable; no start gate is opened. diff --git a/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md b/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md index f3336513..348f555f 100644 --- a/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md +++ b/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md @@ -36,7 +36,7 @@ disclosure ability is explicitly lost under occupation. | `charity.ts::charityQuote`, `revival.ts::freeRevivalRate` | Shared low bonus | Preserve ordinary eligibility, bank funding, inflation, Recruits and resource caps. Changing the shared helper reaches engine/view quotes. | | `homeworld-benefits.ts::snapshotHomeworldRevival` | Tleilax physically low at Revival opening | Snapshot the verified low-penalty condition at the original phase-opening event. `tleilaxuHomeworldFreeIncomeBlocked` must continue reading that saved receipt, not current population or occupation. | | `homeworld-benefits.ts::homeworldSardaukarFreeSupport` | Advanced Salusa high | A native high capability. Occupied Salusa's loss of Sardaukar advantage needs its own rule composition, not a physical force conversion. | -| `homeworld-benefits.ts::homeworldSardaukarGholaBlock` | Salusa population exactly one after a full seal or during an active battle promise | Remaining support-transition preview guard; pre-seal Ghola uses current population and renews impossible original inspections. If occupation changes effective support, compare the relevant before/after capability; do not remove the guard solely because occupation exists. | +| `homeworld-benefits.ts::homeworldSardaukarGholaBlock` | Salusa population exactly one after a full seal | Remaining support-transition preview guard; pre-seal Ghola uses current population, renews obsolete inspections and enforces exact own-promise feasibility. If occupation changes effective support, compare the relevant before/after capability; do not remove the guard solely because occupation exists. | | `homeworld-mobility.ts::homeworldMovementForesightBlock` | Caladan low | Retained low penalty blocks a new Movement peek; it does not erase already acquired knowledge. | | `homeworld-mobility.ts::homeworldMobileStrongholdMovementBlock` | Ix low | Retained low penalty blocks stronghold relocation, including its special Karama route; ordinary troop movement remains separate. | | `homeworld-mobility.ts::homeworldNoFieldMovementBlock` | Richese low | Retained low penalty restricts existing marker movement. Shipment and reveal rules remain separate. | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2b0ced93..ab7d71be 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,38 @@ # Dune implementation status +## 27 October 2026 — Salusa Ghola with live Truthtrance + +Before either full seal, preserve feasible Yes/whole-No/grouped promises and +reject the actor's own impossible return. Carry actual actor/action context +through inspection renewal, truth feasibility and physical card disposal; the +own promise cannot be released first. Exact-group native-effect/solver quotes +drive current force maxima, human controls and minimal AI. The existing +voluntary-impossibility product interpretation is explicit; full-seal timing +remains a preview boundary. +[Contract](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026). + +Sixteen-file145/145, types/lint and three original public native runtime +programmes pass. Advanced E/A/G base33/Homeworlds earn the earlier paid star +loss, receive original Truthtrance and make actual current-battle declarations/ +definite answers. Three prefixes total169 actions;16 accepted continuations +compare feasible Yes2 return, immutable rejected Yes1 followed by original +completion/later Ghola, and Prescience1/TruthYes2 renewal/completion. One rejected +attempt,11 GameTable/two BattlePreparation SSR renders,3 group-block adapters +and1 answer-adapter packet are observed. Tests additionally cover whole-No, +OR1/2, paid-support impossibility and all four minimal policies. No played +resource, phase, inspection, promise or receipt is installed; only unopened +initial card/Spice positions and native scalar lotteries are selected. + +Stateful input/browser operation, full seals, played Cunning/Ghola pairs, +native-Salusa battle, complete modes, mobile, deployed and final assurance are +not certified; no build was repeated for this focused rule change. Private +proof `20261027-salusa-ghola-truth-proof-v1/` and runtime +`20261027-salusa-ghola-truth-runtime/run-v1/` use the existing private checkpoint +directory. Frozen tree +`d713902330ddca9084ab9a2d9e02b239ee7344b2d33d97eb9f040131a5f1c65c` +contains3354 files; final prose overlays are limited to this log, +`CURRENT_STATUS.md` and `HOMEWORLD_BENEFITS_RULES.md`. + ## 27 October 2026 — Salusa Ghola inspection renewal An actual Salusa threshold return before either full plan seals now reuses the diff --git a/docs/NEXUS_ATREIDES_RULES.md b/docs/NEXUS_ATREIDES_RULES.md index 4abb5eab..d99556d9 100644 --- a/docs/NEXUS_ATREIDES_RULES.md +++ b/docs/NEXUS_ATREIDES_RULES.md @@ -73,6 +73,9 @@ The unresolved cases were reported to the coordinator before implementation deci The [Salusa Ghola follow-up](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-renewed-inspection-answers--27-october-2026) also reuses this native-first ordered renewal before either full plan seals. Its actual physical return occurs once; it does not buy another inspection. +The later [Truthtrance follow-up](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026) +carries the voluntary Ghola actor through the subsequent promise check, so +retiring an obsolete inspection cannot release that actor's impossible promise. **Selected provisional composition — 26 October 2026.** The 14 October choose-recommended authority supersedes the earlier blanket product refusal. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ce681450..64733b6d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,18 @@ # Rule decision index +## Salusa Ghola with binding Truthtrance — 27 October 2026 + +Remove the blanket live-promise restriction before either full seal. Preserve +feasible Yes/whole-No/grouped claims and reject a return that deliberately makes +the actor's own promise impossible. Renew obsolete inspections first, then test +with the actual Ghola actor/action through effect and physical card disposal; +do not release that own promise without actor context. Exact-group projections +share the native effect and existing solver with current controls/minimal AI. +Compliance-while-possible comes from the supplied physical39/printed37 and +official FAQp8; voluntary self-impossibility rejection remains the existing +product interpretation, not a separately printed Ghola rule. Full seals retain +their preview boundary. [Contract](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026). + ## Salusa Ghola inspection renewal — 27 October 2026 Before either full plan is sealed, an actual Salusa-crossing Ghola return may diff --git a/game/engine.ts b/game/engine.ts index c02ddd14..0252608b 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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( diff --git a/game/homeworld-benefits.ts b/game/homeworld-benefits.ts index 05bf0bdc..003893ed 100644 --- a/game/homeworld-benefits.ts +++ b/game/homeworld-benefits.ts @@ -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, 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, + 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; } diff --git a/game/reference.ts b/game/reference.ts index a0c253a8..79569ef2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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', diff --git a/tests/fixture-salusa-ghola-truth.ts b/tests/fixture-salusa-ghola-truth.ts new file mode 100644 index 00000000..ee381260 --- /dev/null +++ b/tests/fixture-salusa-ghola-truth.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { PlanClaim } from '../game/battle-promises'; +import type { TruthQuestion } from '../game/truthtrance'; +import { + createSalusaGholaFixture, actSalusaGhola, advanceSalusaGhola, salusaGholaClean, + salusaGholaPlayer, salusaGholaInventory, type SalusaGholaStep, +} from './fixture-salusa-ghola'; + +export const actSalusaGholaTruth = actSalusaGhola; +export const salusaGholaTruthPlayer = salusaGholaPlayer; +export const salusaGholaTruthInventory = salusaGholaInventory; +export const salusaGholaTruthClean = salusaGholaClean; + +/** Original public source and native Truth protocol. The optional inspection + * was really declared/answered before this original Truth card is used. No + * binding, frozen answer, battle commitment or physical receipt is installed. */ +export function createSalusaGholaTruthFixture(options: { + claim?: PlanClaim; answer?: 'yes'|'no'; inspectionDial?: 1|2; +} = {}) { + const base = createSalusaGholaFixture({firstTerritory:'imperial_basin',truthtrance:true, + ...(options.inspectionDial === undefined ? {} : {inspectionDial:options.inspectionDial})}); + const actor = base.actor, opponent = base.opponent; + assert.equal(opponent,'atreides'); + const truth = salusaGholaPlayer(base.game,opponent).hand.find(c => c.effect === 'truthtrance'); + assert.ok(truth); + const claim: PlanClaim = structuredClone(options.claim ?? {kind:'dial',compare:'eq',value:2}); + const answer = options.answer ?? 'yes'; + const question: Extract = { + kind:'battlePlan',target:actor,territory:base.territory,claim, + }; + const actions: SalusaGholaStep[] = [...base.sourceActions], at = actions.length; + let game = actSalusaGhola(structuredClone(base.game),opponent,{type:'card',card:truth.id},actions); + const declared = structuredClone(game); + for (let n = 0; game.truthtrance?.stage === 'priority' && n < game.players.length; n++) { + const owner = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(owner); + game = actSalusaGhola(game,owner.id,{type:'truthPass'},actions); + } + assert.equal(game.truthtrance?.stage,'ask'); + const beforeQuestion = structuredClone(game); + game = actSalusaGhola(game,opponent,{type:'truthAsk',question},actions); + const asked = structuredClone(game); + assert.ok(viewGame(game,actor).truthBattleAnswers?.includes(answer), + 'The actual native Truth question must offer the requested definite answer.'); + game = actSalusaGhola(game,actor,{type:'truthAnswer',answer},actions); + assert.equal(game.truthtrance,null); + assert.deepEqual(game.battle!.plans,{}); + assert.equal(game.discard.filter(c => c.id === truth.id).length,1); + assert.deepEqual(salusaGholaInventory(game),base.original); + return {...base,base,game,source:structuredClone(game),actor,opponent,truthCard:truth.id, + claim,answer,question,declared,beforeQuestion,asked,event:game.battle!.event!, + actions,sourceActions:actions,sourceCount:actions.length,truthActions:actions.slice(at), + disclosure:base.disclosure+' Atreides actually received its original unopened Truthtrance instead of Shield. Its native printed declaration, original priority passes, battle-plan question and Emperor definite answer create the live binding; no promise is installed.'}; +} +export type SalusaGholaTruthFixture = ReturnType; + +export function salusaGholaTruthPlan(game: Game, dial = 2, support = 0): Action { + const leaders = salusaGholaPlayer(game).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy) + .sort((a,b) => b.strength-a.strength); + assert.ok(leaders[0]); + return {type:'battlePlan',leader:leaders[0].id,dial,support}; +} + +/** Actual native seals and calls only. It does not play Ghola or manufacture a + * replacement inspection answer; the caller must finish the current chooser. */ +export function finishSalusaGholaTruthBattle(f: SalusaGholaTruthFixture, state: Game, + plan: Action = salusaGholaTruthPlan(state), actions: SalusaGholaStep[] = []) { + let game = actSalusaGhola(state,f.actor,plan,actions); + const afterPlan = structuredClone(game); + const enemy = salusaGholaPlayer(game,f.opponent).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy) + .sort((a,b) => a.strength-b.strength)[0]; assert.ok(enemy); + game = actSalusaGhola(game,f.opponent,{type:'battlePlan',leader:enemy.id,dial:0,support:0},actions); + const revealed = structuredClone(game); + game = advanceSalusaGhola(game,g => !g.battle && salusaGholaClean(g),actions); + return {game,afterPlan,revealed,actions}; +} diff --git a/tests/fixture-salusa-ghola.ts b/tests/fixture-salusa-ghola.ts index 63af7dcb..39e7dd19 100644 --- a/tests/fixture-salusa-ghola.ts +++ b/tests/fixture-salusa-ghola.ts @@ -75,7 +75,7 @@ function livingLeader(game: Game, actor: string, strongest: boolean) { * while unopened; native setup, payments, typed shipments, split movement and * the earlier combat casualty create every later resource and battle field. */ export function createSalusaGholaFixture(options: { - firstTerritory?: 'imperial_basin'|'arrakeen'; inspectionDial?: 1|2; + firstTerritory?: 'imperial_basin'|'arrakeen'; inspectionDial?: 1|2; truthtrance?: true; } = {}) { const actor = 'emperor', atreides = 'atreides', guild = 'guild'; let game = createGame('SALUSAGHOLA',newPlayer(actor,'Emperor','emperor'),true); @@ -91,9 +91,10 @@ export function createSalusaGholaFixture(options: { const setup = structuredClone(game); assert.equal(game.status,'setup'); assert.ok(game.players.every(p => p.hand.length === 0)); const ghola = game.deck.find(c => c.effect === 'ghola'); - const shield = game.deck.find(c => c.kind === 'shield'), projectile = game.deck.find(c => c.kind === 'projectile'); - assert.ok(ghola && shield && projectile); - const selected = [ghola,shield,projectile], dealOrder = selected.map((card,position) => { + const atreidesCard = game.deck.find(c => options.truthtrance ? c.effect === 'truthtrance' : c.kind === 'shield'); + const projectile = game.deck.find(c => c.kind === 'projectile'); + assert.ok(ghola && atreidesCard && projectile); + const selected = [ghola,atreidesCard,projectile], dealOrder = selected.map((card,position) => { const from = game.deck.findIndex(c => c.id === card.id); assert.ok(from >= position); [game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]]; return {position,from,card:card.id}; diff --git a/tests/salusa-ghola-truth-controls.test.ts b/tests/salusa-ghola-truth-controls.test.ts new file mode 100644 index 00000000..f0728019 --- /dev/null +++ b/tests/salusa-ghola-truth-controls.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import type { PlanClaim } from '../game/battle-promises'; +import { homeworldRevivalActionBlock } from '../game/homeworld-revival-deployment-options'; +import { createSalusaGholaTruthFixture } from './fixture-salusa-ghola-truth'; + +type Fixture = ReturnType; +type Case = 'dial2' | 'dial1' | 'support'; +const sources = new Map(); +function source(kind: Case): Fixture { + let fixture = sources.get(kind); + if (!fixture) { + const claim: PlanClaim = kind === 'support' + ? { kind: 'support', compare: 'gte', value: 1 } + : { kind: 'dial', compare: 'eq', value: kind === 'dial1' ? 1 : 2 }; + fixture = createSalusaGholaTruthFixture({ claim, answer: 'yes', inspectionDial: kind === 'dial1' ? 1 : 2 }); + sources.set(kind, fixture); + } + return fixture; +} +const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; +function ownPromise(game: Game, actor: string) { + const promise = game.battle?.truthPromises?.find(row => row.player === actor && !row.released); + assert.ok(promise, 'The actual answered Truthtrance promise remains live.'); + return promise; +} + +// These are shared control-adapter and minimal-policy checks over the earned +// native source. No Ghola amount selector or Battle Plan form is operated here; +// GameTable SSR is covered by the integration owner's separate runtime smoke. +void test('the exact owned Ghola group remains available when Salusa High preserves the native Yes dial2 promise', () => { + const f = source('dial2'), game = structuredClone(f.game), view = viewGame(game, f.actor); + assert.deepEqual(f.action, { type: 'card', card: f.card, amount: 1, elite: 1 }); + assert.deepEqual(ownPromise(game, f.actor).claim, f.claim); + assert.equal(homeworldRevivalActionBlock(view, f.action), null); + assert.equal(view.ghola.available, true); assert.ok(view.ghola.cards.includes(f.card)); + assert.equal(view.ghola.maxForces, 1); assert.equal(view.ghola.eliteRemaining, 1); + const returned = applyAction(game, f.actor, f.action); + assert.equal(returned.battle?.event, game.battle?.event); + assert.deepEqual(ownPromise(returned, f.actor).claim, f.claim); + assert.equal(ownPromise(returned, f.actor).answer, true); + assert.equal(viewGame(returned, f.actor).battle?.ownForces?.eliteFreeSupport, true); + assert.equal(returned.discard.filter(card => card.id === f.card).length, 1); + assert.equal(returned.players.find(player => player.id === f.actor)!.elites!.tanks, 0); +}); + +void test('the selected Ghola group block agrees with native rejection for an odd-dial or paid-support Yes promise', () => { + for (const kind of ['dial1', 'support'] as const) { + const f = source(kind), game = structuredClone(f.game), view = viewGame(game, f.actor); + const blocked = homeworldRevivalActionBlock(view, f.action); + assert.ok(blocked, `${kind}: the actual selected return needs a visible block.`); + assert.match(blocked, /Truthtrance|promise/i); + assert.ok(view.homeworldRevivalBlocks?.some(row => row.source === 'ghola' && + row.amount === f.action.amount && row.elite === f.action.elite && row.reason === blocked)); + assert.equal(view.ghola.maxForces, 0); assert.equal(view.ghola.eliteRemaining, 0); + const before = structuredClone(game); + assert.throws(() => applyAction(game, f.actor, f.action), error => { + assert.ok(error instanceof Error); + assert.equal(error.message, blocked); + return true; + }); + assert.deepEqual(game, before); + assert.equal(ownPromise(game, f.actor).answer, true); + assert.ok(game.players.find(player => player.id === f.actor)!.hand.some(card => card.id === f.card)); + } +}); + +void test('all four minimal profiles omit blocked Ghola and retain legal required plans before and after an allowed return', () => { + for (const kind of ['dial1', 'support', 'dial2'] as const) { + const f = source(kind); + const start = kind === 'dial2' ? applyAction(f.game, f.actor, f.action) : f.game; + for (const profile of profiles) { + const game = structuredClone(start), view = viewGame(game, f.actor); + view.players.find(player => player.id === f.actor)!.bot = profile; + const actions = botActions(view); + assert.ok(actions.length, `${kind}/${profile}: current legal participation remains available.`); + assert.ok(actions.every(action => action.type !== 'card' || action.card !== f.card), + `${kind}/${profile}: no blocked or already spent Ghola is offered.`); + const plans = actions.filter(action => action.type === 'battlePlan'); + assert.ok(plans.length, `${kind}/${profile}: the live promise has a legal current plan.`); + for (const plan of plans) { + if (kind === 'support') assert.ok(Number(plan.support) >= 1); + else assert.equal(plan.dial, kind === 'dial1' ? 1 : 2); + if (kind === 'dial2') assert.equal(plan.support ?? 0, 0); + const sealed = applyAction(game, f.actor, plan); + assert.ok(sealed.battle?.plans[f.actor]); + assert.equal(ownPromise(sealed, f.actor).answer, true); + } + } + } +}); diff --git a/tests/salusa-ghola-truth.test.ts b/tests/salusa-ghola-truth.test.ts new file mode 100644 index 00000000..a86fb6ff --- /dev/null +++ b/tests/salusa-ghola-truth.test.ts @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, type Action, type Game } from '../game/engine'; +import { + createSalusaGholaTruthFixture as fixture, actSalusaGholaTruth as act, + salusaGholaTruthPlayer as p, salusaGholaTruthInventory as inventory, + salusaGholaTruthPlan as plan, finishSalusaGholaTruthBattle as finish, + type SalusaGholaTruthFixture, +} from './fixture-salusa-ghola-truth'; +import { salusaGholaArmy as army } from './fixture-salusa-ghola'; + +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game,actor,action)); + assert.deepEqual(game,before); +} +function binding(game: Game, f: SalusaGholaTruthFixture) { + assert.deepEqual(game.battle!.truthPromises,[{player:f.actor,asker:f.opponent, + claim:f.claim,answer:f.answer === 'yes'}]); + assert.equal(game.discard.filter(c => c.id === f.truthCard).length,1); + assert.equal(game.truthHistory!.at(-1)!.answer,f.answer); + assert.deepEqual(game.truthHistory!.at(-1)!.question,f.question); + assert.deepEqual(inventory(game),f.base.original); +} +function done(game: Game, f: SalusaGholaTruthFixture, gholaUsed: boolean) { + assert.equal(game.battle,null); + assert.equal(game.lastBattleContext!.winner,f.actor); + assert.equal(game.discard.filter(c => c.id === f.truthCard).length,1); + assert.equal(game.discard.filter(c => c.id === f.card).length,Number(gholaUsed)); + assert.equal(game.truthHistory!.at(-1)!.answer,f.answer); + assert.deepEqual(game.truthHistory!.at(-1)!.question,f.question); + assert.deepEqual(inventory(game),f.base.original); +} + +void test('earned native Truth Yes dial2 permits Salusa Ghola and preserves its original binding through the actual plan', () => { + const f = fixture(); + assert.equal(p(f.base.afterSetup,f.opponent).hand[0].id,f.truthCard); + assert.equal(f.base.beforeDeal.players.every(p => p.hand.length === 0),true); + assert.equal(f.truthActions.filter(s => s.action.type === 'card').length,1); + assert.equal(f.truthActions.filter(s => s.action.type === 'truthAsk').length,1); + assert.equal(f.truthActions.filter(s => s.action.type === 'truthAnswer').length,1); + assert.equal(f.sourceCount,f.sourceActions.length); + assert.deepEqual(f.game.battle!.plans,{}); + binding(f.game,f); + const game = act(structuredClone(f.game),f.actor,f.action); + assert.deepEqual(army(game),{normal:0,elite:2}); + assert.equal(p(game).elites!.tanks,0); + binding(game,f); + const result = finish(f,game); + assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2); + assert.equal(result.afterPlan.battle!.plans[f.actor].support,0); + assert.equal(result.revealed.battle!.truthPromises![0].released,undefined); + done(result.game,f,true); +}); + +void test('earned native Truth No dial1 remains feasible after Salusa Ghola without releasing its aggregate answer', () => { + const f = fixture({claim:{kind:'dial',compare:'eq',value:1},answer:'no'}); + const game = act(structuredClone(f.game),f.actor,f.action); + assert.deepEqual(army(game),{normal:0,elite:2}); + binding(game,f); + const result = finish(f,game,plan(game,2,0)); + assert.equal(result.revealed.battle!.truthPromises![0].answer,false); + assert.equal(result.revealed.battle!.truthPromises![0].released,undefined); + done(result.game,f,true); +}); + +void test('earned native Truth Yes odd1 rejects Salusa Ghola before cost and can finish dial1 before later printed use', () => { + const f = fixture({claim:{kind:'dial',compare:'eq',value:1}}); + binding(f.game,f); + reject(f.game,f.actor,f.action); + assert.deepEqual(army(f.game),{normal:0,elite:1}); + assert.equal(p(f.game).elites!.tanks,1); + assert.ok(p(f.game).hand.some(c => c.id === f.card)); + assert.equal(f.game.discard.some(c => c.id === f.card),false); + const result = finish(f,structuredClone(f.game),plan(f.game,1,0)); + assert.equal(result.afterPlan.battle!.plans[f.actor].dial,1); + done(result.game,f,false); + const returned = act(result.game,f.actor,f.action); + assert.deepEqual(army(returned),{normal:0,elite:2}); + assert.equal(returned.discard.filter(c => c.id === f.card).length,1); + assert.deepEqual(inventory(returned),f.base.original); +}); + +void test('earned native Truth Yes paid support rejects Salusa Ghola while the original supported plan stays legal', () => { + const f = fixture({claim:{kind:'support',compare:'gte',value:1}}); + binding(f.game,f); + reject(f.game,f.actor,f.action); + assert.deepEqual(army(f.game),{normal:0,elite:1}); + assert.equal(p(f.game).elites!.tanks,1); + const result = finish(f,structuredClone(f.game),plan(f.game,2,1)); + assert.equal(result.afterPlan.battle!.plans[f.actor].support,1); + assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2); + done(result.game,f,false); +}); + +void test('earned grouped Truth Yes odd1 or even2 keeps its live even alternative after Salusa Ghola', () => { + const f = fixture({claim:{kind:'or',terms:[ + {kind:'dial',compare:'eq',value:1},{kind:'dial',compare:'eq',value:2}, + ]}}); + const game = act(structuredClone(f.game),f.actor,f.action); + binding(game,f); + assert.deepEqual(army(game),{normal:0,elite:2}); + const result = finish(f,game,plan(game,2,0)); + assert.deepEqual(result.revealed.battle!.truthPromises![0].claim,f.claim); + assert.equal(result.revealed.battle!.truthPromises![0].released,undefined); + done(result.game,f,true); +}); + +void test('earned Prescience odd1 and native Truth Yes even2 can renew the inspection through actual Salusa Ghola', () => { + const f = fixture({inspectionDial:1}); + assert.ok(f.base.inspection); + assert.equal(f.base.inspection.answered.battle!.prescience!.value,1); + assert.equal(f.game.battle!.prescience!.value,1); + binding(f.game,f); + let game = act(structuredClone(f.game),f.actor,f.action); + assert.deepEqual(army(game),{normal:0,elite:2}); + binding(game,f); + assert.equal(Object.hasOwn(game.battle!.prescience!,'value'),false); + assert.equal(game.battle!.preparation?.kind,'prescienceAnswer'); + assert.equal(game.battle!.preparation?.owner,f.actor); + reject(game,f.actor,{type:'prescienceAnswer',value:1}); + game = act(game,f.actor,{type:'prescienceAnswer',value:2}); + assert.equal(game.battle!.prescience!.value,2); + binding(game,f); + const result = finish(f,game,plan(game,2,0)); + done(result.game,f,true); +});