Offer Semuta after clean CHOAM Inflation Karama cost

This commit is contained in:
Quintine committed 2026-09-30 12:16:40 +10:00
1 parent d9c67ca992
commit e9f9994513
10 files changed
+207 -55

No files matched your search

+10 -8
View File
@@ -12,9 +12,10 @@ the physical Ix deck. Clean public ordinary-card, paid Box, retired
Ornithopter, private Distrans, final Truthtrance, Once Around,
movement-order, between-battles chooser or pre-plan aggressor Sapho,
clean pre-leader Residual Poison, printed Karama canceling clean
CHOAM Charity, battle-card and Ix-deck Thumper/Amal discards open
the same neutral offer regardless of hidden Semuta custody. Karama
waits after its public cost but before settling the canceled income.
CHOAM Charity or Inflation, battle-card and Ix-deck Thumper/Amal
discards open the same neutral offer regardless of hidden Semuta
custody. Karama waits after its public cost but before settling the
canceled response.
Residual Poison commits one random death before the offer; only an
uninspected battle with nobody ready qualifies. Once Around commits
bidder order and frees a slot; other Sapho scopes commit chooser or
@@ -30,11 +31,12 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
The previous `32d9ef4` container workflow built successfully but
failed isolated storage/HTTP verification; its image was not published.
The public job exposes no assertion log. Local Docker/Podman and
authenticated GitHub logs are unavailable. A later checkpoint must
pass container verification before it is a deployable candidate.
Container workflow `36656043783` built `32d9ef4` but failed
isolated storage/HTTP verification; that image was not published and
the public job exposes no assertion log. The next checkpoint
`d9c67ca` passed the same verification and published its image in
workflow `36657454120`. Neither push is a live NAS deployment;
the latest independently verified live revision remains `c9a3625`.
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,
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 30 September 2026 — Semuta after printed CHOAM Inflation Karama
The explicit Richese preview now also pauses after printed Karama
publicly discards to cancel a clean CHOAM Inflation response.
CHOAM's placement attempt has already been spent; the token is not
placed until the response settles. A neutral Semuta offer permits a
different holder to claim only the used card, or all seats to decline.
The saved Inflation receipt binds the attempt turn, absent token,
original response and physical card/spice custody. Completion
restores and cancels the response once; no Inflation token appears
or same-turn attempt reopens. CHOAM Charity retains its existing
distinct serialized receipt and signature so a saved pending Charity
offer remains readable; a new Inflation receipt has its own kind.
Focused Charity/Inflation paths, authenticated SQLite restart/CAS and
owner-only projections passed. A direct production-engine smoke run
observed the neutral offer and recovered card without an Inflation
token. Independent rules/privacy review found and resolved a saved
Charity receipt compatibility regression; follow-up source review
found no remaining mismatch. `npm run check` passed typecheck, lint
and 5,829 offline tests; `npm run build` and 55 local HTTP tests
passed. The phone-width local reference rendered Inflation scope.
No deployed revision was exercised. Other Karama uses, pending
rewards, normal Richese starts and publication acceptance remain
gated. Container workflow `36657454120` for the previous
`d9c67ca` checkpoint verified isolated storage/HTTP and published
that image; the failed `32d9ef4` image was not published.
## 30 September 2026 — Semuta after printed CHOAM Charity Karama
In the explicit Richese preview, a clean printed Karama cancellation
+7 -5
View File
@@ -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 a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, other Karama forms, inspected/partly ready battles and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho retain separate bounded paths, not combined-mode certification.
- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity or Inflation, and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, other Karama forms, inspected/partly ready battles and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho retain separate bounded paths, not combined-mode certification.
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
@@ -114,10 +114,12 @@ Neutral all-seat responses, physical one-card recovery, provisional
free-slot policy, minimal legal AI and saved continuation work after
clean ordinary-card, paid Box, retired Ornithopter, private Distrans,
final Truthtrance, four Sapho scopes, clean pre-leader Residual Poison,
printed Karama canceling CHOAM Charity, Ix-deck Thumper/Amal and
three battle discard stages. Karama's cost pauses before the one
canceled response settles; Once Around commits lot order and hand-slot
release, battle-order/pre-plan Sapho commit priority, and Residual
printed Karama canceling CHOAM Charity or Inflation, Ix-deck
Thumper/Amal and three battle discard stages. Karama's cost pauses
before its one canceled response settles; Inflation's placement
attempt remains spent without placing a token.
Once Around commits lot order and hand-slot release,
battle-order/pre-plan Sapho commit priority, and Residual
Poison commits one random death before the neutral offer. Thumper
pauses before worm/spice draw; Amal resumes its already-halved opening
once. Multi-owner batches require committed owner-only selection.
+8 -6
View File
@@ -47,12 +47,14 @@ binds the victim, leader custody and unsealed battle. The neutral
offer is limited to a pre-leader board with no inspection or ready
combatant; other accepted plays retain automatic continuation.
Printed Karama canceling a clean CHOAM Charity response stages
`karamaCharityDiscard` after its public card cost but before the
accepted cancellation. The receipt binds its sole suspended response,
spent card and unchanged spice/hand custody; claim or decline restores
the original response and finishes its cancellation once. Other
Karama forms and nested responses remain automatic.
Printed Karama canceling clean CHOAM Charity or Inflation stages
`karamaCharityDiscard` or `karamaInflationDiscard` after its public
card cost but before accepted cancellation. Charity retains its
existing saved receipt format; Inflation binds its attempt marker
and absent token. Each receipt binds the sole suspended response,
spent card and hand/spice custody; claim or decline restores that
response and finishes cancellation once. Other Karama forms and
nested responses remain automatic.
Completed Ornithopter movement now offers the same retired public card
before its already committed arrival suffix only in a roster without
+10 -9
View File
@@ -66,9 +66,9 @@ with CHOAM & Richese and optionally the physical Ix deck, without
opening public expansion starts. It interrupts **clean public
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
printed Karama canceling clean CHOAM Charity, battle and Ix-deck
Thumper/Amal discards**. Other than the one explicitly suspended
CHOAM Charity response, the parent transaction has no competing
printed Karama canceling clean CHOAM Charity or Inflation, battle
and Ix-deck Thumper/Amal discards**. Other than the one explicitly
suspended CHOAM response, the parent transaction has no competing
response. Every seat sees the same neutral fresh-discard opportunity
regardless of hidden Semuta custody.
An early-ended Ornithopter advances the saved queue; a completed
@@ -93,12 +93,13 @@ card before the offer. Claim or decline resumes the same pre-leader
preparation once; an inspected or partly ready battle retains the
automatic death path without this preview interruption.
For clean printed Karama cancellation of CHOAM Charity, the spent
public card reaches the pile before its accepted cancellation runs.
The sole suspended response is bound to the saved receipt; claim or
decline resumes the same canceled income once. Worthless conversion,
other cancellations, shipping, purchases and nested Karama responses
retain their separate automatic paths.
For clean printed Karama cancellation of CHOAM Charity or Inflation,
the spent public card reaches the pile before its accepted cancellation
runs. The sole suspended response is bound to the saved receipt;
claim or decline resumes the same cancellation once. Charity pays no
income; Inflation retains its spent placement attempt without a token.
Worthless conversion, other cancellations, shipping, purchases and
nested Karama responses retain their separate automatic paths.
For a genuine Ix-deck Thumper, its played physical card reaches
the discard before an injected worm or actual spice-card draw.
+1 -1
View File
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
if (definition?.card.effect === 'semutaDrug')
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity or Inflation, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
+43 -21
View File
@@ -1191,7 +1191,7 @@ export type Game = {
stateSignature: string;
}
| {
kind: 'karamaCharityDiscard';
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard';
player: string;
card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>;
@@ -4858,16 +4858,23 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu
})),
});
}
type KaramaCharityDiscardContinuation = Extract<
type KaramaChoamDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'karamaCharityDiscard' }
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' }
>;
function karamaCharityDiscardSignature(g: Game, c: KaramaCharityDiscardContinuation) {
function karamaChoamDiscardSignature(g: Game, c: KaramaChoamDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume, `karama:charity:${c.card}`),
parent: nullentropyParentSignature(g, c.resume,
c.kind === 'karamaCharityDiscard'
? `karama:charity:${c.card}` : `karama:choamInflation:${c.card}`),
turn: g.turn, phase: g.phase, advanced: g.advanced,
charity: g.choamCharity, order: g.order,
charity: g.choamCharity,
...(c.kind === 'karamaInflationDiscard' ? {
inflation: g.inflation, inflationUsed: g.inflationUsed,
inflationAttemptTurn: g.inflationAttemptTurn,
} : {}),
order: g.order,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally,
hand: p.hand.map(card => card.id), spice: p.spice,
@@ -5435,21 +5442,29 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === residualPoisonDiscardSignature(g, c),
'The used Residual Poison no longer matches its committed leader death.',
);
} else if (continuation?.kind === 'karamaCharityDiscard') {
} else if (continuation?.kind === 'karamaCharityDiscard' ||
continuation?.kind === 'karamaInflationDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
g.phase === 2 && !g.response && g.choamCharity?.turn !== g.turn &&
c.use.response.kind === 'choamCharity' &&
!g.response &&
((c.kind === 'karamaCharityDiscard' &&
c.use.response.kind === 'choamCharity' &&
g.phase === 2 && g.choamCharity?.turn !== g.turn) ||
(c.kind === 'karamaInflationDiscard' &&
c.use.response.kind === 'choamInflation' &&
g.phase === 8 && g.inflationAttemptTurn === g.turn &&
!g.inflation && !g.inflationUsed)) &&
c.use.response.owner !== c.player &&
c.resume.response?.kind === 'choamCharity' &&
c.resume.response?.kind === c.use.response.kind &&
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
!c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening &&
batch.cause === 'karama:choamCharity' && batch.entries.length === 1 &&
batch.cause === `karama:${c.use.response.kind}` &&
batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.effect === 'karama' &&
g.players.some(p => p.id === c.player) &&
c.stateSignature === karamaCharityDiscardSignature(g, c),
'The spent Karama no longer matches its suspended CHOAM Charity cancellation.',
c.stateSignature === karamaChoamDiscardSignature(g, c),
'The spent Karama no longer matches its suspended CHOAM response cancellation.',
);
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
@@ -6109,6 +6124,7 @@ function semutaOfferSupported(
continuation.kind !== 'saphoBattleOrderDiscard' &&
continuation.kind !== 'residualPoisonDiscard' &&
continuation.kind !== 'karamaCharityDiscard' &&
continuation.kind !== 'karamaInflationDiscard' &&
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
@@ -6163,8 +6179,9 @@ function semutaOfferSupported(
continuation.kind === 'battleResolved'
? entries.every(entry => entry.publicFace)
: entries.length === 1 && entries[0].publicFace) &&
(continuation.kind === 'karamaCharityDiscard'
? continuation.resume.response?.kind === 'choamCharity' &&
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard'
? ['choamCharity', 'choamInflation'].includes(continuation.resume.response?.kind ?? '') &&
!continuation.resume.decision && !continuation.resume.pendingKarama &&
!continuation.resume.phaseOpening && !g.pendingKarama &&
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
@@ -6173,6 +6190,7 @@ function semutaOfferSupported(
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
(!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama &&
(continuation.kind === 'amalDiscard'
@@ -6380,7 +6398,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
const battle = g.battle!;
if (battle.preLeader && !battle.preLeader.closed)
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
} else if (next.kind === 'karamaCharityDiscard') {
} else if (next.kind === 'karamaCharityDiscard' ||
next.kind === 'karamaInflationDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
@@ -10733,9 +10752,12 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
assertKaramaPromiseFeasibility(g, p, use, card);
const used = discard(g, p, card.id);
if (g.semutaPreview && card.effect === 'karama' &&
use.kind === 'cancel' && use.response.kind === 'choamCharity') {
const continuation: KaramaCharityDiscardContinuation = {
kind: 'karamaCharityDiscard', player: p.id, card: used.id, use,
use.kind === 'cancel' &&
(use.response.kind === 'choamCharity' || use.response.kind === 'choamInflation')) {
const continuation: KaramaChoamDiscardContinuation = {
kind: use.response.kind === 'choamCharity'
? 'karamaCharityDiscard' : 'karamaInflationDiscard',
player: p.id, card: used.id, use,
resume: { response: g.response, decision: g.decision,
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
@@ -10743,8 +10765,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
const entries = [{ card: used, discardedBy: p.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
g.response = null;
continuation.stateSignature = karamaCharityDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'karama:choamCharity', entries, continuation);
continuation.stateSignature = karamaChoamDiscardSignature(g, continuation);
stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation);
return;
}
}
+5 -5
View File
@@ -1140,7 +1140,7 @@ export const RULE_TOPICS: RuleTopic[] = [
definition.card.effect === 'juiceOfSapho'
? 'Use the Juice of Sapho panel for an available order change or pre-plan aggressor. Later intervention and other timing modes remain unfinished; consult the timing guide.'
: definition.card.effect === 'karama'
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, a clean printed Karama cancellation of CHOAM Charity can pause on its public discard before canceling that response. Full Richese starts and other Karama/Semuta interactions remain unavailable.'
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed Karama cancellations of CHOAM Charity or Inflation pause on the public cost before settling that response. Full Richese starts and other Karama-to-Semuta reactions remain unavailable.'
: definition.card.effect === 'distrans'
? 'Use the Distrans transfer panel to choose another player and a separate held card. Open-bid timing and self-transfer remain guarded pending clarification.'
: definition.card.effect === 'nullentropyBox'
@@ -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 the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity or Inflation, and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public 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:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer, then the original response is canceled once. Residual Poison commits one random leader death; Sapho commits its applicable finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other producers and full-hand exchange remain unfinished.',
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity or Inflation, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer, then its original CHOAM response is canceled once. Residual Poison commits one random leader death; Sapho commits its applicable finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other 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 clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity or Inflation, Ix-deck Thumper/Amal and three 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, custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Ix-deck Thumper/Amal and three battle discard stages. Other Karama uses, pending rewards, full games and live deployment remain unverified.',
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity or Inflation, Ix-deck Thumper/Amal and three battle discard stages. Other Karama uses, pending rewards, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+37
View File
@@ -9,6 +9,8 @@ import {
RuleError,
} from '../game/engine';
import { baseDeck, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { charityAmount, charityMultiplier } from '../game/charity';
import { createTechTokens } from '../game/tech-tokens';
import { botActions } from '../game/bots';
@@ -82,6 +84,41 @@ function mentat(state: Game) {
g.ready = [];
return ready(g);
}
void test('clean printed Karama cancellation offers Semuta before CHOAM Inflation settles', () => {
let g = fixture();
g.players[1] = newPlayer('e', 'Richese', 'richese');
g.richeseCache = richeseCards();
g.semutaPreview = true;
const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID);
assert.ok(semuta >= 0);
g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]);
contest(g);
g = place(ready(g), 'double');
assert.equal(g.response?.kind, 'choamInflation');
const spent = g.players[1].hand.find(c => c.effect === 'karama')!;
g = cancel(g);
const event = g.pendingTreacheryDiscard!.batch.event;
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(g.response, null);
assert.equal(g.inflation, undefined);
assert.equal(g.inflationAttemptTurn, g.turn);
assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, true);
assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, false);
let declined: Game = JSON.parse(JSON.stringify(g));
for (const id of ['c', 'e', 'b'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.equal(declined.inflation, undefined);
assert.equal(declined.inflationAttemptTurn, g.turn);
const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'b', { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.inflation, undefined);
assert.equal(claimed.inflationAttemptTurn, g.turn);
assert.equal(claimed.players[2].hand.filter(c => c.id === spent.id).length, 1);
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.throws(() => place(claimed), /already attempted/);
});
void test('Inflation placement is a CHOAM Mentat action with a response before the token enters play', () => {
const initial = contest(fixture());
assert.throws(() => place(initial), /Mentat/);
+58
View File
@@ -789,3 +789,61 @@ void test('authenticated printed Karama cost pauses and restores one canceled CH
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama cost suspends CHOAM Inflation placement across restart', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Inflation', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, {
status: 'playing', phase: 8, turn: 2,
order: g.players.map(p => p.id), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true,
});
for (const player of g.players) {
player.spice = 20;
player.hand = [];
player.traitorChoices = [];
}
const karama = take(g, 'karama');
g.players[1].hand.push(karama);
const semuta = take(g, SEMUTA_DRUG_ID);
g.players[2].hand.push(semuta);
g = applyAction(g, auths[0].playerId, { type: 'choamInflation', side: 'double' });
assert.equal(g.response?.kind, 'choamInflation');
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
const pending = await act(1, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.inflation, undefined);
assert.equal(pending.inflationAttemptTurn, 2);
const event = pending.pendingTreacheryDiscard!.batch.event;
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[2])).semutaReaction?.canCommit, true);
const claimed = await act(2, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.inflation, undefined);
assert.equal(claimed.inflationAttemptTurn, 2);
assert.equal(claimed.players[2].hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});