Files
Dune/tests/movement-discard-knowledge.test.ts
T
Quintine b5a53da712 Support owned Box and Kulon movement-promise preparation
Extend bounded Basic ground-movement reachability to held native CHOAM Kulon and owned paid Box recovery. Before payment, use only known current-discard identities. Preserve actual paid event, full inspection, one two-spice fee, original physical card replay and existing provisional full-hand exchange. Reject a Yes-breaking selection before the server shuffle without losing the paid search. Keep No-only searches on their normal decision before movement can end.

Record movement-relevant current-discard certainty at actual disposal, paid search, mixed claim and deck rebuild boundaries without projecting raw metadata or erasing historical memory. Use deterministic preparation on private copies, include preparation state in search memoization, protect fee-funded routes from voluntary spending and keep native Kulon counter semantics. Persistent busy-safe guidance and existing legal profiles use the actual next paid action; no strategy tuning.

Verify 17 files231/231, types/lint/build, three original-action control/table-rendering programmes and four existing profiles with40 accepted actions including neutral passes. Original public sources44/25 retain actual purchases1+1, shipment costs6/1, exhausted Hajr and native Kulon route4. Separate full-hand, legacy certainty, fee-loss and acquired-counter edges disclose conserved staging. Two prior-engine Auditor fixture failures are repaired by actual battle-result acknowledgements; test-only const lint is corrected.

Other timing, preparations/counter mitigation, unknown private possibilities, optional/Advanced, full modes, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
2026-10-24 14:53:11 +11:00

