From 2ec0422b790fb781c05568fdac9ff7f149453c5f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:38:45 +1000 Subject: [PATCH] Offer Semuta on clean early-ended Ornithopter discard --- docs/CURRENT_STATUS.md | 25 ++++++------- docs/IMPLEMENTATION_STATUS.md | 30 ++++++++++++++++ docs/RICHESE_COMPONENTS.md | 2 +- docs/SEMUTA_SOURCE_UPDATE_20260907.md | 38 +++++++++++--------- game/engine.ts | 9 ++--- game/reference.ts | 8 ++--- tests/semuta-engine.test.ts | 51 +++++++++++++++++++++++++++ tests/semuta-recovery.test.ts | 45 +++++++++++++++++++++++ 8 files changed, 170 insertions(+), 38 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 04cefae4..14285e5e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -7,18 +7,19 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) -now pauses a clean public ordinary-card discard or the used Box after -a completed paid search with the same neutral offer for every seat, -independently of hidden Semuta possession. A holder with a free hand -slot can commit to the one fresh other-player card; its physical claim -and Semuta disposal resume the original effect or Box parent once. -All-seat decline, four minimal legal AI profiles, exact -JSON/SQLite continuation and competing-room CAS are connected. Phone -width showed the owned Commit control and the identical rival Continue -event without a private card face. Multi-card and most other discard -producers, full-hand ordering, normal Richese starts and deployed -acceptance remain unfinished; the neutral passes and free-slot guard -are product safety choices, not publisher timing rulings. +now pauses a clean public ordinary-card discard, the used Box after +a completed paid search, or an Ornithopter retired on early movement +ending with the same neutral offer for every seat, independently of +hidden Semuta possession. A holder with a free hand slot can commit +to the one fresh other-player card; its physical claim and Semuta +disposal resume the original effect, Box parent or movement queue once. +All-seat decline, four minimal legal AI profiles, exact JSON/SQLite +continuation and competing-room CAS are connected. Phone width +showed the owned Commit control and the identical rival Continue +event without a private card face. Completed-flight arrival, multi-card +and most 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. 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 be0b6a11..93884f35 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 30 September 2026 — Semuta after an early-ended Ornithopter flight + +The explicit Richese Semuta preview now interrupts the one public +Ornithopter discarded when its pilot ends movement before using every +available flight group. The first group has already moved its exact +forces and consumed one move; the card then leaves escrow exactly once. +Every seat sees the same neutral event before the saved movement queue +advances. Another holder with hand space can claim that retired physical +card and discard Semuta; all-seat decline leaves it on the pile. Both +paths advance the original turn once without moving forces again. + +Genuine setup exercises a completed first group, early retirement, +JSON restoration, owner-only commitment by the legal AI, all-seat +decline, exact force/card census and stale-action rejection. An +isolated migrated SQLite room recovers the paused flight without +auto-advancing, projects only the owner's Commit control and commits +the claim through authenticated room CAS, preserving seat rows. +Completed flights with arrival/intrusion follow-up, competing suspended +transactions, multiple fresh cards and unresolved full-hand ordering +remain gated. This is not a complete Semuta or deployed Richese path. + +Independent rule/continuation and privacy/recovery reviews found no +scoped defect. A throwaway direct production-engine run completed +genuine AI setup, moved one group, showed a neutral offer with owner-only +Commit, recovered the played card and advanced to the next seat with +one move and one arrived force. A phone-width local rules page rendered +the new boundary and Partial checklist. `npm run check` passed typecheck, +lint and 5,803/5,803 offline cases; `npm run build` and 55/55 Vite +development HTTP cases passed. No production deployment was verified. + ## 30 September 2026 — Semuta after a completed paid Box search The explicit Richese Semuta preview now opens the same neutral diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index b94cd82b..f78a2e64 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, Mirror Weapon and Juice of Sapho remain inactive. Source audits now identify Semuta's fresh-discard continuation requirements and Sapho's scoped ordering requirements; these are not runtime completion claims. Other unfinished faction/module systems, mobile acceptance, difficulty-strength calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. +- Semuta Drug now has an explicit bounded development preview for clean public ordinary-card, completed paid Box and early-ended Ornithopter discards; it does not implement other producers, full-hand ordering or normal Richese starts. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished faction/module systems, mobile acceptance, difficulty-strength 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). diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index b0211686..d31c856d 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -63,28 +63,32 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta The explicit `semuta` development profile starts a fresh Richese roster without opening ordinary expansion starts. It now interrupts **one public -ordinary-card discard or the used Box after a completed paid Nullentropy -search**, provided the parent transaction is clean, with a neutral -fresh-discard opportunity for every seat regardless of hidden Semuta -possession. Each seat can Continue; only the canonical holder with a -free hand slot and a card discarded by another player can commit. The -sole eligible fresh card transfers automatically in the same accepted -action, then Semuta goes to the normal discard pile. The saved ordinary -card effect or Box parent resumes once; passing by every seat preserves -the original discard and effect. No face from an undisclosed event is -projected before commitment. Owner-only multi-candidate selection -support exists in the reaction component and bot adapter, but no -multi-discard producer is activated in this bounded runtime. +ordinary-card discard, the used Box after a completed paid Nullentropy +search, or an Ornithopter retired by ending movement early**, provided +the parent transaction is clean, with a neutral fresh-discard opportunity +for every seat regardless of hidden Semuta possession. The flight's +already committed movement and physical escrow remain intact; a claim +or all-seat decline advances the saved movement queue exactly once. +Each seat can Continue; only the canonical holder with a free hand slot +and a card discarded by another player can commit. The sole eligible +fresh card transfers automatically in the same accepted action, then +Semuta goes to the normal discard pile. The saved ordinary effect or +Box parent resumes once; passing by every seat preserves the original +discard and effect. No face from an undisclosed event is projected +before commitment. Owner-only multi-candidate selection support exists +in the reaction component and bot adapter, but no multi-discard +producer is activated in this bounded runtime. This neutral all-seat acknowledgement is a conservative **application policy**, not a newly discovered publisher timing rule. The explicit 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 and other typed discard producers -retain their existing 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 +pending gifts, competing responses, completed Ornithopter flights with +arrival follow-up and other typed discard producers retain their existing +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 separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why auto-closing only when nobody secretly holds the card would leak possession. The main game and public expansion gates remain closed. diff --git a/game/engine.ts b/game/engine.ts index 4ec63b4c..c9882b60 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5642,16 +5642,17 @@ function treacheryDiscardIntegrity(g: Game) { 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); } -/** Bounded public single-card reaction after a completed ordinary effect or - * paid Box search, without any competing suspended transaction. Other typed - * frames retain automatic continuation until their composition is integrated. */ +/** Bounded public single-card reaction after a completed ordinary effect, paid + * Box search, or early-ended Ornithopter flight. A completed flight's arrival + * may open further entry windows, so that continuation remains automatic. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], entries: FreshDiscardBatch['entries'], ) { if (continuation.kind !== 'ordinaryCardDiscard' && - continuation.kind !== 'nullentropyDiscard') return false; + continuation.kind !== 'nullentropyDiscard' && + !(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end')) return false; return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length === 1 && entries[0].publicFace && diff --git a/game/reference.ts b/game/reference.ts index 6420f516..e7b03376 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1155,7 +1155,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 or the used Box following a completed paid search. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically. Other discard producers, full-hand sequencing and normal Richese starts remain unfinished.' + ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, or early ending an Ornithopter flight. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically before the saved turn advances. A completed flight with arrival follow-up, other discard producers, 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' @@ -1165,7 +1165,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 and completed paid Box discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card or Box parent once. Other typed discard producers and full-hand exchange remain unfinished.', + 'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box and early-ended Ornithopter discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card, Box or movement-turn parent once. Completed-flight arrival and other typed discard producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1183,13 +1183,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes the bounded public ordinary-card prototype, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.', + 'The guide distinguishes bounded public ordinary-card, paid Box and early-ended Ornithopter previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card and paid Box reactions. Other discard producers, full games and live deployment remain unverified.', + 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card, paid Box and early-ended Ornithopter reactions. Other discard producers, 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 84a31a81..2875b8fa 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -212,3 +212,54 @@ void test('a completed paid Box discard can be claimed before resumption without assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); }); + +void test('a retired Ornithopter can be claimed after one flight group without replaying movement', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const ornithopter = take(g, 'richese-ornithopter'); + player(g, 'a').hand.push(ornithopter); + player(g, 'a').forces = { 'imperial_basin:10': 3 }; + player(g, 'a').reserves = 17; + player(g, 'a').shipped = true; + const original = inventory(g); + const first = applyAction(g, 'a', { + type: 'move', movementCard: ornithopter.id, ornithopter: 'twoGroups', + forces: { 'imperial_basin:10': 1 }, territory: 'arrakeen', sector: 10, + }); + assert.equal(first.ornithopter?.completed, 1); + assert.equal(first.discard.some(card => card.id === ornithopter.id), false); + const pending = applyAction(first, 'a', { type: 'endMovement' }); + assert.equal(pending.ornithopter, null); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.discard.filter(card => card.id === ornithopter.id).length, 1); + assert.equal(pending.active, 'a'); + assert.deepEqual(pending.movementRemaining, ['a', 'r', 'e']); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); + assert.equal(viewGame(pending, 'a').semutaReaction?.canCommit, false); + 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.active, 'r'); + assert.deepEqual(declined.movementRemaining, ['r', 'e']); + assert.equal(declined.discard.filter(card => card.id === ornithopter.id).length, 1); + const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.active, 'r'); + assert.deepEqual(claimed.movementRemaining, ['r', 'e']); + assert.equal(player(claimed, 'a').moved, 1); + assert.equal(player(claimed, 'a').forces['arrakeen:10'], 1); + assert.equal(player(claimed, 'a').forces['imperial_basin:10'], 2); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === ornithopter.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(claimed.discard.some(card => card.id === ornithopter.id), false); + assert.deepEqual(inventory(claimed), original); + for (const p of claimed.players) + assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); + assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); + assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index d4a3ae20..3c15b971 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -129,5 +129,50 @@ void test('authenticated ordinary and paid-Box discards preserve one Semuta clai assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimedBox, auth.playerId)); + + const flight = structuredClone(claimedBox); + const reusableSemuta = take(flight, SEMUTA_DRUG_ID); + const ornithopter = take(flight, 'richese-ornithopter'); + seat(flight, ids[0]).hand.push(reusableSemuta); + seat(flight, ids[1]).hand.push(ornithopter); + const pilot = seat(flight, ids[1]); + pilot.reserves += Object.values(pilot.forces).reduce((sum, n) => sum + n, 0) - 3; + pilot.forces = { 'imperial_basin:10': 3 }; + pilot.shipped = true; + pilot.moved = 0; + flight.hajr = []; + Object.assign(flight, { turn: 4, phase: 5, active: ids[1], storm: 18, + ready: [], decision: null, response: null, phaseOpening: null, + stormPending: null, movementRemaining: [ids[1], ids[0], ids[2]] }); + flight.version = claimedBox.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(flight), flight.version, code, claimedBox.version).changes, 1); + const moved = await act(1, { type: 'move', movementCard: ornithopter.id, + ornithopter: 'twoGroups', forces: { 'imperial_basin:10': 1 }, + territory: 'arrakeen', sector: 10 }); + assert.equal(moved.ornithopter?.completed, 1); + const retired = await act(1, { type: 'endMovement' }); + const flightEvent = retired.pendingTreacheryDiscard?.batch.event; + assert.equal(retired.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.ok(flightEvent); + assert.equal(retired.active, ids[1]); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, retired.version); + const flightOwner = await f.restart().readSeatView(code, auths[0]); + const flightObserver = await f.restart().readSeatView(code, auths[2]); + assert.equal(flightOwner.semutaReaction?.canCommit, true); + assert.equal(flightObserver.semutaReaction?.canCommit, false); + const flown = await act(0, { type: 'semutaCommit', event: flightEvent }); + assert.equal(flown.pendingTreacheryDiscard, null); + assert.equal(flown.active, ids[0]); + assert.deepEqual(flown.movementRemaining, [ids[0], ids[2]]); + assert.equal(seat(flown, ids[1]).moved, 1); + assert.equal(seat(flown, ids[1]).forces['arrakeen:10'], 1); + assert.equal(seat(flown, ids[0]).hand.filter(card => card.id === ornithopter.id).length, 1); + assert.equal(flown.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(flown.discard.some(card => card.id === ornithopter.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(flown, auth.playerId)); } finally { f.sqlite.close(); } });