diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cd66c197..78b878d3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,20 @@ open mode gates. ## Current checkpoint and work +**Basic independent cards: Residual Poison and Semuta — 28 October. +Prototyped / Partial.** The existing clean pre-leader continuation offers the +public Poison discard to Semuta while preserving the committed death and the +native preparation. Other inspections, competing parents and module profiles +retain their prior guards. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#residual-poison-in-basic-independent-cards--28-october-2026). +The related twelve-file batch passes 84 cases, including claim/pass and all +four legal bot responses. The new real-action fixture transfers Semuta from +the original cache to a hand; that acquisition is staged, not a played purchase. + +**Larger-alliance integration groundwork — 28 October.** Movement-end warnings +and losses for current games now use the shared group-capable separation quote. +The three-member option remains unexposed: consent, faction benefits, funding, +movement and battle paths are not yet integrated for groups. + **Basic allied No-Field transit — 28 October. Prototyped / Partial.** Richese may offer its ally an immediately revealed shipment into a Richese-occupied territory. The recipient may depart or accept the existing provisional loss diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 979afd94..77ce6650 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Residual Poison and Semuta with Basic independent cards — 28 October 2026 + +The existing provisional public-discard reaction also applies to the clean +Richese/classic independent-card preview, after the random death commits and +before battle preparation resumes. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#residual-poison-in-basic-independent-cards--28-october-2026). + ## Basic allied No-Field transit — 28 October 2026 The FAQ's allied shipment and the Richese alliance ability are provisionally diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 5ecf5d47..e9a65236 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,15 @@ # Semuta discard producers and continuation inventory +## Residual Poison in Basic independent cards — 28 October 2026 + +**Prototyped / Partial.** In the existing Richese/classic independent-card +preview, a clean pre-leader Residual Poison death now offers its public +discard to Semuta. The selected leader's death is committed once before the +offer; claiming or passing resumes the original preparation, retaining the +opponent's readiness and reopening the Poison player's readiness. Existing +inspection, competing-parent and module guards remain. The ordering is a +provisional digital interpretation; other profiles remain separately scoped. + ## Richese specials and simple Truthtrance 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 98c95811..23f9973d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -434,6 +434,7 @@ import { import { quoteSpiceCollection, quoteBattlePhaseAdvance, + quoteCoalitionAlliedSeparation, BoardResolutionError, type AdvisorRelease, type CollectionQuote, @@ -747,7 +748,7 @@ import { settleAdvisors, arrivalAsAdvisor, } from './advisors'; -import { quoteAlliedSeparation, isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, +import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, type BasicAlliedShipment } from './allied-separation'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; @@ -9531,6 +9532,7 @@ function semutaOfferSupported( ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleCleanup' && continuation.source === 'winner' || + continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard' && continuation.promise === null && (continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') || @@ -23093,25 +23095,10 @@ function finishMovedGroup(g: Game, move: CompletedMovement) { } /** Native board classification is shared by the consequence and its own-seat preview. */ function alliedSeparationTerritories(g: Game, p: Player) { - const ally = p.ally ? getPlayer(g, p.ally) : null; - return quoteAlliedSeparation({ - advanced: g.advanced, - turn: g.turn, - player: p.id, - ally: ally?.id ?? null, - playerAllySinceTurn: p.allySinceTurn, - allySinceTurn: ally?.allySinceTurn, + return boardResolution(() => quoteCoalitionAlliedSeparation({ + ...g, remaining: g.movementRemaining ?? g.order.slice(g.order.indexOf(p.id)), - territories: gameTerritories(g).map(terr => ({ - territory: terr.id, - polar: terr.type === 'polar', - ownPresent: at(p, terr.id) > 0, - allyPresent: !!ally && at(ally, terr.id) > 0, - ownAdvisors: isAdvisor(p, terr.id), - allyAdvisors: !!ally && isAdvisor(ally, terr.id), - ecazCoexist: !!ally && sharesEcazOccupation(g, p, terr.id), - })), - }); + }, p.id)); } function alliedSeparationView(g: Game, p: Player): {territories: string[]; noField?: true} | null { if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || @@ -29999,7 +29986,7 @@ function playResidualPoison( `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, { faction: p.faction, name: 'Residual Poison' }, ); - if (g.semutaPreview) { + if (g.semutaPreview || g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) { const continuation: ResidualPoisonDiscardContinuation = { kind: 'residualPoisonDiscard', player: p.id, target: target.id, victim: victim.id, battleEvent: b.event!, card: used.id, diff --git a/game/reference.ts b/game/reference.ts index 183906bc..1dfb0ef5 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1328,7 +1328,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'ornithopter' ? 'Use the Ornithopter controls with one unused normal/Hajr movement and a selected group/destination. Two card groups stay distinct, while Hajr may reuse their forces afterward. A genuinely recovered Hajr may be legally replayed for another grant. Advanced needs a fresh immediate-after-movement opportunity. Kulon retains the fixed maximum three and extends both normal groups. Advisor/card and wider module combinations remain unfinished.' : definition.card.effect === 'residualPoison' - ? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, a clean death pauses after public disposal for the neutral offer even if one combatant is already ready. The opponent’s readiness remains; the Poison player must ready again. Inspected battle commitments remain outside this reaction.' + ? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, including the supported Basic independent-card profile, a clean death pauses after public disposal for the neutral offer even if one combatant is already ready. The opponent’s readiness remains; the Poison player must ready again. Inspected battle commitments remain outside this reaction.' : definition.card.effect === 'stoneBurner' ? 'Use Stone Burner in the weapon selector and review public dial/support feasibility before sealing. Clean selected CHOAM/Ix now uses visibly provisional original Stone-first then uncopied Tooth order; copy-first and physical allocation guards remain.' : definition.card.effect === 'mirrorWeapon' diff --git a/tests/fixture-truth-flight-poison.ts b/tests/fixture-truth-flight-poison.ts index 61f777ce..4ed445f9 100644 --- a/tests/fixture-truth-flight-poison.ts +++ b/tests/fixture-truth-flight-poison.ts @@ -67,8 +67,10 @@ export function finishTruthFlightPoison(game: Game, actions: TruthFlightPoisonSt /** Fresh bare R/A/E/G original setup in its actual requested mode. Native * cache1 acquisition, funded physical shipment and real battle preparation * supply every current role; no Nexus/profile is removed from a played game. */ -export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean } = {}) { +export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean; independentCards?: boolean } = {}) { const effect = options.effect ?? 'ornithopter', advanced = options.advanced ?? false; + const independentCards = options.independentCards ?? false; + assert.ok(!advanced || !independentCards, 'This independent-card source is Basic only.'); const target = effect === 'ornithopter' ? 'emperor' : 'atreides', asker = 'richese', recipient = asker; const canonical = richeseCards().find(card => card.effect === effect); assert.ok(canonical); const card = canonical.id; let game = createGame('TRUTHFLIGHTPOISON', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); @@ -93,6 +95,7 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo } throw Error('The actual cache acquisition/source boundary must be reached finitely'); }; + if (independentCards) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0)); @@ -132,7 +135,8 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo assert.deepEqual(game.battle!.plans, {}); assert.ok(residualPoisonCandidates(truthFlightPoisonPlayer(game, asker).leaders, 'arrakeen').length > 1); const quote = viewGame(game, target).residualPoison; assert.ok(quote && !quote.blocked && quote.target === asker); } - assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced); + assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced && !independentCards); + assert.equal(!!game.semutaRecruitsPreview, independentCards); assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards); assert.deepEqual(truthFlightPoisonInventory(game), originalCards); assert.deepEqual(truthFlightPoisonSpice(game), originalSpice); assert.deepEqual(truthFlightPoisonTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0); @@ -141,7 +145,7 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthFlightPoisonPlayer(game, target).spice }; const mixed = truthFlightPoisonGroup('and', truthFlightPoisonUse(effect), truthFlightPoisonPayment(recipient)); const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed }; - return { game, source: structuredClone(game), effect, advanced, target, asker, recipient, card, truth: truths[0], truths, truthHolders, + return { game, source: structuredClone(game), effect, advanced, independentCards, target, asker, recipient, card, truth: truths[0], truths, truthHolders, initial, lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, spiceOrder: structuredClone(front), acquisition, shipment, choice, origin: effect === 'ornithopter' ? 'imperial_basin:9' : null, poisonTarget: effect === 'residualPoison' ? asker : null, event: game.battle?.event ?? null, spiceFact, mixed, question, originalCards, originalSpice, originalTraitors, @@ -150,12 +154,12 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo acquisition: acquisition.steps.length, shipment: shipment.steps.length }, payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action, end: { type: 'endMovement' } satisfies Action, - sourceDisclosure: 'Genuine public bare Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original Basic or Advanced preview mode. ' + + sourceDisclosure: `Genuine public Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original ${advanced ? 'Advanced preview' : 'Basic'} mode${independentCards ? ' with independent Ecaz Treachery Cards selected in the lobby' : ''}. ` + 'Only two original unopened Richese/Guild Truth deals, original unopened first-turn land Spice positions and scalar lotteries are selected. ' + 'The holder actually buys its canonical cache card for1. ' + (effect === 'ornithopter' ? 'Emperor actually pays4 to ship two ordinary counters to Imperial Basin, retaining5 and its unused ground movement. ' : 'Richese actually pays1 to ship one original counter into original Atreides10 in Arrakeen; actual chooseBattle and native preparation create the preleader window while Atreides retains9. ') + - 'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed. Source counts derive from actual arrays.', + 'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed after setup. Source counts derive from actual arrays.', }; } export type TruthFlightPoisonFixture = ReturnType; diff --git a/tests/semuta-independent-residual.test.ts b/tests/semuta-independent-residual.test.ts new file mode 100644 index 00000000..9b8667aa --- /dev/null +++ b/tests/semuta-independent-residual.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { viewGame, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actTruthFlightPoison as act, createTruthFlightPoisonFixture, + truthFlightPoisonAction, truthFlightPoisonInventory, truthFlightPoisonPlayer as player, +} from './fixture-truth-flight-poison'; + +/** Actual public Basic independent-card setup, paid cache purchase, shipment + * and pre-leader battle. Only Semuta is transferred from the original cache + * to Guild's hand; it is not claimed as a real purchase. */ +function fixture() { + const source = createTruthFlightPoisonFixture({ effect: 'residualPoison', independentCards: true }); + const game = structuredClone(source.game), holder = 'guild'; + assert.equal(game.semutaRecruitsPreview, true); + const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); + assert.ok(index >= 0); + assert.ok(player(game, holder).hand.length < 4); + player(game, holder).hand.push(game.richeseCache!.splice(index, 1)[0]); + assert.deepEqual(truthFlightPoisonInventory(game), source.originalCards); + assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed); + return { ...source, game, holder }; +} + +function open(source: ReturnType, who: 'owner' | 'opponent') { + const readyActor = who === 'owner' ? source.target : source.asker; + const ready = act(source.game, readyActor, { type: 'battlePreparationReady', event: source.event }); + assert.deepEqual(ready.battle!.preLeader!.ready, [readyActor]); + const beforeDead = player(ready, source.asker).leaders.filter(leader => leader.dead).map(leader => leader.id); + const pending = act(ready, source.target, truthFlightPoisonAction(ready, source)); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.battle!.preLeader!.ready, [readyActor]); + const newlyDead = player(pending, source.asker).leaders.filter(leader => leader.dead && !beforeDead.includes(leader.id)); + assert.equal(newlyDead.length, 1); + assert.equal(viewGame(pending, source.holder).semutaReaction?.canCommit, true); + return { pending, victim: newlyDead[0].id, event: pending.pendingTreacheryDiscard!.batch.event }; +} + +function react(game: Game, holder: string, event: string, claim: boolean): Game { + if (claim) return act(game, holder, { type: 'semutaCommit', event }); + for (const seat of game.players) { + if (!game.pendingTreacheryDiscard?.reaction) break; + game = act(game, seat.id, { type: 'semutaPass', event }); + } + return game; +} + +for (const who of ['owner', 'opponent'] as const) for (const claim of [false, true]) { + void test(`independent-card ${who}-ready Residual Poison ${claim ? 'claim' : 'pass'} resumes the original preparation once`, () => { + const f = fixture(), { pending, victim, event } = open(f, who); + const dead = player(pending, f.asker).leaders.filter(leader => leader.dead).length; + const resumed = react(pending, f.holder, event, claim); + assert.equal(resumed.pendingTreacheryDiscard ?? null, null); + assert.deepEqual(resumed.battle!.preLeader!.ready, who === 'owner' ? [] : [f.asker]); + assert.deepEqual(resumed.battle!.plans, pending.battle!.plans); + assert.equal(player(resumed, f.asker).leaders.find(leader => leader.id === victim)?.dead, true); + assert.equal(player(resumed, f.asker).leaders.filter(leader => leader.dead).length, dead); + assert.equal(player(resumed, f.holder).hand.some(card => card.id === f.card), claim); + assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); + assert.equal(resumed.discard.some(card => card.id === f.card), !claim); + assert.deepEqual(truthFlightPoisonInventory(resumed), f.originalCards); + assert.throws(() => act(resumed, f.holder, { type: 'semutaCommit', event })); + }); +} + +void test('all profiles can answer an independent-card Residual Poison offer legally', () => { + const f = fixture(), { pending, event } = open(f, 'opponent'); + for (const difficulty of DIFFICULTIES) { + const game = structuredClone(pending); + const view = viewGame(game, f.holder); + view.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass')); + let continued = act(game, f.holder, action); + for (const seat of continued.players) { + if (!continued.pendingTreacheryDiscard?.reaction) break; + const offer = viewGame(continued, seat.id).semutaReaction; + if (offer && !offer.passed) continued = act(continued, seat.id, { type: 'semutaPass', event }); + } + assert.equal(continued.pendingTreacheryDiscard ?? null, null); + assert.deepEqual(continued.battle!.preLeader!.ready, [f.asker]); + assert.deepEqual(truthFlightPoisonInventory(continued), f.originalCards); + } +});