317 lines
10 KiB
TypeScript
317 lines
10 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 {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
import type { FactionId } from '../game/catalog';
|
|
|
|
function fixture(faction: FactionId = 'ixians', copy = false) {
|
|
const g = createGame('FAMBOTS', 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' : 'fremen';
|
|
const token = inventory.tokens.find((t) => t.effect === effect)!;
|
|
inventory.cohort = [
|
|
token.id,
|
|
...inventory.tokens
|
|
.filter(
|
|
(t) =>
|
|
t.effect !== 'ecaz' && t.effect !== 'fremen' && 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: 'fremen',
|
|
});
|
|
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} relocates own multi-sector forces through the real event without spending ordinary movement`, () => {
|
|
const initial = fixture();
|
|
army(initial, 'ec', { 'wind_pass:14': 4, 'wind_pass:15': 3 });
|
|
initial.players[0].shipped = true;
|
|
initial.players[0].moved = 1;
|
|
initial.hajr = ['ec'];
|
|
const g = entered(initial, 'ec'),
|
|
before = structuredClone(g);
|
|
assert.equal(g.pendingAmbassador!.stage, 'move');
|
|
const a = selected(view(g, 'ec', level));
|
|
assert.equal(a.type, 'decision');
|
|
assert.equal(a.event, g.pendingAmbassador!.event);
|
|
assert.equal(a.accept, undefined);
|
|
assert.equal(a.decline, undefined);
|
|
const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'ec', a);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(done.players[0].moved, 1);
|
|
assert.equal(done.players[0].shipped, true);
|
|
assert.deepEqual(done.hajr, ['ec']);
|
|
assert.equal(done.active, 'in');
|
|
assert.deepEqual(done.movementRemaining, g.movementRemaining);
|
|
assert.equal(done.players[1].reserves, g.players[1].reserves);
|
|
assert.equal(done.players[1].forces['arrakeen:10'], 1);
|
|
assert.equal(
|
|
Object.values(done.players[0].forces).reduce(
|
|
(n, amount) => n + amount,
|
|
0,
|
|
),
|
|
7,
|
|
);
|
|
assert.throws(() => applyAction(done, 'ec', a));
|
|
});
|
|
void test(`${level} the ally chooses its own typed force allocation and Ecaz gets no relocation descriptor`, () => {
|
|
const initial = fixture();
|
|
army(initial, 'al', { 'wind_pass:14': 4, 'wind_pass:15': 3 });
|
|
initial.players[2].elites = {
|
|
reserves: 0,
|
|
tanks: 0,
|
|
revived: 0,
|
|
forces: { 'wind_pass:14': 4, 'wind_pass:15': 3 },
|
|
};
|
|
const g = entered(initial, 'al');
|
|
for (const id of ['ec', 'in']) {
|
|
assert.equal(viewGame(g, id).ambassadorEntry!.movement, null);
|
|
assert.deepEqual(botActions(view(g, id, level)), []);
|
|
}
|
|
const a = selected(view(g, 'al', level));
|
|
assert.equal(
|
|
Object.values(a.forces as Record<string, number>).reduce(
|
|
(n, amount) => n + amount,
|
|
0,
|
|
),
|
|
Object.values(a.eliteForces as Record<string, number>).reduce(
|
|
(n, amount) => n + amount,
|
|
0,
|
|
),
|
|
);
|
|
const done = applyAction(g, 'al', a);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.equal(
|
|
Object.values(done.players[2].elites!.forces).reduce(
|
|
(n, amount) => n + amount,
|
|
0,
|
|
),
|
|
7,
|
|
);
|
|
assert.equal(done.players[2].moved, g.players[2].moved);
|
|
});
|
|
void test(`${level} marker-only movement uses separate private custody and preserves zero-valued No-Field`, () => {
|
|
const initial = fixture('richese');
|
|
const p = initial.players[2];
|
|
const state = createRicheseNoField(['zero', 'three', 'five']);
|
|
p.noField = deployRicheseNoField(state, {
|
|
tokenId: 'zero',
|
|
controller: 'al',
|
|
location: { territory: 'wind_pass', sector: 14 },
|
|
});
|
|
p.noFieldEvent = 'private-marker-event';
|
|
const g = entered(initial, 'al'),
|
|
a = selected(view(g, 'al', level));
|
|
assert.deepEqual(a.forces, {});
|
|
assert.deepEqual(a.noField, {
|
|
tokenId: 'zero',
|
|
event: 'private-marker-event',
|
|
});
|
|
assert.notEqual(a.event, 'private-marker-event');
|
|
const done = applyAction(g, 'al', a);
|
|
assert.equal(done.pendingAmbassador, null);
|
|
assert.deepEqual(done.players[2].forces, {});
|
|
assert.equal(done.players[2].reserves, 20);
|
|
assert.deepEqual(done.players[2].noField!.tokens, p.noField.tokens);
|
|
assert.ok('territory' in done.players[2].noField!.deployed!.location);
|
|
assert.equal(
|
|
done.players[2].noField!.deployed!.location.territory,
|
|
a.territory,
|
|
);
|
|
});
|
|
}
|
|
void test('all profiles filter blocked destinations before selection and omit stationary destination-sector forces', () => {
|
|
const initial = fixture();
|
|
army(initial, 'ec', { 'wind_pass:14': 4, 'wind_pass:15': 3 });
|
|
const g = entered(initial, 'ec');
|
|
for (const level of DIFFICULTIES) {
|
|
const v = view(g, 'ec', level);
|
|
const source = v.ambassadorEntry!.movement!.sources[0];
|
|
source.destinations = source.destinations.map((d) => ({
|
|
...d,
|
|
blocked:
|
|
d.territory === 'wind_pass' && d.sector === 15
|
|
? null
|
|
: 'Blocked test projection',
|
|
}));
|
|
const a = selected(v);
|
|
assert.equal(a.territory, 'wind_pass');
|
|
assert.equal(a.sector, 15);
|
|
assert.equal(
|
|
(a.forces as Record<string, number>)['wind_pass:15'],
|
|
undefined,
|
|
);
|
|
assert.doesNotThrow(() => applyAction(g, 'ec', a));
|
|
source.destinations.forEach((d) => {
|
|
d.blocked = 'Blocked test projection';
|
|
});
|
|
assert.deepEqual(
|
|
botActions(v),
|
|
[],
|
|
'no invented decline or rejected destination when none is supported',
|
|
);
|
|
}
|
|
});
|
|
void test('bots preserve one source stronghold occupant when moving to another territory', () => {
|
|
const initial = fixture();
|
|
army(initial, 'ec', { 'carthag:11': 5 });
|
|
const g = entered(initial, 'ec');
|
|
for (const level of DIFFICULTIES) {
|
|
const a = selected(view(g, 'ec', level));
|
|
assert.equal((a.forces as Record<string, number>)['carthag:11'], 4);
|
|
const done = applyAction(g, 'ec', a);
|
|
assert.equal(done.players[0].forces['carthag:11'], 1);
|
|
}
|
|
});
|
|
void test('private opponent state and deck order do not change a relocation candidate', () => {
|
|
const initial = fixture();
|
|
army(initial, 'ec', { 'wind_pass:14': 4 });
|
|
const g = entered(initial, '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('BG copy explicitly considers Fremen and skips it without a group when another effect is available', () => {
|
|
const initial = fixture('ixians', true);
|
|
army(initial, 'ec', { 'wind_pass:14': 2 });
|
|
let g = enter(initial);
|
|
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 === 'fremen',
|
|
);
|
|
const a = selected(v);
|
|
assert.equal(a.effect, 'fremen');
|
|
const moving = applyAction(g, 'ec', a);
|
|
const done = applyAction(moving, 'ec', selected(view(moving, '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', {});
|
|
assert.notEqual(selected(view(empty, 'ec', level)).effect, 'fremen');
|
|
}
|
|
});
|