Offer Semuta on Richese cancellation costs

This commit is contained in:
Quintine committed 2026-10-25 23:56:09 +11:00
1 parent 8e6602633d
commit 0d868a3838
12 files changed
+957 -22

No files matched your search

+63 -20
View File
@@ -1575,7 +1575,7 @@ export type Game = {
stateSignature: string;
}
| {
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard';
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard';
player: string;
card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>;
@@ -5812,9 +5812,23 @@ function bgKaramaCostDiscardSignature(g: Game, c: BgKaramaCostDiscardContinuatio
})),
});
}
/** The original native validators own source legality. This classifier only
* identifies the two Richese cancellation costs for the shared discard lane. */
function semutaRicheseKaramaResponse(g: Game, response: ResponseWindow): boolean {
if (response.kind !== 'richeseAuction' && response.kind !== 'richeseGift') return false;
try {
if (response.kind === 'richeseGift')
return currentRicheseCancellationQuote(g,response)?.kind === 'richeseGift';
validateRicheseCacheCancellation(g,response);
return true;
} catch(error) {
if (error instanceof RuleError) return false;
throw error;
}
}
type KaramaResponseDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' }
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' }
>;
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
const battle = c.kind === 'karamaBattleDiscard';
@@ -5823,6 +5837,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
const income = c.kind === 'karamaIncomeDiscard';
const shipment = c.kind === 'karamaShipmentDiscard';
const conversion = c.kind === 'karamaBgConversionCancelDiscard';
const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard';
return JSON.stringify({
player: c.player, card: c.card, use: c.use,
parent: nullentropyParentSignature(g, c.resume,
@@ -5871,6 +5886,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
homeworlds: g.homeworlds, rate: g.guildRateBlocked, permission: g.guildTransportBlocked,
auction: g.auction, richeseAuction: g.richeseAuction,
} : undefined,
richese: richese ? {
round:g.richeseBidding, cache:g.richeseCache, deck:g.deck,
auction:g.auction, lot:g.richeseAuction, ix:g.ixAuction,
sale:g.currentAuctionSale, gift:g.pendingRicheseGift, blocked:g.richeseGiftBlocked,
purchaseIncome:g.pendingRichesePurchaseIncome, aid:g.aid,
active:g.active, ready:g.ready, remaining:g.movementRemaining,
shipping:g.karamaShipping, battle:g.battle,
} : undefined,
order: g.order,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally,
@@ -6450,11 +6473,11 @@ function treacheryDiscardIntegrity(g: Game) {
!g.decision &&
!g.truthtrance &&
!g.phaseOpening &&
(!g.pendingRicheseGift || box || truth) &&
(!g.pendingRicheseGift || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard') &&
!g.pendingKarama &&
!g.pendingNullentropy &&
(!g.pendingAmbassador || box || truth || ambassadorTerror) &&
(!g.pendingIxAlly || box || truth),
(!g.pendingNullentropy || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingAmbassador || box || truth || ambassadorTerror || continuation?.kind === 'karamaRicheseGiftDiscard') &&
(!g.pendingIxAlly || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard'),
'The saved discard does not match its current continuation.',
);
const ids = new Set<string>();
@@ -6787,6 +6810,8 @@ function treacheryDiscardIntegrity(g: Game) {
continuation?.kind === 'karamaChoamWorthlessDiscard' ||
continuation?.kind === 'karamaEmperorShareDiscard' ||
continuation?.kind === 'karamaIncomeDiscard' ||
continuation?.kind === 'karamaRicheseAuctionDiscard' ||
continuation?.kind === 'karamaRicheseGiftDiscard' ||
continuation?.kind === 'karamaShipmentDiscard' ||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
const c = continuation, entry = batch.entries[0];
@@ -6811,6 +6836,10 @@ function treacheryDiscardIntegrity(g: Game) {
semutaEmperorShareKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaIncomeDiscard' &&
semutaIncomeKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaRicheseAuctionDiscard' && c.use.response.kind === 'richeseAuction' &&
semutaRicheseKaramaResponse(g,c.use.response)) ||
(c.kind === 'karamaRicheseGiftDiscard' && c.use.response.kind === 'richeseGift' &&
semutaRicheseKaramaResponse(g,c.use.response)) ||
(c.kind === 'karamaShipmentDiscard' &&
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
(c.kind === 'karamaBgConversionCancelDiscard' &&
@@ -6841,6 +6870,8 @@ function treacheryDiscardIntegrity(g: Game) {
else if (c.use.response.kind === 'richesePurchaseIncome')
currentRicheseCancellationQuote(g, c.use.response);
}
if (c.kind === 'karamaRicheseAuctionDiscard') validateRicheseCacheCancellation(g,c.use.response);
if (c.kind === 'karamaRicheseGiftDiscard') currentRicheseCancellationQuote(g,c.use.response);
if (c.kind === 'karamaChoamWorthlessDiscard') {
try { quoteChoamWorthlessCancellation(g, c.use.response); }
catch (error) {
@@ -7708,6 +7739,8 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
continuation.kind === 'karamaBgConversionCancelDiscard';
const ambassadorDiscard = continuation.kind === 'ambassador';
@@ -7746,6 +7779,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
!richeseDiscard &&
continuation.kind !== 'karamaShipmentDiscard' &&
continuation.kind !== 'karamaShipmentPriceDiscard' &&
continuation.kind !== 'specialKaramaDiscard' &&
@@ -7782,7 +7816,7 @@ function semutaOfferSupported(
continuation.promise.kind === 'shipment' ||
continuation.promise.kind === 'movement' ||
continuation.promise.kind === 'battle'))) return false;
if (g.homeworlds && !bgDiscard && continuation.kind !== 'kaitainDiscard' &&
if (g.homeworlds && !bgDiscard && !richeseDiscard && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
continuation.kind !== 'karamaShipmentDiscard' &&
@@ -7884,6 +7918,7 @@ function semutaOfferSupported(
continuation.kind === 'karamaChoamWorthlessDiscard' ||
continuation.kind === 'karamaEmperorShareDiscard' ||
continuation.kind === 'karamaIncomeDiscard' ||
richeseDiscard ||
continuation.kind === 'karamaShipmentDiscard'
? (continuation.kind === 'karamaBattleDiscard'
? semutaBattleKaramaResponse(g, continuation.use.response)
@@ -7893,6 +7928,8 @@ function semutaOfferSupported(
? semutaEmperorShareKaramaResponse(g, continuation.use.response)
: continuation.kind === 'karamaIncomeDiscard'
? semutaIncomeKaramaResponse(g, continuation.use.response)
: richeseDiscard
? semutaRicheseKaramaResponse(g,continuation.use.response)
: continuation.kind === 'karamaShipmentDiscard'
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
@@ -7926,6 +7963,7 @@ function semutaOfferSupported(
continuation.kind === 'karamaChoamWorthlessDiscard' ||
continuation.kind === 'karamaEmperorShareDiscard' ||
continuation.kind === 'karamaIncomeDiscard' ||
richeseDiscard ||
continuation.kind === 'karamaShipmentDiscard' ||
((continuation.kind === 'ordinaryCardDiscard'
? ordinaryDiscardIncomeMatches(g, continuation)
@@ -7936,9 +7974,9 @@ function semutaOfferSupported(
(continuation.kind === 'amalDiscard'
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) &&
(continuation.kind === 'karamaShipmentDiscard' ||
(richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) &&
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
(richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin &&
(continuation.sale.origin === 'normal'
? !!g.auction && !g.richeseAuction
@@ -7952,11 +7990,11 @@ function semutaOfferSupported(
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
(richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' ||
(richeseGiftDiscard || continuation.kind === 'saphoAggressorDiscard' ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
continuation.kind === 'residualPoisonDiscard' ||
@@ -7979,22 +8017,22 @@ function semutaOfferSupported(
(!!g.stormResolution && !g.pendingChoamMove &&
!g.pendingArrivalOverlap && !g.pendingRevival &&
!g.ornithopter && !g.ecologicalStorm)) &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry &&
(continuation.kind === 'karamaShipmentDiscard' ||
(richeseGiftDiscard || (!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry)) &&
(richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
continuation.kind === 'specialKaramaDiscard' ||
bgDiscard ||
(!g.pendingShipment && !g.pendingHomeworldShipment)) &&
!g.pendingRicheseGift &&
((continuation.kind === 'karamaIncomeDiscard' &&
(richeseGiftDiscard || !g.pendingRicheseGift) &&
(richeseGiftDiscard || (continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) &&
(continuation.kind === 'choamSaleDiscard'
(richeseGiftDiscard || (continuation.kind === 'choamSaleDiscard'
? !!g.choamMarket && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
: continuation.kind === 'kaitainDiscard'
? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null
: !g.choamMarket) &&
!g.pendingChoamMarketGhola;
: !g.choamMarket)) &&
(richeseGiftDiscard || !g.pendingChoamMarketGhola);
}
function semutaContext(g: Game, owner: Player): SemutaContext {
const pending = g.pendingTreacheryDiscard!;
@@ -8252,6 +8290,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
next.kind === 'karamaChoamWorthlessDiscard' ||
next.kind === 'karamaEmperorShareDiscard' ||
next.kind === 'karamaIncomeDiscard' ||
next.kind === 'karamaRicheseAuctionDiscard' ||
next.kind === 'karamaRicheseGiftDiscard' ||
next.kind === 'karamaShipmentDiscard' ||
next.kind === 'karamaBgConversionCancelDiscard' ||
next.kind === 'karamaPurchaseDiscard' ||
@@ -15604,10 +15644,13 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) ||
semutaChoamKaramaResponse(g, use.response) ||
semutaEmperorShareKaramaResponse(g, use.response) ||
semutaIncomeKaramaResponse(g, use.response) || guildShipmentDiscard || bgConversionCancelDiscard)) {
semutaIncomeKaramaResponse(g, use.response) || semutaRicheseKaramaResponse(g,use.response) ||
guildShipmentDiscard || bgConversionCancelDiscard)) {
const continuation: KaramaResponseDiscardContinuation = {
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
: guildShipmentDiscard ? 'karamaShipmentDiscard'
: use.response.kind === 'richeseAuction' ? 'karamaRicheseAuctionDiscard'
: use.response.kind === 'richeseGift' ? 'karamaRicheseGiftDiscard'
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
: use.response.kind === 'choamInflation'
? 'karamaInflationDiscard'
+5
View File
@@ -1287,6 +1287,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.',
'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.',
'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.',
'A printed-role Karama stopping Richese’s compulsory cache auction or allied card gift also offers its exact used cost before prevention completes. Claim or Continue resumes that original prevention once. The cache keeps its cards and frozen ordinary count, including any completed Black Market reduction; a prevented gift stays with Richese and its original parent resumes. Recovering Karama does not repeat the attempt, transfer the gift or replay a paid purchase. Existing gift retry protection remains.',
'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.',
'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.',
'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.',
@@ -1412,6 +1413,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-homeworld-battle-sapho.test.ts',
'tests/semuta-homeworld-economic-karama.test.ts',
'tests/semuta-homeworld-karama-auction.test.ts',
'tests/fixture-semuta-richese-cache-karama.ts',
'tests/semuta-richese-cache-karama-engine.test.ts',
'tests/fixture-semuta-richese-gift-karama.ts',
'tests/semuta-richese-gift-karama-engine.test.ts',
'tests/semuta-homeworld-bg-charity.test.ts',
'tests/semuta-homeworld-emperor-share.test.ts',
'tests/semuta-homeworld-capture-karama.test.ts',