Files
Dune/game/semuta-drug.ts
T

225 lines
6.8 KiB
TypeScript

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<Card['kind']>([
'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<string>();
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<string>();
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),
};
}