Offer Semuta after clean final Truthtrance answer

This commit is contained in:
Quintine committed 2026-09-30 08:03:18 +10:00
1 parent 162f01c160
commit 836af6fb3a
9 files changed
+178 -46

No files matched your search

+16 -15
View File
@@ -7,21 +7,22 @@ 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, the used Box after
a completed paid search, an Ornithopter retired on early movement
ending, or the public used Distrans after a clean private transfer.
Every seat sees the same neutral offer independently of hidden Semuta
possession; the transferred card remains private with its recipient.
A holder with a free hand slot can commit to the fresh other-player
discard; 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 identical rival Continue event without a private
card face. Completed-flight arrival, competing transactions,
multi-card and most other producers, full-hand ordering, normal
Richese starts and deployed acceptance remain unfinished; neutral
passes and free-slot guard are product safety choices, not rulings.
pauses a clean public ordinary-card discard, the used Box after a
paid search, early-ended Ornithopter, public used Distrans after a
private transfer, or a final definite Truthtrance after its answer
is bound. Every seat sees the same neutral offer independently of
hidden Semuta possession. The Distrans gift remains private with
its recipient; Truthtrance history stays fixed through the claim.
A holder with a free hand slot can recover the fresh other-player
discard and dispose Semuta; saved effects and the movement queue
resume once. All-seat decline, minimal legal AI, exact JSON/SQLite
continuation and competing-room CAS are connected. Phone width
showed the owned Commit and rival Continue event without a private
card face. Promise-bound and queued Truthtrance, completed-flight
arrival, competing transactions, multi-card and most other 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,
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 30 September 2026 — Semuta after a final definite Truthtrance answer
The explicit Richese Semuta preview now pauses the public card discard
after a clean final definite Truthtrance answer. The asker has spent
the actual held card, the respondent's answer is already recorded,
and the public history is bound before the neutral all-seat offer.
Another holder with hand space may take only that freshly consumed
card and discard Semuta; all-seat decline retains the original pile.
Neither path re-answers, duplicates a promise, restarts a question,
or changes the recorded fact.
Genuine setup and an isolated migrated SQLite room exercise a real
declaration, priority passes, privately answerable spice/hand fact,
definite answer, persisted reaction and authenticated claim after
restart. JSON projections, custody, public answer history, legal AI,
owner-only commitment, all-seat decline and saved seat rows remain
stable. Setup-era facts, queued follow-ups, promise-bound questions,
competing parent transactions, other discard producers and full-hand
ordering remain outside this bounded preview.
Independent rules/continuation and privacy/recovery reviews found no
scoped defect. A throwaway production-engine run completed genuine
AI setup, declaration, definite hand answer, neutral reaction and
bot claim with one fixed public history entry. The phone-width guide
rendered this boundary as Partial. `npm run check` passed typecheck,
lint and 5,805/5,805 offline cases; `npm run build` and all 55 Vite
development HTTP cases passed. No deployed revision was exercised.
## 30 September 2026 — Semuta after a completed private Distrans transfer
An explicitly opted-in Richese preview now pauses after a clean
+7 -5
View File
@@ -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 now has an explicit bounded development preview for clean public ordinary-card, completed paid Box, early-ended Ornithopter and completed private Distrans transfer discards; the private transferred card is not a fresh target. 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 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, paid Box, early-ended Ornithopter, completed private Distrans and final definite Truthtrance discards; no private gift or unresolved promise becomes a fresh target. 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 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).
@@ -112,7 +112,9 @@ 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
ordinary-card discards and the used Box after a completed paid search.
Other semantic discard families and full-hand ordering remain
unconnected; the ten-card inventory, cache custody and public expansion
start gate are unchanged.
ordinary-card discards, the used Box after a paid search, early-ended
Ornithopter, clean private Distrans transfer and final definite
Truthtrance without a queued question or bound promise. Other semantic
discard families and full-hand ordering remain unconnected; the
ten-card inventory, cache custody and public expansion start gate
are unchanged.
+7
View File
@@ -14,6 +14,13 @@ gifts and competing parents retain the original direct transfer
behavior without this Semuta reaction. The table below remains the
broader producer/continuation inventory, not a claim of complete coverage.
The same preview also pauses a final definite Truthtrance after its
public answer, history and physical card disposal are committed. Only
an ordinary held Truthtrance with no following queued question, new
promise or competing parent qualifies. A claim may move that consumed
card, never re-answer the question or rewrite its history; other
Truthtrance continuations remain automatic.
## Main findings
A post-`applyAction` pile diff cannot implement immediate Semuta. Ix Ambassador and Ixian ally replacement discard then draw directly. Ordinary Karama cancellation/purchase can reach replacement, Harkonnen bonus or a new auction pool indirectly. A stopped frame must resume at a named stage, never rerun its original public action.
+20 -17
View File
@@ -62,18 +62,21 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta
## 30 September 2026 — bounded clean-discard runtime
The explicit `semuta` development profile starts a fresh Richese roster
without opening ordinary expansion starts. It now interrupts **one public
ordinary-card discard, the used Box after a completed paid Nullentropy
search, an Ornithopter retired by ending movement early, or the used
Distrans after a clean completed private transfer**, 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.
Distrans's transferred card stays privately with its recipient and
cannot be claimed; only the played public Distrans enters this fresh
batch. Each seat can Continue; only the canonical holder with a free
hand slot and a card discarded by another player can commit. The sole
without opening ordinary expansion starts. It interrupts **one public
ordinary-card discard, the used Box after a paid Nullentropy search, an
Ornithopter retired by ending movement early, the used Distrans after
a clean private transfer, or a final definite Truthtrance after its
answer is bound**, provided the parent transaction is clean. Every seat
sees a neutral fresh-discard opportunity regardless of hidden Semuta
possession. The flight's committed movement and escrow remain intact;
a claim or all-seat decline advances its saved queue once. Distrans's
transferred card stays privately with its recipient and cannot be
claimed; only the played public Distrans enters the fresh batch. The
Truthtrance's public answer and history are already committed before
the offer, and neither is re-evaluated afterward.
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
@@ -87,11 +90,11 @@ 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, 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
arrival follow-up, queued or promise-bound Truthtrance 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.
+7 -4
View File
@@ -5685,8 +5685,8 @@ function treacheryDiscardIntegrity(g: Game) {
} else throw new RuleError('Unknown saved discard continuation.');
}
/** Bounded public single-card reaction after a completed ordinary effect, paid
* Box search, early-ended Ornithopter flight or clean Distrans transfer. A
* completed flight's arrival may open further entry windows and stays automatic. */
* Box search, early-ended Ornithopter flight, clean Distrans transfer or final
* definite Truthtrance answer without a new promise or parent transaction. */
function semutaOfferSupported(
g: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
@@ -5695,8 +5695,11 @@ function semutaOfferSupported(
if (continuation.kind !== 'ordinaryCardDiscard' &&
continuation.kind !== 'nullentropyDiscard' &&
continuation.kind !== 'distransDiscard' &&
!(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end')) return false;
if (continuation.kind === 'distransDiscard' &&
!(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end') &&
!(continuation.kind === 'truthtranceDiscard' &&
continuation.consumed.source === undefined &&
continuation.remaining === null && continuation.promise === null)) return false;
if ((continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard') &&
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
return g.semutaPreview === true && g.status === 'playing' &&
+4 -4
View File
@@ -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, a completed paid Box search, early ending an Ornithopter flight, or completing a clean private Distrans transfer. Continue or commit your held Semuta with a free hand slot; only the freshly discarded public card transfers, never the private card given to its recipient. Completed-flight arrival, competing transactions, 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, completed paid Box search, early-ended Ornithopter flight, clean private Distrans transfer or final definite Truthtrance answer. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. A Distrans gift remains private with its recipient, and the Truthtrance answer/history is already bound. Competing transactions, later queued questions, 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, completed paid Box, early-ended Ornithopter and completed private Distrans discards with a neutral all-seat offer. Committing held Semuta claims the sole eligible other-player discarded card and discards Semuta. The private Distrans gift remains with its recipient; the other saved effects and movement queue resume once. Completed-flight arrival, competing transactions, other typed producers and full-hand exchange remain unfinished.',
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, early-ended Ornithopter, private Distrans and final definite Truthtrance discards with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and discards Semuta. The private gift stays with its recipient, the answered Truthtrance history remains public, and saved effects or movement resume once. Promise-bound or queued questions, competing transactions, other typed 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 bounded public ordinary-card, paid Box, early-ended Ornithopter and clean Distrans previews, 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, early-ended Ornithopter, clean Distrans and final definite Truthtrance 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 legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter and private Distrans 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 legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter, private Distrans and final definite Truthtrance reactions. Other discard producers, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+49
View File
@@ -320,3 +320,52 @@ void test('a completed private Distrans transfer offers only its public used car
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
void test('a final definite Truthtrance answer offers its consumed card after public history binds', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [];
g.deck.push(hajr);
const truth = take(g, 'truthtrance');
const shield = g.deck.splice(g.deck.findIndex(card => card.name === 'Shield'), 1)[0];
assert.ok(shield);
player(g, 'a').hand.push(truth);
player(g, 'e').hand.push(shield);
const original = inventory(g);
let declared = applyAction(g, 'a', { type: 'card', card: truth.id });
while (declared.truthtrance?.stage === 'priority') {
const responder = declared.players.find(p => !declared.truthtrance!.passed.includes(p.id))!;
declared = applyAction(declared, responder.id, { type: 'truthPass' });
}
const asked = applyAction(declared, 'a', { type: 'truthAsk',
question: { kind: 'fact', target: 'e', fact: { kind: 'hand', name: 'Shield' } },
});
assert.equal(viewGame(asked, 'e').truthAnswer, 'yes');
const pending = applyAction(asked, 'e', { type: 'truthAnswer', answer: 'yes' });
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.truthHistory?.length, 1);
assert.equal(pending.truthHistory?.[0].answer, 'yes');
assert.equal(pending.discard.filter(card => card.id === truth.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.discard.filter(card => card.id === truth.id).length, 1);
assert.deepEqual(declined.truthHistory, pending.truthHistory);
assert.deepEqual(inventory(declined), original);
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.truthtrance, null);
assert.deepEqual(claimed.truthHistory, pending.truthHistory);
assert.equal(player(claimed, 'e').hand.filter(card => card.id === shield.id).length, 1);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === truth.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.discard.some(card => card.id === truth.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.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
+40 -1
View File
@@ -16,7 +16,7 @@ function take(g: Game, identity: string) {
throw new Error('The genuine setup lost a physical card.');
}
void test('authenticated ordinary, paid Box, retired flight and private Distrans claims survive restart and room CAS', async () => {
void test('authenticated clean Semuta producers claim once through restart and room CAS', async () => {
const f = unitStore();
try {
const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']);
@@ -208,5 +208,44 @@ void test('authenticated ordinary, paid Box, retired flight and private Distrans
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(final, auth.playerId));
const finalAnswer = structuredClone(final);
const lastSemuta = take(finalAnswer, SEMUTA_DRUG_ID);
finalAnswer.richeseCache!.push(take(finalAnswer, distrans.id));
seat(finalAnswer, ids[0]).hand.push(lastSemuta);
const truth = take(finalAnswer, 'truthtrance');
seat(finalAnswer, ids[1]).hand.push(truth);
Object.assign(finalAnswer, { turn: 6, phase: 5, active: ids[1], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] });
finalAnswer.version = final.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(finalAnswer), finalAnswer.version, code, final.version).changes, 1);
let question = await act(1, { type: 'card', card: truth.id });
while (question.truthtrance?.stage === 'priority') {
const responder = question.players.find(p => !question.truthtrance!.passed.includes(p.id))!;
question = await act(ids.indexOf(responder.id), { type: 'truthPass' });
}
const targetSpice = seat(question, ids[2]).spice;
question = await act(1, { type: 'truthAsk',
question: { kind: 'fact', target: ids[2],
fact: { kind: 'spice', compare: 'eq', value: targetSpice } } });
const answered = await act(2, { type: 'truthAnswer', answer: 'yes' });
assert.equal(answered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(answered.truthHistory?.at(-1)?.answer, 'yes');
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, answered.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const recovered = await act(0, { type: 'semutaCommit',
event: answered.pendingTreacheryDiscard!.batch.event });
assert.equal(recovered.pendingTreacheryDiscard, null);
assert.equal(recovered.truthtrance, null);
assert.deepEqual(recovered.truthHistory, answered.truthHistory);
assert.equal(seat(recovered, ids[0]).hand.filter(card => card.id === truth.id).length, 1);
assert.equal(recovered.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(recovered.discard.some(card => card.id === truth.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(recovered, auth.playerId));
} finally { f.sqlite.close(); }
});