diff --git a/components/face-dance-actions.tsx b/components/face-dance-actions.tsx new file mode 100644 index 00000000..4588c9fd --- /dev/null +++ b/components/face-dance-actions.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { fighterCount } from '@/game/advisors'; +import { validLocation } from '@/game/board'; +import type { Action, GameView } from '@/game/engine'; +import { Button } from './ui/button'; + +export interface FaceDanceActionsProps { + game: GameView; + sources: Record; + sector: number; + busy: boolean; + act: (action: Action) => void; +} + +/** The existing public survivor count and the caller's own selectable forces. */ +export function faceDanceForceChoices(game: GameView) { + const d = game.decision; + const me = game.players.find((player) => player.id === game.me); + const world = + d?.kind === 'faceDance' && d.player === game.me + ? game.combatLocations?.find( + (place) => place.id === d.territory && place.kind === 'homeworld', + ) + : undefined; + const foreign = d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined; + const maximum = + d?.kind === 'faceDance' && d.player === game.me && me + ? world + ? (foreign?.normal ?? 0) + (foreign?.elite ?? 0) + : d.winners + ? game.players.reduce( + (sum, player) => + sum + + (d.winners!.includes(player.id) + ? fighterCount(player, d.territory) + : 0), + 0, + ) + : fighterCount( + game.players.find((player) => player.id === d.winner)!, + d.territory, + ) + : 0; + const availableSources: Record = + d?.kind === 'faceDance' && d.player === game.me && me + ? world + ? me.forces + : { reserves: me.reserves, ...me.forces } + : {}; + return { world, maximum, availableSources }; +} + +/** Hook-free so the actual guarded callbacks can be exercised directly. */ +export function FaceDanceActions({ + game, + sources, + sector, + busy, + act, +}: FaceDanceActionsProps) { + const d = game.decision; + const owner = d?.kind === 'faceDance' && d.player === game.me; + const me = owner ? game.players.find((player) => player.id === game.me) : undefined; + const held = me?.faceDancers?.some( + (card) => !card.revealed && d?.kind === 'faceDance' && card.leader === d.identity, + ); + const { world, maximum, availableSources } = faceDanceForceChoices(game); + const selected = + sources && typeof sources === 'object' && !Array.isArray(sources) + ? Object.entries(sources) + : null; + const validCounts = + selected !== null && + selected.every( + ([key, count]) => + Object.hasOwn(availableSources, key) && + Number.isSafeInteger(count) && + count >= 0 && + count <= availableSources[key], + ); + const total = validCounts ? selected.reduce((sum, [, count]) => sum + count, 0) : null; + const validSources = validCounts && + Number.isSafeInteger(total) && + total! <= maximum; + const revealDisabled = + busy || + !owner || + !me || + !held || + !!(d?.kind === 'faceDance' && d.blocked) || + !validSources || + (!world && + !(d?.kind === 'faceDance' && Number.isSafeInteger(sector) && validLocation(d.territory, sector))); + const declineDisabled = busy || !owner || !me; + return ( + <> + + + + ); +} diff --git a/components/face-dance-decision.tsx b/components/face-dance-decision.tsx index c8577a39..809712b1 100644 --- a/components/face-dance-decision.tsx +++ b/components/face-dance-decision.tsx @@ -2,37 +2,31 @@ import { useState } from 'react'; import { territory, splitLocation } from '@/game/board'; import type { Action, GameView } from '@/game/engine'; -import { fighterCount } from '@/game/advisors'; -import { Button } from './ui/button'; +import { FaceDanceActions, faceDanceForceChoices } from './face-dance-actions'; import { Input } from './ui/input'; -export function FaceDanceDecision({ + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +export function FaceDanceDecision(props: Props) { + const { game } = props; + const d = game.decision; + if ( + d?.kind !== 'faceDance' || + d.player !== game.me || + !game.players.some((player) => player.id === game.me) + ) + return null; + return ; +} + +function FaceDanceChoice({ game: g, act, busy, -}: { - game: GameView; - act: (a: Action) => void; - busy: boolean; -}) { +}: Props) { const d = g.decision; const me = g.players.find((p) => p.id === g.me)!; - const world = - d?.kind === 'faceDance' - ? g.combatLocations?.find( - (place) => place.id === d.territory && place.kind === 'homeworld', - ) - : undefined; - const foreign = - d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined; - const maximum = - d?.kind === 'faceDance' - ? world - ? (foreign?.normal ?? 0) + (foreign?.elite ?? 0) - : d.winners - ? g.players.reduce((sum, player) => sum + - (d.winners!.includes(player.id) ? fighterCount(player, d.territory) : 0), 0) - : fighterCount(g.players.find((player) => player.id === d.winner)!, d.territory) - : 0; + const { world, maximum, availableSources } = faceDanceForceChoices(g); const [sources, setSources] = useState>( world ? {} @@ -48,9 +42,9 @@ export function FaceDanceDecision({ (c) => !c.revealed && c.leader === d.identity, ); const total = Object.values(sources).reduce((sum, n) => sum + n, 0); - const availableSources = world - ? me.forces - : { reserves: me.reserves, ...me.forces }; + const emperorReturn = g.advanced && !!g.homeworlds?.worlds && + g.players.some((player) => player.faction === 'emperor' && + (player.id === d.winner || d.winners?.includes(player.id))); return ( <>

Face Dancer opportunity

@@ -64,6 +58,13 @@ export function FaceDanceDecision({ {d.blocked} )} + {emperorReturn && ( +

+ Provisional return: the Emperor’s normal forces return to Kaitain; + Sardaukar return to Salusa Secundus. This is a reserve return with no + revival fee or income, new battle or additional battle reward. +

+ )} {held ? ( <>

@@ -115,40 +116,11 @@ export function FaceDanceDecision({

{total} / {maximum} replacement forces selected

- ) : (

You have no unrevealed Face Dancer matching the winning leader.

)} - + ); } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 738ab11b..685e6fe7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,34 @@ open mode gates. ## Current checkpoint and work +**Typed Emperor Face Dance return — 26 October. Prototyped / Partial.** +Advanced/Homeworld returns from Arrakis and visitor battles now use existing +native reserve quotes: normal survivors to Kaitain, Sardaukar to Salusa. This +selected composition is visibly provisional. Tleilaxu Arrakis replacements +withdraw actual native stocks; at Tleilax only external replacements add forces. +Occupied-home immunity, original victory/rewards, casualties, one leader death +and aftermath remain. Reveal/decline controls use shared projected choices and +guard their actual callbacks. No revival/income, saved field or mode gate opens. + +Three disjoint native/Homeworld/control lanes and root integration pass +**13 files,118/118**, types/lint/build and **sixteen engine/GameTable SSR/actual +reveal-or-decline callback paths** on unchanged source. Nine genuine public +Advanced E/T/A/Ix/Homeworld routes earn mixed, normal-only and Sardaukar-only +armies through63 setup/shipment/battle actions, then take four continuation +actions through Collection. Seven explicitly controlled conserved visitor routes +use actual battle and return/decline; six finish Collection, while decline stops +before the legitimate next battle. Each retains all47 Treachery and16 Traitor/ +Face Dancer cards. Feedback corrected test expansion/sector/type fixtures and +an unused import; no application sector guard was relaxed. + +Frozen v4 report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-emperor-face-dance-return-proof-v4/`; +journeys/HTML: adjacent `20261026-emperor-face-dance-return-runtime/`. +No saved-room/D1/server, reset/restart, deployment or recovery operation occurs. +SSR/callbacks do not prove DOM/hydration, browser/mobile/deployed acceptance or +complete Homeworlds/modes/combinations. Final assurance remains open. +[Source, physical contract and evidence](HOMEWORLD_REPLACEMENT_RULES.md#typed-emperor-face-dance-return--26-october-2026). + **Remaining structured Truthtrance authoring — 26 October. Prototyped / Partial.** Battle-plan and pure-shipment forms now expose their full existing grouped inputs. Add/wrap/nest, switch AND/OR, remove/collapse and whole-field validation @@ -46,14 +74,15 @@ deployed acceptance. Full Truthtrance, modes/combinations and final assurance remain open. [Battle controls](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026); [shipment controls](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026). -Next concrete independent rule gap: Advanced/Homeworld Face Dance blocks an -Emperor winner's army return. Scope exact typed normal→Kaitain and Sardaukar→ -Salusa return through the existing native reserve helpers, separately from -revival/payment/income. The live guard and both Arrakis/Homeworld return paths -need integration; removing its message alone misroutes elites. See -`faceDanceReturnBlock`, `addNativeReserves`, `game/homeworld-native-reserves.ts` -and [the replacement contract](HOMEWORLD_REPLACEMENT_RULES.md). This next scope -does not establish Basic completion or authorize a custody/recovery campaign. +Next scoped interaction: Planetologist's ordinary extra range for Richese's own +concealed No-Field marker. The live combined-movement fence still rejects it; +the printed marker-as-one-force and ordinary-range rules support a narrow +composition, not a located joint FAQ. Start with marker-only ordinary movement, +existing token/history and native trained-leader setup. Keep gather, flight, +Ornithopter, Guild and Homeworld routes outside that first slice. See +[No-Field source](RICHESE_NO_FIELD_RULES.md) and +[Planetologist contract](PLANETOLOGIST_RULES.md). This scope does not establish +Basic completion or authorize a combination/recovery campaign. **Truthtrance compound current-fact authoring — 26 October. Prototyped / Partial.** The form now exposes two-through-eight-term AND/OR groups and nesting, with diff --git a/docs/HOMEWORLD_REPLACEMENT_RULES.md b/docs/HOMEWORLD_REPLACEMENT_RULES.md index 0d8a017b..0fe462ed 100644 --- a/docs/HOMEWORLD_REPLACEMENT_RULES.md +++ b/docs/HOMEWORLD_REPLACEMENT_RULES.md @@ -1,8 +1,59 @@ # Homeworld replacement: source audit and adapter contract -Audited 9 September 2026. Read alongside [Homeworld rules](HOMEWORLD_RULES.md). -This document authorizes no runtime activation and adds no occupation or Ghola -ruling. No production files changed in this audit. +Original audit 9 September 2026; typed Emperor return follow-up 26 October. +Read alongside [Homeworld rules](HOMEWORLD_RULES.md). The historical source +audit below adds no occupation or Ghola ruling; current execution is recorded +separately here and does not certify full Homeworlds or expansion modes. + +## Typed Emperor Face Dance return — 26 October 2026 + +The Advanced/Homeworld Emperor assignment fence is removed. Both Arrakis and +visitor-Homeworld returns now use the existing native reserve quotes: living +normal survivors return to Kaitain and Sardaukar to Salusa Secundus. Their +actual types, battle casualties and original reserve pools remain distinct. +This destination composition is explicitly provisional under the 14 October +choose-recommended authority, not an express publisher Face Dance assignment. +The authorized supplied rulebook's physical38,A.1–3 retains the original victory, +sends the leader to Tanks without another bounty and returns surviving +non-advisor forces to reserves. E3p10's initial/revival split supports the +selected routing by composition; it does not prescribe historical-origin return. + +`addNativeReserves` commits `quoteNativeReserveDeposit` for Arrakis or +`quoteHomeworldReserveReturn` for the exact visitor pool. Arrakis counters leave +their actual sectors before deposit; a Homeworld return withdraws its exact +typed visitors and deposits to the appropriate native stocks. This is a live +reserve return, with no revival cost, allowance or income. Other winning-side +return targets and original victory/rewards retain their existing contract. +Tleilax's occupied-face protection for its occupier/ally remains authoritative. + +Tleilaxu Arrakis replacements withdraw the selected actual native reserves; +board-source counters move from their original positions. At Tleilax, already +present native reserve selections remain a physical no-op and only selected +external counters add to that pool. Unselected native/third-party forces stay. +Zero replacements and decline remain usable. Leader death, revelation/cycling, +advisor follow-ups and original aftermath are not replayed as another battle. + +The owner has persistent provisional routing guidance and the same reveal/ +decline action shape. Shared projected survivor/source choices drive the +selectors and guarded actual callbacks. No new confirmation, saved field, +rule-module admission, provider/deployment action or revival permission is added. + +**Frozen evidence:** v4 passes13 files,118/118, types/lint/build and sixteen +engine/GameTable SSR/actual action-callback paths. Nine genuine public Advanced +E/T/A/Ix/Homeworld routes earn mixed/normal/Sardaukar armies through63 setup/ +shipment/battle actions and use four continuation actions through Collection. +Seven explicitly controlled conserved visitor routes retain their disclosed +position/card exchange; six return paths reach Collection and decline stops +before a legitimate next battle. All47 Treachery and16 Traitor/Face Dancer cards +remain in each path. Four minimal profiles and invalid/stale/source/ownership +cases have focused test coverage. Feedback corrected fixture expansion, sector +and typing errors plus an unused import; the sector guard remains. + +Report/logs: private `20261026-emperor-face-dance-return-proof-v4/` under +`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent +`20261026-emperor-face-dance-return-runtime/`. No saved-room/D1/server, reset/ +restart, deployment or recovery operation. SSR/callbacks do not certify DOM/ +hydration, browser/mobile/deployed acceptance, complete combinations or modes. ## Sources and explicit rules @@ -32,8 +83,9 @@ Sardaukar and revived Sardaukar go to Salusa, revived normal forces to Kaitain. An expressly permitted transfer can subsequently mix the types. Those placement sentences do not expressly assign forces returned by Face Dance. The [designer's expansion page](https://futurepastimes.com/dune-ecaz-moritani) -supplies no additional replacement ruling. No community compilation was adopted -as an erratum. +supplies no additional replacement ruling. That original audit adopted no +community compilation; the 1 October user authorization of the identified local +Advanced rulebook supersedes its old source restriction, as applied above. ## Physical interpretation and remaining assignment boundary @@ -52,11 +104,11 @@ include Tleilaxu garrisons on other foreign Homeworlds. **Returning winner:** preserve every normal/special identity and return only that winning army, not third parties. A single native Homeworld has an -unambiguous destination. Advanced Emperor's allocation remains unverified: -neither arbitrary player choice, universal Salusa routing for stars, nor -restoration to a historical shipment origin is expressly prescribed for this -effect. Keep this assignment boundary visible rather than presenting a revival -helper's default as a confirmed Face Dance rule. +unambiguous destination. Advanced Emperor's allocation now uses the visible +26 October provisional normal→Kaitain/star→Salusa composition above. The source +still does not expressly prescribe a destination for this effect; this is not +arbitrary player choice, restoration to a historical shipment origin or a claim +that the revival helper proves a publisher Face Dance ruling. **Ixian adapter:** treating substitution as ordinary battle cleanup at a Homeworld is an application of existing combat mechanics, not a new shipment diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2fe40aa5..e4928289 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 26 October 2026 — Typed Emperor Face Dance reserve return + +**Prototyped / Partial.** Remove the Advanced/Homeworld assignment fence and +route actual normal survivors to Kaitain and Sardaukar to Salusa through native +reserve quotes. This selected composition is explicit and provisional, not a +publisher Face Dance destination ruling. Arrakis replacements withdraw real +Tleilaxu native stock; at Tleilax selected native reserves stay put and only +external counters add. Original victory, rewards, casualties, leader death and +aftermath remain, with existing occupied-home immunity. Persistent routing +guidance and shared projected source/survivor choices feed guarded actual +reveal/decline callbacks. No revival fee/income, action, saved field or mode opens. + +Frozen **13-file118/118**, types/lint/build and **sixteen engine/GameTable SSR/ +actual callback paths** pass. Nine genuine public Advanced E/T/A/Ix/Homeworld +routes use63 setup/shipment/battle actions, mixed/normal/Sardaukar survivors and +four continuation actions through Collection. Seven labelled conserved visitor +routes use actual battle and return/decline; six reach Collection, decline stops +before another legal battle. Each retains47 Treachery and16 Traitor/Face Dancer +cards. Native and visitor tests also cover zero/native/external selections, +rejections and four minimal profiles. Feedback corrected fixture expansion, +sector and TypeScript issues plus an unused engine import. The legal sector +check remains; earlier failed reports are retained. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-emperor-face-dance-return-proof-v4/`; +journeys/HTML: adjacent `20261026-emperor-face-dance-return-runtime/`. +No saved-room/D1/server, reset/restart, deployment or recovery operation. +SSR/callbacks do not prove DOM/hydration, browser/mobile/deployed acceptance or +complete Homeworlds/modes/combinations. Final assurance remains due. +[Source and contract](HOMEWORLD_REPLACEMENT_RULES.md#typed-emperor-face-dance-return--26-october-2026). + ## 26 October 2026 — Grouped battle-plan and full pure shipment authoring **Prototyped / Partial.** Deliver the remaining structured Truthtrance form diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3b31856a..e7af0d65 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Typed Emperor Face Dance reserve return — 26 October 2026 + +Under 14 October choose-recommended authority, route Advanced/Homeworld +Emperor's exact normal survivors to Kaitain and Sardaukar to Salusa from either +Arrakis or a visitor Homeworld battle. This is a visible provisional composition +of authorized physical38,A.1–3's reserve return and E3p10's initial/revival split, +not an express Face Dance destination ruling. Preserve occupied-face immunity, +original victory/rewards, actual casualties and one leader death/no new bounty. +No revival cost/allowance/income or historical-origin reconstruction occurs. +Native Tleilaxu replacements use their actual stocks; already-present natives at +Tleilax remain a no-op. [Source and execution](HOMEWORLD_REPLACEMENT_RULES.md#typed-emperor-face-dance-return--26-october-2026). + ## Remaining structured Truthtrance authoring — 26 October 2026 Expose the existing full grouped battle-plan and pure-shipment inputs rather diff --git a/game/engine.ts b/game/engine.ts index aa6a08d9..4ef00867 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -105,7 +105,7 @@ import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateH import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence'; import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer'; import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state'; -import { homeworldForceGroups, quoteHomeworldCustody } from './homeworld-custody'; +import { homeworldForceGroups } from './homeworld-custody'; import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment'; import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment'; import { quoteJunctionTransport } from './junction-transport'; @@ -9311,8 +9311,7 @@ function faceDanceReturnBlock(g: Game, winner: string): string | null { const quote = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, target)); if (quote.blocked) return quote.blocked; } - return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor' - ? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null; + return null; } function homeworldSubstitutionIntegrity(g: Game) { const pending = g.pendingIxSubstitution; @@ -36150,7 +36149,11 @@ function applyActionInner( requireRule(home || validGameLocation(g, decision.territory, sector), 'Choose a sector in the battle territory.'); // This is replacement, not shipment or movement: no transport cost or worm/storm transit. for (const source of selected) { - if (source.key === 'reserves') { if (!home) p.reserves -= source.count; } + if (source.key === 'reserves') { + // Native reserves already at the Homeworld stay there. An Arrakis + // replacement withdraws the actual stock before placing it. + if (!home) withdrawNativeReserves(g, p, source.count, 0); + } else { p.forces[source.key] -= source.count; if (!p.forces[source.key]) delete p.forces[source.key]; @@ -36163,30 +36166,21 @@ function applyActionInner( : winningArmies.map(owner => [owner.id, at(owner, decision.territory)] as const)) .filter(([, count]) => count > 0)); if (home) { - const destination = `homeworld:${winner.faction}`; const external = selected.filter((source) => source.key !== 'reserves').reduce((n, source) => n + source.count, 0); - const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [ - { homeworld: home.id, player: winner.id, withdraw: army, deposit: {normal: 0, elite: 0} }, - { homeworld: destination, player: winner.id, withdraw: {normal: 0, elite: 0}, deposit: army }, - { homeworld: home.id, player: p.id, withdraw: {normal: 0, elite: 0}, deposit: {normal: external, elite: 0} }, - ]); - g.homeworlds!.custody = quote.state; - for (const update of quote.players) { - const owner = getPlayer(g, update.id); - owner.reserves = update.reserves; - if (owner.elites) owner.elites.reserves = update.eliteReserves; - } + // Selected provisional composition: return each physical type to its + // native stock (Emperor normal→Kaitain, Sardaukar→Salusa). This is not + // revival and does not restore a historical shipment origin. + addNativeReserves(g, winner, army.normal + army.elite, army.elite, home.id); + if (external) addNativeReserves(g, p, external, 0); } else { for (const owner of winningArmies) for (const key of Object.keys(owner.forces).filter( (key) => splitLocation(key).territory === decision.territory, )) { - owner.reserves += owner.forces[key]; - if (owner.elites) { - owner.elites.reserves += owner.elites.forces[key] ?? 0; - delete owner.elites.forces[key]; - } + const amount = owner.forces[key], elite = owner.elites?.forces[key] ?? 0; + if (owner.elites) delete owner.elites.forces[key]; delete owner.forces[key]; + addNativeReserves(g, owner, amount, elite); } } const leader = g.players diff --git a/game/homeworld-native-reserves.ts b/game/homeworld-native-reserves.ts index f7d96b69..7eb1ae37 100644 --- a/game/homeworld-native-reserves.ts +++ b/game/homeworld-native-reserves.ts @@ -164,7 +164,8 @@ function nativeReserveDeposits( /** Authorized native-reserve deposits: E3 p.10 restores Advanced Emperor's * normal forces to Kaitain and Sardaukar to Salusa. Used by revival and by the - * Nexus Secret Ally reserve return (user ruling 7 October 2026); it does not + * Nexus Secret Ally reserve return (user ruling 7 October 2026) and selected + * provisional Face Dance return composition (26 October 2026); it does not * validate Tanks, cost, revival allowances or route permission. */ export function quoteNativeReserveDeposit( context: HomeworldCustodyContext, diff --git a/game/reference.ts b/game/reference.ts index e34e33d3..5296ccad 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2224,12 +2224,25 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Tleilaxu take no ordinary traitors. Once everyone else has selected their traitors, shuffle the unused cards and deal Tleilaxu three private Face Dancers.', 'After another faction wins, that winner resolves losses, leader bounties, card choices and tech-token rewards. Tleilaxu then have a separate opportunity to reveal an unrevealed Face Dancer matching the winning leader.', 'Return the winner’s surviving forces to reserves, then replace up to that number with Tleilaxu forces from reserves or other board locations. The winning leader goes to the tanks with no additional bounty. A leader already killed does not die again.', + 'With Advanced Homeworlds, the Emperor’s normal survivors provisionally return to Kaitain and Sardaukar to Salusa Secundus, preserving their exact types and existing casualties. This selected reserve-return composition is not revival and grants no revival income, fee or second reward. The original victory remains the winner’s.', + 'Arrakis replacements withdraw actual Tleilaxu native stocks. On Tleilax, existing native forces remain and selecting those reserves adds no counters; only selected external Arrakis replacements increase that pool. Occupied-Tleilax protection for its occupier or ally still blocks a reveal. Zero replacements or decline remain available.', 'A winning Cheap Hero may be a Face Dancer. Kwisatz Haderach does not protect an accompanying leader against Face Dancing. Karama cannot cancel the reveal or the replacement army.', 'Keep revealed Face Dancers face up until all three have been used. Shuffle those cards back into the unused deck and draw three new ones. During Mentat Pause, you may instead replace one unrevealed Face Dancer; this once-per-turn exchange has a Karama response.', 'Zoal has variable battle strength: he copies the opposing leader disc, without any KH bonus, or counts zero against a Cheap Hero. His death bounty uses that copied strength. His ordinary revival value is three spice before discounts.', 'After completed replacement, advisors left alone at remote sources or a zero-replacement target have a separate BG conversion response. Face Dance remains complete and immune; prevention keeps its returns, replacement, leader death and revelation/cycle, then resumes the original aftermath once.', 'The core setup, reveal sequence, card cycling, Zoal effects and AI controls are tested. Tleilaxu revival commerce also has tested support. Foreign ghola acquisition and buyback controls are also implemented. Their special Karama now also has tested support; its timing and the complete expansion interaction audit are still unfinished, so starts remain labelled previews.', ], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Advanced Emperor typed returns use native reserve quotes from Arrakis and visitor Homeworlds. Actual replacement stocks, existing immunity, original victory and single leader death remain. Broader combinations stay open.' }, + { area: 'Player controls', status: 'Partial', detail: 'Persistent provisional routing, public survivor maximum and own selectable sources feed the original choices and guarded reveal/decline callbacks.' }, + { area: 'AI', status: 'Partial', detail: 'All four minimal profiles use the unblocked projected opportunity and legal native replacement sources; no strategic refinement or calibration.' }, + { area: 'Documentation', status: 'Partial', detail: 'Distinguishes selected normal/Kaitain and Sardaukar/Salusa reserve return from revival, historical origin and a confirmed publisher destination ruling.' }, + { area: 'Verification', status: 'Partial', detail: 'Genuine public Advanced shipment/battle sources, labelled controlled Homeworld visitor cases and control supplements cover the route. Full modes, browser/mobile/deployed and final assurance remain due.', evidence: [ + 'game/homeworld-native-reserves.ts', 'components/face-dance-actions.tsx', 'components/face-dance-decision.tsx', + 'tests/fixture-emperor-face-dance-return.ts', 'tests/emperor-face-dance-return-engine.test.ts', + 'tests/fixture-emperor-face-dance-homeworld.ts', 'tests/emperor-face-dance-homeworld.test.ts', 'tests/emperor-face-dance-return-controls.test.ts', 'docs/HOMEWORLD_REPLACEMENT_RULES.md', + ] }, + ], related: [ 'battle', 'cheap-hero-traitor', diff --git a/tests/emperor-face-dance-homeworld.test.ts b/tests/emperor-face-dance-homeworld.test.ts new file mode 100644 index 00000000..f5b44834 --- /dev/null +++ b/tests/emperor-face-dance-homeworld.test.ts @@ -0,0 +1,152 @@ +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 { + createEmperorFaceDanceHomeworldFixture as fixture, + actEmperorFaceDanceHomeworld as act, + finishEmperorFaceDanceHomeworld as finish, + emperorFaceDanceHomeworldPlayer as player, + emperorFaceDanceHomeworldArmy as army, + emperorFaceDanceHomeworldInventory as inventory, + emperorFaceDanceHomeworldTraitors as traitors, + type EmperorFaceDanceHomeworldFixture as Fixture, + type EmperorFaceDanceHomeworldStep as Step, +} from './fixture-emperor-face-dance-homeworld'; + +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function allowances(game: Game) { + return { + players: game.players.map(p => ({ id: p.id, spice: p.spice, shipped: p.shipped, moved: p.moved, + revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived, + revivalCycle: p.revivalCycle, eliteRevived: p.elites?.revived, battleLosses: p.battleLosses })), + revivalRules: structuredClone(game.revivalRules), revivalRequests: structuredClone(game.revivalRequests), + revivalFreeIncome: structuredClone(game.revivalFreeIncome), pendingRevival: structuredClone(game.pendingRevival), + }; +} +function source(f: Fixture) { + assert.equal(f.lobby.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); + assert.deepEqual(f.setup.expansions, ['ix']); assert.equal(f.afterSetup.status, 'playing'); + assert.equal(f.game.homeworldOccupationPreview, f.afterSetup.homeworldOccupationPreview); + assert.match(f.staging[1], /CONTROLLED/); assert.match(f.staging[1], /not native invasion evidence/); + assert.match(f.staging[2], /CONTROLLED/); assert.equal(f.sourceActionCount, f.actions.length); + assert.ok(f.sourceActionCount > 4); assert.deepEqual(f.rawGame, f.game); + assert.equal(player(f.afterSetup, f.tleilaxu).reserves, 20); + assert.equal(player(f.game, f.emperor).reserves, 17); + assert.equal(player(f.game, f.emperor).elites!.reserves, 5 - f.visitors.elite); + assert.equal(player(f.game, f.emperor).tanks, 0); assert.equal(player(f.game, f.emperor).elites!.tanks, 0); + assert.deepEqual(army(f.game, f.emperor), f.visitors); + assert.deepEqual(army(f.game, f.emperor, 'homeworld:emperor'), { normal: 15 - f.visitors.normal, elite: 0 }); + assert.deepEqual(army(f.game, f.emperor, 'homeworld:emperor:salusa'), { normal: 0, elite: 5 - f.visitors.elite }); + assert.equal(player(f.game, f.tleilaxu).reserves, 10); assert.equal(player(f.game, f.tleilaxu).tanks, 0); + assert.equal(player(f.game, f.tleilaxu).forces['polar_sink:0'], 10); + assert.deepEqual(army(f.game, f.tleilaxu), { normal: 10, elite: 0 }); + assert.equal(player(f.game, f.atreides).tanks, 3); assert.deepEqual(army(f.game, f.atreides), { normal: 0, elite: 0 }); + assert.equal(f.game.decision?.kind, 'faceDance'); assert.equal(f.game.decision?.player, f.tleilaxu); + assert.equal(f.game.pendingFaceDance!.identity, f.winnerLeader); + assert.equal(f.game.lastBattleContext!.winner, f.emperor); assert.equal(f.game.lastBattleContext!.result, 'normal'); + assert.equal(f.beforePreparation.battle!.attacker, f.emperor); + assert.equal(f.beforePreparation.battle!.defender, f.atreides); + assert.equal(player(f.game, f.emperor).leaders.find(disc => disc.id === f.winnerLeader)!.dead, false); + const view = viewGame(f.game, f.tleilaxu); assert.ok(view.decision?.kind === 'faceDance'); + assert.equal(view.decision.blocked ?? null, null); + assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.length, 47); + assert.deepEqual(traitors(f.game), f.originalTraitors); + assert.deepEqual(f.game.players.map(p => ({ player: p.id, ids: p.leaders.map(disc => disc.id).sort() })), f.originalLeaders); +} +function returned(game: Game, f: Fixture, external: number, beforeLeader = player(f.game, f.emperor) + .leaders.find(disc => disc.id === f.winnerLeader)!) { + assert.equal(player(game, f.emperor).reserves, 20); assert.equal(player(game, f.emperor).elites!.reserves, 5); + assert.deepEqual(army(game, f.emperor), { normal: 0, elite: 0 }); + assert.deepEqual(army(game, f.emperor, 'homeworld:emperor'), { normal: 15, elite: 0 }); + assert.deepEqual(army(game, f.emperor, 'homeworld:emperor:salusa'), { normal: 0, elite: 5 }); + assert.equal(player(game, f.emperor).tanks, 0); assert.equal(player(game, f.emperor).elites!.tanks, 0); + assert.deepEqual(player(game, f.emperor).forces, player(f.game, f.emperor).forces); + assert.deepEqual(player(game, f.emperor).elites!.forces, player(f.game, f.emperor).elites!.forces); + assert.equal(player(game, f.tleilaxu).reserves, 10 + external); + assert.deepEqual(army(game, f.tleilaxu), { normal: 10 + external, elite: 0 }); + assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 10 - external); + assert.equal(player(game, f.tleilaxu).tanks, 0); + assert.equal(game.homeworlds!.custody!.visitors[f.territory]?.[f.tleilaxu], undefined); + assert.deepEqual(player(game, f.atreides), player(f.game, f.atreides), 'The actual defeated visitor and its off-battle army stay unchanged'); + assert.deepEqual(game.lastBattleContext, f.originalContext, 'Face Dance preserves original sides, victory, casualties and rewards'); + assert.deepEqual(allowances(game), allowances(f.game), 'Replacement creates no payment, revival quota, income or new revival state'); + const leader = player(game, f.emperor).leaders.find(disc => disc.id === f.winnerLeader)!; + assert.equal(leader.dead, true); assert.equal(leader.deaths, beforeLeader.deaths + 1); + assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true); + assert.equal(game.pendingFaceDance ?? null, null); + assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors); +} +function completed(game: Game, f: Fixture, actions: Step[] = []) { + const done = finish(game, actions); + assert.equal(done.phase, 7); assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null); + assert.equal(done.pendingFaceDance ?? null, null); assert.equal(done.lastBattleContext!.winner, f.emperor); + assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(traitors(done), f.originalTraitors); + reject(done, f.tleilaxu, f.faceDanceAction); return done; +} + +for (const composition of ['mixed', 'normal', 'star'] as const) { + void test(`Advanced controlled ${composition} Emperor visitors return to typed native homes after a real Homeworld victory`, () => { + const f = fixture({ composition }); source(f); const actions: Step[] = []; + const game = act(f.game, f.tleilaxu, f.faceDanceAction, actions); returned(game, f, 3); + const done = completed(game, f, actions); + assert.equal(player(done, f.emperor).reserves, 20); assert.equal(player(done, f.emperor).elites!.reserves, 5); + assert.equal(player(done, f.tleilaxu).reserves, 13); + assert.equal(actions.filter(step => step.action.reveal === true).length, 1); + assert.equal(actions.some(step => step.action.type === 'revive'), false); + }); + + void test(`Advanced controlled ${composition} Emperor visitor victory preserves its army and native caller when Face Dance is declined`, () => { + const f = fixture({ composition }); source(f); + const players = structuredClone(f.game.players), custody = structuredClone(f.game.homeworlds!.custody); + const game = act(f.game, f.tleilaxu, f.faceDanceDecline); + assert.deepEqual(game.players, players); assert.deepEqual(game.homeworlds!.custody, custody); + assert.deepEqual(game.lastBattleContext, f.originalContext); assert.equal(game.pendingFaceDance ?? null, null); + assert.equal(game.decision ?? null, null); assert.equal(game.battle ?? null, null); assert.equal(game.phase, 6); + assert.equal(player(game, f.emperor).leaders.find(disc => disc.id === f.winnerLeader)!.dead, false); + assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false); + assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors); + reject(game, f.tleilaxu, f.faceDanceAction); + }); +} + +void test('Advanced controlled Homeworld reveal allows zero external replacement and treats selected native reserves as a physical no-op', () => { + const f = fixture(); source(f); + for (const action of [f.faceDanceZeroAction, f.faceDanceNativeAction, + { type: 'decision', reveal: true, sources: { reserves: 2, 'polar_sink:0': 1 } } as Action]) { + const external = (action.sources as Record)['polar_sink:0'] ?? 0; + const game = act(f.game, f.tleilaxu, action); returned(game, f, external); completed(game, f); + } +}); + +void test('Advanced controlled Emperor Homeworld reveal rejects foreign owners, excess replacement and unavailable physical sources before mutation', () => { + const f = fixture(); source(f); + reject(f.game, f.emperor, f.faceDanceAction); reject(f.game, f.atreides, f.faceDanceAction); + for (const sources of [ + { 'polar_sink:0': 4 }, { 'polar_sink:0': 11 }, { reserves: 11 }, + { reserves: 2, 'polar_sink:0': 2 }, { 'hagga_basin:12': 1 }, + { 'homeworld:tleilaxu': 1 }, { 'polar_sink:0': -1 }, { 'polar_sink:0': 1.5 }, + ]) reject(f.game, f.tleilaxu, { ...f.faceDanceAction, sources }); + completed(act(f.game, f.tleilaxu, f.faceDanceAction), f); +}); + +void test('all four minimal profiles use legal controls for the controlled Advanced Emperor visitor Homeworld Face Dance', () => { + const f = fixture(); source(f); + for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(f.game, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = profile; + const choices = botActions(view); assert.ok(choices.length); + for (const action of choices) { + const game = act(f.game, f.tleilaxu, action); + if (action.reveal) { + returned(game, f, Object.entries((action.sources ?? {}) as Record).filter(([key]) => key !== 'reserves') + .reduce((sum, [, amount]) => sum + amount, 0)); + completed(game, f); + } else { + assert.deepEqual(game.players, f.game.players); assert.deepEqual(game.homeworlds!.custody, f.game.homeworlds!.custody); + assert.equal(game.pendingFaceDance ?? null, null); assert.equal(game.phase, 6); + } + } + } +}); diff --git a/tests/emperor-face-dance-return-controls.test.ts b/tests/emperor-face-dance-return-controls.test.ts new file mode 100644 index 00000000..8587950a --- /dev/null +++ b/tests/emperor-face-dance-return-controls.test.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { FaceDanceActions, faceDanceForceChoices, type FaceDanceActionsProps } from '../components/face-dance-actions'; +import { FaceDanceDecision } from '../components/face-dance-decision'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; + +// These are controlled entitled projections, supplementing the separate native +// Arrakis and controlled conserved Homeworld engine sources. No pending receipt, +// winning army or private card is installed in an authoritative Game here. +// React renders the stateful parent; direct calls enter only the hook-free leaf. +function projected(world = false): GameView { + const game = createGame('EMPERORFACEDANCECONTROLS', newPlayer('t', 'Tleilaxu', 'tleilaxu'), true, ['ix', 'ecaz']); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('c', 'Ecaz', 'ecaz')); + const view = viewGame(game, 't'); + view.status = 'playing'; view.phase = 6; + // Presence of the public Homeworld table identifies the selected module. Its + // populations are unnecessary to this explanation and never become custody. + view.homeworlds = { worlds: [] }; + const caller = view.players.find(player => player.id === 't')!; + caller.reserves = 5; + caller.forces = { 'arrakeen:10': 2, 'sietch_tabr:14': 3 }; + caller.faceDancers = [{ leader: 'emperor-0', revealed: false }]; + view.players.find(player => player.id === 'e')!.forces = { 'hagga_basin:12': 2, 'hagga_basin:13': 2 }; + view.players.find(player => player.id === 'c')!.forces = { 'hagga_basin:13': 2 }; + view.decision = { + kind: 'faceDance', player: 't', winner: 'e', leader: 'emperor-0', identity: 'emperor-0', + territory: world ? 'homeworld:tleilaxu' : 'hagga_basin', + }; + if (world) view.combatLocations.push({ + id: 'homeworld:tleilaxu', name: 'Tleilax', kind: 'homeworld', native: 't', + forces: { t: { normal: 5, elite: 0 }, e: { normal: 2, elite: 2 }, a: { normal: 1, elite: 0 } }, + }); + return view; +} +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(FaceDanceDecision, { game, busy, act() {} })); +} +function button(html: string, action: 'reveal' | 'decline') { + const found = html.match(new RegExp(`]*data-testid="face-dance-${action}")[^>]*>[\\s\\S]*?<\\/button>`))?.[0]; + assert.ok(found, `The Face Dance ${action} action is present`); + return found; +} +function propsFor(game: GameView, sources: Record = {}): FaceDanceActionsProps { + return { game, sources, sector: 12, busy: false, act() {} }; +} +type ActionButton = { props: { 'data-testid': string; disabled: boolean; onClick: () => void } }; +function click(props: FaceDanceActionsProps, action: 'reveal' | 'decline'): Action[] { + const actions: Action[] = []; + const leaf = FaceDanceActions({ ...props, act(value) { actions.push(value); } }); + const found = (leaf.props.children as ActionButton[]).find(child => child.props['data-testid'] === `face-dance-${action}`); + assert.ok(found); + found.props.onClick(); + return actions; +} +function provisional(html: string) { + assert.match(html, /aria-label="Provisional Emperor Face Dance return"/); + assert.match(html, /Provisional return: the Emperor’s normal forces return to Kaitain; Sardaukar return to Salusa Secundus/); + assert.match(html, /reserve return with no revival fee or income, new battle or additional battle reward/); + assert.doesNotMatch(html, /Confirm|Acknowledge|countdown/); +} + +void test('Advanced Homeworld Emperor returns show persistent provisional typed guidance on Arrakis and a visitor Homeworld', () => { + for (const world of [false, true]) { + const game = projected(world), before = structuredClone(game); + for (let render = 0; render < 3; render++) { + const html = markup(game); provisional(html); + assert.match(html, /Their battle rewards remain theirs/); + assert.match(html, /winner’s 4 remaining forces/); + assert.match(html, /selected winning leader goes to the tanks without a spice bounty/); + assert.match(html, /Arrakeen · sector 10/); + assert.match(html, /Sietch Tabr · sector 14/); + assert.doesNotMatch(button(html, 'reveal'), /\sdisabled=/); + assert.doesNotMatch(button(html, 'decline'), /\sdisabled=/); + if (world) { + assert.match(html, /Emperor won in Tleilax/); + assert.match(html, /native forces already at Tleilax remain there/); + assert.match(html, /0 \/ 4 replacement forces selected/); + assert.doesNotMatch(html, /face-source-reserves|face-sector|Replacement sector/); + } else { + assert.match(html, /Emperor won in Hagga Basin/); + assert.match(html, /face-source-reserves|Replacement sector/); + assert.match(html, /4 \/ 4 replacement forces selected/); + for (const sector of [12, 13]) assert.match(html, new RegExp(`