Preserve original Cunning fuel and suffix through Semuta

This commit is contained in:
Quintine committed 2026-10-12 10:50:39 +11:00
1 parent 9afc03b347
commit bb6835e6c3
12 files changed
+1237 -34

No files matched your search

+92 -32
View File
@@ -1371,6 +1371,7 @@ export type Game = {
kind: 'choamKulonDiscard';
player: string;
card: string;
nexusEvent?: string;
before: number;
stateSignature: string;
}
@@ -1378,6 +1379,7 @@ export type Game = {
kind: 'choamLaLaLaDiscard';
player: string;
card: string;
nexusEvent?: string;
target: string;
stoppedRevival?: true;
stateSignature: string;
@@ -1386,6 +1388,7 @@ export type Game = {
kind: 'choamBalisetDiscard';
player: string;
card: string;
nexusEvent?: string;
target: string;
territory: string;
stoppedMovement?: true;
@@ -1395,6 +1398,7 @@ export type Game = {
kind: 'choamGamontDiscard';
player: string;
card: string;
nexusEvent?: string;
target: string;
location: string;
elite?: 1;
@@ -1406,6 +1410,7 @@ export type Game = {
kind: 'choamJubbaDiscard';
player: string;
card: string;
nexusEvent?: string;
territory: string;
stateSignature: string;
}
@@ -5218,6 +5223,7 @@ function choamKulonDiscardSignature(g: Game, c: ChoamKulonDiscardContinuation) {
const owner = getPlayer(g, c.player);
return JSON.stringify({
player: c.player, card: c.card, before: c.before,
nexusEvent: c.nexusEvent,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
movement: g.choamMovement, remaining: g.movementRemaining,
parent: nullentropyParentSignature(g,
@@ -5235,6 +5241,7 @@ function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation)
const owner = getPlayer(g, c.player), target = getPlayer(g, c.target);
return JSON.stringify({
player: c.player, card: c.card, target: c.target,
nexusEvent: c.nexusEvent,
stoppedRevival: c.stoppedRevival,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
revivalRules: g.revivalRules, pendingRevival: g.pendingRevival,
@@ -5253,6 +5260,7 @@ function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuatio
const owner = getPlayer(g, c.player);
return JSON.stringify({
player: c.player, card: c.card, target: c.target, territory: c.territory,
nexusEvent: c.nexusEvent,
stoppedMovement: c.stoppedMovement,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
restrictions: g.choamBaliset, remaining: g.movementRemaining,
@@ -5271,6 +5279,7 @@ function choamGamontDiscardSignature(g: Game, c: ChoamGamontDiscardContinuation)
const owner = getPlayer(g, c.player), target = getPlayer(g, c.target);
return JSON.stringify({
player: c.player, card: c.card, target: c.target, location: c.location,
nexusEvent: c.nexusEvent,
elite: c.elite, noFieldEvent: c.noFieldEvent,
mentat: c.mentat, mentatPending: c.mentat ? g.choamMentatPending : undefined,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
@@ -5291,6 +5300,7 @@ function choamJubbaDiscardSignature(g: Game, c: ChoamJubbaDiscardContinuation) {
const owner = getPlayer(g, c.player);
return JSON.stringify({
player: c.player, card: c.card, territory: c.territory,
nexusEvent: c.nexusEvent,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
storm: g.storm, stormPending: g.stormPending,
resolution: g.stormResolution,
@@ -5301,6 +5311,62 @@ function choamJubbaDiscardSignature(g: Game, c: ChoamJubbaDiscardContinuation) {
ownerForces: owner.forces, ownerElites: owner.elites,
});
}
type ChoamCunningDiscardContinuation =
ChoamKulonDiscardContinuation | ChoamLaLaLaDiscardContinuation |
ChoamBalisetDiscardContinuation | ChoamGamontDiscardContinuation | ChoamJubbaDiscardContinuation;
function choamCunningDiscard(
c: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
): c is ChoamCunningDiscardContinuation & { nexusEvent: string } {
switch (c.kind) {
case 'choamKulonDiscard':
case 'choamLaLaLaDiscard':
case 'choamBalisetDiscard':
case 'choamGamontDiscard':
case 'choamJubbaDiscard':
return typeof c.nexusEvent === 'string' && c.nexusEvent.length > 0;
default: return false;
}
}
/** Cunning changes the physical fuel, not the original quoted effect or its parent. */
function choamDiscardCostMatches(
g: Game, c: ChoamCunningDiscardContinuation,
entry: FreshDiscardBatch['entries'][number], effect: Exclude<NexusChoamEffect, 'kull'>,
) {
if (!entry || entry.card.id !== c.card || entry.discardedBy !== c.player) return false;
if (c.nexusEvent === undefined)
return entry.publicFace && entry.card.kind === 'worthless' &&
entry.card.name === CHOAM_NEXUS_EFFECTS[effect];
if (!choamCunningDiscard(c) || entry.publicFace) return false;
nexusChoamIntegrity(g);
const record = g.nexusChoamHistory?.at(-1);
if (!record || record.stage !== 'complete' || record.receipt.event !== c.nexusEvent ||
record.receipt.owner !== c.player || record.receipt.card !== c.card ||
record.receipt.effect !== effect || record.receipt.turn !== g.turn ||
record.receipt.phase !== g.phase) return false;
let original: Game['pendingChoamWorthless'];
try { original = JSON.parse(record.frame); } catch { return false; }
if (!original || original.owner !== c.player || original.card !== c.card ||
original.effect !== effect || original.nexusEvent !== c.nexusEvent) return false;
switch (c.kind) {
case 'choamKulonDiscard':
return !original.storm && !original.revival && !original.movement && !original.mentat;
case 'choamLaLaLaDiscard':
return original.target === c.target && original.revival === (c.stoppedRevival === true) &&
!original.storm && !original.movement && !original.mentat;
case 'choamBalisetDiscard':
return original.target === c.target && original.location === c.territory &&
original.movement === (c.stoppedMovement === true) &&
!original.storm && !original.revival && !original.mentat;
case 'choamGamontDiscard':
return original.target === c.target && original.location === c.location &&
original.elite === (c.elite ?? 0) && original.noFieldEvent === c.noFieldEvent &&
original.mentat === (c.mentat === true) &&
!original.storm && !original.revival && !original.movement;
case 'choamJubbaDiscard':
return original.location === c.territory && original.storm === true &&
!original.revival && !original.movement && !original.mentat;
}
}
type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' }
@@ -5956,9 +6022,7 @@ function treacheryDiscardIntegrity(g: Game) {
!g.pendingChoamWorthless && !g.response && !g.decision &&
!g.phaseOpening && !g.ornithopter && !g.pendingChoamMove &&
batch.cause === 'choam:kulon' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'Kulon' &&
choamDiscardCostMatches(g, c, entry, 'kulon') &&
Number.isSafeInteger(c.before) && c.before >= 0 &&
c.before === (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) &&
c.stateSignature === choamKulonDiscardSignature(g, c),
@@ -5975,9 +6039,7 @@ function treacheryDiscardIntegrity(g: Game) {
!g.pendingChoamWorthless && !g.pendingRevival &&
!g.response && !g.decision && !g.phaseOpening &&
batch.cause === 'choam:laLaLa' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'La La La' &&
choamDiscardCostMatches(g, c, entry, 'laLaLa') &&
c.stateSignature === choamLaLaLaDiscardSignature(g, c),
'The used La La La no longer matches its pending revival block.',
);
@@ -5992,9 +6054,7 @@ function treacheryDiscardIntegrity(g: Game) {
!g.pendingChoamWorthless && !g.pendingChoamMove &&
!g.response && !g.decision && !g.phaseOpening && !g.ornithopter &&
batch.cause === 'choam:baliset' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'Baliset' &&
choamDiscardCostMatches(g, c, entry, 'baliset') &&
c.stateSignature === choamBalisetDiscardSignature(g, c),
'The used Baliset no longer matches its pending territory restriction.',
);
@@ -6012,9 +6072,7 @@ function treacheryDiscardIntegrity(g: Game) {
!g.pendingChoamWorthless && !g.response && !g.decision &&
!g.phaseOpening && !g.choamMarket &&
batch.cause === 'choam:gamont' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'Trip to Gamont' &&
choamDiscardCostMatches(g, c, entry, 'gamont') &&
c.stateSignature === choamGamontDiscardSignature(g, c),
'The used Trip to Gamont no longer matches its pending typed force return.',
);
@@ -6028,9 +6086,7 @@ function treacheryDiscardIntegrity(g: Game) {
!g.pendingChoamWorthless && !g.response && !g.decision &&
!g.phaseOpening &&
batch.cause === 'choam:jubba' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'Jubba Cloak' &&
choamDiscardCostMatches(g, c, entry, 'jubba') &&
c.stateSignature === choamJubbaDiscardSignature(g, c),
'The used Jubba Cloak no longer matches its pending storm protection.',
);
@@ -6788,7 +6844,7 @@ function semutaOfferSupported(
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
(continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
continuation.kind === 'nexusChoamBetrayal'
continuation.kind === 'nexusChoamBetrayal' || choamCunningDiscard(continuation)
? entries.length === 1 && !entries[0].publicFace
: continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
@@ -24066,15 +24122,16 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
return;
}
if (pending.effect === 'kulon') {
if (g.semutaPreview && used.name === 'Kulon' &&
if (g.semutaPreview && (used.name === 'Kulon' || pending.nexusEvent) &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
!pending.mentat) {
const continuation: ChoamKulonDiscardContinuation = {
kind: 'choamKulonDiscard', player: choam.id, card: used.id,
before: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
stateSignature: '',
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent;
const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamKulonDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:kulon', entries, continuation);
@@ -24083,14 +24140,15 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
}
completeChoamKulon(g, choam);
} else if (pending.effect === 'jubba') {
if (g.semutaPreview && used.name === 'Jubba Cloak' &&
if (g.semutaPreview && (used.name === 'Jubba Cloak' || pending.nexusEvent) &&
pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
!pending.mentat) {
const continuation: ChoamJubbaDiscardContinuation = {
kind: 'choamJubbaDiscard', player: choam.id,
card: used.id, territory: pending.location!, stateSignature: '',
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent;
const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamJubbaDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:jubba', entries, continuation);
@@ -24099,8 +24157,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
}
completeChoamJubba(g, choam, pending.location!);
} else if (pending.effect === 'baliset') {
if (g.semutaPreview && used.name === 'Baliset' &&
!pending.storm && !pending.revival && !pending.mentat && !pending.nexusEvent &&
if (g.semutaPreview && (used.name === 'Baliset' || pending.nexusEvent) &&
!pending.storm && !pending.revival && !pending.mentat &&
(!pending.movement || (g.pendingChoamMove && !g.pendingChoamMove.source &&
!g.pendingChoamMove.ornithopterEvent && !g.pendingChoamMove.ornithopterRange &&
!g.pendingChoamMove.discoveryFlight))) {
@@ -24111,8 +24169,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
card: used.id, target: pending.target!,
territory: pending.location!, stateSignature: '',
};
if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent;
if (movement) continuation.stoppedMovement = true;
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamBalisetDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:baliset', entries, continuation);
@@ -24133,18 +24192,18 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (ambassador) offerAmbassadorRelocation(g);
}
} else if (pending.effect === 'gamont') {
if (g.semutaPreview && used.name === 'Trip to Gamont' &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.nexusEvent) {
if (g.semutaPreview && (used.name === 'Trip to Gamont' || pending.nexusEvent) &&
!pending.storm && !pending.revival && !pending.movement) {
const continuation: ChoamGamontDiscardContinuation = {
kind: 'choamGamontDiscard', player: choam.id,
card: used.id, target: pending.target!,
location: pending.location!, stateSignature: '',
};
if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent;
if (pending.elite === 1) continuation.elite = 1;
if (pending.noFieldEvent) continuation.noFieldEvent = pending.noFieldEvent;
if (pending.mentat) continuation.mentat = true;
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamGamontDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:gamont', entries, continuation);
@@ -24154,8 +24213,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
completeChoamGamont(g, choam, pending.target!, pending.location!,
pending.elite!, pending.noFieldEvent);
} else {
if (g.semutaPreview && used.name === 'La La La' &&
!pending.storm && !pending.movement && !pending.mentat && !pending.nexusEvent &&
if (g.semutaPreview && (used.name === 'La La La' || pending.nexusEvent) &&
!pending.storm && !pending.movement && !pending.mentat &&
(!pending.revival || (g.pendingRevival?.kind === 'forces' &&
g.pendingRevival.player === pending.target))) {
const revival = pending.revival ? g.pendingRevival : null;
@@ -24164,8 +24223,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
kind: 'choamLaLaLaDiscard', player: choam.id,
card: used.id, target: pending.target!, stateSignature: '',
};
if (pending.nexusEvent) continuation.nexusEvent = pending.nexusEvent;
if (revival) continuation.stoppedRevival = true;
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
const entries = [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamLaLaLaDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:laLaLa', entries, continuation);
+3
View File
@@ -1182,6 +1182,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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.',
'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.',
@@ -1246,6 +1247,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/ordinary-card-discard-recovery.test.ts',
'tests/semuta-nexus-trade.test.ts',
'tests/semuta-nexus-betrayal.test.ts',
'tests/semuta-nexus-cunning-proactive.test.ts',
'tests/semuta-nexus-cunning-reactive.test.ts',
],
},
],