Files
Dune/tests/coalition-emperor-aid.test.ts
T
Quintine 9081249574
Verify and publish checkpoint container / container (push) Successful in 10m13s
Support staged group Emperor aid
2026-10-28 23:33:37 +11:00

228 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CoalitionEmperorAid } from '../components/coalition-emperor-aid';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { botActions } from '../game/bots';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
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)!;
/** Stage a Basic Nexus because group play has no lobby entry. The actual
* alliance and unanimous admission actions create the three-member group.
* Tanks and reserves are subsequently set to conserved ordinary forces. */
function fixture(phase = 4) {
let game = createGame('GROUPEMP', newPlayer('e', 'Emperor', 'emperor'), false);
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','h','g'],
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
game = send(game, 'e', {type:'alliance', target:'a'});
game = send(game, 'a', {type:'alliance', target:'e'});
const pair = coalitionOf(game.players, game.coalitionState, 'e'); assert.ok(pair);
game = send(game, 'e', {type:'coalitionPropose', groupId:pair.id, entrant:'h',
expectedRevision:game.coalitionState!.revision});
for (const id of ['a', 'h']) {
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, 'e')!.members, ['a','e','h']);
Object.assign(game, {phase, nexus:false, ready:[], response:null, decision: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 ['a','h']) {
player(game, id).reserves = 16;
player(game, id).tanks = 4;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index, 1);
player(game, 'g').hand.push(karama);
return {game, karama};
}
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, 'Each response has a remaining eligible player.');
game = send(game, next.id, {type:'passResponse'});
}
assert.equal(game.response ?? null, null);
return game;
}
function reject(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => send(game, id, action));
assert.deepEqual(game, before);
}
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);
}
void test('Emperor gifts can reach either group ally and settle from the Emperor wallet', () => {
let {game} = fixture(3);
let pending = send(game, 'e', {type:'emperorGift', target:'a', amount:3});
assert.equal(pending.response?.recipient, 'a');
assert.equal(player(pending, 'e').spice, 10);
game = passResponses(pending);
assert.equal(player(game, 'e').spice, 7);
assert.equal(player(game, 'a').spice, 13);
assert.equal(player(game, 'h').spice, 10);
pending = send(game, 'e', {type:'emperorGift', target:'h', amount:2});
assert.equal(pending.response?.recipient, 'h');
game = passResponses(pending);
assert.equal(player(game, 'e').spice, 5);
assert.equal(player(game, 'a').spice, 13);
assert.equal(player(game, 'h').spice, 12);
reject(game, 'e', {type:'emperorGift', target:'a', amount:6});
conserved(game);
});
void test('Emperor revival reaches each group ally, costs two per force and tracks their separate caps', () => {
let {game} = fixture();
player(game, 'e').spice = 12;
let pending = send(game, 'e', {type:'emperorRevival', target:'a', amount:3, elite:0});
assert.equal(pending.response?.recipient, 'a');
assert.equal(player(pending, 'a').tanks, 4);
game = passResponses(pending);
assert.equal(player(game, 'e').spice, 6);
assert.equal(player(game, 'a').tanks, 1);
assert.equal(player(game, 'a').reserves, 19);
assert.equal(player(game, 'a').revived, 0);
assert.equal(game.emperorExtra.a, 3);
reject(game, 'e', {type:'emperorRevival', target:'a', amount:1, elite:0});
pending = send(game, 'e', {type:'emperorRevival', target:'h', amount:3, elite:0});
assert.equal(pending.response?.recipient, 'h');
game = passResponses(pending);
assert.equal(player(game, 'e').spice, 0);
assert.equal(player(game, 'h').tanks, 1);
assert.equal(player(game, 'h').reserves, 19);
assert.equal(player(game, 'h').revived, 0);
assert.equal(game.emperorExtra.h, 3);
conserved(game);
});
void test('Emperor aid rejects outsiders, self, missing targets and non-Emperor actors', () => {
const {game} = fixture();
for (const type of ['emperorGift', 'emperorRevival'] as const) {
for (const target of ['g','e','missing'])
reject(game, 'e', {type, target, amount:1, elite:0});
reject(game, 'e', {type, amount:1, elite:0});
reject(game, 'a', {type, target:'h', amount:1, elite:0});
}
});
void test('Emperor revival requires Revival, available tanks and enough Emperor spice', () => {
const {game} = fixture(3);
reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0});
game.phase = 4;
reject(game, 'e', {type:'emperorRevival', target:'h', amount:5, elite:0});
player(game, 'e').spice = 3;
reject(game, 'e', {type:'emperorRevival', target:'h', amount:2, elite:0});
const done = passResponses(send(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0}));
assert.equal(player(done, 'e').spice, 1);
assert.equal(player(done, 'h').tanks, 3);
conserved(done);
});
for (const type of ['emperorGift', 'emperorRevival'] as const)
void test(`Karama cancels the selected group Emperor ${type} without payment or revival`, () => {
const {game, karama} = fixture();
const declared = send(game, 'e', {type, target:'h', amount:2, elite:0});
assert.equal(declared.response?.recipient, 'h');
const done = passResponses(send(declared, 'g', {type:'card', mode:'cancel', card:karama.id}));
assert.equal(player(done, 'e').spice, 10);
assert.equal(player(done, 'h').spice, 10);
assert.equal(player(done, 'h').tanks, 4);
assert.equal(done.emperorExtra.h ?? 0, 0);
conserved(done);
});
void test('a Medium Emperor bot proposes a legal targeted group revival', () => {
const {game} = fixture();
const view = viewGame(game, 'e');
view.players.find(seat => seat.id === 'e')!.bot = 'Medium';
const revival = botActions(view).find(action => action.type === 'emperorRevival');
assert.ok(revival);
assert.ok(revival.target === 'a' || revival.target === 'h');
const declared = send(game, 'e', revival);
assert.equal(declared.response?.recipient, revival.target);
conserved(passResponses(declared));
});
void test('group Emperor controls render both recipients and require an explicit selection', () => {
const {game} = fixture();
const view = viewGame(game, 'e');
const projection = view.coalitionEmperor!;
const html = renderToStaticMarkup(createElement(CoalitionEmperorAid, {
allies:view.players.filter(seat => projection.allies.includes(seat.id)), phase:view.phase,
advanced:view.advanced, availableSpice:projection.availableSpice,
emperorExtra:view.emperorExtra, act:() => {},
}));
assert.match(html, /Atreides/);
assert.match(html, /Harkonnen/);
assert.match(html, /Choose an ally/);
assert.match(html, /disabled=""[^>]*>Share spice/);
assert.match(html, /disabled=""[^>]*>Pay for extra ally revivals/);
});
void test('a two-player alliance retains the original implicit Emperor recipient', () => {
let game = createGame('PAIREMP', newPlayer('e', 'Emperor', 'emperor'), false);
game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','g'],
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
game = send(game, 'e', {type:'alliance', target:'a'});
game = send(game, 'a', {type:'alliance', target:'e'});
Object.assign(game, {phase:4, nexus:false, ready:[]});
for (const seat of game.players) {
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
}
player(game, 'a').reserves = 18;
player(game, 'a').tanks = 2;
assert.equal(viewGame(game, 'e').coalitionEmperor, null);
game = passResponses(send(game, 'e', {type:'emperorGift', amount:2}));
assert.equal(player(game, 'a').spice, 12);
game = passResponses(send(game, 'e', {type:'emperorRevival', amount:2, elite:0}));
assert.equal(player(game, 'e').spice, 4);
assert.equal(player(game, 'a').tanks, 0);
assert.equal(game.emperorExtra.a, 2);
conserved(game);
});
void test('the staged three-member Emperor benefit is unavailable in Advanced play', () => {
const {game} = fixture();
game.advanced = true;
assert.equal(viewGame(game, 'e').coalitionEmperor, null);
reject(game, 'e', {type:'emperorGift', target:'a', amount:1});
reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0});
});
void test('group Emperor aid projects and respects spice committed to its own leading bid', () => {
let {game} = fixture(2);
Object.assign(game, {active:null, ready:[]});
for (const seat of game.players) game = send(game, seat.id, {type:'ready'});
assert.equal(game.phase, 3);
game = passResponses(game);
assert.equal(game.auction?.active, 'e');
game = send(game, 'e', {type:'bid', amount:6, allyPayment:0});
assert.equal(game.auction?.bidder, 'e');
assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 4);
reject(game, 'e', {type:'emperorGift', target:'h', amount:5});
const pending = send(game, 'e', {type:'emperorGift', target:'h', amount:4});
assert.equal(pending.response?.recipient, 'h');
game = passResponses(pending);
assert.equal(player(game, 'e').spice, 6);
assert.equal(player(game, 'h').spice, 14);
assert.equal(game.auction?.bidder, 'e');
assert.equal(game.auction?.bid, 6);
assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 0);
});