Offer Semuta before optional winner battle cleanup
This commit is contained in:
1 parent
593ca3beb4
commit
e55d275014
9 files changed
+228
-46
No files matched your search
+16
-15
@@ -8,22 +8,23 @@ 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 clean public ordinary-card, paid Box, retired Ornithopter,
|
pauses clean public ordinary-card, paid Box, retired Ornithopter,
|
||||||
private Distrans, final definite Truthtrance, movement-order Sapho
|
private Distrans, final definite Truthtrance, movement-order Sapho,
|
||||||
and winner-selected battle-card discards without pending rewards.
|
mandatory winner or later optional winner battle-card discards without
|
||||||
|
pending rewards. The two winner batches are separate: a claim on the
|
||||||
|
mandatory Hero can resume the winner's optional weapon choice once.
|
||||||
Every seat sees a neutral offer regardless of hidden Semuta possession.
|
Every seat sees a neutral offer regardless of hidden Semuta possession.
|
||||||
A sole eligible card transfers on commitment; a simultaneous
|
A sole eligible card transfers on commitment; a simultaneous winner
|
||||||
winner-card batch first requires public commitment, then reveals
|
batch first requires public commitment, then reveals eligible faces
|
||||||
only to that holder the eligible fresh faces to select exactly one.
|
only to the holder for one-card selection. Other cards remain in the
|
||||||
Other cards remain in the pile and the resolved battle resumes once.
|
pile and the resolved battle resumes once. The flight queue, committed
|
||||||
The early-ended flight queue, completed-flight arrival, private gift,
|
arrival, private gift, answer history and Sapho order also resume
|
||||||
answer history and Sapho order also resume without replay. Minimal
|
without replay. Minimal legal AI, exact JSON/SQLite continuation and
|
||||||
legal AI, exact JSON/SQLite continuation and competing-room CAS are
|
competing-room CAS are connected. Mixed arrivals, auction/battle
|
||||||
connected. Phone width showed owned Commit and rival Continue without
|
Sapho, queued or promise-bound Truthtrance, loser/mixed-owner battle
|
||||||
a private card face. Mixed arrivals, auction/battle Sapho, queued or
|
batches, pending battle rewards, other discard producers, full-hand
|
||||||
promise-bound Truthtrance, mandatory or reward-pending battle batches,
|
ordering, normal Richese starts and deployed acceptance remain
|
||||||
other discard producers, full-hand ordering, normal Richese starts
|
unfinished. Neutral passes and free-slot guard are product safety
|
||||||
and deployed acceptance remain unfinished. Neutral passes and the
|
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,35 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 30 September 2026 — Semuta between mandatory and optional winner cleanup
|
||||||
|
|
||||||
|
The explicit Richese Semuta preview now pauses a clean public
|
||||||
|
`winnerMandatoryDiscard` frame after the loser/battle discard but
|
||||||
|
before optional winning-card cleanup. A physically used Hero is
|
||||||
|
already in the ordinary pile; every seat receives the same neutral
|
||||||
|
offer. Another holder with free hand capacity may recover that one
|
||||||
|
Hero and discard Semuta. All-seat decline leaves it on the pile.
|
||||||
|
The committed casualty counts and winner remain fixed; `finishWinner`
|
||||||
|
continues once to the still-held optional weapon decision. Its later
|
||||||
|
discard is a distinct fresh batch, not another chance at the Hero.
|
||||||
|
|
||||||
|
Genuine setup covers two combatants, preparation, Hero plus retained
|
||||||
|
projectile, the mandatory Hero offer, neutral passes, legal AI claim,
|
||||||
|
JSON continuation and exact physical census. An isolated migrated
|
||||||
|
SQLite room restores that offer, authenticates the Hero claim, then
|
||||||
|
opens and declines the later optional weapon batch without repeating
|
||||||
|
Semuta disposal or battle rewards. Loser/mixed-owner mandatory cleanup,
|
||||||
|
pending battle rewards, full-hand ordering, normal Richese starts
|
||||||
|
and deployed acceptance remain gated.
|
||||||
|
|
||||||
|
Independent rules/continuation and privacy/recovery reviews found no
|
||||||
|
scoped defect. A direct production-engine run completed genuine AI
|
||||||
|
setup, a mandatory Hero discard, neutral offer and bot claim, then
|
||||||
|
showed the retained projectile in the original winner's optional
|
||||||
|
card decision. Phone-width local rules rendered the two-batch boundary
|
||||||
|
as Partial. `npm run check` passed typecheck, lint and 5,810/5,810
|
||||||
|
offline cases; `npm run build` and all 55 Vite development HTTP
|
||||||
|
cases passed. No deployed revision was exercised.
|
||||||
|
|
||||||
## 30 September 2026 — Semuta choice among simultaneous winner cards
|
## 30 September 2026 — Semuta choice among simultaneous winner cards
|
||||||
|
|
||||||
The explicit Richese Semuta preview now recognizes a clean batch of
|
The explicit Richese Semuta preview now recognizes a clean batch of
|
||||||
|
|||||||
@@ -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 has an explicit bounded development preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and winner-selected battle-card batches. A multi-card winner batch exposes candidates only after holder commitment and transfers one physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho and mandatory/reward-pending battle batches remain outside scope. 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 systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed.
|
- Semuta Drug has an explicit bounded development preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and separate mandatory/optional winner battle-card batches. A multi-card winner batch exposes candidates only after holder commitment and transfers one physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho, loser/mixed-owner battle batches and pending battle rewards remain outside scope. 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 systems, mobile acceptance, difficulty 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).
|
||||||
|
|
||||||
@@ -111,12 +111,12 @@ The [bounded Semuta runtime](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026-
|
|||||||
supersedes the older inactive-card observations above for one
|
supersedes the older inactive-card observations above for one
|
||||||
explicitly opted-in Richese development profile. Neutral all-seat
|
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 work after clean public
|
||||||
ordinary-card, paid Box, retired Ornithopter, private Distrans, final
|
ordinary-card, paid Box, retired Ornithopter, private Distrans, final
|
||||||
definite Truthtrance, movement-order Sapho and winner-selected
|
definite Truthtrance, movement-order Sapho and separate mandatory
|
||||||
battle-card discards, including a committed owner-only multi-card
|
and optional winner battle-card batches, including committed
|
||||||
choice. Queued questions, promise-bound answers, reactive arrivals,
|
owner-only multi-card selection. Queued questions, promise-bound
|
||||||
auction/battle Sapho, mandatory or reward-pending battle batches
|
answers, reactive arrivals, auction/battle Sapho, loser or mixed-owner
|
||||||
and other semantic discard families remain unconnected. Full-hand
|
battle batches and pending battle rewards remain unconnected.
|
||||||
ordering, ten-card inventory, cache custody and public expansion
|
Full-hand ordering, ten-card inventory, cache custody and public
|
||||||
start gate are unchanged.
|
expansion start gate are unchanged.
|
||||||
@@ -35,14 +35,16 @@ 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.
|
||||||
|
|
||||||
Winner-selected public played cards now pause as one `battleCleanup`
|
Mandatory winner cleanup now pauses at `winnerMandatoryDiscard`
|
||||||
batch after the battle is resolved and any earlier mandatory discard
|
after any earlier loser/battle batch has retired; a Hero already in
|
||||||
has retired. The winner, combatants and all selected card identities
|
the public pile can be claimed before the exact optional winner
|
||||||
remain bound. Every seat sees the same neutral offer; only a committed
|
choice resumes. If the winner then selects other played cards,
|
||||||
holder inspects the eligible batch and selects one of multiple cards.
|
their public disposal is a separate `battleCleanup` batch. Each
|
||||||
The others remain in the pile before `finishBattle` runs once. Moritani
|
stage has a neutral all-seat offer; only a committed holder inspects
|
||||||
loser retention and pending Auditor, capture, tech, Face Dance or
|
multiple eligible cards, selects one, and leaves the others in the
|
||||||
CHOAM rewards remain automatic without this reaction.
|
pile. No battle award or card cost repeats. Moritani loser retention
|
||||||
|
and pending Auditor, capture, tech, Face Dance or CHOAM rewards
|
||||||
|
remain automatic without this reaction.
|
||||||
|
|
||||||
## Main findings
|
## Main findings
|
||||||
|
|
||||||
|
|||||||
@@ -66,16 +66,19 @@ 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,
|
Distrans after a private transfer, a final definite Truthtrance,
|
||||||
movement-order Juice of Sapho, or a winner-selected battle-card batch
|
movement-order Juice of Sapho, mandatory winning-card cleanup, or a
|
||||||
without pending battle rewards**, provided the parent transaction is
|
later winner-selected battle-card batch without pending rewards**,
|
||||||
clean. Every seat sees a neutral fresh-discard opportunity regardless
|
provided the parent transaction is clean. Every seat sees a neutral
|
||||||
|
fresh-discard opportunity regardless
|
||||||
of hidden Semuta possession. An early-ended Ornithopter advances its
|
of hidden Semuta possession. An early-ended Ornithopter advances its
|
||||||
saved movement queue; a completed flight has committed its forces
|
saved movement queue; a completed flight has committed its forces
|
||||||
and resumes its one arrival only without BG, Ecaz or Moritani entry
|
and resumes its one arrival only without BG, Ecaz or Moritani entry
|
||||||
reactions. Distrans's gift stays private with its recipient. The
|
reactions. Distrans's gift stays private with its recipient. The
|
||||||
Truthtrance answer/history and Sapho first/last queue are bound.
|
Truthtrance answer/history and Sapho first/last queue are bound.
|
||||||
The winner's chosen played cards are already in one public fresh
|
Mandatory winning cards form one public fresh batch after the
|
||||||
batch; its resolved battle and remaining phase transition resume once.
|
previous loser cleanup, and any later optional winner selection is a
|
||||||
|
separate batch. Neither a Semuta claim nor all-seat decline replays
|
||||||
|
casualties or re-discards an already claimed card.
|
||||||
|
|
||||||
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. A sole
|
slot and a card discarded by another player can commit. A sole
|
||||||
@@ -93,9 +96,9 @@ 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, mandatory/reward-pending
|
promise-bound Truthtrance, auction/battle Sapho, loser or mixed-owner
|
||||||
battle batches and other typed producers retain their automatic
|
mandatory battle batches and pending battle rewards retain their
|
||||||
continuation; Semuta cannot claim those events yet.
|
automatic continuation; Semuta cannot claim those events yet.
|
||||||
The completed Box's selected search card stays private and its paid
|
The completed Box's selected search card stays private and its paid
|
||||||
shuffle is not repeated when another seat takes the used Box.
|
shuffle is not repeated when another seat takes the used Box.
|
||||||
No source ruling is inferred from this omission. The
|
No source ruling is inferred from this omission. The
|
||||||
|
|||||||
+6
-2
@@ -5754,6 +5754,7 @@ function semutaOfferSupported(
|
|||||||
continuation.kind !== 'distransDiscard' &&
|
continuation.kind !== 'distransDiscard' &&
|
||||||
continuation.kind !== 'saphoMovementDiscard' &&
|
continuation.kind !== 'saphoMovementDiscard' &&
|
||||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||||
|
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||||
!(continuation.kind === 'ornithopterDiscard' &&
|
!(continuation.kind === 'ornithopterDiscard' &&
|
||||||
(continuation.source === 'end' ||
|
(continuation.source === 'end' ||
|
||||||
(continuation.source === 'move' &&
|
(continuation.source === 'move' &&
|
||||||
@@ -5763,7 +5764,8 @@ 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' &&
|
if ((continuation.kind === 'battleCleanup' ||
|
||||||
|
continuation.kind === 'winnerMandatoryDiscard') &&
|
||||||
(g.pendingAuditor || g.pendingCapture || g.pendingTech ||
|
(g.pendingAuditor || g.pendingCapture || g.pendingTech ||
|
||||||
g.pendingFaceDance || g.pendingChoamBattleIncome ||
|
g.pendingFaceDance || g.pendingChoamBattleIncome ||
|
||||||
g.pendingWinnerDiscards || g.moritaniRetention ||
|
g.pendingWinnerDiscards || g.moritaniRetention ||
|
||||||
@@ -5777,10 +5779,12 @@ 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 > 0 &&
|
entries.length > 0 &&
|
||||||
(continuation.kind === 'battleCleanup'
|
(continuation.kind === 'battleCleanup' ||
|
||||||
|
continuation.kind === 'winnerMandatoryDiscard'
|
||||||
? entries.every(entry => entry.publicFace)
|
? entries.every(entry => entry.publicFace)
|
||||||
: entries.length === 1 && entries[0].publicFace) &&
|
: entries.length === 1 && entries[0].publicFace) &&
|
||||||
(continuation.kind === 'battleCleanup' ||
|
(continuation.kind === 'battleCleanup' ||
|
||||||
|
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||||
(!continuation.resume.response && !continuation.resume.decision &&
|
(!continuation.resume.response && !continuation.resume.decision &&
|
||||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
|
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
|
||||||
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
||||||
|
|||||||
+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, movement-order Sapho discard or winner-selected battle-card batch with no pending rewards. Continue or commit held Semuta with a free hand slot. One fresh card transfers automatically; for several winner-discarded cards, commit first, then privately choose exactly one. The original flight arrival, private gift, answered history, reordered movement or resolved battle resumes once. BG/Ecaz/Moritani arrivals, other multi-card sources, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.'
|
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho or winner battle-card discards without pending rewards. Mandatory winning cards and later optional winner discards are separate fresh batches. Continue or commit held Semuta with a free hand slot; a sole card transfers automatically. For several cards, commit first, then privately select exactly one. The original effect or battle suffix resumes once. BG/Ecaz/Moritani arrivals, loser and mixed-owner mandatory battle batches, 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, movement-order Sapho and winner-selected battle-card discards with a neutral all-seat offer. A committed sole eligible card transfers immediately; several public winner cards open only the holder’s one-card selection. Semuta is discarded once and the original effect resumes once. Mixed arrivals, other multi-card sources, pending battle rewards, 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 Truthtrance, movement-order Sapho, mandatory winner and optional winner battle-card discards with a neutral all-seat offer. Mandatory and later optional winner cards are separate batches; committed multi-card candidates belong only to the holder. Semuta transfers one other-player card, is discarded once and resumes the original effect once. Mixed arrivals, loser battle batches, pending 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, clean Ornithopter, Distrans, final definite Truthtrance, movement-order Sapho and winner-selected multi-card previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and other discard sources.',
|
'The guide distinguishes bounded public ordinary-card, paid Box, clean Ornithopter, Distrans, final Truthtrance, movement-order Sapho and mandatory/optional winner battle batches 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, exact physical custody, neutral hidden-hand parity, JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, final definite Truthtrance, movement-order Sapho and winner-selected single/multi-card reactions. Other battle batches, full games and live deployment remain unverified.',
|
'Genuine setup, physical custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, final Truthtrance, movement-order Sapho and consecutive mandatory/optional winner battle reactions. Loser and mixed-owner 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',
|
||||||
|
|||||||
@@ -600,3 +600,69 @@ void test('clean winner-selected battle cards allow one committed Semuta choice
|
|||||||
assert.deepEqual(inventory(alternate), original);
|
assert.deepEqual(inventory(alternate), original);
|
||||||
assert.deepEqual(settled(alternate), settled(declined));
|
assert.deepEqual(settled(alternate), settled(declined));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('a mandatory winner Hero discard can be claimed before optional battle cleanup', () => {
|
||||||
|
const { g, semuta, hajr } = fixture();
|
||||||
|
player(g, 'a').hand = [];
|
||||||
|
g.deck.push(hajr);
|
||||||
|
const [hero] = g.deck.splice(g.deck.findIndex(card => card.kind === 'hero'), 1);
|
||||||
|
const [weapon] = g.deck.splice(g.deck.findIndex(card => card.kind === 'projectile'), 1);
|
||||||
|
const [defense] = g.deck.splice(g.deck.findIndex(card => card.kind === 'snooper'), 1);
|
||||||
|
assert.ok(hero && weapon && defense);
|
||||||
|
player(g, 'a').hand.push(hero, 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: hero.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 });
|
||||||
|
const pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
|
||||||
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
|
||||||
|
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [hero.id]);
|
||||||
|
assert.equal(pending.discard.filter(card => card.id === hero.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.decision?.kind, 'battleCards');
|
||||||
|
assert.equal(declined.discard.filter(card => card.id === hero.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(claimed.decision?.kind, 'battleCards');
|
||||||
|
assert.equal(player(claimed, 'a').hand.some(card => card.id === weapon.id), true);
|
||||||
|
assert.equal(player(claimed, 'r').hand.filter(card => card.id === hero.id).length, 1);
|
||||||
|
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||||
|
assert.equal(claimed.discard.some(card => card.id === hero.id), false);
|
||||||
|
assert.deepEqual(inventory(claimed), original);
|
||||||
|
assert.deepEqual(claimed.lastBattle, declined.lastBattle);
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
@@ -388,5 +388,81 @@ 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(battleClaim, auth.playerId));
|
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(battleClaim, auth.playerId));
|
||||||
|
|
||||||
|
const mandatoryStart = structuredClone(battleClaim);
|
||||||
|
const heroSemuta = take(mandatoryStart, SEMUTA_DRUG_ID);
|
||||||
|
mandatoryStart.deck.push(take(mandatoryStart, shield.id));
|
||||||
|
seat(mandatoryStart, ids[0]).hand.push(heroSemuta);
|
||||||
|
const hero = mandatoryStart.deck.splice(
|
||||||
|
mandatoryStart.deck.findIndex(card => card.kind === 'hero'), 1)[0];
|
||||||
|
const nextWeapon = mandatoryStart.deck.splice(
|
||||||
|
mandatoryStart.deck.findIndex(card => card.kind === 'projectile'), 1)[0];
|
||||||
|
const nextDefense = mandatoryStart.deck.splice(
|
||||||
|
mandatoryStart.deck.findIndex(card => card.kind === 'snooper'), 1)[0];
|
||||||
|
assert.ok(hero && nextWeapon && nextDefense);
|
||||||
|
seat(mandatoryStart, ids[1]).hand.push(hero, nextWeapon);
|
||||||
|
seat(mandatoryStart, ids[2]).hand.push(nextDefense);
|
||||||
|
for (const id of [ids[1], ids[2]]) {
|
||||||
|
const p = seat(mandatoryStart, 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(mandatoryStart, { turn: 10, phase: 6, active: ids[1],
|
||||||
|
order: [ids[1], ids[2], ids[0]], ready: [],
|
||||||
|
decision: null, response: null, phaseOpening: null, stormPending: null,
|
||||||
|
lastBattle: [], lastBattleContext: null });
|
||||||
|
mandatoryStart.version = battleClaim.version + 1;
|
||||||
|
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||||
|
.run(JSON.stringify(mandatoryStart), mandatoryStart.version, code, battleClaim.version).changes, 1);
|
||||||
|
let mandatoryBattle = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] });
|
||||||
|
for (let step = 0; step < 40; step++) {
|
||||||
|
if (mandatoryBattle.response) {
|
||||||
|
const p = mandatoryBattle.players.find(p => !mandatoryBattle.response!.passed.includes(p.id))!;
|
||||||
|
mandatoryBattle = await act(ids.indexOf(p.id), { type: 'passResponse' });
|
||||||
|
} else if (mandatoryBattle.battle?.preparation)
|
||||||
|
mandatoryBattle = await act(ids.indexOf(mandatoryBattle.battle.preparation.owner),
|
||||||
|
{ type: 'declineBattlePower' });
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (mandatoryBattle.battle?.preLeader && !mandatoryBattle.battle.preLeader.closed)
|
||||||
|
for (const index of [1, 2])
|
||||||
|
mandatoryBattle = await act(index, {
|
||||||
|
type: 'battlePreparationReady', event: mandatoryBattle.battle!.preLeader!.event,
|
||||||
|
});
|
||||||
|
mandatoryBattle = await act(1, { type: 'battlePlan', dial: 2, support: 2,
|
||||||
|
leader: hero.id, weapon: nextWeapon.id });
|
||||||
|
mandatoryBattle = await act(2, { type: 'battlePlan', dial: 0, support: 0,
|
||||||
|
leader: seat(mandatoryBattle, ids[2]).leaders.find(leader => !leader.dead)!.id,
|
||||||
|
defense: nextDefense.id });
|
||||||
|
mandatoryBattle = await act(1, { type: 'traitorCall', call: false });
|
||||||
|
const heroOffer = await act(2, { type: 'traitorCall', call: false });
|
||||||
|
assert.equal(heroOffer.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
|
||||||
|
assert.equal(heroOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
assert.equal(heroOffer.discard.filter(card => card.id === hero.id).length, 1);
|
||||||
|
await f.restart().continueRoomAutomatic(code, clock);
|
||||||
|
assert.equal((await f.restart().readRoom(code)).version, heroOffer.version);
|
||||||
|
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
|
||||||
|
const heroClaim = await act(0, { type: 'semutaCommit',
|
||||||
|
event: heroOffer.pendingTreacheryDiscard!.batch.event });
|
||||||
|
assert.equal(heroClaim.pendingTreacheryDiscard, null);
|
||||||
|
assert.equal(heroClaim.decision?.kind, 'battleCards');
|
||||||
|
assert.equal(seat(heroClaim, ids[1]).hand.some(card => card.id === nextWeapon.id), true);
|
||||||
|
assert.equal(seat(heroClaim, ids[0]).hand.filter(card => card.id === hero.id).length, 1);
|
||||||
|
assert.equal(heroClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||||
|
assert.equal(heroClaim.discard.some(card => card.id === hero.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(heroClaim, auth.playerId));
|
||||||
|
let optional = await act(1, { type: 'decision', discard: [nextWeapon.id] });
|
||||||
|
assert.equal(optional.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
|
||||||
|
assert.equal(optional.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||||
|
const optionalEvent = optional.pendingTreacheryDiscard!.batch.event;
|
||||||
|
for (const index of [2, 0, 1])
|
||||||
|
optional = await act(index, { type: 'semutaPass', event: optionalEvent });
|
||||||
|
assert.equal(optional.pendingTreacheryDiscard, null);
|
||||||
|
assert.equal(optional.discard.filter(card => card.id === nextWeapon.id).length, 1);
|
||||||
|
assert.equal(optional.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||||
|
assert.equal(seat(optional, ids[0]).hand.filter(card => card.id === hero.id).length, 1);
|
||||||
} finally { f.sqlite.close(); }
|
} finally { f.sqlite.close(); }
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user