Replace one unused movement entitlement by the selected card mode and retain independent Hajr movement after a completed two-group flight. Preserve distinct cohorts and later legal reuse. Adopt supplied Advanced immediate-after-movement Hajr play timing with explicit provisional online/card-group interpretation; Basic timing remains. Validation: frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes; four minimal policies retain both continuations. Actual cards are acquired by original auctions and source groups use funded shipments. Multiple Hajr cards, advisors, wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type GameView,
|
|
} from '../game/engine';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { gameDistance, splitLocation } from '../game/board';
|
|
import { validateCohortSelection } from '../game/ornithopter';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
|
|
const cardId = 'richese-ornithopter',
|
|
source = 'red_chasm:7';
|
|
function fixture(faction: FactionId = 'emperor', amount = 1) {
|
|
const g = createGame('ORNIBOT', newPlayer('p', 'Pilot', faction), true);
|
|
g.players.push(
|
|
newPlayer('o', 'Observer', faction === 'atreides' ? 'emperor' : 'atreides'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 5;
|
|
g.turn = 2;
|
|
g.active = 'p';
|
|
g.order = ['p', 'o'];
|
|
g.movementRemaining = [...g.order];
|
|
g.storm = 18;
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.forces = {};
|
|
p.spice = 10;
|
|
}
|
|
g.players[0].hand = [richeseCards().find((c) => c.effect === 'ornithopter')!];
|
|
g.players[0].forces = { [source]: amount };
|
|
g.players[0].reserves = 20 - amount;
|
|
g.players[0].shipped = true;
|
|
return g;
|
|
}
|
|
function projection(g: Game, difficulty: Difficulty) {
|
|
const v = viewGame(g, 'p');
|
|
v.players[0].bot = difficulty;
|
|
return v;
|
|
}
|
|
const cardMoves = (v: GameView) =>
|
|
botActions(v).filter(
|
|
(a) => a.type === 'move' && (a.movementCard || a.ornithopterEvent),
|
|
);
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
void test('every profile actually uses card range beyond normal access without a stronghold and discards once', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture(),
|
|
v = projection(g, difficulty),
|
|
before = structuredClone(v);
|
|
const action = botActions(v)[0];
|
|
assert.equal(action.movementCard, cardId);
|
|
assert.equal(action.ornithopter, 'range3');
|
|
const destination = `${action.territory as string}:${action.sector as number}`;
|
|
const distance = gameDistance(
|
|
g,
|
|
source,
|
|
destination,
|
|
(k) => splitLocation(k).sector === g.storm,
|
|
);
|
|
assert.ok(distance > 1 && distance <= 3);
|
|
assert.deepEqual(v, before);
|
|
const done = applyAction(g, 'p', action);
|
|
assert.equal(done.players[0].forces[destination], 1);
|
|
assert.equal(done.players[0].moved, 1);
|
|
assert.equal(done.players[0].spice, 10);
|
|
assert.equal(done.players[0].reserves, 19);
|
|
assert.equal(done.discard.filter((c) => c.id === cardId).length, 1);
|
|
assert.equal(done.ornithopter, null);
|
|
assert.equal(cardMoves(projection(reload(done), difficulty)).length, 0);
|
|
assert.throws(() => applyAction(done, 'p', action));
|
|
}
|
|
});
|
|
|
|
void test('all profiles can split one origin into two different typed groups and finish with the exact restored event', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture('emperor', 5);
|
|
g.players[0].elites = {
|
|
forces: { [source]: 3 },
|
|
reserves: 2,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
const first = cardMoves(projection(g, difficulty)).find(
|
|
(a) => a.ornithopter === 'twoGroups',
|
|
);
|
|
assert.ok(first, difficulty);
|
|
assert.ok(Number(first.amount) < 5);
|
|
const moved = applyAction(g, 'p', first),
|
|
v = projection(reload(moved), difficulty);
|
|
assert.equal(moved.ornithopter!.completed, 1);
|
|
assert.equal(moved.discard.length, 0);
|
|
const candidates = cardMoves(v);
|
|
assert.ok(candidates.length);
|
|
const cohort = v.ornithopter!.active!.cohort!;
|
|
for (const action of candidates) {
|
|
assert.equal(action.ornithopterEvent, moved.ornithopter!.event);
|
|
assert.equal(action.movementCard, undefined);
|
|
const from = String(action.from);
|
|
validateCohortSelection(
|
|
cohort,
|
|
v.players[0].forces,
|
|
v.players[0].elites!.forces,
|
|
{ [from]: Number(action.amount) },
|
|
{ [from]: Number(action.elite) },
|
|
);
|
|
assert.notEqual(
|
|
from,
|
|
`${first.territory as string}:${first.sector as number}`,
|
|
);
|
|
}
|
|
const done = applyAction(moved, 'p', candidates[0]);
|
|
assert.equal(done.players[0].moved, 2);
|
|
assert.equal(done.ornithopter, null);
|
|
assert.equal(
|
|
Object.values(done.players[0].forces).reduce((a, b) => a + b, 0),
|
|
5,
|
|
);
|
|
assert.equal(
|
|
Object.values(done.players[0].elites!.forces).reduce((a, b) => a + b, 0),
|
|
3,
|
|
);
|
|
assert.equal(done.discard.filter((c) => c.id === cardId).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('merged first-group arrivals never expand the original normal or elite quota offered to the second group', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = fixture('emperor', 5);
|
|
const first = cardMoves(projection(g, difficulty)).find(
|
|
(a) => a.ornithopter === 'twoGroups',
|
|
)!;
|
|
const destination = `${first.territory as string}:${first.sector as number}`;
|
|
g.players[0].forces[destination] = 2;
|
|
g.players[0].reserves -= 2;
|
|
const moved = applyAction(g, 'p', first),
|
|
v = projection(moved, difficulty);
|
|
const cohort = v.ornithopter!.active!.cohort!;
|
|
assert.equal(cohort.forces[destination], 2);
|
|
for (const action of cardMoves(v)) {
|
|
const from = String(action.from);
|
|
assert.ok(Number(action.amount) <= (cohort.forces[from] ?? 0));
|
|
assert.doesNotThrow(() => applyAction(moved, 'p', action));
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('concealed marker moves use separate exact events, never hidden value, and cannot be moved by both card groups', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let firstShape: unknown;
|
|
for (const value of [0, 3, 5] as const) {
|
|
const g = fixture('richese', 2),
|
|
p = g.players[0];
|
|
p.noField = createRicheseNoField(['zero', 'three', 'five']);
|
|
const selected = p.noField.tokens.find((t) => t.value === value)!;
|
|
p.noField = deployRicheseNoField(p.noField, {
|
|
tokenId: selected.id,
|
|
controller: 'p',
|
|
location: { territory: 'red_chasm', sector: 7 },
|
|
});
|
|
p.noFieldEvent = 'marker-event';
|
|
const action = cardMoves(projection(g, difficulty)).find(
|
|
(a) => a.noField && a.ornithopter === 'twoGroups',
|
|
);
|
|
assert.ok(action);
|
|
assert.equal(action.event, 'marker-event');
|
|
assert.equal(action.ornithopterEvent, undefined);
|
|
assert.deepEqual(action.forces, {});
|
|
const shape = { ...action, noField: 'opaque' };
|
|
if (firstShape) assert.deepEqual(shape, firstShape);
|
|
else firstShape = shape;
|
|
const moved = applyAction(g, 'p', action);
|
|
assert.equal(moved.ornithopter!.cohort!.noField, undefined);
|
|
const next = cardMoves(projection(reload(moved), difficulty));
|
|
assert.ok(next.length);
|
|
assert.ok(
|
|
next.every(
|
|
(a) =>
|
|
a.noField === undefined &&
|
|
a.ornithopterEvent === moved.ornithopter!.event,
|
|
),
|
|
);
|
|
const done = applyAction(moved, 'p', next[0]);
|
|
assert.equal(done.players[0].reserves, 18);
|
|
assert.ok(done.players[0].noField!.deployed);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('all profiles honor mode/ownership guards, existing interaction priority and finite no-group closure', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
for (const change of [
|
|
(g: Game) => {
|
|
g.players[0].moved = 1;
|
|
},
|
|
(g: Game) => {
|
|
g.hajr = ['p'];
|
|
g.players[0].moved = 2;
|
|
},
|
|
(g: Game) => {
|
|
g.active = 'o';
|
|
},
|
|
(g: Game) => {
|
|
g.players[0].hand = [];
|
|
},
|
|
]) {
|
|
const g = fixture();
|
|
change(g);
|
|
assert.equal(cardMoves(projection(g, difficulty)).length, 0);
|
|
}
|
|
const v = projection(fixture(), difficulty);
|
|
v.ornithopter!.blocked = 'Card committed.';
|
|
assert.equal(cardMoves(v).length, 0);
|
|
const truth = projection(fixture(), difficulty);
|
|
truth.truthtrance = {
|
|
stage: 'priority',
|
|
queue: [],
|
|
passed: [],
|
|
} as unknown as NonNullable<GameView['truthtrance']>;
|
|
assert.equal(botActions(truth)[0].type, 'truthPass');
|
|
const response = projection(fixture(), difficulty);
|
|
response.response = { kind: 'emperorIncome', owner: 'o', passed: [] };
|
|
assert.equal(cardMoves(response).length, 0);
|
|
const g = fixture('emperor', 2),
|
|
first = cardMoves(projection(g, difficulty)).find(
|
|
(a) => a.ornithopter === 'twoGroups',
|
|
)!;
|
|
const moved = applyAction(g, 'p', first),
|
|
blocked = projection(moved, difficulty);
|
|
blocked.ornithopter!.active!.cohort = { forces: {}, elites: {} };
|
|
assert.equal(botActions(blocked)[0].type, 'endMovement');
|
|
const ended = applyAction(moved, 'p', botActions(blocked)[0]);
|
|
assert.equal(ended.ornithopter, null);
|
|
assert.equal(ended.discard.filter((c) => c.id === cardId).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('Fremen and Ixian normal two-group ranges remain distinct from fixed card range without an Ix speed response', () => {
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const faction of ['fremen', 'ixians'] as const) {
|
|
const g = fixture(faction, 5);
|
|
if (faction === 'ixians')
|
|
g.players[0].elites = {
|
|
forces: { [source]: 3 },
|
|
reserves: 4,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
const first = cardMoves(projection(g, difficulty)).find(
|
|
(a) => a.ornithopter === 'twoGroups',
|
|
);
|
|
assert.ok(first);
|
|
const distance = gameDistance(
|
|
g,
|
|
source,
|
|
`${first.territory as string}:${first.sector as number}`,
|
|
(key) => splitLocation(key).sector === g.storm,
|
|
);
|
|
assert.ok(
|
|
distance <= (faction === 'fremen' || Number(first.elite) > 0 ? 2 : 1),
|
|
);
|
|
const moved = applyAction(g, 'p', first);
|
|
assert.equal(moved.players[0].moved, 1);
|
|
const continued = cardMoves(projection(moved, difficulty));
|
|
assert.ok(continued.length);
|
|
assert.doesNotThrow(() => applyAction(moved, 'p', continued[0]));
|
|
if (faction === 'ixians') {
|
|
const fixed = fixture('ixians', 1);
|
|
fixed.players[0].elites = {
|
|
forces: { [source]: 1 },
|
|
reserves: 6,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
const action = cardMoves(projection(fixed, difficulty))[0];
|
|
assert.equal(action.ornithopter, 'range3');
|
|
assert.equal(
|
|
gameDistance(
|
|
fixed,
|
|
source,
|
|
`${action.territory as string}:${action.sector as number}`,
|
|
(key) => splitLocation(key).sector === fixed.storm,
|
|
),
|
|
3,
|
|
);
|
|
const done = applyAction(fixed, 'p', action);
|
|
assert.equal(done.pendingIxMove, undefined);
|
|
assert.equal(done.response, null);
|
|
assert.equal(done.players[0].moved, 1);
|
|
}
|
|
}
|
|
});
|