From 58303654e8eef3773d0d57b69a0d88db742a3cbc Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:07:55 +1100 Subject: [PATCH] Extend Semuta ordinary discards to native factions --- docs/CURRENT_STATUS.md | 21 +- docs/MORITANI_TERROR_RULES.md | 13 ++ docs/RULE_DECISIONS.md | 6 + docs/SEMUTA_DISCARD_CONTINUATIONS.md | 10 + game/engine.ts | 4 +- game/reference.ts | 4 +- tests/great-maker-atomics-ride.test.ts | 86 ++++++++ ...semuta-independent-native-ordinary.test.ts | 185 ++++++++++++++++++ 8 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 tests/great-maker-atomics-ride.test.ts create mode 100644 tests/semuta-independent-native-ordinary.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2e93bb87..ad765115 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,18 @@ open mode gates. ## Current checkpoint and work +**Semuta ordinary specials with native factions — 29 October. Prototyped / +Partial.** Fresh Basic Richese and independent-card games can offer used Hajr, +Ghola, Harvester, Weather Control and Family Atomics to Semuta with selected +native expansion factions seated. Actual Tleilaxu/Ecaz games use original +purchases and selected card order; two additional native-actor cases stage a +conserved card transfer. Seven related files pass 49 cases; typecheck, lint and +the production build pass. Battle and other discard parents retain their existing boundaries. +Great Maker's reserve ride is treated as movement through Aftermath, a +source-based interpretation exercised with staged board and Spice order. +[Semuta scope](SEMUTA_DISCARD_CONTINUATIONS.md#ordinary-specials-with-native-expansion-factions--29-october-2026); +[Great Maker scope](MORITANI_TERROR_RULES.md#great-maker-reserve-ride-through-aftermath--29-october-2026). + **Aftermath shipment and Recruits Semuta — 29 October. Prototyped / Partial.** Moritani Atomics Aftermath now blocks Spiritual accompaniment, Guild Ambassador, cross-planet and Junction shipments, including concealed markers; @@ -26,8 +38,10 @@ discard to Semuta without broadening other discard producers. Twelve related files pass 93 cases, including actual Guild Ambassador/Atomics actions and real Tleilaxu/Ecaz games with selected card order. Typecheck, lint and the production build pass. -Great Maker's reserve ride classification, other Semuta producers and complete -module games remain open. [Aftermath scope](MORITANI_TERROR_RULES.md#aftermath-shipment-destinations--29-october-2026); +Other Semuta producers and complete module games remain open. Great Maker's +reserve ride is treated as movement through Aftermath under the later +[source interpretation](MORITANI_TERROR_RULES.md#great-maker-reserve-ride-through-aftermath--29-october-2026). +[Aftermath scope](MORITANI_TERROR_RULES.md#aftermath-shipment-destinations--29-october-2026); [Semuta scope](SEMUTA_DISCARD_CONTINUATIONS.md#recruits-with-native-expansion-factions--29-october-2026). **Black Market knowledge and group Guild transport — 29 October. Prototyped / @@ -658,7 +672,8 @@ last earlier check. Run 51 for `cdc47ed` subsequently succeeded, including isolated HTTP verification, registry login and image publication. Run 52 was canceled before starting; run 53 for `3ba9087` succeeded, including HTTP verification and image publication. Run 54 was canceled before starting and -run 55 for `4feb418` was running at the latest check. The already-approved runner network adjustment and registry secret +run 55 for `4feb418` succeeded and published its verified image. Run 56 for +`0f8e0b4` was running at the latest check. The already-approved runner network adjustment and registry secret remain in use; neither needed to be recreated. Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. diff --git a/docs/MORITANI_TERROR_RULES.md b/docs/MORITANI_TERROR_RULES.md index 68ca50d4..4aad06da 100644 --- a/docs/MORITANI_TERROR_RULES.md +++ b/docs/MORITANI_TERROR_RULES.md @@ -1,5 +1,18 @@ # Moritani terror rules and components +## Great Maker reserve ride through Aftermath — 29 October 2026 + +The publisher's Ecaz and Moritani rulebook (physical and printed p. 12) +describes the Great Maker reserve entry as a worm ride and movement. The +user-authorized revised rulebook describes the same movement on physical p. 23 +(printed p. 21) and the Atomics shipment prohibition on physical p. 35 +(printed p. 33). The implementation permits this ride into Aftermath as a +movement, without consuming a shipment. This is an interpretation of those +passages, not an explicit publisher ruling on their combination. A focused +fixture stages a conserved board and Spice order, reveals the actual Atomics +token and then resolves the actual Great Maker vote and ride. Broader Discovery +and Aftermath combinations remain open. [Publisher rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) + ## Aftermath shipment destinations — 29 October 2026 Aftermath's printed shipment ban applies to ordinary and Fremen shipment, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3b8e948f..fe43730a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Great Maker reserve ride through Aftermath — 29 October 2026 + +The Great Maker reserve ride is treated as movement, so it may enter an Atomics +Aftermath territory. This is a source-based interpretation, not an explicit +publisher ruling on the overlap. [Scope](MORITANI_TERROR_RULES.md#great-maker-reserve-ride-through-aftermath--29-october-2026). + ## Advanced classic group alliance foundation — 29 October 2026 The staged classic group profile applies the user-authorized Advanced diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 1111d793..314a1e9b 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,15 @@ # Semuta discard producers and continuation inventory +## Ordinary specials with native expansion factions — 29 October 2026 + +**Prototyped / Partial.** Fresh Basic Richese games with selected native +faction decks and independent Ecaz Treachery Cards can offer the completed +Hajr, Ghola, Harvester, Weather Control and Family Atomics discard to Semuta. +The original effect commits before claim or pass and resumes once. Public +Tleilaxu and Ecaz setup journeys use all three expansion decks, actual card +purchases and selected unopened card and Spice order. Battle, other discard +producers and optional modules retain their prior separate boundaries. + ## Recruits with native expansion factions — 29 October 2026 **Prototyped / Partial.** Fresh Basic games with Richese, selected native diff --git a/game/engine.ts b/game/engine.ts index aac77c39..0a44fb50 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9707,7 +9707,9 @@ function semutaOfferSupported( // staged offers and already-saved reactions retain the current rules. return (g.semutaPreview === true || g.semutaRecruitsPreview === true && (!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) && - basicIndependentSemutaProfile(g) && + (continuation.kind === 'ordinaryCardDiscard' && + ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) + ? basicIndependentRecruitsSemutaProfile(g) : basicIndependentSemutaProfile(g)) && (continuation.kind === 'ordinaryCardDiscard' && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || diff --git a/game/reference.ts b/game/reference.ts index dc95629f..d0d046cc 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5866,7 +5866,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Connected native outcomes use original sources: Suboid Cunning and winning trained Suk settle typed fixed casualties before one equal actual Cyborg Tank substitution; original cleanup and mandatory winner Tech precede matching Face Dance, retaining its foreign-Homeworld suppression. Signed Richese two-marker one-invoice physical shipment requires a currently revealed nested site. Ecaz-selected coalition armies and Moritani skill-first assassination remain distinct from shared-Duke training. These programmes do not certify every skill or card band.', 'The original nexus --discoveries entry retains NO Skills with classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds/Tech3+/Advanced Strongholds remain optional. Real native Traitors, Arrakis Occupy and Advanced assassination marks remain; no starting Nexus hand or training is added. Maker losses/votes/typed reserve rides, both Advanced piles and actual settled alliance change precede the closing card deal. Source guards and public/played-save boundaries remain.', 'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.', - 'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Unless this turn’s predeclared Fremen Nexus worm-riding prohibition applies, Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.', + 'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Unless this turn’s predeclared Fremen Nexus worm-riding prohibition applies, Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply. The reserve ride may enter Atomics Aftermath under the provisional interpretation that this is movement rather than shipment.', 'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.', 'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.', 'Spice Stash grants seven bank spice and leaves play. Treachery Card Stash draws one private Treachery Card and leaves play; if the new hand is too large, choose any held card to discard, including the newly drawn card. The owned discard panel includes a card inspector.', @@ -6620,7 +6620,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, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it, including when other selected expansion factions are seated. The Recruits benefit still lasts for the turn and cannot be played again. In the separately supported classic-roster profile, used ordinary specials, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. Other Semuta opportunities remain unfinished.', + 'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. The original effect stays committed. In the separately supported classic-roster profile, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. Other Semuta opportunities 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/great-maker-atomics-ride.test.ts b/tests/great-maker-atomics-ride.test.ts new file mode 100644 index 00000000..ca7af40e --- /dev/null +++ b/tests/great-maker-atomics-ride.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { placeTerror } from '../game/moritani-terror'; +import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; + +const player = (game: Game, id: string) => { + const seat = game.players.find(candidate => candidate.id === id); + assert.ok(seat); return seat; +}; + +/** Genuine original Discovery and Fremen/Moritani setup. The first movement + * position, Terror placement and the later unplayed Spice ordering are staged; + * the original Atomics reveal, Great Maker vote and reserve ride are actions. */ +function encounterAfterAtomics() { + let game = createGame('MAKERATOMICS', newPlayer('m','Moritani','moritani'), false, ['ecaz']); + joinGame(game,newPlayer('f','Fremen','fremen')); + joinGame(game,newPlayer('a','Atreides','atreides')); + game.discoveryEnabled = true; + for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'}); + game = initializeDiscoveryGameForAudit(game); + for (let count = 0; game.status === 'setup' && count < 150; count++) { + if (game.decision?.kind === 'moritaniSetup') + game = applyAction(game,game.decision.player,{type:'decision',territory:'carthag',sector:11}); + else { + const next = nextSpiceBankerIncomeNativeStep(game); assert.ok(next); + game = applyAction(game,next.actor,next.action); + } + } + assert.equal(game.status,'playing'); + const atomics = game.moritaniTerror!.tokens.find(token => token.kind === 'atomics'); assert.ok(atomics); + game.moritaniTerror = placeTerror(game.moritaniTerror!,atomics.id,'arrakeen',1); + Object.assign(game,{turn:2,phase:5,storm:18,active:'a',order:['a','m','f'], + movementRemaining:['a','m','f'],ready:[],response:null,decision:null,phaseOpening:null}); + for (const seat of game.players) { + game.deck.push(...seat.hand); + Object.assign(seat,{hand:[],forces:{},reserves:20,tanks:0,ally:null,shipped:false,moved:0,spice:20}); + if (seat.elites) Object.assign(seat.elites,{reserves:seat.faction === 'fremen' ? 3 : 0,tanks:0,forces:{},revived:0}); + } + game = applyAction(game,'a',{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0}); + assert.equal(game.pendingTerrorEntry?.stage,'offer'); + game = applyAction(game,'m',{type:'decision',reveal:true}); + assert.equal(game.moritaniAtomics?.territory,'arrakeen'); + assert.equal(game.moritaniTerror!.tokens.find(token => token.id === atomics.id)?.status,'removed'); + + const find = (predicate: (card: Game['spiceDeck'][number]) => boolean) => { + const index = game.spiceDeck.findIndex(predicate); assert.ok(index >= 0); + return game.spiceDeck.splice(index,1)[0]; + }; + const maker = find(card => 'worm' in card && card.greatMaker === true); + const source = find(card => 'territory' in card && card.territory === 'broken_land' && !card.discovery); + const replacement = find(card => 'territory' in card && !card.discovery); + assert.ok('territory' in source); + Object.assign(game,{phase:1,storm:18,nexus:false,summonedBeforeBlow:false,wormRides:[], + spiceSequence:null,spiceResolution:null,spiceWindow:null,response:null,decision:null,phaseOpening:null,ready:[]}); + game.spiceDiscard = [[source],[]]; + game.spiceDeck = [maker,replacement,...game.spiceDeck]; + game.spice = {[`${source.territory}:${source.sector}`]:7}; + for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'}); + assert.equal(game.greatMaker?.stage,'vote'); + while (game.decision?.kind === 'greatMakerVote') + game = applyAction(game,game.decision.player,{type:'decision',event:game.decision.event,yes:false}); + assert.equal(game.greatMaker?.stage,'ride'); + assert.equal(game.decision?.player,'f'); + return game; +} + +void test('Great Maker reserve ride may enter Atomics Aftermath without becoming a shipment', () => { + const game = encounterAfterAtomics(), view = viewGame(game,'f'); + assert.ok(view.greatMaker?.ride?.destinations.some(destination => + destination.territory === 'arrakeen' && destination.sector === 10)); + const before = player(game,'f'), count = before.reserves, spice = before.spice; + const shipped = before.shipped, moved = before.moved; + const event = game.greatMaker!.event; + const done = applyAction(game,'f',{type:'decision',event,accept:true, + territory:'arrakeen',sector:10,amount:2,elite:0}); + assert.equal(player(done,'f').reserves,count-2); + assert.equal(player(done,'f').forces['arrakeen:10'],2); + assert.equal(player(done,'f').spice,spice); + assert.equal(player(done,'f').shipped,shipped); + assert.equal(player(done,'f').moved,moved); + assert.equal(done.moritaniAtomics?.territory,'arrakeen'); + assert.equal(done.greatMaker?.stage,'complete'); + for (const seat of done.players) + assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum,n) => sum+n,0),20); +}); diff --git a/tests/semuta-independent-native-ordinary.test.ts b/tests/semuta-independent-native-ordinary.test.ts new file mode 100644 index 00000000..9bd25f3e --- /dev/null +++ b/tests/semuta-independent-native-ordinary.test.ts @@ -0,0 +1,185 @@ +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 { TERRITORIES } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory, + basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador'; + +type Effect = 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics'; +type Native = 'tleilaxu' | 'ecaz'; +const act = (game: Game, player: string, action: Action) => applyAction(game, player, action); + +/** Real public selected-family setup, cache purchase, ordinary auction and + * shipment. Unopened main/Spice order is selected to shorten the route. When + * the native faction is the actor, the bought physical card moves between + * hands after the auction to exercise that faction's own control. */ +function nativeFixture(effect: Effect, native: Native, actor: 'atreides' | Native = 'atreides') { + let game = createGame('SEMUTANATIVEORDINARY', newPlayer('atreides', 'Atreides', 'atreides'), false, + ['choam', 'ix', 'ecaz']); + for (const faction of ['richese', 'guild', native] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const owner = actor, buyer = 'atreides', holder = 'guild'; + const send = (actor: string, action: Action) => { game = act(game, actor, action); }; + const reach = (until: (state: Game) => boolean) => { + for (let count = 0; count < 1600; count++) { + if (until(game)) return; + assert.notEqual(game.status, 'finished'); + const reaction = game.pendingTreacheryDiscard?.reaction; + if (reaction?.stage === 'offer') { + const responder = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(responder); + send(responder.id, {type:'semutaPass', event:game.pendingTreacheryDiscard!.batch.event}); + continue; + } + const step = nextRicheseStrongholdsNativeStep(game); + assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`); + send(step.actor, step.action); + } + throw Error('The selected native Semuta fixture did not reach its boundary.'); + }; + send(buyer, {type:'moduleOption', module:'ecazTreachery', enabled:true}); + for (const player of game.players) send(player.id, {type:'ready'}); + send(buyer, {type:'start'}); + assert.equal(game.status, 'setup'); + assert.equal(game.semutaRecruitsPreview, true); + const original = inventory(game); + const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)) + .slice(0, game.players.length).map(card => card.id); + assert.equal(starting.length, game.players.length); + const card = game.deck.find(item => item.effect === effect); assert.ok(card); + for (const [position, id] of [...starting, card.id].entries()) { + const from = game.deck.findIndex(candidate => candidate.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); send(step.actor, step.action); + } + assert.equal(game.status, 'playing'); + for (let position = 0; position < 4; position++) { + const from = game.spiceDeck.findIndex((item, index) => index >= position && 'territory' in item && !item.discovery); + assert.ok(from >= position); + const item = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, item); + } + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); + send('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; !seat(game, holder).hand.some(item => item.id === SEMUTA_DRUG_ID) && count < 8; count++) { + const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome); + send(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0}); + reach(clean); + } + assert.ok(seat(game, holder).hand.some(item => item.id === SEMUTA_DRUG_ID)); + reach(state => state.phase === 3 && clean(state) && state.auction?.active === buyer); + assert.equal(game.auction!.cards[game.auction!.index].id, card.id); + send(buyer, {type:'bid', amount:1, allyPayment:0}); + reach(state => clean(state) && seat(state, buyer).hand.some(item => item.id === card.id)); + if (owner !== buyer) { + const source = seat(game, buyer), recipient = seat(game, owner); + const index = source.hand.findIndex(item => item.id === card.id); assert.ok(index >= 0); + recipient.hand.push(...source.hand.splice(index, 1)); + assert.deepEqual(inventory(game), original); + } + reach(state => state.phase === 5 && state.active === owner && clean(state)); + if (effect === 'hajr') return {game, owner, holder, card:card.id, original, play:{type:'card',card:card.id} as Action}; + const sector = effect === 'atomics' ? [8,9].find(value => value !== game.storm)! : game.storm % 18 + 1; + const target = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') && + place.sectors.includes(sector) && game.players.every(player => + !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`)))); + assert.ok(target); + const casualty = `${target.id}:${sector}`; + send(owner, {type:'ship', territory:target.id, sector, amount:3, elite:0, allyPayment:0}); + if (effect === 'weather' || effect === 'atomics') + reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && clean(state)); + else if (effect === 'harvester') + reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow && !state.spiceWindow.harvested && clean(state)); + else { + reach(state => state.turn === 2 && state.phase === 4 && clean(state)); + assert.equal(seat(game, owner).forces[casualty], undefined); + assert.equal(seat(game, owner).tanks, 3); + } + assert.deepEqual(inventory(game), original); + const play = (effect === 'weather' ? {type:'card',card:card.id,amount:1} + : effect === 'ghola' ? {type:'card',card:card.id,amount:3,elite:0} + : {type:'card',card:card.id}) as Action; + return {game, owner, holder, card:card.id, original, play, casualty}; +} + +for (const native of ['tleilaxu','ecaz'] as const) { + void test(`Basic native ${native} plays Hajr and Semuta resumes its actual movement`, () => { + const f = nativeFixture('hajr', native, native); + const pending = act(f.game, native, f.play); + const frame = pending.pendingTreacheryDiscard; assert.ok(frame); + assert.equal(frame.reaction?.stage, 'offer'); + assert.equal(frame.continuation.kind, 'ordinaryCardDiscard'); + assert.equal(frame.continuation.player, native); + assert.ok(pending.hajr.includes(native)); + assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); + let done = act(pending, f.holder, {type:'semutaCommit',event:frame.batch.event}); + if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') + done = act(done, f.holder, {type:'semutaSelect',event:frame.batch.event,card:f.card}); + assert.equal(done.pendingTreacheryDiscard, null); + assert.ok(done.hajr.includes(native)); + assert.equal(seat(done, f.holder).hand.some(item => item.id === f.card), true); + assert.deepEqual(inventory(done), f.original); + done = act(done, native, {type:'endMovement'}); + assert.ok(!done.movementRemaining?.includes(native)); + }); +} + +for (const effect of ['hajr','ghola','weather','harvester','atomics'] as const) { + for (const native of ['tleilaxu','ecaz'] as const) for (const claim of [false,true]) { + void test(`Basic ${native} ${effect} discard offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => { + const f = nativeFixture(effect, native), original = structuredClone(f.game); + const prior = seat(f.game, f.owner); + let game = act(f.game, f.owner, f.play); + assert.deepEqual(f.game, original); + const frame = game.pendingTreacheryDiscard; assert.ok(frame); + assert.equal(frame.reaction?.stage, 'offer'); + assert.equal(frame.continuation.kind, 'ordinaryCardDiscard'); + assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]); + if (effect === 'hajr') assert.ok(game.hajr.includes(f.owner)); + if (effect === 'ghola') { + assert.equal(seat(game, f.owner).reserves, prior.reserves + 3); + assert.equal(seat(game, f.owner).tanks, prior.tanks - 3); + } + if (effect === 'weather') assert.equal(game.stormPending, 1); + if (effect === 'harvester') { + assert.equal(game.spiceWindow!.amount, f.game.spiceWindow!.amount * 2); + assert.equal(game.spiceWindow!.harvested, true); + } + if (effect === 'atomics') { + assert.equal(game.shieldWallDestroyed, true); + assert.equal(seat(game, f.owner).forces[f.casualty!], undefined); + assert.equal(seat(game, f.owner).tanks, prior.tanks + 3); + } + const committed = {phase:game.phase, turn:game.turn, storm:game.storm, stormPending:game.stormPending, + hajr:game.hajr, spice:game.spice, spiceWindow:game.spiceWindow, + shieldWallDestroyed:game.shieldWallDestroyed, forces:seat(game, f.owner).forces, + reserves:seat(game, f.owner).reserves, tanks:seat(game, f.owner).tanks}; + if (effect === 'hajr' && native === 'tleilaxu') for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.holder); view.players.find(player => player.id === f.holder)!.bot = difficulty; + assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); + } + const event = frame.batch.event; + if (claim) { + game = act(game, f.holder, {type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game, f.holder, {type:'semutaSelect',event,card:f.card}); + } else for (const player of game.players) game = act(game, player.id, {type:'semutaPass',event}); + assert.equal(game.pendingTreacheryDiscard, null); + assert.deepEqual({phase:game.phase, turn:game.turn, storm:game.storm, stormPending:game.stormPending, + hajr:game.hajr, spice:game.spice, spiceWindow:game.spiceWindow, + shieldWallDestroyed:game.shieldWallDestroyed, forces:seat(game, f.owner).forces, + reserves:seat(game, f.owner).reserves, tanks:seat(game, f.owner).tanks}, committed); + assert.equal(seat(game, f.holder).hand.some(item => item.id === f.card), claim); + assert.equal(game.discard.some(item => item.id === f.card), !claim); + assert.equal(game.discard.some(item => item.id === SEMUTA_DRUG_ID), claim); + assert.deepEqual(inventory(game), f.original); + assert.throws(() => act(game, f.holder, {type:'semutaCommit',event})); + }); + } +}