Files
Dune/tests/guild-ambassador-advisor-quote.test.ts
T
Quintine 767bace774 Separate Spiritual Advisor shipment matching and alone conversion
Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
2026-10-24 22:28:42 +11:00

284 lines
8.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Action, type Game } from '../game/engine';
import {
quoteGuildAmbassadorAdvisor,
quoteGuildAmbassadorShipment,
GuildAmbassadorShipmentError,
type GuildAmbassadorShipment,
} from '../game/guild-ambassador';
function fixture(advanced = true) {
const g = createGame(
'GUILD-ADVISOR',
newPlayer('b', 'BG', 'beneGesserit'),
advanced,
);
g.players.push(
newPlayer('p', 'Ixians', 'ixians'),
newPlayer('e', 'Ecaz', 'ecaz'),
newPlayer('m', 'Moritani', 'moritani'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'm',
});
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
if (p.elites) p.elites.forces = {};
}
g.players[1].forces = { 'hagga_basin:12': 2 };
g.players[1].reserves = 18;
const shipment: GuildAmbassadorShipment = {
player: 'p',
amount: 2,
elite: 0,
territory: 'hagga_basin',
sector: 12,
advisors: false,
cost: 0,
allyPayment: 0,
};
return { g, shipment };
}
const quote = (
g: Game,
s: GuildAmbassadorShipment,
action: Partial<Action> = {},
) => quoteGuildAmbassadorAdvisor(g, 'b', s, { type: 'decision', ...action });
void test('advanced accompaniment uses the exact shipment territory with its original or selected sector', () => {
const { g, shipment } = fixture();
const before = JSON.stringify({ g, shipment });
assert.deepEqual(quote(g, shipment, { accompany: true }), {
player: 'b',
territory: 'hagga_basin',
sector: 12,
amount: 1,
elite: 0,
advisors: true,
});
assert.equal(quote(g, shipment, { accompany: true, sector: 13 }).sector, 13);
assert.equal(JSON.stringify({ g, shipment }), before);
});
void test('Polar fallback stays ordinary in Basic and starts advisor-side in Advanced', () => {
for (const advanced of [false, true]) {
const { g, shipment } = fixture(advanced);
for (const accompany of advanced
? [false, undefined]
: [true, false, undefined])
assert.deepEqual(quote(g, shipment, { accompany }), {
player: 'b',
territory: 'polar_sink',
sector: 0,
amount: 1,
elite: 0,
advisors: advanced,
});
}
});
void test('BG may accompany an Ixian HMS shipment but gains no direct HMS shipment entitlement', () => {
const { g, shipment } = fixture();
g.mobileStronghold = { location: 'hagga_basin:12' };
Object.assign(shipment, { territory: 'hidden_mobile_stronghold', sector: 0 });
g.players[1].forces = { 'hidden_mobile_stronghold:0': 2 };
assert.deepEqual(quote(g, shipment, { accompany: true }), {
player: 'b',
territory: 'hidden_mobile_stronghold',
sector: 0,
amount: 1,
elite: 0,
advisors: true,
});
assert.throws(
() =>
quoteGuildAmbassadorShipment(g, 'b', {
type: 'decision',
territory: 'hidden_mobile_stronghold',
sector: 0,
amount: 1,
}),
GuildAmbassadorShipmentError,
);
g.players[1].faction = 'guild';
assert.throws(
() => quote(g, shipment, { accompany: true }),
GuildAmbassadorShipmentError,
);
});
void test('historical primary losses, changed alliances and reserves do not invalidate the BG quote', () => {
const { g, shipment } = fixture();
g.phase = 1;
g.active = null;
g.players[1].forces = {};
g.players[1].reserves = 0;
g.players[1].tanks = 20;
g.players[1].ally = 'm';
g.players[3].ally = 'p';
const result = quote(g, shipment, { accompany: true });
assert.equal(result.advisors, true); // Empty Advanced arrival converts separately after placement.
assert.equal(result.amount, 1);
assert.equal(result.territory, 'hagga_basin');
});
void test('current advisor/fighter stance and Ecaz shared occupation determine capacity without looking at cards', () => {
const { g, shipment } = fixture();
Object.assign(shipment, { territory: 'carthag', sector: 11 });
g.players[1].forces = { 'carthag:11': 2 };
g.players[3].forces = { 'carthag:11': 1 };
assert.equal(quote(g, shipment, { accompany: true }).advisors, true);
g.players[0].forces = { 'carthag:11': 1 }; // Retained fighters forbid fresh accompaniment here.
assert.throws(
() => quote(g, shipment, { accompany: true }),
GuildAmbassadorShipmentError,
);
g.players[0].advisors = { carthag: {} };
assert.equal(quote(g, shipment, { accompany: true }).advisors, true);
delete g.players[0].advisors;
g.players[3].forces = {};
g.players[2].forces = { 'carthag:11': 1 };
g.players[0].ally = 'e';
g.players[2].ally = 'b';
assert.throws(
() => quote(g, shipment, { accompany: true }),
/cannot accompany/,
);
});
void test('invalid BG/source identity and forged shipment shape reject without mutation', () => {
const changes: ((g: Game, s: GuildAmbassadorShipment) => void)[] = [
(g) => {
g.players[0].faction = 'emperor';
},
(_, s) => {
s.player = 'b';
},
(_, s) => {
s.player = 'missing';
},
(g) => {
g.players[1].faction = 'fremen';
},
(_, s) => {
s.amount = 0;
},
(_, s) => {
s.amount = 5;
},
(_, s) => {
s.elite = 3;
},
(_, s) => {
Object.assign(s, { cost: 1 });
},
(_, s) => {
Object.assign(s, { allyPayment: 1 });
},
(_, s) => {
Object.assign(s, { noField: { tokenId: 'hidden' } });
},
];
for (const change of changes) {
const { g, shipment } = fixture();
change(g, shipment);
const before = JSON.stringify({ g, shipment });
assert.throws(
() => quote(g, shipment, { accompany: true }),
GuildAmbassadorShipmentError,
);
assert.equal(JSON.stringify({ g, shipment }), before);
}
});
void test('selection is exactly one ordinary force and cannot forge destination, marker or option types', () => {
const { g, shipment } = fixture();
const actions: Partial<Action>[] = [
{ accompany: 'yes' },
{ accompany: null },
{ sector: '13' },
{ sector: null },
{ sector: -1 },
{ sector: 1.5 },
{ sector: 19 },
{ accompany: true, sector: 11 },
{ accompany: true, territory: 'arrakeen' },
{ amount: 2 },
{ elite: 1 },
{ noField: {} },
{ alliedNoField: {} },
];
const before = JSON.stringify(g);
for (const action of actions)
assert.throws(
() => quote(g, shipment, action),
GuildAmbassadorShipmentError,
);
assert.equal(JSON.stringify(g), before);
});
void test('current reserves, storm, HMS availability and safe resulting force arithmetic are revalidated', () => {
const changes: ((g: Game, s: GuildAmbassadorShipment) => void)[] = [
(g) => {
g.players[0].reserves = 0;
},
(g) => {
g.players[0].reserves = -1;
},
(g) => {
g.storm = 12;
},
(g, s) => {
Object.assign(s, { territory: 'hidden_mobile_stronghold', sector: 0 });
g.mobileStronghold = { location: null };
},
(g) => {
g.players[0].forces = { 'hagga_basin:12': Number.MAX_SAFE_INTEGER };
},
];
for (const change of changes) {
const { g, shipment } = fixture();
change(g, shipment);
assert.throws(
() => quote(g, shipment, { accompany: true }),
GuildAmbassadorShipmentError,
);
}
});
void test('repeated JSON quotes do not use private cards, spice or randomness', () => {
const { g, shipment } = fixture();
const expected = quote(g, shipment, { accompany: true });
for (const p of g.players) {
p.hand = [];
p.spice = 0;
p.traitors = [];
}
const before = JSON.stringify({ g, shipment });
const random = Math.random;
Math.random = () => {
throw new Error('Unexpected randomness');
};
try {
for (let i = 0; i < 5; i++)
assert.deepEqual(
quote(
JSON.parse(JSON.stringify(g)),
JSON.parse(JSON.stringify(shipment)),
{ accompany: true },
),
expected,
);
} finally {
Math.random = random;
}
assert.equal(JSON.stringify({ g, shipment }), before);
});