Offer Semuta on auction advantage cancellation costs
This commit is contained in:
1 parent
0d868a3838
commit
70c95f2686
10 files changed
+1063
-9
No files matched your search
+67
-8
@@ -1575,7 +1575,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5826,9 +5826,21 @@ function semutaRicheseKaramaResponse(g: Game, response: ResponseWindow): boolean
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Preflight only: the native cancellation remains the sole owner of any
|
||||
* draw, peek or exchange. Its just-spent card is already in physical custody. */
|
||||
function semutaAuctionAdvantageResponse(g: Game, response: ResponseWindow, player: string): boolean {
|
||||
if (g.phase !== 3 || !['atreidesAuction','ixAuction','ixTechnology'].includes(response.kind)) return false;
|
||||
try {
|
||||
validateKaramaUse({...g,pendingTreacheryDiscard:null,response},getPlayer(g,player),{kind:'cancel',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' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
@@ -5838,6 +5850,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
const shipment = c.kind === 'karamaShipmentDiscard';
|
||||
const conversion = c.kind === 'karamaBgConversionCancelDiscard';
|
||||
const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard';
|
||||
const auctionAdvantage = c.kind === 'karamaAuctionAdvantageDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -5894,6 +5907,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
active:g.active, ready:g.ready, remaining:g.movementRemaining,
|
||||
shipping:g.karamaShipping, battle:g.battle,
|
||||
} : undefined,
|
||||
auctionAdvantage: auctionAdvantage ? {
|
||||
auction:g.auction, ix:g.ixAuction, technology:g.pendingIxTechnology,
|
||||
technologyTurn:g.ixTechnologyTurn, ixKnown:g.ixAuctionKnown,
|
||||
round:g.richeseBidding, lot:g.richeseAuction, richeseKnown:g.richesePeekKnown,
|
||||
sale:g.currentAuctionSale, deck:g.deck, aid:g.aid,
|
||||
active:g.active, ready:g.ready, homeworlds:g.homeworlds,
|
||||
inspection:g.homeworldAuctionInspection, occupation:g.homeworldOccupationHistory,
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally,
|
||||
@@ -6812,6 +6833,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaIncomeDiscard' ||
|
||||
continuation?.kind === 'karamaRicheseAuctionDiscard' ||
|
||||
continuation?.kind === 'karamaRicheseGiftDiscard' ||
|
||||
continuation?.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
continuation?.kind === 'karamaShipmentDiscard' ||
|
||||
continuation?.kind === 'karamaBgConversionCancelDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
@@ -6840,6 +6862,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
semutaRicheseKaramaResponse(g,c.use.response)) ||
|
||||
(c.kind === 'karamaRicheseGiftDiscard' && c.use.response.kind === 'richeseGift' &&
|
||||
semutaRicheseKaramaResponse(g,c.use.response)) ||
|
||||
(c.kind === 'karamaAuctionAdvantageDiscard' &&
|
||||
semutaAuctionAdvantageResponse(g,c.use.response,c.player)) ||
|
||||
(c.kind === 'karamaShipmentDiscard' &&
|
||||
semutaGuildShipmentKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaBgConversionCancelDiscard' &&
|
||||
@@ -7741,6 +7765,7 @@ function semutaOfferSupported(
|
||||
) {
|
||||
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
|
||||
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
|
||||
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
|
||||
const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' ||
|
||||
continuation.kind === 'karamaBgConversionCancelDiscard';
|
||||
const ambassadorDiscard = continuation.kind === 'ambassador';
|
||||
@@ -7779,7 +7804,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
!richeseDiscard &&
|
||||
!richeseDiscard && !auctionAdvantageDiscard &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentPriceDiscard' &&
|
||||
continuation.kind !== 'specialKaramaDiscard' &&
|
||||
@@ -7816,7 +7841,7 @@ function semutaOfferSupported(
|
||||
continuation.promise.kind === 'shipment' ||
|
||||
continuation.promise.kind === 'movement' ||
|
||||
continuation.promise.kind === 'battle'))) return false;
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && continuation.kind !== 'kaitainDiscard' &&
|
||||
continuation.kind !== 'tupileCleanupDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaShipmentDiscard' &&
|
||||
@@ -7919,6 +7944,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaEmperorShareDiscard' ||
|
||||
continuation.kind === 'karamaIncomeDiscard' ||
|
||||
richeseDiscard ||
|
||||
auctionAdvantageDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
@@ -7930,6 +7956,8 @@ function semutaOfferSupported(
|
||||
? semutaIncomeKaramaResponse(g, continuation.use.response)
|
||||
: richeseDiscard
|
||||
? semutaRicheseKaramaResponse(g,continuation.use.response)
|
||||
: auctionAdvantageDiscard
|
||||
? semutaAuctionAdvantageResponse(g,continuation.use.response,continuation.player)
|
||||
: continuation.kind === 'karamaShipmentDiscard'
|
||||
? semutaGuildShipmentKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
@@ -7964,6 +7992,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaEmperorShareDiscard' ||
|
||||
continuation.kind === 'karamaIncomeDiscard' ||
|
||||
richeseDiscard ||
|
||||
auctionAdvantageDiscard ||
|
||||
continuation.kind === 'karamaShipmentDiscard' ||
|
||||
((continuation.kind === 'ordinaryCardDiscard'
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
@@ -7976,7 +8005,7 @@ function semutaOfferSupported(
|
||||
: !continuation.resume.phaseOpening)))) &&
|
||||
(richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' ||
|
||||
continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) &&
|
||||
(richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
|
||||
(auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard'
|
||||
? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin &&
|
||||
(continuation.sale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction
|
||||
@@ -7990,7 +8019,7 @@ function semutaOfferSupported(
|
||||
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
|
||||
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
|
||||
(richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
|
||||
(auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
(continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
|
||||
@@ -8047,7 +8076,7 @@ function semutaCandidates(g: Game, owner: Player) {
|
||||
const context = semutaContext(g, owner);
|
||||
const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID &&
|
||||
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
||||
requireRule(semuta && !transferCardBlock(g, owner, semuta),
|
||||
requireRule(semuta && !semutaSpendingBlock(g, owner, semuta),
|
||||
'This Semuta Drug is reserved for an unfinished transaction.');
|
||||
try {
|
||||
return committedSemutaCandidates(context,
|
||||
@@ -8057,6 +8086,21 @@ function semutaCandidates(g: Game, owner: Player) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A committed prevention releases only its selected Technology/gift
|
||||
* reservation for Semuta's cost response. Other commitments remain live. */
|
||||
function semutaSpendingBlock(g: Game, owner: Player, card: Card): string | null {
|
||||
const c=g.pendingTreacheryDiscard?.continuation;
|
||||
const gift=g.pendingRicheseGift;
|
||||
const preventedTechnology=c?.kind==='karamaAuctionAdvantageDiscard' &&
|
||||
c.use.response.kind==='ixTechnology' && c.use.response.owner===owner.id &&
|
||||
g.pendingIxTechnology?.card===card.id && card.id===SEMUTA_DRUG_ID;
|
||||
const preventedGift=c?.kind==='karamaRicheseGiftDiscard' &&
|
||||
c.use.response.kind==='richeseGift' && gift?.intent.owner===owner.id &&
|
||||
gift.intent.cardId===card.id && card.id===SEMUTA_DRUG_ID;
|
||||
return transferCardBlock(preventedTechnology ? {...g,pendingIxTechnology:null}
|
||||
: preventedGift ? {...g,...gift!.resume,pendingRicheseGift:null} : g,owner,card);
|
||||
}
|
||||
|
||||
function projectedSemutaReaction(g: Game, me: Player) {
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
const reaction = pending?.reaction;
|
||||
@@ -8072,7 +8116,7 @@ function projectedSemutaReaction(g: Game, me: Player) {
|
||||
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
||||
const held = !!semuta;
|
||||
const blocked = !held || passed ? null
|
||||
: transferCardBlock(g, me, semuta!) ??
|
||||
: semutaSpendingBlock(g, me, semuta!) ??
|
||||
(me.hand.length > handLimit(me)
|
||||
? 'Resolve your excess hand cards before using Semuta Drug.'
|
||||
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
|
||||
@@ -8130,6 +8174,17 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
// Retire before the Semuta transfer or any suffix can draw the fresh card.
|
||||
g.resolvedTreacheryDiscardSequence = pending.sequence;
|
||||
g.pendingTreacheryDiscard = null;
|
||||
// A stopped gift retains Semuta before Semuta spends as a different effect.
|
||||
// Native prevention only records/restores here; it cannot advance its parent.
|
||||
// Other continuations retain their original post-transfer ordering.
|
||||
const preventedGiftBeforeClaim=!!result && !!owner && next.kind==='karamaRicheseGiftDiscard' &&
|
||||
g.pendingRicheseGift?.intent.owner===owner.id &&
|
||||
g.pendingRicheseGift.intent.cardId===SEMUTA_DRUG_ID;
|
||||
if (preventedGiftBeforeClaim && next.kind==='karamaRicheseGiftDiscard') {
|
||||
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,pending.batch.entries[0].card);
|
||||
}
|
||||
if (result && owner) {
|
||||
owner.hand = result.ownerHand;
|
||||
g.discard = result.discard;
|
||||
@@ -8140,6 +8195,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`,
|
||||
{ faction: owner.faction, name: 'Semuta Drug' });
|
||||
}
|
||||
if (preventedGiftBeforeClaim) return;
|
||||
if (next.kind === 'winnerMandatoryDiscard') {
|
||||
finishWinner(g, getPlayer(g, next.player), next.territory, next.optional);
|
||||
return;
|
||||
@@ -8292,6 +8348,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaIncomeDiscard' ||
|
||||
next.kind === 'karamaRicheseAuctionDiscard' ||
|
||||
next.kind === 'karamaRicheseGiftDiscard' ||
|
||||
next.kind === 'karamaAuctionAdvantageDiscard' ||
|
||||
next.kind === 'karamaShipmentDiscard' ||
|
||||
next.kind === 'karamaBgConversionCancelDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
@@ -15645,12 +15702,14 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
semutaChoamKaramaResponse(g, use.response) ||
|
||||
semutaEmperorShareKaramaResponse(g, use.response) ||
|
||||
semutaIncomeKaramaResponse(g, use.response) || semutaRicheseKaramaResponse(g,use.response) ||
|
||||
semutaAuctionAdvantageResponse(g,use.response,p.id) ||
|
||||
guildShipmentDiscard || bgConversionCancelDiscard)) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard'
|
||||
: guildShipmentDiscard ? 'karamaShipmentDiscard'
|
||||
: use.response.kind === 'richeseAuction' ? 'karamaRicheseAuctionDiscard'
|
||||
: use.response.kind === 'richeseGift' ? 'karamaRicheseGiftDiscard'
|
||||
: ['atreidesAuction','ixAuction','ixTechnology'].includes(use.response.kind) ? 'karamaAuctionAdvantageDiscard'
|
||||
: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard'
|
||||
: use.response.kind === 'choamInflation'
|
||||
? 'karamaInflationDiscard'
|
||||
|
||||
@@ -1288,6 +1288,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'When Semuta itself was proposed as the now-prevented allied gift, its donor may spend it to claim that counter’s used cost. Prevention first retains Semuta and restores the original parent; the same committed response then spends Semuta and takes the cost, without a second prevention or transfer to the ally. Passing keeps Semuta. Other commitments remain enforced.',
|
||||
'Printed-role Karama stopping Atreides auction inspection, Ixian auction preparation or Advanced Technology also offers its exact cost before prevention. Claim or Continue resumes the original lot or ordinary draw once, without new inspection, extra Ix choice or exchange. Technology stays used and its original unseen lot remains; the native Atreides peek follows. If outgoing Semuta was selected for that now-prevented exchange, only that reservation releases so Semuta can claim the counter’s cost. Other commitments and independent knowledge remain.',
|
||||
'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.',
|
||||
@@ -1417,6 +1419,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-richese-cache-karama-engine.test.ts',
|
||||
'tests/fixture-semuta-richese-gift-karama.ts',
|
||||
'tests/semuta-richese-gift-karama-engine.test.ts',
|
||||
'tests/fixture-semuta-auction-inspection.ts',
|
||||
'tests/semuta-auction-inspection-engine.test.ts',
|
||||
'tests/fixture-semuta-ixian-auction.ts',
|
||||
'tests/semuta-ixian-auction-engine.test.ts',
|
||||
'tests/semuta-prevented-gift-reservation.test.ts',
|
||||
'tests/semuta-homeworld-bg-charity.test.ts',
|
||||
'tests/semuta-homeworld-emperor-share.test.ts',
|
||||
'tests/semuta-homeworld-capture-karama.test.ts',
|
||||
|
||||
Reference in new issue
Block a user