From 6719245f40365fdee66f9d007befd3cb43957249 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:34:39 +1100 Subject: [PATCH] Connect independent-card battle and arrival reactions --- components/game-table.tsx | 4 +- docs/CURRENT_STATUS.md | 21 ++- docs/MORITANI_ENTRY_TIMING.md | 12 ++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 12 ++ game/engine.ts | 16 +- game/reference.ts | 4 +- tests/basic-mixed-independent-arrival.test.ts | 166 ++++++++++++++++++ tests/fixture-semuta-independent-battle.ts | 103 +++++++++++ tests/semuta-independent-battle.test.ts | 114 ++++++++++++ tests/semuta-independent-legacy.test.ts | 21 +++ 10 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 tests/basic-mixed-independent-arrival.test.ts create mode 100644 tests/fixture-semuta-independent-battle.ts create mode 100644 tests/semuta-independent-battle.test.ts create mode 100644 tests/semuta-independent-legacy.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index e2059311..6afb490f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1282,8 +1282,8 @@ export function GameTable({

Semuta Drug preview: in this independent-card game, Semuta can react to another player discarding Recruits, Hajr, Ghola, Harvester, - Weather Control or Family Atomics. Other discard opportunities are - still being implemented.{' '} + Weather Control, Family Atomics or cards in supported battle cleanup. + Other discard opportunities are still being implemented.{' '} Recruits rules

)} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ea59535e..ae7aed99 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,23 @@ open mode gates. ## Current checkpoint and work +**Basic mixed-family independent-card arrival overlap — 28 October. +Prototyped / Partial.** Selected native families with Ecaz and Moritani can +retain the original Ambassador/Terror queue when independent Ecaz cards are +enabled. Paid entry and both owners' actual token decisions follow the existing +provisional storm order; other optional-module combinations remain separate. +[Scope](MORITANI_ENTRY_TIMING.md#basic-mixed-family-independent-cards--28-october-2026). +The mixed arrival and Semuta related union passes 93 cases, including both +child arrivals and compatibility with an already pending discard. + +**Semuta after battle cleanup in Basic independent cards — 28 October. +Prototyped / Partial.** The supported Richese/classic profile now offers each +actual losing, mandatory winning and selected winning discard batch, including +the independent battle cards, while retaining native combat and card custody. +Earlier pending receipts without a reaction continue automatically. The focused +tests cover real battles, claim/pass, legal AI and compatibility; other discard +producers and roster/module combinations remain separate. + **Fresh No-Field with reserves to a foreign Homeworld — 28 October. Prototyped / Partial.** In the native Basic/Advanced Homeworld preview, Richese can send selected physical reserves with its fresh concealed marker; @@ -168,7 +185,9 @@ published its revision tag and `checkpoint` with digest `sha256:74340835931011be1d0be907c8db688719f05a1a1b949ba0633eff4c5fb55248`. Run 7 was canceled before starting; run 8 for `e529cc2` succeeded and published digest `sha256:9a188718e46adec7ec9e4edc29fe03126c1afe2a349bb426052339a5ee4dce55`. -Run 9 was canceled before starting; run 10 for `1ba9421` is running. +Run 9 was canceled before starting; run 10 for `1ba9421` succeeded and +published digest `sha256:2c403b71b24de97188804c874f621af5b72bc6dd807bbcdcca9cbe7ab420029c`. +Run 11 for `4d769af` is running. Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 5abb35de..31807f98 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -2,6 +2,18 @@ Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not enable combined-faction play or certify an implementation order. +## Basic mixed-family independent cards — 28 October 2026 + +The existing native arrival queue also accepts the independent Ecaz +Treachery Cards in a Basic game with both Ecaz and Moritani and other selected +native families, without other optional modules. A paid entry retains both +eligible token opportunities and the original child-arrival handling. The +selected cards do not create an extra entry or change the provisional +storm-order policy below. Original-action tests exercise both storm orders, +Duke/Robbery and Fremen/Guild child continuations; the child tests select a +needed Ambassador using a custody-preserving packet swap. Wider combinations +and full acceptance remain open. + ## Paired World card entries — 17 October 2026 Original paired native World/cards now retains the same queue with or without diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 3c051994..faae0d46 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,17 @@ # Semuta discard producers and continuation inventory +## Battle cleanup in Basic independent cards — 28 October 2026 + +**Prototyped / Partial.** In the existing Richese/classic independent-card +preview, the losing mandatory batch, winning mandatory batch and the winner's +selected discard batch each offer their actual public cards to Semuta. This +includes played Reinforcements and Harass & Withdraw when discarded, while +cards the winner keeps remain in hand. Combat results, physical costs and +casualties remain committed and each native cleanup resumes once. Earlier +saved discard continuations without an offer continue without gaining a new +one. This ordering is provisional; other discard producers and other +roster/module combinations remain separate. + ## Ordinary specials in Basic independent cards — 28 October 2026 **Prototyped / Partial.** The same fresh Basic Richese/classic independent-card diff --git a/game/engine.ts b/game/engine.ts index 704ce3f4..6160f8c4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9520,9 +9520,16 @@ function semutaOfferSupported( if (continuation.kind === 'thumperDiscard' && (g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 || g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false; + // A discard saved before this preview expansion may already be pending + // without a reaction. Keep its original automatic continuation; newly + // staged offers and already-saved reactions retain the current rules. return (g.semutaPreview === true || g.semutaRecruitsPreview === true && - basicIndependentSemutaProfile(g) && continuation.kind === 'ordinaryCardDiscard' && - ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect)) && + (!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) && + basicIndependentSemutaProfile(g) && + (continuation.kind === 'ordinaryCardDiscard' && + ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || + continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || + continuation.kind === 'battleCleanup' && continuation.source === 'winner')) && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && @@ -23252,7 +23259,7 @@ function ambassadorEffectBlock( ): string | null { if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild') && !bareBasicNativeArrivalOverlap(g)) - return 'This overlapping Ambassador-created arrival is only available in the bare Basic faction preview.'; + return 'This overlapping Ambassador-created arrival is available in the supported Basic faction and independent-card previews.'; if (!implementedAmbassadorEffects.includes(effect)) return 'This Ambassador effect is still being implemented.'; if (effect === 'ecaz') { @@ -23283,7 +23290,8 @@ function bareBasicNativeArrivalOverlap(g: Game): boolean { // selection alone is not an optional phase module or a new arrival effect. return !g.advanced && selectedPhaseModuleRoster(g) && !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && - !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.ecazTreachery; + !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && + (!g.ecazTreachery || selectedIndependentCardsProfile(g)); } function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean { if (bareBasicNativeArrivalOverlap(g)) return true; diff --git a/game/reference.ts b/game/reference.ts index 70355b01..457eff3a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1704,7 +1704,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke for itself or its current reciprocal ally. The token returns to supply and its companions are unchanged. Direct acquisition persists unused until battle use or Moritani acquisition; that shared tenure is provisional. It is distinct from the optional Basic or automatic Advanced new-alliance turn loan. A fresh token entry is required, with no standing transfer action.', 'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.', 'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.', - 'In supported bare Basic games with Ecaz and Moritani, a single entry that earns both an Ambassador and Terror opportunity retains both in their existing provisional storm order, including when other selected native factions are seated. A Fremen relocation or Guild shipment from the Ambassador resolves its own child arrival before the original queue continues. If that child reveals the same Terror token, it cannot trigger again for the original entrant; if it is left hidden, the original opportunity remains. Unsupported module interactions remain guarded.', + 'In supported Basic games with Ecaz and Moritani, a single entry that earns both an Ambassador and Terror opportunity retains both in their existing provisional storm order, including selected native factions and the independent Ecaz cards. A Fremen relocation or Guild shipment from the Ambassador resolves its own child arrival before the original queue continues. If that child reveals the same Terror token, it cannot trigger again for the original entrant; if it is left hidden, the original opportunity remains. Unsupported module interactions remain guarded.', 'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.', 'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.', 'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.', @@ -6606,7 +6606,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.', 'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.', - 'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used Hajr, Ghola, Harvester, Weather Control and Family Atomics can also be claimed; other Semuta discard opportunities in this combination remain unfinished.', + 'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used Hajr, Ghola, Harvester, Weather Control, Family Atomics and cards in supported battle cleanup can also be claimed; other Semuta discard opportunities in this combination remain unfinished.', 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.', 'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.', 'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.', diff --git a/tests/basic-mixed-independent-arrival.test.ts b/tests/basic-mixed-independent-arrival.test.ts new file mode 100644 index 00000000..e74f2fc9 --- /dev/null +++ b/tests/basic-mixed-independent-arrival.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { validateAmbassadors, type AmbassadorEffect } from '../game/ecaz-ambassadors'; +import { fremenAmbassadorMovement } from '../game/fremen-ambassador'; +import { guildAmbassadorShipments } from '../game/guild-ambassador'; +import { finishEcazOccupySetup } from './fixture-ecaz-occupy'; +import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy'; + +const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!; +const quiet = homeworldEcazClean; + +/** Native public setup: Ecaz and Moritani with a selected Ix deck and + * Tleilaxu seat. Only the unopened Spice positions are chosen. */ +function fixture(first: 'ambassador' | 'terror', effect: AmbassadorEffect = 'ecaz', entrant = 'guild') { + let game = createGame('MIXEDARRIVAL', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']); + for (const faction of ['moritani', 'guild', 'tleilaxu'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); }; + if (first === 'ambassador') { + act('ecaz', {type: 'seatPosition', position: 5}); + act('moritani', {type: 'seatPosition', position: 1}); + act('ecaz', {type: 'seatPosition', position: 2}); + } + act('ecaz', {type: 'moduleOption', module: 'ecazTreachery', enabled: true}); + for (const player of game.players) act(player.id, {type: 'ready'}); + act('ecaz', {type: 'start'}); + assert.equal(game.status, 'setup'); + assert.equal(game.ecazTreachery, true); + assert.deepEqual(game.expansions, ['ecaz', 'ix']); + game = finishEcazOccupySetup(game); + const afterSetup = structuredClone(game); + for (let index = 0; index < 3; index++) { + const at = game.spiceDeck.findIndex((card, position) => position >= index && 'territory' in card && !card.discovery); + assert.ok(at >= index); + game.spiceDeck.splice(index, 0, game.spiceDeck.splice(at, 1)[0]); + } + const step = () => { + if (game.phase === 5 && game.active && quiet(game)) act(game.active, {type: 'endMovement'}); + else { + const next = nextHomeworldEcazStep(game); + assert.ok(next, `Native continuation ${game.turn}/${game.phase}/${game.decision?.kind}`); + act(next.actor, next.action); + } + }; + const reach = (predicate: (state: Game) => boolean) => { + for (let count = 0; count < 900 && !predicate(game); count++) step(); + assert.ok(predicate(game), 'The native turn must reach its original decision.'); + }; + reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement'); + const ambassadors = game.ecazAmbassadors!; + const chosen = ambassadors.tokens.find(token => token.effect === effect); assert.ok(chosen); + // Child cases control only the five-token packet, preserving the original + // physical inventory; both actual board placements are still native actions. + if (chosen.zone === 'pool') { + const replaced = ambassadors.tokens.find(token => token.zone === 'supply' && token.effect !== 'ecaz'); + assert.ok(replaced); + const index = ambassadors.cohort.indexOf(replaced.id); assert.ok(index >= 0); + ambassadors.cohort[index] = chosen.id; replaced.zone = 'pool'; chosen.zone = 'supply'; + validateAmbassadors(ambassadors); + } + assert.equal(chosen.zone, 'supply'); + const ambassador = chosen.id; + act('ecaz', {type: 'decision', token: ambassador, territory: 'arrakeen'}); + reach(state => !state.response && state.ecazAmbassadors!.tokens.find(token => token.id === ambassador)!.zone === 'placed'); + reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'moritaniPlacement'); + const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available')!.id; + act('moritani', {type: 'decision', token: terror, territory: 'arrakeen'}); + reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && quiet(state)); + const before = structuredClone(game); + const action: Action = {type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0}; + act(entrant, action); + reach(state => !!state.pendingArrivalOverlap); + assert.equal(game.pendingArrivalOverlap!.first, first); + return {game, before, action, ambassador, terror, afterSetup, entrant}; +} + +for (const first of ['ambassador', 'terror'] as const) + void test(`mixed Basic independent cards preserve ${first}-first native arrival and its one payment`, () => { + const f = fixture(first), seen: string[] = []; + let game = f.game; + assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1); + for (let count = 0; count < 50 && (game.pendingArrivalOverlap || game.pendingAmbassador || + game.pendingTerrorEntry || !quiet(game)); count++) { + let actor: string, action: Action; + if (game.response) { + const next = nextHomeworldEcazStep(game); assert.ok(next); + actor = next.actor; action = next.action; + } else if (game.decision?.kind === 'ecazAmbassador') { + seen.push('ambassador'); actor = 'ecaz'; + action = {type: 'decision', event: game.pendingAmbassador!.event, + trigger: true, choice: 'duke', beneficiary: 'ecaz'}; + } else if (game.decision?.kind === 'moritaniTerror') { + const entry = viewGame(game, 'moritani').terrorEntry!; assert.ok('stage' in entry); + seen.push(`terror:${entry.stage}`); actor = 'moritani'; + action = entry.stage === 'offer' ? {type: 'decision', reveal: true} : {type: 'decision', choice: 'spice'}; + } else throw Error(`Unexpected native overlap continuation: ${game.decision?.kind}`); + game = applyAction(game, actor, action); + } + assert.deepEqual(seen, first === 'ambassador' ? ['ambassador', 'terror:offer', 'terror:robbery'] : + ['terror:offer', 'terror:robbery', 'ambassador']); + const robbery = Math.ceil((seat(f.before, 'guild').spice - 1) / 2); + assert.equal(seat(game, 'guild').spice, seat(f.before, 'guild').spice - 1 - robbery); + assert.equal(seat(game, 'moritani').spice, seat(f.before, 'moritani').spice + robbery); + assert.equal(seat(game, 'guild').reserves, seat(f.before, 'guild').reserves - 1); + assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1); + assert.equal(game.dukeVidal!.controller, 'ecaz'); + assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)!.status, 'removed'); + assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.ambassador)!.zone, 'supply'); + assert.equal(game.active, 'guild'); + assert.throws(() => applyAction(game, 'guild', f.action)); + game = applyAction(game, 'guild', {type: 'endMovement'}); + assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1); + }); + +void test('all four AI profiles can legally respond to a mixed independent-card arrival', () => { + const f = fixture('terror'), actor = f.game.decision!.player; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(f.game, actor); + view.players.find(player => player.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action); + assert.doesNotThrow(() => applyAction(f.game, actor, action)); + } +}); + +for (const effect of ['fremen', 'guild'] as const) + void test(`mixed Basic independent cards resume a real ${effect} Ambassador child before original Terror`, () => { + // Native Guild entrant does not trigger the Guild Ambassador; Tleilaxu + // supplies a separate genuine paid entry for that physical token. + const entrant = effect === 'guild' ? 'tleilaxu' : 'guild'; + const f = fixture('ambassador', effect, entrant); + let game = f.game; + const entry = game.pendingAmbassador; assert.ok(entry); + const before = {spice: seat(game, 'ecaz').spice, reserves: seat(game, 'ecaz').reserves}; + game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, trigger: true, beneficiary: 'ecaz'}); + if (effect === 'fremen') { + assert.equal(game.pendingAmbassador?.stage, 'move'); + const options = fremenAmbassadorMovement(game, 'ecaz').sources; + const source = options.find(row => row.territory !== 'arrakeen' && row.sectors.some(s => s.forces > 0) && + row.destinations.some(d => d.territory === 'arrakeen' && d.sector === 10)); + assert.ok(source, 'An original Ecaz force can reach the triggering stronghold.'); + const sector = source.sectors.find(row => row.forces > 0)!; + game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, + forces: {[sector.key]: 1}, territory: 'arrakeen', sector: 10}); + } else { + assert.equal(game.pendingAmbassador?.stage, 'ship'); + assert.ok(guildAmbassadorShipments(game, 'ecaz').destinations.some(d => d.territory === 'arrakeen' && d.sector === 10)); + game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, amount: 1, elite: 0, + territory: 'arrakeen', sector: 10}); + assert.equal(seat(game, 'ecaz').reserves, before.reserves - 1); + } + assert.equal(seat(game, 'ecaz').spice, before.spice); + assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz'); + assert.equal(game.pendingTerrorEntry?.cause, 'ambassador'); + assert.equal(game.pendingTerrorEntry?.token, f.terror); + game = applyAction(game, 'moritani', {type: 'decision', decline: true}); + assert.equal(game.pendingTerrorEntry?.entrant, entrant); + game = applyAction(game, 'moritani', {type: 'decision', decline: true}); + assert.equal(game.pendingArrivalOverlap, null); + assert.equal(game.pendingAmbassador, null); + assert.equal(game.pendingTerrorEntry, null); + assert.equal(seat(game, entrant).forces['arrakeen:10'], 1); + assert.equal(seat(game, 'ecaz').forces['arrakeen:10'], 1); + }); diff --git a/tests/fixture-semuta-independent-battle.ts b/tests/fixture-semuta-independent-battle.ts new file mode 100644 index 00000000..e260d7ad --- /dev/null +++ b/tests/fixture-semuta-independent-battle.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { splitLocation } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean, + basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador'; + +export { act, clean, inventory, player }; + +/** Original public setup, Richese cache sale, paid shipments and battle. Only + * unopened starting-deck and Spice-card order are selected for the witness. */ +export function createIndependentSemutaBattleFixture(kind: 'loser-reinforcements' | 'loser-harass' | 'winner-mandatory' | 'winner-selected') { + let game = createGame('SEMUTAINDEPENDENTBATTLE', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']); + for (const faction of ['emperor', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const loser = 'atreides', winner = 'emperor', holder = 'guild', location = 'red_chasm:7'; + const perform = (actor: string, action: Action) => { game = act(game, actor, action); }; + const reach = (until: (state: Game) => boolean) => { + for (let count = 0; count < 1800 && !until(game); count++) { + assert.notEqual(game.status, 'finished'); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const reaction = game.pendingTreacheryDiscard.reaction; + const actor = game.players.find(p => !reaction.passed.includes(p.id)); + assert.ok(actor); + perform(actor.id, {type:'semutaPass', event:game.pendingTreacheryDiscard.batch.event}); + } else { + const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `Native step required at ${game.phase}`); + perform(step.actor, step.action); + } + } + assert.ok(until(game), 'The real game must reach the requested boundary.'); + }; + perform(loser, {type:'moduleOption', module:'ecazTreachery', enabled:true}); + for (const seat of game.players) perform(seat.id, {type:'ready'}); + perform(loser, {type:'start'}); + assert.equal(game.semutaRecruitsPreview, true); + const originalInventory = inventory(game); + const cardId = kind === 'loser-harass' ? 'ecaz-harass-withdraw' : 'ecaz-reinforcements'; + const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield); + const reserved = new Set([cardId, shield.id]); + const filler = game.deck.filter(card => !reserved.has(card.id)).slice(0, 2); assert.equal(filler.length, 2); + const starting = kind === 'winner-mandatory' + ? [filler[0].id, cardId, filler[1].id, shield.id] + : [cardId, shield.id, filler[0].id, filler[1].id]; + for (let index = 0; index < starting.length; index++) { + const from = game.deck.findIndex(card => card.id === starting[index]); assert.ok(from >= index); + [game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]]; + } + reach(state => state.status === 'playing'); + assert.ok(player(game, kind === 'winner-mandatory' ? winner : loser).hand.some(card => card.id === cardId)); + if (kind !== 'winner-mandatory') assert.ok(player(game, winner).hand.some(card => card.id === shield.id)); + for (let index = 0; index < 3; index++) { + const from = game.spiceDeck.findIndex((card, pos) => pos >= index && 'territory' in card && !card.discovery); + assert.ok(from >= index); game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]); + } + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); + perform('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID, + method:'onceAround', direction:'counterclockwise'}); + reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome); + for (let count = 0; !player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) { + const lot = game.richeseAuction; assert.ok(lot?.active && !lot.outcome); + perform(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0}); + reach(clean); + } + assert.ok(player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + const shipped = new Set(); + while (shipped.size < 2) { + reach(state => state.phase === 5 && !!state.active && clean(state)); + const actor = game.active!; + if ([loser, winner].includes(actor) && !shipped.has(actor)) { + perform(actor, {type:'ship', ...splitLocation(location), amount:actor === winner ? 4 : 1, elite:0, allyPayment:0}); + reach(clean); shipped.add(actor); + } + perform(actor, {type:'endMovement'}); + } + reach(state => state.phase === 6 && !state.battle && clean(state)); + const actor = game.active!; + const choice = viewGame(game, actor).battleChoices.find(row => row.territory === 'red_chasm' && + [row.attacker, row.defender].includes(loser) && [row.attacker, row.defender].includes(winner)); + assert.ok(choice); + perform(choice.chooser, {type:'chooseBattle', territory:choice.territory, + target:choice.attacker === choice.chooser ? choice.defender : choice.attacker}); + reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && clean(state)); + const strongest = player(game, winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => b.strength-a.strength)[0]; + const weakest = player(game, loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => a.strength-b.strength)[0]; + assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength + 2); + perform(winner, {type:'battlePlan', dial:1, support:0, leader:strongest.id, weapon:null, + defense:kind === 'winner-mandatory' ? cardId : shield.id}); + perform(loser, {type:'battlePlan', dial:0, support:0, leader:weakest.id, + weapon:kind === 'winner-mandatory' ? null : cardId, defense:null}); + let final: {actor:string;action:Action} | undefined; + for (let count = 0; count < 100; count++) { + const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); + if (step.action.type === 'traitorCall') { + const voters = viewGame(game, game.host).battle!.traitorVoters; + if (voters.every(id => id === step.actor || game.battle!.traitorCalls[id] !== undefined)) { final = step; break; } + } + perform(step.actor, step.action); + } + assert.ok(final); + return {game, loser, winner, holder, location, card:cardId, shield:shield.id, originalInventory, + finalActor:final.actor, finalAction:final.action, kind}; +} diff --git a/tests/semuta-independent-battle.test.ts b/tests/semuta-independent-battle.test.ts new file mode 100644 index 00000000..70ab238b --- /dev/null +++ b/tests/semuta-independent-battle.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { type Game, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, clean, createIndependentSemutaBattleFixture as fixture, inventory, player } from './fixture-semuta-independent-battle'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; + +function react(game: Game, holder: string, claim: boolean, selected: string) { + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + const event = pending.batch.event; + if (!claim) { + for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event}); + return game; + } + game = act(game, holder, {type:'semutaCommit', event}); + return game.pendingTreacheryDiscard?.reaction?.stage === 'select' + ? act(game, holder, {type:'semutaSelect', event, card:selected}) : game; +} + +function advance(game: Game, until: (state: Game) => boolean): Game { + for (let count = 0; count < 600 && !until(game); count++) { + if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + game = react(game, '', false, ''); + continue; + } + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.ok(until(game)); return game; +} + +for (const kind of ['loser-reinforcements', 'loser-harass', 'winner-mandatory', 'winner-selected'] as const) + for (const claim of [false, true]) void test(`Basic independent battle ${kind}: ${claim ? 'claim' : 'pass'} without replay`, () => { + const f = fixture(kind); + let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction)); + if (kind === 'winner-selected') { + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory'); + game = react(game, f.holder, false, f.card); + game = advance(game, state => state.decision?.kind === 'battleCards' && state.decision.player === f.winner); + assert.ok(player(game, f.winner).hand.some(card => card.id === f.shield)); + game = act(game, f.winner, {type:'decision', discard:[f.shield]}); + } + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.batch.cause, kind === 'winner-selected' ? 'battle:winner' : + kind === 'winner-mandatory' ? 'battle:winnerMandatory' : 'battle:mandatory'); + const card = kind === 'winner-selected' ? f.shield : f.card; + assert.ok(pending.batch.entries.some(entry => entry.card.id === card)); + const before = structuredClone(game); + const ownTanks = player(before, f.winner).tanks; + const ownForces = player(before, f.winner).forces[f.location] ?? 0; + const casualties = pending.continuation.kind === 'battleResolved' ? pending.continuation.casualties : undefined; + const immediateLoss = casualties?.options.length === 1 + ? casualties.options[0].normal + casualties.options[0].elite : 0; + game = react(game, f.holder, claim, card); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(player(game, f.loser).tanks, player(before, f.loser).tanks); + assert.equal(player(game, f.winner).tanks, ownTanks + immediateLoss); + assert.equal(player(game, f.winner).forces[f.location] ?? 0, + ownForces - immediateLoss); + assert.equal(game.lastBattleContext?.event, before.lastBattleContext?.event); + for (const seat of game.players) assert.equal(seat.spice, player(before, seat.id).spice); + assert.equal(player(game, f.holder).hand.some(held => held.id === card), claim); + assert.equal(game.discard.some(discarded => discarded.id === card), !claim); + assert.equal(game.discard.some(discarded => discarded.id === SEMUTA_DRUG_ID), claim); + assert.deepEqual(inventory(game), f.originalInventory); + assert.throws(() => act(game, f.holder, {type:'semutaCommit', event:pending.batch.event})); + game = advance(game, state => state.phase >= 7 && clean(state)); + assert.equal(game.turn, 1); + assert.equal(player(game, f.winner).tanks, + player(f.game, f.winner).tanks + 1 + (kind === 'winner-mandatory' ? 3 : 0)); + if (kind === 'winner-mandatory') + assert.equal(player(game, f.winner).reserves, player(f.game, f.winner).reserves - 3); + assert.equal(player(game, f.winner).forces[f.location], 3); + }); + +void test('each AI profile may legally answer the native independent battle discard', () => { + const f = fixture('loser-reinforcements'), pending = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction)); + for (const difficulty of DIFFICULTIES) { + const game = structuredClone(pending); + player(game, f.holder).bot = difficulty; + const action = botActions(viewGame(game, f.holder)).find(candidate => + candidate.type === 'semutaCommit' || candidate.type === 'semutaPass'); + assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action)); + } +}); + +void test('an earlier pending battle discard resumes automatically without creating an offer', () => { + const f = fixture('loser-reinforcements'); + const produced = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction)); + assert.equal(produced.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory'); + assert.ok(produced.pendingTreacheryDiscard?.reaction); + // Model a saved automatic continuation created before this battle source was + // admitted to the independent-card Semuta preview; all other facts are real. + const saved = structuredClone(produced); + delete saved.pendingTreacheryDiscard!.reaction; + assert.equal(viewGame(saved, f.holder).automaticContinuationPending, true); + assert.equal(viewGame(saved, f.holder).semutaReaction, null); + const resumed = act(saved, f.holder, {type:'advanceBots'}); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.equal(viewGame(resumed, f.holder).semutaReaction, null); + assert.equal(resumed.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(resumed, f.holder).hand.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(player(resumed, f.loser).tanks, player(saved, f.loser).tanks); + const continuation = saved.pendingTreacheryDiscard!.continuation; + const casualties = continuation.kind === 'battleResolved' ? continuation.casualties : undefined; + const immediateLoss = casualties?.options.length === 1 + ? casualties.options[0].normal + casualties.options[0].elite : 0; + assert.equal(player(resumed, f.winner).tanks, player(saved, f.winner).tanks + immediateLoss); + assert.equal(resumed.lastBattleContext?.event, saved.lastBattleContext?.event); + assert.deepEqual(inventory(resumed), f.originalInventory); +}); diff --git a/tests/semuta-independent-legacy.test.ts b/tests/semuta-independent-legacy.test.ts new file mode 100644 index 00000000..5dac9d35 --- /dev/null +++ b/tests/semuta-independent-legacy.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import { actIndependentOrdinary as act, createIndependentOrdinaryFixture as fixture, + independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player } from './fixture-semuta-independent-ordinary'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; + +void test('an older pending ordinary discard continues without creating a new Semuta offer', () => { + const f = fixture('hajr'); + const played = act(f.game, f.owner, f.play); + assert.ok(played.pendingTreacheryDiscard?.reaction); + const saved = structuredClone(played); + delete saved.pendingTreacheryDiscard!.reaction; + assert.equal(viewGame(saved, f.owner).automaticContinuationPending, true); + const resumed = applyAction(saved, f.owner, {type: 'advanceBots'}); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.ok(resumed.hajr.includes(f.owner)); + assert.ok(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.equal(resumed.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(inventory(resumed), f.originalInventory); +});