Files
Dune/tests/advanced-source-engine.test.ts

260 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction, viewGame, type Game} from '../game/engine';
import {splitLocation, territory} from '../game/board';
import {stormCardDistance} from '../game/storm-cards';
import {createAdvancedSourceFixture, advanceToForecast, advanceToNextStorm, settleAdvancedSourceResponses} from './fixture-advanced-source';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function confirmStorm(state: Game): Game {
let game = state;
for (const player of state.players) game = applyAction(game, player.id, {type: 'ready'});
return game;
}
function receiptFaces(game: Game): number[] {
return game.log.flatMap(entry => {
const face = stormCardDistance(entry.component);
return face === null ? [] : [face];
});
}
function group(game: Game, id: string, where: string[]) {
const player = game.players.find(p => p.id === id)!;
const amount = Object.entries(player.forces).filter(([key]) => where.includes(splitLocation(key).territory))
.reduce((sum, [, n]) => sum + n, 0);
const elite = Object.entries(player.elites?.forces ?? {}).filter(([key]) => where.includes(splitLocation(key).territory))
.reduce((sum, [, n]) => sum + n, 0);
return {amount, elite, tanks: player.tanks, eliteTanks: player.elites?.tanks ?? 0};
}
function custody(game: Game, id: string) {
const player = game.players.find(p => p.id === id)!;
return {forces: player.forces, reserves: player.reserves, tanks: player.tanks,
elites: player.elites ?? null, advisors: player.advisors ?? null,
noField: player.noField ?? null, hand: player.hand};
}
void test('actual first Advanced storm keeps two native 0..20 wheels and commits once', () => {
let {game} = createAdvancedSourceFixture();
assert.equal(game.turn, 1);
assert.equal(game.stormDialers.length, 2);
assert.equal(game.stormPending, null);
const [first, second] = game.stormDialers;
game = applyAction(game, first, {type: 'stormDial', amount: 20});
const checkpoint = JSON.stringify(game);
assert.throws(() => applyAction(game, first, {type: 'stormDial', amount: 0}), /locked/);
assert.equal(JSON.stringify(game), checkpoint);
game = applyAction(reload(game), second, {type: 'stormDial', amount: 20});
assert.equal(game.stormPending, 40);
assert.equal(game.stormMovementSource?.kind, 'dials');
game = confirmStorm(game);
assert.equal(game.phase, 1);
assert.equal(game.storm, 5);
assert.equal(game.stormMovementSource, undefined);
assert.deepEqual(receiptFaces(game), []);
});
void test('every subsequent Advanced opening uses one canonical public card without granting a Fremen forecast', () => {
let {game} = createAdvancedSourceFixture({storm: 'next'});
assert.equal(game.turn, 2);
assert.deepEqual(game.stormDialers, []);
const face = game.stormPending!;
assert.ok(face >= 1 && face <= 6);
assert.equal(game.stormMovementSource?.kind, 'card');
assert.deepEqual(receiptFaces(game), [face]);
const before = JSON.stringify(game);
for (const p of game.players) {
const view = viewGame(game, p.id);
assert.equal(view.stormForecast, null);
assert.equal(view.stormPending, face);
assert.equal(view.stormRevealed, null);
}
assert.equal(JSON.stringify(game), before);
const first = game.players[0].id;
assert.throws(() => applyAction(game, first, {type: 'stormDial', amount: 1}), /not dialing/);
game = applyAction(reload(game), first, {type: 'ready'});
const savedSource = game.stormMovementSource;
assert.deepEqual(receiptFaces(game), [face]);
for (const p of game.players.slice(1)) game = applyAction(game, p.id, {type: 'ready'});
assert.equal(game.phase, 1);
assert.equal(game.storm, 1 + face);
assert.deepEqual(receiptFaces(game), [face]);
assert.equal(savedSource?.distance, face);
game = advanceToNextStorm(game);
assert.equal(game.turn, 3);
assert.deepEqual(game.stormDialers, []);
assert.equal(receiptFaces(game).length, 2);
});
void test('native forecast remains private and the held physical face is the next actual reveal, including cancellation', () => {
for (const cancel of [false, true]) {
const fixture = createAdvancedSourceFixture({fremen: true, karama: true});
let game = advanceToForecast(fixture.game);
const fremen = game.players.find(p => p.faction === 'fremen')!;
const face = game.stormCard!;
const counter = game.players.find(p => p.faction !== 'fremen' && p.hand.some(c => c.effect === 'karama'))!;
assert.ok(counter, 'the native deal supplies a genuine counter even when it will pass');
const card = counter.hand.find(c => c.effect === 'karama')!;
assert.equal(game.response?.kind, 'stormPeek');
assert.ok(viewGame(game, counter.id).responseControls?.cancelCards.includes(card.id));
const forces = structuredClone(game.players.map(p => ({id: p.id, custody: custody(game, p.id)})));
assert.equal(viewGame(game, fremen.id).stormForecast, null);
if (cancel) {
game = applyAction(game, counter.id, {type: 'card', card: card.id, mode: 'cancel'});
} else game = settleAdvancedSourceResponses(game);
for (const before of forces) {
const after = custody(game, before.id);
assert.deepEqual({...after, hand: undefined}, {...before.custody, hand: undefined});
}
assert.equal(game.players.find(p => p.id === counter.id)!.hand.some(c => c.id === card.id), !cancel);
assert.equal(game.discard.filter(c => c.id === card.id).length, cancel ? 1 : 0);
for (const p of game.players)
assert.equal(viewGame(game, p.id).stormForecast, !cancel && p.id === fremen.id ? face : null);
assert.equal(game.stormCard, face);
assert.deepEqual(receiptFaces(game), []);
game = advanceToNextStorm(reload(game));
assert.equal(game.stormPending, face);
assert.deepEqual(receiptFaces(game), [face]);
assert.equal(game.stormCard, null);
}
const automatic = createAdvancedSourceFixture({fremen: true, karama: false});
let game = advanceToForecast(automatic.game);
assert.equal(game.response, null, 'a native forecast without an eligible counter resolves automatically');
assert.ok(game.stormCardKnown);
const face = game.stormCard!;
assert.ok(face >= 1 && face <= 6);
for (const player of game.players) {
assert.equal(player.hand.some(card => card.effect === 'karama'), false);
assert.equal(viewGame(game, player.id).stormForecast, player.faction === 'fremen' ? face : null);
}
assert.deepEqual(receiptFaces(game), []);
game = advanceToNextStorm(reload(game));
assert.equal(game.stormPending, face);
assert.deepEqual(receiptFaces(game), [face]);
assert.equal(game.stormCard, null);
});
void test('Basic next-turn wheels retain their native 1..3 bounds and never become the Advanced Deck', () => {
let {game} = createAdvancedSourceFixture({advanced: false, storm: 'next'});
assert.equal(game.stormDialers.length, 2);
assert.equal(game.stormPending, null);
assert.equal(game.stormCard, null);
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, game.stormDialers[0], {type: 'stormDial', amount: 0}), /Storm dial/);
assert.equal(JSON.stringify(game), before);
for (const id of game.stormDialers) game = applyAction(game, id, {type: 'stormDial', amount: 3});
assert.equal(game.stormPending, 6);
assert.equal(game.stormMovementSource?.kind, 'dials');
assert.deepEqual(receiptFaces(game), []);
});
void test('corrupt or orphaned card source cannot mutate a saved native pending Storm', () => {
const original = createAdvancedSourceFixture({storm: 'next'}).game;
for (const corrupt of [
(game: Game) => {game.stormMovementSource!.signature = 'foreign';},
(game: Game) => {delete game.stormMovementSource;},
(game: Game) => {game.stormPending = game.stormPending === 6 ? 5 : 6;},
(game: Game) => {game.stormMovementSource!.turn++;},
]) {
const game = reload(original);
corrupt(game);
const before = JSON.stringify(game);
assert.throws(() => viewGame(game, game.players[0].id), /source|storm/i);
assert.throws(() => applyAction(game, game.players[0].id, {type: 'ready'}), /source|storm/i);
assert.equal(JSON.stringify(game), before);
}
});
void test('Advanced ends own exact normal and elite custody now, even with the new ally still to act', () => {
for (const alliance of ['firstEnding', 'laterEnding'] as const) {
const fixture = createAdvancedSourceFixture({alliance});
const {actor, ally, lossTerritories} = fixture;
let game = fixture.game;
assert.ok(game.movementRemaining!.includes(ally!));
assert.equal(game.players.find(p => p.id === actor)!.allySinceTurn, game.turn);
assert.equal(game.players.find(p => p.id === ally)!.allySinceTurn, game.turn);
if (alliance === 'laterEnding') assert.ok(game.order.indexOf(actor) > 0);
const own = group(game, actor, lossTerritories);
assert.equal(own.amount, 2);
assert.equal(own.elite, 1);
const ownCustody = structuredClone(custody(game, actor));
const other = structuredClone(custody(game, ally!));
const immutable = JSON.stringify(game);
assert.deepEqual(viewGame(game, actor).advancedAllySeparation,
{territories: lossTerritories.map(id => territory(id).name)});
assert.equal(viewGame(game, ally!).advancedAllySeparation, null);
assert.equal(JSON.stringify(game), immutable);
game = applyAction(reload(game), actor, {type: 'endMovement'});
const after = group(game, actor, lossTerritories);
assert.deepEqual(after, {amount: 0, elite: 0, tanks: own.tanks + own.amount, eliteTanks: own.eliteTanks + own.elite});
const endedCustody = custody(game, actor);
assert.equal(endedCustody.reserves, ownCustody.reserves);
assert.equal(endedCustody.elites?.reserves, ownCustody.elites?.reserves);
assert.deepEqual(endedCustody.advisors, ownCustody.advisors);
assert.deepEqual(endedCustody.noField, ownCustody.noField);
assert.deepEqual(endedCustody.hand, ownCustody.hand);
assert.deepEqual(custody(game, ally!), other);
assert.ok(!game.movementRemaining?.includes(actor));
const committed = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, {type: 'endMovement'}), /movement turn/);
assert.equal(JSON.stringify(game), committed);
}
});
void test('Basic preserves newly allied overlap and only the later old ally loses its own group', () => {
const fresh = createAdvancedSourceFixture({advanced: false, alliance: 'newThisTurn'});
const before = structuredClone(custody(fresh.game, fresh.actor));
const after = applyAction(fresh.game, fresh.actor, {type: 'endMovement'});
assert.deepEqual(custody(after, fresh.actor), before);
assert.equal(viewGame(fresh.game, fresh.actor).advancedAllySeparation, null);
const old = createAdvancedSourceFixture({advanced: false, alliance: 'laterEnding'});
assert.equal(old.turn, 3);
assert.ok(!old.game.movementRemaining?.includes(old.ally!));
const own = group(old.game, old.actor, old.lossTerritories);
const ally = structuredClone(custody(old.game, old.ally!));
const ended = applyAction(old.game, old.actor, {type: 'endMovement'});
assert.equal(group(ended, old.actor, old.lossTerritories).tanks, own.tanks + own.amount);
assert.equal(group(ended, old.actor, old.lossTerritories).amount, 0);
assert.deepEqual(custody(ended, old.ally!), ally);
});
void test('native advisor stance exempts both directions; Ecaz and Polar custody also survives end', () => {
for (const alliance of ['ownAdvisors', 'allyAdvisors', 'ecaz', 'polar'] as const) {
const fixture = createAdvancedSourceFixture({alliance});
const {game, actor, ally} = fixture;
const where = alliance === 'ecaz' ? ['imperial_basin'] : alliance === 'polar' ? ['polar_sink'] : ['arrakeen'];
const own = group(game, actor, where);
assert.ok(own.amount > 0);
assert.ok(group(game, ally!, where).amount > 0);
assert.equal(viewGame(game, actor).advancedAllySeparation, null);
const ownCustody = structuredClone(custody(game, actor));
const other = structuredClone(custody(game, ally!));
const ended = applyAction(game, actor, {type: 'endMovement'});
assert.deepEqual(group(ended, actor, where), own);
assert.deepEqual(custody(ended, actor), ownCustody);
assert.deepEqual(custody(ended, ally!), other);
}
});
void test('actual dealt Hajr retires once and the final native end still applies exactly one owned loss', () => {
const fixture = createAdvancedSourceFixture({alliance: 'firstEnding', hajr: true});
let game = fixture.game;
const actor = fixture.actor;
const card = game.players.find(p => p.id === actor)!.hand.find(c => c.effect === 'hajr')!;
const own = group(game, actor, fixture.lossTerritories);
// Advanced Hajr follows a completed movement. Earn that native window with
// one ordinary counter; the remaining shared group still has a real loss.
game = settleAdvancedSourceResponses(applyAction(game,actor,{type:'move',
from:'imperial_basin:9',amount:1,territory:'shield_wall',sector:9,elite:0}));
game = applyAction(game, actor, {type: 'card', card: card.id});
const remaining = group(game,actor,fixture.lossTerritories);
assert.equal(remaining.amount,own.amount-1);
assert.ok(remaining.amount>0);
assert.equal(remaining.tanks,own.tanks);
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
assert.ok(game.hajr.includes(actor));
game = applyAction(reload(game), actor, {type: 'endMovement'});
assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + remaining.amount);
assert.equal(game.players.find(p=>p.id===actor)!.forces['shield_wall:9'],1);
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
});