Files
Dune/tests/fremen-movement-karama.test.ts
Quintine edbcbe28de Enforce Basic Truthtrance ground-movement promises
Add a structured future single-movement predicate and persistent authoring, feasible-answer and promise controls for bare native Basic active post-shipment turns. Yes requires one matching physical movement while possible; No survives every later Hajr/Ornithopter opportunity until actual turn closure. Matching physical commitment precedes disposal and arrival effects. Preserve compatible promises after opposing loss and reject voluntary destruction of the last owned completion.

Reuse the actual movement declaration and physical commitment in a focused owned-action search rather than a strategic shortlist. Retain typed groups, original two-group cohorts, held Hajr, both Ornithopter modes, city access and current Kulon bonus. Deterministic preparation on private clones retains physical cost semantics without inventing response/discard events in reachability. Existing native/optional/Advanced movement retains its actual controls.

Verify 17-file218/218, types/lint/build, seven original-action gameplay/control/table-rendering programmes and all four existing legal profiles. Original public classic, paid cache Ornithopter and native allowed-Kulon sources use only unopened positions and scalar lotteries; extra-card/force-loss edges are explicitly controlled. Repair nearby stale fixture continuations to acknowledge Semuta/battle outcomes and put pre-movement Hajr in Basic. Full modes, wider preparation/timing/module coverage, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 14:32:16 +11:00

