296 lines
9.5 KiB
TypeScript
296 lines
9.5 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { baseDeck, ixDeck, type Card } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import {
|
|
SEMUTA_DRUG_ID,
|
|
committedSemutaCandidates,
|
|
resolveSemutaDrug,
|
|
type SemutaContext,
|
|
type SemutaCommitment,
|
|
} from '../game/semuta-drug';
|
|
const semuta = () => richeseCards().find((c) => c.id === SEMUTA_DRUG_ID)!;
|
|
function fixture(): SemutaContext {
|
|
const cards = baseDeck();
|
|
const old = cards[0],
|
|
fresh = cards[1],
|
|
own = cards[2],
|
|
other = cards[3];
|
|
return {
|
|
owner: 'holder',
|
|
ownerHand: [semuta(), cards[4]],
|
|
discard: [old, fresh, own, other],
|
|
event: 'fresh-1',
|
|
turn: 2,
|
|
phase: 3,
|
|
semutaId: SEMUTA_DRUG_ID,
|
|
handLimit: 4,
|
|
incomingReservedSlots: 0,
|
|
reservedTargetIds: [],
|
|
batch: {
|
|
event: 'fresh-1',
|
|
turn: 2,
|
|
phase: 3,
|
|
cause: 'simultaneous',
|
|
entries: [
|
|
{
|
|
card: structuredClone(fresh),
|
|
discardedBy: 'victim-a',
|
|
publicFace: false,
|
|
},
|
|
{ card: structuredClone(own), discardedBy: 'holder', publicFace: true },
|
|
{
|
|
card: structuredClone(other),
|
|
discardedBy: 'victim-b',
|
|
publicFace: true,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
const commitment = (c: SemutaContext): SemutaCommitment => ({
|
|
event: c.event,
|
|
player: c.owner,
|
|
semutaId: SEMUTA_DRUG_ID,
|
|
});
|
|
const target = (c: SemutaContext) => c.batch.entries[0].card.id;
|
|
function rejected(c: SemutaContext, selected = target(c)) {
|
|
const before = structuredClone(c);
|
|
assert.throws(() => resolveSemutaDrug(c, selected));
|
|
assert.deepEqual(c, before);
|
|
}
|
|
|
|
void test('Semuta claims exactly one other-player fresh card, preserves pile order, and discards itself last without mutating inputs', () => {
|
|
const c = fixture(),
|
|
before = structuredClone(c),
|
|
ids = [...c.ownerHand, ...c.discard].map((card) => card.id).sort();
|
|
const r = resolveSemutaDrug(c, target(c));
|
|
assert.deepEqual(c, before);
|
|
assert.deepEqual(
|
|
r.ownerHand.map((card) => card.id),
|
|
[c.ownerHand[1].id, target(c)],
|
|
);
|
|
assert.deepEqual(
|
|
r.discard.map((card) => card.id),
|
|
[c.discard[0].id, c.discard[2].id, c.discard[3].id, SEMUTA_DRUG_ID],
|
|
);
|
|
assert.deepEqual(
|
|
[...r.ownerHand, ...r.discard].map((card) => card.id).sort(),
|
|
ids,
|
|
);
|
|
assert.equal(r.usedCard.id, SEMUTA_DRUG_ID);
|
|
assert.equal(r.claimedCard.id, target(c));
|
|
r.claimedCard.name = 'Changed receipt';
|
|
r.usedCard.name = 'Changed receipt';
|
|
assert.equal(r.ownerHand.at(-1)!.name, c.batch.entries[0].card.name);
|
|
assert.equal(r.discard.at(-1)!.name, 'Semuta Drug');
|
|
r.ownerHand[0].name = 'Changed output';
|
|
assert.deepEqual(c, before);
|
|
});
|
|
void test('private candidate inspection requires an exact caller commitment and excludes own and explicitly reserved targets', () => {
|
|
const c = fixture(),
|
|
before = structuredClone(c);
|
|
const cards = committedSemutaCandidates(c, commitment(c));
|
|
assert.deepEqual(
|
|
cards.map((card) => card.id),
|
|
[target(c), c.batch.entries[2].card.id],
|
|
);
|
|
cards[0].name = 'Changed authorized snapshot';
|
|
assert.deepEqual(c, before);
|
|
for (const supplied of [
|
|
undefined,
|
|
{},
|
|
{ ...commitment(c), event: 'old' },
|
|
{ ...commitment(c), player: 'observer' },
|
|
{ ...commitment(c), semutaId: 'other' },
|
|
])
|
|
assert.throws(() =>
|
|
committedSemutaCandidates(c, supplied as SemutaCommitment),
|
|
);
|
|
c.reservedTargetIds = [target(c)];
|
|
assert.deepEqual(
|
|
committedSemutaCandidates(c, commitment(c)).map((card) => card.id),
|
|
[c.batch.entries[2].card.id],
|
|
);
|
|
rejected(c, target(c));
|
|
rejected(c, c.batch.entries[1].card.id);
|
|
c.reservedTargetIds = [...c.reservedTargetIds, c.batch.entries[2].card.id];
|
|
assert.throws(() => committedSemutaCandidates(c, commitment(c)));
|
|
});
|
|
void test('old, expired, missing and replayed physical targets are rejected atomically', () => {
|
|
const c = fixture();
|
|
rejected(c, c.discard[0].id);
|
|
rejected(c, 'guess');
|
|
for (const field of ['event', 'turn', 'phase'] as const) {
|
|
const changed = structuredClone(c);
|
|
if (field === 'event') changed.event = 'later';
|
|
else changed[field]++;
|
|
rejected(changed);
|
|
}
|
|
const absent = structuredClone(c);
|
|
absent.discard = absent.discard.filter((card) => card.id !== target(c));
|
|
rejected(absent);
|
|
const r = resolveSemutaDrug(c, target(c));
|
|
rejected({ ...c, ownerHand: r.ownerHand, discard: r.discard });
|
|
});
|
|
void test('canonical Semuta identity is required and every supplied physical zone and batch rejects duplicates', () => {
|
|
const start = fixture();
|
|
for (const patch of [
|
|
{ id: 'forged' },
|
|
{ name: 'Other name' },
|
|
{ kind: 'poison' },
|
|
{ effect: 'other' },
|
|
{ effect: undefined },
|
|
]) {
|
|
const c = structuredClone(start);
|
|
c.ownerHand = [
|
|
{ ...c.ownerHand[0], ...patch } as Card,
|
|
...c.ownerHand.slice(1),
|
|
];
|
|
c.semutaId = c.ownerHand[0].id;
|
|
rejected(c);
|
|
}
|
|
for (const corrupt of [
|
|
(c: SemutaContext) => {
|
|
c.ownerHand = [...c.ownerHand, c.ownerHand[0]];
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.discard = [...c.discard, c.ownerHand[0]];
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.discard = [...c.discard, c.discard[0]];
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.batch.entries = [...c.batch.entries, c.batch.entries[0]];
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.batch.entries[0].card.name = 'Changed snapshot';
|
|
},
|
|
]) {
|
|
const c = structuredClone(start);
|
|
corrupt(c);
|
|
rejected(c);
|
|
}
|
|
});
|
|
void test('atomic Semuta exchange preserves normal and Harkonnen capacity and incoming reservations', () => {
|
|
for (const limit of [4, 8])
|
|
for (let held = 1; held <= limit + 1; held++)
|
|
for (let reserved = 0; reserved <= 3; reserved++) {
|
|
const c = fixture();
|
|
c.handLimit = limit;
|
|
c.ownerHand = [semuta(), ...baseDeck().slice(10, 10 + held - 1)];
|
|
c.incomingReservedSlots = reserved;
|
|
if (held + reserved <= limit) {
|
|
const result = resolveSemutaDrug(c, target(c));
|
|
assert.equal(result.ownerHand.length, held);
|
|
assert.ok(result.ownerHand.some(card => card.id === target(c)));
|
|
assert.ok(!result.ownerHand.some(card => card.id === SEMUTA_DRUG_ID));
|
|
assert.equal(result.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
|
} else rejected(c);
|
|
}
|
|
for (const n of [-1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
|
rejected({ ...fixture(), handLimit: n });
|
|
rejected({ ...fixture(), incomingReservedSlots: n });
|
|
}
|
|
});
|
|
void test('fresh Box, Cheap Hero, Worthless, weapons and Karama are valid Treachery targets in every phase', () => {
|
|
const catalog = [...baseDeck(), ...ixDeck(), ...richeseCards()];
|
|
const targets = [
|
|
catalog.find((c) => c.effect === 'nullentropyBox')!,
|
|
catalog.find((c) => c.kind === 'hero')!,
|
|
catalog.find((c) => c.kind === 'worthless')!,
|
|
catalog.find((c) => c.kind === 'projectile')!,
|
|
catalog.find((c) => c.kind === 'poisonTooth')!,
|
|
catalog.find((c) => c.effect === 'karama')!,
|
|
];
|
|
for (const card of targets)
|
|
for (let phase = 0; phase <= 8; phase++) {
|
|
const c = fixture();
|
|
c.ownerHand = [semuta()];
|
|
c.discard = [card];
|
|
c.phase = phase;
|
|
c.batch = {
|
|
...c.batch,
|
|
phase,
|
|
entries: [
|
|
{
|
|
card: structuredClone(card),
|
|
discardedBy: 'other',
|
|
publicFace: false,
|
|
},
|
|
],
|
|
};
|
|
assert.equal(committedSemutaCandidates(c, commitment(c)).length, 1);
|
|
assert.deepEqual(resolveSemutaDrug(c, card.id).ownerHand, [card]);
|
|
}
|
|
});
|
|
void test('non-Treachery entries, malformed ownership/visibility and sparse restored arrays cannot become candidates', () => {
|
|
for (const patch of [
|
|
{ kind: 'leader' },
|
|
{ kind: 'traitor' },
|
|
{ kind: 'force' },
|
|
{ id: '' },
|
|
]) {
|
|
const c = fixture(),
|
|
bad = { ...c.discard[1], ...patch } as Card;
|
|
c.discard = [bad];
|
|
c.batch.entries = [{ card: bad, discardedBy: 'other', publicFace: false }];
|
|
rejected(c, bad.id);
|
|
}
|
|
for (const corrupt of [
|
|
(c: SemutaContext) => {
|
|
c.batch.entries[0].discardedBy = '';
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.batch.entries[0].publicFace = undefined as unknown as boolean;
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.batch.entries = [];
|
|
(c.batch.entries as unknown[]).length = 1;
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.ownerHand = [];
|
|
(c.ownerHand as unknown[]).length = 2;
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.discard = [];
|
|
(c.discard as unknown[]).length = 2;
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.batch.cause = '';
|
|
},
|
|
(c: SemutaContext) => {
|
|
c.reservedTargetIds = ['', 'x'];
|
|
},
|
|
]) {
|
|
const c = fixture();
|
|
corrupt(c);
|
|
const before = structuredClone(c);
|
|
assert.throws(() => resolveSemutaDrug(c, 'target'));
|
|
assert.deepEqual(c, before);
|
|
}
|
|
});
|
|
void test('own-only batches have no claim and private-face labels do not grant broader inspection', () => {
|
|
const c = fixture();
|
|
c.batch.entries = c.batch.entries.map((entry) => ({
|
|
...entry,
|
|
discardedBy: c.owner,
|
|
}));
|
|
assert.throws(() => committedSemutaCandidates(c, commitment(c)));
|
|
rejected(c);
|
|
const mixed = fixture(),
|
|
other = structuredClone(mixed);
|
|
other.batch.entries = other.batch.entries.map((entry) => ({
|
|
...entry,
|
|
publicFace: !entry.publicFace,
|
|
}));
|
|
assert.deepEqual(
|
|
committedSemutaCandidates(mixed, commitment(mixed)),
|
|
committedSemutaCandidates(other, commitment(other)),
|
|
);
|
|
assert.deepEqual(
|
|
resolveSemutaDrug(mixed, target(mixed)),
|
|
resolveSemutaDrug(other, target(other)),
|
|
);
|
|
});
|