461 lines
13 KiB
TypeScript
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));
|
|
});
|