Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
type GameView,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAmbassadors,
validateAmbassadors,
type AmbassadorEffect,
} from '../game/ecaz-ambassadors';
function fixture(effect: AmbassadorEffect = 'emperor') {
const g = createGame('ECAZBOTS', newPlayer('ecaz', 'Ecaz', 'ecaz'));
g.players.push(
newPlayer(
'entrant',
'Entrant',
effect === 'atreides' ? 'emperor' : 'atreides',
),
newPlayer('ally', 'Ally', 'harkonnen'),
);
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['entrant', 'ecaz', 'ally'];
g.active = 'entrant';
g.movementRemaining = [...g.order];
g.deck = baseDeck();
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.spice = 10;
p.traitors = [p.leaders[0].id];
}
for (const p of g.players) {
for (const kind of p.id === 'entrant'
? ['projectile']
: ['worthless', 'shield']) {
const index = g.deck.findIndex((c) => c.kind === kind);
p.hand.push(g.deck.splice(index, 1)[0]);
}
}
const state = createAmbassadors(() => 0);
const cohortEffects =
effect === 'beneGesserit'
? ['beneGesserit', 'fremen', 'richese', 'guild', 'tleilaxu']
: [
effect,
...state.tokens
.map((t) => t.effect)
.filter((e) => e !== effect && e !== 'ecaz'),
].slice(0, 5);
state.cohort = state.tokens
.filter((t) => cohortEffects.includes(t.effect))
.map((t) => t.id);
for (const token of state.tokens) {
token.zone =
token.effect === 'ecaz' || state.cohort.includes(token.id)
? 'supply'
: 'pool';
token.location = null;
}
const token = state.tokens.find((t) => t.effect === effect)!;
token.zone = 'placed';
token.location = 'arrakeen';
validateAmbassadors(state);
g.ecazAmbassadors = state;
return g;
}
function enter(g: Game) {
return applyAction(g, 'entrant', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
}
function botView(
g: Game,
id: string,
difficulty: (typeof DIFFICULTIES)[number],
) {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = difficulty;
return view;
}
function choose(g: Game, difficulty: (typeof DIFFICULTIES)[number]) {
assert.equal(g.decision?.kind, 'ecazAmbassador');
const id = g.decision!.player;
const before = structuredClone(g);
const actions = botActions(botView(g, id, difficulty));
assert.equal(actions.length, 1, difficulty);
assert.equal(actions[0].event, g.pendingAmbassador!.event);
assert.deepEqual(g, before, 'policy must not mutate authoritative state');
return { action: actions[0], next: applyAction(g, id, actions[0]) };
}
function finish(g: Game, difficulty: (typeof DIFFICULTIES)[number]) {
for (let steps = 0; g.pendingAmbassador && steps < 4; steps++)
g = choose(JSON.parse(JSON.stringify(g)) as Game, difficulty).next;
assert.equal(
g.pendingAmbassador,
null,
`${difficulty}: bounded entry completion`,
);
return g;
}
void test('all four profiles legally resolve each supported ordinary effect from only the decision owner’s view', () => {
for (const difficulty of DIFFICULTIES)
for (const effect of [
'emperor',
'atreides',
'harkonnen',
'choam',
'ixians',
] as const) {
const initial = enter(fixture(effect));
for (const id of ['entrant', 'ally'])
assert.deepEqual(botActions(botView(initial, id, difficulty)), []);
const done = finish(initial, difficulty);
assert.equal(done.players[1].reserves, 19);
assert.equal(done.players[1].spice, 9);
assert.equal(done.players[1].forces['arrakeen:10'], 1);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
'used',
);
if (effect === 'emperor') assert.equal(done.players[0].spice, 15);
if (effect === 'choam') {
assert.equal(done.players[0].spice, 13);
assert.equal(done.players[0].hand.length, 1);
}
if (effect === 'ixians') assert.equal(done.players[0].hand.length, 2);
if (effect === 'atreides' || effect === 'harkonnen') {
assert.equal(viewGame(done, 'ecaz').ambassadorInsights.length, 1);
assert.deepEqual(viewGame(done, 'entrant').ambassadorInsights, []);
assert.deepEqual(viewGame(done, 'ally').ambassadorInsights, []);
if (effect === 'atreides')
assert.deepEqual(
viewGame(done, 'ecaz').ambassadorInsights[0].cards,
initial.players[1].hand,
);
else
assert.equal(
viewGame(done, 'ecaz').ambassadorInsights[0].traitor,
initial.players[1].traitors[0],
);
}
for (const player of done.players)
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
);
}
});
void test('unsupported effects are explicitly declined without consuming the placed token at every difficulty', () => {
for (const difficulty of DIFFICULTIES)
for (const effect of ['tleilaxu'] as const) {
const entered = enter(fixture(effect));
const { action, next } = choose(entered, difficulty);
assert.equal(action.decline, true);
assert.equal(next.pendingAmbassador, null);
assert.equal(
next.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
'placed',
);
assert.equal(next.players[0].spice, 10);
}
});
void test('Bene Gesserit copies choose only supported projected effects and complete without another acknowledgement', () => {
for (const difficulty of DIFFICULTIES) {
const entered = enter(fixture('beneGesserit'));
const copying = choose(entered, difficulty).next;
assert.equal(copying.pendingAmbassador?.stage, 'copy');
const view = botView(copying, 'ecaz', difficulty);
const action = botActions(view)[0];
assert.ok(
view.ambassadorEntry!.copies.some(
(option) => !option.blocked && option.effect === action.effect,
),
);
const done = finish(copying, difficulty);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')!
.zone,
'removed',
);
}
});
void test('an unavailable own Ixian effect can be assigned to an eligible ally whose own bot selects its private card', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture('ixians');
g.players[0].ally = 'ally';
g.players[2].ally = 'ecaz';
g.deck.push(...g.players[0].hand);
g.players[0].hand = [];
const entered = enter(g);
const { action, next } = choose(entered, difficulty);
assert.equal(action.beneficiary, 'ally');
assert.equal(next.decision?.player, 'ally');
assert.deepEqual(viewGame(next, 'ecaz').ambassadorEntry!.cards, []);
assert.deepEqual(botActions(botView(next, 'ecaz', difficulty)), []);
const done = finish(next, difficulty);
assert.equal(done.players[2].hand.length, 2);
assert.equal(done.players[0].hand.length, 0);
}
});
void test('card policies respect projected blocked IDs, CHOAM permits zero cards, and Ix chooses exactly one', () => {
for (const difficulty of DIFFICULTIES)
for (const effect of ['choam', 'ixians'] as const) {
const cards = choose(enter(fixture(effect)), difficulty).next;
const view = botView(cards, 'ecaz', difficulty);
const worthless = view.players[0].hand!.find(
(c) => c.kind === 'worthless',
)!;
view.ambassadorEntry!.cards.find(
(option) => option.card === worthless.id,
)!.blocked = 'Reserved card';
const action = botActions(view)[0];
assert.ok(Array.isArray(action.cards));
assert.ok(!action.cards.includes(worthless.id));
assert.equal(action.cards.length, effect === 'choam' ? 0 : 1);
assert.doesNotThrow(() => applyAction(cards, 'ecaz', action));
}
});
void test('entry policy is independent of opponent hand identities and unprojected balances', () => {
for (const difficulty of DIFFICULTIES) {
const g = enter(fixture('choam'));
const first = botView(g, 'ecaz', difficulty);
const changed = structuredClone(g);
changed.players[1].spice = 999;
changed.players[1].hand = baseDeck().slice(-4);
const second = botView(changed, 'ecaz', difficulty);
assert.equal(first.players[1].spice, undefined);
assert.equal(second.players[1].hand, undefined);
assert.deepEqual(botActions(first), botActions(second));
const empty: GameView = structuredClone(first);
empty.ambassadorEntry = null;
assert.deepEqual(botActions(empty), []);
}
});