Offer Semuta on clean early-ended Ornithopter discard
This commit is contained in:
1 parent
0e9945e333
commit
2ec0422b79
8 files changed
+170
-38
No files matched your search
+13
-12
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
+5
-4
@@ -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<Game['pendingTreacheryDiscard']>['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 &&
|
||||
|
||||
+4
-4
@@ -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',
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
Reference in new issue
Block a user