419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeHomeworldGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import {
|
|
homeworldGameIntegrity,
|
|
homeworldContext,
|
|
} from '../game/homeworld-game';
|
|
import { createHomeworldCustody } from '../game/homeworld-custody';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import { mobileRoutes, MOBILE_LOCATION } from '../game/board';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
import { quoteMovementPhaseStart } from '../game/movement-phase-quote';
|
|
import {
|
|
fremenAmbassadorMovement,
|
|
quoteFremenAmbassadorMove,
|
|
} from '../game/fremen-ambassador';
|
|
import type { FactionId } from '../game/catalog';
|
|
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
/** Genuine audited setup retains every physical card and counter. Later phase
|
|
* positioning and typed reserve-to-board redistribution isolate each threshold. */
|
|
function fixture(faction: FactionId, advanced = true) {
|
|
// Richese's full expansion deck remains gated. These narrowly staged module
|
|
// states do not claim a genuine complete Richese setup or release readiness.
|
|
if (faction === 'richese') {
|
|
const g = createGame(
|
|
'HOMEWORLDMOBILITY',
|
|
newPlayer('owner', faction, faction),
|
|
advanced,
|
|
['choam'],
|
|
);
|
|
joinGame(g, newPlayer('other', 'Atreides', 'atreides'));
|
|
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
active: 'owner',
|
|
turn: 2,
|
|
decision: null,
|
|
response: null,
|
|
phaseOpening: null,
|
|
ready: [],
|
|
storm: 18,
|
|
movementRemaining: ['owner', 'other'],
|
|
order: ['owner', 'other'],
|
|
deck: baseDeck(),
|
|
spiceDeck: spiceDeck(),
|
|
});
|
|
const p = g.players[0];
|
|
p.spice = 10;
|
|
p.noField = createRicheseNoField([
|
|
'richese-zero',
|
|
'richese-three',
|
|
'richese-five',
|
|
]);
|
|
p.noFieldEvent = 'initial-richese-marker';
|
|
homeworldGameIntegrity(g);
|
|
return g;
|
|
}
|
|
let g = createGame(
|
|
'HOMEWORLDMOBILITY',
|
|
newPlayer('owner', faction, faction),
|
|
advanced,
|
|
faction === 'ixians' ? ['ix'] : [],
|
|
);
|
|
joinGame(g, newPlayer('other', 'Emperor', 'emperor'));
|
|
g = applyAction(g, 'owner', { type: 'homeworlds', enabled: true });
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
for (let n = 0; g.status === 'setup' && n < 40; n++) {
|
|
let next: Game | undefined;
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) {
|
|
next = applyAction(g, p.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, 'Genuine setup offers an actionable decision.');
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const p of g.players) g.deck.push(...p.hand.splice(0));
|
|
Object.assign(g, {
|
|
phase: 5,
|
|
phaseOpening: null,
|
|
active: 'owner',
|
|
turn: 2,
|
|
decision: null,
|
|
response: null,
|
|
ready: [],
|
|
storm: 18,
|
|
movementRemaining: ['owner', 'other'],
|
|
order: ['owner', 'other'],
|
|
});
|
|
g.players[0].shipped = false;
|
|
g.players[0].moved = 0;
|
|
homeworldGameIntegrity(g);
|
|
return g;
|
|
}
|
|
function population(g: Game, reserves: number, source = 'polar_sink:0') {
|
|
const p = g.players[0];
|
|
p.reserves = reserves;
|
|
p.tanks = 0;
|
|
p.forces = { [source]: 20 - reserves };
|
|
if (p.elites) {
|
|
p.elites.reserves = Math.min(reserves, 4);
|
|
p.elites.tanks = 0;
|
|
p.elites.forces = { [source]: 7 - p.elites.reserves };
|
|
}
|
|
homeworldGameIntegrity(g);
|
|
}
|
|
function reject(g: Game, action: Action, pattern: RegExp) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'owner', action), pattern);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function hold(g: Game, effect: string) {
|
|
const index = g.deck.findIndex((card) => card.effect === effect);
|
|
assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1);
|
|
g.players[0].hand.push(card);
|
|
return card;
|
|
}
|
|
function marker(g: Game) {
|
|
const p = g.players[0],
|
|
token = p.noField!.tokens.find((t) => t.value === 0)!;
|
|
p.noField = deployRicheseNoField(p.noField!, {
|
|
tokenId: token.id,
|
|
controller: p.id,
|
|
location: { territory: 'imperial_basin', sector: 10 },
|
|
});
|
|
return {
|
|
type: 'move',
|
|
forces: {},
|
|
noField: token.id,
|
|
event: p.noFieldEvent,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
} satisfies Action;
|
|
}
|
|
|
|
void test('Caladan threshold suppresses the actual phase-start foresight offer and deck refill only below six', () => {
|
|
for (const advanced of [false, true])
|
|
for (const reserves of [5, 6]) {
|
|
const g = fixture('atreides', advanced);
|
|
population(g, reserves);
|
|
const before = reload(g);
|
|
const quote = quoteMovementPhaseStart(g);
|
|
assert.equal(quote.kind, 'initialize');
|
|
if (quote.kind === 'initialize')
|
|
assert.equal(quote.spice.owner, reserves < 6 ? null : 'owner');
|
|
assert.deepEqual(g, before);
|
|
g.spiceDiscard[0].push(...g.spiceDeck.splice(0));
|
|
const empty = quoteMovementPhaseStart(g);
|
|
if (empty.kind === 'initialize')
|
|
assert.equal(empty.spice.refill, reserves >= 6);
|
|
}
|
|
});
|
|
void test('Atreides retains already learned Movement knowledge after a real shipment lowers Caladan', () => {
|
|
let g = fixture('atreides');
|
|
population(g, 6);
|
|
g.phase = 4;
|
|
g.active = null;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = normalizeAutomaticGame(g);
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.spicePeekKnown, true);
|
|
const known = viewGame(g, 'owner').spicePeek;
|
|
assert.ok(known);
|
|
g = applyAction(g, 'owner', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 1,
|
|
});
|
|
assert.equal(g.players[0].reserves, 5);
|
|
assert.deepEqual(viewGame(reload(g), 'owner').spicePeek, known);
|
|
assert.equal(viewGame(reload(g), 'other').spicePeek, null);
|
|
homeworldGameIntegrity(g);
|
|
});
|
|
void test('a low-Caladan phase start grants no foresight and a later population rise creates no late peek', () => {
|
|
let g = fixture('atreides');
|
|
population(g, 5);
|
|
g.phase = 4;
|
|
g.active = null;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = normalizeAutomaticGame(g);
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.spicePeekKnown, false);
|
|
assert.equal(viewGame(g, 'owner').spicePeek, null);
|
|
population(g, 6);
|
|
assert.equal(viewGame(reload(g), 'owner').spicePeek, null);
|
|
});
|
|
void test('low Ix cannot spend a real special Karama to relocate, while high Ix commits once', () => {
|
|
const g = fixture('ixians');
|
|
population(g, 4, MOBILE_LOCATION);
|
|
g.mobileStronghold = { location: 'polar_sink:0' };
|
|
const card = hold(g, 'karama'),
|
|
route = mobileRoutes(g, 2)[0];
|
|
assert.ok(route);
|
|
const action = {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: card.id,
|
|
route,
|
|
collect: false,
|
|
};
|
|
reject(g, action, /Low-population Ix/);
|
|
population(g, 5, MOBILE_LOCATION);
|
|
const next = applyAction(reload(g), 'owner', action);
|
|
assert.equal(next.mobileStronghold!.location, route.at(-1));
|
|
assert.equal(next.players[0].specialKaramaUsed, true);
|
|
assert.equal(next.discard.filter((c) => c.id === card.id).length, 1);
|
|
assert.equal(next.players[0].moved, 0);
|
|
homeworldGameIntegrity(next);
|
|
});
|
|
void test('low Ix still moves its physical troops out of a stationary Hidden Mobile Stronghold', () => {
|
|
const g = fixture('ixians');
|
|
population(g, 4, MOBILE_LOCATION);
|
|
g.mobileStronghold = { location: 'polar_sink:0' };
|
|
const next = applyAction(g, 'owner', {
|
|
type: 'move',
|
|
forces: { [MOBILE_LOCATION]: 1 },
|
|
eliteForces: { [MOBILE_LOCATION]: 0 },
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
});
|
|
assert.equal(next.players[0].forces['polar_sink:0'], 1);
|
|
assert.deepEqual(next.mobileStronghold, g.mobileStronghold);
|
|
homeworldGameIntegrity(next);
|
|
});
|
|
void test('low Richese rejects token-only and mixed ordinary movement but allows physical forces beside the stationary marker', () => {
|
|
const g = fixture('richese');
|
|
population(g, 9, 'imperial_basin:10');
|
|
const action = marker(g);
|
|
reject(g, action, /Low-population Richese/);
|
|
reject(
|
|
g,
|
|
{ ...action, forces: { 'imperial_basin:10': 1 } },
|
|
/Low-population Richese/,
|
|
);
|
|
const next = applyAction(reload(g), 'owner', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.deepEqual(next.players[0].noField, g.players[0].noField);
|
|
assert.equal(next.players[0].forces['arrakeen:10'], 1);
|
|
homeworldGameIntegrity(next);
|
|
});
|
|
void test('high Richese moves the same physical token once and never borrows concealed force value', () => {
|
|
const g = fixture('richese');
|
|
population(g, 10, 'imperial_basin:10');
|
|
const action = marker(g);
|
|
const next = applyAction(reload(g), 'owner', action);
|
|
assert.ok('territory' in next.players[0].noField!.deployed!.location);
|
|
assert.equal(
|
|
next.players[0].noField!.deployed!.location.territory,
|
|
'arrakeen',
|
|
);
|
|
assert.equal(next.players[0].reserves, 10);
|
|
assert.deepEqual(next.players[0].forces, g.players[0].forces);
|
|
assert.notEqual(next.players[0].noFieldEvent, g.players[0].noFieldEvent);
|
|
reject(reload(next), action, /available|stale/);
|
|
homeworldGameIntegrity(next);
|
|
});
|
|
void test('low Richese may still ship and reveal a No-Field; the penalty applies only to movement', () => {
|
|
let g = fixture('richese');
|
|
population(g, 9);
|
|
const p = g.players[0];
|
|
const token = p.noField!.tokens.find((t) => t.value === 3)!;
|
|
g = applyAction(g, 'owner', {
|
|
type: 'ship',
|
|
noField: token.id,
|
|
event: p.noFieldEvent,
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
});
|
|
assert.equal(g.players[0].reserves, 9);
|
|
g = applyAction(g, 'owner', {
|
|
type: 'revealNoField',
|
|
token: token.id,
|
|
event: g.players[0].noFieldEvent,
|
|
});
|
|
assert.equal(g.players[0].reserves, 6);
|
|
assert.equal(g.players[0].forces['imperial_basin:10'], 3);
|
|
homeworldGameIntegrity(g);
|
|
});
|
|
void test('Fremen Ambassador quote and public source controls reject only the low Richese marker', () => {
|
|
const g = fixture('richese');
|
|
population(g, 9, 'imperial_basin:10');
|
|
marker(g);
|
|
const p = g.players[0],
|
|
token = p.noField!.deployed!.tokenId;
|
|
const action = {
|
|
type: 'decision',
|
|
forces: {},
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
noField: { tokenId: token, event: p.noFieldEvent },
|
|
};
|
|
const before = reload(g);
|
|
assert.throws(
|
|
() => quoteFremenAmbassadorMove(g, 'owner', action),
|
|
/Low-population Richese/,
|
|
);
|
|
assert.equal(
|
|
quoteFremenAmbassadorMove(g, 'owner', {
|
|
...action,
|
|
noField: undefined,
|
|
forces: { 'imperial_basin:10': 1 },
|
|
}).total,
|
|
1,
|
|
);
|
|
const source = fremenAmbassadorMovement(g, 'owner').sources.find(
|
|
(s) => s.territory === 'imperial_basin',
|
|
)!;
|
|
assert.ok(source.destinations.length);
|
|
assert.equal(source.marker, null);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('low Richese Hajr extra movement cannot carry the marker', () => {
|
|
const g = fixture('richese');
|
|
population(g, 9, 'imperial_basin:10');
|
|
const action = marker(g);
|
|
const hajr = hold(g, 'hajr');
|
|
const next = applyAction(g, 'owner', { type: 'card', card: hajr.id });
|
|
assert.ok(next.hajr.includes('owner'));
|
|
reject(next, action, /Low-population Richese/);
|
|
assert.equal(next.discard.filter((c) => c.id === hajr.id).length, 1);
|
|
});
|
|
void test('normalized saved low Caladan foresight response fails without leaking the deck', () => {
|
|
const g = fixture('atreides');
|
|
population(g, 5);
|
|
g.response = { kind: 'atreidesSpice', owner: 'owner', passed: [] };
|
|
const before = reload(g);
|
|
assert.throws(
|
|
() => normalizeAutomaticGame(reload(g)),
|
|
/Caladan|foresight|Homeworld/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
function nextStorm(state: Game) {
|
|
let g = state;
|
|
g.phase = 8;
|
|
g.active = null;
|
|
for (let step = 0; step < 2; step++)
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.phase, 0);
|
|
return g;
|
|
}
|
|
void test('the real Storm phase skips low Ix relocation and offers it again at five native forces', () => {
|
|
for (const reserves of [4, 5]) {
|
|
const g = fixture('ixians');
|
|
population(g, reserves, MOBILE_LOCATION);
|
|
g.mobileStronghold = { location: 'polar_sink:0' };
|
|
const next = nextStorm(g);
|
|
assert.equal(
|
|
next.decision?.kind ?? null,
|
|
reserves < 5 ? null : 'mobileStronghold',
|
|
);
|
|
assert.deepEqual(next.mobileStronghold, g.mobileStronghold);
|
|
homeworldGameIntegrity(next);
|
|
}
|
|
});
|
|
void test('a saved normal HMS relocation cannot execute or consume cancellation after native Ix becomes low', () => {
|
|
let g = fixture('ixians');
|
|
population(g, 5, MOBILE_LOCATION);
|
|
g.mobileStronghold = { location: 'polar_sink:0' };
|
|
const card = hold(g, 'karama');
|
|
g.players[1].hand.push(...g.players[0].hand.splice(0));
|
|
g = nextStorm(g);
|
|
const route = mobileRoutes(g, 3)[0];
|
|
assert.ok(route);
|
|
g = applyAction(g, 'owner', { type: 'decision', route, collect: false });
|
|
assert.equal(g.response?.kind, 'mobileStronghold');
|
|
assert.ok(g.pendingMobileMove);
|
|
population(g, 4, MOBILE_LOCATION);
|
|
reject(g, { type: 'passResponse' }, /Low-population Ix/);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'other', { type: 'card', card: card.id, mode: 'cancel' }),
|
|
/Low-population Ix/,
|
|
);
|
|
assert.throws(() => normalizeAutomaticGame(reload(g)), /Low-population Ix/);
|
|
});
|
|
void test('initial HMS placement remains legal at low Ix because placement is not relocation', () => {
|
|
let g = fixture('ixians');
|
|
population(g, 4, MOBILE_LOCATION);
|
|
g.phase = 0;
|
|
g.turn = 1;
|
|
g.active = null;
|
|
g.mobileStronghold = { location: null };
|
|
g.decision = { kind: 'mobileStronghold', player: 'owner', placement: true };
|
|
g = applyAction(g, 'owner', { type: 'decision', location: 'polar_sink:0' });
|
|
assert.equal(g.mobileStronghold!.location, 'polar_sink:0');
|
|
assert.equal(g.phase, 1);
|
|
homeworldGameIntegrity(g);
|
|
});
|