Files
Dune/tests/coalition-harkonnen-traitor.test.ts
T
Quintine 478b38f5de
Verify and publish checkpoint container / container (push) Canceled after 0s
Support group Harkonnen traitor calls
2026-10-28 23:46:15 +11:00

200 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { botActions } from '../game/bots';
import { applyAction, battles, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { traitorDeck } from '../game/traitors';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const {GameTable} = await import('../components/game-table');
aliases.deregister();
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
const physicalTraitors = (game: Game) => [
...(game.traitorReserve ?? []), ...game.players.flatMap(seat => [...seat.traitors, ...seat.traitorChoices]),
].sort();
/** Three-member groups cannot be started in the lobby. Stage a Nexus, form
* the pair and group through actual actions, then stage conserved forces in
* Arrakeen and transfer physical Traitor identities out of their reserve. */
function source(ally: 'e' | 'f' | 'h', options: { group?: boolean; mutual?: boolean } = {}) {
let game = createGame('GROUPTRAITOR', newPlayer('h', 'Harkonnen', 'harkonnen'), false);
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
newPlayer('g', 'Guild', 'guild'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['h','e','f','g'],
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
game = send(game, 'h', {type:'alliance', target:'e'});
game = send(game, 'e', {type:'alliance', target:'h'});
if (options.group !== false) {
const pair = coalitionOf(game.players, game.coalitionState, 'h'); assert.ok(pair);
game = send(game, 'h', {type:'coalitionPropose', groupId:pair.id, entrant:'f',
expectedRevision:game.coalitionState!.revision});
for (const id of ['e','f']) {
const proposal = game.coalitionState!.proposal!;
game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'h')!.members, ['e','f','h']);
}
Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null,
phaseOpening:null, battle:null});
for (const seat of game.players) {
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
}
for (const id of [ally,'g']) {
player(game, id).forces = {'arrakeen:10':4};
player(game, id).reserves = 16;
}
const opponentLeader = player(game, 'g').leaders[0].id;
const allyLeader = player(game, ally).leaders[0].id;
game.traitorReserve = traitorDeck(game.players);
const inventory = physicalTraitors(game);
const transfer = (identity: string, holder: string) => {
const index = game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
player(game, holder).traitors.push(...game.traitorReserve!.splice(index, 1));
};
transfer(opponentLeader, 'h');
if (options.mutual) transfer(allyLeader, 'g');
assert.deepEqual(physicalTraitors(game), inventory);
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karamaIndex >= 0);
const [karama] = game.deck.splice(karamaIndex, 1); player(game, 'g').hand.push(karama);
const choice = battles(game).find(candidate => candidate.territory === 'arrakeen' &&
[candidate.attacker,candidate.defender].includes(ally) && [candidate.attacker,candidate.defender].includes('g'));
assert.ok(choice);
game.active = choice.chooser;
const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker;
game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy});
assert.ok(game.battle && !game.battle.preparation);
game = send(game, ally, {type:'battlePlan', leader:allyLeader, dial:0, support:0, weapon:null, defense:null});
assert.ok(!game.battle?.revealed);
game = send(game, 'g', {type:'battlePlan', leader:opponentLeader, dial:0, support:0, weapon:null, defense:null});
assert.equal(game.battle?.revealed, true);
return {game, ally, opponentLeader, allyLeader, karama, inventory};
}
function passResponses(game: Game): Game {
for (let count = 0; game.response && count < 24; count++) {
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
assert.ok(next);
game = send(game, next.id, {type:'passResponse'});
}
assert.equal(game.response ?? null, null);
return game;
}
function conserved(game: Game) {
for (const seat of game.players)
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20);
}
function reject(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => send(game, id, action));
assert.deepEqual(game, before);
}
for (const ally of ['e','f'] as const) {
void test(`Harkonnen may reveal its Guild traitor for group ally ${ally}`, () => {
const f = source(ally); let game = f.game;
const own = viewGame(game, 'h').battle!;
assert.ok(own.traitorVoters.includes('h'));
assert.equal(own.traitorBeneficiary, ally);
assert.ok(own.traitorCallChoices?.includes(true));
game = send(game, 'h', {type:'traitorCall', call:true});
assert.equal(game.response?.kind, 'harkonnenTraitor');
assert.equal(game.battle?.traitorCalls.h, true);
game = passResponses(game);
game = send(game, ally, {type:'traitorCall', call:false});
game = send(game, 'g', {type:'traitorCall', call:false});
assert.equal(game.lastBattleContext?.result, 'traitor');
assert.equal(game.lastBattleContext?.winner, ally);
assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true);
assert.deepEqual(physicalTraitors(game), f.inventory);
conserved(game);
});
void test(`Karama cancels Harkonnen's group traitor call for ${ally}`, () => {
const f = source(ally); let game = send(f.game, 'h', {type:'traitorCall', call:true});
assert.equal(game.response?.kind, 'harkonnenTraitor');
game = send(game, 'g', {type:'card', mode:'cancel', card:f.karama.id});
game = passResponses(game);
assert.equal(game.battle?.traitorCalls.h, false);
reject(game, 'h', {type:'traitorCall', call:true});
game = send(game, ally, {type:'traitorCall', call:false});
game = send(game, 'g', {type:'traitorCall', call:false});
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1);
assert.deepEqual(physicalTraitors(game), f.inventory);
conserved(game);
});
}
void test('an opposing Guild call and Harkonnen group call destroy both sides', () => {
const f = source('f', {mutual:true}); let game = send(f.game, 'g', {type:'traitorCall', call:true});
game = send(game, 'h', {type:'traitorCall', call:true});
game = passResponses(game);
game = send(game, 'f', {type:'traitorCall', call:false});
assert.equal(game.lastBattleContext?.result, 'mutualTraitors');
assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true);
assert.equal(player(game, 'f').leaders.find(leader => leader.id === f.allyLeader)?.dead, true);
assert.deepEqual(physicalTraitors(game), f.inventory);
conserved(game);
});
void test('the existing two-member Harkonnen ally call keeps its implicit beneficiary', () => {
const f = source('e', {group:false});
assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'e');
const game = send(f.game, 'h', {type:'traitorCall', call:true});
assert.equal(game.response?.kind, 'harkonnenTraitor');
});
void test('Harkonnen still calls its own traitor as a combatant in a group', () => {
const f = source('h');
assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'h');
const game = send(f.game, 'h', {type:'traitorCall', call:true});
assert.equal(game.battle?.traitorCalls.h, true);
assert.notEqual(game.response?.kind, 'harkonnenTraitor');
});
void test('a Medium Harkonnen bot offers a legal allied group traitor call', () => {
const {game} = source('f');
const view = viewGame(game, 'h');
view.players.find(seat => seat.id === 'h')!.bot = 'Medium';
const action = botActions(view).find(candidate => candidate.type === 'traitorCall');
assert.ok(action); assert.equal(action.call, true);
assert.equal(send(game, 'h', action).response?.kind, 'harkonnenTraitor');
});
void test('the actual group table gives Harkonnen an enabled allied call without revealing its hand to the other ally', () => {
const {game, opponentLeader} = source('e');
const own = viewGame(game, 'h');
const ownMarkup = renderToStaticMarkup(createElement(GameTable, {
game:own, busy:false, send:async () => {}, onExit() {},
}));
const button = ownMarkup.match(/<button\b[^>]*>(?:(?!<\/button>)[\s\S])*Reveal ally’s opponent as traitor(?:(?!<\/button>)[\s\S])*<\/button>/)?.[0];
assert.ok(button, 'The allied call is offered to Harkonnen.');
assert.doesNotMatch(button, /\bdisabled(?:=|\s|>)/);
assert.ok(own.players.find(seat => seat.id === 'h')?.traitors?.includes(opponentLeader));
const observer = viewGame(game, 'f');
assert.equal(observer.players.find(seat => seat.id === 'h')?.traitors, undefined);
const observerMarkup = renderToStaticMarkup(createElement(GameTable, {
game:observer, busy:false, send:async () => {}, onExit() {},
}));
assert.doesNotMatch(observerMarkup, /Reveal ally’s opponent as traitor/);
});