import type { Card } from './cards'; import { richeseCardDefinition } from './richese-cards'; export const SEMUTA_DRUG_ID = 'richese-semuta-drug'; export type FreshDiscardBatch = { event: string; turn: number; phase: number; cause: string; entries: readonly { card: Card; discardedBy: string; publicFace: boolean; }[]; }; export type SemutaContext = { batch: FreshDiscardBatch; /** Current authoritative opportunity, not values accepted from a client. */ event: string; turn: number; phase: number; owner: string; ownerHand: readonly Card[]; discard: readonly Card[]; semutaId: string; handLimit: number; incomingReservedSlots: number; reservedTargetIds: readonly string[]; }; export type SemutaCommitment = { event: string; player: string; semutaId: typeof SEMUTA_DRUG_ID; }; export type SemutaResult = { ownerHand: Card[]; discard: Card[]; /** Detached receipts, not extra physical cards or permission to publish faces. */ usedCard: Card; claimedCard: Card; }; const kinds = new Set([ 'projectile', 'poison', 'lasgun', 'shield', 'snooper', 'poisonBlade', 'shieldSnooper', 'weirdingWay', 'chemistry', 'poisonTooth', 'artillery', 'worthless', 'hero', 'special', ]); const identity = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; function cardShape(card: Card) { return ( !!card && identity(card.id) && identity(card.name) && kinds.has(card.kind) && (card.effect === undefined || identity(card.effect)) ); } function sameCard(a: Card, b: Card) { return ( a.id === b.id && a.name === b.name && a.kind === b.kind && a.effect === b.effect ); } function validate(context: SemutaContext) { if ( !context || !identity(context.owner) || !identity(context.event) || !count(context.turn) || !count(context.phase) || context.phase > 8 || !Array.isArray(context.ownerHand) || !Array.isArray(context.discard) || !Array.isArray(context.reservedTargetIds) || context.reservedTargetIds.some((id) => !identity(id)) || new Set(context.reservedTargetIds).size !== context.reservedTargetIds.length ) throw new Error( 'Semuta requires a current owner, event and physical card custody.', ); if (!count(context.handLimit) || !count(context.incomingReservedSlots)) throw new Error( 'Semuta requires nonnegative safe hand limits and reservations.', ); // User-selected exchange: the acquired card replaces Semuta atomically. // Mandatory incoming cards still require capacity in the completed hand. if (context.ownerHand.length > context.handLimit - context.incomingReservedSlots) throw new Error('Semuta exchange exceeds available hand capacity.'); const seen = new Set(); for (const card of [ ...Array.from(context.ownerHand), ...Array.from(context.discard), ]) { if (!cardShape(card) || seen.has(card.id)) throw new Error( 'Semuta requires unique physical Treachery cards across hand and discard.', ); seen.add(card.id); } const activation = context.ownerHand.find( (card) => card.id === context.semutaId, ); if ( context.semutaId !== SEMUTA_DRUG_ID || !activation || richeseCardDefinition(activation)?.card.effect !== 'semutaDrug' ) throw new Error('Play the canonical Semuta Drug physically in your hand.'); const batch = context.batch; if ( !batch || !identity(batch.event) || !identity(batch.cause) || batch.event !== context.event || batch.turn !== context.turn || batch.phase !== context.phase || !Array.isArray(batch.entries) || !batch.entries.length ) throw new Error( 'This fresh discard opportunity is invalid or has expired.', ); const batchIds = new Set(); for (const entry of Array.from(batch.entries)) { if ( !entry || !cardShape(entry.card) || !identity(entry.discardedBy) || typeof entry.publicFace !== 'boolean' || batchIds.has(entry.card.id) ) throw new Error( 'The fresh discard batch must contain unique owned Treachery cards.', ); batchIds.add(entry.card.id); const actual = context.discard.find((card) => card.id === entry.card.id); if (!actual || !sameCard(actual, entry.card)) throw new Error( 'The fresh discard batch no longer matches physical discard custody.', ); } const reserved = new Set(context.reservedTargetIds); const candidates = batch.entries .filter( (entry) => entry.discardedBy !== context.owner && !reserved.has(entry.card.id), ) .map((entry) => entry.card); if (!candidates.length) throw new Error( 'No available other-player card belongs to this fresh discard opportunity.', ); return { activation, candidates }; } /** * Caller must persist an irrevocable, authenticated commitment before exposing * this result. This pure function checks its binding, not server authorization. * No optional or free-preview variant exists. publicFace does not expand access. */ export function committedSemutaCandidates( context: SemutaContext, commitment: SemutaCommitment, ): Card[] { if ( !commitment || commitment.event !== context?.event || commitment.player !== context?.owner || commitment.semutaId !== SEMUTA_DRUG_ID || commitment.semutaId !== context?.semutaId ) throw new Error( 'Commit Semuta to this exact owner and event before inspecting candidates.', ); return structuredClone(validate(context).candidates); } /** * Fresh-event transfer only. Engine owns timing, globally unique custody outside * these supplied zones, activation reservations and persisted commitment. A * direct selection is also usable when the target face is already authorized. * Acquisition and disposal form an atomic exchange; no spice is paid. */ export function resolveSemutaDrug( context: SemutaContext, selectedId: string, ): SemutaResult { const { activation, candidates } = validate(context); const selected = candidates.find((card) => card.id === selectedId); if (!selected) throw new Error( 'Choose one available other-player card from this fresh discard event.', ); // The logical acquisition precedes discarding Semuta. Neither intermediate // hand nor the caller's inputs escape this pure atomic operation. const acquired = [...context.ownerHand, selected]; return { ownerHand: structuredClone( acquired.filter((card) => card.id !== activation.id), ), discard: structuredClone([ ...context.discard.filter((card) => card.id !== selected.id), activation, ]), usedCard: structuredClone(activation), claimedCard: structuredClone(selected), }; }