Connect Basic Ixians to the full Leader Skills prototype

Admit genuine Basic Ixian setup with the full fourteen-skill deck, real
starting-card choice, optional Tleilaxu/base opponents and first-Storm HMS.
Keep public starts, Advanced and other module combinations gated.

Share per-origin cyborg movement range across engine, controls and legal bots.
Preserve independent Planetologist range after native Karama cancellation.
Connect Sandmaster force routes through room-local HMS pointer adjacency.
Retain canceled movement's no-op contract while validating actual execution.

Resolve Suk rescue before substitution with explicit ambiguous cyborg rescue
origins and exact remaining Tank losses. Finish Rihani before native
substitution, bind saved physical losses and declared exchange, and resume
mandatory/optional winner disposal on allow, decline or cancellation.
Admit exact green Ix Thumper, Harvester and Amal as Planetologist substitutes.

Validation: types, lint, 5,082 offline tests and production build passed.
Five genuine 2-6 player games completed with zero rejected actions and 54
JSON restores. Authenticated in-memory concurrent starting-card recovery,
private setup, physical card/force custody and independent review passed.
Browser FLGHBSA4 completed real setup, first Storm/HMS placement and a
conserved staged Suk battle. Mobile rescue, refresh and one-for-one
substitution passed. Screenshot capture timed out; full visual acceptance
remains open. All 1,582 prior rooms, 3,350 human seats and auxiliary records
were preserved; one new QA room/seat remains. No restart, reset or automation.

AI work remains limited to legal participation until every non-AI feature
is complete, then targets roughly 75% higher-tier wins for each adjacent pair.
This commit is contained in:
Quintine committed 2026-09-21 14:36:27 +10:00
1 parent d3559f65ad
commit 3efda2128e
28 files changed
+1461 -129

No files matched your search

