Support alliance answers with Richese Semuta
Verify and publish checkpoint container / container (push) Successful in 10m15s
Verify and publish checkpoint container / container (push) Successful in 10m15s
This commit is contained in:
1 parent
4dfdd500e0
commit
d6ddb7cb5c
15 files changed
+476
-19
No files matched your search
@@ -2254,8 +2254,7 @@ export function GameTable({
|
||||
</> : <>
|
||||
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
|
||||
Combined turn-action Truthtrance questions are unavailable.
|
||||
When Richese is seated, alliance commitments are also unavailable,
|
||||
and some Kull reactions may be unavailable in complex response windows.
|
||||
Some Kull reactions may be unavailable in complex response windows.
|
||||
</>}{' '}
|
||||
Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules.
|
||||
Every player must ready again after changing it.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Kull Wahad: source limits and Karama interruption design
|
||||
|
||||
## Basic alliance answers with Richese — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Simple, grouped and mixed current-Nexus alliance
|
||||
answers can coexist with Semuta in the supported bare Basic Richese profile,
|
||||
with or without printed Kull. Semuta's claim or pass preserves the exact
|
||||
answer, the original Nexus and a queued Fremen worm ride. Native consent,
|
||||
breaks and final Nexus settlement still apply. Later Kull success or a
|
||||
distinct counter preserves completed alliance history through Semuta. Timing
|
||||
is the existing provisional online composition. Whole-turn questions,
|
||||
special-cache Kull and broader module combinations remain separate.
|
||||
Tests use real setup, offers, answers and actions, with selected unopened cards
|
||||
and explicitly staged physical cards or forces.
|
||||
[Alliance evidence](../tests/kull-richese-alliance-semuta.test.ts),
|
||||
[queued ride evidence](../tests/kull-richese-alliance-worm-ride.test.ts),
|
||||
[rendered controls](../tests/kull-richese-alliance-table-controls.test.tsx).
|
||||
|
||||
## Basic Revival answers with Richese — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Force, named-leader, grouped and mixed Revival
|
||||
@@ -9,8 +25,8 @@ Truthtrance preserves the answer and its exact original return binding. With
|
||||
Kull, successful interception or a distinct counter retains the original
|
||||
Revival answer through Semuta's claim or pass. The online timing remains
|
||||
provisional. Tests use genuine native setup and Revival windows with selected
|
||||
physical cards and, for named-leader cases, staged leader deaths. Alliance
|
||||
questions with Richese, whole-turn questions, special-cache Kull and broader
|
||||
physical cards and, for named-leader cases, staged leader deaths. Whole-turn
|
||||
questions, special-cache Kull and broader
|
||||
module combinations remain separate.
|
||||
[Revival/Kull evidence](../tests/kull-richese-revival-commitment.test.ts),
|
||||
[Truth discard evidence](../tests/kull-richese-revival-truth-semuta.test.ts),
|
||||
@@ -24,7 +40,7 @@ profile. The physical Truthtrance discard retains its bribe binding through
|
||||
Semuta's claim or pass. A later Kull success or distinct counter likewise
|
||||
preserves the answer through its Semuta reaction; the real payment and No cap
|
||||
remain governed by the original bribe rules. The preview's existing timing is
|
||||
provisional. Alliance questions with Richese, whole-turn questions,
|
||||
provisional. Whole-turn questions,
|
||||
special-cache Kull and broader module combinations remain separate.
|
||||
[Runtime evidence](../tests/kull-richese-bribe-runtime.test.ts),
|
||||
[Truth discard evidence](../tests/kull-richese-bribe-truth-semuta.test.ts),
|
||||
|
||||
+14
-3
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Richese and alliance Truthtrance — 29 October. Prototyped / Partial.**
|
||||
Simple, grouped and mixed alliance answers now continue through Semuta in the
|
||||
supported bare Basic Richese profile, with or without printed Kull. The
|
||||
original Nexus, consent and queued Fremen ride survive the Truthtrance discard;
|
||||
later Kull reactions retain completed answer history. Twelve related files
|
||||
pass 72 cases, including actual Nexus actions, Semuta choices, bot answers and
|
||||
rendered controls. Typecheck, lint and the production build pass. Fixtures
|
||||
select unopened cards and explicitly stage some
|
||||
physical cards or forces. Whole-turn questions, special-cache Kull and broader
|
||||
profiles remain unfinished. [Scope](CHOAM_KULL_DESIGN.md#basic-alliance-answers-with-richese--29-october-2026).
|
||||
|
||||
**Basic Richese and Revival Truthtrance — 29 October. Prototyped / Partial.**
|
||||
Force, named-leader, grouped and mixed Revival answers now continue through
|
||||
Semuta in the supported bare Basic Richese profile, with or without printed
|
||||
@@ -25,8 +36,8 @@ or a distinct counter preserves the original Revival commitment through its
|
||||
Semuta response. Thirteen related files pass 87 cases; an additional rendered
|
||||
table/control case passes. Typecheck, lint and the production build pass.
|
||||
Fixtures use genuine setup and Revival windows with
|
||||
selected physical cards and staged deaths for named-leader cases. Alliance and
|
||||
whole-turn questions with Richese, special-cache Kull and broader profiles
|
||||
selected physical cards and staged deaths for named-leader cases. Whole-turn
|
||||
questions with Richese, special-cache Kull and broader profiles
|
||||
remain unfinished. [Scope](CHOAM_KULL_DESIGN.md#basic-revival-answers-with-richese--29-october-2026).
|
||||
|
||||
**Basic Kull, Richese and bribe Truthtrance — 29 October. Prototyped / Partial.**
|
||||
@@ -36,7 +47,7 @@ discard's Semuta claim or pass and later Kull success or a distinct counter,
|
||||
including the respective Semuta response. Twelve related files pass 63 cases,
|
||||
with genuine setup, cache purchase, answer, reaction, payment and minimal bot
|
||||
actions; a separate rendered table/control case passes. Typecheck, lint and the
|
||||
production build pass. Alliance questions with Richese, whole-turn questions,
|
||||
production build pass. Whole-turn questions,
|
||||
special-cache Kull and broader profiles remain unfinished.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-bribe-answers-with-richese--29-october-2026).
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic Richese alliance answers — 29 October 2026
|
||||
|
||||
The supported bare Basic profile retains simple, grouped and mixed alliance
|
||||
answers through Semuta and queued Fremen rides, with or without printed Kull.
|
||||
The existing online ordering remains provisional.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-alliance-answers-with-richese--29-october-2026).
|
||||
|
||||
## Basic Richese Revival answers — 29 October 2026
|
||||
|
||||
The supported bare Basic profile retains force, leader, grouped and mixed
|
||||
|
||||
@@ -47,7 +47,8 @@ export function alliancePromiseModeSupported(game: NativePromiseScope & {
|
||||
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
|
||||
richeseBetrayalPreview?: unknown;
|
||||
}): boolean {
|
||||
return nativePromiseScopeSupported(game) && !game.semutaPreview &&
|
||||
return nativePromiseScopeSupported(game) &&
|
||||
(!game.semutaPreview || game.semutaPreview === true && bribePromiseModeSupported(game)) &&
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview &&
|
||||
(!game.kullPreview || bribePromiseModeSupported(game));
|
||||
}
|
||||
|
||||
+69
-1
@@ -1616,6 +1616,8 @@ export type Game = {
|
||||
bribeReceipt?: true;
|
||||
/** New Kull/Semuta attempts retain the original Revival answer ledger. */
|
||||
revivalReceipt?: true;
|
||||
/** New Kull/Semuta attempts retain the original alliance answer ledger. */
|
||||
allianceReceipt?: true;
|
||||
stage: 'offer' | 'counter';
|
||||
selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string };
|
||||
intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent };
|
||||
@@ -1934,6 +1936,10 @@ export type Game = {
|
||||
| { kind: 'leaderRevival'; index: number; value: LeaderRevivalPromise }
|
||||
| { kind: 'revivalExpression'; index: number; value: RevivalExpressionPromise }
|
||||
| { kind: 'mixedRevival'; index: number; value: NonNullable<Game['mixedRevivalQuestions']>[number] };
|
||||
allianceBinding?:
|
||||
| { kind: 'alliance'; index: number; value: AlliancePromise }
|
||||
| { kind: 'allianceExpression'; index: number; value: AllianceExpressionPromise }
|
||||
| { kind: 'mixedAlliance'; index: number; value: NonNullable<Game['mixedAllianceQuestions']>[number] };
|
||||
parentSignature: string;
|
||||
}
|
||||
| {
|
||||
@@ -7587,6 +7593,10 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
|
||||
revivalPromises: g.revivalPromises, leaderRevivalPromises: g.leaderRevivalPromises,
|
||||
revivalExpressionPromises: g.revivalExpressionPromises, mixedRevivalQuestions: g.mixedRevivalQuestions,
|
||||
revivalPromiseSequence: g.revivalPromiseSequence } : {}),
|
||||
...(next.allianceBinding ? { allianceBinding: next.allianceBinding,
|
||||
alliancePromises: g.alliancePromises, allianceExpressionPromises: g.allianceExpressionPromises,
|
||||
mixedAllianceQuestions: g.mixedAllianceQuestions, alliancePromiseSequence: g.alliancePromiseSequence,
|
||||
nexus: g.nexus, spiceSequence: g.spiceSequence, spiceDiscard: g.spiceDiscard } : {}),
|
||||
controls: nullentropyParentSignature(
|
||||
g,
|
||||
next.resume,
|
||||
@@ -7721,6 +7731,15 @@ function finishTruthtranceAnswer(g: Game) {
|
||||
else if (g.semutaPreview && record.question.kind === 'mixedRevival') continuation.revivalBinding = {
|
||||
kind: 'mixedRevival', index: g.mixedRevivalQuestions!.length - 1, value: structuredClone(g.mixedRevivalQuestions!.at(-1)!),
|
||||
};
|
||||
if (g.semutaPreview && record.question.kind === 'alliance') continuation.allianceBinding = {
|
||||
kind: 'alliance', index: g.alliancePromises!.length - 1, value: structuredClone(g.alliancePromises!.at(-1)!),
|
||||
};
|
||||
else if (g.semutaPreview && record.question.kind === 'allianceExpression') continuation.allianceBinding = {
|
||||
kind: 'allianceExpression', index: g.allianceExpressionPromises!.length - 1, value: structuredClone(g.allianceExpressionPromises!.at(-1)!),
|
||||
};
|
||||
else if (g.semutaPreview && record.question.kind === 'mixedAlliance') continuation.allianceBinding = {
|
||||
kind: 'mixedAlliance', index: g.mixedAllianceQuestions!.length - 1, value: structuredClone(g.mixedAllianceQuestions!.at(-1)!),
|
||||
};
|
||||
continuation.parentSignature = truthtranceDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(
|
||||
g,
|
||||
@@ -8782,6 +8801,40 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The completed mixed Revival answer no longer matches its return binding.');
|
||||
}
|
||||
} else requireRule(!c.revivalBinding, 'This Truthtrance answer has no Revival binding.');
|
||||
const expectedAlliance = g.semutaPreview &&
|
||||
(q.kind === 'alliance' || q.kind === 'allianceExpression' || q.kind === 'mixedAlliance');
|
||||
if (expectedAlliance) {
|
||||
const binding = c.allianceBinding;
|
||||
const actual = binding?.kind === 'alliance' ? g.alliancePromises :
|
||||
binding?.kind === 'allianceExpression' ? g.allianceExpressionPromises : g.mixedAllianceQuestions;
|
||||
requireRule(binding && binding.kind === q.kind && Array.isArray(actual) &&
|
||||
Number.isSafeInteger(binding.index) && binding.index >= 0 && binding.index === actual.length - 1 &&
|
||||
JSON.stringify(binding.value) === JSON.stringify(actual[binding.index]) &&
|
||||
binding.value.player === q.target && binding.value.asker === record.asker &&
|
||||
binding.value.answer === (record.answer === 'yes') && binding.value.turn === g.turn,
|
||||
'The completed Truthtrance lost its original alliance answer.');
|
||||
const opportunity = allianceOpportunity(context);
|
||||
requireRule(opportunity, 'The completed Truthtrance lost its original Nexus.');
|
||||
if (binding.kind === 'alliance' && q.kind === 'alliance')
|
||||
requireRule(binding.value.partner === q.partner && binding.value.source === opportunity.source &&
|
||||
!binding.value.fulfilled && !binding.value.released && g.alliancePromiseSequence?.at(-1) === binding.value.event,
|
||||
'The completed Truthtrance alliance differs from its original question.');
|
||||
else if (binding.kind === 'allianceExpression' && q.kind === 'allianceExpression')
|
||||
requireRule(JSON.stringify(binding.value.claim) === JSON.stringify(q.claim) &&
|
||||
binding.value.source === opportunity.source && !binding.value.fulfilled && !binding.value.released &&
|
||||
g.alliancePromiseSequence?.at(-1) === binding.value.event,
|
||||
'The completed Truthtrance alliance expression differs from its original question.');
|
||||
else if (binding.kind === 'mixedAlliance' && q.kind === 'mixedAlliance') {
|
||||
const residual = g.allianceExpressionPromises?.at(-1);
|
||||
requireRule(JSON.stringify(binding.value.mixed) === JSON.stringify(q.mixed) &&
|
||||
(binding.value.binding ? !!residual && residual.event === binding.value.event &&
|
||||
residual.player === q.target && residual.asker === record.asker && residual.answer === binding.value.answer &&
|
||||
residual.turn === g.turn && residual.source === opportunity.source && !residual.fulfilled && !residual.released &&
|
||||
g.alliancePromiseSequence?.at(-1) === binding.value.event :
|
||||
!g.allianceExpressionPromises?.some(item => item.event === binding.value.event)),
|
||||
'The completed mixed alliance answer no longer matches its Nexus binding.');
|
||||
}
|
||||
} else requireRule(!c.allianceBinding, 'This Truthtrance answer has no alliance binding.');
|
||||
if (expectedShipment || expectedBattle || expectedMovement || expectedTraitor) {
|
||||
const actual = expectedTraitor ? g.battle?.traitorDecisionPromises : expectedMovement ? g.movementPromises : expectedShipment
|
||||
? g.shipmentPromises
|
||||
@@ -9415,6 +9468,9 @@ function semutaOfferSupported(
|
||||
if (continuation.kind === 'truthtranceDiscard' &&
|
||||
['revival', 'leaderRevival', 'revivalExpression', 'mixedRevival'].includes(continuation.record.question.kind) &&
|
||||
(!continuation.revivalBinding || !bareBasicSemutaProfile(g) || !revivalPromiseModeSupported(g))) return false;
|
||||
if (continuation.kind === 'truthtranceDiscard' &&
|
||||
['alliance', 'allianceExpression', 'mixedAlliance'].includes(continuation.record.question.kind) &&
|
||||
(!continuation.allianceBinding || !bareBasicSemutaProfile(g) || !alliancePromiseModeSupported(g))) return false;
|
||||
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
|
||||
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
@@ -9590,12 +9646,14 @@ function semutaOfferSupported(
|
||||
g.pendingChoamMove || g.pendingIxMove ||
|
||||
g.pendingFremenMove || g.ornithopter || g.summonedWorm ||
|
||||
g.wormRides.length > 0)) return false;
|
||||
const allianceNexusDiscard = continuation.kind === 'truthtranceDiscard' && !!continuation.allianceBinding &&
|
||||
g.phase === 1 && g.nexus && bareBasicSemutaProfile(g) && alliancePromiseModeSupported(g);
|
||||
if ((continuation.kind === 'distransDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' ||
|
||||
continuation.kind === 'saphoMovementDiscard' ||
|
||||
(continuation.kind === 'ornithopterDiscard' && continuation.source === 'move')) &&
|
||||
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
|
||||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
|
||||
g.ornithopter || g.summonedWorm || !allianceNexusDiscard && g.wormRides.length > 0)) return false;
|
||||
if (continuation.kind === 'saphoAggressorDiscard' &&
|
||||
(!g.battle || g.pendingAuditor || g.pendingCapture || g.pendingTech ||
|
||||
g.pendingFaceDance || g.pendingChoamBattleIncome ||
|
||||
@@ -19226,6 +19284,9 @@ function kullSignature(g: Game, frame: KullFrame): string {
|
||||
...(frame.revivalReceipt ? {revivalReceipt:frame.revivalReceipt,revivalPromises:source.revivalPromises,
|
||||
leaderRevivalPromises:source.leaderRevivalPromises,revivalExpressionPromises:source.revivalExpressionPromises,
|
||||
mixedRevivalQuestions:source.mixedRevivalQuestions,revivalPromiseSequence:source.revivalPromiseSequence} : {}),
|
||||
...(frame.allianceReceipt ? {allianceReceipt:frame.allianceReceipt,alliancePromises:source.alliancePromises,
|
||||
allianceExpressionPromises:source.allianceExpressionPromises,mixedAllianceQuestions:source.mixedAllianceQuestions,
|
||||
alliancePromiseSequence:source.alliancePromiseSequence} : {}),
|
||||
});
|
||||
}
|
||||
/** Only the verified pre-source printed profile may bind its old held cost. */
|
||||
@@ -19277,6 +19338,10 @@ function kullIntegrity(g: Game): void {
|
||||
revivalPromiseModeSupported(g) && !!(g.revivalPromises?.length || g.leaderRevivalPromises?.length ||
|
||||
g.revivalExpressionPromises?.length || g.mixedRevivalQuestions?.length),
|
||||
'The Kull attempt has lost its original Basic Revival-answer ledger.');
|
||||
requireRule(frame.allianceReceipt === undefined || frame.allianceReceipt === true && g.semutaPreview === true &&
|
||||
alliancePromiseModeSupported(g) && !!(g.alliancePromises?.length || g.allianceExpressionPromises?.length ||
|
||||
g.mixedAllianceQuestions?.length),
|
||||
'The Kull attempt has lost its original Basic alliance-answer ledger.');
|
||||
validateKullAttemptStamp(g, frame, physicalTreacheryCards(g));
|
||||
requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 &&
|
||||
frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]),
|
||||
@@ -19364,6 +19429,9 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int
|
||||
...(g.semutaPreview && revivalPromiseModeSupported(g) && (g.revivalPromises?.length ||
|
||||
g.leaderRevivalPromises?.length || g.revivalExpressionPromises?.length || g.mixedRevivalQuestions?.length)
|
||||
? { revivalReceipt: true as const } : {}),
|
||||
...(g.semutaPreview && alliancePromiseModeSupported(g) && (g.alliancePromises?.length ||
|
||||
g.allianceExpressionPromises?.length || g.mixedAllianceQuestions?.length)
|
||||
? { allianceReceipt: true as const } : {}),
|
||||
worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
|
||||
};
|
||||
// Payment dispatch removed this control before entering its native branch.
|
||||
|
||||
@@ -14,7 +14,7 @@ export const FACTION_RULES: Record<
|
||||
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
|
||||
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
|
||||
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
|
||||
'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe and Revival, simple and mixed current-lot bid, and phase-wide bid questions are supported, while alliance commitments remain unavailable. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
|
||||
'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe, Revival and alliance, simple and mixed current-lot bid, and phase-wide bid questions are supported. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
|
||||
],
|
||||
advanced: [
|
||||
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
|
||||
|
||||
+4
-3
@@ -3490,6 +3490,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An owned Yes route uses an already held partnership or a currently available reciprocal invitation. The respondent may break an old pair first if the real invitation remains. Another player’s future consent, departure or renewed offer is not invented. Other players keep their original right to withdraw or break.',
|
||||
'If consent is not available and earlier answers do not fix the result, No or I don’t know may be available. Unknown preserves the physical card; the holder may ask a different question or save it to resume negotiations, then ask again after a real invitation.',
|
||||
'Your persistent panel offers the next native break, acceptance or Ready action in a quiet Nexus, including out of turn. A new definite answer reopens your own readiness. Ready requires the current partnership to match every live answer. Actual closing settles them before the next native draw or worm ride.',
|
||||
'In supported bare Basic Richese games, including printed Kull, simple, grouped and mixed alliance answers survive Semuta’s response to the discarded Truthtrance. An already queued Fremen worm ride waits through the same Nexus and response.',
|
||||
'Own actions cannot erase the last owned completion of Yes. A completed opposing withdrawal or break can release an impossible answer; earlier compatible answers remain. Existing financial and movement commitments are retained. An earlier owed bribe to the desired partner uses its original payment guide before acceptance.',
|
||||
'The current-Nexus interval, owned-consent witness and release priority are provisional online compositions of general current-turn Truthtrance authority. Wider negotiations, earlier-phase alliance questions, optional modules and complete-mode acceptance remain due. No start gate opens.',
|
||||
],
|
||||
@@ -3499,7 +3500,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Fixed-turn named partner, public final-status preview, target-only answers and persistent event-bound break/accept/Ready guidance. No private Nexus source is projected.', evidence: ['components/alliance-claim-fields.tsx', 'components/alliance-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-alliance-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four minimal policies use feasible answers and owned native guides. Strategic negotiation and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-alliance-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Physical alliance/general Truthtrance sources, owned consent, final interval, unknown/save and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_ALLIANCE_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original setup/Worm/consent histories and focused UI/pure/actor-order cases; wider source compositions, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-alliance.ts', 'tests/truth-alliance-runtime.test.ts', 'tests/truth-alliance-controls.test.tsx', 'tests/truth-alliance-interactions.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original setup/Worm/consent histories and focused UI/pure/actor-order cases; Basic Richese/Semuta adds Kull, queued-ride and rendered-control cases with documented staging. Wider source compositions, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-alliance.ts', 'tests/truth-alliance-runtime.test.ts', 'tests/truth-alliance-controls.test.tsx', 'tests/truth-alliance-interactions.test.ts', 'tests/kull-richese-alliance-semuta.test.ts', 'tests/kull-richese-alliance-worm-ride.test.ts', 'tests/kull-richese-alliance-table-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -4861,7 +4862,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
|
||||
steps: [
|
||||
'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.',
|
||||
'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe and Revival questions are available, including with Richese and Semuta. Without Richese, supported alliance questions are also available. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.',
|
||||
'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe, Revival and alliance questions are available, including with Richese and Semuta. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.',
|
||||
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
|
||||
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
|
||||
'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.',
|
||||
@@ -4876,7 +4877,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe and Revival commitments can coexist with this preview including Richese; alliance commitments remain limited to the non-Richese profile. Deferred overbid recovery, whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe, Revival and alliance commitments can coexist with this preview including Richese. Deferred overbid recovery, whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-richese-alliance-semuta.test.ts', 'tests/kull-richese-alliance-worm-ride.test.ts', 'tests/kull-richese-alliance-table-controls.test.tsx', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
|
||||
|
||||
@@ -16,6 +16,11 @@ void test('printed Kull admission reuses the supported Basic CHOAM envelope', ()
|
||||
assert.equal(supported({ ...basic, players: [{ faction: 'emperor' }, { faction: 'guild' }] }), false);
|
||||
assert.equal(supported({ ...basic, players: [...basic.players, { faction: 'guild' }] }), false);
|
||||
assert.equal(supported({ ...basic, kullPreview: false }), true);
|
||||
const richese = { ...basic, players: [...basic.players, { faction: 'richese' }], semutaPreview: true };
|
||||
assert.equal(supported(richese), true);
|
||||
assert.equal(supported({ ...richese, kullPreview: false }), true);
|
||||
assert.equal(supported({ ...richese, advanced: true }), false);
|
||||
assert.equal(supported({ ...richese, nexusCards: true }), false);
|
||||
for (const flag of ['semutaPreview', 'nexusKullPreview', 'richeseBetrayalPreview',
|
||||
'guildBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
|
||||
'nexusHarkonnenBetrayalPreview', 'moritaniAssassinatePreview', 'nexusCards', 'homeworlds',
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { handLimit, viewGame, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import { nextKullPurposeStep } from './fixture-kull-purpose';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** The inherited native Basic game buys Semuta through the real Richese cache.
|
||||
* Only one original Truthtrance is moved from a physical zone to Harkonnen;
|
||||
* a later unopened Worm and replacement are selected before the Spice draw. */
|
||||
let initial: ReturnType<typeof createKullRicheseSemutaFixture> | undefined;
|
||||
function source(kull = true) {
|
||||
const f = initial ??= createKullRicheseSemutaFixture();
|
||||
let game = structuredClone(f.game);
|
||||
if (!kull) game.kullPreview = false;
|
||||
const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []),
|
||||
...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)];
|
||||
const zone = zones.find(cards => cards.some(card => card.effect === 'truthtrance')); assert.ok(zone);
|
||||
const [truth] = zone.splice(zone.findIndex(card => card.effect === 'truthtrance'), 1);
|
||||
assert.ok(player(game, 'harkonnen').hand.length < handLimit(player(game, 'harkonnen')));
|
||||
player(game, 'harkonnen').hand.push(truth);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
game = advance(game, state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game, ['worm', 'land']);
|
||||
game = advance(game, state => state.phase === 1 && state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution);
|
||||
game = act(game, 'richese', {type:'alliance', target:'emperor'});
|
||||
return {...f, game, truth:truth.id};
|
||||
}
|
||||
function react(game:Game, card:string, claim:boolean):Game {
|
||||
const pending=game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage,'offer');
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
||||
const event=pending.batch.event;
|
||||
if (claim) {
|
||||
assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true);
|
||||
game=act(game,'richese',{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage==='select')
|
||||
game=act(game,'richese',{type:'semutaSelect',event,card});
|
||||
} else {
|
||||
for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count<game.players.length;count++) {
|
||||
const response=game.pendingTreacheryDiscard!.reaction; assert.ok(response?.stage==='offer');
|
||||
const seat=game.players.find(player=>!response.passed.includes(player.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'semutaPass',event});
|
||||
}
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
type Kind = 'alliance' | 'allianceExpression' | 'mixedAlliance';
|
||||
function question(game:Game,kind:Kind):TruthQuestion {
|
||||
const partner = 'richese';
|
||||
if (kind === 'alliance') return {kind,target:'emperor',turn:game.turn,partner};
|
||||
if (kind === 'allianceExpression') return {kind,target:'emperor',turn:game.turn,
|
||||
claim:{kind:'partner',partner}};
|
||||
return {kind,target:'emperor',turn:game.turn,mixed:{kind:'and',terms:[
|
||||
{kind:'fact',fact:{kind:'spice',compare:'gte',value:0}},
|
||||
{kind:'action',action:{partner}},
|
||||
]}};
|
||||
}
|
||||
function ask(game:Game,truth:string,kind:Kind,answer:'yes'|'no'):Game {
|
||||
game=act(game,'harkonnen',{type:'card',card:truth});
|
||||
while (game.truthtrance?.stage==='priority') {
|
||||
const seat=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'truthPass'});
|
||||
}
|
||||
game=act(game,'harkonnen',{type:'truthAsk',question:question(game,kind)});
|
||||
const view=viewGame(game,'emperor');
|
||||
const answers=kind==='alliance'?view.truthAllianceAnswers:
|
||||
kind==='allianceExpression'?view.truthAllianceExpressionAnswers:view.truthMixedAllianceAnswers;
|
||||
assert.ok(answers,`${kind}: answer controls`);
|
||||
assert.ok(answers.includes(answer),`${kind}: ${answer}`);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot=viewGame(game,'emperor'); bot.players.find(player=>player.id==='emperor')!.bot=difficulty;
|
||||
const action=botActions(bot)[0]; assert.ok(action,difficulty);
|
||||
assert.equal(action.type,'truthAnswer',difficulty);
|
||||
assert.ok(answers.includes(action.answer as 'yes'|'no'|'unknown'),difficulty);
|
||||
assert.doesNotThrow(()=>act(game,'emperor',action),difficulty);
|
||||
}
|
||||
return act(game,'emperor',{type:'truthAnswer',answer});
|
||||
}
|
||||
function close(game:Game):Game {
|
||||
for (let count=0;count<300 && game.nexus;count++) {
|
||||
const row=game.players.map(p=>({p,guide:viewGame(game,p.id).alliancePromiseGuide})).find(entry=>entry.guide);
|
||||
const step=row ? {actor:row.p.id,action:row.guide!.action} : nextKullPurposeStep(game);
|
||||
assert.ok(step,'The ordinary Nexus must retain an owned continuation');
|
||||
game=act(game,step.actor,step.action);
|
||||
}
|
||||
assert.equal(game.nexus,false); return game;
|
||||
}
|
||||
/** Later Richese auctions choose an actual remaining cache card; Semuta was
|
||||
* purchased in turn one and cannot be selected from the cache again. */
|
||||
function advanceLater(game:Game, until:(game:Game)=>boolean):Game {
|
||||
for (let count=0;count<1800;count++) {
|
||||
if (until(game)) return game;
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind==='richeseCache') {
|
||||
const card=game.richeseCache?.[0]; assert.ok(card);
|
||||
game=act(game,game.decision.player,{type:'decision',event:game.richeseBidding!.event,card:card.id,method:'silent'});
|
||||
continue;
|
||||
}
|
||||
const lot=game.richeseAuction;
|
||||
if (lot && !lot.outcome && !game.phaseOpening && !game.response && !game.decision) {
|
||||
assert.equal(lot.method,'silent');
|
||||
const seat=lot.eligible.find(id=>!Object.hasOwn(lot.sealed,id)); assert.ok(seat);
|
||||
game=act(game,seat,{type:'richeseBid',event:lot.event,amount:seat==='richese'?1:0,allyPayment:0});
|
||||
continue;
|
||||
}
|
||||
const step=nextKullPurposeStep(game); assert.ok(step);
|
||||
game=act(game,step.actor,step.action);
|
||||
}
|
||||
throw Error('The native programme did not reach its requested window');
|
||||
}
|
||||
function answerRows(game:Game,kind:Kind) {
|
||||
const view=viewGame(game,'emperor');
|
||||
return kind==='alliance' ? view.alliancePromises :
|
||||
kind==='allianceExpression' ? view.allianceExpressionPromises : view.mixedAlliancePromises;
|
||||
}
|
||||
for (const kull of [false,true]) for (const kind of ['alliance','allianceExpression','mixedAlliance'] as const)
|
||||
for (const claim of [false,true]) void test(`${kull?'Kull':'non-Kull'} ${kind} answer survives Semuta ${claim?'claim':'pass'} of Truthtrance`,()=>{
|
||||
const f=source(kull); let game=ask(f.game,f.truth,kind,'yes');
|
||||
game=react(game,f.truth,claim);
|
||||
const guide=viewGame(game,'emperor').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'alliance'); assert.equal(guide.action.target,'richese');
|
||||
game=act(game,'emperor',guide.action);
|
||||
assert.equal(player(game,'emperor').ally,'richese');
|
||||
game=close(game);
|
||||
assert.ok(answerRows(game,kind).some(row=>row.fulfilled && !row.released));
|
||||
assert.equal(player(game,'richese').hand.some(card=>card.id===f.truth),claim);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
void test('a No survives the Truth discard and requires a temporary alliance to end before Nexus close',()=>{
|
||||
const f=source(); let game=ask(f.game,f.truth,'alliance','no');
|
||||
game=react(game,f.truth,false);
|
||||
game=act(game,'emperor',{type:'alliance',target:'richese'});
|
||||
const before=structuredClone(game);
|
||||
assert.throws(()=>act(game,'emperor',{type:'ready'}));
|
||||
assert.deepEqual(game,before);
|
||||
const guide=viewGame(game,'emperor').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'alliance'); assert.equal(guide.action.target,null);
|
||||
game=act(game,'emperor',guide.action);
|
||||
game=close(game);
|
||||
assert.equal(player(game,'emperor').ally,null);
|
||||
assert.ok(answerRows(game,'alliance').some(row=>row.fulfilled && !row.released));
|
||||
});
|
||||
for (const counter of [false,true]) for (const claim of [false,true])
|
||||
void test(`completed alliance answer remains after later Kull ${counter?'counter':'success'} and Semuta ${claim?'claim':'pass'}`,()=>{
|
||||
const f=source(); let game=ask(f.game,f.truth,'alliance','yes');
|
||||
game=react(game,f.truth,false);
|
||||
const guide=viewGame(game,'emperor').alliancePromiseGuide; assert.ok(guide);
|
||||
game=act(game,'emperor',guide.action); game=close(game);
|
||||
const original=structuredClone(game.alliancePromises);
|
||||
game=advanceLater(game,state=>state.phase===5 && state.active==='emperor' && clean(state) &&
|
||||
!player(state,'emperor').shipped);
|
||||
game=act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'});
|
||||
const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction);
|
||||
game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
|
||||
if (counter) game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
|
||||
else game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
game=react(game,counter?f.counter:f.kull,claim);
|
||||
game=advance(game,clean);
|
||||
assert.deepEqual(game.alliancePromises,original);
|
||||
assert.ok(answerRows(game,'alliance').some(row=>row.fulfilled && !row.released));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
for (const claim of [false,true]) void test(`a mixed constant alliance answer survives Semuta ${claim?'claim':'pass'} without a residual promise`,()=>{
|
||||
const f=source(); let game=act(f.game,'harkonnen',{type:'card',card:f.truth});
|
||||
while (game.truthtrance?.stage==='priority') {
|
||||
const seat=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(seat);
|
||||
game=act(game,seat.id,{type:'truthPass'});
|
||||
}
|
||||
game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'mixedAlliance',target:'emperor',turn:game.turn,
|
||||
mixed:{kind:'or',terms:[{kind:'fact',fact:{kind:'spice',compare:'gte',value:0}},
|
||||
{kind:'action',action:{partner:'richese'}}]}}});
|
||||
assert.ok(viewGame(game,'emperor').truthMixedAllianceAnswers?.includes('yes'));
|
||||
game=act(game,'emperor',{type:'truthAnswer',answer:'yes'});
|
||||
const answer=game.mixedAllianceQuestions?.at(-1); assert.ok(answer);
|
||||
assert.equal(answer.binding,false);
|
||||
assert.ok(!game.allianceExpressionPromises?.some(row=>row.event===answer.event));
|
||||
game=react(game,f.truth,claim);
|
||||
assert.deepEqual(game.mixedAllianceQuestions?.at(-1),answer);
|
||||
game=close(game);
|
||||
assert.ok(viewGame(game,'emperor').mixedAlliancePromises.some(row=>row.event===answer.event && row.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { handLimit, viewGame, type Action, type Game, type GameView } from '../game/engine';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
const { AlliancePromiseGuideButton } = await import('../components/alliance-promises');
|
||||
aliases.deregister();
|
||||
|
||||
const table = (game:GameView) => renderToStaticMarkup(createElement(GameTable,
|
||||
{game,busy:false,send:async () => {},onExit() {}}));
|
||||
const truth = (game:GameView) => renderToStaticMarkup(createElement(Truthtrance,
|
||||
{game,busy:false,act() {}}));
|
||||
|
||||
/** The native Basic game purchases Semuta through Richese's real cache auction.
|
||||
* Only a physical Truthtrance is reassigned and an unopened Worm and replacement
|
||||
* are selected; the offer, question, Semuta pass and acceptance are real actions. */
|
||||
function source() {
|
||||
const f = createKullRicheseSemutaFixture();
|
||||
let game = structuredClone(f.game);
|
||||
const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []),
|
||||
...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)];
|
||||
const zone = zones.find(cards => cards.some(card => card.effect === 'truthtrance')); assert.ok(zone);
|
||||
const [card] = zone.splice(zone.findIndex(row => row.effect === 'truthtrance'), 1);
|
||||
const asker = player(game,'harkonnen'); assert.ok(asker.hand.length < handLimit(asker)); asker.hand.push(card);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
game = advance(game,state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['worm','land']);
|
||||
game = advance(game,state => state.phase === 1 && state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution);
|
||||
game = act(game,'richese',{type:'alliance',target:'emperor'});
|
||||
game = act(game,'harkonnen',{type:'card',card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
|
||||
game = act(game,seat.id,{type:'truthPass'});
|
||||
}
|
||||
return {game,originalCards:f.originalCards};
|
||||
}
|
||||
|
||||
void test('Basic Kull and Richese render a genuine alliance question and owned acceptance guide',()=>{
|
||||
const f = source(); let game:Game = f.game;
|
||||
const asker = viewGame(game,'harkonnen');
|
||||
assert.ok(asker.truthAllianceTargets.includes('emperor'));
|
||||
assert.match(truth(asker), /<option value="alliance">Bind alliance membership at this Nexus close<\/option>/);
|
||||
assert.match(table(asker), /Bind alliance membership at this Nexus close/);
|
||||
game = act(game,'harkonnen',{type:'truthAsk',question:{kind:'alliance',target:'emperor',turn:game.turn,partner:'richese'}});
|
||||
const respondent = viewGame(game,'emperor');
|
||||
assert.ok(respondent.truthAllianceAnswers?.includes('yes'));
|
||||
assert.ok(respondent.truthAllianceAnswers?.includes('no'));
|
||||
assert.equal(viewGame(game,'harkonnen').truthAllianceAnswers,null);
|
||||
assert.match(truth(respondent),/Answer Yes/); assert.match(truth(respondent),/Answer No/);
|
||||
assert.match(table(respondent),/Answer Yes/); assert.match(table(respondent),/Answer No/);
|
||||
game = act(game,'emperor',{type:'truthAnswer',answer:'yes'});
|
||||
game = advance(game,clean);
|
||||
const own = viewGame(game,'emperor'), sent:Action[] = [];
|
||||
assert.ok(own.alliancePromiseGuide);
|
||||
assert.match(table(own),/Accept richese.*alliance invitation/);
|
||||
const button = AlliancePromiseGuideButton({game:own,busy:false,send(action) {sent.push(action);}});
|
||||
assert.ok(button); assert.equal(button.props.disabled,false); button.props.onClick();
|
||||
assert.deepEqual(sent,[own.alliancePromiseGuide.action]);
|
||||
game = act(game,'emperor',sent[0]);
|
||||
assert.equal(player(game,'emperor').ally,'richese');
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { territory } from '../game/board';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
|
||||
/** Public Basic setup, original cache purchase and a real Worm/Nexus. Only
|
||||
* unopened card positions and one physical Fremen reserve are selected: the
|
||||
* latter is placed at the previous blow so the real Worm creates a ride. */
|
||||
function source() {
|
||||
let game = createGame('ALLIANCERIDE', newPlayer('choam','CHOAM','choam'), false, ['choam','ix']);
|
||||
for (const faction of ['richese','fremen','emperor','guild'] as const)
|
||||
joinGame(game, newPlayer(faction,faction,faction));
|
||||
game = act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
|
||||
for (const seat of game.players) game = act(game,seat.id,{type:'ready'});
|
||||
game = act(game,'choam',{type:'start'});
|
||||
assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview);
|
||||
const originalCards = inventory(game), selected: Game['deck'] = [];
|
||||
for (const kind of ['kull','filler','filler','filler','truth'] as const) {
|
||||
const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' :
|
||||
kind === 'truth' ? card.effect === 'truthtrance' : card.kind === 'worthless' && card.id !== 'ix-kull-wahad');
|
||||
assert.ok(index >= 0); selected.push(game.deck.splice(index,1)[0]);
|
||||
}
|
||||
game.deck.unshift(...selected);
|
||||
game = advance(game,state => state.status === 'playing');
|
||||
const truth = selected.at(-1)!.id;
|
||||
assert.ok(player(game,'guild').hand.some(card => card.id === truth));
|
||||
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
|
||||
territory(card.territory).type === 'sand');
|
||||
assert.ok(land >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(land,1)[0]);
|
||||
game = advance(game,state => player(state,'richese').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
game = advance(game,state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
const previous = game.spiceDiscard[0].findLast(card => 'territory' in card);
|
||||
assert.ok(previous && 'territory' in previous);
|
||||
const sector = territory(previous.territory).sectors.find(value => value !== game.storm);
|
||||
assert.notEqual(sector,undefined);
|
||||
const fremen = player(game,'fremen'); assert.ok(fremen.reserves >= 1);
|
||||
fremen.reserves--;
|
||||
const key = `${previous.territory}:${sector}`;
|
||||
fremen.forces[key] = (fremen.forces[key] ?? 0) + 1;
|
||||
orderIxSpiceCards(game,['worm','land']);
|
||||
game = advance(game,state => state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution);
|
||||
assert.deepEqual(game.wormRides,[previous.territory]);
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
return {game,truth,originalCards,ride:previous.territory};
|
||||
}
|
||||
|
||||
for (const claim of [false,true]) void test(`Semuta ${claim ? 'claims' : 'passes'} alliance Truth while the Fremen ride waits`, () => {
|
||||
const f = source(); let game = act(f.game,'guild',{type:'card',card:f.truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const next = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(next);
|
||||
game = act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
game = act(game,'guild',{type:'truthAsk',question:{kind:'alliance',target:'emperor',turn:game.turn,partner:'choam'}});
|
||||
assert.ok(viewGame(game,'emperor').truthAllianceAnswers?.includes('no'));
|
||||
game = act(game,'emperor',{type:'truthAnswer',answer:'no'});
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.reaction?.stage,'offer');
|
||||
assert.deepEqual(game.wormRides,[f.ride]);
|
||||
const event = pending.batch.event;
|
||||
if (claim) {
|
||||
game = act(game,'richese',{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game,'richese',{type:'semutaSelect',event,card:f.truth});
|
||||
} else for (let count=0;game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length;count++) {
|
||||
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const next = game.players.find(seat => !reaction.passed.includes(seat.id)); assert.ok(next);
|
||||
game = act(game,next.id,{type:'semutaPass',event});
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
assert.deepEqual(game.wormRides,[f.ride]);
|
||||
assert.ok(game.alliancePromises?.some(row => row.player === 'emperor' && !row.released));
|
||||
for (const seat of game.players) if (!game.ready.includes(seat.id)) game = act(game,seat.id,{type:'ready'});
|
||||
assert.equal(game.decision?.kind,'wormRide');
|
||||
assert.equal(game.decision?.player,'fremen');
|
||||
game = act(game,'fremen',{type:'decision',accept:false});
|
||||
assert.equal(game.nexus,false);
|
||||
assert.ok(game.alliancePromises?.some(row => row.player === 'emperor' && row.fulfilled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
@@ -36,8 +36,8 @@ void test('Basic Kull and Semuta expose the bribe family only in the supported n
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic Semuta retains separate alliance and bid boundaries', () => {
|
||||
assert.equal(alliancePromiseModeSupported(profile),false);
|
||||
void test('Basic Semuta retains separate bid boundaries', () => {
|
||||
assert.equal(alliancePromiseModeSupported(profile),true);
|
||||
assert.equal(revivalPromiseModeSupported(profile),true);
|
||||
assert.equal(bidPromiseModeSupported(profile),false);
|
||||
assert.equal(currentBidPromiseModeSupported(profile),true);
|
||||
|
||||
@@ -35,8 +35,8 @@ void test('Basic Richese admits each Revival family with and without printed Kul
|
||||
'moritaniAssassinatePreview'] as const)
|
||||
assert.equal(quote({...profile,[key]:true}),false,key);
|
||||
}
|
||||
assert.equal(alliancePromiseModeSupported(profile),false);
|
||||
assert.equal(alliancePromiseModeSupported({...profile,kullPreview:false}),false);
|
||||
assert.equal(alliancePromiseModeSupported(profile),true);
|
||||
assert.equal(alliancePromiseModeSupported({...profile,kullPreview:false}),true);
|
||||
});
|
||||
|
||||
function assertRevivalTargets(game: ReturnType<typeof createGame>) {
|
||||
|
||||
@@ -33,7 +33,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
|
||||
assert.match(html,/Printed Kull is enabled by default/);
|
||||
assert.match(html,/Combined turn-action Truthtrance questions are unavailable/);
|
||||
assert.match(html,/Unfunded non-CHOAM Karama-dependent bids remain deferred/);
|
||||
assert.match(html,/When Richese is seated, alliance commitments are also unavailable/);
|
||||
assert.match(html,/Some Kull reactions may be unavailable in complex response windows/);
|
||||
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
const unselected = viewGame(off,off.host);
|
||||
assert.equal(unselected.lobbyModules?.kullPreview,false);
|
||||
|
||||
Reference in new issue
Block a user