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.
233 lines
11 KiB
TypeScript
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()));
|
|
});
|