From 1bb7a99836546f867d4d6267429e586dcfa447b5 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:49:56 +1100 Subject: [PATCH] Allow Salusa Ghola before battle commitments --- docs/CURRENT_STATUS.md | 21 +++ docs/HOMEWORLD_BATTLE_RUNTIME.md | 2 +- docs/HOMEWORLD_BENEFITS_RULES.md | 52 +++++++ docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md | 4 + docs/HOMEWORLD_OCCUPATION_INTEGRATION.md | 2 +- docs/IMPLEMENTATION_STATUS.md | 25 +++ docs/RULE_DECISIONS.md | 13 ++ game/homeworld-benefits.ts | 21 ++- game/reference.ts | 6 +- tests/fixture-salusa-ghola.ts | 190 +++++++++++++++++++++++ tests/homeworld-salusa-engine.test.ts | 47 +++++- tests/salusa-ghola-runtime.test.ts | 114 ++++++++++++++ 12 files changed, 483 insertions(+), 14 deletions(-) create mode 100644 tests/fixture-salusa-ghola.ts create mode 100644 tests/salusa-ghola-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f3842420..8a6a61fc 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,27 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Salusa Ghola before battle commitments — 27 October. Prototyped / Partial.** +An otherwise legal Sardaukar Ghola return can raise Salusa from one to two +during the Emperor's uncommitted battle. That same battle uses current High +free support; the returned counter stays at Salusa. Once either plan, an +inspection answer or an active battle promise is committed, the existing +preview boundary remains. It is unfinished timing, not an official Ghola ban. +[Source and remaining boundary](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-before-battle-commitments--27-october-2026). + +**Nine files78/78**, types/lint and one native runtime programme pass. The +original public Advanced E/A/G base33/Homeworld source uses51 actions to earn +a paid first-battle star casualty and open a second actual uncommitted battle. +Five further native actions play Ghola, seal both plans and complete both +traitor calls. Eight GameTable SSR renders check current controls and the +separate actual sealed-plan guard branch. Only unopened initial cards, land +Spice positions and scalar lotteries are selected; no later force/purse/hand/ +phase/battle receipt is installed. Stateful Ghola/plan inputs use native +packets, not operated browser controls. Four profiles retain legal plans after +the return; no strategy or difficulty calibration is claimed. Native-Salusa +battle, browser/mobile, deployed and final assurance are not newly certified. +No build was repeated for this focused rule-helper change. + **Occupied Ecaz captive-tenure expiry — 27 October. Prototyped / Partial.** An original occupied grant now retires at public turn end or entitlement loss while Harkonnen keeps the actual captive. Capture, concealment and physical diff --git a/docs/HOMEWORLD_BATTLE_RUNTIME.md b/docs/HOMEWORLD_BATTLE_RUNTIME.md index 2d8c1cbe..8e87f82c 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 existing Salusa Ghola support-timing question also remains gated. 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 battle commitments; the [commitment-free window](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-before-battle-commitments--27-october-2026) now uses actual current population. 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 935fca4e..6a9e2407 100644 --- a/docs/HOMEWORLD_BENEFITS_RULES.md +++ b/docs/HOMEWORLD_BENEFITS_RULES.md @@ -2,6 +2,58 @@ 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 before battle commitments — 27 October 2026 + +An otherwise legal Ghola may return a dead Sardaukar to Salusa during the +Emperor's battle while no plan is sealed and no inspection answer or active +Truthtrance battle promise is committed. Returning the actual counter from +Tanks raises Salusa from one to two; the current High benefit then supplies +free Sardaukar support in that same battle. It does not add that counter to an +Arrakis army, spend ordinary revival allowance, waive the once-per-turn elite +revival limit or bypass a pending response/decision. Existing force controls and +minimal AI use the same projected elite allowance and combat descriptor. + +The user-authorized revised PDF establishes immediate Ghola at any time +(physical39 / printed37), current population and the native return destination +(physical22 / printed20), and strength/spice commitments plus matching casualty +allocation (physical11–12 / printed9–10). Salusa's printed High face supplies +the free support. No new timing priority is introduced around native modal +decisions. A native-Salusa return joins that World's actual pool; its connected +population rules remain current rather than freezing a face for the battle. + +**Remaining preview boundary:** once either plan, an inspection answer or an +active battle promise exists, the population-crossing return remains guarded. +This is an unfinished continuation, not an official prohibition. The source's +identified unofficial impossible-Prescience-answer update (physical41 / +printed39) supports a future explicit update, but does not settle every sealed +dial/support composition. Do not refund support, reopen sealed plans, allow +wasted support or weaken free Sardaukar merely to make an old dial legal. Normal +force and eligible leader targets remain usable under their ordinary gates. + +### Focused evidence + +Nine selected files pass78/78 with types/lint. The public original Advanced +E/A/G base33/Homeworld programme takes51 source actions: paid E4-star shipment, +actual two-star movement and Guild entry create two real battles; a paid first +win puts one actual star in Tanks. In the later uncommitted battle, five native +actions play the originally dealt Ghola, submit plans with free star support +and complete traitor calls. Salusa ends at two, Arrakis receives no revived +counter and the same physical card is discarded once. All four profiles provide +legal plans after that return. The previous paid-support and answered-Prescience +guard cases remain; a new opponent-only sealed case also remains guarded. + +The separate native runtime renders eight GameTables before/after return, +after an actual sealed-plan branch and after completion. This is server +rendering with native action packets, not stateful Ghola/plan input operation +or browser hydration. It does not certify native-Salusa battle chronology, +module combinations, full modes, deployed acceptance or final recovery/privacy/ +custody assurance. No build was repeated for the focused rule-helper change. +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-salusa-ghola-proof-v1/`; +runtime: `20261027-salusa-ghola-runtime/run-v1/` beside it. Frozen source tree +`7d6408588870da018b93bc5a7208d19eb24a4050e023ce9eff3ac3c3227efbba` +contains3350 files. Only this evidence text, the dashboard and implementation +log are final documentation overlays after those checks. + ## Provisional Low Homeworld revival prevention — 25 October 2026 **Prototyped / Partial.** The earlier target guard against Tleilaxu special diff --git a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md index fcd00c4c..255feaba 100644 --- a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md @@ -1,5 +1,9 @@ # Homeworld combat foundation and Salusa support +Current follow-up: [commitment-free Ghola](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-before-battle-commitments--27-october-2026) +can raise Salusa during an ongoing battle. The older whole-battle restriction +below is superseded only before commitments; later repricing remains 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. ## Connected Salusa behavior diff --git a/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md b/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md index ba6c01d9..e16f0a4a 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 during an Emperor battle | Existing unresolved support-transition guard. 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 during a committed Emperor battle | Remaining support-transition preview guard; commitment-free Ghola now uses current population. 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 7e3ac7bd..8a80a12a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,30 @@ # Dune implementation status +## 27 October 2026 — Commitment-free Salusa Ghola + +Remove the whole-battle threshold guard only before either sealed plan, +inspection answer or active battle promise. A real Sardaukar return raises +Salusa from one to two and current High free support applies in the same battle; +the revived piece remains in native reserves. The later timing boundary stays +explicit and provisional. No refunds, sealed-plan changes or weakened free +stars are invented. [Source and evidence](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-before-battle-commitments--27-october-2026). + +Nine-file78/78, types/lint and a separate native runtime pass. An original public +Advanced E/A/G base33/Homeworld51-action prefix earns an actual paid-star loss, +then reaches another live uncommitted battle;5 continuation actions play Ghola, +seal plans and finish both traitor calls. Eight GameTable SSR renders include +an actual sealed-plan guard branch. Initial unopened card/Spice positions and +native scalar lotteries alone are selected; no later played resource, phase, +hand or battle receipt is installed. Stateful card/plan controls use native +packets. Four minimal profiles supply legal plans after the real return. +No build was repeated for the focused rule-helper change; no browser/mobile, +native-Salusa battle, deployed, complete-mode or final assurance is certified. +Private proof `20261027-salusa-ghola-proof-v1/` and runtime +`20261027-salusa-ghola-runtime/run-v1/` are under the existing private checkpoint +directory. Frozen tree `7d6408588870da018b93bc5a7208d19eb24a4050e023ce9eff3ac3c3227efbba` +contains3350 files; final evidence overlays are limited to this log, +`CURRENT_STATUS.md` and `HOMEWORLD_BENEFITS_RULES.md`. + ## 27 October 2026 — Occupied Ecaz captive-tenure expiry Public deadline/entitlement loss retires only the original occupied grant while diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ab73e159..747ab4cc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,18 @@ # Rule decision index +## Salusa Ghola commitment-free battle window — 27 October 2026 + +Remove the whole-battle population-crossing restriction before any sealed plan, +inspection answer or active battle promise. Actual revival raises current native +Salusa population and the existing combat quote uses its new High free support; +ordinary modal priorities and elite revival limits still apply. Retain the +post-commitment guard as an unfinished preview boundary, not a publisher ban. +The authorized PDF allows immediate Ghola/current population and separately +requires dial/spice-compatible losses; it supplies no general refund, wasted +support or sealed-plan rewrite. Its identified unofficial impossible-Prescience +answer update supports a later continuation rather than bypassing those rules. +[Source and boundary](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-before-battle-commitments--27-october-2026). + ## Occupied Ecaz grant expiry during captivity — 27 October 2026 Extend the selected provisional one-battle/turn/qualification tenure through diff --git a/game/homeworld-benefits.ts b/game/homeworld-benefits.ts index bdf6230f..c496da9f 100644 --- a/game/homeworld-benefits.ts +++ b/game/homeworld-benefits.ts @@ -1,6 +1,7 @@ import type { Game } from './engine'; import { homeworldContext } from './homeworld-game'; import { homeworldPopulations } from './homeworld-population'; +import { committedPlanElements } from './battle-inspections'; type HomeworldBenefitContext = Pick< Game, @@ -59,9 +60,10 @@ export function homeworldSardaukarFreeSupport( ); } -/** Temporary release boundary: a population-changing return during combat - * must not invalidate sealed dials/support or earlier binding answers. Use - * public battle participation, never an opponent's hidden plan contents. */ +/** Current population can change before battle commitments. The remaining + * preview boundary protects sealed dials/support and binding answers 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( context: HomeworldBenefitContext & Pick, playerId: string, @@ -74,7 +76,7 @@ export function homeworldSardaukarGholaBlock( !context.players.some((p) => p.id === playerId && p.faction === 'emperor') ) return null; - return homeworldPopulations( + const crossing = homeworldPopulations( homeworldContext(context), context.homeworlds.custody, ).some( @@ -82,8 +84,15 @@ export function homeworldSardaukarGholaBlock( home.native === playerId && home.card === 'salusa_secundus' && home.population === 1, - ) - ? 'Ghola raising Salusa to high population during the Emperor’s battle awaits its battle-support timing ruling.' + ); + if (!crossing) return null; + const battle = context.battle; + const committed = battle.revealed || Object.keys(battle.plans).length > 0 || + [battle.attacker, battle.defender].some( + (id) => committedPlanElements(battle, id).length > 0, + ) || battle.truthPromises?.some((promise) => !promise.released); + return committed + ? 'Ghola raising Salusa to high population after a battle plan or binding answer is not yet supported. Finish this battle first.' : null; } diff --git a/game/reference.ts b/game/reference.ts index 36dcc653..a9ef9c5c 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. 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 now raise Salusa during a commitment-free battle; sealed/inspected/promised 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 unresolved special-Karama and mid-battle Ghola timing boundaries. 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 post-commitment Ghola timing boundaries; before commitments, a real Salusa return updates the shared free-support descriptor. 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'], }, { @@ -5620,6 +5620,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/homeworld-actions-engine.test.ts', 'tests/homeworld-basic-counters.test.ts', 'tests/homeworld-salusa-engine.test.ts', + 'tests/fixture-salusa-ghola.ts', + 'tests/salusa-ghola-runtime.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.ts b/tests/fixture-salusa-ghola.ts new file mode 100644 index 00000000..f600af65 --- /dev/null +++ b/tests/fixture-salusa-ghola.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { treacheryDeck } from '../game/cards'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { actFamilyAtomics } from './fixture-family-atomics'; +import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type SalusaGholaStep = StrongholdFactionsNativeStep; +export const actSalusaGhola = actFamilyAtomics; +export const salusaGholaPlayer = (game: Game, actor = 'emperor') => { + const p = game.players.find(p => p.id === actor); assert.ok(p); return p; +}; +export const salusaGholaClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment; +export function salusaGholaArmy(game: Game) { + const home = homeworldForceGroups(homeworldContext(game),game.homeworlds!.custody!) + .find(home => home.id === 'homeworld:emperor:salusa'); assert.ok(home); + return structuredClone(home.forces.emperor); +} +export function salusaGholaInventory(game: Game) { + homeworldGameIntegrity(game); + const cards = [...game.deck,...game.discard,...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin === 'normal')) ?? [])] + .map(c => c.id).sort(); + assert.deepEqual(cards,treacheryDeck([]).map(c => c.id).sort()); + const traits = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort(); + const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(c => JSON.stringify(c)).sort(); + return {cards,traitors:traits,spice}; +} + +/** Native controls only, including current ordered Charity/Revival. A live + * unsealed plan and Ghola declaration remain caller choices. */ +export function nextSalusaGhola(game: Game): SalusaGholaStep|null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + if (game.decision?.kind === 'guildTiming') + return {actor:game.decision.player,action:{type:'decision',take:false}}; + if (game.decision?.kind === 'caladanReinforcement') + return {actor:game.decision.player,action:{type:'decision',event:game.decision.event,decline:true}}; + if (game.decision?.kind === 'battleLosses' && game.decision.player === 'emperor') { + const choice = game.decision.options.findIndex(loss => loss.normal === 0 && loss.elite === 1); + assert.ok(choice >= 0,'The actual dial must offer exactly one Sardaukar casualty.'); + return {actor:'emperor',action:{type:'decision',choice}}; + } + if (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) { + const actor = [game.battle.attacker,game.battle.defender] + .find(id => !game.battle!.outcomeWindow!.ready.includes(id)); assert.ok(actor); + return {actor,action:{type:'battleOutcomeReady',event:game.battle.outcomeWindow.event}}; + } + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceSalusaGhola(game: Game, until: (game: Game) => boolean, + actions: SalusaGholaStep[] = []): Game { + for (let n = 0; n < 1300; n++) { + if (until(game)) return game; + assert.notEqual(game.status,'finished'); + const next = nextSalusaGhola(game); + assert.ok(next,`Choose the current native Ghola/plan action at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSalusaGhola(game,next.actor,next.action,actions); + } + assert.fail('The genuine Salusa/Ghola source did not reach its finite boundary.'); +} +const planReady = (game: Game) => salusaGholaClean(game) && !!game.battle && + !game.battle.revealed && !game.battle.preparation && + (!game.battle.preLeader || game.battle.preLeader.closed); +function livingLeader(game: Game, actor: string, strongest: boolean) { + const leaders = salusaGholaPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy); + leaders.sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength); + assert.ok(leaders[0]); return leaders[0].id; +} + +/** Earned public one-turn source. Initial hands/Spice order are selected only + * while unopened; native setup, payments, typed shipments, split movement and + * the earlier combat casualty create every later resource and battle field. */ +export function createSalusaGholaFixture() { + const actor = 'emperor', atreides = 'atreides', guild = 'guild'; + let game = createGame('SALUSAGHOLA',newPlayer(actor,'Emperor','emperor'),true); + joinGame(game,newPlayer(atreides,'Atreides','atreides')); + joinGame(game,newPlayer(guild,'Guild','guild')); + const initial = structuredClone(game), actions: SalusaGholaStep[] = []; + const act = (owner: string, action: Action) => {game = actSalusaGhola(game,owner,action,actions);}; + const reach = (until: (game: Game) => boolean) => {game = advanceSalusaGhola(game,until,actions);}; + act(actor,{type:'homeworlds',enabled:true}); + for (const p of game.players) act(p.id,{type:'ready'}); + const lobby = structuredClone(game); + act(actor,{type:'start',advancedPreview:true}); + 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 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}; + }); + const beforeDeal = structuredClone(game); + reach(g => g.status === 'playing'); + const afterSetup = structuredClone(game), setupActions = structuredClone(actions); + assert.deepEqual(game.players.flatMap(p => p.hand.map(c => c.id)),selected.map(c => c.id)); + assert.deepEqual(salusaGholaArmy(game),{normal:0,elite:5}); + const original = salusaGholaInventory(game); + const spiceOrder = [0,1].map(position => { + const from = game.spiceDeck.findIndex((card,index) => index >= position && + 'territory' in card && !card.discovery && card.territory !== 'imperial_basin'); + assert.ok(from >= position); const [card] = game.spiceDeck.splice(from,1); + game.spiceDeck.splice(position,0,card); return {position,from,card:structuredClone(card)}; + }); + reach(g => g.phase === 5 && salusaGholaClean(g)); + const shipments: {before:Game;after:Game;steps:SalusaGholaStep[]}[] = []; + let movement: {before:Game;after:Game;steps:SalusaGholaStep[]}|undefined; + for (let n = 0; game.phase === 5 && n < 200; n++) { + if (!salusaGholaClean(game)) { + const next = nextSalusaGhola(game); assert.ok(next); act(next.actor,next.action); continue; + } + const owner = game.active!; + if (owner === actor || owner === guild) { + const before = structuredClone(game), start = actions.length; + const amount = owner === actor ? 4 : 1, elite = owner === actor ? 4 : 0; + const homeworldSources = nativeShipmentSources(viewGame(game,owner),amount,elite); assert.ok(homeworldSources); + act(owner,{type:'ship',territory:'imperial_basin',sector:9,amount,elite,allyPayment:0,homeworldSources}); + reach(salusaGholaClean); + shipments.push({before,after:structuredClone(game),steps:structuredClone(actions.slice(start))}); + if (owner === actor) { + const beforeMove = structuredClone(game), at = actions.length; + act(owner,{type:'move',from:'imperial_basin:9',territory:'arrakeen',sector:10,amount:2,elite:2}); + reach(salusaGholaClean); + movement = {before:beforeMove,after:structuredClone(game),steps:structuredClone(actions.slice(at))}; + } + } + act(owner,{type:'endMovement'}); + } + assert.ok(movement); assert.equal(game.phase,6); + reach(g => salusaGholaClean(g) && !g.battle && !!g.active); + const beforeFirstBattle = structuredClone(game), firstTerritory = game.active === guild ? 'imperial_basin' : 'arrakeen'; + const firstOpponent = firstTerritory === 'arrakeen' ? atreides : guild; + const firstChooser = game.active!; + assert.ok(viewGame(game,firstChooser).battleChoices.some(c => c.territory === firstTerritory)); + const firstAt = actions.length; + act(firstChooser,{type:'chooseBattle',territory:firstTerritory,target:firstChooser === actor ? firstOpponent : actor}); + const firstPreparation = structuredClone(game); + reach(planReady); + const firstPlans = {[actor]:{leader:livingLeader(game,actor,true),dial:2,support:1}, + [firstOpponent]:{leader:livingLeader(game,firstOpponent,false),dial:0,support:0}}; + const firstBeforePlans = structuredClone(game); + for (const owner of [game.battle!.attacker,game.battle!.defender]) + act(owner,{type:'battlePlan',...firstPlans[owner]}); + reach(g => !g.battle && salusaGholaClean(g)); + const firstResolution = structuredClone(game), firstActions = structuredClone(actions.slice(firstAt)); + assert.equal(game.lastBattleContext?.winner,actor); + assert.equal(salusaGholaPlayer(game).elites!.tanks,1); + assert.deepEqual(salusaGholaArmy(game),{normal:0,elite:1}); + const secondTerritory = firstTerritory === 'arrakeen' ? 'imperial_basin' : 'arrakeen'; + const opponent = firstOpponent === atreides ? guild : atreides; + reach(g => !g.battle && salusaGholaClean(g) && !!g.active); + const beforeSecondBattle = structuredClone(game), chooser = game.active!; + assert.ok(viewGame(game,chooser).battleChoices.some(c => c.territory === secondTerritory)); + act(chooser,{type:'chooseBattle',territory:secondTerritory,target:chooser === actor ? opponent : actor}); + const preparation = structuredClone(game); + reach(planReady); + assert.deepEqual(game.battle!.plans,{}); + assert.ok(salusaGholaPlayer(game).hand.some(c => c.id === ghola.id)); + assert.equal(salusaGholaPlayer(game).elites!.tanks,1); + assert.equal(viewGame(game,actor).battle!.ownForces!.elite,2); + assert.deepEqual(salusaGholaInventory(game),original); + return {game,source:structuredClone(game),actor,card:ghola.id, + action:{type:'card',card:ghola.id,amount:1,elite:1} as Action,opponent, + territory:secondTerritory,firstTerritory,firstOpponent,firstPlans, + initial,lobby,setup,beforeDeal,afterSetup,dealOrder,spiceOrder,original,setupActions, + shipments,movement,beforeFirstBattle,firstPreparation,firstBeforePlans,firstResolution,firstActions, + beforeSecondBattle,preparation,actions,sourceActions:actions,sourceCount:actions.length, + disclosure:'Original public Advanced E/A/G with Homeworlds enabled before Ready/start, base33 and no optional modules. Only unopened starting cards, two land Spice positions and scalar lotteries selected. Real typed E4stars desert shipment costs8 and leaves Salusa1; real two-star movement creates Arrakeen contest, and real Guild1 shipment creates Basin contest. The first actual E win uses paid support1/dial2 and kills one real Sardaukar. Stop at the other actual live unsealed battle with Ghola held, Salusa1, tanks1star and two fighting stars. No later phase, force, purse, Tank, hand, battle commitment or receipt is installed.'}; +} +export type SalusaGholaFixture = ReturnType; + +export function playSalusaGholaBattle(f: SalusaGholaFixture, source = f.game) { + let game = structuredClone(source); const actions: SalusaGholaStep[] = []; + const before = structuredClone(game); + game = actSalusaGhola(game,f.actor,f.action,actions); + game = advanceSalusaGhola(game,planReady,actions); + const returned = structuredClone(game), leader = livingLeader(game,f.actor,true); + const enemy = livingLeader(game,f.opponent,false); + game = actSalusaGhola(game,f.actor,{type:'battlePlan',leader,dial:2,support:0},actions); + const afterPlan = structuredClone(game); + game = actSalusaGhola(game,f.opponent,{type:'battlePlan',leader:enemy,dial:0,support:0},actions); + game = advanceSalusaGhola(game,g => !g.battle && salusaGholaClean(g),actions); + return {game,before,returned,afterPlan,actions}; +} diff --git a/tests/homeworld-salusa-engine.test.ts b/tests/homeworld-salusa-engine.test.ts index 01ff2c7c..51b714fa 100644 --- a/tests/homeworld-salusa-engine.test.ts +++ b/tests/homeworld-salusa-engine.test.ts @@ -343,10 +343,29 @@ function gholaThresholdBattle(normal = 1) { return { g, card: card.id }; } -void test('unsupported Ghola crossing of Salusa during battle rejects before and after a sealed paid-star plan without corrupting the saved decision', () => { +void test('Ghola can raise Salusa before battle commitments and the same battle uses current free Sardaukar support', () => { + const { g: before, card } = gholaThresholdBattle(); + assert.equal(homeworldSardaukarGholaBlock(before, 'e'), null); + assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 1); + const done = applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 }); + assert.equal(done.homeworlds!.custody!.salusa!.elite, 2); + assert.equal(emperor(done).elites!.tanks, 0); + assert.equal(emperor(done).elites!.revived, 1); + assert.equal(done.discard.filter((held) => held.id === card).length, 1); + assert.equal(viewGame(done, 'e').battle!.ownForces!.eliteFreeSupport, true); + assert.equal(viewGame(done, 'e').battle!.ownForces!.elite, 3); + assert.equal(emperor(done).spice, 3); + const resolved = resolve(done, 6); + assert.equal(emperor(resolved).forces['arrakeen:10'], 1); + assert.equal(emperor(resolved).spice, 5, 'Only automatic Arrakeen collection adds spice.'); + assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 2); + inventory(resolved); +}); + +void test('the remaining Salusa preview guard protects a sealed paid-star plan until its actual battle completes', () => { const { g: before, card } = gholaThresholdBattle(); const sealed = applyAction(before, 'e', plan(before, 6, 3)); - for (const state of [before, sealed]) { + for (const state of [sealed]) { const g = reload(state); const reason = homeworldSardaukarGholaBlock(g, 'e'); assert.ok( @@ -395,9 +414,11 @@ void test('unsupported Ghola crossing of Salusa during battle rejects before and inventory(g); }); -void test('the temporary Salusa threshold gate leaves normal-force and eligible-leader Ghola choices available during battle', () => { +void test('the Salusa commitment guard leaves normal-force and eligible-leader Ghola choices available during battle', () => { for (const kind of ['normal', 'leader'] as const) { - const { g: before, card } = gholaThresholdBattle(); + const fixture = gholaThresholdBattle(); + const before = applyAction(fixture.g, 'e', plan(fixture.g, 6, 3)); + const card = fixture.card; const player = emperor(before); let command: Action; let leaderId: string | undefined; @@ -434,6 +455,24 @@ void test('the temporary Salusa threshold gate leaves normal-force and eligible- } }); +void test('an opponent-only sealed plan keeps the remaining Salusa timing boundary visible before Emperor submits', () => { + const fixture = gholaThresholdBattle(); + const g = applyAction(fixture.g, 'a', { + type: 'battlePlan', dial: 0, support: 0, leader: atreides(fixture.g).leaders[0].id, + }); + assert.equal(g.battle!.plans.e, undefined); + assert.ok(g.battle!.plans.a); + const view = viewGame(g, 'e'); + assert.deepEqual(view.battle!.plans, {}); + assert.equal(view.ghola.eliteRemaining, 0); + assert.match(view.ghola.eliteBlock!, /not yet supported/); + const before = structuredClone(g); + assert.throws(() => applyAction(g, 'e', { + type: 'card', card: fixture.card, amount: 1, elite: 1, + }), /not yet supported/); + assert.deepEqual(g, before); +}); + void test('Salusa threshold protection also preserves an answered Prescience dial before either full plan is sealed', () => { const fixture = gholaThresholdBattle(0); const card = fixture.card; diff --git a/tests/salusa-ghola-runtime.test.ts b/tests/salusa-ghola-runtime.test.ts new file mode 100644 index 00000000..6bd50dc5 --- /dev/null +++ b/tests/salusa-ghola-runtime.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + createSalusaGholaFixture, playSalusaGholaBattle, actSalusaGhola as act, + salusaGholaPlayer as p, salusaGholaArmy as army, + salusaGholaInventory as inventory, type SalusaGholaFixture, +} from './fixture-salusa-ghola'; + +let earned: SalusaGholaFixture|undefined; +const fixture = () => earned ??= createSalusaGholaFixture(); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game,actor,action)); + assert.deepEqual(game,before); +} + +void test('earned public Salusa source has one actual Sardaukar casualty and a later live commitment-free battle', () => { + const f = fixture(); + assert.equal(f.initial.status,'lobby'); + assert.deepEqual(f.initial.players.map(p => p.faction),['emperor','atreides','guild']); + assert.ok(f.lobby.homeworlds); assert.equal(f.setup.status,'setup'); + assert.ok(f.beforeDeal.players.every(p => p.hand.length === 0)); + assert.equal(f.afterSetup.advanced,true); + assert.deepEqual(f.afterSetup.expansions,[]); + assert.ok(!f.afterSetup.nexusCards && !f.afterSetup.leaderSkills && !f.afterSetup.discoveryEnabled); + assert.equal(p(f.afterSetup).hand[0].id,f.card); + assert.deepEqual(army(f.afterSetup),{normal:0,elite:5}); + assert.equal(f.original.cards.length,33); + assert.equal(f.sourceCount,f.sourceActions.length); + const ship = f.shipments.find(s => s.steps[0].actor === f.actor)!; + assert.ok(ship); + assert.equal(ship.steps[0].action.type,'ship'); + assert.equal(ship.steps[0].action.amount,4); + assert.equal(ship.steps[0].action.elite,4); + assert.equal(p(ship.before).spice-p(ship.after).spice,8); + assert.deepEqual(army(ship.after),{normal:0,elite:1}); + assert.equal(f.movement.steps[0].action.type,'move'); + assert.equal(f.movement.steps[0].action.amount,2); + assert.equal(f.movement.steps[0].action.elite,2); + assert.ok(f.firstActions.filter(s => s.action.type === 'battlePlan').length === 2); + assert.equal(f.firstResolution.lastBattleContext!.winner,f.actor); + assert.equal(p(f.firstResolution).tanks,1); + assert.equal(p(f.firstResolution).elites!.tanks,1); + assert.equal(p(f.firstBeforePlans).spice-p(f.firstResolution).spice,1); + assert.equal(f.game.battle!.territory,f.territory); + assert.deepEqual(f.game.battle!.plans,{}); + assert.equal(f.game.battle!.revealed,false); + assert.equal(f.game.truthtrance ?? null,null); + assert.equal(f.game.turnActionPromises,undefined); + assert.equal(viewGame(f.game,f.actor).battle!.ownForces!.elite,2); + assert.equal(viewGame(f.game,f.actor).battle!.ownForces!.eliteFreeSupport,undefined); + assert.deepEqual(army(f.game),{normal:0,elite:1}); + assert.deepEqual(inventory(f.game),f.original); + assert.match(f.disclosure,/Original public.*No later phase, force, purse, Tank, hand, battle commitment or receipt is installed/); +}); + +void test('actual uncommitted-battle Ghola returns the dead Sardaukar to Salusa2 and immediately changes the native support view', () => { + const f = fixture(), before = structuredClone(f.game); + reject(before,f.opponent,f.action); + const game = act(before,f.actor,f.action), player = p(game); + assert.deepEqual(army(game),{normal:0,elite:2}); + assert.equal(player.tanks,p(before).tanks-1); + assert.equal(player.elites!.tanks,p(before).elites!.tanks-1); + assert.equal(player.reserves,p(before).reserves+1); + assert.equal(player.elites!.reserves,p(before).elites!.reserves+1); + assert.equal(player.spice,p(before).spice); + assert.deepEqual(player.forces,p(before).forces); + assert.deepEqual(player.elites!.forces,p(before).elites!.forces); + assert.deepEqual(game.battle!.plans,{}); + assert.equal(game.battle!.event,before.battle!.event); + assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,true); + assert.equal(viewGame(game,f.actor).battle!.ownForces!.elite,2); + assert.equal(game.discard.filter(c => c.id === f.card).length,1); + assert.equal(player.hand.some(c => c.id === f.card),false); + reject(game,f.actor,f.action); + assert.deepEqual(inventory(game),f.original); +}); + +void test('native plans and calls after Salusa Ghola use zero-spice support and settle actual star casualties', () => { + const f = fixture(), result = playSalusaGholaBattle(f); + assert.deepEqual(army(result.returned),{normal:0,elite:2}); + assert.equal(result.afterPlan.battle!.plans[f.actor].support,0); + assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2); + assert.equal(p(result.afterPlan).spice,p(result.returned).spice); + assert.equal(result.game.lastBattleContext!.winner,f.actor); + assert.equal(result.game.lastBattleContext!.territory,f.territory); + assert.equal(result.game.battle,null); + assert.equal(p(result.game).elites!.tanks,1); + assert.deepEqual(army(result.game),{normal:0,elite:2}); + assert.equal(result.actions.filter(s => s.action.type === 'battlePlan').length,2); + assert.equal(result.actions.filter(s => s.action.type === 'traitorCall').length,2); + assert.equal(result.game.discard.filter(c => c.id === f.card).length,1); + assert.deepEqual(inventory(result.game),f.original); +}); + +void test('all four minimal policies propose legal native plans after the actual Salusa Ghola return', () => { + const f = fixture(), returned = act(structuredClone(f.game),f.actor,f.action); + for (const profile of ['Easy','Medium','Hard','Brutal'] as const) { + const game = structuredClone(returned), view = viewGame(game,f.actor); + view.players.find(p => p.id === f.actor)!.bot = profile; + const plans = botActions(view).filter(action => action.type === 'battlePlan'); + assert.ok(plans.length,`${profile} needs a legal current Battle Plan.`); + for (const plan of plans) { + assert.equal(plan.support ?? 0,0); + const next = act(game,f.actor,plan); + assert.ok(next.battle!.plans[f.actor]); + assert.deepEqual(army(next),{normal:0,elite:2}); + assert.equal(next.discard.filter(c => c.id === f.card).length,1); + assert.deepEqual(inventory(next),f.original); + } + } +});