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)
|
||||
pauses clean public ordinary-card, paid Box, retired Ornithopter,
|
||||
private Distrans, final definite Truthtrance, movement-order Sapho
|
||||
and winner-selected battle-card discards without pending rewards.
|
||||
private Distrans, final definite Truthtrance, movement-order Sapho,
|
||||
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.
|
||||
A sole eligible card transfers on commitment; a simultaneous
|
||||
winner-card batch first requires public commitment, then reveals
|
||||
only to that holder the eligible fresh faces to select exactly one.
|
||||
Other cards remain in the pile and the resolved battle resumes once.
|
||||
The early-ended flight queue, completed-flight arrival, private gift,
|
||||
answer history and Sapho order also resume without replay. Minimal
|
||||
legal AI, exact JSON/SQLite continuation and competing-room CAS are
|
||||
connected. Phone width showed owned Commit and rival Continue without
|
||||
a private card face. Mixed arrivals, auction/battle Sapho, queued or
|
||||
promise-bound Truthtrance, mandatory or reward-pending battle batches,
|
||||
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.
|
||||
A sole eligible card transfers on commitment; a simultaneous winner
|
||||
batch first requires public commitment, then reveals eligible faces
|
||||
only to the holder for one-card selection. Other cards remain in the
|
||||
pile and the resolved battle resumes once. The flight queue, committed
|
||||
arrival, private gift, answer history and Sapho order also resume
|
||||
without replay. Minimal legal AI, exact JSON/SQLite continuation and
|
||||
competing-room CAS are connected. Mixed arrivals, auction/battle
|
||||
Sapho, queued or promise-bound Truthtrance, loser/mixed-owner battle
|
||||
batches, pending battle rewards, other discard producers, full-hand
|
||||
ordering, normal Richese starts and deployed acceptance remain
|
||||
unfinished. Neutral passes and 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 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
|
||||
|
||||
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.
|
||||
|
||||
- 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).
|
||||
|
||||
@@ -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
|
||||
explicitly opted-in Richese development profile. Neutral all-seat
|
||||
responses, physical one-card recovery, a provisional free-slot guard,
|
||||
minimal legal AI and saved continuation now work after clean public
|
||||
minimal legal AI and saved continuation work after clean public
|
||||
ordinary-card, paid Box, retired Ornithopter, private Distrans, final
|
||||
definite Truthtrance, movement-order Sapho and winner-selected
|
||||
battle-card discards, including a committed owner-only multi-card
|
||||
choice. Queued questions, promise-bound answers, reactive arrivals,
|
||||
auction/battle Sapho, mandatory or reward-pending battle batches
|
||||
and other semantic discard families remain unconnected. Full-hand
|
||||
ordering, ten-card inventory, cache custody and public expansion
|
||||
start gate are unchanged.
|
||||
definite Truthtrance, movement-order Sapho and separate mandatory
|
||||
and optional winner battle-card batches, including committed
|
||||
owner-only multi-card selection. Queued questions, promise-bound
|
||||
answers, reactive arrivals, auction/battle Sapho, loser or mixed-owner
|
||||
battle batches and pending battle rewards remain unconnected.
|
||||
Full-hand ordering, ten-card inventory, cache custody and public
|
||||
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
|
||||
arrival once. Mixed entry reactions remain unsupported for Semuta.
|
||||
|
||||
Winner-selected public played cards now pause as one `battleCleanup`
|
||||
batch after the battle is resolved and any earlier mandatory discard
|
||||
has retired. The winner, combatants and all selected card identities
|
||||
remain bound. Every seat sees the same neutral offer; only a committed
|
||||
holder inspects the eligible batch and selects one of multiple cards.
|
||||
The others remain in the pile before `finishBattle` runs once. Moritani
|
||||
loser retention and pending Auditor, capture, tech, Face Dance or
|
||||
CHOAM rewards remain automatic without this reaction.
|
||||
Mandatory winner cleanup now pauses at `winnerMandatoryDiscard`
|
||||
after any earlier loser/battle batch has retired; a Hero already in
|
||||
the public pile can be claimed before the exact optional winner
|
||||
choice resumes. If the winner then selects other played cards,
|
||||
their public disposal is a separate `battleCleanup` batch. Each
|
||||
stage has a neutral all-seat offer; only a committed holder inspects
|
||||
multiple eligible cards, selects one, and leaves the others in the
|
||||
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
|
||||
|
||||
|
||||
@@ -66,16 +66,19 @@ without opening ordinary expansion starts. It interrupts **one public
|
||||
ordinary-card discard, the used Box after a paid Nullentropy search,
|
||||
either retirement of a played Ornithopter in a clean flight, the used
|
||||
Distrans after a private transfer, a final definite Truthtrance,
|
||||
movement-order Juice of Sapho, or a winner-selected battle-card batch
|
||||
without pending battle rewards**, provided the parent transaction is
|
||||
clean. Every seat sees a neutral fresh-discard opportunity regardless
|
||||
movement-order Juice of Sapho, mandatory winning-card cleanup, or a
|
||||
later winner-selected battle-card batch without pending rewards**,
|
||||
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
|
||||
saved movement queue; a completed flight has committed its forces
|
||||
and resumes its one arrival only without BG, Ecaz or Moritani entry
|
||||
reactions. Distrans's gift stays private with its recipient. The
|
||||
Truthtrance answer/history and Sapho first/last queue are bound.
|
||||
The winner's chosen played cards are already in one public fresh
|
||||
batch; its resolved battle and remaining phase transition resume once.
|
||||
Mandatory winning cards form one public fresh batch after the
|
||||
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
|
||||
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,
|
||||
pending gifts, competing responses, completed Ornithopter arrivals
|
||||
with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or
|
||||
promise-bound Truthtrance, auction/battle Sapho, mandatory/reward-pending
|
||||
battle batches and other typed producers retain their automatic
|
||||
continuation; Semuta cannot claim those events yet.
|
||||
promise-bound Truthtrance, auction/battle Sapho, loser or mixed-owner
|
||||
mandatory battle batches and pending battle rewards retain their
|
||||
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
|
||||
|
||||
+6
-2
@@ -5754,6 +5754,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
!(continuation.kind === 'ornithopterDiscard' &&
|
||||
(continuation.source === 'end' ||
|
||||
(continuation.source === 'move' &&
|
||||
@@ -5763,7 +5764,8 @@ function semutaOfferSupported(
|
||||
!(continuation.kind === 'truthtranceDiscard' &&
|
||||
continuation.consumed.source === undefined &&
|
||||
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.pendingFaceDance || g.pendingChoamBattleIncome ||
|
||||
g.pendingWinnerDiscards || g.moritaniRetention ||
|
||||
@@ -5777,10 +5779,12 @@ function semutaOfferSupported(
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length > 0 &&
|
||||
(continuation.kind === 'battleCleanup'
|
||||
(continuation.kind === 'battleCleanup' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard'
|
||||
? entries.every(entry => entry.publicFace)
|
||||
: entries.length === 1 && entries[0].publicFace) &&
|
||||
(continuation.kind === 'battleCleanup' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
|
||||
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
||||
|
||||
+4
-4
@@ -1156,7 +1156,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, 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.',
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1166,7 +1166,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, 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,
|
||||
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Documentation' as const,
|
||||
status: 'Partial' as const,
|
||||
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,
|
||||
status: 'Partial' as const,
|
||||
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: [
|
||||
'tests/semuta-engine.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(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);
|
||||
for (const auth of auths)
|
||||
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(); }
|
||||
});
|
||||
Reference in new issue
Block a user