diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 27bd8c99..4b8da79a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -8,22 +8,23 @@ Expansion starts and full rules/publication acceptance remain gated.** The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) pauses clean public ordinary-card, paid Box, retired Ornithopter, -private Distrans, final definite Truthtrance, movement-order Sapho -and winner-selected battle-card discards without pending rewards. +private Distrans, final definite Truthtrance, movement-order Sapho, +mandatory winner or later optional winner battle-card discards without +pending rewards. The two winner batches are separate: a claim on the +mandatory Hero can resume the winner's optional weapon choice once. Every seat sees a neutral offer regardless of hidden Semuta possession. -A sole eligible card transfers on commitment; a simultaneous -winner-card batch first requires public commitment, then reveals -only to that holder the eligible fresh faces to select exactly one. -Other cards remain in the pile and the resolved battle resumes once. -The early-ended flight queue, completed-flight arrival, private gift, -answer history and Sapho order also resume without replay. Minimal -legal AI, exact JSON/SQLite continuation and competing-room CAS are -connected. Phone width showed owned Commit and rival Continue without -a private card face. Mixed arrivals, auction/battle Sapho, queued or -promise-bound Truthtrance, mandatory or reward-pending battle batches, -other discard producers, full-hand ordering, normal Richese starts -and deployed acceptance remain unfinished. Neutral passes and the -free-slot guard are product safety choices, not publisher rulings. +A sole eligible card transfers on commitment; a simultaneous winner +batch first requires public commitment, then reveals eligible faces +only to the holder for one-card selection. Other cards remain in the +pile and the resolved battle resumes once. The flight queue, committed +arrival, private gift, answer history and Sapho order also resume +without replay. Minimal legal AI, exact JSON/SQLite continuation and +competing-room CAS are connected. Mixed arrivals, auction/battle +Sapho, queued or promise-bound Truthtrance, loser/mixed-owner battle +batches, pending battle rewards, other discard producers, full-hand +ordering, normal Richese starts and deployed acceptance remain +unfinished. Neutral passes and free-slot guard are product safety +choices, not publisher rulings. One [natural Advanced Moritani assassination](MORITANI_ASSASSINATE_LEADERS.md#29-september-2026--natural-revealed-card-game) now proceeds from genuine battle loss to a dead-target Traitor reveal, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3e87fe18..f491a60e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 30 September 2026 — Semuta between mandatory and optional winner cleanup + +The explicit Richese Semuta preview now pauses a clean public +`winnerMandatoryDiscard` frame after the loser/battle discard but +before optional winning-card cleanup. A physically used Hero is +already in the ordinary pile; every seat receives the same neutral +offer. Another holder with free hand capacity may recover that one +Hero and discard Semuta. All-seat decline leaves it on the pile. +The committed casualty counts and winner remain fixed; `finishWinner` +continues once to the still-held optional weapon decision. Its later +discard is a distinct fresh batch, not another chance at the Hero. + +Genuine setup covers two combatants, preparation, Hero plus retained +projectile, the mandatory Hero offer, neutral passes, legal AI claim, +JSON continuation and exact physical census. An isolated migrated +SQLite room restores that offer, authenticates the Hero claim, then +opens and declines the later optional weapon batch without repeating +Semuta disposal or battle rewards. Loser/mixed-owner mandatory cleanup, +pending battle rewards, full-hand ordering, normal Richese starts +and deployed acceptance remain gated. + +Independent rules/continuation and privacy/recovery reviews found no +scoped defect. A direct production-engine run completed genuine AI +setup, a mandatory Hero discard, neutral offer and bot claim, then +showed the retained projectile in the original winner's optional +card decision. Phone-width local rules rendered the two-batch boundary +as Partial. `npm run check` passed typecheck, lint and 5,810/5,810 +offline cases; `npm run build` and all 55 Vite development HTTP +cases passed. No deployed revision was exercised. + ## 30 September 2026 — Semuta choice among simultaneous winner cards The explicit Richese Semuta preview now recognizes a clean batch of diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index f2351fab..9dba1a7c 100644 --- a/docs/RICHESE_COMPONENTS.md +++ b/docs/RICHESE_COMPONENTS.md @@ -12,7 +12,7 @@ Audited 6 September 2026. The ten physical identities and their readable face in - Player controls show both original plans, exact undialed counts or honest possible totals, the mode choice and a complete internal guide. No victim/death confirmation is added. The card stays physically reserved until standard cleanup; stale events, duplicate requests and corrupted source custody fail before resources change. All four AI profiles use public force possibilities, legal low-dial plans and revealed leader/bounty information. They preserve leader value when Artillery suppresses bounty and leave traitor/explosion precedence intact. -- Semuta Drug has an explicit bounded development preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and winner-selected battle-card batches. A multi-card winner batch exposes candidates only after holder commitment and transfers one physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho and mandatory/reward-pending battle batches remain outside scope. Other producers, full-hand ordering and normal Richese starts remain unfinished. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. +- Semuta Drug has an explicit bounded development preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and separate mandatory/optional winner battle-card batches. A multi-card winner batch exposes candidates only after holder commitment and transfers one physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho, loser/mixed-owner battle batches and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts remain unfinished. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md). @@ -111,12 +111,12 @@ The [bounded Semuta runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026- supersedes the older inactive-card observations above for one explicitly opted-in Richese development profile. Neutral all-seat responses, physical one-card recovery, a provisional free-slot guard, -minimal legal AI and saved continuation now work after clean public +minimal legal AI and saved continuation work after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final -definite Truthtrance, movement-order Sapho and winner-selected -battle-card discards, including a committed owner-only multi-card -choice. Queued questions, promise-bound answers, reactive arrivals, -auction/battle Sapho, mandatory or reward-pending battle batches -and other semantic discard families remain unconnected. Full-hand -ordering, ten-card inventory, cache custody and public expansion -start gate are unchanged. +definite Truthtrance, movement-order Sapho and separate mandatory +and optional winner battle-card batches, including committed +owner-only multi-card selection. Queued questions, promise-bound +answers, reactive arrivals, auction/battle Sapho, loser or mixed-owner +battle batches and pending battle rewards remain unconnected. +Full-hand ordering, ten-card inventory, cache custody and public +expansion start gate are unchanged. diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 9d47ce8f..a2b876f1 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -35,14 +35,16 @@ sources. The final forces and move counter are held in the existing bound flight frame; claim or decline retires that frame and logs the arrival once. Mixed entry reactions remain unsupported for Semuta. -Winner-selected public played cards now pause as one `battleCleanup` -batch after the battle is resolved and any earlier mandatory discard -has retired. The winner, combatants and all selected card identities -remain bound. Every seat sees the same neutral offer; only a committed -holder inspects the eligible batch and selects one of multiple cards. -The others remain in the pile before `finishBattle` runs once. Moritani -loser retention and pending Auditor, capture, tech, Face Dance or -CHOAM rewards remain automatic without this reaction. +Mandatory winner cleanup now pauses at `winnerMandatoryDiscard` +after any earlier loser/battle batch has retired; a Hero already in +the public pile can be claimed before the exact optional winner +choice resumes. If the winner then selects other played cards, +their public disposal is a separate `battleCleanup` batch. Each +stage has a neutral all-seat offer; only a committed holder inspects +multiple eligible cards, selects one, and leaves the others in the +pile. No battle award or card cost repeats. Moritani loser retention +and pending Auditor, capture, tech, Face Dance or CHOAM rewards +remain automatic without this reaction. ## Main findings diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 230e8489..9e669662 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -66,16 +66,19 @@ without opening ordinary expansion starts. It interrupts **one public ordinary-card discard, the used Box after a paid Nullentropy search, either retirement of a played Ornithopter in a clean flight, the used Distrans after a private transfer, a final definite Truthtrance, -movement-order Juice of Sapho, or a winner-selected battle-card batch -without pending battle rewards**, provided the parent transaction is -clean. Every seat sees a neutral fresh-discard opportunity regardless +movement-order Juice of Sapho, mandatory winning-card cleanup, or a +later winner-selected battle-card batch without pending rewards**, +provided the parent transaction is clean. Every seat sees a neutral +fresh-discard opportunity regardless of hidden Semuta possession. An early-ended Ornithopter advances its saved movement queue; a completed flight has committed its forces and resumes its one arrival only without BG, Ecaz or Moritani entry reactions. Distrans's gift stays private with its recipient. The Truthtrance answer/history and Sapho first/last queue are bound. -The winner's chosen played cards are already in one public fresh -batch; its resolved battle and remaining phase transition resume once. +Mandatory winning cards form one public fresh batch after the +previous loser cleanup, and any later optional winner selection is a +separate batch. Neither a Semuta claim nor all-seat decline replays +casualties or re-discards an already claimed card. Each seat can Continue; only the canonical holder with a free hand slot and a card discarded by another player can commit. A sole @@ -93,9 +96,9 @@ free-slot requirement is a provisional safety guard, not the printed resolution of full-hand take-before-discard. Auction, CHOAM market, pending gifts, competing responses, completed Ornithopter arrivals with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or -promise-bound Truthtrance, auction/battle Sapho, mandatory/reward-pending -battle batches and other typed producers retain their automatic -continuation; Semuta cannot claim those events yet. +promise-bound Truthtrance, auction/battle Sapho, loser or mixed-owner +mandatory battle batches and pending battle rewards retain their +automatic continuation; Semuta cannot claim those events yet. The completed Box's selected search card stays private and its paid shuffle is not repeated when another seat takes the used Box. No source ruling is inferred from this omission. The diff --git a/game/engine.ts b/game/engine.ts index 2e2294e1..0fb6df60 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5754,6 +5754,7 @@ function semutaOfferSupported( continuation.kind !== 'distransDiscard' && continuation.kind !== 'saphoMovementDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && + continuation.kind !== 'winnerMandatoryDiscard' && !(continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || (continuation.source === 'move' && @@ -5763,7 +5764,8 @@ function semutaOfferSupported( !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && continuation.remaining === null && continuation.promise === null)) return false; - if (continuation.kind === 'battleCleanup' && + if ((continuation.kind === 'battleCleanup' || + continuation.kind === 'winnerMandatoryDiscard') && (g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || @@ -5777,10 +5779,12 @@ function semutaOfferSupported( return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && - (continuation.kind === 'battleCleanup' + (continuation.kind === 'battleCleanup' || + continuation.kind === 'winnerMandatoryDiscard' ? entries.every(entry => entry.publicFace) : entries.length === 1 && entries[0].publicFace) && (continuation.kind === 'battleCleanup' || + continuation.kind === 'winnerMandatoryDiscard' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) && !g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle && diff --git a/game/reference.ts b/game/reference.ts index ae19879b..a42d3240 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' : definition.card.effect === 'semutaDrug' - ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, paid Box search, retired Ornithopter, private Distrans transfer, final definite Truthtrance answer, movement-order Sapho discard or winner-selected battle-card batch with no pending rewards. Continue or commit held Semuta with a free hand slot. One fresh card transfers automatically; for several winner-discarded cards, commit first, then privately choose exactly one. The original flight arrival, private gift, answered history, reordered movement or resolved battle resumes once. BG/Ecaz/Moritani arrivals, other multi-card sources, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.' + ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho or winner battle-card discards without pending rewards. Mandatory winning cards and later optional winner discards are separate fresh batches. Continue or commit held Semuta with a free hand slot; a sole card transfers automatically. For several cards, commit first, then privately select exactly one. The original effect or battle suffix resumes once. BG/Ecaz/Moritani arrivals, loser and mixed-owner mandatory battle batches, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and winner-selected battle-card discards with a neutral all-seat offer. A committed sole eligible card transfers immediately; several public winner cards open only the holder’s one-card selection. Semuta is discarded once and the original effect resumes once. Mixed arrivals, other multi-card sources, pending battle rewards, other typed producers and full-hand exchange remain unfinished.', + 'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho, mandatory winner and optional winner battle-card discards with a neutral all-seat offer. Mandatory and later optional winner cards are separate batches; committed multi-card candidates belong only to the holder. Semuta transfers one other-player card, is discarded once and resumes the original effect once. Mixed arrivals, loser battle batches, pending rewards, other typed producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes bounded public ordinary-card, paid Box, clean Ornithopter, Distrans, final definite Truthtrance, movement-order Sapho and winner-selected multi-card previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and other discard sources.', + 'The guide distinguishes bounded public ordinary-card, paid Box, clean Ornithopter, Distrans, final Truthtrance, movement-order Sapho and mandatory/optional winner battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, exact physical custody, neutral hidden-hand parity, JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, final definite Truthtrance, movement-order Sapho and winner-selected single/multi-card reactions. Other battle batches, full games and live deployment remain unverified.', + 'Genuine setup, physical custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, final Truthtrance, movement-order Sapho and consecutive mandatory/optional winner battle reactions. Loser and mixed-owner batches, full games and live deployment remain unverified.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 4ef5af13..33461331 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -600,3 +600,69 @@ void test('clean winner-selected battle cards allow one committed Semuta choice assert.deepEqual(inventory(alternate), original); assert.deepEqual(settled(alternate), settled(declined)); }); + +void test('a mandatory winner Hero discard can be claimed before optional battle cleanup', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const [hero] = g.deck.splice(g.deck.findIndex(card => card.kind === 'hero'), 1); + const [weapon] = g.deck.splice(g.deck.findIndex(card => card.kind === 'projectile'), 1); + const [defense] = g.deck.splice(g.deck.findIndex(card => card.kind === 'snooper'), 1); + assert.ok(hero && weapon && defense); + player(g, 'a').hand.push(hero, weapon); + player(g, 'e').hand.push(defense); + for (const id of ['a', 'e']) { + player(g, id).forces = { 'arrakeen:10': 5 }; + player(g, id).reserves = 15; + for (const leader of player(g, id).leaders) leader.strength = 0; + } + Object.assign(g, { phase: 6, active: 'a', order: ['a', 'e', 'r'], + response: null, decision: null, phaseOpening: null, ready: [] }); + const original = inventory(g); + let battle = applyAction(g, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' }); + for (let step = 0; step < 40; step++) { + if (battle.response) { + const p = battle.players.find(p => !battle.response!.passed.includes(p.id))!; + battle = applyAction(battle, p.id, { type: 'passResponse' }); + } else if (battle.battle?.preparation) + battle = applyAction(battle, battle.battle.preparation.owner, { type: 'declineBattlePower' }); + else break; + } + if (battle.battle?.preLeader && !battle.battle.preLeader.closed) + for (const id of ['a', 'e']) + battle = applyAction(battle, id, { + type: 'battlePreparationReady', event: battle.battle!.preLeader!.event, + }); + battle = applyAction(battle, 'a', { type: 'battlePlan', dial: 2, support: 2, + leader: hero.id, weapon: weapon.id }); + battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0, + leader: player(battle, 'e').leaders[0].id, defense: defense.id }); + battle = applyAction(battle, 'a', { type: 'traitorCall', call: false }); + const pending = applyAction(battle, 'e', { type: 'traitorCall', call: false }); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [hero.id]); + assert.equal(pending.discard.filter(card => card.id === hero.id).length, 1); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); + assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending)); + let declined = reload(pending); + for (const id of ['e', 'a', 'r']) + declined = applyAction(declined, id, { type: 'semutaPass', event }); + assert.equal(declined.pendingTreacheryDiscard, null); + assert.equal(declined.decision?.kind, 'battleCards'); + assert.equal(declined.discard.filter(card => card.id === hero.id).length, 1); + assert.deepEqual(inventory(declined), original); + const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.decision?.kind, 'battleCards'); + assert.equal(player(claimed, 'a').hand.some(card => card.id === weapon.id), true); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === hero.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(claimed.discard.some(card => card.id === hero.id), false); + assert.deepEqual(inventory(claimed), original); + assert.deepEqual(claimed.lastBattle, declined.lastBattle); + for (const p of claimed.players) + assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); + assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index d1dba000..3e781a45 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -388,5 +388,81 @@ void test('authenticated clean Semuta producers claim once through restart and r assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(battleClaim, auth.playerId)); + + const mandatoryStart = structuredClone(battleClaim); + const heroSemuta = take(mandatoryStart, SEMUTA_DRUG_ID); + mandatoryStart.deck.push(take(mandatoryStart, shield.id)); + seat(mandatoryStart, ids[0]).hand.push(heroSemuta); + const hero = mandatoryStart.deck.splice( + mandatoryStart.deck.findIndex(card => card.kind === 'hero'), 1)[0]; + const nextWeapon = mandatoryStart.deck.splice( + mandatoryStart.deck.findIndex(card => card.kind === 'projectile'), 1)[0]; + const nextDefense = mandatoryStart.deck.splice( + mandatoryStart.deck.findIndex(card => card.kind === 'snooper'), 1)[0]; + assert.ok(hero && nextWeapon && nextDefense); + seat(mandatoryStart, ids[1]).hand.push(hero, nextWeapon); + seat(mandatoryStart, ids[2]).hand.push(nextDefense); + for (const id of [ids[1], ids[2]]) { + const p = seat(mandatoryStart, id); + p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0) - 5; + p.forces = { 'arrakeen:10': 5 }; + for (const leader of p.leaders) leader.strength = 0; + } + Object.assign(mandatoryStart, { turn: 10, phase: 6, active: ids[1], + order: [ids[1], ids[2], ids[0]], ready: [], + decision: null, response: null, phaseOpening: null, stormPending: null, + lastBattle: [], lastBattleContext: null }); + mandatoryStart.version = battleClaim.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(mandatoryStart), mandatoryStart.version, code, battleClaim.version).changes, 1); + let mandatoryBattle = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] }); + for (let step = 0; step < 40; step++) { + if (mandatoryBattle.response) { + const p = mandatoryBattle.players.find(p => !mandatoryBattle.response!.passed.includes(p.id))!; + mandatoryBattle = await act(ids.indexOf(p.id), { type: 'passResponse' }); + } else if (mandatoryBattle.battle?.preparation) + mandatoryBattle = await act(ids.indexOf(mandatoryBattle.battle.preparation.owner), + { type: 'declineBattlePower' }); + else break; + } + if (mandatoryBattle.battle?.preLeader && !mandatoryBattle.battle.preLeader.closed) + for (const index of [1, 2]) + mandatoryBattle = await act(index, { + type: 'battlePreparationReady', event: mandatoryBattle.battle!.preLeader!.event, + }); + mandatoryBattle = await act(1, { type: 'battlePlan', dial: 2, support: 2, + leader: hero.id, weapon: nextWeapon.id }); + mandatoryBattle = await act(2, { type: 'battlePlan', dial: 0, support: 0, + leader: seat(mandatoryBattle, ids[2]).leaders.find(leader => !leader.dead)!.id, + defense: nextDefense.id }); + mandatoryBattle = await act(1, { type: 'traitorCall', call: false }); + const heroOffer = await act(2, { type: 'traitorCall', call: false }); + assert.equal(heroOffer.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard'); + assert.equal(heroOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(heroOffer.discard.filter(card => card.id === hero.id).length, 1); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, heroOffer.version); + assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true); + const heroClaim = await act(0, { type: 'semutaCommit', + event: heroOffer.pendingTreacheryDiscard!.batch.event }); + assert.equal(heroClaim.pendingTreacheryDiscard, null); + assert.equal(heroClaim.decision?.kind, 'battleCards'); + assert.equal(seat(heroClaim, ids[1]).hand.some(card => card.id === nextWeapon.id), true); + assert.equal(seat(heroClaim, ids[0]).hand.filter(card => card.id === hero.id).length, 1); + assert.equal(heroClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(heroClaim.discard.some(card => card.id === hero.id), false); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(heroClaim, auth.playerId)); + let optional = await act(1, { type: 'decision', discard: [nextWeapon.id] }); + assert.equal(optional.pendingTreacheryDiscard?.batch.cause, 'battle:winner'); + assert.equal(optional.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const optionalEvent = optional.pendingTreacheryDiscard!.batch.event; + for (const index of [2, 0, 1]) + optional = await act(index, { type: 'semutaPass', event: optionalEvent }); + assert.equal(optional.pendingTreacheryDiscard, null); + assert.equal(optional.discard.filter(card => card.id === nextWeapon.id).length, 1); + assert.equal(optional.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(seat(optional, ids[0]).hand.filter(card => card.id === hero.id).length, 1); } finally { f.sqlite.close(); } });