169 lines
9.2 KiB
TypeScript
169 lines
9.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import { emptyCoalitionState, type CoalitionEdge } from '../game/coalition-alliance';
|
|
import { coalitionBattleBeneficiary } from '../game/coalition-battle-preparation';
|
|
import { createCoalitionWormProtection, currentCoalitionWormAlly,
|
|
decideCoalitionWormProtection, resolveCoalitionWormProtection,
|
|
protectedCoalitionWormAllies } from '../game/coalition-worm-protection';
|
|
import { projectFremenGroupRevivalGrant, quoteFremenRevivalGrant } from '../game/fremen-revival-grant';
|
|
import { createRevivalOrder } from '../game/revival-order';
|
|
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
|
|
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
|
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
|
|
|
/** The unexposed Advanced group and phase are deliberately staged: no lobby
|
|
* offers this mode. A real ordered-Revival frame, native response actions and
|
|
* conservation of physical forces are exercised after the controlled state. */
|
|
function source(): Game {
|
|
const game = createGame('ADVANCEDGROUPPOWERS', newPlayer('f', 'Fremen', 'fremen'), true);
|
|
game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'),
|
|
newPlayer('g', 'Guild', 'guild'));
|
|
Object.assign(game, {status:'playing', turn:2, phase:4, order:['f','a','h','g'],
|
|
active:'f', ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
|
const members = ['a','f','h'];
|
|
const edges: CoalitionEdge[] = [
|
|
{members:['a','f'], formedTurn:1}, {members:['a','h'], formedTurn:1},
|
|
{members:['f','h'], formedTurn:1},
|
|
];
|
|
game.coalitionState = {...emptyCoalitionState('unlimited'),
|
|
groups:[{id:'staged-group', members, formedTurn:1, edges}]};
|
|
for (const player of game.players) {
|
|
player.ally = null; player.forces = {}; player.reserves = 20; player.tanks = 0;
|
|
player.spice = 10; player.hand = []; player.traitors = []; player.traitorChoices = [];
|
|
}
|
|
for (const id of ['a','h']) { seat(game, id).reserves = 16; seat(game, id).tanks = 4; }
|
|
game.revivalOrder = createRevivalOrder({turn:game.turn, order:game.order, ready:game.ready});
|
|
// This is an actual physical card from the staged original deck, used to
|
|
// keep the normal cancellation response open until players choose to pass.
|
|
const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 0);
|
|
seat(game, 'g').hand.push(game.deck.splice(karama, 1)[0]);
|
|
return game;
|
|
}
|
|
function pass(game: Game): Game {
|
|
for (let count = 0; game.response && count < 20; count++) {
|
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
|
|
assert.ok(actor);
|
|
game = act(game, actor.id, {type:'passResponse'});
|
|
}
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
|
|
void test('Advanced allied Voice and Prescience select the actual combatant and reject allied opposing sides', () => {
|
|
const game = source();
|
|
assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'g'), 'f');
|
|
assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h', true), 'h');
|
|
assert.equal(coalitionBattleBeneficiary(game, 'a', 'a', 'g'), 'a');
|
|
assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'h'), null);
|
|
});
|
|
|
|
void test('Advanced Fremen resolves each threatened group ally in storm order', () => {
|
|
const game = source();
|
|
game.phase = 1;
|
|
for (const id of ['a','h']) { seat(game, id).forces = {'red_chasm:7':2}; seat(game, id).reserves -= 2; }
|
|
let frame = createCoalitionWormProtection(game, 'f', 'red_chasm', 'advanced-worm');
|
|
assert.ok(frame);
|
|
assert.deepEqual(frame.order, ['a','h']);
|
|
assert.equal(currentCoalitionWormAlly(game, frame), 'a');
|
|
frame = decideCoalitionWormProtection(game, frame, 'f', frame.event, 'a', true);
|
|
frame = resolveCoalitionWormProtection(game, frame, 'f', frame.event, 'a', false);
|
|
assert.equal(currentCoalitionWormAlly(game, frame), 'h');
|
|
frame = decideCoalitionWormProtection(game, frame, 'f', frame.event, 'h', true);
|
|
frame = resolveCoalitionWormProtection(game, frame, 'f', frame.event, 'h', true);
|
|
assert.deepEqual(protectedCoalitionWormAllies(game, frame), ['a']);
|
|
assert.deepEqual(frame.outcomes.map(row => row.outcome), ['allowed','canceled']);
|
|
});
|
|
|
|
void test('Advanced worm draw applies allowed and canceled group protections before native casualties', () => {
|
|
// Controlled supplement to the staged group source: move the four original
|
|
// forces from reserves into the same exposed sector, and position original
|
|
// Spice Cards as the prior territory and the next worm. All decisions,
|
|
// Karama consumption, resolution and resulting casualties are engine actions.
|
|
let game = source();
|
|
Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], active:null,
|
|
response:null, decision:null, phaseOpening:null, stormPending:null});
|
|
delete game.revivalOrder;
|
|
for (const player of game.players) {
|
|
player.forces = {'red_chasm:7':3}; player.reserves = 17; player.tanks = 0;
|
|
}
|
|
const additional = game.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(additional >= 0);
|
|
seat(game, 'g').hand.push(game.deck.splice(additional, 1)[0]);
|
|
const cards = spiceDeck();
|
|
const land = cards.find(card => 'territory' in card && card.territory === 'red_chasm');
|
|
const worm = cards.find(card => 'worm' in card); assert.ok(land && worm);
|
|
game.spiceDiscard = [[land], []];
|
|
game.spiceDeck = [worm, ...cards.filter(card => card !== land && card !== worm && 'territory' in card),
|
|
...cards.filter(card => card !== worm && 'worm' in card)];
|
|
game.spice = {'red_chasm:7':8};
|
|
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
|
assert.equal(game.decision?.kind, 'wormProtection');
|
|
assert.deepEqual(game.coalitionWormProtection?.order, ['a','h']);
|
|
assert.equal(game.decision.ally, 'a');
|
|
game = act(game, 'f', {type:'decision', accept:true, event:game.decision.event, ally:'a'});
|
|
assert.equal(game.response?.kind, 'wormAllyProtection');
|
|
game = pass(game);
|
|
assert.equal(game.decision?.kind, 'wormProtection');
|
|
assert.equal(game.decision.ally, 'h');
|
|
game = act(game, 'f', {type:'decision', accept:true, event:game.decision.event, ally:'h'});
|
|
assert.equal(game.response?.kind, 'wormAllyProtection');
|
|
const counter = seat(game, 'g').hand.find(card => card.effect === 'karama'); assert.ok(counter);
|
|
game = act(game, 'g', {type:'card', mode:'cancel', card:counter.id});
|
|
assert.equal(game.response?.kind, 'wormSurvival');
|
|
assert.deepEqual(game.coalitionWormProtection?.outcomes,
|
|
[{ally:'a', outcome:'allowed'}, {ally:'h', outcome:'canceled'}]);
|
|
game = pass(game);
|
|
for (const id of ['f','a']) assert.equal(seat(game, id).forces['red_chasm:7'], 3);
|
|
for (const id of ['h','g']) {
|
|
assert.equal(seat(game, id).forces['red_chasm:7'] ?? 0, 0);
|
|
assert.equal(seat(game, id).tanks, 3);
|
|
}
|
|
assert.equal(game.spice['red_chasm:7'], undefined);
|
|
assert.equal(game.discard.filter(card => card.id === counter.id).length, 1);
|
|
assert.equal(game.coalitionWormProtection, undefined);
|
|
for (const player of game.players)
|
|
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
|
|
});
|
|
|
|
void test('Advanced Fremen grants each group ally independently while native Revival remains ordered', () => {
|
|
let game = source();
|
|
const beforeOrder = structuredClone(game.revivalOrder);
|
|
assert.deepEqual(projectFremenGroupRevivalGrant(game, 'f')!.map(row => row.recipient), ['a','h']);
|
|
for (const target of ['a','h']) {
|
|
assert.deepEqual(quoteFremenRevivalGrant(game, 'f', target), {turn:2, owner:'f', recipient:target});
|
|
const pending = act(game, 'f', {type:'grantRevival', target});
|
|
assert.equal(pending.response?.kind, 'fremenRevivalGrant');
|
|
assert.equal(pending.response?.recipient, target);
|
|
game = pass(pending);
|
|
assert.ok(game.freeRevival.includes(target));
|
|
assert.deepEqual(game.revivalOrder, beforeOrder);
|
|
}
|
|
game = act(game, 'f', {type:'ready'});
|
|
assert.equal(game.revivalOrder?.remaining[0], 'a');
|
|
for (const target of ['a','h']) {
|
|
assert.equal(game.revivalOrder?.remaining[0], target);
|
|
assert.equal(viewGame(game, target).revival.freeRemaining, 3);
|
|
const original = seat(game, target).spice;
|
|
game = pass(act(game, target, {type:'revive', amount:3, elite:0}));
|
|
assert.equal(seat(game, target).spice, original);
|
|
assert.equal(seat(game, target).tanks, 1);
|
|
assert.equal(seat(game, target).reserves, 19);
|
|
game = act(game, target, {type:'ready'});
|
|
}
|
|
});
|
|
|
|
void test('Advanced group faction extensions retain the existing expansion and module fences', () => {
|
|
const sourceGame = source();
|
|
for (const patch of [{expansions:['ix']}, {homeworlds:{}}, {nexusCards:{}}, {leaderSkills:{}},
|
|
{techTokens:{}}, {strongholdCards:{}}, {ecazTreachery:true}, {discoveryEnabled:true}]) {
|
|
const game = {...structuredClone(sourceGame), ...patch} as Game;
|
|
assert.equal(coalitionBattleBeneficiary(game, 'a', 'f', 'g'), null);
|
|
assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null);
|
|
game.phase = 1;
|
|
seat(game, 'a').forces = {'red_chasm:7':1};
|
|
assert.equal(createCoalitionWormProtection(game, 'f', 'red_chasm', 'worm'), null);
|
|
}
|
|
});
|