Compose native Homeworld Kaitain and Tupile discards with Semuta

This commit is contained in:
Quintine committed 2026-10-12 16:40:03 +11:00
1 parent d718e380b7
commit a4f1cfc650
14 files changed
+955 -18

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
if (definition?.card.effect === 'semutaDrug')
return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation, Bene Gesserit Charity or supported native battle powers, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Acquiring a printed cancellation cost never restores its canceled effect. Battle discards may pause before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor; original cleanup and the same opportunity resume once, using the current audited hand when relevant. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after its original choice and Karama response. A Basic Richese/Moritani CHOAM/Ecaz profile also reacts to private Sabotage and Robbery overflow after clean shipment/movement arrivals. Clean reactive Baliset and La La La pause after cost without completing the stopped move or free revival. Proactive/final-Mentat Trip to Gamont preserves the exact ordinary/elite return or native No-Field reveal and capped recall, restoring its final owner window when applicable. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.';
return 'Use the neutral fresh-discard response when offered in the selected Semuta development profile. Its connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.';
return definition && definition.card.effect !== 'karama'
? 'This Richese card’s effect is not implemented yet. You can inspect its rules.'
: null;
+109 -16
View File
@@ -1267,6 +1267,12 @@ export type Game = {
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
| { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number }
| { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
| {
kind: 'tupileCleanupDiscard';
cleanup: NonNullable<Game['pendingHomeworldTupileCleanup']>;
cards: string[];
stateSignature: string;
}
| { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable<Game['pendingWinnerDiscards']> }
| {
kind: 'nullentropyDiscard';
@@ -5653,10 +5659,40 @@ function semutaNexusProfile(g: Game): boolean {
mixedE1E2NexusLeaderSkillsProfile(g)
: pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g));
}
/** Original Richese/CHOAM Homeworld occupation only; other optional envelopes stay separate. */
function semutaHomeworldRoster(g: Game): boolean {
return !!g.homeworlds && g.expansions.length === 1 && g.expansions[0] === 'choam' &&
g.players.length >= 2 && g.players.length <= 6 &&
g.players.some(p => p.faction === 'richese') &&
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' ||
faction(p.faction).expansion === 'base') &&
!g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
!g.greatMaker && !g.ecazTreachery;
}
type TupileDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'tupileCleanupDiscard' }
>;
function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) {
return JSON.stringify({
cleanup: c.cleanup, cards: c.cards, turn: g.turn, phase: g.phase,
active: g.active, order: g.order, ready: g.ready, movementRemaining: g.movementRemaining,
response: g.response, decision: g.decision, opening: g.phaseOpening,
homeworlds: g.homeworlds, history: g.homeworldOccupationHistory,
tupile: g.homeworldTupileState,
players: g.players.map(p => ({
id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id),
spice: p.spice, reserves: p.reserves, tanks: p.tanks, forces: p.forces,
elites: p.elites, shipped: p.shipped, moved: p.moved, slot: p.tupileHandSlot,
})),
});
}
function treacheryDiscardIntegrity(g: Game) {
requireRule(g.semutaPreview === undefined || g.semutaPreview === true &&
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
!g.homeworlds && (!g.nexusCards || semutaNexusProfile(g)) &&
(!g.homeworlds || g.homeworldOccupationPreview === true && semutaHomeworldRoster(g)) &&
(!g.nexusCards || semutaNexusProfile(g)) &&
(!g.leaderSkills || ordinaryLeaderSkillModeSupported(g)) && !g.discoveries &&
!g.discoveryEnabled && (!g.techTokens || (g.nexusCards
? semutaNexusProfile(g) : g.leaderSkills
@@ -6808,6 +6844,26 @@ function treacheryDiscardIntegrity(g: Game) {
batch.entries[0].discardedBy === c.target && !batch.entries[0].publicFace &&
batch.entries[0].card.id === c.card && g.nexusChoamBetrayals?.at(-1)?.event === c.event,
'The CHOAM Nexus Betrayal has changed its random physical discard.');
} else if (continuation?.kind === 'tupileCleanupDiscard') {
const c = continuation, source = c.cleanup;
const row = g.homeworldTupileState?.cleanups.find(cleanup => cleanup.event === source.event);
const player = getPlayer(g, source.player);
requireRule(semutaHomeworldRoster(g) && g.homeworldTupilePreview &&
!g.pendingHomeworldTupileCleanup && !g.decision && !g.response && !g.phaseOpening &&
row?.status === 'pending' && row.player === source.player &&
source.signature === JSON.stringify([source.event, source.player, source.hand,
source.opening, g.turn, g.phase]) &&
batch.cause === 'homeworld:tupileCleanup' && c.cards.length > 0 &&
batch.entries.length === c.cards.length &&
batch.entries.every((entry, index) => !entry.publicFace &&
entry.discardedBy === source.player && entry.card.id === c.cards[index]) &&
JSON.stringify(player.hand.map(card => card.id)) ===
JSON.stringify(source.hand.filter(id => !c.cards.includes(id))) &&
c.stateSignature === tupileDiscardSignature(g, c),
'The private Tupile discard no longer matches its original cleanup.');
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, source.event));
requireRule(!quote.blocked && quote.excess === 0,
'The original normal-limit cleanup must be paid before its Semuta reaction.');
} else if (continuation?.kind === 'kaitainDiscard') {
const c = continuation;
requireRule(g.phase === 3 && g.biddingEnd?.event === c.event &&
@@ -6855,6 +6911,8 @@ function semutaOfferSupported(
continuation.kind !== 'karamaChoamWorthlessDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaIncomeDiscard' &&
continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard' &&
continuation.kind !== 'karamaPurchaseDiscard' &&
continuation.kind !== 'karamaPaymentDiscard' &&
continuation.kind !== 'thumperDiscard' &&
@@ -6882,6 +6940,8 @@ function semutaOfferSupported(
(continuation.promise === null ||
continuation.promise.kind === 'shipment' ||
continuation.promise.kind === 'battle'))) return false;
if (g.homeworlds && continuation.kind !== 'kaitainDiscard' &&
continuation.kind !== 'tupileCleanupDiscard') return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if ((continuation.kind === 'battleCleanup' ||
@@ -6923,7 +6983,9 @@ function semutaOfferSupported(
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
(continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
(continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard'
? entries.every(entry => !entry.publicFace)
: continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
continuation.kind === 'nexusChoamBetrayal' || choamCunningDiscard(continuation)
? entries.length === 1 && !entries[0].publicFace
: continuation.kind === 'battleCleanup' ||
@@ -6954,6 +7016,8 @@ function semutaOfferSupported(
(continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
continuation.kind === 'kaitainDiscard' ||
continuation.kind === 'tupileCleanupDiscard' ||
continuation.kind === 'ixAllyCard' ||
continuation.kind === 'terrorDiscard' ||
continuation.kind === 'nexusChoamTrade' ||
@@ -7024,7 +7088,9 @@ function semutaOfferSupported(
(continuation.kind === 'choamSaleDiscard'
? !!g.choamMarket && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
: !g.choamMarket) &&
: continuation.kind === 'kaitainDiscard'
? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null
: !g.choamMarket) &&
!g.pendingChoamMarketGhola;
}
function semutaContext(g: Game, owner: Player): SemutaContext {
@@ -7150,6 +7216,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
return;
}
if (next.kind === 'kaitainDiscard') return;
if (next.kind === 'tupileCleanupDiscard') {
finishTupileCleanup(g, next.cleanup, next.cards.length);
return;
}
if (next.kind === 'ambassador') {
const p = getPlayer(g, next.entry.beneficiary!);
if (next.entry.effect === 'ixians') {
@@ -9775,8 +9845,8 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
initializeMoritaniAssassinateState(g);
return g;
}
/** Development-only Semuta with original native Tech/Advanced Strongholds/Nexus
* and explicit native skills, or bounded Basic Moritani/E3. Public starts stay gated. */
/** Development-only Semuta with original native modules/skills, separate bounded
* Homeworld occupation, or Basic Moritani/E3. Public starts stay gated. */
export function initializeSemutaGameForAudit(state: Game, skills = false): Game {
const nativeTerror = !state.advanced && state.expansions.length === 2 &&
state.expansions.includes('choam') && state.expansions.includes('ecaz') &&
@@ -9787,11 +9857,14 @@ export function initializeSemutaGameForAudit(state: Game, skills = false): Game
state.expansions.includes('choam') &&
(state.expansions.every(id => id === 'choam' || id === 'ix') || nativeTerror) &&
state.players.some(p => p.faction === 'richese'),
'Semuta needs a fresh Richese faction game: CHOAM with optional Ix, original Tech/Advanced Strongholds/Nexus, and explicit native Basic Richese or Advanced E1/E2 Leader Skills; separate Basic Moritani uses CHOAM/Ecaz and classics without modules. Other overlays remain excluded.');
'Semuta needs a fresh Richese faction game: CHOAM with optional Ix and supported original modules/skills; separate exact-CHOAM Homeworld occupation has no other modules, while Basic Moritani uses CHOAM/Ecaz and classics without modules. Other overlays remain excluded.');
if (state.nexusCards) requireRule(!nativeTerror && !state.homeworlds &&
!state.discoveryEnabled && !state.discoveries,
'Semuta Nexus retains original E1/E2 rosters without Homeworlds, Discovery or E3.');
const g = skills ? initializeLeaderSkillsGameForAudit(state)
if (state.homeworlds) requireRule(!skills && semutaHomeworldRoster(state),
'Semuta Homeworlds require original Richese/CHOAM and classic factions, exact CHOAM, and no other modules.');
const g = state.homeworlds ? initializeHomeworldOccupationGameForAudit(state)
: skills ? initializeLeaderSkillsGameForAudit(state)
: state.nexusCards ? state.expansions.length === 1 && !!byFaction(state, 'choam')
? initializePairedNexusGameForAudit(state) : initializeNexusGameForAudit(state)
: state.strongholdCards ? initializeStrongholdFactionsGameForAudit(state)
@@ -19072,6 +19145,20 @@ function projectedTupileCleanup(g: Game, viewer: string) {
blocked: quote.blocked ?? (eligibleCards.length < quote.excess
? 'The original committed cards must settle before this hand cleanup can complete.' : null) };
}
/** Resume only the original cleanup ledger and source controls; cards are already discarded. */
function finishTupileCleanup(g: Game,
cleanup: NonNullable<Game['pendingHomeworldTupileCleanup']>, discarded: number) {
const player = getPlayer(g, cleanup.player);
g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g,
g.homeworldTupileState!, cleanup.event, player.id));
g.pendingHomeworldTupileCleanup = null;
g.decision = null;
g.phaseOpening = cleanup.opening;
log(g, `${player.name} discarded ${discarded} Treachery Card${discarded === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`,
{ faction: player.faction, name: 'Tupile hand cleanup' });
syncTupileSlots(g);
prepareTupileCleanup(g);
}
function decideTupileCleanup(g: Game, player: Player,
decision: Extract<Decision, { kind: 'homeworldTupileCleanup' }>, action: Action) {
tupileSlotsIntegrity(g, decision);
@@ -19085,15 +19172,21 @@ function decideTupileCleanup(g: Game, player: Player,
requireRule(!quote.blocked && selected.length === quote.excess &&
selected.every(id => eligible.includes(id) && physical.filter(card => card.id === id).length === 1),
'Discard exactly the normal-limit excess without consuming a reserved card.');
for (const id of selected) discard(g, player, id);
g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event, player.id));
g.pendingHomeworldTupileCleanup = null;
g.decision = null;
g.phaseOpening = pending.opening;
log(g, `${player.name} discarded ${selected.length} Treachery Card${selected.length === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`,
{ faction: player.faction, name: 'Tupile hand cleanup' });
syncTupileSlots(g);
prepareTupileCleanup(g);
const entries = selected.map(id => ({ card: discard(g, player, id),
discardedBy: player.id, publicFace: false }));
if (g.semutaPreview && semutaHomeworldRoster(g) && entries.length > 0) {
const continuation: TupileDiscardContinuation = {
kind: 'tupileCleanupDiscard', cleanup: structuredClone(pending),
cards: [...selected], stateSignature: '',
};
g.pendingHomeworldTupileCleanup = null;
g.decision = null;
g.phaseOpening = null;
continuation.stateSignature = tupileDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'homeworld:tupileCleanup', entries, continuation);
return;
}
finishTupileCleanup(g, pending, selected.length);
}
function tupileAutomatic(g: Game): boolean {
if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup ||
+3
View File
@@ -1186,6 +1186,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Only paid high-Kaitain closing-Bidding disposal and occupied Tupile lost-slot cleanup offer Semuta in this envelope. Kaitain pays two spice per discarded card; multiple private cards become one fresh group, inspected only after commitment. Taking one cannot play it, refund the cost or finish either original closing owner. After a proved Tupile loss, each old holder discards its own exact normal-limit excess in a separate private event; claim or Continue completes that original cleanup before the next holder or saved phase opening. The lost slot is not restored. Basic unresolved Tupile epochs, other Homeworld producers, module mixtures and public starts stay guarded.',
'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.',
@@ -1256,6 +1257,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-emperor-share.test.ts',
'tests/semuta-guild-income.test.ts',
'tests/semuta-emperor-income.test.ts',
'tests/semuta-kaitain.test.ts',
'tests/semuta-tupile.test.ts',
],
},
],