Files
Dune/tests/battle-inspections.test.ts

461 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createNexusInspection,
cancelNexusInspection,
answerNexusInspection,
allowNexusInspection,
reopenNexusNative,
answerNexusNative,
reopenNexusInspection,
validateNexusInspection,
committedPlanElements,
inspectedPlanElements,
type BattleInspectionContext,
type NexusInspection,
} from '../game/battle-inspections';
const context = (): BattleInspectionContext => ({
event: 'battle-2',
attacker: 'a',
defender: 'g',
players: [
{ id: 'a', faction: 'atreides' },
{ id: 'g', faction: 'guild' },
{ id: 'h', faction: 'harkonnen' },
],
native: { player: 'a', field: 'dial', value: 0 },
});
const cunning = (c = context()) =>
createNexusInspection(c, {
mode: 'cunning',
owner: 'a',
target: 'g',
field: 'weapon',
});
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
void test('Cunning snapshots a real first answer including zero and null and requires a distinct second element', () => {
for (const native of [
{ player: 'a', field: 'dial' as const, value: 0 },
{ player: 'a', field: 'leader' as const, value: null },
]) {
const c = { ...context(), native };
const before = JSON.stringify(c);
const inspection = cunning(c);
assert.deepEqual(inspection.first, native);
assert.equal(inspection.stage, 'response');
assert.deepEqual(inspection.answers, []);
assert.throws(() =>
createNexusInspection(c, {
mode: 'cunning',
owner: 'a',
target: 'g',
field: native.field,
}),
);
assert.equal(JSON.stringify(c), before);
const unanswered = {
...c,
native: { player: native.player, field: native.field },
};
assert.throws(() => cunning(unanswered));
assert.throws(() => cunning({ ...c, native: undefined }));
}
});
void test('Cunning binds the current native answer and permits changes only through recorded Residual transitions', () => {
const c = context();
const inspection = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
null,
);
const pending = { ...c, native: { player: 'a', field: 'dial' as const } };
const changed = {
...c,
native: { player: 'a', field: 'dial' as const, value: 2.5 },
};
assert.throws(() => validateNexusInspection(pending, inspection));
assert.throws(() => validateNexusInspection(changed, inspection));
const reopened = reopenNexusNative(c, inspection);
assert.equal(Object.hasOwn(reopened, 'firstCurrent'), false);
validateNexusInspection(pending, reopened);
assert.throws(() => reopenNexusNative(pending, reopened));
assert.throws(() => answerNexusNative(c, inspection, 2.5));
const answered = answerNexusNative(pending, reopened, 2.5);
validateNexusInspection(changed, answered);
assert.deepEqual(answered.firstCurrent, { value: 2.5 });
assert.equal(answered.first!.value, 0);
assert.equal(inspection.firstCurrent!.value, 0);
assert.throws(() => validateNexusInspection(c, answered));
assert.throws(() =>
validateNexusInspection({ ...c, native: undefined }, answered),
);
const canceled = cancelNexusInspection(c, cunning(c));
const canceledReopened = reopenNexusNative(c, canceled);
validateNexusInspection(pending, canceledReopened);
assert.equal(
answerNexusNative(pending, canceledReopened, 1).stage,
'canceled',
);
});
void test('reopened non-leader inspection retains immutable disclosure history but only its latest answered value binds', () => {
const c = context();
const first = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
'treachery-1',
);
const firstJson = JSON.stringify(first);
const reopened = reopenNexusInspection(c, reload(first));
const battle = {
event: c.event,
attacker: c.attacker,
defender: c.defender,
prescience: c.native,
nexusInspection: reopened,
};
assert.deepEqual(committedPlanElements(battle, 'g'), [
{
source: 'native',
beneficiary: 'a',
target: 'g',
field: 'dial',
value: 0,
},
]);
const second = answerNexusInspection(c, reopened, null);
assert.deepEqual(second.answers, ['treachery-1', null]);
assert.deepEqual(
committedPlanElements({ ...battle, nexusInspection: second }, 'g').at(-1),
{
source: 'nexus',
beneficiary: 'a',
target: 'g',
field: 'weapon',
value: null,
},
);
assert.deepEqual(
inspectedPlanElements({ ...battle, nexusInspection: second }, 'a'),
[],
);
assert.deepEqual(
committedPlanElements({ ...battle, nexusInspection: second }, 'h'),
[],
);
assert.equal(JSON.stringify(first), firstJson);
assert.deepEqual(reopened.answers, ['treachery-1']);
assert.throws(() => reopenNexusInspection(c, reopened));
assert.throws(() => answerNexusInspection(c, second, 'treachery-2'));
});
void test('Secret Ally requires an absent Atreides and an actual opposing combatant; leaders never reopen', () => {
const c: BattleInspectionContext = {
event: 'absent',
attacker: 'h',
defender: 'g',
players: [
{ id: 'h', faction: 'harkonnen' },
{ id: 'g', faction: 'guild' },
{ id: 'e', faction: 'emperor' },
],
};
const input = {
mode: 'secretAlly' as const,
owner: 'h',
target: 'g',
field: 'leader' as const,
};
const inspection = answerNexusInspection(
c,
createNexusInspection(c, input),
null,
);
assert.throws(() => reopenNexusInspection(c, inspection));
assert.throws(() =>
createNexusInspection(
{ ...c, players: [...c.players, { id: 'a', faction: 'atreides' }] },
input,
),
);
assert.throws(() => createNexusInspection(c, { ...input, owner: 'e' }));
assert.throws(() => createNexusInspection(c, { ...input, target: 'e' }));
assert.throws(() => createNexusInspection(c, { ...input, mode: 'cunning' }));
validateNexusInspection(reload(c), reload(inspection));
});
void test('Betrayal binds a pending native attempt even for an allied beneficiary and a third-party holder', () => {
const c: BattleInspectionContext = {
...context(),
attacker: 'h',
native: { player: 'h', field: 'defense' },
};
const input = {
mode: 'betrayal' as const,
owner: 'g',
target: 'g',
field: 'defense' as const,
};
const inspection = createNexusInspection(c, input);
assert.equal(inspection.nativeProvider, 'a');
assert.equal(inspection.nativeBeneficiary, 'h');
assert.equal(inspection.stage, 'canceled');
assert.deepEqual(inspection.answers, []);
validateNexusInspection({ ...c, native: undefined }, reload(inspection));
assert.deepEqual(
committedPlanElements({ ...c, nexusInspection: inspection }, 'g'),
[],
);
assert.throws(() => answerNexusInspection(c, inspection, null));
assert.throws(() => reopenNexusInspection(c, inspection));
assert.throws(() =>
createNexusInspection(
{ ...c, native: { ...c.native!, value: null } },
input,
),
);
assert.throws(() =>
createNexusInspection({ ...c, native: undefined }, input),
);
assert.throws(() => createNexusInspection(c, { ...input, owner: 'a' }));
const outside = {
...c,
players: [...c.players, { id: 'e', faction: 'emperor' as const }],
};
validateNexusInspection(
outside,
createNexusInspection(outside, { ...input, owner: 'e' }),
);
});
void test('bound receipt rejects battle, owner, target, field, history and stage corruption without mutation', () => {
const c = context();
const original = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
null,
);
const corruptions: Array<(i: NexusInspection) => void> = [
(i) => {
i.event = 'another';
},
(i) => {
i.owner = 'h';
},
(i) => {
i.target = 'a';
},
(i) => {
i.field = 'defense';
},
(i) => {
i.stage = 'answer';
},
(i) => {
i.answers = [];
},
(i) => {
i.first!.value = 1;
},
(i) => {
delete i.first;
},
(i) => {
Object.assign(i, { hand: ['secret'] });
},
(i) => {
i.signature = '';
},
];
for (const corrupt of corruptions) {
const candidate = reload(original);
corrupt(candidate);
const before = JSON.stringify(candidate);
assert.throws(() => validateNexusInspection(c, candidate));
assert.throws(() => reopenNexusInspection(c, candidate));
assert.equal(JSON.stringify(candidate), before);
}
assert.throws(() =>
validateNexusInspection({ ...c, event: 'another' }, original),
);
assert.throws(() =>
committedPlanElements(
{
attacker: 'a',
defender: 'g',
event: 'another',
nexusInspection: original,
},
'g',
),
);
});
void test('typed answers allow half-force dials and no-card null but reject invalid serialized values', () => {
const c: BattleInspectionContext = {
event: 'dial',
attacker: 'h',
defender: 'g',
players: [
{ id: 'h', faction: 'harkonnen' },
{ id: 'g', faction: 'guild' },
],
};
const waiting = createNexusInspection(c, {
mode: 'secretAlly',
owner: 'h',
target: 'g',
field: 'dial',
});
assert.deepEqual(answerNexusInspection(c, waiting, 0.5).answers, [0.5]);
for (const value of [
null,
'2',
-1,
NaN,
Infinity,
0.25,
Number.MAX_SAFE_INTEGER,
])
assert.throws(() => answerNexusInspection(c, waiting, value));
const weapon = createNexusInspection(c, {
mode: 'secretAlly',
owner: 'h',
target: 'g',
field: 'weapon',
});
for (const value of [0, '', ' ', undefined])
assert.throws(() =>
answerNexusInspection(c, weapon, value as unknown as null),
);
});
void test('public-only context never reads hands, plans, resources or unrelated private state', () => {
const c = context();
for (const player of c.players)
for (const key of ['hand', 'spice', 'traitors'])
Object.defineProperty(player, key, {
get() {
throw new Error(`Private ${key} accessed`);
},
});
Object.defineProperty(c, 'plans', {
get() {
throw new Error('Plans accessed');
},
});
const before = JSON.stringify(c);
const answered = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
null,
);
validateNexusInspection(c, reload(answered));
assert.equal(JSON.stringify(c), before);
assert.deepEqual(Object.keys(answered).sort(), [
'answers',
'event',
'field',
'first',
'firstCurrent',
'mode',
'owner',
'signature',
'stage',
'target',
'version',
]);
});
void test('canceling the original Cunning extra attempt preserves its first disclosure and cannot cancel other sources or old answers', () => {
const c = context();
const waiting = cunning(c);
const before = JSON.stringify(waiting);
const canceled = cancelNexusInspection(c, waiting);
assert.equal(canceled.stage, 'canceled');
assert.deepEqual(canceled.answers, []);
assert.deepEqual(canceled.first, waiting.first);
validateNexusInspection(c, reload(canceled));
assert.deepEqual(
committedPlanElements(
{
event: c.event,
attacker: c.attacker,
defender: c.defender,
prescience: c.native,
nexusInspection: canceled,
},
'g',
),
[
{
source: 'native',
beneficiary: 'a',
target: 'g',
field: 'dial',
value: 0,
},
],
);
assert.equal(JSON.stringify(waiting), before);
assert.throws(() => cancelNexusInspection(c, canceled));
assert.throws(() => answerNexusInspection(c, canceled, null));
assert.throws(() => reopenNexusInspection(c, canceled));
const answered = answerNexusInspection(
c,
allowNexusInspection(c, waiting),
null,
);
assert.throws(() => cancelNexusInspection(c, answered));
assert.throws(() =>
cancelNexusInspection(c, reopenNexusInspection(c, answered)),
);
const absent: BattleInspectionContext = {
...c,
attacker: 'h',
native: undefined,
players: c.players.filter((p) => p.faction !== 'atreides'),
};
const secret = createNexusInspection(absent, {
mode: 'secretAlly',
owner: 'h',
target: 'g',
field: 'weapon',
});
assert.throws(() => cancelNexusInspection(absent, secret));
const pending = { ...c, native: { player: 'a', field: 'weapon' as const } };
const betrayal = createNexusInspection(pending, {
mode: 'betrayal',
owner: 'h',
target: 'g',
field: 'weapon',
});
assert.throws(() => cancelNexusInspection(pending, betrayal));
});
void test('Cunning cannot answer or reopen before its original response is explicitly allowed', () => {
const c = context();
const response = cunning(c);
const before = JSON.stringify(response);
assert.equal(response.stage, 'response');
assert.throws(() => answerNexusInspection(c, response, null));
assert.throws(() => reopenNexusInspection(c, response));
const allowed = allowNexusInspection(c, response);
assert.equal(allowed.stage, 'answer');
assert.deepEqual(allowed.first, response.first);
assert.equal(JSON.stringify(response), before);
assert.throws(() => allowNexusInspection(c, allowed));
assert.throws(() => cancelNexusInspection(c, allowed));
const rewritten = reload(response);
rewritten.stage = 'answer';
assert.throws(() => validateNexusInspection(c, rewritten));
const answered = answerNexusInspection(c, allowed, null);
assert.throws(() => allowNexusInspection(c, answered));
const reopened = reopenNexusInspection(c, answered);
assert.throws(() => allowNexusInspection(c, reopened));
assert.throws(() => cancelNexusInspection(c, reopened));
});