Prototype neutral Semuta reactions on clean ordinary discards
This commit is contained in:
1 parent
55b47ce039
commit
9ea50a792e
18 files changed
+908
-20
No files matched your search
+187
-7
@@ -377,7 +377,7 @@ import {
|
||||
resolveNullentropyBox,
|
||||
} from './nullentropy-box';
|
||||
import { transferDistrans } from './distrans';
|
||||
import type { FreshDiscardBatch } from './semuta-drug';
|
||||
import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug';
|
||||
import {
|
||||
prepareRicheseGift,
|
||||
transferRicheseGift,
|
||||
@@ -1105,6 +1105,9 @@ export type Game = {
|
||||
pendingTreacheryDiscard?: {
|
||||
sequence: number;
|
||||
batch: FreshDiscardBatch;
|
||||
reaction?:
|
||||
| { stage: 'offer'; passed: string[] }
|
||||
| { stage: 'select'; player: string; candidates: string[] };
|
||||
continuation:
|
||||
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']> }
|
||||
| { kind: 'discoveryStash'; event: string; owner: string; card: string }
|
||||
@@ -1682,6 +1685,8 @@ export type Game = {
|
||||
mentatQuestionPreview?: true;
|
||||
/** Private development opt-in while uniform hidden-eligibility UX is pending. */
|
||||
moritaniAssassinatePreview?: true;
|
||||
/** Explicit development-only neutral Semuta reaction profile. */
|
||||
semutaPreview?: true;
|
||||
moritaniAssassinate?: MoritaniAssassinateState;
|
||||
moritaniAssassinateCallEvents?: string[];
|
||||
moritaniAssassinateResume?: {
|
||||
@@ -4838,6 +4843,11 @@ function suspendedControlsIntegrity(
|
||||
auditorIntegrity(context);
|
||||
}
|
||||
function treacheryDiscardIntegrity(g: Game) {
|
||||
requireRule(g.semutaPreview === undefined || g.semutaPreview === true &&
|
||||
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveries &&
|
||||
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards,
|
||||
'This Semuta preview no longer matches its original Richese profile.');
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
const sequence = g.treacheryDiscardSequence ?? 0;
|
||||
const resolved = g.resolvedTreacheryDiscardSequence ?? 0;
|
||||
@@ -4910,6 +4920,32 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
);
|
||||
ids.add(card.id);
|
||||
}
|
||||
const reaction = pending.reaction;
|
||||
requireRule(reaction !== undefined || !semutaOfferSupported(g, continuation, batch.entries),
|
||||
'The saved fresh discard lost its neutral Semuta reaction.');
|
||||
if (reaction !== undefined) {
|
||||
requireRule(semutaOfferSupported(g, continuation, batch.entries),
|
||||
'This saved Semuta opportunity has no supported fresh discard.');
|
||||
const physical = physicalTreacheryCards(g).filter(card => card.id === SEMUTA_DRUG_ID);
|
||||
requireRule(physical.length === 1 &&
|
||||
richeseCardDefinition(physical[0])?.card.effect === 'semutaDrug',
|
||||
'The saved Semuta reaction lost unique physical card custody.');
|
||||
if (reaction.stage === 'offer') {
|
||||
requireRule(Object.keys(reaction).sort().join(',') === 'passed,stage' &&
|
||||
Array.isArray(reaction.passed) &&
|
||||
new Set(reaction.passed).size === reaction.passed.length &&
|
||||
reaction.passed.length < g.players.length &&
|
||||
reaction.passed.every(id => g.players.some(p => p.id === id)),
|
||||
'The neutral Semuta passes lost their original participants.');
|
||||
} else if (reaction.stage === 'select') {
|
||||
const owner = g.players.find(p => p.id === reaction.player);
|
||||
requireRule(Object.keys(reaction).sort().join(',') === 'candidates,player,stage' &&
|
||||
owner && Array.isArray(reaction.candidates) && reaction.candidates.length >= 1 &&
|
||||
JSON.stringify(reaction.candidates) ===
|
||||
JSON.stringify(semutaCandidates(g, owner).map(card => card.id)),
|
||||
'The committed Semuta claim lost its private fresh candidates.');
|
||||
} else throw new RuleError('Unknown saved Semuta reaction stage.');
|
||||
}
|
||||
if (continuation?.kind === 'nullentropyDiscard') {
|
||||
const c = continuation;
|
||||
const owner = g.players.find((p) => p.id === c.player);
|
||||
@@ -5606,6 +5642,71 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The committed Kaitain discard no longer matches its payment and closing opportunity.');
|
||||
} else throw new RuleError('Unknown saved discard continuation.');
|
||||
}
|
||||
/** First playable Semuta boundary: one public ordinary-card discard with no
|
||||
* competing suspended transaction. Other typed frames retain their existing
|
||||
* automatic continuation until their reaction composition is integrated. */
|
||||
function semutaOfferSupported(
|
||||
g: Game,
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
entries: FreshDiscardBatch['entries'],
|
||||
) {
|
||||
return g.semutaPreview === true && g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
continuation.kind === 'ordinaryCardDiscard' &&
|
||||
entries.length === 1 && entries[0].publicFace &&
|
||||
!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening &&
|
||||
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
|
||||
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
|
||||
!g.pendingHomeworldShipment && !g.pendingRicheseGift &&
|
||||
!g.pendingRichesePurchaseIncome && !g.choamMarket &&
|
||||
!g.pendingChoamMarketGhola;
|
||||
}
|
||||
function semutaContext(g: Game, owner: Player): SemutaContext {
|
||||
const pending = g.pendingTreacheryDiscard!;
|
||||
return {
|
||||
batch: pending.batch, event: pending.batch.event, turn: g.turn, phase: g.phase,
|
||||
owner: owner.id, ownerHand: owner.hand, discard: g.discard,
|
||||
semutaId: SEMUTA_DRUG_ID, handLimit: handLimit(owner),
|
||||
incomingReservedSlots: 0, capacityPolicy: 'freeSlot', reservedTargetIds: [],
|
||||
};
|
||||
}
|
||||
function semutaCandidates(g: Game, owner: Player) {
|
||||
const context = semutaContext(g, owner);
|
||||
try {
|
||||
return committedSemutaCandidates(context,
|
||||
{ event: context.event, player: owner.id, semutaId: SEMUTA_DRUG_ID });
|
||||
} catch {
|
||||
throw new RuleError('This fresh Semuta claim is not available.');
|
||||
}
|
||||
}
|
||||
|
||||
function projectedSemutaReaction(g: Game, me: Player) {
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
const reaction = pending?.reaction;
|
||||
if (!pending || !reaction) return null;
|
||||
if (reaction.stage === 'select')
|
||||
return {
|
||||
event: pending.batch.event, stage: 'select' as const, passed: true,
|
||||
canCommit: false, blocked: null,
|
||||
candidates: reaction.player === me.id ? semutaCandidates(g, me) : [] as Card[],
|
||||
};
|
||||
const passed = reaction.passed.includes(me.id);
|
||||
const held = me.hand.some(card => card.id === SEMUTA_DRUG_ID &&
|
||||
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
|
||||
const blocked = !held || passed ? null
|
||||
: me.hand.length >= handLimit(me)
|
||||
? 'A free hand slot is required before taking a card with Semuta Drug.'
|
||||
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
|
||||
? 'No other player discarded a card in this fresh event.'
|
||||
: null;
|
||||
return {
|
||||
event: pending.batch.event, stage: 'offer' as const, passed,
|
||||
canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[],
|
||||
};
|
||||
}
|
||||
|
||||
function stageTreacheryDiscard(
|
||||
g: Game,
|
||||
cause: string,
|
||||
@@ -5625,20 +5726,38 @@ function stageTreacheryDiscard(
|
||||
cause,
|
||||
entries: structuredClone(entries),
|
||||
},
|
||||
...(semutaOfferSupported(g, continuation, entries)
|
||||
? { reaction: { stage: 'offer' as const, passed: [] as string[] } } : {}),
|
||||
continuation: structuredClone(continuation),
|
||||
};
|
||||
treacheryDiscardIntegrity(g);
|
||||
}
|
||||
/** No reaction policy is selected here. Semuta activation remains unavailable.
|
||||
* The existing exchanges resume automatically from their exact committed stage. */
|
||||
function finishTreacheryDiscard(g: Game) {
|
||||
/** Retire a completed fresh discard before its typed continuation resumes. */
|
||||
function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string }, finalPass?: string) {
|
||||
treacheryDiscardIntegrity(g);
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
if (!pending) return;
|
||||
const next = pending.continuation;
|
||||
// Retire before any suffix can draw or create the next semantic discard.
|
||||
requireRule(!pending.reaction ||
|
||||
(claim ? pending.reaction.stage === 'select' &&
|
||||
pending.reaction.player === claim.player &&
|
||||
pending.reaction.candidates.includes(claim.card) :
|
||||
pending.reaction.stage === 'offer' && finalPass !== undefined &&
|
||||
pending.reaction.passed.length === g.players.length - 1 &&
|
||||
g.players.some(p => p.id === finalPass) &&
|
||||
!pending.reaction.passed.includes(finalPass)),
|
||||
'Finish the fresh discard reaction before its continuation.');
|
||||
const owner = claim ? getPlayer(g, claim.player) : null;
|
||||
const result = claim && owner ? resolveSemutaDrug(semutaContext(g, owner), claim.card) : null;
|
||||
// Retire before the Semuta transfer or any suffix can draw the fresh card.
|
||||
g.resolvedTreacheryDiscardSequence = pending.sequence;
|
||||
g.pendingTreacheryDiscard = null;
|
||||
if (result && owner) {
|
||||
owner.hand = result.ownerHand;
|
||||
g.discard = result.discard;
|
||||
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 (next.kind === 'winnerMandatoryDiscard') {
|
||||
finishWinner(g, getPlayer(g, next.player), next.territory, next.optional);
|
||||
return;
|
||||
@@ -5722,6 +5841,40 @@ function finishTreacheryDiscard(g: Game) {
|
||||
finishBattle(g);
|
||||
}
|
||||
}
|
||||
|
||||
function applySemutaReaction(g: Game, id: string, action: Action) {
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
const reaction = pending?.reaction;
|
||||
requireRule(pending && reaction && action.event === pending.batch.event,
|
||||
'This fresh discard reaction is no longer available.');
|
||||
const player = getPlayer(g, id);
|
||||
if (reaction.stage === 'offer') {
|
||||
requireRule(!reaction.passed.includes(id), 'You already passed this discard.');
|
||||
if (action.type === 'semutaPass') {
|
||||
requireRule(Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Pass only the current fresh discard.');
|
||||
if (reaction.passed.length + 1 === g.players.length)
|
||||
finishTreacheryDiscard(g, undefined, id);
|
||||
else reaction.passed.push(id);
|
||||
return;
|
||||
}
|
||||
requireRule(action.type === 'semutaCommit' &&
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Choose whether to commit Semuta to this discard.');
|
||||
const candidates = semutaCandidates(g, player).map(card => card.id);
|
||||
pending.reaction = { stage: 'select', player: id, candidates };
|
||||
if (candidates.length === 1) finishTreacheryDiscard(g, { player: id, card: candidates[0] });
|
||||
else log(g, `${player.name} committed Semuta Drug to this fresh discard and must choose one card privately.`,
|
||||
{ faction: player.faction, name: 'Semuta Drug' });
|
||||
return;
|
||||
}
|
||||
requireRule(action.type === 'semutaSelect' &&
|
||||
Object.keys(action).sort().join(',') === 'card,event,type' &&
|
||||
id === reaction.player && typeof action.card === 'string' &&
|
||||
reaction.candidates.includes(action.card),
|
||||
'Choose one committed Semuta card from this fresh discard.');
|
||||
finishTreacheryDiscard(g, { player: id, card: action.card });
|
||||
}
|
||||
function place(p: Player, t: string, s: number, n: number, elite = 0) {
|
||||
const k = location(t, s);
|
||||
p.forces[k] = (p.forces[k] ?? 0) + n;
|
||||
@@ -7013,6 +7166,15 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||
g.moritaniAssassinateCallEvents = [];
|
||||
return g;
|
||||
}
|
||||
/** Development-only Semuta reaction in a fresh Richese roster; public starts stay gated. */
|
||||
export function initializeSemutaGameForAudit(state: Game): Game {
|
||||
requireRule(!state.semutaPreview && state.expansions.length === 1 &&
|
||||
state.expansions[0] === 'choam' && state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game without other modules.');
|
||||
const g = initializeFactionExpansionsGameForAudit(state);
|
||||
g.semutaPreview = true;
|
||||
return g;
|
||||
}
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
|
||||
@@ -22966,6 +23128,13 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
if (state.pendingTreacheryDiscard) {
|
||||
getPlayer(state, id);
|
||||
if (state.pendingTreacheryDiscard.reaction &&
|
||||
['semutaPass', 'semutaCommit', 'semutaSelect'].includes(action?.type)) {
|
||||
const g = structuredClone(state);
|
||||
applySemutaReaction(g, id, action);
|
||||
treacheryDiscardIntegrity(g);
|
||||
return g.pendingTreacheryDiscard?.reaction ? g : normalizeAutomaticGame(g);
|
||||
}
|
||||
requireRule(
|
||||
action?.type === 'advanceBots',
|
||||
'Finish the committed discard continuation before another game action.',
|
||||
@@ -23064,6 +23233,10 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
}
|
||||
finishActionContinuations(g);
|
||||
if (g.pendingTreacheryDiscard?.reaction) {
|
||||
treacheryDiscardIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
harkonnenExchangeIntegrity(g);
|
||||
finishLeaderSkillCustody(g, state);
|
||||
leaderSkillsIntegrity(g);
|
||||
@@ -23099,7 +23272,9 @@ function finishActionContinuations(g: Game) {
|
||||
if (g.pendingNullentropy) return;
|
||||
// Losing cleanup may finish casualties and produce the winner's mandatory
|
||||
// discard. Retire that next physical batch before exposing optional choices.
|
||||
for (let i = 0; g.pendingTreacheryDiscard && i < 16; i++) finishTreacheryDiscard(g);
|
||||
for (let i = 0; g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction && i < 16; i++)
|
||||
finishTreacheryDiscard(g);
|
||||
if (g.pendingTreacheryDiscard?.reaction) return;
|
||||
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
observeOccupation(g);
|
||||
@@ -23233,6 +23408,10 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
normalizeCardNames(g);
|
||||
normalizeBattle(g);
|
||||
finishActionContinuations(g);
|
||||
if (g.pendingTreacheryDiscard?.reaction) {
|
||||
treacheryDiscardIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
settleAutomaticContinuations(g);
|
||||
@@ -27144,7 +27323,8 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
schema: g.schema,
|
||||
botsPending: g.botsPending ?? false,
|
||||
automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
semutaReaction: projectedSemutaReaction(g, me),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
botNextActionAt: g.botNextActionAt ?? null,
|
||||
code: g.code,
|
||||
version: g.version,
|
||||
|
||||
Reference in new issue
Block a user