Offer Semuta after clean CHOAM Inflation Karama cost
This commit is contained in:
1 parent
d9c67ca992
commit
e9f9994513
10 files changed
+207
-55
No files matched your search
+10
-8
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
Reference in new issue
Block a user