Offer Semuta after clean BG Charity Karama cost

This commit is contained in:
Quintine committed 2026-09-30 12:38:56 +10:00
1 parent 6de75a7d78
commit ebfae0f803
10 files changed
+201 -48

No files matched your search

+4 -4
View File
@@ -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
+26
View File
@@ -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
+6 -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 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
+8 -8
View File
@@ -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
+11 -9
View File
@@ -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.
+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 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
View File
@@ -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
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, 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',
+34
View File
@@ -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);
+73
View File
@@ -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(); }
});