Files
Dune/tests/ix-skills-movement.test.ts
T

447 lines
20 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { GRAPH, TERRITORIES, gameDistance, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { botActions } from '../game/bots';
import { botMovementRange } from '../game/bot-mobility';
import { DIFFICULTIES } from '../game/bot-profiles';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { PlanetologistMovement, planetologistMoveDraft } from '../components/planetologist-movement';
import { SandmasterMovement, sandmasterMoveDraft } from '../components/sandmaster-movement';
import { sandmasterRouteDistance } from '../game/sandmaster-movement';
import { createTechTokens } from '../game/tech-tokens';
import {
assertIxSkillsCustody as custody,
completedIxSkillsGame,
initializedIxSkillsOffers,
ixSkillsPlayer as player,
rejectIxSkillsAction as reject,
reloadIxSkillsGame as reload,
} from './ix-skills-fixture';
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
const sand = TERRITORIES.filter(t => t.type === 'sand')
.flatMap(t => t.sectors.filter(s => s !== 18).map(s => location(t.id, s)));
const blocked = (key: string) => splitLocation(key).sector === 18;
const distance = (g: Game, from: string, to: string) => gameDistance(g, from, to, blocked);
function stable(g: Game) {
custody(g);
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
}
/** Conserved movement scenario after the complete real Ix setup and Storm placement. */
function movement(skill: 'planetologist' | 'sandmaster' = 'planetologist', heldKarama = false): Game {
const g = completedIxSkillsGame({requestedSkill: skill});
for (const p of g.players) {
g.deck.push(...p.hand);
p.hand = [];
}
if (heldKarama) {
const index = g.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
player(g, 'e').hand.push(...g.deck.splice(index, 1));
}
Object.assign(g, {
phase: 5, phaseOpening: null, active: 'i', storm: 18, decision: null,
response: null, ready: [], order: ['i', 'e', 't'], movementRemaining: ['i', 'e', 't'],
});
player(g, 'i').moved = 0;
player(g, 'i').shipped = true;
return g;
}
function deploy(g: Game, forces: Record<string, number>, cyborgs: Record<string, number>) {
const ix = player(g, 'i');
ix.forces = forces;
ix.reserves = 20 - Object.values(forces).reduce((a, b) => a + b, 0);
ix.elites!.forces = cyborgs;
ix.elites!.reserves = 7 - Object.values(cyborgs).reduce((a, b) => a + b, 0);
custody(g);
return g;
}
function geometry(g: Game) {
const from = sand.find(key => [1, 2, 3].every(d => sand.some(to => distance(g, key, to) === d)));
assert.ok(from);
const target = (d: number) => {
const to = sand.find(key => distance(g, from, key) === d);
assert.ok(to);
return to;
};
return {from, target};
}
function move(from: string, to: string, elite = 1, mode: 'range' | 'gather' | undefined = 'range'): Action {
const destination = splitLocation(to);
return {type: 'move', from, amount: 3, elite, territory: destination.territory,
sector: destination.sector, ...(mode ? {planetologist: mode} : {})};
}
function allow(g: Game): Game {
for (let step = 0; g.response && step < 8; step++) {
const owner = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(owner);
g = act(g, owner.id, {type: 'passResponse'});
}
assert.equal(g.response, null);
return g;
}
function cancel(g: Game): Game {
const card = player(g, 'e').hand.find(c => c.effect === 'karama');
assert.ok(card);
return act(g, 'e', {type: 'card', card: card.id, mode: 'cancel'});
}
void test('all fourteen physical skills reach genuine Ix card selection, Traitors, Face Dancers and first Storm HMS placement', () => {
for (const card of LEADER_SKILL_CARDS) {
const g = completedIxSkillsGame({requestedSkill: card.id});
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === 'i')!.skill, card.id);
assert.equal(g.ixSetupCards, null);
assert.equal(g.mobileStronghold!.location, 'polar_sink:0');
assert.equal(player(g, 'i').reserves, 14);
assert.equal(player(g, 'i').elites!.reserves, 4);
assert.equal(player(g, 't').faceDancers!.length, 3);
assert.ok(g.players.every(p => p.hand.length === 1));
stable(g);
}
const offers = initializedIxSkillsOffers();
assert.equal(offers.setupStage, 'leaderSkills');
assert.ok(offers.players.every(p => p.traitorChoices.length === 0));
assert.equal(player(offers, 't').faceDancers, undefined);
});
void test('genuine ready Ix lobbies initialize the supported native module profiles', () => {
const lobby = () => {
let g = createGame('IXGATED', newPlayer('i', 'Ixians', 'ixians'), false, ['ix']);
joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
for (const p of g.players) g = act(g, p.id, {type: 'ready'});
return g;
};
// The supported native Advanced, Discovery, Tech Token and Homeworld
// profiles now initialize for a fresh Ix lobby without mutating it.
for (const change of [
(g: Game) => { g.advanced = true; },
(g: Game) => { g.discoveryEnabled = true; },
(g: Game) => { g.techTokens = createTechTokens(); },
(g: Game) => { g.homeworlds = {custody: null}; },
]) {
const g = lobby();
change(g);
const before = structuredClone(g);
assert.ok(initializeLeaderSkillsGameForAudit(g).leaderSkills,
'The supported native module profile initializes.');
assert.deepEqual(g, before, 'The authoritative lobby is never mutated.');
}
// A combined CHOAM expansion still rejects.
const combined = lobby();
combined.expansions.push('choam');
const before = structuredClone(combined);
assert.throws(() => initializeLeaderSkillsGameForAudit(combined));
assert.deepEqual(combined, before);
});
void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
const g = movement('planetologist', true);
const {from, target} = geometry(g);
deploy(g, {[from]: 4}, {[from]: 1});
const suboids = act(g, 'i', move(from, target(2), 0));
assert.equal(suboids.response, null);
assert.equal(player(suboids, 'i').elites!.forces[from], 1);
assert.equal(player(suboids, 'i').forces[target(2)], 3);
stable(suboids);
const pending = act(g, 'i', move(from, target(3)));
assert.equal(pending.response?.kind, 'ixMovement');
assert.equal(pending.pendingIxMove!.planetologist!.mode, 'range');
assert.equal(player(pending, 'i').moved, 0);
assert.deepEqual(player(pending, 'i').forces, player(g, 'i').forces);
stable(pending);
const settled = allow(pending);
assert.equal(player(settled, 'i').forces[target(3)], 3);
assert.equal(player(settled, 'i').elites!.forces[target(3)], 1);
assert.equal(player(settled, 'i').moved, 1);
stable(settled);
});
void test('cyborg cancellation keeps the army and unspent movement while the Planetologist range-two replacement stays legal', () => {
const g = movement('planetologist', true);
const {from, target} = geometry(g);
deploy(g, {[from]: 4}, {[from]: 1});
let next = cancel(act(g, 'i', move(from, target(3))));
assert.equal(next.pendingIxMove, null);
assert.equal(player(next, 'i').moved, 0);
assert.deepEqual(player(next, 'i').forces, player(g, 'i').forces);
assert.deepEqual(player(next, 'i').elites, player(g, 'i').elites);
reject(next, 'i', move(from, target(3)), /blocked|more than 2/);
const view = viewGame(reload(next), 'i');
const ix = view.players.find(p => p.id === 'i')!;
assert.equal(botMovementRange(view, ix, 1), 1);
assert.equal(botMovementRange(view, ix, 1, 'range'), 2);
next = act(next, 'i', move(from, target(2)));
assert.equal(player(next, 'i').moved, 1);
assert.equal(player(next, 'i').forces[target(2)], 3);
assert.equal(next.response, null);
stable(next);
});
function gatherGeometry(g: Game, secondDistance: number) {
for (const to of sand) {
const first = sand.find(from => distance(g, from, to) === 2);
if (!first) continue;
const second = sand.find(from => splitLocation(from).territory !== splitLocation(first).territory &&
splitLocation(from).territory !== splitLocation(to).territory && distance(g, from, to) === secondDistance);
if (second) return {first, second, to};
}
assert.fail('A pair of distinct sand origins must exist.');
}
function gather(first: string, second: string, to: string, secondElite = 0): Action {
const target = splitLocation(to);
return {type: 'move', planetologist: 'gather', forces: {[first]: 3, [second]: 2},
eliteForces: {[first]: 1, [second]: secondElite}, territory: target.territory, sector: target.sector};
}
void test('gather rejects lending another origin cyborg speed, even when an unselected cyborg remains at the slow origin', () => {
const g = movement('planetologist', true);
const {first, second, to} = gatherGeometry(g, 2);
deploy(g, {[first]: 3, [second]: 3}, {[first]: 1, [second]: 1});
const action = gather(first, second, to);
reject(g, 'i', action, /blocked|more than/);
const target = splitLocation(to);
const draft = planetologistMoveDraft(viewGame(g, 'i'), 'gather', target.territory, target.sector,
{[first]: 3, [second]: 2}, {[first]: 1, [second]: 0});
assert.equal(draft.action, null);
assert.match(draft.blocked!, /Each selected source/);
const pending = act(g, 'i', gather(first, second, to, 1));
assert.equal(pending.response?.kind, 'ixMovement');
assert.deepEqual(pending.pendingIxMove!.origins, [splitLocation(first).territory, splitLocation(second).territory]);
const next = allow(pending);
assert.equal(player(next, 'i').forces[to], 5);
assert.equal(player(next, 'i').elites!.forces[to], 2);
assert.equal(player(next, 'i').forces[second], 1);
stable(next);
});
void test('gather combines a cyborg origin at distance two with a suboid origin at distance one, then cancels without partial transfer', () => {
const g = movement('planetologist', true);
const {first, second, to} = gatherGeometry(g, 1);
deploy(g, {[first]: 3, [second]: 2}, {[first]: 1});
const pending = act(g, 'i', gather(first, second, to));
assert.equal(pending.response?.kind, 'ixMovement');
const canceled = cancel(pending);
assert.deepEqual(player(canceled, 'i').forces, player(g, 'i').forces);
assert.deepEqual(player(canceled, 'i').elites, player(g, 'i').elites);
assert.equal(player(canceled, 'i').moved, 0);
reject(canceled, 'i', gather(first, second, to), /blocked|more than/);
const settled = allow(pending);
assert.equal(player(settled, 'i').forces[to], 5);
assert.equal(player(settled, 'i').elites!.forces[to], 1);
stable(canceled);
stable(settled);
});
void test('selected cyborgs in another sector of the same origin carry that origin in the UI and engine', () => {
const g = movement('planetologist', true);
let selected: {keys: string[]; to: string} | undefined;
for (const t of TERRITORIES.filter(t => t.type === 'sand')) {
const keys = t.sectors.filter(s => s !== 18).slice(0, 2).map(s => location(t.id, s));
if (keys.length !== 2) continue;
const to = sand.find(to => keys.every(from => distance(g, from, to) <= 3) &&
keys.some(from => distance(g, from, to) === 3));
if (to) { selected = {keys, to}; break; }
}
assert.ok(selected);
const {keys, to} = selected;
deploy(g, {[keys[0]]: 2, [keys[1]]: 2}, {[keys[1]]: 1});
const target = splitLocation(to);
const view = viewGame(g, 'i');
const draft = planetologistMoveDraft(view, 'range', target.territory, target.sector,
{[keys[0]]: 2, [keys[1]]: 2}, {[keys[0]]: 0, [keys[1]]: 1});
assert.equal(draft.blocked, null);
assert.ok(draft.action);
const pending = act(g, 'i', draft.action);
assert.equal(pending.response?.kind, 'ixMovement');
const next = allow(pending);
assert.equal(player(next, 'i').forces[to], 4);
assert.equal(player(next, 'i').elites!.forces[to], 1);
const html = renderToStaticMarkup(createElement(PlanetologistMovement, {
game: view, act: () => {}, busy: false, destination: target.territory, sector: target.sector,
}));
assert.match(html, /Each origin uses its own selected cyborgs/);
stable(next);
});
void test('city ornithopters give the Ix group range three without exposing an unnecessary cyborg response', () => {
const g = movement('planetologist', true);
const {from, target} = geometry(g);
deploy(g, {[from]: 4, 'arrakeen:10': 1}, {[from]: 1});
const next = act(g, 'i', move(from, target(3), 0));
assert.equal(next.response, null);
assert.equal(player(next, 'i').elites!.forces[from], 1);
assert.equal(player(next, 'i').forces[target(3)], 3);
stable(next);
});
void test('Ix Sandmaster chosen detours use the cyborg response even when the shortest destination route is one territory', () => {
const g = movement('sandmaster', true);
let selected: string[] | undefined;
for (const from of sand) {
for (const through of GRAPH[from]) {
if (!sand.includes(through) || splitLocation(through).territory === splitLocation(from).territory) continue;
const to = GRAPH[through].find(to => sand.includes(to) && distance(g, from, to) === 1 &&
mobileRouteDistance([from, through, to]) === 2);
if (to) { selected = [from, through, to]; break; }
}
if (selected) break;
}
assert.ok(selected);
const [from, through, to] = selected;
deploy(g, {[from]: 4}, {[from]: 1});
g.spice[through] = 3;
const action: Action = {...move(from, to, 1, undefined), sandmaster: {routes: {[from]: selected}, collect: [through]}};
// Explicitly omit Planetologist: this is the independently assigned Sandmaster card.
delete action.planetologist;
const pending = act(g, 'i', action);
assert.equal(pending.response?.kind, 'ixMovement');
assert.equal(pending.spice[through], 3);
const canceled = cancel(pending);
assert.equal(canceled.spice[through], 3);
assert.equal(player(canceled, 'i').moved, 0);
reject(canceled, 'i', action, /range|route|Sandmaster/);
const next = allow(pending);
assert.equal(next.spice[through], 2);
assert.equal(player(next, 'i').spice, player(g, 'i').spice + 1);
assert.equal(player(next, 'i').forces[to], 3);
stable(canceled);
stable(next);
});
void test('all bot levels produce legal Ix Planetologist routes from genuine custody without sharing cyborg speed across origins', () => {
const g = movement();
const {first, second, to} = gatherGeometry(g, 2);
deploy(g, {[first]: 5, [second]: 5}, {[first]: 2});
g.spice[to] = 12;
let skills = 0;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(g), 'i');
view.players.find(p => p.id === 'i')!.bot = difficulty;
const before = structuredClone(view);
const candidates = botActions(view).filter(a => a.type === 'move');
assert.deepEqual(view, before);
assert.ok(candidates.length > 0, difficulty);
for (const action of candidates) {
if (action.planetologist) skills++;
const next = act(g, 'i', action);
custody(next);
}
}
assert.ok(skills > 0, 'At least one real skill movement must be exercised.');
});
void test('saved Ix allowance revalidates each origin and trainer while cancellation only needs its original declaration', () => {
const g = movement('planetologist', true);
const {first, second, to} = gatherGeometry(g, 2);
deploy(g, {[first]: 3, [second]: 3}, {[first]: 1, [second]: 1});
const pending = act(g, 'i', gather(first, second, to, 1));
const changedSource = reload(pending);
player(changedSource, 'i').elites!.forces[second] = 0;
player(changedSource, 'i').elites!.reserves++;
reject(changedSource, 'e', {type: 'passResponse'});
const canceled = cancel(changedSource);
assert.equal(canceled.pendingIxMove, null);
assert.equal(player(canceled, 'i').moved, 0);
assert.deepEqual(player(canceled, 'i').forces, player(changedSource, 'i').forces);
assert.deepEqual(player(canceled, 'i').elites, player(changedSource, 'i').elites);
stable(canceled);
for (const mutate of [
(saved: Game) => { player(saved, 'i').leaders[0].capturedBy = 't'; },
(saved: Game) => { saved.pendingIxMove!.origins!.reverse(); },
]) {
const saved = reload(pending);
mutate(saved);
const card = player(saved, 'e').hand.find(c => c.effect === 'karama')!;
reject(saved, 'e', {type: 'passResponse'});
reject(saved, 'e', {type: 'card', card: card.id, mode: 'cancel'});
}
});
void test('Sandmaster ordinary HMS entry and exit use the current pointer in engine, controls and saved routes', () => {
const pointer = 'wind_pass:14';
const g = movement('sandmaster');
g.mobileStronghold!.location = pointer; // Explicit conserved board position after genuine placement.
g.spice[pointer] = 3;
const exit: Action = {type: 'move', from: MOBILE_LOCATION, amount: 3, elite: 1,
territory: 'wind_pass', sector: 14};
const view = viewGame(g, 'i');
const draft = sandmasterMoveDraft(view, exit);
assert.equal(draft.blocked, null);
assert.ok(draft.action);
assert.deepEqual(draft.choice!.routes[MOBILE_LOCATION], [MOBILE_LOCATION, pointer]);
assert.equal(sandmasterRouteDistance(g, draft.choice!.routes[MOBILE_LOCATION]), 1);
const html = renderToStaticMarkup(createElement(SandmasterMovement, {
game: view, move: exit, act: () => {}, busy: false,
}));
assert.match(html, /Hidden Mobile Stronghold/);
const next = act(g, 'i', draft.action);
assert.equal(next.mobileStronghold!.location, pointer);
assert.equal(player(next, 'i').forces[MOBILE_LOCATION], 3);
assert.equal(player(next, 'i').forces[pointer], 3);
assert.equal(next.spice[pointer], 2);
stable(next);
const entering = movement('sandmaster');
entering.mobileStronghold!.location = pointer;
deploy(entering, {[pointer]: 4, [MOBILE_LOCATION]: 2}, {[pointer]: 1, [MOBILE_LOCATION]: 2});
const enter: Action = {type: 'move', from: pointer, amount: 3, elite: 1,
territory: MOBILE_STRONGHOLD, sector: 0};
const entryDraft = sandmasterMoveDraft(viewGame(entering, 'i'), enter);
assert.equal(entryDraft.blocked, null);
assert.ok(entryDraft.action);
assert.deepEqual(entryDraft.choice!.routes[pointer], [pointer, MOBILE_LOCATION]);
const entered = act(entering, 'i', entryDraft.action);
assert.equal(player(entered, 'i').forces[MOBILE_LOCATION], 5);
assert.equal(player(entered, 'i').elites!.forces[MOBILE_LOCATION], 3);
assert.equal(entered.mobileStronghold!.location, pointer);
stable(entered);
const storm = reload(g);
storm.storm = 14;
assert.equal(sandmasterMoveDraft(viewGame(storm, 'i'), exit).action, null);
reject(storm, 'i', draft.action);
const shifted = reload(g);
shifted.mobileStronghold!.location = 'false_wall_west:16';
reject(shifted, 'i', draft.action, /Sandmaster|route|range/);
});
void test('Sandmaster HMS endpoint extension saves a cyborg response and rejects a changed pointer before settlement', () => {
const g = movement('sandmaster', true);
const pointer = 'wind_pass:14';
g.mobileStronghold!.location = pointer;
const target = sand.find(key => distance(g, pointer, key) === 1 && distance(g, MOBILE_LOCATION, key) === 2);
assert.ok(target);
const at = splitLocation(target);
const action: Action = {type: 'move', from: MOBILE_LOCATION, amount: 3, elite: 1,
territory: at.territory, sector: at.sector};
const draft = sandmasterMoveDraft(viewGame(g, 'i'), action);
assert.ok(draft.action);
const pending = act(g, 'i', draft.action);
assert.equal(pending.response?.kind, 'ixMovement');
stable(pending);
const changed = reload(pending);
changed.mobileStronghold!.location = 'false_wall_west:16';
reject(changed, 'e', {type: 'passResponse'}, /Sandmaster|route/);
const canceled = cancel(pending);
assert.equal(player(canceled, 'i').forces[MOBILE_LOCATION], 6);
assert.equal(player(canceled, 'i').moved, 0);
const allowed = allow(pending);
assert.equal(player(allowed, 'i').forces[target], 3);
assert.equal(player(allowed, 'i').forces[MOBILE_LOCATION], 3);
assert.equal(allowed.mobileStronghold!.location, pointer);
stable(allowed);
stable(canceled);
});