Files
Dune/tests/advisor-matching.test.ts
T
Quintine 438a5692a6 Counter incoming advisor matching before normal movement and shipment
Preserve residents and unused actions when one selected packet cannot match. Bind public matching receipts to private declarations and share packet-opportunity denials with legal bot filtering.
2026-10-24 21:50:02 +11:00

233 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
advisorMatchingKey,
AdvisorMatchingError,
quoteAdvisorMatching,
validateAdvisorMatching,
type AdvisorMatchingContext,
type AdvisorMatchingIntent,
type AdvisorMatchingSource,
} from '../game/advisor-matching';
function context(): AdvisorMatchingContext {
return {
advanced: true, status: 'playing', phase: 5, turn: 4, active: 'b',
players: [
{
id: 'b', faction: 'beneGesserit', moved: 0, reserves: 10,
forces: {
'hagga_basin:12': 3, 'hagga_basin:13': 2, 'imperial_basin:9': 2,
'carthag:11': 2, 'arrakeen:10': 1,
},
advisors: { imperial_basin: { lockedTurn: 4 }, carthag: {} },
},
{ id: 'a', faction: 'atreides', forces: { 'imperial_basin:9': 1 } },
],
};
}
function movement(fields: Partial<AdvisorMatchingIntent> = {}): AdvisorMatchingIntent {
return {
kind: 'movement', owner: 'b', move: 0, origin: 'hagga_basin', destination: 'imperial_basin',
sector: 10, advisors: true, group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 }, ...fields,
};
}
function shipment(fields: Partial<AdvisorMatchingIntent> = {}): AdvisorMatchingIntent {
return {
kind: 'shipment', owner: 'b', move: 0, origin: 'reserves', destination: 'imperial_basin',
sector: 10, advisors: true, group: { reserves: 2 }, ...fields,
};
}
function source(ctx = context(), intent = movement()): AdvisorMatchingSource {
const quote = quoteAdvisorMatching(ctx, intent);
assert.ok(quote);
return { event: 'original-matching-response', ...quote };
}
void test('movement matching quotes only the selected incoming packet in both directions and never changes resident forces or stance', () => {
const ctx = context();
const incoming = movement();
const before = structuredClone({ ctx, incoming });
const quote = quoteAdvisorMatching(ctx, incoming);
assert.deepEqual(quote, {
kind: 'movement', owner: 'b', turn: 4, move: 0, origin: 'hagga_basin', destination: 'imperial_basin',
sector: 10, advisors: true, group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 },
});
assert.deepEqual({ ctx, incoming }, before);
assert.notEqual(quote!.group, incoming.group);
quote!.group['hagga_basin:12'] = 99;
assert.equal(ctx.players[0].forces['hagga_basin:12'], 3);
assert.equal(incoming.group['hagga_basin:12'], 1);
const reverse = movement({
origin: 'carthag', destination: 'arrakeen', sector: 10, advisors: false,
group: { 'carthag:11': 1 },
});
assert.equal(quoteAdvisorMatching(ctx, reverse)?.advisors, false);
const original = source(ctx, reverse);
assert.doesNotThrow(() => validateAdvisorMatching(ctx, original));
assert.deepEqual(Object.keys(original).sort(), [
'advisors', 'destination', 'event', 'group', 'kind', 'move', 'origin', 'owner', 'sector', 'turn',
]);
assert.deepEqual(ctx, before.ctx);
});
void test('Basic, foreign actors, other windows, absent residents and same-type ground groups need no matching response', () => {
for (const patch of [
{ advanced: false }, { status: 'setup' }, { phase: 4 }, { phase: 6 }, { active: 'a' },
]) {
assert.equal(quoteAdvisorMatching({ ...context(), ...patch }, movement()), null);
}
const outsider = context();
outsider.players[0].faction = 'atreides';
assert.equal(quoteAdvisorMatching(outsider, movement()), null);
assert.equal(quoteAdvisorMatching(context(), movement({ owner: 'missing' })), null);
assert.equal(quoteAdvisorMatching(context(), movement({
destination: 'arrakeen', sector: 10, advisors: false,
})), null);
assert.equal(quoteAdvisorMatching(context(), movement({
origin: 'carthag', group: { 'carthag:11': 1 }, advisors: true,
})), null);
assert.equal(quoteAdvisorMatching(context(), movement({
origin: 'imperial_basin', destination: 'imperial_basin', sector: 11,
group: { 'imperial_basin:9': 1 },
})), null);
const empty = context();
empty.players[0].advisors = { ...empty.players[0].advisors, polar_sink: {} };
assert.equal(quoteAdvisorMatching(empty, movement({ destination: 'polar_sink', sector: 0 })), null);
});
void test('ordinary reserve shipments use the provisional fighter default and only joining resident advisors needs conversion', () => {
const ctx = context();
const before = structuredClone(ctx);
assert.deepEqual(quoteAdvisorMatching(ctx, shipment()), {
kind: 'shipment', owner: 'b', turn: 4, move: 0, origin: 'reserves', destination: 'imperial_basin',
sector: 10, advisors: true, group: { reserves: 2 },
});
assert.doesNotThrow(() => validateAdvisorMatching(ctx, source(ctx, shipment())));
assert.equal(quoteAdvisorMatching(ctx, shipment({ destination: 'arrakeen', sector: 10, advisors: false })), null);
assert.equal(quoteAdvisorMatching(ctx, shipment({ destination: 'polar_sink', sector: 0, advisors: false })), null);
assert.deepEqual(ctx, before);
assert.throws(() => quoteAdvisorMatching(ctx, shipment({ move: 1 })), AdvisorMatchingError);
assert.throws(() => quoteAdvisorMatching(ctx, shipment({ group: { 'hagga_basin:12': 2 } })), AdvisorMatchingError);
ctx.players[0].reserves = 1;
assert.throws(() => quoteAdvisorMatching(ctx, shipment()), AdvisorMatchingError);
});
void test('stable matching keys ignore response UUID, destination sector and map insertion order while binding the owned opportunity and packet', () => {
const original = source();
const key = advisorMatchingKey(original);
assert.equal(advisorMatchingKey({
...original, event: 'new-response-uuid', sector: 11,
group: { 'hagga_basin:13': 1, 'hagga_basin:12': 1 },
}), key);
for (const change of [
{ turn: 5 }, { owner: 'other' }, { kind: 'shipment' as const }, { move: 1 },
{ destination: 'carthag' }, { advisors: false }, { group: { 'hagga_basin:12': 1 } },
]) {
assert.notEqual(advisorMatchingKey({ ...original, ...change }), key);
}
});
void test('Karama denial stops the same packet to the same territory only in its current owned opportunity', () => {
const ctx = context();
const original = source(ctx);
ctx.advisorMatchingDenied = [advisorMatchingKey(original)];
const before = structuredClone(ctx);
assert.throws(() => quoteAdvisorMatching(ctx, movement()), /already prevented.*current owned opportunity/);
assert.throws(() => quoteAdvisorMatching(ctx, movement({ sector: 11 })), AdvisorMatchingError);
assert.throws(() => validateAdvisorMatching(ctx, { ...original, event: 'different-uuid', sector: 9 }), AdvisorMatchingError);
assert.deepEqual(ctx, before);
assert.ok(quoteAdvisorMatching(ctx, movement({ group: { 'hagga_basin:12': 1 } })));
assert.ok(quoteAdvisorMatching(ctx, movement({ destination: 'carthag', sector: 11 })));
assert.ok(quoteAdvisorMatching(ctx, shipment()));
ctx.players[0].moved = 1;
assert.ok(quoteAdvisorMatching(ctx, movement({ move: 1 })));
ctx.players[0].moved = 0;
ctx.turn = 5;
assert.ok(quoteAdvisorMatching(ctx, movement()));
ctx.turn = 4;
ctx.players[0].advisors = { carthag: {} };
assert.equal(quoteAdvisorMatching(ctx, movement({ advisors: false })), null);
});
void test('source validation rejects stale owner, opportunity, physical selection and resident stance without changing the uncommitted action', () => {
const changes: ((ctx: AdvisorMatchingContext, original: AdvisorMatchingSource) => void)[] = [
(ctx) => { ctx.active = 'a'; },
(ctx) => { ctx.turn++; },
(ctx) => { ctx.phase = 6; },
(ctx) => { ctx.players[0].moved = 1; },
(ctx) => { delete ctx.players[0].moved; },
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'hagga_basin:12': 0 }; },
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'imperial_basin:9': 0 }; },
(ctx) => { ctx.players[0].advisors = { carthag: {} }; },
(_ctx, original) => { original.event = ''; },
(_ctx, original) => { original.owner = 'missing'; },
(_ctx, original) => { original.turn = 3; },
(_ctx, original) => { original.move = 1; },
(_ctx, original) => { original.advisors = false; },
(_ctx, original) => { original.destination = 'missing'; },
(_ctx, original) => { original.sector = 8; },
(_ctx, original) => { original.group = {}; },
(_ctx, original) => { original.group = { 'hagga_basin:12': 4 }; },
(_ctx, original) => { original.group = { 'hagga_basin:12': 0 }; },
(_ctx, original) => { original.group = { 'hagga_basin:12': 1.5 }; },
(_ctx, original) => { original.group = { 'hagga_basin:12.0': 1 }; },
(_ctx, original) => { original.group = { 'carthag:11': 1 }; },
];
for (const change of changes) {
const ctx = context();
const original = source(ctx);
change(ctx, original);
const before = structuredClone({ ctx, original });
assert.throws(() => validateAdvisorMatching(ctx, original), (error: unknown) =>
error instanceof AdvisorMatchingError && error.name === 'AdvisorMatchingError');
assert.deepEqual({ ctx, original }, before);
}
});
void test('matching receipt shapes reject private order data, multiple-origin groups and non-board destinations', () => {
const ctx = context();
const original = source(ctx);
for (const field of ['order', 'signature', 'leader', 'skills', 'payment', 'aloneFlipHold', 'phase']) {
assert.throws(() => validateAdvisorMatching(ctx, { ...original, [field]: 'private-or-unrelated' } as AdvisorMatchingSource), AdvisorMatchingError);
}
for (const malformed of [
null, [], { ...original, kind: 'ambassador' }, { ...original, advisors: 'true' },
{ ...original, group: null }, { ...original, group: { 'hagga_basin:12': 1, 'carthag:11': 1 } },
]) {
assert.throws(() => validateAdvisorMatching(ctx, malformed as unknown as AdvisorMatchingSource), AdvisorMatchingError);
}
assert.throws(() => quoteAdvisorMatching(ctx, movement({ destination: 'homeworld:guild', sector: 0 })), AdvisorMatchingError);
assert.throws(() => quoteAdvisorMatching(ctx, movement({ origin: 'homeworld:beneGesserit' })), AdvisorMatchingError);
});
void test('matching uses only the selected public counters and stance identity without private funding, leaders or automatic-flip holds', () => {
const ctx = context();
const original = source(ctx);
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'ready']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Advisor matching read ${player.id} private or unrelated ${field}`); },
});
}
}
for (const stance of Object.values(ctx.players[0].advisors!)) {
for (const field of ['lockedTurn', 'aloneFlipHold']) {
Object.defineProperty(stance, field, {
get() { throw new Error(`Matching interpreted unrelated arrival or automatic-flip ${field}`); },
});
}
}
for (const field of ['pendingMove', 'pendingShipment', 'leaderSkills', 'matchingOrder']) {
Object.defineProperty(ctx, field, {
get() { throw new Error(`Matching read private original parent ${field}`); },
});
}
assert.ok(quoteAdvisorMatching(ctx, movement()));
assert.doesNotThrow(() => validateAdvisorMatching(ctx, original));
assert.ok(quoteAdvisorMatching(ctx, shipment()));
});