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.
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type GameView,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { baseDeck } from '../game/cards';
|
|
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import type { FactionId } from '../game/catalog';
|
|
|
|
function fixture(faction: FactionId = 'ixians', copy = false) {
|
|
const g = createGame('GAMBOTS', newPlayer('ec', 'Ecaz', 'ecaz'), true, [
|
|
'ecaz',
|
|
'ix',
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('in', 'Entrant', 'emperor'),
|
|
newPlayer('al', 'Ally', faction),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'in',
|
|
order: ['in', 'ec', 'al'],
|
|
movementRemaining: ['in', 'ec', 'al'],
|
|
deck: baseDeck(),
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
hand: [],
|
|
forces: {},
|
|
elites: undefined,
|
|
reserves: 20,
|
|
tanks: 0,
|
|
spice: 10,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
g.players[0].ally = 'al';
|
|
g.players[2].ally = 'ec';
|
|
const inventory = createAmbassadors(() => 0);
|
|
const effect = copy ? 'beneGesserit' : 'guild';
|
|
const token = inventory.tokens.find((t) => t.effect === effect)!;
|
|
inventory.cohort = [
|
|
token.id,
|
|
...inventory.tokens
|
|
.filter(
|
|
(t) => t.effect !== 'ecaz' && t.effect !== 'guild' && t.id !== token.id,
|
|
)
|
|
.slice(0, 4)
|
|
.map((t) => t.id),
|
|
];
|
|
for (const t of inventory.tokens) {
|
|
t.zone =
|
|
t.effect === 'ecaz' || inventory.cohort.includes(t.id)
|
|
? 'supply'
|
|
: 'pool';
|
|
t.location = null;
|
|
}
|
|
g.ecazAmbassadors = placeAmbassador(inventory, token.id, {
|
|
turn: 1,
|
|
availableSpice: 10,
|
|
destination: {
|
|
id: 'arrakeen',
|
|
stronghold: true,
|
|
inStorm: false,
|
|
allowed: true,
|
|
},
|
|
}).state;
|
|
return g;
|
|
}
|
|
function army(g: Game, id: string, forces: Record<string, number>) {
|
|
const p = g.players.find((p) => p.id === id)!;
|
|
p.forces = forces;
|
|
p.reserves = 20 - Object.values(forces).reduce((n, amount) => n + amount, 0);
|
|
}
|
|
function enter(g: Game) {
|
|
return applyAction(g, 'in', {
|
|
type: 'ship',
|
|
amount: 1,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
}
|
|
// Entry IDs are generated by the actual arrival; obtain and use one arrival only.
|
|
function entered(g: Game, beneficiary: string, copy = false) {
|
|
let out = enter(g);
|
|
out = applyAction(out, 'ec', {
|
|
type: 'decision',
|
|
event: out.pendingAmbassador!.event,
|
|
trigger: true,
|
|
beneficiary,
|
|
});
|
|
if (copy)
|
|
out = applyAction(out, beneficiary, {
|
|
type: 'decision',
|
|
event: out.pendingAmbassador!.event,
|
|
effect: 'guild',
|
|
});
|
|
return out;
|
|
}
|
|
function view(g: Game, id: string, level: Difficulty) {
|
|
const v = viewGame(g, id);
|
|
v.players.find((p) => p.id === id)!.bot = level;
|
|
return v;
|
|
}
|
|
function selected(v: GameView) {
|
|
const before = structuredClone(v);
|
|
const actions = botActions(v);
|
|
assert.deepEqual(v, before);
|
|
assert.equal(actions.length, 1);
|
|
return actions[0];
|
|
}
|
|
for (const level of DIFFICULTIES) {
|
|
void test(`${level} uses a real Guild Ambassador reserve shipment without spending ordinary allowances`, () => {
|
|
const initial = fixture();
|
|
initial.players[0].shipped = true;
|
|
initial.players[0].moved = 1;
|
|
initial.hajr = ['ec'];
|
|
const g = entered(initial, 'ec');
|
|
assert.equal(g.pendingAmbassador!.stage, 'ship');
|
|
const a = selected(view(g, 'ec', level));
|
|
assert.equal(a.amount, 4);
|
|
assert.equal(a.elite, 0);
|
|
assert.equal(a.noField, undefined);
|
|
assert.equal(a.event, g.pendingAmbassador!.event);
|
|
const before = structuredClone(g);
|
|
const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'ec', a);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.equal(done.players[0].reserves, 16);
|
|
assert.equal(done.players[0].spice, g.players[0].spice);
|
|
assert.equal(done.players[0].shipped, true);
|
|
assert.equal(done.players[0].moved, 1);
|
|
assert.deepEqual(done.hajr, ['ec']);
|
|
assert.equal(done.active, 'in');
|
|
assert.deepEqual(done.movementRemaining, g.movementRemaining);
|
|
assert.equal(done.players[1].forces['arrakeen:10'], 1);
|
|
assert.equal(done.players[1].reserves, g.players[1].reserves);
|
|
assert.throws(() => applyAction(done, 'ec', a));
|
|
});
|
|
void test(`${level} allied elite-only reserves require canonical elite allocation and a private descriptor`, () => {
|
|
const initial = fixture();
|
|
army(initial, 'al', { 'wind_pass:14': 17 });
|
|
initial.players[2].elites = {
|
|
reserves: 3,
|
|
tanks: 0,
|
|
revived: 0,
|
|
forces: {},
|
|
};
|
|
const g = entered(initial, 'al');
|
|
for (const id of ['ec', 'in']) {
|
|
assert.equal(viewGame(g, id).ambassadorEntry!.shipment, null);
|
|
assert.deepEqual(botActions(view(g, id, level)), []);
|
|
}
|
|
const a = selected(view(g, 'al', level));
|
|
assert.equal(a.amount, 3);
|
|
assert.equal(a.elite, 3);
|
|
const done = applyAction(g, 'al', a);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.equal(done.players[2].reserves, 0);
|
|
assert.equal(done.players[2].elites!.reserves, 0);
|
|
assert.equal(
|
|
Object.values(done.players[2].elites!.forces).reduce((n, x) => n + x, 0),
|
|
3,
|
|
);
|
|
assert.equal(done.players[2].spice, g.players[2].spice);
|
|
});
|
|
void test(`${level} filters every blocked destination before scoring and explicitly sends zero if none remain`, () => {
|
|
const g = entered(fixture(), 'ec');
|
|
const v = view(g, 'ec', level);
|
|
const descriptor = v.ambassadorEntry!.shipment!;
|
|
const legal = descriptor.destinations.find(
|
|
(d) => d.territory === 'polar_sink',
|
|
)!;
|
|
assert.ok(legal);
|
|
descriptor.destinations.forEach((d) => {
|
|
d.blocked = d === legal ? null : 'Unresolved competing arrival';
|
|
});
|
|
const a = selected(v);
|
|
assert.equal(a.territory, 'polar_sink');
|
|
assert.doesNotThrow(() => applyAction(g, 'ec', a));
|
|
descriptor.destinations.forEach((d) => {
|
|
d.blocked = 'Unresolved competing arrival';
|
|
});
|
|
const zero = selected(v);
|
|
assert.deepEqual(zero, {
|
|
type: 'decision',
|
|
event: g.pendingAmbassador!.event,
|
|
amount: 0,
|
|
});
|
|
const done = applyAction(g, 'ec', zero);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.deepEqual(done.players[0].forces, g.players[0].forces);
|
|
assert.equal(done.players[0].reserves, g.players[0].reserves);
|
|
assert.equal(done.players[0].shipped, g.players[0].shipped);
|
|
assert.equal(done.active, 'in');
|
|
});
|
|
}
|
|
void test('hidden opponent balances, cards, traitors and deck order do not change a Guild shipment candidate', () => {
|
|
const g = entered(fixture(), 'ec'),
|
|
changed = structuredClone(g);
|
|
changed.players[2].hand.push(changed.deck.shift()!);
|
|
changed.players[2].spice = 0;
|
|
changed.players[2].traitors = [changed.players[1].leaders[0].id];
|
|
changed.deck.reverse();
|
|
for (const level of DIFFICULTIES)
|
|
assert.deepEqual(
|
|
selected(view(g, 'ec', level)),
|
|
selected(view(changed, 'ec', level)),
|
|
);
|
|
});
|
|
void test('mixed reserves enforce the elite minimum for Easy and the available maximum for other levels', () => {
|
|
const initial = fixture();
|
|
army(initial, 'al', { 'wind_pass:14': 15 });
|
|
initial.players[2].elites = {
|
|
reserves: 2,
|
|
tanks: 0,
|
|
revived: 0,
|
|
forces: {},
|
|
};
|
|
const g = entered(initial, 'al');
|
|
for (const level of DIFFICULTIES) {
|
|
const a = selected(view(g, 'al', level));
|
|
assert.equal(a.amount, 4);
|
|
assert.equal(a.elite, level === 'Easy' ? 1 : 2);
|
|
const done = applyAction(g, 'al', a);
|
|
assert.equal(done.players[2].reserves, 1);
|
|
assert.equal(done.players[2].elites!.reserves, 2 - Number(a.elite));
|
|
}
|
|
});
|
|
void test('BG copy explicitly selects Guild and completes its real shipment at every level', () => {
|
|
let g = enter(fixture('ixians', true));
|
|
g = applyAction(g, 'ec', {
|
|
type: 'decision',
|
|
event: g.pendingAmbassador!.event,
|
|
trigger: true,
|
|
beneficiary: 'ec',
|
|
});
|
|
assert.equal(g.pendingAmbassador!.stage, 'copy');
|
|
for (const level of DIFFICULTIES) {
|
|
const v = view(g, 'ec', level);
|
|
v.ambassadorEntry!.copies = v.ambassadorEntry!.copies.filter(
|
|
(c) => c.effect === 'guild',
|
|
);
|
|
const copy = selected(v);
|
|
assert.equal(copy.effect, 'guild');
|
|
const shipping = applyAction(g, 'ec', copy);
|
|
const done = applyAction(
|
|
shipping,
|
|
'ec',
|
|
selected(view(shipping, 'ec', level)),
|
|
);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.equal(
|
|
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')!
|
|
.zone,
|
|
'removed',
|
|
);
|
|
const empty = structuredClone(g);
|
|
army(empty, 'ec', { 'wind_pass:14': 20 });
|
|
assert.notEqual(selected(view(empty, 'ec', level)).effect, 'guild');
|
|
}
|
|
});
|
|
|
|
function advisorWindow() {
|
|
const initial = fixture('beneGesserit');
|
|
initial.storm = 14;
|
|
army(initial, 'in', { 'imperial_basin:10': 1 });
|
|
// A real move avoids the entrant's independent off-planet accompaniment.
|
|
let g = applyAction(initial, 'in', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
g = applyAction(g, 'ec', {
|
|
type: 'decision',
|
|
event: g.pendingAmbassador!.event,
|
|
trigger: true,
|
|
beneficiary: 'ec',
|
|
});
|
|
g = applyAction(g, 'ec', {
|
|
type: 'decision',
|
|
event: g.pendingAmbassador!.event,
|
|
amount: 1,
|
|
elite: 0,
|
|
territory: 'wind_pass',
|
|
sector: 15,
|
|
});
|
|
assert.equal(g.decision?.kind, 'advisor');
|
|
assert.equal(g.decision.player, 'al');
|
|
return g;
|
|
}
|
|
void test('Guild Ambassador accompaniment candidates exclude the storm sector at every level', () => {
|
|
const g = advisorWindow();
|
|
assert.equal(viewGame(g, 'ec').guildAmbassadorAdvisorChoices, null);
|
|
assert.equal(viewGame(g, 'in').guildAmbassadorAdvisorChoices, null);
|
|
for (const level of DIFFICULTIES) {
|
|
const v = view(g, 'al', level);
|
|
const before = structuredClone(v);
|
|
const descriptor = v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!;
|
|
assert.ok(
|
|
descriptor.choices.some(
|
|
(c) => c.accompany && c.sector === 14 && c.blocked,
|
|
),
|
|
);
|
|
assert.ok(
|
|
descriptor.choices.some(
|
|
(c) => c.accompany && c.sector === 15 && !c.blocked,
|
|
),
|
|
);
|
|
const actions = botActions(v);
|
|
assert.deepEqual(v, before);
|
|
assert.ok(actions.length > 1);
|
|
for (const a of actions) {
|
|
if (a.accept) {
|
|
assert.ok(
|
|
descriptor.choices.some(
|
|
(c) =>
|
|
!c.blocked &&
|
|
c.accompany === a.accompany &&
|
|
c.territory === a.territory &&
|
|
c.sector === a.sector,
|
|
),
|
|
);
|
|
assert.notEqual(a.sector, 14);
|
|
}
|
|
assert.doesNotThrow(() => applyAction(g, 'al', a));
|
|
}
|
|
const done = applyAction(g, 'al', actions[0]);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.equal(done.players[2].reserves, 19);
|
|
assert.equal(done.players[1].moved, g.players[1].moved);
|
|
assert.equal(done.active, 'in');
|
|
}
|
|
});
|
|
void test('projected blocked accompaniment falls back to Polar Sink, then decline when every choice is blocked', () => {
|
|
const g = advisorWindow();
|
|
for (const level of DIFFICULTIES) {
|
|
const v = view(g, 'al', level);
|
|
const choices = (v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!).choices;
|
|
// The policy consumes projected blocking regardless of its public cause.
|
|
// Server-side competing-arrival rules have separate engine fixtures.
|
|
for (const c of choices)
|
|
if (c.accompany) c.blocked = 'Competing arrival is unavailable';
|
|
const actions = botActions(v);
|
|
assert.equal(actions[0].accept, true);
|
|
assert.equal(actions[0].accompany, false);
|
|
assert.equal(actions[0].territory, 'polar_sink');
|
|
assert.equal(actions[0].sector, 0);
|
|
const done = applyAction(g, 'al', actions[0]);
|
|
assert.equal(done.players[2].forces['polar_sink:0'], 1);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
for (const c of choices) c.blocked = 'No available reserve shipment';
|
|
assert.deepEqual(botActions(v), [{ type: 'decision', accept: false }]);
|
|
assert.doesNotThrow(() => applyAction(g, 'al', botActions(v)[0]));
|
|
}
|
|
});
|