97 lines
6.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { Card } from '../game/cards';
import type { FreshDiscardBatch } from '../game/semuta-drug';
import {
recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge,
recordMovementClaimKnowledge, clearMovementDiscardKnowledge,
type MovementDiscardKnowledge,
} from '../game/movement-discard-knowledge';
const seats = ['owner', 'rival', 'observer'];
const hajr: Card = { id: 'physical-hajr', kind: 'special', name: 'Hajr', effect: 'hajr' };
const ornithopter: Card = { id: 'richese-ornithopter', kind: 'special', name: 'Ornithopter', effect: 'ornithopter' };
const kulon: Card = { id: 'physical-kulon', kind: 'worthless', name: 'Kulon' };
const shield: Card = { id: 'physical-shield', kind: 'shield', name: 'Shield' };
const entry = (card: Card, discardedBy = 'owner', publicFace = false): FreshDiscardBatch['entries'][number] =>
({ card, discardedBy, publicFace });
void test('a public relevant disposal is remembered by all seats without creating unrelated face knowledge', () => {
const entries = [entry(hajr, 'rival', true), entry(shield, 'rival', true)];
const before = structuredClone(entries);
const result = recordMovementDiscardKnowledge(undefined, seats, entries);
assert.deepEqual(result, { observer: [hajr.id], owner: [hajr.id], rival: [hajr.id] });
for (const player of seats) assert.deepEqual(knownMovementDiscards(result, player, [shield, hajr]), [hajr]);
assert.deepEqual(entries, before);
});
void test('a private disposal teaches only its holder and an unknown relevant face cannot leak through lookup', () => {
const result = recordMovementDiscardKnowledge(undefined, seats, [entry(hajr), entry(kulon, 'rival')]);
assert.deepEqual(result, { owner: [hajr.id], rival: [kulon.id] });
assert.deepEqual(knownMovementDiscards(result, 'owner', [hajr, kulon, ornithopter]), [hajr]);
assert.deepEqual(knownMovementDiscards(result, 'observer', [hajr, kulon, ornithopter]), []);
});
void test('canonical relevance excludes an Ornithopter lookalike, unrelated Worthless and absent state', () => {
const fake = { ...ornithopter, id: 'different-ornithopter' };
const otherWorthless: Card = { id: 'other-worthless', name: 'Baliset', kind: 'worthless' };
assert.equal(recordMovementDiscardKnowledge(undefined, seats, [entry(fake, 'owner', true), entry(otherWorthless), entry(shield)]), undefined);
assert.equal(recordMovementDiscardKnowledge(undefined, seats, []), undefined);
assert.deepEqual(knownMovementDiscards(undefined, 'owner', [hajr, ornithopter, kulon]), []);
assert.deepEqual(recordMovementDiscardKnowledge(undefined, seats, [entry(ornithopter), entry(kulon)]),
{ owner: [kulon.id, ornithopter.id].sort() });
});
void test('a paid private Box invalidates other memories and grants only its owner the actual remaining faces', () => {
const state: MovementDiscardKnowledge = { owner: [hajr.id], rival: [hajr.id, ornithopter.id], observer: [kulon.id] };
const before = structuredClone(state), pile = [shield, ornithopter, kulon], originalPile = structuredClone(pile);
const result = recordMovementBoxKnowledge(state, seats, 'owner', pile);
assert.deepEqual(result, { owner: [kulon.id, ornithopter.id].sort() });
assert.deepEqual(knownMovementDiscards(result, 'owner', pile), [ornithopter, kulon]);
assert.deepEqual(knownMovementDiscards(result, 'rival', pile), []);
assert.equal(recordMovementBoxKnowledge(state, seats, 'owner', [shield]), undefined);
assert.deepEqual(state, before); assert.deepEqual(pile, originalPile);
});
void test('a private mixed claim removes ambiguous fresh memories while preserving identities outside that batch', () => {
const state: MovementDiscardKnowledge = {
owner: [hajr.id, ornithopter.id], rival: [hajr.id, ornithopter.id], observer: [kulon.id, ornithopter.id],
};
const before = structuredClone(state), entries = [entry(hajr, 'rival'), entry(kulon, 'observer')];
const originalEntries = structuredClone(entries);
const result = recordMovementClaimKnowledge(state, seats, 'owner', entries, hajr.id);
assert.deepEqual(result, {
observer: [ornithopter.id], owner: [kulon.id, ornithopter.id].sort(), rival: [ornithopter.id],
});
assert.deepEqual(knownMovementDiscards(result, 'owner', [ornithopter, kulon]), [ornithopter, kulon]);
assert.deepEqual(knownMovementDiscards(result, 'observer', [ornithopter, kulon]), [ornithopter]);
assert.deepEqual(state, before); assert.deepEqual(entries, originalEntries);
});
void test('a full discard reshuffle clears preparation knowledge without mutating the former record', () => {
const state = { owner: [hajr.id], rival: [ornithopter.id] }, before = structuredClone(state);
assert.equal(clearMovementDiscardKnowledge(state), undefined);
assert.equal(clearMovementDiscardKnowledge(undefined), undefined);
assert.deepEqual(state, before);
});
void test('lookup filters stale identities, preserves physical pile order and returns independent card faces', () => {
const state = { owner: [hajr.id, 'removed-card', kulon.id, shield.id] };
const pile = [kulon, shield, hajr], before = structuredClone(pile);
const faces = knownMovementDiscards(state, 'owner', pile);
assert.deepEqual(faces, [kulon, hajr]);
faces[0].name = 'changed'; faces.push(ornithopter);
assert.deepEqual(pile, before);
assert.deepEqual(state.owner, [hajr.id, 'removed-card', kulon.id, shield.id]);
});
void test('repeated observations use deterministic unique identities and do not alias original state arrays', () => {
const state = { owner: [hajr.id, hajr.id] }, before = structuredClone(state);
const events = [entry(ornithopter, 'owner', true), entry(kulon), entry(hajr)];
const first = recordMovementDiscardKnowledge(state, seats, events)!;
const second = recordMovementDiscardKnowledge(first, [...seats].reverse(), [...events].reverse())!;
assert.deepEqual(first, second); assert.deepEqual(state, before);
second.owner.push('new-id');
assert.deepEqual(first.owner, [hajr.id, kulon.id, ornithopter.id].sort());
});