+20
View File
@@ -135,6 +135,26 @@ void test('Tleilaxu Skills samples preserve their full module on saved continuat
assert.equal(existsSync(unsupported), false);
});
void test('Ixian Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'ix-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'ix-skills-2-basic', 1026, false);
const snapshot = join(out, 'failed-ix-skills-2-basic.json');
const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record<string, { cards: string[] }>; assignments: { skill: string }[] } }>(snapshot);
assert.equal(game.advanced, false);
const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'ix-skills-2-basic', 1026, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'ix-skills', '--rules', 'advanced');
assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
assert.equal(existsSync(unsupported), false);
});
void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => {
const area = temporary(t);
const first = join(area, 'first');
+266
View File
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import {
completedIxSkillsGame,
assertIxSkillsCustody as custody,
ixSkillsPlayer as player,
reloadIxSkillsGame as reload,
rejectIxSkillsAction as reject,
} from './ix-skills-fixture';
const act = (game: Game, owner: string, action: Action) => applyAction(reload(game), owner, action);
function stable(game: Game) {
custody(game);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
}
/** Conserved battle positions after real setup; these are not claimed played phase histories. */
function stage(skill: LeaderSkillId, location = 'wind_pass:14') {
const game = completedIxSkillsGame({ requestedSkill: skill });
for (const p of game.players) {
game.deck.push(...p.hand);
p.hand = [];
p.forces = p.id === 't' ? {} : { [location]: 8, 'carthag:11': 1 };
p.reserves = p.id === 't' ? 20 : 11;
p.tanks = 0;
p.battleLosses = 0;
p.spice = 20;
}
player(game, 'i').elites = { reserves: 5, tanks: 0, forces: { [location]: 2 }, revived: 0 };
Object.assign(game, { phase: 6, active: 'i', order: ['i', 'e', 't'], storm: 18,
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
return game;
}
function take(game: Game, owner: string, predicate: (card: Game['deck'][number]) => boolean) {
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
player(game, owner).hand.push(card);
return card;
}
function passResponses(game: Game) {
for (let step = 0; game.response && step < 20; step++) {
const p = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
assert.ok(p);
game = act(game, p.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
function open(game: Game, territory = 'wind_pass') {
game = act(game, 'i', { type: 'chooseBattle', territory, target: 'e' });
for (let step = 0; step < 30; step++) {
if (game.response) game = passResponses(game);
else if (game.decision?.kind === 'leaderSkillVisibility')
game = act(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
else if (game.battle?.preparation)
game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
assert.equal(game.decision, null);
assert.equal(game.battle?.preparation, undefined);
return game;
}
function resolve(game: Game, own: Partial<Action> = {}, territory = 'wind_pass') {
game = open(game, territory);
game = act(game, 'i', { type: 'battlePlan', dial: 6, leader: player(game, 'i').leaders[0].id, ...own });
game = act(game, 'e', { type: 'battlePlan', dial: 0, leader: player(game, 'e').leaders[4].id });
game = act(game, 'i', { type: 'traitorCall', call: false });
game = act(game, 'e', { type: 'traitorCall', call: false });
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(option => option.normal === 4 && option.elite === 2);
assert.ok(choice >= 0);
game = act(game, 'i', { type: 'decision', choice });
}
return game;
}
function finishSubstitution(game: Game, outcome: 'allow' | 'cancel' | 'decline', karama?: string) {
assert.equal(game.decision?.kind, 'ixSubstitution');
if (outcome === 'decline') return act(game, 'i', { type: 'decision', decline: true });
game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
assert.equal(game.response?.kind, 'ixSubstitution');
stable(game);
if (outcome === 'cancel') {
assert.ok(karama);
return passResponses(act(game, 'e', { type: 'card', card: karama, mode: 'cancel' }));
}
return passResponses(game);
}
void test('Ixian Rihani completes its private exchange before native cyborg substitution and optional winner cleanup', () => {
for (const outcome of ['allow', 'cancel', 'decline'] as const) {
let game = stage('rihani-decipherer');
const shield = take(game, 'i', card => card.kind === 'shield');
const karama = take(game, 'e', card => card.effect === 'karama');
game = resolve(game, { defense: shield.id });
assert.equal(game.decision?.kind, 'rihani');
assert.deepEqual(game.pendingIxSubstitution?.losses, { 'wind_pass:14': 2 });
assert.equal(game.pendingIxSubstitution?.sources, undefined);
assert.equal(player(game, 'i').elites!.tanks, 2);
stable(game);
const event = game.rihaniHistory!.at(-1)!.event;
reject(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
if (outcome === 'decline') game = act(game, 'i', { type: 'decision', event, draw: false });
else {
game = act(game, 'i', { type: 'decision', event, draw: true });
const receipt = game.rihaniHistory!.at(-1)!;
assert.equal(receipt.stage, 'return');
assert.deepEqual(viewGame(game, 'e').rihani!.history, []);
stable(game);
game = act(game, 'i', { type: 'decision', event, cards: [receipt.drawn[0], receipt.eligible[0]] });
}
const history = structuredClone(game.rihaniHistory);
assert.equal(game.lastBattleContext!.rihani!.completed, true);
stable(game);
game = finishSubstitution(game, outcome, karama.id);
assert.equal(game.pendingIxSubstitution, null);
assert.deepEqual(game.decision, { kind: 'battleCards', player: 'i', territory: 'wind_pass', cards: [shield.id] });
assert.equal(player(game, 'i').elites!.tanks, outcome === 'allow' ? 1 : 2);
assert.equal(player(game, 'i').elites!.forces['wind_pass:14'] ?? 0, outcome === 'allow' ? 1 : 0);
assert.deepEqual(game.rihaniHistory, history);
stable(game);
game = act(game, 'i', { type: 'decision', discard: [] });
assert.equal(game.rihaniHistory!.length, 1);
assert.equal(player(game, 'i').hand.some(card => card.id === shield.id), true);
stable(game);
}
});
void test('a delayed Ixian substitution preserves its original losses and cannot be removed or selected during Rihani', () => {
const game = resolve(stage('rihani-decipherer'));
assert.equal(game.decision?.kind, 'rihani');
const event = game.rihaniHistory!.at(-1)!.event;
const mutations: ((saved: Game) => void)[] = [
saved => { saved.pendingIxSubstitution!.losses = { 'wind_pass:15': 2 }; },
saved => { saved.pendingIxSubstitution!.sources = { 'wind_pass:14': 1 }; },
saved => { saved.pendingIxSubstitution!.recover = { 'wind_pass:14': 1 }; },
saved => { saved.pendingIxSubstitution = null; },
saved => { player(saved, 'i').elites!.tanks--; player(saved, 'i').elites!.reserves++; },
];
for (const mutate of mutations) {
const saved = reload(game);
mutate(saved);
reject(saved, 'i', { type: 'decision', event, draw: false });
}
const restored = act(game, 'i', { type: 'decision', event, draw: false });
assert.equal(restored.decision?.kind, 'ixSubstitution');
assert.deepEqual(restored.pendingIxSubstitution!.losses, { 'wind_pass:14': 2 });
stable(restored);
});
void test('an accepted Ixian substitution freezes matching source and recovery counts through its response', () => {
let game = stage('sandmaster');
take(game, 'e', card => card.effect === 'karama');
game = resolve(game);
game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
assert.equal(game.response?.kind, 'ixSubstitution');
for (const changeBoth of [false, true]) {
const saved = reload(game);
saved.pendingIxSubstitution!.recover = { 'wind_pass:14': 2 };
if (changeBoth) saved.pendingIxSubstitution!.sources = { 'wind_pass:14': 2 };
const voter = saved.players.find(p => !saved.response!.passed.includes(p.id));
assert.ok(voter);
reject(saved, voter.id, { type: 'passResponse' });
}
stable(game);
});
void test('every Ixian substitution outcome resumes mandatory Planetologist disposal before optional winner cards', () => {
const cases = [
...(['allow', 'cancel', 'decline'] as const).map(outcome => ({ outcome, card: 'treachery-24' })),
{ outcome: 'allow', card: 'ix-thumper' },
{ outcome: 'cancel', card: 'ix-harvester' },
{ outcome: 'decline', card: 'ix-amal' },
] as const;
for (const { outcome, card } of cases) {
let game = stage('planetologist');
const special = take(game, 'i', candidate => candidate.id === card);
const shield = take(game, 'i', card => card.kind === 'shield');
const karama = take(game, 'e', card => card.effect === 'karama');
game = resolve(game, { weapon: special.id, defense: shield.id });
assert.equal(game.decision?.kind, 'ixSubstitution');
assert.equal(game.pendingWinnerDiscards!.cards.includes(special.id), true);
assert.equal(player(game, 'i').hand.some(card => card.id === special.id), true);
assert.equal(game.discard.some(card => card.id === special.id), false);
stable(game);
game = finishSubstitution(game, outcome, karama.id);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
assert.deepEqual(game.decision, { kind: 'battleCards', player: 'i', territory: 'wind_pass', cards: [shield.id] });
assert.equal(game.discard.filter(card => card.id === special.id).length, 1);
reject(game, 'i', { type: 'decision', discard: [special.id] });
stable(game);
game = act(game, 'i', { type: 'decision', discard: [shield.id] });
assert.equal(game.discard.filter(card => card.id === special.id).length, 1);
assert.equal(game.discard.filter(card => card.id === shield.id).length, 1);
stable(game);
}
});
void test('a battle inside the HMS never uses spice in the territory beneath its pointer', () => {
for (const skill of ['smuggler', 'sandmaster'] as const) {
let game = stage(skill, MOBILE_LOCATION);
game.mobileStronghold!.location = 'wind_pass:14';
game.spice = { 'wind_pass:14': 8 };
game = resolve(game, { dial: 0 }, MOBILE_STRONGHOLD);
assert.equal(game.lastBattleContext!.territory, MOBILE_STRONGHOLD);
assert.deepEqual(game.spice, { 'wind_pass:14': 8 });
assert.equal(player(game, 'i').spice, 20);
assert.equal(game.pendingIxSubstitution, undefined);
stable(game);
}
});
for (const rescue of [true, false]) {
void test(rescue
? 'Ixian Suk rescues a suboid in place and cyborg to reserves before substituting only the remaining dead cyborg'
: 'declining skilled Ixian Suk rescue leaves both original cyborg casualties available for substitution', () => {
let game = stage('suk-graduate');
take(game, 'e', card => card.effect === 'karama');
game = resolve(game);
const decision = game.decision;
assert.equal(decision?.kind, 'sukRescue');
if (decision?.kind !== 'sukRescue') throw new Error('Expected the original Suk rescue');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 0 });
assert.equal(game.pendingIxSubstitution, undefined);
assert.equal(player(game, 'i').tanks, 0);
stable(game);
const choice = decision.options.findIndex(option => rescue
? option.normal === 1 && option.elite === 1 && option.kept?.kind === 'normal' && option.kept.key === 'wind_pass:14'
: option.normal === 0 && option.elite === 0 && option.kept === null);
assert.ok(choice >= 0);
game = act(game, 'i', { type: 'decision', event: decision.event, choice });
const recover = rescue ? 1 : 2;
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.decision?.kind, 'ixSubstitution');
assert.deepEqual(game.pendingIxSubstitution!.losses, { 'wind_pass:14': recover });
assert.equal(player(game, 'i').elites!.reserves, rescue ? 6 : 5);
assert.equal(player(game, 'i').elites!.tanks, recover);
assert.equal(player(game, 'i').forces['wind_pass:14'], rescue ? 3 : 2);
assert.equal(player(game, 'i').tanks, rescue ? 4 : 6);
stable(game);
if (rescue) reject(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 2 }, recover: { 'wind_pass:14': 2 } });
game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': recover }, recover: { 'wind_pass:14': recover } });
assert.equal(game.response?.kind, 'ixSubstitution');
stable(game);
game = passResponses(game);
assert.equal(game.pendingIxSubstitution, null);
assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true);
assert.equal(player(game, 'i').elites!.reserves, rescue ? 6 : 5);
assert.equal(player(game, 'i').elites!.tanks, 0);
assert.equal(player(game, 'i').elites!.forces['wind_pass:14'], recover);
assert.equal(player(game, 'i').forces['wind_pass:14'], rescue ? 3 : 2);
assert.equal(player(game, 'i').tanks, rescue ? 4 : 6);
stable(game);
});
}
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
type Action,
type Game,
type Player,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { traitorDeck } from '../game/traitors';
export const IX_SKILLS_SEATS = {
tleilaxu: 't',
ixians: 'i',
emperor: 'e',
} as const;
export type IxSkillsFaction = keyof typeof IX_SKILLS_SEATS;
export type IxSkillsFixtureOptions = {
requestedSkill?: LeaderSkillId;
skillOwner?: IxSkillsFaction;
};
const names: Record<IxSkillsFaction, string> = {
tleilaxu: 'Tleilaxu',
ixians: 'Ixians',
emperor: 'Emperor',
};
export const reloadIxSkillsGame = (game: Game): Game =>
JSON.parse(JSON.stringify(game)) as Game;
export function ixSkillsPlayer(game: Game, id: string): Player {
const player = game.players.find((candidate) => candidate.id === id);
assert.ok(player);
return player;
}
export function rejectIxSkillsAction(
game: Game,
id: string,
action: Action,
pattern?: RegExp,
): void {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, id, action), pattern);
else assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
/** Count physical pieces only; projected/history copies do not create custody. */
export function assertIxSkillsCustody(game: Game): void {
const skills = game.leaderSkills!;
const physical = [
...skills.deck,
...Object.values(skills.offers).flatMap((offer) => offer.cards),
...skills.assignments.map((assignment) => assignment.skill),
];
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physical.length, canonical.length);
assert.equal(new Set(physical).size, canonical.length);
assert.deepEqual([...physical].sort(), [...canonical].sort());
const treachery = [
...(game.ixSetupCards ?? []),
...(game.ixAuction?.cards ?? []),
...game.deck,
...game.discard,
...game.players.flatMap((player) => player.hand),
...(game.auction?.cards.slice(game.auction.index) ?? []),
].map((card) => card.id);
assert.deepEqual(treachery.sort(), treacheryDeck(['ix']).map((card) => card.id).sort());
for (const player of game.players)
assert.equal(
player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
`${player.name} must retain every physical force`,
);
const ixians = ixSkillsPlayer(game, 'i');
assert.equal(ixians.elites!.reserves + ixians.elites!.tanks + Object.values(ixians.elites!.forces).reduce((a, b) => a + b, 0), 7);
for (const [key, count] of Object.entries(ixians.elites!.forces))
assert.ok(count <= (ixians.forces[key] ?? 0));
if (game.status === 'playing' || game.status === 'finished') {
const traitors = [
...game.traitorReserve!,
...game.players.flatMap((player) => player.traitors),
...game.players.flatMap((player) => (player.faceDancers ?? []).map((card) => card.leader)),
];
assert.deepEqual(traitors.sort(), traitorDeck(game.players, true).sort());
assert.equal(new Set(traitors).size, traitors.length);
}
}
/** Real ready lobby and setup deal; only the production shuffle is controlled. */
export function initializedIxSkillsOffers(
options: IxSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'planetologist';
const skillOwner = options.skillOwner ?? 'ixians';
const factions = (
[skillOwner, 'ixians', 'tleilaxu', 'emperor'] as IxSkillsFaction[]
).filter((faction, index, all) => all.indexOf(faction) === index);
const first = factions[0];
let game = createGame(
'IXSKILLS',
newPlayer(IX_SKILLS_SEATS[first], names[first], first),
false,
['ix'],
);
for (const faction of factions.slice(1))
joinGame(game, newPlayer(IX_SKILLS_SEATS[faction], names[faction], faction));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex((card) => card.id === requestedSkill);
assert.ok(targetIndex >= 0);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
assert.equal(game.status, 'setup');
assert.equal(game.setupStage, 'skillTreachery');
assert.equal(game.decision?.kind, 'ixSetup');
assert.equal(game.ixSetupCards!.length, game.players.length);
assert.ok(game.players.every(player => player.hand.length === 0 && player.traitorChoices.length === 0));
assertIxSkillsCustody(game);
game = applyAction(reloadIxSkillsGame(game), 'i', {type: 'decision', card: game.ixSetupCards![0].id});
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.ixSetupCards, null);
assert.equal(game.leaderSkills!.offers[IX_SKILLS_SEATS[skillOwner]].cards[0], requestedSkill);
assertIxSkillsCustody(game);
return game;
}
/** Complete skills, Traitors, three Face Dancers and the first Storm HMS placement. */
export function completedIxSkillsGame(
options: IxSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'planetologist';
const requestedOwner = IX_SKILLS_SEATS[options.skillOwner ?? 'ixians'];
let game = initializedIxSkillsOffers(options);
for (const player of game.players) {
const offer = game.leaderSkills!.offers[player.id];
game = applyAction(game, player.id, {
type: 'leaderSkill',
event: offer.event,
skill: player.id === requestedOwner ? requestedSkill : offer.cards[0],
leader: player.leaders[0].id,
});
}
assert.equal(game.setupStage, 'traitors');
assert.equal(ixSkillsPlayer(game, 't').faceDancers, undefined);
for (const player of game.players) {
if (player.faction === 'tleilaxu') continue;
const choice = ixSkillsPlayer(game, player.id).traitorChoices[0];
assert.ok(choice);
game = applyAction(game, player.id, { type: 'traitor', leader: choice });
}
assert.equal(game.status, 'playing');
assert.equal(game.setupStage, undefined);
assert.deepEqual(ixSkillsPlayer(game, 't').traitors, []);
assert.equal(ixSkillsPlayer(game, 't').faceDancers!.length, 3);
for (let step = 0; step < 20 && game.decision?.kind !== 'mobileStronghold'; step++) {
if (game.phaseOpening) {
const owner = game.players.find(player => !game.phaseOpening!.passed.includes(player.id));
assert.ok(owner);
game = applyAction(reloadIxSkillsGame(game), owner.id, {type: 'ready'});
} else if (game.stormPending !== null) {
const owner = game.players.find(player => !game.ready.includes(player.id));
assert.ok(owner);
game = applyAction(reloadIxSkillsGame(game), owner.id, {type: 'ready'});
} else {
const owner = game.stormDialers.find(id => game.stormDials[id] === undefined);
assert.ok(owner);
game = applyAction(reloadIxSkillsGame(game), owner, {type: 'stormDial', amount: 0});
}
}
assert.equal(game.decision?.kind, 'mobileStronghold');
game = applyAction(reloadIxSkillsGame(game), 'i', {type: 'decision', location: 'polar_sink:0'});
assert.equal(game.mobileStronghold!.location, 'polar_sink:0');
assert.equal(game.phase, 1);
assertIxSkillsCustody(game);
return game;
}
+439
View File
@@ -0,0 +1,439 @@
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 keep public starts, Advanced and additional module profiles closed', () => {
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;
};
reject(lobby(), 'i', {type: 'start'});
for (const change of [
(g: Game) => { g.advanced = true; },
(g: Game) => { g.discoveryEnabled = true; },
(g: Game) => { g.techTokens = createTechTokens(); },
(g: Game) => { g.expansions.push('choam'); },
(g: Game) => { g.homeworlds = {custody: null}; },
]) {
const g = lobby();
change(g);
const before = structuredClone(g);
assert.throws(() => initializeLeaderSkillsGameForAudit(g));
assert.deepEqual(g, 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);
});
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import { startPrototypeRoom } from '../tools/prototype-room';
import type { Action } from '../game/engine';
void test('authenticated concurrent Ixian starting-card choice survives restart into private skill and Traitor setup', async () => {
const f = unitStore();
const db: DatabaseSync = f.sqlite;
const clock = { now: () => 10000, sleep: async () => {} };
try {
const created = await f.rooms.createRoom('Ixians', 'ixians', false, ['ix']);
const code = created.view.code;
const joined = await f.rooms.joinRoom(code, 'Tleilaxu', 'tleilaxu');
const auths = await Promise.all([created.token!, joined.token!].map(token => f.restart().authenticate(code, token)));
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
await act(0, { type: 'ready' }); await act(1, { type: 'ready' });
const ready = await f.restart().readRoom(code);
const seats = db.prepare('SELECT * FROM seats').all();
startPrototypeRoom(db, code, ready.version, 'leader-skills');
const initial = await f.restart().readRoom(code);
assert.equal(initial.decision?.kind, 'ixSetup');
assert.equal(initial.ixSetupCards!.length, 2);
const other = await f.restart().readSeatView(code, auths[1]);
assert.equal(other.ixTechnology, null);
const choice = { type: 'decision', card: initial.ixSetupCards![0].id };
let arrivals = 0; let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], initial.version, choice, clock),
f.restart().act(code, auths[0], initial.version, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
assert.equal(results.filter(r => r.status === 'rejected').length, 1);
let game = await f.restart().readRoom(code);
assert.equal(game.version, initial.version + 1);
assert.equal(game.ixSetupCards, null);
assert.equal(game.setupStage, 'leaderSkills');
assert.ok(game.players.every(p => p.hand.length === 1 && p.traitorChoices.length === 0));
for (const [index, auth] of auths.entries()) {
const offer = game.leaderSkills!.offers[auth.playerId];
const own = await f.restart().readSeatView(code, auth);
assert.deepEqual(own.leaderSkills!.offer, offer);
assert.equal('offers' in own.leaderSkills!, false);
assert.equal('deck' in own.leaderSkills!, false);
const p = game.players.find(p => p.id === auth.playerId)!;
game = await act(index, { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: p.leaders[0].id });
}
assert.equal(game.setupStage, 'traitors');
const ix = game.players.find(p => p.faction === 'ixians')!;
game = await act(0, { type: 'traitor', leader: ix.traitorChoices[0] });
assert.equal(game.status, 'playing');
assert.equal(game.players.find(p => p.faction === 'tleilaxu')!.faceDancers!.length, 3);
const skills = [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(a => a.skill)];
assert.equal(skills.length, 14); assert.equal(new Set(skills).size, 14);
const cards = [...game.deck, ...game.players.flatMap(p => p.hand)].map(c => c.id);
assert.equal(cards.length, 47); assert.equal(new Set(cards).size, 47);
const cyborgs = game.players.find(p => p.faction === 'ixians')!.elites!;
assert.equal(cyborgs.reserves + cyborgs.tanks + Object.values(cyborgs.forces).reduce((a, b) => a + b, 0), 7);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
} finally { db.close(); }
});
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { quoteSukRescue, sukRescueOptions } from '../game/suk-graduate';
import { sukRescueLabel } from '../components/suk-graduate';
void test('Ixian Suk rescue explicitly commits ambiguous cyborg reserve origins before substitution', () => {
const pool = [{ key: 'wind_pass:14', normal: 2, elite: 1 }, { key: 'wind_pass:15', normal: 2, elite: 2 }];
const losses = { normal: 2, elite: 3, paidNormal: 2, paidElite: 3 };
const skill = { leader: 'ixians-0', mode: 'skilled' as const };
const options = sukRescueOptions(skill, pool, losses, true);
const choices = options.filter(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'normal');
assert.equal(choices.length, 2);
assert.deepEqual(choices.map(o => quoteSukRescue(skill, pool, losses, o, true).eliteTanks), [
{ 'wind_pass:14': 1, 'wind_pass:15': 1 }, { 'wind_pass:15': 2 },
]);
assert.match(sukRescueLabel(choices[0]), /cyborgs: 1 from sector 15/);
assert.match(sukRescueLabel(choices[1]), /cyborgs: 1 from sector 14/);
assert.throws(() => quoteSukRescue(skill, pool, losses, { ...choices[0], eliteReserves: { 'wind_pass:14': 2 } }, true));
for (const option of options) {
const q = quoteSukRescue(skill, pool, losses, JSON.parse(JSON.stringify(option)), true);
assert.equal(Object.values(q.eliteTanks!).reduce((a, b) => a + b, 0), q.tanks.elite);
for (const group of q.removed) assert.ok((q.eliteTanks![group.key] ?? 0) <= group.elite);
assert.equal(q.tanks.elite + q.reserves.elite + (option.kept?.kind === 'elite' ? 1 : 0), 3);
}
const legacy = sukRescueOptions(skill, pool, losses);
assert.ok(legacy.every(o => o.eliteReserves === undefined));
assert.ok(legacy.every(o => quoteSukRescue(skill, pool, losses, o).eliteTanks === undefined));
});
void test('normal Suk rescue distinguishes both lost-cyborg origins and single-origin choices remain compact', () => {
const skill = { leader: 'ixians-0', mode: 'normal' as const };
const losses = { normal: 0, elite: 2, paidNormal: 0, paidElite: 2 };
const pool = [{ key: 'wind_pass:14', normal: 0, elite: 1 }, { key: 'wind_pass:15', normal: 0, elite: 1 }];
const options = sukRescueOptions(skill, pool, losses, true);
assert.equal(options.length, 2);
assert.ok(options.every(o => !o.kept && o.eliteReserves));
assert.deepEqual(options.map(o => quoteSukRescue(skill, pool, losses, o, true).eliteTanks), [{ 'wind_pass:14': 1 }, { 'wind_pass:15': 1 }]);
const unique = sukRescueOptions(skill, [{ key: 'wind_pass:14', normal: 0, elite: 2 }], losses, true);
assert.deepEqual(unique, [{ normal: 0, elite: 1, kept: null }]);
assert.deepEqual(quoteSukRescue(skill, [{ key: 'wind_pass:14', normal: 0, elite: 2 }], losses, unique[0], true).eliteTanks, { 'wind_pass:14': 1 });
});
+1 -1
View File
@@ -247,7 +247,7 @@ void test('two-origin movement keeps separate sources and elites, one shared arr
unchanged(g, { ...a, forces: { [first]: 2 } }, /exactly two/);
const far = fixture();
const selected = gather(far, undefined, 2);
unchanged(far, selected.a, /more than 1/);
unchanged(far, selected.a, /Each selected origin.*own movement range/);
});
void test('Fremen extra range preserves a real Karama response and replacement keeps Planetologist', () => {
+2 -2
View File
@@ -56,7 +56,7 @@ function exchange(g: Game, owner: string) {
return act(g, owner, { type: 'decision', event, cards: [r.drawn[0], r.eligible[0]] });
}
void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Advanced, Ixian and module gates remain closed', () => {
void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Advanced, Richese and module gates remain closed', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedTleilaxuSkillsGame({ requestedSkill: card.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 't')!.skill, card.id);
@@ -64,7 +64,7 @@ void test('all fourteen skills admit genuine Basic Tleilaxu setup while public,
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
stable(game);
}
for (const mutate of [(g: Game) => {g.advanced = true;}, (g: Game) => {g.players[1] = newPlayer('x','Ixian','ixians');},
for (const mutate of [(g: Game) => {g.advanced = true;}, (g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
(g: Game) => {g.discoveryEnabled = true;}, (g: Game) => {g.expansions.push('choam');}]) {
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);