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
|
the physical Ix deck. Clean public ordinary-card, paid Box, retired
|
||||||
Ornithopter, private Distrans, final Truthtrance, Once Around,
|
Ornithopter, private Distrans, final Truthtrance, Once Around,
|
||||||
movement-order, between-battles chooser or pre-plan aggressor Sapho,
|
movement-order, between-battles chooser or pre-plan aggressor Sapho,
|
||||||
clean pre-leader Residual Poison, battle-card and Ix-deck Thumper/Amal
|
clean pre-leader Residual Poison, printed Karama canceling clean
|
||||||
discards open the same neutral offer regardless of hidden Semuta custody.
|
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
|
Residual Poison commits one random death before the offer; only an
|
||||||
uninspected battle with no preparation ready qualifies. Once Around
|
uninspected battle with nobody ready qualifies. Once Around commits
|
||||||
commits bidder order and a freed slot; other Sapho scopes commit
|
bidder order and frees a slot; other Sapho scopes commit chooser or
|
||||||
chooser or aggressor priority. Claim or decline resumes the same lot,
|
aggressor priority. Claim or decline resumes the saved effect once.
|
||||||
chooser queue or unsealed battle without repeating its prior effect;
|
A Black Market Semuta reserved as the offered card cannot Commit.
|
||||||
a Black Market Semuta reserved as the offered card cannot Commit.
|
Thumper waits before worm/spice draw; Amal waits after halving spice
|
||||||
Thumper waits before its injected
|
and clearing opening passes. Mixed-owner battle cards require
|
||||||
worm/spice draw; Amal waits after halving personal spice and clearing
|
committed owner-only selection; mandatory and optional winner cleanup
|
||||||
earlier opening passes. Mixed-owner battle cards require committed
|
remain separate. Minimal legal AI, JSON/SQLite continuation and
|
||||||
owner-only selection of another player's fresh card; mandatory and
|
competing-room CAS are connected. Mixed arrivals, queued or
|
||||||
optional winner cleanup remain separate. Minimal legal AI, JSON/
|
promise-bound Truthtrance, other Karama uses, pending rewards,
|
||||||
SQLite continuation and competing-room CAS are connected. Mixed
|
full-hand ordering, normal Richese starts and deployed acceptance
|
||||||
arrivals, queued or promise-bound Truthtrance, pending rewards, other
|
remain unfinished. Neutral passes and free-slot guard are product
|
||||||
producers, full-hand ordering, normal Richese starts and deployed
|
choices.
|
||||||
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)
|
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,
|
now proceeds from genuine battle loss to a dead-target Traitor reveal,
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 30 September 2026 — Semuta after clean Residual Poison
|
||||||
|
|
||||||
In the explicit Richese preview, Residual Poison now pauses after
|
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.
|
- 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).
|
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
|
free-slot policy, minimal legal AI and saved continuation work after
|
||||||
clean ordinary-card, paid Box, retired Ornithopter, private Distrans,
|
clean ordinary-card, paid Box, retired Ornithopter, private Distrans,
|
||||||
final Truthtrance, four Sapho scopes, clean pre-leader Residual Poison,
|
final Truthtrance, four Sapho scopes, clean pre-leader Residual Poison,
|
||||||
Ix-deck Thumper/Amal and three battle discard stages. Once Around
|
printed Karama canceling CHOAM Charity, Ix-deck Thumper/Amal and
|
||||||
commits the lot order and hand-slot release before a neutral offer;
|
three battle discard stages. Karama's cost pauses before the one
|
||||||
battle-order/pre-plan Sapho commit chooser/tie priority; Residual
|
canceled response settles; Once Around commits lot order and hand-slot
|
||||||
Poison commits one random death while readiness remains unsealed.
|
release, battle-order/pre-plan Sapho commit priority, and Residual
|
||||||
Thumper pauses before worm/spice draw; Amal resumes its already-halved
|
Poison commits one random death before the neutral offer. Thumper
|
||||||
opening once. Multi-owner batches require committed owner-only selection
|
pauses before worm/spice draw; Amal resumes its already-halved opening
|
||||||
of one other-player card. Queued questions, promises, reactive arrivals,
|
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
|
inspected/partly ready battles, pending rewards and other sources
|
||||||
stay unconnected.
|
stay unconnected.
|
||||||
Full-hand ordering, ten-card inventory, cache custody and public
|
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
|
offer is limited to a pre-leader board with no inspection or ready
|
||||||
combatant; other accepted plays retain automatic continuation.
|
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
|
Completed Ornithopter movement now offers the same retired public card
|
||||||
before its already committed arrival suffix only in a roster without
|
before its already committed arrival suffix only in a roster without
|
||||||
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
|
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
|
with CHOAM & Richese and optionally the physical Ix deck, without
|
||||||
opening public expansion starts. It interrupts **clean public
|
opening public expansion starts. It interrupts **clean public
|
||||||
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
|
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
|
||||||
Truthtrance, Once Around, movement-order, between-battles chooser
|
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
|
||||||
or pre-plan aggressor Sapho, pre-leader Residual Poison, battle and
|
printed Karama canceling clean CHOAM Charity, battle and Ix-deck
|
||||||
Ix-deck Thumper/Amal discards**, provided the parent transaction
|
Thumper/Amal discards**. Other than the one explicitly suspended
|
||||||
has no competing response.
|
CHOAM Charity response, the parent transaction has no competing
|
||||||
Every seat sees the same neutral fresh-discard opportunity regardless
|
response. Every seat sees the same neutral fresh-discard opportunity
|
||||||
of hidden Semuta custody.
|
regardless of hidden Semuta custody.
|
||||||
An early-ended Ornithopter advances the saved queue; a completed
|
An early-ended Ornithopter advances the saved queue; a completed
|
||||||
flight resumes one committed arrival only without BG, Ecaz or
|
flight resumes one committed arrival only without BG, Ecaz or
|
||||||
Moritani entry reactions. Distrans's gift remains private with its
|
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
|
preparation once; an inspected or partly ready battle retains the
|
||||||
automatic death path without this preview interruption.
|
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
|
For a genuine Ix-deck Thumper, its played physical card reaches
|
||||||
the discard before an injected worm or actual spice-card draw.
|
the discard before an injected worm or actual spice-card draw.
|
||||||
Semuta claim or all-seat decline resumes the one saved Spice Blow;
|
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')
|
if (definition?.card.effect === 'distrans')
|
||||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||||
if (definition?.card.effect === 'semutaDrug')
|
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'
|
return definition && definition.card.effect !== 'karama'
|
||||||
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
+76
-6
@@ -1190,6 +1190,14 @@ export type Game = {
|
|||||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||||
stateSignature: string;
|
stateSignature: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'karamaCharityDiscard';
|
||||||
|
player: string;
|
||||||
|
card: string;
|
||||||
|
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||||
|
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||||
|
stateSignature: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'thumperDiscard';
|
kind: 'thumperDiscard';
|
||||||
player: string;
|
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<
|
type ThumperDiscardContinuation = Extract<
|
||||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||||
{ kind: 'thumperDiscard' }
|
{ kind: 'thumperDiscard' }
|
||||||
@@ -5411,6 +5435,22 @@ function treacheryDiscardIntegrity(g: Game) {
|
|||||||
c.stateSignature === residualPoisonDiscardSignature(g, c),
|
c.stateSignature === residualPoisonDiscardSignature(g, c),
|
||||||
'The used Residual Poison no longer matches its committed leader death.',
|
'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') {
|
} else if (continuation?.kind === 'thumperDiscard') {
|
||||||
const c = continuation, entry = batch.entries[0];
|
const c = continuation, entry = batch.entries[0];
|
||||||
requireRule(
|
requireRule(
|
||||||
@@ -6068,6 +6108,7 @@ function semutaOfferSupported(
|
|||||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||||
continuation.kind !== 'saphoBattleOrderDiscard' &&
|
continuation.kind !== 'saphoBattleOrderDiscard' &&
|
||||||
continuation.kind !== 'residualPoisonDiscard' &&
|
continuation.kind !== 'residualPoisonDiscard' &&
|
||||||
|
continuation.kind !== 'karamaCharityDiscard' &&
|
||||||
continuation.kind !== 'thumperDiscard' &&
|
continuation.kind !== 'thumperDiscard' &&
|
||||||
continuation.kind !== 'amalDiscard' &&
|
continuation.kind !== 'amalDiscard' &&
|
||||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||||
@@ -6122,14 +6163,21 @@ function semutaOfferSupported(
|
|||||||
continuation.kind === 'battleResolved'
|
continuation.kind === 'battleResolved'
|
||||||
? entries.every(entry => entry.publicFace)
|
? entries.every(entry => entry.publicFace)
|
||||||
: entries.length === 1 && entries[0].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 === 'battleCleanup' ||
|
||||||
continuation.kind === 'winnerMandatoryDiscard' ||
|
continuation.kind === 'winnerMandatoryDiscard' ||
|
||||||
continuation.kind === 'battleResolved' ||
|
continuation.kind === 'battleResolved' ||
|
||||||
(!continuation.resume.response && !continuation.resume.decision &&
|
(continuation.kind === 'karamaCharityDiscard' ||
|
||||||
!continuation.resume.pendingKarama &&
|
(!continuation.resume.response && !continuation.resume.decision &&
|
||||||
(continuation.kind === 'amalDiscard'
|
!continuation.resume.pendingKarama &&
|
||||||
? continuation.resume.phaseOpening?.passed.length === 0
|
(continuation.kind === 'amalDiscard'
|
||||||
: !continuation.resume.phaseOpening))) &&
|
? continuation.resume.phaseOpening?.passed.length === 0
|
||||||
|
: !continuation.resume.phaseOpening)))) &&
|
||||||
!g.karamaShipping && !g.auction &&
|
!g.karamaShipping && !g.auction &&
|
||||||
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
|
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
|
||||||
(continuation.kind === 'saphoAggressorDiscard' ||
|
(continuation.kind === 'saphoAggressorDiscard' ||
|
||||||
@@ -6332,6 +6380,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
|||||||
const battle = g.battle!;
|
const battle = g.battle!;
|
||||||
if (battle.preLeader && !battle.preLeader.closed)
|
if (battle.preLeader && !battle.preLeader.closed)
|
||||||
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
|
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') {
|
} else if (next.kind === 'amalDiscard') {
|
||||||
g.response = next.resume.response;
|
g.response = next.resume.response;
|
||||||
g.decision = next.resume.decision;
|
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.');
|
requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
|
||||||
validateKaramaUse(g, p, use, card);
|
validateKaramaUse(g, p, use, card);
|
||||||
assertKaramaPromiseFeasibility(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 (use.kind === 'shipment') use = { ...use, card: card.id };
|
||||||
if (card.kind === 'worthless') {
|
if (card.kind === 'worthless') {
|
||||||
requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
|
requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
|
||||||
|
|||||||
+5
-5
@@ -1140,7 +1140,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
definition.card.effect === 'juiceOfSapho'
|
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.'
|
? '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'
|
: 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'
|
: 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.'
|
? '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'
|
: definition.card.effect === 'nullentropyBox'
|
||||||
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
: definition.card.effect === 'portableSnooper'
|
: 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.'
|
? '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'
|
: 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.',
|
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||||
],
|
],
|
||||||
...(definition.card.effect === 'semutaDrug'
|
...(definition.card.effect === 'semutaDrug'
|
||||||
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Implementation' as const,
|
area: 'Implementation' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
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,
|
area: 'Player controls' as const,
|
||||||
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Documentation' as const,
|
area: 'Documentation' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
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,
|
area: 'Verification' as const,
|
||||||
status: 'Partial' as const,
|
status: 'Partial' as const,
|
||||||
detail:
|
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: [
|
evidence: [
|
||||||
'tests/semuta-engine.test.ts',
|
'tests/semuta-engine.test.ts',
|
||||||
'tests/semuta-recovery.test.ts',
|
'tests/semuta-recovery.test.ts',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { baseDeck, treacheryDeck } from '../game/cards';
|
import { baseDeck, treacheryDeck } from '../game/cards';
|
||||||
import { createTechTokens } from '../game/tech-tokens';
|
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 { botActions } from '../game/bots';
|
||||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
|
||||||
@@ -77,6 +79,78 @@ function cancel(g: Game) {
|
|||||||
assert.ok(card, 'canceller must hold Karama before the declaration');
|
assert.ok(card, 'canceller must hold Karama before the declaration');
|
||||||
return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' });
|
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', () => {
|
void test('CHOAM receives two spice per faction, including itself, only after its opening response', () => {
|
||||||
const initial = contest(fixture());
|
const initial = contest(fixture());
|
||||||
let g = ready(initial);
|
let g = ready(initial);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||||
import { botActions } from '../game/bots';
|
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 { 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 { unitStore } from './fixture-nexus-room-store';
|
||||||
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
|
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
|
||||||
import { takeSaphoBattleCard } from './fixture-sapho-battle-order';
|
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);
|
assert.equal(prepared.battle?.saphoAggressor?.uses.length, 1);
|
||||||
} finally { f.sqlite.close(); }
|
} 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