Offer Semuta before native paid-income Karama cancellations
This commit is contained in:
1 parent
adb054023d
commit
d718e380b7
10 files changed
+799
-15
No files matched your search
+62
-12
@@ -1344,7 +1344,7 @@ export type Game = {
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard';
|
||||
kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
use: Extract<KaramaUse, { kind: 'cancel' }>;
|
||||
@@ -5172,14 +5172,32 @@ function semutaEmperorShareKaramaResponse(g: Game, response: ResponseWindow): bo
|
||||
response.amount! > 0 && (response.elite === undefined || response.elite === 0 || response.elite === 1) &&
|
||||
!g.battle && !g.pendingShipment && !g.pendingGuildTransport;
|
||||
}
|
||||
/** Original paid income sources; cancellation never reverses the completed transaction. */
|
||||
function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean {
|
||||
if (g.battle || g.pendingShipment || g.pendingGuildTransport ||
|
||||
response.source === 'ambassador') return false;
|
||||
const owner = g.players.find(p => p.id === response.owner);
|
||||
if (response.kind === 'guildIncome')
|
||||
return g.phase === 5 && owner?.faction === 'guild' &&
|
||||
Number.isSafeInteger(response.amount) && response.amount! > 0;
|
||||
if (owner?.faction !== 'emperor') return false;
|
||||
if (response.kind === 'emperorIncome')
|
||||
return g.phase === 3 && !!g.currentAuctionSale &&
|
||||
auctionEmperorIncomeAmount(g.currentAuctionSale) > 0;
|
||||
const purchase = g.pendingRichesePurchaseIncome;
|
||||
return response.kind === 'richesePurchaseIncome' && g.advanced &&
|
||||
response.amount === 3 && !!purchase && purchase.owner === owner.id &&
|
||||
purchase.turn === g.turn && purchase.phase === g.phase;
|
||||
}
|
||||
type KaramaResponseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' }
|
||||
{ kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' }
|
||||
>;
|
||||
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
|
||||
const battle = c.kind === 'karamaBattleDiscard';
|
||||
const worthless = c.kind === 'karamaChoamWorthlessDiscard';
|
||||
const emperorShare = c.kind === 'karamaEmperorShareDiscard';
|
||||
const income = c.kind === 'karamaIncomeDiscard';
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card, use: c.use,
|
||||
parent: nullentropyParentSignature(g, c.resume,
|
||||
@@ -5206,11 +5224,17 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
emperorShare: emperorShare ? {
|
||||
response: g.response, emperorExtra: g.emperorExtra,
|
||||
} : undefined,
|
||||
income: income ? {
|
||||
auction: g.auction, sale: g.currentAuctionSale,
|
||||
richeseBidding: g.richeseBidding, richeseAuction: g.richeseAuction,
|
||||
active: g.active, ready: g.ready, movementRemaining: g.movementRemaining,
|
||||
} : undefined,
|
||||
order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally,
|
||||
hand: p.hand.map(card => card.id), spice: p.spice,
|
||||
physical: battle || worthless ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
shipped: income ? p.shipped : undefined,
|
||||
physical: battle || worthless || income ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, leaders: battle ? p.leaders : undefined } : undefined,
|
||||
})),
|
||||
});
|
||||
@@ -5975,7 +5999,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
continuation?.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation?.kind === 'karamaBattleDiscard' ||
|
||||
continuation?.kind === 'karamaChoamWorthlessDiscard' ||
|
||||
continuation?.kind === 'karamaEmperorShareDiscard') {
|
||||
continuation?.kind === 'karamaEmperorShareDiscard' ||
|
||||
continuation?.kind === 'karamaIncomeDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
!g.response &&
|
||||
@@ -5995,7 +6020,9 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(c.kind === 'karamaChoamWorthlessDiscard' &&
|
||||
semutaChoamKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaEmperorShareDiscard' &&
|
||||
semutaEmperorShareKaramaResponse(g, c.use.response))) &&
|
||||
semutaEmperorShareKaramaResponse(g, c.use.response)) ||
|
||||
(c.kind === 'karamaIncomeDiscard' &&
|
||||
semutaIncomeKaramaResponse(g, c.use.response))) &&
|
||||
c.use.response.owner !== c.player &&
|
||||
c.resume.response?.kind === c.use.response.kind &&
|
||||
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
|
||||
@@ -6015,6 +6042,12 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
ixSubstitutionCancellationQuote(g, c.use.response);
|
||||
else validateAftermathCancellation(g, c.use.response);
|
||||
}
|
||||
if (c.kind === 'karamaIncomeDiscard') {
|
||||
if (c.use.response.kind === 'emperorIncome')
|
||||
validateAuctionContinuationCancellation(g, c.use.response);
|
||||
else if (c.use.response.kind === 'richesePurchaseIncome')
|
||||
currentRicheseCancellationQuote(g, c.use.response);
|
||||
}
|
||||
if (c.kind === 'karamaChoamWorthlessDiscard') {
|
||||
try { quoteChoamWorthlessCancellation(g, c.use.response); }
|
||||
catch (error) {
|
||||
@@ -6821,6 +6854,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaBattleDiscard' &&
|
||||
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
|
||||
continuation.kind !== 'karamaEmperorShareDiscard' &&
|
||||
continuation.kind !== 'karamaIncomeDiscard' &&
|
||||
continuation.kind !== 'karamaPurchaseDiscard' &&
|
||||
continuation.kind !== 'karamaPaymentDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
@@ -6902,14 +6936,17 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaBgCharityDiscard' ||
|
||||
continuation.kind === 'karamaBattleDiscard' ||
|
||||
continuation.kind === 'karamaChoamWorthlessDiscard' ||
|
||||
continuation.kind === 'karamaEmperorShareDiscard'
|
||||
continuation.kind === 'karamaEmperorShareDiscard' ||
|
||||
continuation.kind === 'karamaIncomeDiscard'
|
||||
? (continuation.kind === 'karamaBattleDiscard'
|
||||
? semutaBattleKaramaResponse(g, continuation.use.response)
|
||||
: continuation.kind === 'karamaChoamWorthlessDiscard'
|
||||
? semutaChoamKaramaResponse(g, continuation.use.response)
|
||||
: continuation.kind === 'karamaEmperorShareDiscard'
|
||||
? semutaEmperorShareKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
: continuation.kind === 'karamaIncomeDiscard'
|
||||
? semutaIncomeKaramaResponse(g, continuation.use.response)
|
||||
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
|
||||
!continuation.resume.decision && !continuation.resume.pendingKarama &&
|
||||
!continuation.resume.phaseOpening && !g.pendingKarama &&
|
||||
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
|
||||
@@ -6933,6 +6970,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'karamaBattleDiscard' ||
|
||||
continuation.kind === 'karamaChoamWorthlessDiscard' ||
|
||||
continuation.kind === 'karamaEmperorShareDiscard' ||
|
||||
continuation.kind === 'karamaIncomeDiscard' ||
|
||||
(!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
@@ -6948,9 +6986,15 @@ function semutaOfferSupported(
|
||||
: continuation.kind === 'karamaPurchaseDiscard' ||
|
||||
continuation.kind === 'karamaPaymentDiscard'
|
||||
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
|
||||
: !g.auction) &&
|
||||
: continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'emperorIncome'
|
||||
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
|
||||
: !g.auction) &&
|
||||
(continuation.kind === 'saphoAuctionDiscard' ||
|
||||
continuation.kind === 'ixAllyCard' || !g.richeseAuction) &&
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
(continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) &&
|
||||
(continuation.kind === 'saphoAggressorDiscard' ||
|
||||
continuation.kind === 'residualPoisonDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' ||
|
||||
@@ -6975,7 +7019,8 @@ function semutaOfferSupported(
|
||||
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
||||
!g.pendingHomeworldShipment && !g.pendingRicheseGift &&
|
||||
!g.pendingRichesePurchaseIncome &&
|
||||
((continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) &&
|
||||
(continuation.kind === 'choamSaleDiscard'
|
||||
? !!g.choamMarket && !g.choamMarket.sale &&
|
||||
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
|
||||
@@ -7199,6 +7244,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
next.kind === 'karamaBattleDiscard' ||
|
||||
next.kind === 'karamaChoamWorthlessDiscard' ||
|
||||
next.kind === 'karamaEmperorShareDiscard' ||
|
||||
next.kind === 'karamaIncomeDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard' ||
|
||||
next.kind === 'karamaPaymentDiscard') {
|
||||
g.response = next.resume.response;
|
||||
@@ -13413,7 +13459,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
use.response.kind === 'choamInflation' ||
|
||||
use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) ||
|
||||
semutaChoamKaramaResponse(g, use.response) ||
|
||||
semutaEmperorShareKaramaResponse(g, use.response))) {
|
||||
semutaEmperorShareKaramaResponse(g, use.response) ||
|
||||
semutaIncomeKaramaResponse(g, use.response))) {
|
||||
const continuation: KaramaResponseDiscardContinuation = {
|
||||
kind: use.response.kind === 'choamCharity'
|
||||
? 'karamaCharityDiscard'
|
||||
@@ -13422,7 +13469,10 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
: use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard'
|
||||
: use.response.kind === 'choamWorthless' ? 'karamaChoamWorthlessDiscard'
|
||||
: use.response.kind === 'emperorGift' || use.response.kind === 'emperorRevival'
|
||||
? 'karamaEmperorShareDiscard' : 'karamaBattleDiscard',
|
||||
? 'karamaEmperorShareDiscard'
|
||||
: use.response.kind === 'guildIncome' || use.response.kind === 'emperorIncome' ||
|
||||
use.response.kind === 'richesePurchaseIncome'
|
||||
? 'karamaIncomeDiscard' : 'karamaBattleDiscard',
|
||||
player: p.id, card: used.id, use,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
|
||||
@@ -1185,6 +1185,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents remain separate.',
|
||||
'A printed Karama canceling the Emperor’s shared gift or extra allied revival also offers that exact public cost before the denial completes. The canceled share moves no spice, forces or revival; the Emperor’s ally and extra-revival ledger are unchanged. Claim or Continue restores the saved response and finishes the cancellation once. Only the actual Semuta holder sees the private claimant choice; other seats see the neutral offer.',
|
||||
'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.',
|
||||
'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.',
|
||||
'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.',
|
||||
'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.',
|
||||
@@ -1253,6 +1254,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-nexus-cunning-reactive.test.ts',
|
||||
'tests/semuta-choam-karama.test.ts',
|
||||
'tests/semuta-emperor-share.test.ts',
|
||||
'tests/semuta-guild-income.test.ts',
|
||||
'tests/semuta-emperor-income.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user