Offer Semuta after clean CHOAM Charity Karama cost
This commit is contained in:
1 parent
32d9ef4ec9
commit
d9c67ca992
10 files changed
+300
-45
No files matched your search
+23
-17
@@ -11,24 +11,30 @@ accepts a fresh Richese roster with CHOAM & Richese and optionally
|
||||
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, battle-card and Ix-deck Thumper/Amal
|
||||
discards open the same neutral offer regardless of hidden Semuta custody.
|
||||
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.
|
||||
Residual Poison commits one random death before the offer; only an
|
||||
uninspected battle with no preparation ready qualifies. Once Around
|
||||
commits bidder order and a freed slot; other Sapho scopes commit
|
||||
chooser or aggressor priority. Claim or decline resumes the same lot,
|
||||
chooser queue or unsealed battle without repeating its prior effect;
|
||||
a Black Market Semuta reserved as the offered card cannot Commit.
|
||||
Thumper waits before its injected
|
||||
worm/spice draw; Amal waits after halving personal spice and clearing
|
||||
earlier opening passes. Mixed-owner battle cards require committed
|
||||
owner-only selection of another player's fresh card; mandatory and
|
||||
optional winner cleanup remain separate. Minimal legal AI, JSON/
|
||||
SQLite continuation and competing-room CAS are connected. Mixed
|
||||
arrivals, queued or promise-bound Truthtrance, pending rewards, other
|
||||
producers, full-hand ordering, normal Richese starts and deployed
|
||||
acceptance remain unfinished. Neutral passes and free-slot guard are
|
||||
product choices.
|
||||
uninspected battle with nobody ready qualifies. Once Around commits
|
||||
bidder order and frees a slot; other Sapho scopes commit chooser or
|
||||
aggressor priority. Claim or decline resumes the saved effect once.
|
||||
A Black Market Semuta reserved as the offered card cannot Commit.
|
||||
Thumper waits before worm/spice draw; Amal waits after halving spice
|
||||
and clearing opening passes. Mixed-owner battle cards require
|
||||
committed owner-only selection; mandatory and optional winner cleanup
|
||||
remain separate. Minimal legal AI, JSON/SQLite continuation and
|
||||
competing-room CAS are connected. Mixed arrivals, queued or
|
||||
promise-bound Truthtrance, other Karama uses, pending rewards,
|
||||
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.
|
||||
|
||||
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,32 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 30 September 2026 — Semuta after printed CHOAM Charity Karama
|
||||
|
||||
In the explicit Richese preview, a clean printed Karama cancellation
|
||||
of CHOAM Charity now pauses after the public activating card reaches
|
||||
the discard but before canceled income settles. Every seat receives
|
||||
the same neutral offer; a different Semuta holder can claim the
|
||||
spent Karama, or all seats may decline. The receipt binds the exact
|
||||
suspended response, spent card and spice/hand custody. Completion
|
||||
retires it, restores that response and cancels CHOAM income once.
|
||||
Prior-turn charity receipts remain valid; no current-turn cancellation
|
||||
is accepted twice. Worthless conversion, other Karama uses and nested
|
||||
responses retain their automatic paths.
|
||||
|
||||
Authenticated SQLite restart/CAS, owner-only projection, corrupted
|
||||
receipt rejection, first- and later-turn cancellation and one-card
|
||||
custody were exercised. A direct production-engine smoke run observed
|
||||
the parent response, neutral offer and one cancellation with zero
|
||||
CHOAM income. Two independent read-only reviews found the prior-turn
|
||||
receipt and test type-narrowing defects; both were corrected.
|
||||
`npm run check` passed typecheck, lint and 5,827 offline tests;
|
||||
`npm run build` and 55 local HTTP tests passed. The phone-width reference
|
||||
rendered the new scope. No deployed revision was exercised. The
|
||||
previous `32d9ef4` container workflow built but failed its isolated
|
||||
storage/HTTP step; public logs expose only exit code 1, not the
|
||||
underlying assertion. Docker, Podman and authenticated GitHub logs
|
||||
were unavailable locally. Do not treat that image as published.
|
||||
|
||||
## 30 September 2026 — Semuta after clean Residual Poison
|
||||
|
||||
In the explicit Richese preview, Residual Poison now pauses after
|
||||
|
||||
@@ -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 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, 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, 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,13 +114,14 @@ 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,
|
||||
Ix-deck Thumper/Amal and three battle discard stages. Once Around
|
||||
commits the lot order and hand-slot release before a neutral offer;
|
||||
battle-order/pre-plan Sapho commit chooser/tie priority; Residual
|
||||
Poison commits one random death while readiness remains unsealed.
|
||||
Thumper pauses before worm/spice draw; Amal resumes its already-halved
|
||||
opening once. Multi-owner batches require committed owner-only selection
|
||||
of one other-player card. Queued questions, promises, reactive arrivals,
|
||||
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
|
||||
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.
|
||||
Queued questions, promises, reactive arrivals, other Karama forms,
|
||||
inspected/partly ready battles, pending rewards and other sources
|
||||
stay unconnected.
|
||||
Full-hand ordering, ten-card inventory, cache custody and public
|
||||
|
||||
@@ -47,6 +47,13 @@ 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.
|
||||
|
||||
Completed Ornithopter movement now offers the same retired public card
|
||||
before its already committed arrival suffix only in a roster without
|
||||
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
|
||||
|
||||
@@ -65,12 +65,12 @@ The explicit `semuta` development profile starts a fresh Richese roster
|
||||
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, Once Around, movement-order, between-battles chooser
|
||||
or pre-plan aggressor Sapho, pre-leader Residual Poison, battle and
|
||||
Ix-deck Thumper/Amal discards**, provided the parent transaction
|
||||
has no competing response.
|
||||
Every seat sees the same neutral fresh-discard opportunity regardless
|
||||
of hidden Semuta custody.
|
||||
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
|
||||
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
|
||||
Moritani entry reactions. Distrans's gift remains private with its
|
||||
@@ -93,6 +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 a genuine Ix-deck Thumper, its played physical card reaches
|
||||
the discard before an injected worm or actual spice-card draw.
|
||||
Semuta claim or all-seat decline resumes the one saved Spice Blow;
|
||||
|
||||
@@ -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, Once Around/movement/battle-order/pre-plan Sapho, unready pre-leader Residual Poison, 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, 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;
|
||||
|
||||
+76
-6
@@ -1190,6 +1190,14 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'thumperDiscard';
|
||||
player: string;
|
||||
@@ -4850,6 +4858,22 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu
|
||||
})),
|
||||
});
|
||||
}
|
||||
type KaramaCharityDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' }
|
||||
>;
|
||||
function karamaCharityDiscardSignature(g: Game, c: KaramaCharityDiscardContinuation) {
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume, `karama:charity:${c.card}`),
|
||||
turn: g.turn, phase: g.phase, advanced: g.advanced,
|
||||
charity: g.choamCharity, 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
type ThumperDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'thumperDiscard' }
|
||||
@@ -5411,6 +5435,22 @@ 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') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
g.phase === 2 && !g.response && g.choamCharity?.turn !== g.turn &&
|
||||
c.use.response.kind === 'choamCharity' &&
|
||||
c.use.response.owner !== c.player &&
|
||||
c.resume.response?.kind === 'choamCharity' &&
|
||||
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 &&
|
||||
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.',
|
||||
);
|
||||
} else if (continuation?.kind === 'thumperDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
@@ -6068,6 +6108,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||
continuation.kind !== 'saphoBattleOrderDiscard' &&
|
||||
continuation.kind !== 'residualPoisonDiscard' &&
|
||||
continuation.kind !== 'karamaCharityDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
continuation.kind !== 'amalDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
@@ -6122,14 +6163,21 @@ 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.resume.decision && !continuation.resume.pendingKarama &&
|
||||
!continuation.resume.phaseOpening && !g.pendingKarama &&
|
||||
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
|
||||
: true) &&
|
||||
(continuation.kind === 'battleCleanup' ||
|
||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||
continuation.kind === 'battleResolved' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
? continuation.resume.phaseOpening?.passed.length === 0
|
||||
: !continuation.resume.phaseOpening))) &&
|
||||
(continuation.kind === 'karamaCharityDiscard' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
? continuation.resume.phaseOpening?.passed.length === 0
|
||||
: !continuation.resume.phaseOpening)))) &&
|
||||
!g.karamaShipping && !g.auction &&
|
||||
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
|
||||
(continuation.kind === 'saphoAggressorDiscard' ||
|
||||
@@ -6332,6 +6380,12 @@ 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') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
completeKarama(g, getPlayer(g, next.player), next.use);
|
||||
} else if (next.kind === 'amalDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -10677,7 +10731,23 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
|
||||
requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
|
||||
validateKaramaUse(g, p, use, card);
|
||||
assertKaramaPromiseFeasibility(g, p, use, card);
|
||||
discard(g, p, card.id);
|
||||
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,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
stateSignature: '',
|
||||
};
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (use.kind === 'shipment') use = { ...use, card: card.id };
|
||||
if (card.kind === 'worthless') {
|
||||
requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
|
||||
|
||||
+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. Full Richese starts and complete interaction verification remain unavailable.'
|
||||
? '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.'
|
||||
: 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, clean Once Around/movement/battle-order/pre-plan Sapho, clean pre-leader Residual Poison and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. The committed death or order resumes once. 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 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 bounded Sapho scopes, pre-leader Residual Poison, Ix-deck Thumper/Amal and three battle discard stages. Residual Poison commits one random leader death before the neutral offer and restores an uninspected, unready battle once; Once Around commits bidder order, the chooser commits battle priority and pre-plan Sapho commits aggressor 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. Inspected/partly ready battles, 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, 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.',
|
||||
},
|
||||
{
|
||||
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, Once Around/movement/battle-order/pre-plan Sapho, pre-leader Residual Poison, 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, 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, Ix-deck Thumper/Amal and three battle discard stages. Inspected/partly ready battles, 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, 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',
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '../game/engine';
|
||||
import { baseDeck, treacheryDeck } from '../game/cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
@@ -77,6 +79,78 @@ function cancel(g: Game) {
|
||||
assert.ok(card, 'canceller must hold Karama before the declaration');
|
||||
return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' });
|
||||
}
|
||||
void test('clean printed Karama cancellation offers Semuta before CHOAM Charity is canceled', () => {
|
||||
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 = ready(g);
|
||||
assert.equal(g.response?.kind, 'choamCharity');
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
g = cancel(g);
|
||||
const event = g.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.choamCharity, undefined);
|
||||
assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, true);
|
||||
assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, false);
|
||||
for (const change of [
|
||||
(saved: Game) => {
|
||||
const continuation = saved.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'karamaCharityDiscard');
|
||||
if (continuation.kind === 'karamaCharityDiscard')
|
||||
continuation.stateSignature = 'wrong';
|
||||
},
|
||||
(saved: Game) => { saved.players[1].spice = 1; },
|
||||
(saved: Game) => { saved.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
|
||||
]) {
|
||||
const corrupted: Game = JSON.parse(JSON.stringify(g));
|
||||
change(corrupted);
|
||||
const snapshot = JSON.stringify(corrupted);
|
||||
assert.throws(() => applyAction(corrupted, 'b', { type: 'semutaCommit', event }));
|
||||
assert.equal(JSON.stringify(corrupted), snapshot);
|
||||
}
|
||||
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.choamCharity, { turn: 1, canceled: true });
|
||||
assert.equal(declined.players[0].spice, 0);
|
||||
const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'b', { type: 'semutaCommit', event });
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(claimed.choamCharity, declined.choamCharity);
|
||||
assert.equal(claimed.players[2].hand.some(c => c.effect === 'karama'), true);
|
||||
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(claimed.players[0].spice, 0);
|
||||
});
|
||||
|
||||
void test('prior-turn CHOAM Charity receipt does not block a fresh Karama Semuta response', () => {
|
||||
let g = fixture();
|
||||
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
||||
g.richeseCache = richeseCards();
|
||||
g.semutaPreview = true;
|
||||
g.turn = 2;
|
||||
g.choamCharity = { turn: 1, canceled: false };
|
||||
const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
||||
g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]);
|
||||
contest(g);
|
||||
g = ready(g);
|
||||
assert.equal(g.response?.kind, 'choamCharity');
|
||||
const pending = cancel(g);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.choamCharity, { turn: 1, canceled: false });
|
||||
let resumed = pending;
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const id of ['c', 'e', 'b'])
|
||||
resumed = applyAction(resumed, id, { type: 'semutaPass', event });
|
||||
assert.deepEqual(resumed.choamCharity, { turn: 2, canceled: true });
|
||||
assert.equal(resumed.players[0].spice, 0);
|
||||
});
|
||||
|
||||
void test('CHOAM receives two spice per faction, including itself, only after its opening response', () => {
|
||||
const initial = contest(fixture());
|
||||
let g = ready(initial);
|
||||
|
||||
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import { botActions } from '../game/bots';
|
||||
import { viewGame, type Game, type Action } from '../game/engine';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { spiceDeck } from '../game/cards';
|
||||
import { baseDeck, spiceDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
|
||||
import { takeSaphoBattleCard } from './fixture-sapho-battle-order';
|
||||
@@ -726,3 +727,65 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be
|
||||
assert.equal(prepared.battle?.saphoAggressor?.uses.length, 1);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('authenticated printed Karama cost pauses and restores one canceled CHOAM Charity response', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const made = await f.rooms.createRoom('Semuta Charity', '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: 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[2].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 });
|
||||
assert.equal(g.response?.kind, 'choamCharity');
|
||||
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.choamCharity, undefined);
|
||||
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);
|
||||
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, false);
|
||||
const claimed = await act(2, { type: 'semutaCommit', event });
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(claimed.choamCharity, { turn: 1, canceled: true });
|
||||
assert.equal(claimed.players[0].spice, 0);
|
||||
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