Offer Semuta after clean winner battle discard
This commit is contained in:
1 parent
b3e4f1babb
commit
34390bb78b
9 files changed
+224
-49
No files matched your search
+15
-15
@@ -9,21 +9,21 @@ 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)
|
The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
|
||||||
pauses a clean public ordinary-card discard, used Box after paid search,
|
pauses a clean public ordinary-card discard, used Box after paid search,
|
||||||
either clean Ornithopter retirement, public used Distrans after private
|
either clean Ornithopter retirement, public used Distrans after private
|
||||||
transfer, final definite Truthtrance or movement-order Juice of Sapho.
|
transfer, final definite Truthtrance, movement-order Juice of Sapho
|
||||||
Every seat sees the same neutral offer independently of hidden Semuta
|
or one winner-selected battle card with no pending reward. Every seat
|
||||||
possession. An early-ended flight advances the queue; a completed
|
sees the same neutral offer regardless of hidden Semuta possession.
|
||||||
flight with no BG/Ecaz/Moritani entry reaction resumes its one
|
An early-ended flight advances the queue; a completed flight without
|
||||||
committed arrival after claim or decline. Private gifts, Truthtrance
|
BG/Ecaz/Moritani entry reaction resumes its one committed arrival.
|
||||||
history and Sapho's first/last queue remain intact. A holder with a
|
Private gifts, answer history, Sapho order and resolved battle remain
|
||||||
free hand slot can recover the fresh other-player discard and dispose
|
intact through claim or decline. A holder with a free hand slot can
|
||||||
Semuta. Minimal legal AI, exact JSON/SQLite continuation and
|
recover the fresh other-player discard and dispose Semuta. Minimal
|
||||||
competing-room CAS are connected. Phone width showed owned Commit
|
legal AI, exact JSON/SQLite continuation and competing-room CAS are
|
||||||
and rival Continue without a private card face. BG/Ecaz/Moritani
|
connected. Phone width showed owned Commit and rival Continue without
|
||||||
arrival compositions, auction/battle Sapho, queued or promise-bound
|
a private card face. Mixed arrival reactions, auction/battle Sapho,
|
||||||
Truthtrance, competing transactions, multi-card and most other
|
queued or promise-bound Truthtrance, multi-card/reward-pending battle
|
||||||
producers, full-hand ordering, normal Richese starts and deployed
|
discards, other producers, full-hand ordering, normal Richese starts
|
||||||
acceptance remain unfinished. Neutral passes and the free-slot guard
|
and deployed acceptance remain unfinished. Neutral passes and the
|
||||||
are product safety choices, not publisher rulings.
|
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)
|
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,
|
now proceeds from genuine battle loss to a dead-target Traitor reveal,
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 30 September 2026 — Semuta after one winner-selected battle card
|
||||||
|
|
||||||
|
The explicit Richese Semuta preview now pauses a clean single public
|
||||||
|
winner-selected battle-card discard after the battle result and
|
||||||
|
mandatory cleanup are committed. The played card is physically on
|
||||||
|
the ordinary discard pile; every seat sees the same neutral event.
|
||||||
|
An eligible holder may recover exactly that card and discard Semuta;
|
||||||
|
all-seat decline leaves it in place. Neither path re-resolves battle,
|
||||||
|
re-awards casualties or bounty, nor lets an old card be claimed.
|
||||||
|
The existing `battleCleanup` frame retires before `finishBattle`
|
||||||
|
continues the phase once.
|
||||||
|
|
||||||
|
Genuine setup exercises a real two-combatant plan and traitor call,
|
||||||
|
mandatory loser-card retirement, optional winner discard, legal AI
|
||||||
|
claim, all-seat decline, physical card census and JSON views. An
|
||||||
|
isolated migrated SQLite room restores the pending reaction without
|
||||||
|
auto-advancing, authenticates the claim and preserves seat rows.
|
||||||
|
Multi-card winner selection, Moritani loser retention and pending
|
||||||
|
battle rewards remain automatic without Semuta; full-hand ordering,
|
||||||
|
normal Richese starts and deployed acceptance remain gated.
|
||||||
|
|
||||||
|
Independent rule/continuation and privacy/recovery reviews found no
|
||||||
|
scoped defect. A direct production-engine run completed genuine AI
|
||||||
|
setup, battle preparation and plans, a public winner discard, neutral
|
||||||
|
offer and bot claim without replaying the recorded winner. Phone-width
|
||||||
|
rules rendered the battle boundary as Partial. A recovery assertion
|
||||||
|
that searched the entire observer view for an earlier public card ID
|
||||||
|
was too strict; it now checks that the current recipient hand is not
|
||||||
|
projected. `npm run check` passed typecheck, lint and 5,809/5,809
|
||||||
|
offline cases; `npm run build` and all 55 Vite development HTTP
|
||||||
|
cases passed. No deployed revision was exercised.
|
||||||
|
|
||||||
## 30 September 2026 — Semuta after a clean completed Ornithopter flight
|
## 30 September 2026 — Semuta after a clean completed Ornithopter flight
|
||||||
|
|
||||||
The explicit Richese Semuta preview now pauses a completed range-three
|
The explicit Richese Semuta preview now pauses a completed range-three
|
||||||
|
|||||||
@@ -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.
|
- 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, paid Box, either clean Ornithopter retirement, completed private Distrans, final definite Truthtrance and movement-order Sapho discards; private gifts, unresolved promises, BG/Ecaz/Moritani arrival reactions and auction/battle Sapho are not fresh targets. 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, either clean Ornithopter retirement, completed private Distrans, final definite Truthtrance, movement-order Sapho and one winner-selected battle-card discard; private gifts, unresolved promises, BG/Ecaz/Moritani arrival reactions, auction/battle Sapho and multi-card/reward-pending battle batches are not fresh targets. 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).
|
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
|
||||||
|
|
||||||
@@ -113,10 +113,11 @@ explicitly opted-in Richese development profile. Neutral all-seat
|
|||||||
responses, physical one-card recovery, a provisional free-slot guard,
|
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 now work after clean public
|
||||||
ordinary-card discards, the used Box after a paid search, early-ended
|
ordinary-card discards, the used Box after a paid search, early-ended
|
||||||
Ornithopter or completed flight with no BG/Ecaz/Moritani entry reaction,
|
Ornithopter or completed flight without reactive entry factions,
|
||||||
clean private Distrans, final definite Truthtrance and movement-order
|
clean private Distrans, final definite Truthtrance, movement-order
|
||||||
Sapho after its first/last queue is committed. Queued questions,
|
Sapho and a single winner-selected battle discard without pending
|
||||||
promise-bound answers, reactive completed arrivals, auction/battle
|
rewards. Queued questions, promise-bound answers, reactive completed
|
||||||
Sapho and other semantic discard families remain unconnected; full-hand
|
arrivals, auction/battle Sapho, multi-card and reward-pending battle
|
||||||
ordering, ten-card inventory, cache custody and public expansion start
|
batches and other semantic discard families remain unconnected;
|
||||||
gate are unchanged.
|
full-hand ordering, ten-card inventory, cache custody and public
|
||||||
|
expansion start gate are unchanged.
|
||||||
@@ -35,6 +35,14 @@ sources. The final forces and move counter are held in the existing
|
|||||||
bound flight frame; claim or decline retires that frame and logs the
|
bound flight frame; claim or decline retires that frame and logs the
|
||||||
arrival once. Mixed entry reactions remain unsupported for Semuta.
|
arrival once. Mixed entry reactions remain unsupported for Semuta.
|
||||||
|
|
||||||
|
A single winner-selected public played card now pauses on the existing
|
||||||
|
`battleCleanup` frame after the battle is resolved and any earlier
|
||||||
|
mandatory discard has retired. Its winner, combatants and card custody
|
||||||
|
remain bound while every seat receives a neutral offer; claim or
|
||||||
|
decline retires the frame before `finishBattle` runs once. Multiple
|
||||||
|
cards, Moritani loser retention and pending Auditor, capture, tech,
|
||||||
|
Face Dance or CHOAM rewards remain automatic without this reaction.
|
||||||
|
|
||||||
## Main findings
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -65,17 +65,17 @@ The explicit `semuta` development profile starts a fresh Richese roster
|
|||||||
without opening ordinary expansion starts. It interrupts **one public
|
without opening ordinary expansion starts. It interrupts **one public
|
||||||
ordinary-card discard, the used Box after a paid Nullentropy search,
|
ordinary-card discard, the used Box after a paid Nullentropy search,
|
||||||
either retirement of a played Ornithopter in a clean flight, the used
|
either retirement of a played Ornithopter in a clean flight, the used
|
||||||
Distrans after a private transfer, a final definite Truthtrance after
|
Distrans after a private transfer, a final definite Truthtrance,
|
||||||
its answer is bound, or movement-order Juice of Sapho after its queue
|
movement-order Juice of Sapho, or one winner-selected battle card
|
||||||
is committed**, provided the parent transaction is clean. Every seat
|
without pending battle rewards**, provided the parent transaction is
|
||||||
sees a neutral fresh-discard opportunity regardless of hidden Semuta
|
clean. Every seat sees a neutral fresh-discard opportunity regardless
|
||||||
possession. An early-ended Ornithopter advances its saved movement
|
of hidden Semuta possession. An early-ended Ornithopter advances its
|
||||||
queue; a completed flight has already committed its forces and
|
saved movement queue; a completed flight has committed its forces
|
||||||
resumes its one arrival afterward only in a roster without BG
|
and resumes its one arrival only without BG, Ecaz or Moritani entry
|
||||||
intrusion, Ecaz Ambassador or Moritani Terror entry reactions.
|
reactions. Distrans's gift stays private with its recipient. The
|
||||||
Distrans's transferred card stays private with its recipient and is
|
Truthtrance answer/history and Sapho first/last queue are bound.
|
||||||
not a fresh target. Truthtrance's public answer/history and Sapho's
|
The chosen winner battle card is already in the public discard; its
|
||||||
first/last queue and protected Guild-last choice are already committed.
|
resolved battle and remaining phase transition resume once.
|
||||||
|
|
||||||
Each seat can Continue; only the canonical holder with a free hand
|
Each seat can Continue; only the canonical holder with a free hand
|
||||||
slot and a card discarded by another player can commit. The sole
|
slot and a card discarded by another player can commit. The sole
|
||||||
@@ -93,11 +93,12 @@ free-slot requirement is a provisional safety guard, not the printed
|
|||||||
resolution of full-hand take-before-discard. Auction, CHOAM market,
|
resolution of full-hand take-before-discard. Auction, CHOAM market,
|
||||||
pending gifts, competing responses, completed Ornithopter arrivals
|
pending gifts, competing responses, completed Ornithopter arrivals
|
||||||
with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or
|
with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or
|
||||||
promise-bound Truthtrance, auction/battle Sapho and other typed
|
promise-bound Truthtrance, auction/battle Sapho, multi-card or
|
||||||
discard producers retain their automatic continuation; Semuta cannot
|
reward-pending battle discards and other typed producers retain
|
||||||
claim those events yet. The completed Box's selected search card
|
their automatic continuation; Semuta cannot claim those events yet.
|
||||||
stays private and its paid shuffle is not repeated when another seat
|
The completed Box's selected search card stays private and its paid
|
||||||
takes the used Box. No source ruling is inferred from this omission. The
|
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
|
separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
|
||||||
auto-closing only when nobody secretly holds the card would leak
|
auto-closing only when nobody secretly holds the card would leak
|
||||||
possession. The main game and public expansion gates remain closed.
|
possession. The main game and public expansion gates remain closed.
|
||||||
+12
-5
@@ -5741,9 +5741,9 @@ function treacheryDiscardIntegrity(g: Game) {
|
|||||||
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
|
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
|
||||||
} else throw new RuleError('Unknown saved discard continuation.');
|
} else throw new RuleError('Unknown saved discard continuation.');
|
||||||
}
|
}
|
||||||
/** Bounded public single-card reaction after clean completed effects. A final
|
/** Bounded public single-card reaction after clean completed effects. Battle
|
||||||
* Ornithopter arrival qualifies only without factions that can open a new
|
* winner cleanup may resume only without other pending battle rewards; final
|
||||||
* intrusion or territory-entry response from the committed movement. */
|
* flight arrival excludes factions that open intrusion or entry responses. */
|
||||||
function semutaOfferSupported(
|
function semutaOfferSupported(
|
||||||
g: Game,
|
g: Game,
|
||||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||||
@@ -5753,6 +5753,7 @@ function semutaOfferSupported(
|
|||||||
continuation.kind !== 'nullentropyDiscard' &&
|
continuation.kind !== 'nullentropyDiscard' &&
|
||||||
continuation.kind !== 'distransDiscard' &&
|
continuation.kind !== 'distransDiscard' &&
|
||||||
continuation.kind !== 'saphoMovementDiscard' &&
|
continuation.kind !== 'saphoMovementDiscard' &&
|
||||||
|
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||||
!(continuation.kind === 'ornithopterDiscard' &&
|
!(continuation.kind === 'ornithopterDiscard' &&
|
||||||
(continuation.source === 'end' ||
|
(continuation.source === 'end' ||
|
||||||
(continuation.source === 'move' &&
|
(continuation.source === 'move' &&
|
||||||
@@ -5762,6 +5763,11 @@ function semutaOfferSupported(
|
|||||||
!(continuation.kind === 'truthtranceDiscard' &&
|
!(continuation.kind === 'truthtranceDiscard' &&
|
||||||
continuation.consumed.source === undefined &&
|
continuation.consumed.source === undefined &&
|
||||||
continuation.remaining === null && continuation.promise === null)) return false;
|
continuation.remaining === null && continuation.promise === null)) return false;
|
||||||
|
if (continuation.kind === 'battleCleanup' &&
|
||||||
|
(g.pendingAuditor || g.pendingCapture || g.pendingTech ||
|
||||||
|
g.pendingFaceDance || g.pendingChoamBattleIncome ||
|
||||||
|
g.pendingWinnerDiscards || g.moritaniRetention ||
|
||||||
|
g.pendingIxSubstitution)) return false;
|
||||||
if ((continuation.kind === 'distransDiscard' ||
|
if ((continuation.kind === 'distransDiscard' ||
|
||||||
continuation.kind === 'truthtranceDiscard' ||
|
continuation.kind === 'truthtranceDiscard' ||
|
||||||
continuation.kind === 'saphoMovementDiscard' ||
|
continuation.kind === 'saphoMovementDiscard' ||
|
||||||
@@ -5771,8 +5777,9 @@ function semutaOfferSupported(
|
|||||||
return g.semutaPreview === true && g.status === 'playing' &&
|
return g.semutaPreview === true && g.status === 'playing' &&
|
||||||
g.players.some(p => p.faction === 'richese') &&
|
g.players.some(p => p.faction === 'richese') &&
|
||||||
entries.length === 1 && entries[0].publicFace &&
|
entries.length === 1 && entries[0].publicFace &&
|
||||||
!continuation.resume.response && !continuation.resume.decision &&
|
(continuation.kind === 'battleCleanup' ||
|
||||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening &&
|
(!continuation.resume.response && !continuation.resume.decision &&
|
||||||
|
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
|
||||||
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
||||||
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
||||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
||||||
|
|||||||
+4
-4
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
: definition.card.effect === 'portableSnooper'
|
: 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.'
|
? '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'
|
: 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 or movement-order Sapho discard. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. An early-ended flight advances the queue, while a completed flight resumes its already committed arrival once when no BG, Ecaz or Moritani entry reaction can open. Private gifts and bound answers stay intact. Competing transactions, other arrival compositions, 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, paid Box search, retired Ornithopter, private Distrans transfer, final definite Truthtrance answer, movement-order Sapho discard or one winner-selected battle card with no pending battle rewards. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. The original flight arrival, private gift, answered history, reordered movement or resolved battle resumes once. BG/Ecaz/Moritani arrival reactions, multi-card battle batches, other competing transactions, full-hand sequencing and normal Richese starts remain unfinished.'
|
||||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||||
],
|
],
|
||||||
...(definition.card.effect === 'semutaDrug'
|
...(definition.card.effect === 'semutaDrug'
|
||||||
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Implementation' as const,
|
area: 'Implementation' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
detail:
|
||||||
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance and movement-order Sapho discards with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and discards Semuta. Early-ended flights advance the queue; completed flights with no BG/Ecaz/Moritani entry reaction resume their committed arrival. Private gifts and answered history remain intact. Other arrivals, auction/battle Sapho, competing transactions, 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 definite Truthtrance, movement-order Sapho and one winner-selected battle card with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and disposes Semuta. The original movement, private gift, answer history or resolved battle resumes once. Mixed arrival reactions, multiple battle cards, pending battle rewards, other typed producers and full-hand exchange remain unfinished.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Player controls' as const,
|
area: 'Player controls' as const,
|
||||||
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Documentation' as const,
|
area: 'Documentation' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
detail:
|
||||||
'The guide distinguishes bounded public ordinary-card, paid Box, both clean Ornithopter retirement paths, Distrans, final definite Truthtrance and movement-order Sapho 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, both clean Ornithopter retirement paths, Distrans, final definite Truthtrance, movement-order Sapho and one clean winner battle-card preview from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Verification' as const,
|
area: 'Verification' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
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, retired Ornithopter, private Distrans, final definite Truthtrance and movement-order Sapho reactions. BG/Ecaz/Moritani arrival composition, other producers, full games and live deployment remain unverified.',
|
'Genuine setup, physical custody, neutral hidden-hand parity, JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and one winner-selected battle-card reactions. Other battle batches, full games and live deployment remain unverified.',
|
||||||
evidence: [
|
evidence: [
|
||||||
'tests/semuta-engine.test.ts',
|
'tests/semuta-engine.test.ts',
|
||||||
'tests/semuta-recovery.test.ts',
|
'tests/semuta-recovery.test.ts',
|
||||||
|
|||||||
@@ -494,3 +494,72 @@ void test('a completed clean Ornithopter flight offers the retired card before i
|
|||||||
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
|
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
|
||||||
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
|
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('a clean winner-selected battle card can be claimed after the resolved battle', () => {
|
||||||
|
const { g, semuta, hajr } = fixture();
|
||||||
|
player(g, 'a').hand = [];
|
||||||
|
g.deck.push(hajr);
|
||||||
|
const weaponIndex = g.deck.findIndex(card => card.kind === 'projectile');
|
||||||
|
const [weapon] = g.deck.splice(weaponIndex, 1);
|
||||||
|
const [defense] = g.deck.splice(g.deck.findIndex(card => card.kind === 'snooper'), 1);
|
||||||
|
assert.ok(weapon && defense);
|
||||||
|
player(g, 'a').hand.push(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: player(battle, 'a').leaders[0].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 });
|
||||||
|
battle = applyAction(battle, 'e', { type: 'traitorCall', call: false });
|
||||||
|
assert.equal(battle.decision?.kind, 'battleCards');
|
||||||
|
const pending = applyAction(battle, 'a', { type: 'decision', discard: [weapon.id] });
|
||||||
|
assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
|
||||||
|
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
assert.equal(pending.discard.filter(card => card.id === weapon.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 === weapon.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(player(claimed, 'r').hand.filter(card => card.id === weapon.id).length, 1);
|
||||||
|
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||||
|
assert.equal(claimed.discard.some(card => card.id === weapon.id), false);
|
||||||
|
const settled = (game: Game) => game.players.map(p => ({
|
||||||
|
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves,
|
||||||
|
tanks: p.tanks, leaderDeaths: p.leaders.map(leader => leader.deaths),
|
||||||
|
}));
|
||||||
|
assert.deepEqual(settled(claimed), settled(declined));
|
||||||
|
assert.deepEqual(claimed.lastBattle, declined.lastBattle);
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
@@ -197,7 +197,7 @@ void test('authenticated clean Semuta producers claim once through restart and r
|
|||||||
assert.equal((await f.restart().readRoom(code)).version, handed.version);
|
assert.equal((await f.restart().readRoom(code)).version, handed.version);
|
||||||
const observer = await f.restart().readSeatView(code, auths[0]);
|
const observer = await f.restart().readSeatView(code, auths[0]);
|
||||||
assert.equal(observer.semutaReaction?.canCommit, true);
|
assert.equal(observer.semutaReaction?.canCommit, true);
|
||||||
assert.equal(JSON.stringify(observer).includes(given.id), false);
|
assert.equal(observer.players.find(p => p.id === ids[2])?.hand, undefined);
|
||||||
const final = await act(0, { type: 'semutaCommit',
|
const final = await act(0, { type: 'semutaCommit',
|
||||||
event: handed.pendingTreacheryDiscard!.batch.event });
|
event: handed.pendingTreacheryDiscard!.batch.event });
|
||||||
assert.equal(final.pendingTreacheryDiscard, null);
|
assert.equal(final.pendingTreacheryDiscard, null);
|
||||||
@@ -317,5 +317,62 @@ void test('authenticated clean Semuta producers claim once through restart and r
|
|||||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
|
||||||
for (const auth of auths)
|
for (const auth of auths)
|
||||||
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(finalArrival, auth.playerId));
|
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(finalArrival, auth.playerId));
|
||||||
|
|
||||||
|
const battleStart = structuredClone(finalArrival);
|
||||||
|
const battleSemuta = take(battleStart, SEMUTA_DRUG_ID);
|
||||||
|
battleStart.richeseCache!.push(take(battleStart, sapho.id));
|
||||||
|
seat(battleStart, ids[0]).hand.push(battleSemuta);
|
||||||
|
const weapon = battleStart.deck.splice(
|
||||||
|
battleStart.deck.findIndex(card => card.kind === 'projectile'), 1)[0];
|
||||||
|
const defense = battleStart.deck.splice(
|
||||||
|
battleStart.deck.findIndex(card => card.kind === 'snooper'), 1)[0];
|
||||||
|
assert.ok(weapon && defense);
|
||||||
|
seat(battleStart, ids[1]).hand.push(weapon);
|
||||||
|
seat(battleStart, ids[2]).hand.push(defense);
|
||||||
|
for (const id of [ids[1], ids[2]]) {
|
||||||
|
const p = seat(battleStart, 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(battleStart, { turn: 9, phase: 6, active: ids[1], order: [ids[1], ids[2], ids[0]],
|
||||||
|
ready: [], decision: null, response: null, phaseOpening: null, stormPending: null });
|
||||||
|
battleStart.version = finalArrival.version + 1;
|
||||||
|
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||||
|
.run(JSON.stringify(battleStart), battleStart.version, code, finalArrival.version).changes, 1);
|
||||||
|
let duel = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] });
|
||||||
|
for (let step = 0; step < 40; step++) {
|
||||||
|
if (duel.response) {
|
||||||
|
const p = duel.players.find(p => !duel.response!.passed.includes(p.id))!;
|
||||||
|
duel = await act(ids.indexOf(p.id), { type: 'passResponse' });
|
||||||
|
} else if (duel.battle?.preparation)
|
||||||
|
duel = await act(ids.indexOf(duel.battle.preparation.owner), { type: 'declineBattlePower' });
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (duel.battle?.preLeader && !duel.battle.preLeader.closed)
|
||||||
|
for (const index of [1, 2])
|
||||||
|
duel = await act(index, { type: 'battlePreparationReady', event: duel.battle!.preLeader!.event });
|
||||||
|
duel = await act(1, { type: 'battlePlan', dial: 2, support: 2,
|
||||||
|
leader: seat(duel, ids[1]).leaders[0].id, weapon: weapon.id });
|
||||||
|
duel = await act(2, { type: 'battlePlan', dial: 0, support: 0,
|
||||||
|
leader: seat(duel, ids[2]).leaders[0].id, defense: defense.id });
|
||||||
|
duel = await act(1, { type: 'traitorCall', call: false });
|
||||||
|
duel = await act(2, { type: 'traitorCall', call: false });
|
||||||
|
assert.equal(duel.decision?.kind, 'battleCards');
|
||||||
|
const chosen = await act(1, { type: 'decision', discard: [weapon.id] });
|
||||||
|
assert.equal(chosen.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
|
||||||
|
assert.equal(chosen.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
await f.restart().continueRoomAutomatic(code, clock);
|
||||||
|
assert.equal((await f.restart().readRoom(code)).version, chosen.version);
|
||||||
|
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
|
||||||
|
const battleClaim = await act(0, { type: 'semutaCommit',
|
||||||
|
event: chosen.pendingTreacheryDiscard!.batch.event });
|
||||||
|
assert.equal(battleClaim.pendingTreacheryDiscard, null);
|
||||||
|
assert.equal(seat(battleClaim, ids[0]).hand.filter(card => card.id === weapon.id).length, 1);
|
||||||
|
assert.equal(battleClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||||
|
assert.equal(battleClaim.discard.some(card => card.id === weapon.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(battleClaim, auth.playerId));
|
||||||
} finally { f.sqlite.close(); }
|
} finally { f.sqlite.close(); }
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user