289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions, runBots } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { baseDeck } from '../game/cards';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
import type { FactionId } from '../game/catalog';
|
|
|
|
function fixture(recipient: FactionId = 'emperor') {
|
|
const g = createGame('ALLYNOFIELDBOT', newPlayer('r', 'Richese', 'richese'));
|
|
g.players.push(
|
|
newPlayer('a', 'Recipient', recipient),
|
|
newPlayer('o', 'Observer', 'atreides'),
|
|
);
|
|
g.status = 'playing';
|
|
g.advanced = true;
|
|
g.phase = 5;
|
|
g.turn = 2;
|
|
g.active = 'a';
|
|
g.storm = 18;
|
|
g.order = ['a', 'r', 'o'];
|
|
g.movementRemaining = [...g.order];
|
|
g.players[0].ally = 'a';
|
|
g.players[1].ally = 'r';
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.forces = {};
|
|
p.spice = 10;
|
|
p.reserves = 20;
|
|
}
|
|
g.players[0].noField = createRicheseNoField([
|
|
'secret0',
|
|
'secret3',
|
|
'secret5',
|
|
]);
|
|
g.players[0].noFieldEvent = 'allied-inventory-event';
|
|
if (recipient === 'emperor')
|
|
g.players[1].elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 };
|
|
return g;
|
|
}
|
|
function projection(g: Game, difficulty: Difficulty, id = 'r') {
|
|
const v = viewGame(g, id);
|
|
v.players.find((p) => p.id === id)!.bot = difficulty;
|
|
return v;
|
|
}
|
|
const offerAction = (g: Game, difficulty: Difficulty) =>
|
|
botActions(projection(normalizeAutomaticGame(g), difficulty)).find(
|
|
(a) => a.type === 'offerRicheseNoField',
|
|
);
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
void test('all profiles proactively offer during the ally turn and accept an exact typed shipment without spending Richese movement', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const initial = fixture();
|
|
const action = offerAction(initial, difficulty);
|
|
assert.ok(action, difficulty);
|
|
assert.equal(action.payer, 'r');
|
|
assert.equal(action.event, initial.players[0].noFieldEvent);
|
|
const value = difficulty === 'Easy' ? 3 : 5;
|
|
assert.equal(action.token, `secret${value}`);
|
|
let g = applyAction(initial, 'r', action);
|
|
assert.equal(g.decision?.kind, 'richeseAllyShipment');
|
|
assert.deepEqual(botActions(projection(g, difficulty, 'r')), []);
|
|
assert.equal(viewGame(g, 'o').richeseNoField!.allyOffer, null);
|
|
g = reload(g);
|
|
const accept = botActions(projection(g, difficulty, 'a'))[0];
|
|
assert.equal(accept.accept, true);
|
|
assert.equal(accept.event, g.richeseAllyOffer!.event);
|
|
assert.equal(accept.elite, difficulty === 'Easy' ? 0 : 5);
|
|
g = applyAction(g, 'a', accept);
|
|
assert.equal(g.players[0].spice, 9);
|
|
assert.equal(g.players[1].spice, 10);
|
|
assert.equal(g.players[0].shipped, false);
|
|
assert.equal(g.players[1].shipped, true);
|
|
assert.equal(g.players[0].moved, 0);
|
|
assert.equal(g.players[1].moved, 0);
|
|
assert.equal(g.players[0].reserves, 20);
|
|
assert.equal(g.players[1].reserves, 20 - value);
|
|
assert.equal(
|
|
Object.values(g.players[1].forces).reduce((n, amount) => n + amount, 0),
|
|
value,
|
|
);
|
|
assert.equal(g.players[0].noField!.deployed, null);
|
|
assert.equal(g.players[0].noField!.lastShipped, action.token);
|
|
assert.equal(offerAction(reload(g), difficulty), undefined);
|
|
}
|
|
});
|
|
|
|
void test('all profiles preserve required elite minimum and materialize an existing owner marker only on accepted commit', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = fixture();
|
|
g.players[0].noField = deployRicheseNoField(g.players[0].noField!, {
|
|
tokenId: 'secret5',
|
|
controller: 'r',
|
|
location: { territory: 'arrakeen', sector: 10 },
|
|
});
|
|
g.players[0].reserves = 2;
|
|
g.players[0].tanks = 18;
|
|
g.players[1].reserves = 3;
|
|
g.players[1].tanks = 17;
|
|
g.players[1].elites!.reserves = 3;
|
|
const action = offerAction(g, difficulty)!;
|
|
assert.equal(action.token, 'secret3');
|
|
assert.notEqual(action.territory, 'arrakeen');
|
|
g = applyAction(g, 'r', action);
|
|
assert.ok(g.players[0].noField!.deployed);
|
|
assert.deepEqual(g.players[0].forces, {});
|
|
const accept = botActions(projection(g, difficulty, 'a'))[0];
|
|
assert.equal(accept.elite, 3);
|
|
g = applyAction(reload(g), 'a', accept);
|
|
assert.equal(g.players[0].forces['arrakeen:10'], 2);
|
|
assert.equal(g.players[0].reserves, 0);
|
|
assert.equal(g.players[1].reserves, 0);
|
|
assert.equal(g.players[1].elites!.reserves, 0);
|
|
assert.equal(
|
|
Object.values(g.players[1].elites!.forces).reduce(
|
|
(n, amount) => n + amount,
|
|
0,
|
|
),
|
|
3,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('zero or newly blocked offers are declined once, retaining custody and preventing automatic reoffers', () => {
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const blocked of [false, true]) {
|
|
const initial = fixture();
|
|
let g = applyAction(initial, 'r', {
|
|
type: 'offerRicheseNoField',
|
|
event: initial.players[0].noFieldEvent,
|
|
token: blocked ? 'secret3' : 'secret0',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
payer: blocked ? 'a' : 'r',
|
|
});
|
|
if (blocked) g.players[1].spice = 0;
|
|
const current = structuredClone(
|
|
g.players.map((p) => ({
|
|
spice: p.spice,
|
|
reserves: p.reserves,
|
|
shipped: p.shipped,
|
|
noField: p.noField,
|
|
})),
|
|
);
|
|
const decline = botActions(projection(g, difficulty, 'a'))[0];
|
|
assert.equal(decline.decline, true);
|
|
assert.equal(decline.event, g.richeseAllyOffer!.event);
|
|
g = applyAction(g, 'a', decline);
|
|
assert.deepEqual(
|
|
g.players.map((p) => ({
|
|
spice: p.spice,
|
|
reserves: p.reserves,
|
|
shipped: p.shipped,
|
|
noField: p.noField,
|
|
})),
|
|
current,
|
|
);
|
|
assert.equal(viewGame(g, 'r').richeseNoField!.allyDeclined, true);
|
|
assert.equal(offerAction(reload(g), difficulty), undefined);
|
|
}
|
|
});
|
|
|
|
void test('offer choices do not use hidden recipient money and respect projection gates and unsupported recipients', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
const original = offerAction(g, difficulty);
|
|
g.players[1].spice = 10000;
|
|
g.players[2].spice = 5000;
|
|
assert.deepEqual(offerAction(g, difficulty), original);
|
|
g.players[0].spice = 0;
|
|
assert.equal(
|
|
offerAction(g, difficulty),
|
|
undefined,
|
|
'no automatic commitment of recipient money',
|
|
);
|
|
const view = projection(fixture(), difficulty);
|
|
view.richeseNoField!.canOfferAlly = false;
|
|
assert.ok(!botActions(view).some((a) => a.type === 'offerRicheseNoField'));
|
|
assert.equal(offerAction(fixture('fremen'), difficulty), undefined);
|
|
const inactive = fixture();
|
|
inactive.active = 'r';
|
|
assert.equal(offerAction(inactive, difficulty), undefined);
|
|
const consumed = fixture();
|
|
consumed.players[1].shipped = true;
|
|
assert.equal(offerAction(consumed, difficulty), undefined);
|
|
}
|
|
});
|
|
|
|
void test('responses and Truthtrance retain priority, and allowed allied shipment finishes without repeating the offer', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = fixture();
|
|
g.players[2].hand = [baseDeck().find((c) => c.effect === 'karama')!];
|
|
g = applyAction(g, 'r', offerAction(g, difficulty)!);
|
|
const accept = botActions(projection(g, difficulty, 'a'))[0];
|
|
g = applyAction(g, 'a', accept);
|
|
assert.equal(g.response?.kind, 'richeseNoField');
|
|
assert.equal(offerAction(g, difficulty), undefined);
|
|
const pass = botActions(projection(g, difficulty, 'o'))[0];
|
|
assert.equal(pass.type, 'passResponse');
|
|
g = applyAction(reload(g), 'o', pass);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.players[1].shipped, true);
|
|
assert.equal(offerAction(g, difficulty), undefined);
|
|
const v = projection(fixture(), difficulty);
|
|
v.truthtrance = {
|
|
stage: 'priority',
|
|
queue: [],
|
|
passed: [],
|
|
} as unknown as NonNullable<typeof v.truthtrance>;
|
|
assert.equal(botActions(v)[0].type, 'truthPass');
|
|
}
|
|
});
|
|
|
|
void test('actual bot scheduling offers before the active ally ships in either seating order and respects human supplier ownership', () => {
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const reverse of [false, true]) {
|
|
const g = fixture();
|
|
for (const p of g.players) p.bot = difficulty;
|
|
if (reverse) g.players.reverse();
|
|
const offered = runBots(g, 1);
|
|
assert.equal(offered.decision?.kind, 'richeseAllyShipment');
|
|
assert.equal(offered.richeseAllyOffer!.owner, 'r');
|
|
assert.equal(offered.richeseAllyOffer!.recipient, 'a');
|
|
assert.equal(offered.players.find((p) => p.id === 'a')!.shipped, false);
|
|
const committed = runBots(reload(offered), 1);
|
|
assert.equal(committed.players.find((p) => p.id === 'a')!.shipped, true);
|
|
assert.equal(committed.players.find((p) => p.id === 'r')!.shipped, false);
|
|
const humanSupplier = reload(g);
|
|
humanSupplier.players.find((p) => p.id === 'r')!.bot = undefined;
|
|
const waiting = runBots(humanSupplier, 1);
|
|
assert.equal(waiting.richeseAllyOffer, undefined);
|
|
assert.equal(waiting.decision?.kind, 'richeseAllyOpportunity');
|
|
assert.equal(waiting.decision?.player, 'r');
|
|
assert.equal(waiting.players.find((p) => p.id === 'a')!.shipped, false);
|
|
assert.equal(waiting.botsPending, false);
|
|
assert.deepEqual(runBots(reload(waiting), 1), reload(waiting));
|
|
const passed = applyAction(waiting, 'r', {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
const ordinary = runBots(passed, 1);
|
|
assert.equal(ordinary.players.find((p) => p.id === 'a')!.shipped, true);
|
|
assert.equal(ordinary.active, 'a');
|
|
const humanOffered = applyAction(
|
|
waiting,
|
|
'r',
|
|
offerAction(waiting, difficulty)!,
|
|
);
|
|
const accepted = runBots(reload(humanOffered), 1);
|
|
assert.equal(accepted.players.find((p) => p.id === 'a')!.shipped, true);
|
|
assert.equal(accepted.players.find((p) => p.id === 'r')!.shipped, false);
|
|
assert.equal(accepted.active, 'a');
|
|
}
|
|
});
|
|
|
|
void test('every profile declines an unaffordable supplier opportunity and autopilot has the same legal ownership', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture();
|
|
g.players[0].spice = 0;
|
|
g.players[0].bot = difficulty;
|
|
g.players[1].bot = difficulty;
|
|
const passed = runBots(g, 1);
|
|
assert.equal(passed.decision, null);
|
|
assert.equal(passed.richeseAllyOffer, undefined);
|
|
assert.equal(passed.players[1].shipped, false);
|
|
assert.equal(runBots(passed, 1).players[1].shipped, true);
|
|
const autopilot = fixture();
|
|
autopilot.players[0].autopilot = difficulty;
|
|
const offered = runBots(autopilot, 1);
|
|
assert.equal(offered.decision?.kind, 'richeseAllyShipment');
|
|
assert.equal(offered.richeseAllyOffer?.owner, 'r');
|
|
const waiting = runBots(offered, 1);
|
|
assert.equal(waiting.botsPending, false);
|
|
assert.equal(waiting.players[1].shipped, false);
|
|
}
|
|
});
|