Offer Semuta after clean BG Charity Karama cost
This commit is contained in:
1 parent
6de75a7d78
commit
ebfae0f803
10 files changed
+201
-48
No files matched your search
@@ -12,10 +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 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.
|
||||
CHOAM Charity, Inflation or Bene Gesserit 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 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
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 30 September 2026 — Semuta after BG Charity Karama
|
||||
|
||||
In the explicit Richese preview, a clean printed Karama
|
||||
cancellation of Bene Gesserit Charity now pauses after its public
|
||||
card cost but before that extra charity response settles. A neutral
|
||||
all-seat Semuta offer can transfer only the used Karama to a
|
||||
different holder. Claim or decline restores the original response
|
||||
and finishes its cancellation once: BG's declared charity remains
|
||||
spent without granting spice or undoing CHOAM's earlier income.
|
||||
The new `karamaBgCharityDiscard` receipt binds the BG charity turn,
|
||||
saved response and physical card/spice custody. Previously saved
|
||||
CHOAM Charity and Inflation receipts retain their distinct kinds
|
||||
and signatures; Worthless conversion and other Karama uses remain
|
||||
automatic.
|
||||
|
||||
Focused claim/decline and authenticated SQLite restart/CAS checked
|
||||
owner-only projection, exact card transfer, unchanged balances and
|
||||
saved-seat parity. A direct production-engine smoke run observed the
|
||||
same outcome. Two independent read-only rules/privacy reviews found
|
||||
no scoped defect. `npm run check` passed typecheck, lint and 5,831
|
||||
offline tests; `npm run build` and 55 local HTTP tests passed. The
|
||||
phone-width local guide rendered the new scope. No deployed revision
|
||||
was exercised. `e9f9994` passed isolated container verification and
|
||||
image publication in workflow `36659099415`; the live footer was
|
||||
separately observed at `ab5c782`, not this new source.
|
||||
|
||||
## 30 September 2026 — Semuta after printed CHOAM Inflation Karama
|
||||
|
||||
The explicit Richese preview now also pauses after printed Karama
|
||||
|
||||
@@ -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 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.
|
||||
- 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, Inflation or Bene Gesserit 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.
|
||||
|
||||
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
|
||||
|
||||
@@ -114,10 +114,11 @@ 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 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.
|
||||
printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit
|
||||
Charity, Ix-deck Thumper/Amal and three battle discard stages.
|
||||
Karama's cost pauses before its one canceled response settles; BG's
|
||||
extra allowance is withheld, while 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
|
||||
|
||||
@@ -47,14 +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 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.
|
||||
Printed Karama canceling clean CHOAM Charity, Inflation or Bene
|
||||
Gesserit Charity stages `karamaCharityDiscard`,
|
||||
`karamaInflationDiscard` or `karamaBgCharityDiscard` after the public
|
||||
card cost but before accepted cancellation. The first two retain
|
||||
their existing saved receipt signatures. Each 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,10 +66,11 @@ 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 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
|
||||
printed Karama canceling clean CHOAM Charity, Inflation or Bene
|
||||
Gesserit Charity, battle and Ix-deck Thumper/Amal discards**. Other
|
||||
than the one explicitly suspended charity/Inflation 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
|
||||
flight resumes one committed arrival only without BG, Ecaz or
|
||||
@@ -93,11 +94,12 @@ 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 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.
|
||||
For clean printed Karama cancellation of CHOAM Charity, Inflation or
|
||||
Bene Gesserit 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 cancellation
|
||||
once. CHOAM income or BG's extra allowance is withheld, while
|
||||
Inflation retains its spent placement attempt without a token.
|
||||
Worthless conversion, other cancellations, shipping, purchases and
|
||||
nested Karama responses retain their separate automatic paths.
|
||||
|
||||
|
||||
@@ -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 or Inflation, 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 CHOAM Charity, Inflation or Bene Gesserit Charity, 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;
|
||||
|
||||
+33
-16
@@ -1191,7 +1191,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -4858,22 +4858,26 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu
|
||||
})),
|
||||
});
|
||||
}
|
||||
type KaramaChoamDiscardContinuation = Extract<
|
||||
type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' }
|
||||
>;
|
||||
function karamaChoamDiscardSignature(g: Game, c: KaramaChoamDiscardContinuation) {
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
c.kind === 'karamaCharityDiscard'
|
||||
? `karama:charity:${c.card}` : `karama:choamInflation:${c.card}`),
|
||||
? `karama:charity:${c.card}`
|
||||
: `karama:${c.use.response.kind}:${c.card}`),
|
||||
turn: g.turn, phase: g.phase, advanced: g.advanced,
|
||||
charity: g.choamCharity,
|
||||
...(c.kind === 'karamaInflationDiscard' ? {
|
||||
inflation: g.inflation, inflationUsed: g.inflationUsed,
|
||||
inflationAttemptTurn: g.inflationAttemptTurn,
|
||||
} : {}),
|
||||
...(c.kind === 'karamaBgCharityDiscard' ? {
|
||||
claimed: getPlayer(g, c.use.response.owner).charityTurn === g.turn,
|
||||
} : {}),
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally,
|
||||
@@ -5443,7 +5447,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The used Residual Poison no longer matches its committed leader death.',
|
||||
);
|
||||
} else if (continuation?.kind === 'karamaCharityDiscard' ||
|
||||
continuation?.kind === 'karamaInflationDiscard') {
|
||||
continuation?.kind === 'karamaInflationDiscard' ||
|
||||
continuation?.kind === 'karamaBgCharityDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
!g.response &&
|
||||
@@ -5453,7 +5458,11 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(c.kind === 'karamaInflationDiscard' &&
|
||||
c.use.response.kind === 'choamInflation' &&
|
||||
g.phase === 8 && g.inflationAttemptTurn === g.turn &&
|
||||
!g.inflation && !g.inflationUsed)) &&
|
||||
!g.inflation && !g.inflationUsed) ||
|
||||
(c.kind === 'karamaBgCharityDiscard' &&
|
||||
c.use.response.kind === 'bgCharity' && g.phase === 2 &&
|
||||
g.players.some(p => p.id === c.use.response.owner &&
|
||||
p.faction === 'beneGesserit' && p.charityTurn === g.turn))) &&
|
||||
c.use.response.owner !== c.player &&
|
||||
c.resume.response?.kind === c.use.response.kind &&
|
||||
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
|
||||
@@ -5463,8 +5472,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
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 === karamaChoamDiscardSignature(g, c),
|
||||
'The spent Karama no longer matches its suspended CHOAM response cancellation.',
|
||||
c.stateSignature === karamaResponseDiscardSignature(g, c),
|
||||
'The spent Karama no longer matches its suspended charity or Inflation cancellation.',
|
||||
);
|
||||
} else if (continuation?.kind === 'thumperDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6125,6 +6134,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'residualPoisonDiscard' &&
|
||||
continuation.kind !== 'karamaCharityDiscard' &&
|
||||
continuation.kind !== 'karamaInflationDiscard' &&
|
||||
continuation.kind !== 'karamaBgCharityDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
continuation.kind !== 'amalDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
@@ -6180,8 +6190,9 @@ function semutaOfferSupported(
|
||||
? entries.every(entry => entry.publicFace)
|
||||
: entries.length === 1 && entries[0].publicFace) &&
|
||||
(continuation.kind === 'karamaCharityDiscard' ||
|
||||
continuation.kind === 'karamaInflationDiscard'
|
||||
? ['choamCharity', 'choamInflation'].includes(continuation.resume.response?.kind ?? '') &&
|
||||
continuation.kind === 'karamaInflationDiscard' ||
|
||||
continuation.kind === 'karamaBgCharityDiscard'
|
||||
? ['choamCharity', 'choamInflation', 'bgCharity'].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))
|
||||
@@ -6191,6 +6202,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'battleResolved' ||
|
||||
(continuation.kind === 'karamaCharityDiscard' ||
|
||||
continuation.kind === 'karamaInflationDiscard' ||
|
||||
continuation.kind === 'karamaBgCharityDiscard' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
@@ -6399,7 +6411,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
if (battle.preLeader && !battle.preLeader.closed)
|
||||
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
|
||||
} else if (next.kind === 'karamaCharityDiscard' ||
|
||||
next.kind === 'karamaInflationDiscard') {
|
||||
next.kind === 'karamaInflationDiscard' ||
|
||||
next.kind === 'karamaBgCharityDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
@@ -10753,10 +10766,14 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
|
||||
const used = discard(g, p, card.id);
|
||||
if (g.semutaPreview && card.effect === 'karama' &&
|
||||
use.kind === 'cancel' &&
|
||||
(use.response.kind === 'choamCharity' || use.response.kind === 'choamInflation')) {
|
||||
const continuation: KaramaChoamDiscardContinuation = {
|
||||
(use.response.kind === 'choamCharity' ||
|
||||
use.response.kind === 'choamInflation' ||
|
||||
use.response.kind === 'bgCharity')) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: use.response.kind === 'choamCharity'
|
||||
? 'karamaCharityDiscard' : 'karamaInflationDiscard',
|
||||
? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard' : 'karamaBgCharityDiscard',
|
||||
player: p.id, card: used.id, use,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
@@ -10765,7 +10782,7 @@ 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 = karamaChoamDiscardSignature(g, continuation);
|
||||
continuation.stateSignature = karamaResponseDiscardSignature(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, 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.'
|
||||
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity 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 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.'
|
||||
? '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, Inflation or Bene Gesserit 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.'
|
||||
: '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 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.',
|
||||
'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, Inflation or Bene Gesserit Charity, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer, then its original response is canceled once. Residual Poison commits one random leader death; Sapho commits its 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 or Inflation, 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, Inflation or Bene Gesserit Charity, 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 or Inflation, 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, Inflation or Bene Gesserit Charity, 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',
|
||||
|
||||
@@ -151,6 +151,40 @@ void test('prior-turn CHOAM Charity receipt does not block a fresh Karama Semuta
|
||||
assert.equal(resumed.players[0].spice, 0);
|
||||
});
|
||||
|
||||
void test('clean BG charity Karama cancellation offers Semuta without awarding extra charity', () => {
|
||||
let g = fixture(true);
|
||||
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[0].hand.push(g.richeseCache.splice(semuta, 1)[0]);
|
||||
g = allow(ready(g));
|
||||
contest(g);
|
||||
g.players[2].spice = 12;
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.response?.kind, 'bgCharity');
|
||||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||||
const beforeSpice = g.players.map(p => p.spice);
|
||||
g = cancel(g);
|
||||
const event = g.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(g, 'b').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.deepEqual(declined.players.map(p => p.spice), beforeSpice);
|
||||
const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'c', { type: 'semutaCommit', event });
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(claimed.players.map(p => p.spice), beforeSpice);
|
||||
assert.equal(claimed.players[0].hand.filter(c => c.effect === 'karama').length, 1);
|
||||
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(viewGame(claimed, 'b').players[2].charityClaimed, true);
|
||||
});
|
||||
|
||||
void test('CHOAM receives two spice per faction, including itself, only after its opening response', () => {
|
||||
const initial = contest(fixture());
|
||||
let g = ready(initial);
|
||||
|
||||
@@ -847,3 +847,76 @@ void test('authenticated printed Karama cost suspends CHOAM Inflation placement
|
||||
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('authenticated BG Charity Karama cost restores one canceled extra allowance', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const made = await f.rooms.createRoom('Semuta BG Charity', 'choam', true, ['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'), true, ['choam']);
|
||||
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
|
||||
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
|
||||
Object.assign(g, {
|
||||
status: 'playing', phase: 1, nexus: true,
|
||||
order: g.players.map(p => p.id), deck: baseDeck(),
|
||||
richeseCache: richeseCards(), semutaPreview: true,
|
||||
});
|
||||
for (const player of g.players) {
|
||||
player.spice = 0;
|
||||
player.hand = [];
|
||||
player.traitors = player.leaders.length ? [player.leaders[0].id] : [];
|
||||
player.traitorChoices = [];
|
||||
}
|
||||
const karama = take(g, 'karama');
|
||||
g.players[1].hand.push(karama);
|
||||
const semuta = take(g, SEMUTA_DRUG_ID);
|
||||
g.players[0].hand.push(semuta);
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
while (g.decision?.kind === 'choamMarket')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
|
||||
while (g.response) {
|
||||
const owner = g.players.find(p => {
|
||||
const controls = viewGame(g, p.id).responseControls;
|
||||
return controls?.cancelCards.length && !controls.hasPassed;
|
||||
});
|
||||
assert.ok(owner);
|
||||
g = applyAction(g, owner.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.deepEqual(g.choamCharity, { turn: 1, canceled: false });
|
||||
g.players[2].spice = 12;
|
||||
g = applyAction(g, auths[2].playerId, { type: 'charity' });
|
||||
assert.equal(g.response?.kind, 'bgCharity');
|
||||
const balances = g.players.map(p => p.spice);
|
||||
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');
|
||||
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[0])).semutaReaction?.canCommit, true);
|
||||
assert.equal((await f.restart().readSeatView(code, auths[2])).semutaReaction?.canCommit, false);
|
||||
const claimed = await act(0, { type: 'semutaCommit', event });
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(claimed.players.map(p => p.spice), balances);
|
||||
assert.equal(claimed.players[0].hand.filter(card => card.id === karama.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(viewGame(claimed, auths[2].playerId).players[2].charityClaimed, true);
|
||||
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