Files
Dune/tests/fremen-movement-review.test.ts
T
2026-09-08 07:37:52 +10:00

292 lines
9.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
TERRITORIES,
gameDistance,
location,
splitLocation,
} from '../game/board';
const karamas = baseDeck().filter((c) => c.effect === 'karama');
const source = 'pasty_mesa:5';
function fixture() {
const g = createGame(
'FREMENREVIEW',
newPlayer('f', 'Fremen', 'fremen'),
true,
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('c', 'CHOAM', 'choam'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'f',
order: ['f', 'e', 'c'],
movementRemaining: ['f', 'e', 'c'],
});
for (const p of g.players)
Object.assign(p, {
forces: {},
reserves: 20,
spice: 10,
hand: [],
shipped: true,
});
g.players[0].forces = { [source]: 4 };
g.players[0].reserves = 16;
g.players[0].elites = {
forces: { [source]: 1 },
reserves: 2,
tanks: 0,
revived: 0,
};
g.players[1].hand = [karamas[0]];
return g;
}
function destination(g: Game, range: number, origins = [source]) {
const found = TERRITORIES.filter((t) => t.type === 'sand')
.flatMap((t) =>
t.sectors.filter((s) => s !== 18).map((s) => location(t.id, s)),
)
.find((to) =>
origins.every(
(from) =>
gameDistance(g, from, to, (k) => splitLocation(k).sector === 18) ===
range,
),
);
assert.ok(found);
return splitLocation(found);
}
function move(g: Game, range = 2, extra: Partial<Action> = {}) {
return applyAction(g, 'f', {
type: 'move',
from: source,
amount: 2,
...destination(g, range),
...extra,
});
}
const restore = (g: Game): Game => JSON.parse(JSON.stringify(g));
const cancel = (g: Game) =>
applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karamas[0].id });
void test('multi-sector normal/elite groups retain exact custody through Fremen allowance', () => {
const g = fixture(),
f = g.players[0];
f.forces = { [source]: 3, 'pasty_mesa:6': 3 };
f.reserves = 14;
f.elites = {
forces: { [source]: 1, 'pasty_mesa:6': 1 },
reserves: 1,
tanks: 0,
revived: 0,
};
const to = destination(g, 2, [source, 'pasty_mesa:6']);
const pending = applyAction(g, 'f', {
type: 'move',
forces: { [source]: 2, 'pasty_mesa:6': 2 },
eliteForces: { [source]: 1, 'pasty_mesa:6': 1 },
...to,
});
assert.equal(pending.response?.kind, 'fremenMovement');
assert.deepEqual(pending.players[0].forces, f.forces);
const done = applyAction(restore(pending), 'e', { type: 'passResponse' });
assert.deepEqual(done.players[0].forces, {
[source]: 1,
'pasty_mesa:6': 1,
[location(to.territory, to.sector)]: 4,
});
assert.deepEqual(done.players[0].elites!.forces, {
[location(to.territory, to.sector)]: 2,
});
assert.equal(done.players[0].elites!.reserves, 1);
assert.equal(done.players[0].reserves, 14);
assert.equal(done.players[0].moved, 1);
});
void test('restored mismatched group metadata is rejected by explicit and automatic Fremen continuations without mutations', () => {
const initial = move(fixture());
const corruptions: ((g: Game) => void)[] = [
(g) => {
g.pendingFremenMove!.move++;
},
(g) => {
g.pendingFremenMove!.order.total++;
g.response!.amount = Number(g.response!.amount) + 1;
},
(g) => {
g.pendingFremenMove!.order.elite++;
},
(g) => {
g.pendingFremenMove!.order.group.push([
...g.pendingFremenMove!.order.group[0],
]);
},
(g) => {
g.response!.location = 'carthag:11';
},
(g) => {
g.pendingFremenMove!.order.origin = 'red_chasm';
},
];
for (const corrupt of corruptions) {
const g = restore(initial);
corrupt(g);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'e', { type: 'passResponse' }));
assert.deepEqual(g, before);
g.response!.passed = g.players.map((p) => p.id);
const automaticBefore = structuredClone(g);
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, automaticBefore);
}
});
void test('canceling the second distinct Ornithopter group preserves completed first-group custody and forbids reusing arrivals', () => {
const g = fixture();
const card = richeseCards().find((c) => c.effect === 'ornithopter')!;
g.players[0].hand = [card];
const first = move(g, 1, {
movementCard: card.id,
ornithopter: 'twoGroups',
elite: 1,
});
assert.equal(first.players[0].moved, 1);
assert.equal(first.ornithopter!.completed, 1);
const event = first.ornithopter!.event;
const second = move(first, 2, { ornithopterEvent: event, elite: 0 });
assert.equal(second.pendingFremenMove!.move, 1);
const denied = cancel(restore(second));
assert.deepEqual(denied.players[0].forces, first.players[0].forces);
assert.deepEqual(denied.players[0].elites, first.players[0].elites);
assert.deepEqual(denied.ornithopter!.cohort, first.ornithopter!.cohort);
assert.equal(denied.ornithopter!.completed, 1);
assert.equal(denied.players[0].moved, 1);
assert.deepEqual(denied.players[0].fremenMovementBlocked, {
turn: 2,
move: 1,
});
const arrived = destination(g, 1);
assert.throws(
() =>
applyAction(denied, 'f', {
type: 'move',
from: location(arrived.territory, arrived.sector),
amount: 2,
territory: 'pasty_mesa',
sector: 5,
ornithopterEvent: event,
elite: 1,
}),
/original unmoved cohort/,
);
const done = move(restore(denied), 1, { ornithopterEvent: event, elite: 0 });
assert.equal(done.players[0].moved, 2);
assert.equal(done.ornithopter, null);
assert.equal(
done.players[0].forces[location(arrived.territory, arrived.sector)],
4,
);
assert.equal(
done.players[0].elites!.forces[location(arrived.territory, arrived.sector)],
1,
);
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
});
void test('legal city access gained by an unspent allied shipment remains independent after Fremen range cancellation', () => {
const g = fixture();
const guild = newPlayer('g', 'Guild', 'guild');
guild.ally = 'f';
guild.spice = 10;
g.players.push(guild);
g.order.push('g');
g.movementRemaining!.push('g');
g.players[0].ally = 'g';
g.players[0].shipped = false;
const denied = cancel(move(g));
assert.equal(denied.players[0].shipped, false);
assert.equal(denied.players[0].moved, 0);
const access = applyAction(restore(denied), 'f', {
type: 'guildShip',
from: 'reserves',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
assert.equal(access.players[0].forces['arrakeen:10'], 1);
assert.equal(access.players[0].moved, 0);
assert.equal(viewGame(access, 'f').players[0].fremenMovementBlocked, true);
const done = move(access, 3, { elite: 1 });
assert.equal(done.players[0].moved, 1);
assert.equal(done.pendingFremenMove, null);
assert.equal(done.response, null);
});
void test('Fremen allowance feeds the exact group into CHOAM Baliset and cancellation resumes it once', () => {
for (const block of [false, true]) {
const g = fixture(),
to = destination(g, 2),
key = location(to.territory, to.sector);
const baliset = baseDeck().find((c) => c.name === 'Baliset')!;
g.players[2].forces = { [key]: 1 };
g.players[2].reserves = 19;
g.players[2].hand = [baliset];
g.players[0].hand = [karamas[1]];
const pending = move(g, 2, { elite: 1 });
const choam = applyAction(restore(pending), 'e', { type: 'passResponse' });
assert.equal(choam.pendingFremenMove, null);
assert.equal(choam.decision?.kind, 'choamMovement');
assert.deepEqual(choam.players[0].forces, g.players[0].forces);
assert.equal(choam.players[0].moved, 0);
assert.equal('pendingChoamMove' in viewGame(choam, 'c'), false);
const requested = applyAction(choam, 'c', {
type: 'card',
mode: 'choam',
card: baliset.id,
target: 'f',
territory: to.territory,
});
assert.equal(requested.response?.kind, 'choamWorthless');
let done: Game;
if (block) {
done = applyAction(requested, 'f', { type: 'passResponse' });
if (done.response)
done = applyAction(done, 'e', { type: 'passResponse' });
assert.equal(done.players[0].moved, 0);
assert.deepEqual(done.players[0].forces, g.players[0].forces);
assert.equal(done.players[2].hand.length, 0);
} else {
done = applyAction(restore(requested), 'f', {
type: 'card',
mode: 'cancel',
card: karamas[1].id,
});
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].forces[key], 2);
assert.equal(done.players[0].elites!.forces[key], 1);
assert.equal(done.players[2].hand[0].id, baliset.id);
}
assert.equal(done.pendingChoamMove, null);
assert.equal(done.pendingFremenMove, null);
assert.equal(done.players[0].fremenMovementBlocked, undefined);
assert.deepEqual(normalizeAutomaticGame(restore(done)), done);
}
});