From 50c6201d333110caa3c433e59fbab4c18b2a1e98 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:39:34 +1100 Subject: [PATCH] Expand independent-card Semuta continuations --- components/game-table.tsx | 3 +- docs/CURRENT_STATUS.md | 8 ++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 13 +++ game/engine.ts | 9 +- game/reference.ts | 2 +- ...emuta-independent-richese-specials.test.ts | 105 ++++++++++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/semuta-independent-richese-specials.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 6afb490f..561e64e2 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1282,7 +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, Family Atomics or cards in supported battle cleanup. + Weather Control, Family Atomics, a completed Box search, Distrans, + simple Truthtrance, ordinary Ornithopter 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 ae7aed99..25b29250 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,14 @@ open mode gates. ## Current checkpoint and work +**Semuta after Richese specials and simple Truthtrance in Basic independent +cards — 28 October. Prototyped / Partial.** The Richese/classic preview adds +completed Box, Distrans, definite fact/freeform Truthtrance and ordinary +Ornithopter discard offers. The recorded effect and native continuation stay +intact. Nine focused claim/pass and AI cases pass; card acquisition was staged +by physical custody-preserving transfer after real public setup. Other parent +combinations remain guarded. + **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 diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index faae0d46..5ecf5d47 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,18 @@ # Semuta discard producers and continuation inventory +## Richese specials and simple Truthtrance in Basic independent cards — 28 October 2026 + +**Prototyped / Partial.** In the existing Richese/classic independent-card +preview, a completed Box search, Distrans transfer, definite fact/freeform +Truthtrance answer or ordinary Ornithopter disposal can offer its used card +to Semuta. The private search or transfer, recorded answer or completed +movement remains committed and its original continuation resumes once. +The tests use genuine public setup and a real Semuta purchase, with explicit +custody-preserving transfers to give the acting player the tested card. Other +questions, nested effects, unsupported lots and module combinations remain +guarded. Earlier saved discards without a reaction retain their original +automatic continuation; digital reaction ordering remains provisional. + ## Battle cleanup in Basic independent cards — 28 October 2026 **Prototyped / Partial.** In the existing Richese/classic independent-card diff --git a/game/engine.ts b/game/engine.ts index 6160f8c4..b87ae911 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9529,7 +9529,12 @@ function semutaOfferSupported( (continuation.kind === 'ordinaryCardDiscard' && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || - continuation.kind === 'battleCleanup' && continuation.source === 'winner')) && + continuation.kind === 'battleCleanup' && continuation.source === 'winner' || + continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' || + continuation.kind === 'truthtranceDiscard' && continuation.promise === null && + (continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') || + continuation.kind === 'ornithopterDiscard' && + (continuation.source === 'end' || continuation.source === 'move'))) && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && @@ -41881,7 +41886,7 @@ function applyActionInner( `${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`, { faction: p.faction, name: 'Distrans' }, ); - if (g.semutaPreview && !g.pendingTreacheryDiscard) { + if ((g.semutaPreview || g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) && !g.pendingTreacheryDiscard) { const continuation: DistransDiscardContinuation = { kind: 'distransDiscard', owner: p.id, recipient: recipient.id, transferred: result.transferred.id, card: result.discarded.id, diff --git a/game/reference.ts b/game/reference.ts index 457eff3a..7b0b10fd 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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, Family Atomics and cards in supported battle cleanup 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 ordinary specials, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card; other Semuta 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/semuta-independent-richese-specials.test.ts b/tests/semuta-independent-richese-specials.test.ts new file mode 100644 index 00000000..b7e413d1 --- /dev/null +++ b/tests/semuta-independent-richese-specials.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { distance, location, TERRITORIES } from '../game/board'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actIndependentOrdinary as act, advanceIndependentOrdinary, + createIndependentOrdinaryFixture, independentOrdinaryClean, + independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player, +} from './fixture-semuta-independent-ordinary'; +import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share'; + +type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter'; +function close(game: Game, holder: string, claim: boolean): Game { + const batch = game.pendingTreacheryDiscard; assert.ok(batch?.reaction); + const event = batch.batch.event; + if (claim) return act(game, holder, {type:'semutaCommit', event}); + for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event}); + return game; +} + +/** Real public independent-card game and Semuta acquisition. The additional + * printed card is moved once between existing physical zones to isolate this + * interaction; no force, money, phase or event is staged. */ +function fixture(kind: Kind) { + const source = createIndependentOrdinaryFixture('hajr'); + let game = structuredClone(source.game); + const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind; + const card = semutaEmperorCardId(game, item => item.effect === effect); + moveSemutaEmperorCard(game, card, source.owner); + const initialInventory = inventory(game); + let given: string | undefined, selected: string | undefined; + if (kind === 'box') { + // Use the genuinely bought Hajr to create an actual discard target. + game = act(game, source.owner, source.play); + game = close(game, source.holder, false); + selected = source.card; + game = act(game, source.owner, {type:'card', card}); + // A sole eligible discard is selected automatically by the native Box. + if (game.pendingNullentropy) + game = act(game, source.owner, {type:'decision', event:game.pendingNullentropy.event, card:selected}); + } else if (kind === 'distrans') { + given = source.card; + game = act(game, source.owner, {type:'card', card, target:'emperor', give:given}); + } else if (kind === 'truth') { + game = act(game, source.owner, {type:'card', card}); + while (game.truthtrance?.stage === 'priority') { + const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, {type:'truthPass'}); + } + game = act(game, source.owner, {type:'truthAsk', question:{kind:'freeform', target:'emperor', + scope:'fact', text:'Do you intend to protect Arrakeen?'}}); + game = act(game, 'emperor', {type:'truthAnswer', answer:'yes'}); + } else { + const open = TERRITORIES.flatMap(place => place.type === 'sand' ? place.sectors.map(sector => ({place, sector})) : []) + .find(({place, sector}) => sector !== game.storm && game.players.every(seat => + !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`)))); + assert.ok(open); + game = act(game, source.owner, {type:'ship', territory:open.place.id, sector:open.sector, amount:1, elite:0, allyPayment:0}); + game = advanceIndependentOrdinary(game, independentOrdinaryClean); + const from = location(open.place.id, open.sector); + const target = TERRITORIES.flatMap(place => place.sectors.map(sector => ({place, sector}))) + .find(({place, sector}) => place.id !== open.place.id && sector !== game.storm && + distance(from, location(place.id, sector)) <= 1 && game.players.every(seat => + !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`)))); + assert.ok(target); + const action: Action = {type:'move', from, amount:1, elite:0, territory:target.place.id, + sector:target.sector, movementCard:card, ornithopter:'range3'}; + game = act(game, source.owner, action); + } + return {game, owner:source.owner, holder:source.holder, card, given, selected, initialInventory}; +} + +for (const kind of ['box','distrans','truth','ornithopter'] as const) for (const claim of [false,true]) { + void test(`independent-card ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the completed effect`, () => { + const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.card]); + assert.equal(viewGame(f.game, f.holder).semutaReaction?.canCommit, true); + if (kind === 'distrans') { + assert.ok(player(f.game,'emperor').hand.some(card => card.id === f.given)); + assert.ok(!pending.batch.entries.some(entry => entry.card.id === f.given)); + } + if (kind === 'box') assert.ok(player(f.game,f.owner).hand.some(card => card.id === f.selected)); + const before = structuredClone({players:f.game.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})), + truthHistory:f.game.truthHistory, moved:player(f.game,f.owner).moved}); + const done = close(f.game, f.holder, claim); + assert.equal(done.pendingTreacheryDiscard, null); + assert.deepEqual({players:done.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})), + truthHistory:done.truthHistory, moved:player(done,f.owner).moved}, before); + assert.equal(player(done,f.holder).hand.some(card => card.id === f.card), claim); + assert.equal(done.discard.some(card => card.id === f.card), !claim); + assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); + assert.deepEqual(inventory(done), f.initialInventory); + }); +} + +void test('all AI profiles can answer the independent-card Distrans discard', () => { + const f = fixture('distrans'); + for (const difficulty of DIFFICULTIES) { + const game = structuredClone(f.game); player(game,f.holder).bot = difficulty; + const answer = botActions(viewGame(game,f.holder)).find(action => action.type === 'semutaCommit' || action.type === 'semutaPass'); + assert.ok(answer); assert.doesNotThrow(() => act(game,f.holder,answer)); + } +});