Compose Semuta with completed paid Box discard
This commit is contained in:
1 parent
9ea50a792e
commit
0e9945e333
9 files changed
+146
-28
No files matched your search
@@ -7,14 +7,15 @@ 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 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 once. All-seat decline, four minimal legal AI profiles, exact
|
||||
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 other discard
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 30 September 2026 — Semuta after a completed paid Box search
|
||||
|
||||
The explicit Richese Semuta preview now opens the same neutral
|
||||
all-seat response when a clean paid Nullentropy Box search finishes
|
||||
and places its used physical Box on the discard pile. The search
|
||||
owner has already paid two spice, privately recovered its chosen
|
||||
card and shuffled the remaining pile once. Another holder may commit
|
||||
Semuta and take only the freshly discarded Box; declining preserves
|
||||
the Box on top. Neither path re-pays, reselects, reshuffles or
|
||||
reopens the search. Older discard cards and the privately recovered
|
||||
card are not fresh targets.
|
||||
|
||||
Genuine-setup engine cases verify both claim and all-pass paths,
|
||||
exact card census and discard order, every-seat JSON restoration
|
||||
and stable two-spice payment. An isolated migrated SQLite room
|
||||
restores the Box reaction after the paid selection, leaves automatic
|
||||
recovery idle until a real choice and commits the claim through
|
||||
authenticated seat control without changing saved seat rows.
|
||||
Independent privacy and continuation reviews found no scoped issue.
|
||||
This extends the previous phone-width neutral control surface; no
|
||||
new browser POST was sent. Auction/CHOAM-market overlaps, multiple
|
||||
fresh cards, other producer families and full-hand ordering remain
|
||||
gated. This is not a complete Semuta, Richese or deployed claim.
|
||||
|
||||
`npm run check` passes typecheck, lint and 5,802/5,802 offline cases;
|
||||
`npm run build` and the 55/55 local development HTTP cases pass.
|
||||
The phone-width rules deep link shows the updated Box target boundary
|
||||
and partial checklist. No authenticated production Box/Semuta play or
|
||||
deployed revision was exercised.
|
||||
|
||||
## 30 September 2026 — neutral Semuta clean-discard prototype
|
||||
|
||||
The [bounded runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
|
||||
|
||||
@@ -112,6 +112,7 @@ 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. The other semantic discard families and
|
||||
full-hand ordering are not connected; the ten-card inventory, cache
|
||||
custody and public expansion start gate remain unchanged.
|
||||
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.
|
||||
@@ -63,17 +63,18 @@ 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** from a clean transaction with a neutral
|
||||
fresh-discard opportunity for every seat, regardless of hidden Semuta
|
||||
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 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 first boundary.
|
||||
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.
|
||||
|
||||
This neutral all-seat acknowledgement is a conservative **application
|
||||
policy**, not a newly discovered publisher timing rule. The explicit
|
||||
@@ -81,7 +82,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 and other typed discard producers
|
||||
retain their existing automatic continuation; Semuta cannot claim those
|
||||
events yet. No source ruling is inferred from this omission. The
|
||||
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.
|
||||
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||||
if (definition?.card.effect === 'distrans')
|
||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||
if (definition?.card.effect === 'semutaDrug')
|
||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Only clean public ordinary-card discards are connected; other discard events remain unfinished.';
|
||||
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean public ordinary-card discards and the used Box after a completed paid search are connected; other discard events remain unfinished.';
|
||||
return definition && definition.card.effect !== 'karama'
|
||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||
: null;
|
||||
@@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
richese.card.effect === 'juiceOfSapho'
|
||||
? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.'
|
||||
: richese.card.effect === 'semutaDrug'
|
||||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.'
|
||||
? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards and completed paid Box disposal, with owner-only commitment and a conservative free-slot requirement. Other discard producers, full-hand sequencing and normal expansion starts remain unfinished.'
|
||||
: richese.card.effect === 'mirrorWeapon'
|
||||
? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.'
|
||||
: richese.card.effect === 'stoneBurner'
|
||||
|
||||
+5
-4
@@ -5642,17 +5642,18 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
|
||||
} else throw new RuleError('Unknown saved discard continuation.');
|
||||
}
|
||||
/** First playable Semuta boundary: one public ordinary-card discard with no
|
||||
* competing suspended transaction. Other typed frames retain their existing
|
||||
* automatic continuation until their reaction composition is integrated. */
|
||||
/** 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. */
|
||||
function semutaOfferSupported(
|
||||
g: Game,
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
entries: FreshDiscardBatch['entries'],
|
||||
) {
|
||||
if (continuation.kind !== 'ordinaryCardDiscard' &&
|
||||
continuation.kind !== 'nullentropyDiscard') return false;
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
continuation.kind === 'ordinaryCardDiscard' &&
|
||||
entries.length === 1 && entries[0].publicFace &&
|
||||
!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening &&
|
||||
|
||||
+4
-4
@@ -941,7 +941,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An active Truthtrance question and exact card or transaction commitments must resolve as required. A full hand is currently guarded: the printed sequence adds the recovered card before discarding the Box, and the temporary capacity question is unresolved. The current implementation requires a pre-existing free hand slot. This is not a verified prohibition on every net-zero exchange.',
|
||||
'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
|
||||
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
|
||||
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation, the provisional Guild refund interaction and Semuta response to the used Box remain unresolved or unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path; Semuta Drug has a separate clean public ordinary-card preview, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
|
||||
'This bounded search, its controls and recovery are integrated in development fixtures. Full-hand activation and the provisional Guild refund interaction remain unresolved. In the explicit Semuta preview, another holder may recover the used Box after a clean completed search without replaying payment or shuffle; other Semuta discard sources remain unfinished. Mirror Weapon has a separate bounded CHOAM/Richese-deck classic-faction battle path, and full Richese starts remain disabled. Complete browser, mobile and full-game acceptance is not claimed here.',
|
||||
],
|
||||
related: [
|
||||
'card-richese-nullentropy-box',
|
||||
@@ -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. 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 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.'
|
||||
: '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 now interrupts clean public ordinary-card discards with a neutral all-seat offer. Committing a held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card suffix once. Other typed discard producers and full-hand exchange remain unfinished.',
|
||||
'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.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls' as const,
|
||||
@@ -1189,7 +1189,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
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 the bounded ordinary-card reaction. 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 and paid Box reactions. Other discard producers, full games and live deployment remain unverified.',
|
||||
evidence: [
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
|
||||
@@ -166,3 +166,49 @@ void test('an impossible saved all-passed offer rejects instead of waiting forev
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt));
|
||||
assert.equal(JSON.stringify(corrupt), before);
|
||||
});
|
||||
|
||||
void test('a completed paid Box discard can be claimed before resumption without repeating its search', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
player(g, 'a').hand = [];
|
||||
g.deck.push(hajr);
|
||||
const box = take(g, 'richese-nullentropy-box');
|
||||
player(g, 'a').hand.push(box);
|
||||
g.discard.push(g.deck.shift()!, g.deck.shift()!);
|
||||
g.phase = 4;
|
||||
g.active = null;
|
||||
const original = inventory(g);
|
||||
const spice = player(g, 'a').spice;
|
||||
const paid = applyAction(g, 'a', { type: 'card', card: box.id });
|
||||
assert.ok(paid.pendingNullentropy);
|
||||
assert.equal(player(paid, 'a').spice, spice - 2);
|
||||
const target = paid.discard[0].id;
|
||||
const pending = applyAction(paid, 'a', {
|
||||
type: 'decision', event: paid.pendingNullentropy.event, card: target,
|
||||
});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const shuffled = pending.discard.filter(card => card.id !== box.id).map(card => card.id);
|
||||
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(pending, 'a').semutaReaction?.canCommit, false);
|
||||
assert.deepEqual(viewGame(pending, 'r').semutaReaction?.candidates, []);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
let declined = reload(pending);
|
||||
for (const id of ['r', 'e', 'a']) declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||||
assert.equal(declined.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(declined.discard, pending.discard);
|
||||
assert.equal(player(declined, 'a').spice, spice - 2);
|
||||
assert.equal(player(declined, 'a').hand.filter(card => card.id === target).length, 1);
|
||||
assert.deepEqual(inventory(declined), original);
|
||||
const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event });
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(claimed, 'a').spice, spice - 2);
|
||||
assert.equal(player(claimed, 'a').hand.filter(card => card.id === target).length, 1);
|
||||
assert.equal(player(claimed, 'r').hand.filter(card => card.id === box.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.deepEqual(claimed.discard.filter(card => card.id !== semuta.id).map(card => card.id), shuffled);
|
||||
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));
|
||||
});
|
||||
@@ -16,7 +16,7 @@ function take(g: Game, identity: string) {
|
||||
throw new Error('The genuine setup lost a physical card.');
|
||||
}
|
||||
|
||||
void test('authenticated fresh discard survives restart and duplicate Semuta commits transfer one physical card', async () => {
|
||||
void test('authenticated ordinary and paid-Box discards preserve one Semuta claim through restart and room CAS', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']);
|
||||
@@ -93,5 +93,41 @@ void test('authenticated fresh discard survives restart and duplicate Semuta com
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
|
||||
for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(done, auth.playerId));
|
||||
|
||||
const next = structuredClone(done);
|
||||
const usedSemuta = take(next, SEMUTA_DRUG_ID);
|
||||
const box = take(next, 'richese-nullentropy-box');
|
||||
seat(next, ids[0]).hand.push(usedSemuta);
|
||||
seat(next, ids[1]).hand.push(box);
|
||||
const older = [next.deck.shift(), next.deck.shift()];
|
||||
assert.ok(older[0] && older[1]);
|
||||
next.discard.push(older[0], older[1]);
|
||||
Object.assign(next, { turn: 3, phase: 4, active: null, ready: [], decision: null,
|
||||
response: null, phaseOpening: null, stormPending: null });
|
||||
next.version = done.version + 1;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(next), next.version, code, done.version).changes, 1);
|
||||
const beforeSpice = seat(next, ids[1]).spice;
|
||||
const paid = await act(1, { type: 'card', card: box.id });
|
||||
assert.ok(paid.pendingNullentropy);
|
||||
const selected = paid.discard[0].id;
|
||||
const offered = await act(1, { type: 'decision', event: paid.pendingNullentropy.event, card: selected });
|
||||
assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal((await f.restart().readRoom(code)).version, offered.version);
|
||||
const claimedBox = await act(0, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event });
|
||||
assert.equal(claimedBox.pendingTreacheryDiscard, null);
|
||||
assert.equal(seat(claimedBox, ids[1]).spice, beforeSpice - 2);
|
||||
assert.equal(seat(claimedBox, ids[1]).hand.filter(card => card.id === selected).length, 1);
|
||||
assert.equal(seat(claimedBox, ids[0]).hand.filter(card => card.id === box.id).length, 1);
|
||||
assert.equal(claimedBox.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(claimedBox.discard.some(card => card.id === box.id), false);
|
||||
const finalCards = [...claimedBox.deck, ...claimedBox.discard, ...claimedBox.richeseCache!,
|
||||
...claimedBox.players.flatMap(p => p.hand)];
|
||||
assert.equal(new Set(finalCards.map(card => card.id)).size, finalCards.length);
|
||||
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));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user