354 lines
13 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, distance, location, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const source = 'red_chasm:7';
const karamas = baseDeck().filter((c) => c.effect === 'karama');
const worthless = baseDeck().find((c) => c.kind === 'worthless')!;
const destinations = TERRITORIES.filter((t) => t.type === 'sand').flatMap((t) =>
t.sectors.filter((s) => s !== 18).map((s) => location(t.id, s)),
);
function target(range: number, from = source) {
const result = destinations.find(
(to) =>
distance(from, to, (key) => splitLocation(key).sector === 18) === range,
);
assert.ok(result, `range ${range} from ${from}`);
return result;
}
function fixture(advanced = false): Game {
const g = createGame(
'FREMENMOVEK',
newPlayer('f', 'Fremen', 'fremen'),
advanced,
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'Sisterhood', 'beneGesserit'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'f',
order: ['f', 'e', 'b'],
movementRemaining: ['f', 'e', 'b'],
});
for (const p of g.players)
Object.assign(p, {
hand: [],
forces: {},
reserves: 20,
spice: 10,
traitors: [],
});
const f = g.players[0];
f.forces = { [source]: 4 };
f.reserves = 16;
f.shipped = true;
if (advanced)
f.elites = { forces: { [source]: 1 }, reserves: 2, tanks: 0, revived: 0 };
return g;
}
function moveAction(range = 2, from = source): Action {
const to = splitLocation(target(range, from));
return {
type: 'move',
from,
amount: 4,
territory: to.territory,
sector: to.sector,
};
}
const restore = (g: Game): Game => JSON.parse(JSON.stringify(g));
function declare(g: Game) {
return applyAction(g, 'f', moveAction());
}
function cancel(g: Game) {
return applyAction(g, 'e', {
type: 'card',
card: karamas[0].id,
mode: 'cancel',
});
}
function conserved(g: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const elites = g.players[0].elites;
if (elites)
assert.equal(
elites.reserves +
elites.tanks +
Object.values(elites.forces).reduce((a, b) => a + b, 0),
3,
);
}
void test('Fremen range-two movement waits only for a real foreign blocker and serializes the exact unspent group', () => {
for (const advanced of [false, true]) {
const initial = fixture(advanced);
initial.players[1].hand = [karamas[0]];
initial.players[0].hand = [karamas[1]];
const pending = declare(initial);
assert.equal(pending.response?.kind, 'fremenMovement');
assert.equal(pending.response?.owner, 'f');
assert.deepEqual(pending.players[0].forces, initial.players[0].forces);
assert.equal(pending.players[0].moved, 0);
assert.equal(pending.pendingFremenMove?.turn, 2);
assert.equal(pending.pendingFremenMove?.move, 0);
assert.deepEqual(pending.pendingFremenMove?.order.group, [[source, 4]]);
assert.deepEqual(viewGame(pending, 'f').responseControls?.cancelCards, []);
assert.throws(() =>
applyAction(pending, 'f', {
type: 'card',
card: karamas[1].id,
mode: 'cancel',
}),
);
const resumed = applyAction(restore(pending), 'e', {
type: 'passResponse',
});
assert.equal(resumed.response, null);
assert.equal(resumed.pendingFremenMove, null);
assert.equal(resumed.players[0].forces[target(2)], 4);
assert.equal(resumed.players[0].moved, 1);
if (advanced) assert.equal(resumed.players[0].elites!.forces[target(2)], 1);
assert.deepEqual(normalizeAutomaticGame(restore(resumed)), resumed);
assert.throws(() => applyAction(resumed, 'e', { type: 'passResponse' }));
conserved(resumed);
}
});
void test('no-card and owner-only Karama tables apply Fremen movement automatically without a confirmation', () => {
for (const ownerCard of [false, true]) {
const g = fixture();
if (ownerCard) g.players[0].hand = [karamas[0]];
const done = declare(g);
assert.equal(done.response, null);
assert.equal(done.pendingFremenMove, null);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].forces[target(2)], 4);
assert.equal(done.players[0].hand.length, ownerCard ? 1 : 0);
conserved(done);
}
});
void test('Basic pre-movement Hajr preserves forces and allowance when Karama reduces only the current Fremen group range', () => {
const initial = fixture();
initial.players[1].hand = [karamas[0]];
initial.players[0].hand = [baseDeck().find((c) => c.effect === 'hajr')!];
const hajr = applyAction(initial, 'f', {
type: 'card',
card: initial.players[0].hand[0].id,
});
const denied = cancel(declare(hajr));
assert.equal(denied.players[0].moved, 0);
assert.deepEqual(denied.players[0].forces, initial.players[0].forces);
assert.deepEqual(denied.players[0].elites, initial.players[0].elites);
assert.deepEqual(denied.players[0].fremenMovementBlocked, {
turn: 2,
move: 0,
});
assert.equal(viewGame(denied, 'f').players[0].fremenMovementBlocked, true);
assert.equal(denied.discard.filter((c) => c.id === karamas[0].id).length, 1);
assert.throws(() => declare(denied), /more than 1/);
const shorter = applyAction(restore(denied), 'f', moveAction(1));
assert.equal(shorter.players[0].moved, 1);
assert.equal(viewGame(shorter, 'f').players[0].fremenMovementBlocked, false);
const second = applyAction(shorter, 'f', moveAction(2, target(1)));
assert.equal(second.players[0].moved, 2);
conserved(second);
const later = restore(denied);
later.turn++;
assert.equal(viewGame(later, 'f').players[0].fremenMovementBlocked, false);
assert.equal(declare(later).players[0].moved, 1);
});
void test('city ornithopters and the fixed-range Ornithopter card bypass the Fremen faction movement response', () => {
for (const mode of ['city', 'card'] as const) {
const g = fixture();
g.players[1].hand = [karamas[0]];
const action = moveAction(3);
if (mode === 'city') {
g.players[0].forces['arrakeen:10'] = 1;
g.players[0].reserves--;
} else {
const card = richeseCards().find((c) => c.effect === 'ornithopter')!;
g.players[0].hand = [card];
action.movementCard = card.id;
action.ornithopter = 'range3';
}
const done = applyAction(g, 'f', action);
assert.equal(done.response, null);
assert.equal(done.pendingFremenMove ?? null, null);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].forces[target(3)], 4);
assert.equal(done.players[1].hand[0].id, karamas[0].id);
conserved(done);
}
});
void test('serialized BG Worthless conversion either cancels Fremen range or restores and applies the original movement exactly once', () => {
for (const countered of [false, true]) {
const g = fixture(true);
g.players[1].hand = [karamas[0]];
g.players[2].hand = [worthless];
const pending = declare(g);
const conversion = applyAction(pending, 'b', {
type: 'card',
card: worthless.id,
mode: 'cancel',
});
assert.equal(conversion.response?.kind, 'worthlessKarama');
assert.deepEqual(conversion.pendingFremenMove, pending.pendingFremenMove);
assert.deepEqual(conversion.players[0].forces, g.players[0].forces);
const done = countered
? cancel(restore(conversion))
: applyAction(restore(conversion), 'e', { type: 'passResponse' });
assert.equal(done.response, null);
assert.equal(done.pendingKarama ?? null, null);
assert.equal(done.pendingFremenMove, null);
assert.equal(done.players[0].moved, countered ? 1 : 0);
assert.equal(
viewGame(done, 'f').players[0].fremenMovementBlocked,
!countered,
);
assert.equal(done.players[0].forces[countered ? target(2) : source], 4);
assert.equal(done.discard.filter((c) => c.id === worthless.id).length, 1);
assert.deepEqual(normalizeAutomaticGame(restore(done)), done);
conserved(done);
}
});
void test('all four bots propose legal range-one replacements from the projected cancellation without consuming another move', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture(true);
g.players[1].hand = [karamas[0]];
const denied = cancel(declare(g));
const view = viewGame(restore(denied), 'f');
view.players[0].bot = difficulty;
assert.equal(view.players[0].fremenMovementBlocked, true);
const actions = botActions(view).filter((a) => a.type === 'move');
assert.ok(actions.length > 0);
for (const a of actions) {
assert.ok(
distance(
String(a.from),
location(String(a.territory), Number(a.sector)),
) <= 1,
);
assert.doesNotThrow(() => applyAction(denied, 'f', a));
}
const done = applyAction(denied, 'f', actions[0]);
assert.equal(done.players[0].moved, 1);
conserved(done);
}
});
void test('a mismatched restored Fremen movement intent fails before applying the pending group', () => {
const g = fixture();
g.players[1].hand = [karamas[0]];
const pending = declare(g);
pending.pendingFremenMove!.turn--;
const before = structuredClone(pending);
assert.throws(() => applyAction(pending, 'e', { type: 'passResponse' }));
assert.deepEqual(pending, before);
});
void test('all four defender bots act on public threatened forces without reading the Fremen hand or balance', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture();
g.players[1].hand = [karamas[0]];
g.players[1].forces = { [target(2)]: 2 };
g.players[1].reserves = 18;
const pending = declare(g);
const view = viewGame(pending, 'e');
view.players[1].bot = difficulty;
const snapshot = structuredClone(view);
const actions = botActions(view);
assert.deepEqual(view, snapshot);
assert.equal(actions.length, 1);
const cancels = ['Hard', 'Brutal'].includes(difficulty);
assert.equal(actions[0].type, cancels ? 'card' : 'passResponse');
if (cancels) assert.equal(actions[0].mode, 'cancel');
const changed = restore(pending);
changed.players[0].spice = 9999;
changed.players[0].hand = [
baseDeck().find((c) => c.kind === 'projectile')!,
];
const changedView = viewGame(changed, 'e');
changedView.players[1].bot = difficulty;
assert.deepEqual(botActions(changedView), actions);
const done = applyAction(pending, 'e', actions[0]);
assert.equal(done.players[0].moved, cancels ? 0 : 1);
conserved(done);
}
});
void test('canceling Fremen range during two-group Ornithopter preserves its event and allows a shorter first group', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture();
g.players[1].hand = [karamas[0]];
const card = richeseCards().find((c) => c.effect === 'ornithopter')!;
g.players[0].hand = [card];
const pending = applyAction(g, 'f', {
...moveAction(),
amount: 2,
movementCard: card.id,
ornithopter: 'twoGroups',
});
assert.equal(pending.response?.kind, 'fremenMovement');
const event = pending.ornithopter!.event;
const denied = cancel(restore(pending));
assert.equal(denied.ornithopter!.event, event);
assert.equal(denied.ornithopter!.completed, 0);
const view = viewGame(denied, 'f');
view.players[0].bot = difficulty;
const moves = botActions(view).filter((a) => a.type === 'move');
assert.ok(moves.length > 0);
for (const a of moves) {
assert.equal(a.ornithopterEvent, event);
assert.ok(
distance(
String(a.from),
location(String(a.territory), Number(a.sector)),
) <= 1,
);
assert.doesNotThrow(() => applyAction(denied, 'f', a));
}
const first = applyAction(denied, 'f', {
...moveAction(1),
amount: 2,
ornithopterEvent: event,
});
assert.equal(first.ornithopter!.completed, 1);
assert.equal(viewGame(first, 'f').players[0].fremenMovementBlocked, false);
const second = applyAction(restore(first), 'f', {
...moveAction(2),
amount: 2,
ornithopterEvent: event,
});
assert.equal(second.players[0].moved, 2);
assert.equal(second.ornithopter, null);
assert.equal(second.discard.filter((c) => c.id === card.id).length, 1);
conserved(second);
}
});