Connect Basic CHOAM skills and preserve market Ghola choices

Connect ordinary Leader Skill paths to Basic CHOAM with base opponents,
the real 35-card Treachery deck and all fourteen skills. Retain wider
development initializer behavior and existing mode/publication gates.

Pause a declared sale until its Ghola revival and private skill choice
finish. Bind the actual revived leader, offer event and completion to the
saved interruption. Preserve nested Truthtrance disposal and exact sale
responses; canceling a resumed sale leaves the free revival completed.
Keep force-only and retained-skill revivals free of replacement offers.

Add genuine setup, native card-sale, mandatory disposal, private custody,
corruption and concurrent authenticated recovery regressions plus a
two-through-six-player complete-game sample profile. Record the unresolved
Sandmaster/native-HMS eligibility question without inventing a bonus.

Validation: types, lint, 5101 offline tests and production build passed.
Five genuine 2-6 player CHOAM Skills games completed with zero rejected
actions. Independent review and 50 focused compatibility tests passed.
Browser Y2UJGMCS exercised real setup, Ghola draw, exact private refresh,
mobile assignment and resumed sale paying two spice once. Screenshot
capture timed out; full visual acceptance remains open.
All 1,583 prior rooms, 3,351 seats and auxiliary records remain intact;
one QA room and human seat were added. No restart, reset or automation.

Full AI implementation and tuning remain deferred until all non-AI
features are complete, then target roughly 75% for each adjacent tier.
This commit is contained in:
Quintine committed 2026-09-21 15:10:29 +10:00
1 parent 3efda2128e
commit d1bf33d0bc
20 files changed
+1479 -31

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { treacheryDeck } from '../game/cards';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
type Action,
type Game,
type Player,
} from '../game/engine';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import { traitorDeck } from '../game/traitors';
export const CHOAM_SKILLS_SEATS = {
choam: 'c',
atreides: 'a',
emperor: 'e',
} as const;
export type ChoamSkillsFaction = keyof typeof CHOAM_SKILLS_SEATS;
export type ChoamSkillsFixtureOptions = {
requestedSkill?: LeaderSkillId;
skillOwner?: ChoamSkillsFaction;
seats?: Readonly<Record<ChoamSkillsFaction, string>>;
code?: string;
};
const names: Record<ChoamSkillsFaction, string> = {
choam: 'CHOAM',
atreides: 'Atreides',
emperor: 'Emperor',
};
export const reloadChoamSkillsGame = (game: Game): Game =>
JSON.parse(JSON.stringify(game)) as Game;
export function choamSkillsPlayer(game: Game, id: string): Player {
const player = game.players.find((candidate) => candidate.id === id);
assert.ok(player);
return player;
}
export function rejectChoamSkillsAction(
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 only physical inventories; projections and history are not custody. */
export function assertChoamSkillsCustody(game: Game): void {
const skills = game.leaderSkills!;
const physicalSkills = [
...skills.deck,
...Object.values(skills.offers).flatMap((offer) => offer.cards),
...skills.assignments.map((assignment) => assignment.skill),
];
const canonicalSkills = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physicalSkills.length, 14);
assert.equal(new Set(physicalSkills).size, 14);
assert.deepEqual([...physicalSkills].sort(), [...canonicalSkills].sort());
const physicalTreachery = [
...game.deck,
...game.discard,
...game.players.flatMap((player) => player.hand),
...(game.auction?.cards.slice(
game.auction.index + (game.currentAuctionSale ? 1 : 0),
) ?? []),
].map((card) => card.id);
const canonicalTreachery = treacheryDeck(['choam']).map((card) => card.id);
assert.equal(physicalTreachery.length, 35);
assert.equal(new Set(physicalTreachery).size, 35);
assert.deepEqual(physicalTreachery.sort(), canonicalTreachery.sort());
for (const player of game.players) {
const forces =
player.reserves +
player.tanks +
Object.values(player.forces).reduce((sum, count) => sum + count, 0);
assert.equal(
forces,
20,
`${player.name} must retain all twenty physical forces`,
);
}
if (game.status === 'playing' || game.status === 'finished') {
const physicalTraitors = [
...game.traitorReserve!,
...game.players.flatMap((player) => player.traitors),
];
const canonicalTraitors = traitorDeck(game.players, game.advanced);
assert.equal(physicalTraitors.length, canonicalTraitors.length);
assert.equal(new Set(physicalTraitors).size, physicalTraitors.length);
assert.deepEqual(physicalTraitors.sort(), canonicalTraitors.sort());
}
}
/** Real ready lobby and setup deal; only the production skill shuffle is controlled. */
export function initializedChoamSkillsOffers(
options: ChoamSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'planetologist';
const skillOwner = options.skillOwner ?? 'choam';
const seats = options.seats ?? CHOAM_SKILLS_SEATS;
const factions = (
[skillOwner, 'choam', 'atreides', 'emperor'] as ChoamSkillsFaction[]
).filter((faction, index, all) => all.indexOf(faction) === index);
const first = factions[0];
let game = createGame(
options.code ?? 'CHOSKILL',
newPlayer(seats[first], names[first], first),
false,
['choam'],
);
for (const faction of factions.slice(1))
joinGame(game, newPlayer(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, 'leaderSkills');
assert.equal(
game.leaderSkills!.offers[seats[skillOwner]].cards[0],
requestedSkill,
);
assertChoamSkillsCustody(game);
return game;
}
/** Complete genuine skill assignment and ordinary Traitor selection. */
export function completedChoamSkillsGame(
options: ChoamSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'planetologist';
const seats = options.seats ?? CHOAM_SKILLS_SEATS;
const requestedOwner = seats[options.skillOwner ?? 'choam'];
let game = initializedChoamSkillsOffers(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');
for (const player of game.players) {
const choice = choamSkillsPlayer(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);
assertChoamSkillsCustody(game);
return game;
}
+89
View File
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { applyAction, type Action, type Game } from '../game/engine';
import {
assertChoamSkillsCustody,
choamSkillsPlayer,
completedChoamSkillsGame,
reloadChoamSkillsGame,
} from './choam-skills-fixture';
export const actChoamGhola = (game: Game, owner: string, action: Action): Game =>
reloadChoamSkillsGame(applyAction(reloadChoamSkillsGame(game), owner, action));
export function passChoamGholaResponses(game: Game): Game {
for (let step = 0; game.response && step < 20; step++) {
const player = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
assert.ok(player);
game = actChoamGhola(game, player.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
/** Genuine setup, conserved staged positions, then an actual lethal battle and phase-end sale. */
export function stagedMarketGhola(
initial = completedChoamSkillsGame({ requestedSkill: 'warmaster' }),
keepAssignedSkill = false,
) {
let game = reloadChoamSkillsGame(initial);
const owner = game.players.find(player => player.faction === 'choam')!.id;
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
const assignment = game.leaderSkills!.assignments.find(candidate => candidate.owner === owner)!;
assert.equal(assignment.skill, 'warmaster');
const leader = keepAssignedSkill
? choamSkillsPlayer(game, owner).leaders.find(candidate => candidate.id !== assignment.leader)!.id
: assignment.leader;
const lostSkill = assignment.skill;
for (const player of game.players) {
game.deck.push(...player.hand);
player.hand = [];
player.forces = [owner, opponent].includes(player.id) ? { 'wind_pass:14': 8 } : {};
player.reserves = [owner, opponent].includes(player.id) ? 12 : 20;
player.tanks = 0;
player.battleLosses = 0;
player.spice = 20;
}
Object.assign(game, { phase: 6, active: owner, storm: 18, ready: [],
order: [owner, opponent, ...game.players.map(player => player.id).filter(id => ![owner, opponent].includes(id))],
decision: null, response: null, phaseOpening: null, spice: {} });
const take = (id: 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];
choamSkillsPlayer(game, id).hand.push(card);
return card.id;
};
const ghola = take(owner, card => card.effect === 'ghola');
const sale = take(owner, card => card.kind === 'worthless');
const poison = take(opponent, card => card.kind === 'poison');
const karama = take(opponent, card => card.effect === 'karama');
assertChoamSkillsCustody(game);
game = actChoamGhola(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
for (let step = 0; step < 30; step++) {
if (game.response) game = passChoamGholaResponses(game);
else if (game.decision?.kind === 'leaderSkillVisibility')
game = actChoamGhola(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
else if (game.battle?.preparation)
game = actChoamGhola(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
assert.equal(game.decision, null);
assert.equal(game.battle?.preparation, undefined);
game = actChoamGhola(game, owner, { type: 'battlePlan', dial: 0, leader });
const opponentLeader = choamSkillsPlayer(game, opponent).leaders.find(candidate =>
!candidate.dead && !game.leaderSkills!.assignments.some(skill => skill.leader === candidate.id))!;
game = actChoamGhola(game, opponent, { type: 'battlePlan', dial: 4, leader: opponentLeader.id, weapon: poison });
game = actChoamGhola(game, owner, { type: 'traitorCall', call: false });
game = actChoamGhola(game, opponent, { type: 'traitorCall', call: false });
assert.equal(game.decision?.kind, 'battleCards');
game = actChoamGhola(game, opponent, { type: 'decision', discard: [] });
assert.equal(choamSkillsPlayer(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, true);
assert.equal(game.leaderSkills!.assignments.some(candidate => candidate.owner === owner), keepAssignedSkill);
assert.equal(game.leaderSkills!.deck.filter(skill => skill === lostSkill).length, keepAssignedSkill ? 0 : 1);
assert.equal(game.phase, 6);
assert.equal(game.decision?.kind, 'choamMarket');
game = actChoamGhola(game, owner, { type: 'decision', mode: 'sell', card: sale });
assert.equal(game.response?.kind, 'choamSale');
assertChoamSkillsCustody(game);
return { game, owner, opponent, leader, ghola, sale, karama, lostSkill };
}
+322
View File
@@ -0,0 +1,322 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import { webcrypto } from 'node:crypto';
import ts from 'typescript';
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
assertChoamSkillsCustody as custody,
choamSkillsPlayer as player,
reloadChoamSkillsGame as reload,
rejectChoamSkillsAction as reject,
} from './choam-skills-fixture';
import { actChoamGhola as act, passChoamGholaResponses, stagedMarketGhola } from './choam-skills-ghola-fixture';
function stable(game: Game) {
custody(game);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
}
void test('market Ghola waits for the own revived leader skill offer before restoring its declared sale', () => {
const staged = stagedMarketGhola();
const { owner, opponent, leader, ghola, sale } = staged;
let game = staged.game;
stable(game);
const originalResponse = structuredClone(game.response);
const originalMarket = structuredClone(game.choamMarket);
const spice = player(game, owner).spice;
game = act(game, owner, { type: 'card', card: ghola, leader });
assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, false);
assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.usedAt, undefined);
assert.equal(player(game, owner).spice, spice, 'Ghola is free.');
assert.equal(game.discard.filter(card => card.id === ghola).length, 1);
assert.equal(game.response, null, 'The sale waits while the replacement offer is undecided.');
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.deepEqual(game.pendingChoamMarketGhola?.response, originalResponse);
assert.deepEqual(game.choamMarket, originalMarket);
assert.equal(game.pendingChoamMarketGhola?.stage, 'complete');
const offer = game.leaderSkills!.offers[owner];
assert.deepEqual(offer.cards, []);
assert.equal(offer.leader, leader);
assert.deepEqual(viewGame(game, owner).leaderSkills!.offer, offer);
assert.equal(viewGame(game, opponent).leaderSkills!.offer, null);
stable(game);
reject(game, opponent, { type: 'leaderSkill', event: offer.event, mode: 'draw' });
game = act(game, owner, { type: 'leaderSkill', event: offer.event, mode: 'draw' });
assert.equal(game.response, null);
const drawn = game.leaderSkills!.offers[owner].cards;
assert.equal(drawn.length, 2);
assert.equal(viewGame(game, opponent).leaderSkills!.offer, null);
reject(game, owner, { type: 'leaderSkill', event: offer.event, mode: 'decline' });
reject(game, owner, { type: 'leaderSkill', event: offer.event, skill: drawn[0], leader: player(game, owner).leaders.find(candidate => candidate.id !== leader)!.id });
stable(game);
game = act(game, owner, { type: 'leaderSkill', event: offer.event, skill: drawn[0], leader });
assert.deepEqual(game.response, originalResponse);
assert.equal(game.pendingChoamMarketGhola, null);
assert.equal(game.leaderSkills!.offers[owner], undefined);
assert.equal(game.leaderSkills!.assignments.find(assignment => assignment.owner === owner)!.leader, leader);
stable(game);
reject(game, owner, { type: 'leaderSkill', event: offer.event, mode: 'draw' });
game = passChoamGholaResponses(game);
assert.equal(game.decision?.kind, 'choamMarket');
assert.equal(player(game, owner).spice, spice + 2);
assert.equal(game.discard.filter(card => card.id === sale).length, 1);
assert.equal(game.discard.filter(card => card.id === ghola).length, 1);
stable(game);
});
void test('declining the Ghola replacement and canceling its restored sale preserve the sunk Ghola and living unskilled leader', () => {
const staged = stagedMarketGhola();
const { owner, opponent, leader, ghola, sale, karama, lostSkill } = staged;
let game = staged.game;
// A response pass predating the interruption must not be forgotten on restore.
game = act(game, owner, { type: 'passResponse' });
const originalResponse = structuredClone(game.response);
const spice = Object.fromEntries(game.players.map(candidate => [candidate.id, candidate.spice]));
const forces = Object.fromEntries(game.players.map(candidate => [candidate.id, {
forces: candidate.forces, reserves: candidate.reserves, tanks: candidate.tanks,
}]));
game = act(game, owner, { type: 'card', card: ghola, leader });
assert.equal(game.decision?.kind, 'leaderSkillRevival');
const event = game.leaderSkills!.offers[owner].event;
stable(game);
game = act(game, owner, { type: 'leaderSkill', event, mode: 'decline' });
assert.deepEqual(game.response, originalResponse);
assert.equal(game.pendingChoamMarketGhola, null);
assert.equal(game.leaderSkills!.offers[owner], undefined);
assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.owner === owner), false);
assert.equal(game.leaderSkills!.deck.filter(skill => skill === lostSkill).length, 1);
stable(game);
game = act(game, opponent, { type: 'card', card: karama, mode: 'cancel' });
game = passChoamGholaResponses(game);
assert.equal(game.decision?.kind, 'choamMarket');
assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, false);
assert.equal(game.discard.filter(card => card.id === ghola).length, 1);
assert.equal(game.discard.filter(card => card.id === karama).length, 1);
assert.equal(game.discard.some(card => card.id === sale), false);
assert.equal(player(game, owner).hand.some(card => card.id === sale), true);
assert.deepEqual(Object.fromEntries(game.players.map(candidate => [candidate.id, candidate.spice])), spice);
assert.deepEqual(Object.fromEntries(game.players.map(candidate => [candidate.id, {
forces: candidate.forces, reserves: candidate.reserves, tanks: candidate.tanks,
}])), forces);
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Leader Skill lost').length, 1);
stable(game);
reject(game, owner, { type: 'leaderSkill', event, mode: 'decline' });
reject(game, owner, { type: 'decision', mode: 'sell', card: sale });
game = act(game, owner, { type: 'decision', done: true });
assert.equal(game.phase, 7);
assert.equal(game.choamMarket, null);
assert.equal(game.pendingChoamMarketGhola, null);
assert.equal(game.leaderSkills!.offers[owner], undefined);
stable(game);
});
void test('invalid Ghola leader targets cannot consume the card, create a replacement offer or alter the pending sale', () => {
const { game, owner, opponent, leader, ghola } = stagedMarketGhola();
const targets = ['missing-leader', player(game, owner).leaders.find(candidate => !candidate.dead)!.id,
player(game, opponent).leaders[0].id];
for (const target of targets)
reject(game, owner, { type: 'card', card: ghola, leader: target }, /dead leader|eligible leader/i);
reject(game, opponent, { type: 'card', card: ghola, leader }, /Karama|card|response/i);
const captured = reload(game);
player(captured, owner).leaders.find(candidate => candidate.id === leader)!.capturedBy = opponent;
// A malformed saved captured target is rejected before any physical disposal.
reject(captured, owner, { type: 'card', card: ghola, leader });
assert.equal(game.leaderSkills!.offers[owner], undefined);
assert.equal(game.pendingChoamMarketGhola, undefined);
assert.equal(player(game, owner).hand.some(card => card.id === ghola), true);
stable(game);
});
void test('the interrupted sale receipt stays bound to its actor and physical Ghola through the private skill choice', () => {
const staged = stagedMarketGhola();
const { owner, opponent, leader, ghola, sale } = staged;
const game = act(staged.game, owner, { type: 'card', card: ghola, leader });
assert.equal(game.decision?.kind, 'leaderSkillRevival');
const event = game.leaderSkills!.offers[owner].event;
const mutations: ((saved: Game) => void)[] = [
saved => { saved.pendingChoamMarketGhola!.player = opponent; },
saved => { saved.pendingChoamMarketGhola!.card = sale; },
saved => { saved.pendingChoamMarketGhola!.discardSequence = 0; },
saved => { saved.pendingChoamMarketGhola!.market.sale!.price++; },
];
for (const mutate of mutations) {
const saved = reload(game);
mutate(saved);
const before = structuredClone(saved);
reject(saved, owner, { type: 'leaderSkill', event, mode: 'decline' });
assert.throws(() => viewGame(saved, owner));
assert.throws(() => normalizeAutomaticGame(saved));
assert.deepEqual(saved, before);
}
stable(game);
});
void test('all four bot profiles can finish both private replacement stages without duplicating market settlement', () => {
const staged = stagedMarketGhola();
const { owner, leader, ghola, sale } = staged;
const interrupted = act(staged.game, owner, { type: 'card', card: ghola, leader });
for (const difficulty of DIFFICULTIES) {
let game = reload(interrupted);
for (let stage = 0; stage < 2; stage++) {
assert.equal(game.decision?.kind, 'leaderSkillRevival');
const view = viewGame(game, owner);
view.players.find(candidate => candidate.id === owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'leaderSkill');
assert.ok(action, `${difficulty} has a legal replacement action at stage ${stage}.`);
for (const other of game.players.filter(candidate => candidate.id !== owner)) {
const otherView = viewGame(game, other.id);
otherView.players.find(candidate => candidate.id === other.id)!.bot = difficulty;
assert.equal(otherView.leaderSkills!.offer, null);
assert.equal(botActions(otherView).some(candidate => candidate.type === 'leaderSkill'), false);
}
game = act(game, owner, action);
stable(game);
}
assert.equal(game.response?.kind, 'choamSale');
assert.equal(game.pendingChoamMarketGhola, null);
game = passChoamGholaResponses(game);
assert.equal(game.discard.filter(card => card.id === ghola).length, 1);
assert.equal(game.discard.filter(card => card.id === sale).length, 1);
assert.equal(player(game, owner).spice, player(interrupted, owner).spice + 2);
stable(game);
}
});
void test('the market Ghola cannot change its revived leader, replace its skill event or omit the pending choice', () => {
const staged = stagedMarketGhola();
const { owner, leader, ghola } = staged;
const game = act(staged.game, owner, { type: 'card', card: ghola, leader });
const event = game.leaderSkills!.offers[owner].event;
assert.equal(game.pendingChoamMarketGhola!.leaderSkill!.leader, leader);
assert.equal(game.pendingChoamMarketGhola!.leaderSkill!.offer, event);
assert.equal(game.pendingChoamMarketGhola!.leaderSkill!.completed, false);
const wrongLeader = player(game, owner).leaders.find(candidate => candidate.id !== leader)!.id;
const mutations: ((saved: Game) => void)[] = [
saved => { saved.leaderSkills!.offers[owner].leader = wrongLeader; },
saved => {
saved.leaderSkills!.offers[owner].event = 'changed-skill-event';
if (saved.decision?.kind === 'leaderSkillRevival') saved.decision.event = 'changed-skill-event';
},
saved => { saved.decision = null; },
saved => { delete saved.leaderSkills!.offers[owner]; },
saved => { delete saved.leaderSkills!.offers[owner]; saved.decision = null; },
saved => { delete saved.pendingChoamMarketGhola!.leaderSkill; },
saved => { saved.pendingChoamMarketGhola = null; },
saved => { saved.pendingChoamMarketGhola!.leaderSkill!.leader = wrongLeader; },
saved => { saved.pendingChoamMarketGhola!.leaderSkill!.offer = 'changed-skill-event'; },
saved => { saved.pendingChoamMarketGhola!.leaderSkill!.completed = true; },
];
for (const mutate of mutations) {
const saved = reload(game);
mutate(saved);
const before = structuredClone(saved);
reject(saved, owner, { type: 'leaderSkill', event, mode: 'draw' });
assert.throws(() => viewGame(saved, owner));
assert.throws(() => normalizeAutomaticGame(saved));
assert.deepEqual(saved, before);
}
const drawn = act(game, owner, { type: 'leaderSkill', event, mode: 'draw' });
const missing = reload(drawn);
// Preserve all fourteen physical cards while trying to erase the required choice.
missing.leaderSkills!.deck.push(...missing.leaderSkills!.offers[owner].cards);
delete missing.leaderSkills!.offers[owner];
missing.decision = null;
custody(missing);
reject(missing, owner, { type: 'passResponse' });
assert.throws(() => normalizeAutomaticGame(missing));
stable(drawn);
});
void test('market Ghola reviving an unskilled own leader preserves the existing assignment and creates no replacement offer', () => {
const staged = stagedMarketGhola(undefined, true);
const { owner, leader, ghola, sale } = staged;
const skills = structuredClone(staged.game.leaderSkills);
const response = structuredClone(staged.game.response);
let game = act(staged.game, owner, { type: 'card', card: ghola, leader });
assert.deepEqual(game.leaderSkills, skills);
assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, false);
assert.equal(game.pendingChoamMarketGhola, null);
assert.deepEqual(game.response, response);
assert.equal(game.decision, null);
stable(game);
game = passChoamGholaResponses(game);
assert.equal(game.discard.filter(card => card.id === ghola).length, 1);
assert.equal(game.discard.filter(card => card.id === sale).length, 1);
assert.deepEqual(game.leaderSkills, skills);
stable(game);
});
void test('market Ghola force revival creates no Leader Skill offer and resumes the exact sale', () => {
const staged = stagedMarketGhola();
const { owner, ghola, leader } = staged;
const before = player(staged.game, owner);
const skills = structuredClone(staged.game.leaderSkills);
const game = act(staged.game, owner, { type: 'card', card: ghola, amount: 2, elite: 0 });
assert.equal(player(game, owner).tanks, before.tanks - 2);
assert.equal(player(game, owner).reserves, before.reserves + 2);
assert.equal(player(game, owner).leaders.find(candidate => candidate.id === leader)!.dead, true);
assert.deepEqual(game.leaderSkills, skills);
assert.deepEqual(game.response, staged.game.response);
assert.equal(game.pendingChoamMarketGhola, null);
stable(game);
});
void test('a saved Truthtrance disposal restores its held Ghola skill choice before the original sale', () => {
const staged = stagedMarketGhola();
const { owner, opponent, leader, ghola, sale } = staged;
let game = staged.game;
const index = game.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(index >= 0);
const truth = game.deck.splice(index, 1)[0];
player(game, opponent).hand.push(truth);
const saleName = player(game, owner).hand.find(card => card.id === sale)!.name;
const originalResponse = structuredClone(game.response);
game = act(game, owner, { type: 'card', card: ghola, leader });
const event = game.leaderSkills!.offers[owner].event;
game = act(game, opponent, { type: 'card', card: truth.id });
while (game.truthtrance?.stage === 'priority') {
const voter = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!;
game = act(game, voter.id, { type: 'truthPass' });
}
game = act(game, opponent, { type: 'truthAsk', question: {
kind: 'fact', target: owner, fact: { kind: 'hand', name: saleName },
} });
stable(game);
// Observe the unchanged production dispatcher before its wrapper retires the
// mandatory physical discard; this is a real continuation, not invented history.
const observed: { applyActionInner?: (state: Game, id: string, action: Action) => Game } = {};
runInNewContext(ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') + '\nexport { applyActionInner };\n',
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } },
).outputText, { exports: observed, require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto: webcrypto, structuredClone, TextEncoder, JSON });
const saved = reload(observed.applyActionInner!(game, owner, { type: 'truthAnswer', answer: 'yes' }));
assert.equal(saved.decision, null);
assert.equal(saved.pendingTreacheryDiscard!.continuation.kind, 'truthtranceDiscard');
assert.equal(saved.pendingChoamMarketGhola!.stage, 'complete');
assert.equal(viewGame(saved, owner).leaderSkills!.offer!.event, event);
const missing = reload(saved);
const continuation = missing.pendingTreacheryDiscard!.continuation;
assert.equal(continuation.kind, 'truthtranceDiscard');
if (continuation.kind === 'truthtranceDiscard') continuation.resume.decision = null;
assert.throws(() => normalizeAutomaticGame(missing));
game = reload(normalizeAutomaticGame(saved));
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.truthtrance, null);
assert.deepEqual(game.decision, { kind: 'leaderSkillRevival', player: owner, event });
assert.equal(game.response, null);
assert.equal(game.discard.filter(card => card.id === truth.id).length, 1);
assert.equal(game.discard.filter(card => card.id === ghola).length, 1);
stable(game);
game = act(game, owner, { type: 'leaderSkill', event, mode: 'decline' });
assert.deepEqual(game.response, originalResponse);
game = passChoamGholaResponses(game);
assert.equal(player(game, owner).spice, player(saved, owner).spice + 2);
assert.equal(game.discard.filter(card => card.id === sale).length, 1);
stable(game);
});
+451
View File
@@ -0,0 +1,451 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TERRITORIES, location, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
basicChoamLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported,
} from '../game/leader-skill-profile';
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
import { sandmasterModeSupported } from '../game/sandmaster-movement';
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { planetologistMoveDraft } from '../components/planetologist-movement';
import { sandmasterMoveDraft } from '../components/sandmaster-movement';
import {
assertChoamSkillsCustody,
choamSkillsPlayer as player,
completedChoamSkillsGame,
initializedChoamSkillsOffers,
reloadChoamSkillsGame as reload,
rejectChoamSkillsAction as reject,
} from './choam-skills-fixture';
function movementPosition(game: Game): Game {
for (const participant of game.players) {
participant.forces = {};
participant.reserves = 20;
participant.tanks = 0;
participant.spice = 20;
participant.moved = 0;
participant.shipped = true;
}
Object.assign(game, {
phase: 5,
active: 'c',
order: ['c', 'a', 'e'],
storm: 18,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
return game;
}
function passResponses(state: Game): Game {
let game = state;
while (game.response) {
const passer = game.players.find((candidate) => {
const controls = viewGame(game, candidate.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(passer);
game = applyAction(game, passer.id, { type: 'passResponse' });
}
return game;
}
function holdByName(game: Game, owner: string, name: string) {
const index = game.deck.findIndex((card) => card.name === name);
assert.ok(index >= 0, name);
const [card] = game.deck.splice(index, 1);
player(game, owner).hand.push(card);
return card;
}
void test('all fourteen production skill faces deal through genuine Basic CHOAM setup', () => {
for (const skill of LEADER_SKILL_CARDS) {
const game = completedChoamSkillsGame({ requestedSkill: skill.id });
assert.equal(
game.leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'c',
)!.skill,
skill.id,
);
assert.equal(
player(game, 'c').leaders.some((leader) => leader.id === 'choam-auditor'),
false,
);
assert.equal(basicChoamLeaderSkillsProfile(game), true);
for (const participant of game.players) {
const view = viewGame(game, participant.id);
assert.equal(view.leaderSkills!.offer, null);
assert.equal(view.leaderSkills!.assignments.length, 3);
assert.deepEqual(viewGame(reload(game), participant.id), view);
}
assertChoamSkillsCustody(game);
}
});
void test('all bot profiles make a legal private skill choice and exact CHOAM profile gates are shared', () => {
const offer = initializedChoamSkillsOffers({
requestedSkill: 'planetologist',
});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(offer), 'c');
view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty;
const action = botActions(view).find(
(candidate) => candidate.type === 'leaderSkill',
);
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(offer, 'c', action));
}
const game = completedChoamSkillsGame();
for (const gate of [
basicChoamLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported,
bureaucratPaymentModeSupported,
planetologistMovementModeSupported,
sandmasterModeSupported,
smugglerBattleModeSupported,
]) {
assert.equal(gate(game), true, gate.name);
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
}
});
void test('Advanced CHOAM and Basic Richese retain audit admission without entering the exact normal profile', () => {
for (const variant of ['advanced', 'richese'] as const) {
let game = createGame(
variant === 'advanced' ? 'CHOADVAN' : 'CHORICHE',
newPlayer('c', 'CHOAM', 'choam'),
variant === 'advanced',
['choam'],
);
joinGame(
game,
variant === 'richese'
? newPlayer('r', 'Richese', 'richese')
: newPlayer('a', 'Atreides', 'atreides'),
);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const participant of game.players)
game = applyAction(game, participant.id, { type: 'ready' });
game = initializeLeaderSkillsGameForAudit(game);
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(basicChoamLeaderSkillsProfile(game), false);
if (variant === 'advanced')
assert.equal(
player(game, 'c').leaders.some(
(leader) => leader.id === 'choam-auditor',
),
true,
);
}
});
void test('CHOAM Planetologist and Sandmaster controls and bots submit legal shared movement actions', () => {
let game = movementPosition(
completedChoamSkillsGame({ requestedSkill: 'planetologist' }),
);
const from = 'red_chasm:7';
player(game, 'c').forces = { [from]: 3 };
player(game, 'c').reserves = 17;
const target = TERRITORIES.filter((territory) => territory.type === 'sand')
.flatMap((territory) =>
territory.sectors.map((sector) => location(territory.id, sector)),
)
.map(splitLocation)
.find((at) => {
const draft = planetologistMoveDraft(
viewGame(game, 'c'),
'range',
at.territory,
at.sector,
{ [from]: 3 },
{},
);
return draft.action && at.territory !== 'red_chasm';
});
assert.ok(target);
const draft = planetologistMoveDraft(
viewGame(game, 'c'),
'range',
target.territory,
target.sector,
{ [from]: 3 },
{},
);
assert.equal(draft.blocked, null);
assert.ok(draft.action);
game.spice[location(target.territory, target.sector)] = 8;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'c');
view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty;
const action = botActions(view).find(
(candidate) =>
candidate.type === 'move' && candidate.planetologist === 'range',
);
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(game, 'c', action));
}
const moved = applyAction(reload(game), 'c', draft.action);
assert.equal(
player(moved, 'c').forces[location(target.territory, target.sector)],
3,
);
game = movementPosition(
completedChoamSkillsGame({ requestedSkill: 'sandmaster' }),
);
const sandFrom = 'wind_pass_north:17';
player(game, 'c').forces = { [sandFrom]: 3 };
player(game, 'c').reserves = 17;
for (const territory of TERRITORIES)
for (const sector of territory.sectors)
game.spice[location(territory.id, sector)] = 2;
const candidates = TERRITORIES.flatMap((territory) =>
territory.sectors.map((sector) => ({ territory: territory.id, sector })),
);
const control = candidates
.map((to) =>
sandmasterMoveDraft(viewGame(game, 'c'), {
type: 'move',
territory: to.territory,
sector: to.sector,
forces: { [sandFrom]: 3 },
}),
)
.find((candidate) => candidate.action && candidate.collectible.length > 0);
assert.ok(control?.action);
assert.doesNotThrow(() => applyAction(reload(game), 'c', control.action!));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'c');
view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty;
const action = botActions(view).find(
(candidate) => candidate.type === 'move' && candidate.sandmaster,
);
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(game, 'c', action));
}
});
void test('CHOAM Smuggler ships its physical companion and Bureaucrat distinguishes qualifying payments', () => {
let game = movementPosition(
completedChoamSkillsGame({ requestedSkill: 'smuggler' }),
);
player(game, 'c').shipped = false;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'c');
view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty;
const action = botActions(view).find(
(candidate) => candidate.type === 'ship' && candidate.smuggler === true,
);
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(game, 'c', action));
}
const ship: Action = {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
smuggler: true,
};
game = applyAction(game, 'c', ship);
assert.equal(player(game, 'c').forces['carthag:11'], 3);
assert.equal(player(game, 'c').reserves, 17);
assert.equal(player(game, 'c').spice, 18);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assertChoamSkillsCustody(game);
game = movementPosition(
completedChoamSkillsGame({ requestedSkill: 'bureaucrat' }),
);
const own = applyAction(game, 'c', { type: 'bribe', target: 'e', amount: 5 });
assert.notEqual(own.decision?.kind, 'bureaucratPayment');
const small = applyAction(game, 'a', {
type: 'bribe',
target: 'e',
amount: 4,
});
assert.notEqual(small.decision?.kind, 'bureaucratPayment');
const bank = reload(game);
bank.active = 'a';
player(bank, 'a').shipped = false;
const smallBankPayment = applyAction(bank, 'a', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 1,
});
assert.notEqual(smallBankPayment.decision?.kind, 'bureaucratPayment');
game = applyAction(game, 'a', { type: 'bribe', target: 'e', amount: 5 });
assert.equal(game.decision?.kind, 'bureaucratPayment');
assert.equal(game.decision.player, 'c');
const event = game.bureaucratPaymentEvent;
const done = applyAction(reload(game), 'c', {
type: 'decision',
event,
redirect: true,
});
assert.equal(player(done, 'a').spice, 15);
assert.equal(player(done, 'e').bribes, 3);
assert.equal(done.bureaucratPayments!.used.length, 1);
reject(done, 'c', { type: 'decision', event, redirect: true });
assertChoamSkillsCustody(done);
});
void test('native Worthless power executes through its response and preserves physical custody', () => {
let game = movementPosition(
completedChoamSkillsGame({ requestedSkill: 'warmaster' }),
);
player(game, 'c').shipped = false;
const kulon = holdByName(game, 'c', 'Kulon');
holdByName(game, 'e', 'Karama');
game = applyAction(game, 'c', {
type: 'card',
mode: 'choam',
card: kulon.id,
});
assert.equal(game.response?.kind, 'choamWorthless');
assert.equal(
player(game, 'c').hand.some((card) => card.id === kulon.id),
true,
);
game = passResponses(game);
assert.equal(game.choamMovement?.bonus, 1);
assert.equal(game.discard.filter((card) => card.id === kulon.id).length, 1);
assertChoamSkillsCustody(game);
});
void test('native Worthless market sale pays from the bank and preserves the 35-card skill game', () => {
let game = completedChoamSkillsGame({ requestedSkill: 'warmaster' });
const worthless = holdByName(game, 'c', 'Baliset');
holdByName(game, 'e', 'Karama');
const spiceBefore = player(game, 'c').spice;
Object.assign(game, {
phase: 2,
active: null,
ready: [],
decision: null,
response: null,
phaseOpening: null,
choamCharity: { turn: game.turn, canceled: false },
});
for (const participant of game.players)
game = applyAction(game, participant.id, { type: 'ready' });
assert.equal(game.decision?.kind, 'choamMarket');
game = applyAction(game, 'c', {
type: 'decision',
mode: 'sell',
card: worthless.id,
});
assert.equal(game.response?.kind, 'choamSale');
assert.equal(
player(game, 'c').hand.some((card) => card.id === worthless.id),
true,
);
game = passResponses(game);
assert.equal(player(game, 'c').spice, spiceBefore + 2);
assert.equal(
game.discard.filter((card) => card.id === worthless.id).length,
1,
);
assert.equal(game.bureaucratPayments?.pending, undefined);
assertChoamSkillsCustody(game);
});
void test('a real concealed Planetologist green Special is discarded once after battle', () => {
let game = completedChoamSkillsGame({ requestedSkill: 'planetologist' });
const assignment = game.leaderSkills!.assignments.find(
(candidate) => candidate.owner === 'c',
)!;
for (const participant of game.players) {
game.deck.push(...participant.hand);
participant.hand = [];
participant.forces = { 'arrakeen:10': 5 };
participant.reserves = 15;
}
for (const name of ['Poison Tooth', 'Artillery Strike']) {
const ordinary = game.deck.find((card) => card.name === name);
assert.ok(ordinary);
assert.equal(isPlanetologistBattleSpecialCard(ordinary), false);
}
const specialIndex = game.deck.findIndex(isPlanetologistBattleSpecialCard);
assert.ok(specialIndex >= 0);
const [special] = game.deck.splice(specialIndex, 1);
player(game, 'c').hand.push(special);
assert.equal(
['Poison Tooth', 'Artillery Strike'].includes(special.name),
false,
);
Object.assign(game, {
phase: 6,
storm: 18,
order: ['c', 'a', 'e'],
active: 'c',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
game = applyAction(game, 'c', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'a',
});
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, {
type: 'leaderSkillVisibility',
event: decision.event,
hide: decision.player === 'c',
});
}
while (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, {
type: 'declineBattlePower',
});
game = applyAction(game, 'c', {
type: 'battlePlan',
dial: 4,
leader: assignment.leader,
weapon: special.id,
});
game = applyAction(game, 'a', {
type: 'battlePlan',
dial: 0,
leader: player(game, 'a').leaders.find(
(leader) =>
leader.id !==
game.leaderSkills!.assignments.find(
(candidate) => candidate.owner === 'a',
)!.leader,
)!.id,
});
game = applyAction(game, 'c', { type: 'traitorCall', call: false });
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
assert.equal(game.discard.filter((card) => card.id === special.id).length, 1);
assert.equal(
player(game, 'c').hand.some((card) => card.id === special.id),
false,
);
assert.equal(game.pendingWinnerDiscards, null);
assertChoamSkillsCustody(game);
});
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { Action } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { startPrototypeRoom } from '../tools/prototype-room';
import { unitStore } from './fixture-nexus-room-store';
import { assertChoamSkillsCustody as custody } from './choam-skills-fixture';
import { stagedMarketGhola } from './choam-skills-ghola-fixture';
void test('authenticated CHOAM setup and market-Ghola replacement commit once across concurrent restarted writes', async () => {
const f = unitStore(), db: DatabaseSync = f.sqlite;
const clock = { now: () => 10000, sleep: async () => {} };
try {
const created = await f.rooms.createRoom('CHOAM', 'choam', false, ['choam']);
const code = created.view.code;
const others = [await f.rooms.joinRoom(code, 'Atreides', 'atreides'), await f.rooms.joinRoom(code, 'Emperor', 'emperor')];
const auths = await Promise.all([created.token!, ...others.map(p => p.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);
};
const concurrent = async (action: Action) => {
const before = await f.restart().readRoom(code);
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], before.version, action, clock),
f.restart().act(code, auths[0], before.version, action, 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);
const after = await f.restart().readRoom(code);
assert.equal(after.version, before.version + 1);
custody(after);
return after;
};
for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
const ready = await f.restart().readRoom(code), seats = db.prepare('SELECT * FROM seats').all();
let cursor = LEADER_SKILL_CARDS.length - 1;
const target = LEADER_SKILL_CARDS.findIndex(c => c.id === 'warmaster');
mock.method(crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = cursor-- === target ? 0 : 0xffffffff; return array;
});
try { startPrototypeRoom(db, code, ready.version, 'leader-skills'); }
finally { mock.restoreAll(); }
let game = await f.restart().readRoom(code);
const own = auths[0].playerId, offer = game.leaderSkills!.offers[own];
assert.ok(offer.cards.includes('warmaster')); custody(game);
game = await concurrent({ type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader: 'choam-0' });
for (const i of [1, 2]) {
const p = game.players.find(p => p.id === auths[i].playerId)!, next = game.leaderSkills!.offers[p.id];
game = await act(i, { type: 'leaderSkill', event: next.event, skill: next.cards[0], leader: p.leaders[0].id });
}
for (let i = 0; i < auths.length; i++)
game = await act(i, { type: 'traitor', leader: game.players.find(p => p.id === auths[i].playerId)!.traitorChoices[0] });
assert.equal(game.status, 'playing'); custody(game);
// Preserve authenticated seats; only this disposable fixture's game is staged.
const prepared = stagedMarketGhola(game), originalResponse = structuredClone(prepared.game.response);
prepared.game.version = game.version + 1;
assert.equal(db.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(prepared.game), prepared.game.version, code, game.version).changes, 1);
const money = prepared.game.players.find(p => p.id === own)!.spice;
game = await act(0, { type: 'card', card: prepared.ghola, leader: prepared.leader });
assert.equal(game.response, null); assert.equal(game.decision?.kind, 'leaderSkillRevival');
const revival = game.leaderSkills!.offers[own];
assert.deepEqual(revival.cards, []);
for (const i of [1, 2]) {
const view = await f.restart().readSeatView(code, auths[i]);
assert.equal(view.leaderSkills!.offer, null);
assert.equal('pendingChoamMarketGhola' in view, false);
}
game = await act(0, { type: 'leaderSkill', event: revival.event, mode: 'draw' });
const drawn = game.leaderSkills!.offers[own].cards;
assert.equal(drawn.length, 2); assert.equal(game.response, null);
assert.equal((await f.restart().readSeatView(code, auths[1])).leaderSkills!.offer, null);
game = await concurrent({ type: 'leaderSkill', event: revival.event, skill: drawn[0], leader: prepared.leader });
assert.deepEqual(game.response, originalResponse);
assert.equal(game.pendingChoamMarketGhola, null);
assert.equal(game.leaderSkills!.assignments.filter(a => a.owner === own).length, 1);
assert.equal(game.discard.filter(c => c.id === prepared.ghola).length, 1);
game = await act(2, { type: 'card', card: prepared.karama, mode: 'cancel' });
for (let step = 0; game.response && step < 10; step++) {
const i = auths.findIndex(a => !game.response!.passed.includes(a.playerId));
assert.ok(i >= 0); game = await act(i, { type: 'passResponse' });
}
assert.equal(game.response, null);
const owner = game.players.find(p => p.id === own)!;
assert.equal(owner.spice, money);
assert.ok(owner.hand.some(c => c.id === prepared.sale));
assert.equal(owner.leaders.find(l => l.id === prepared.leader)!.dead, false);
assert.equal(game.discard.filter(c => c.id === prepared.ghola).length, 1);
custody(game);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
} finally { mock.restoreAll(); db.close(); }
});
+20
View File
@@ -155,6 +155,26 @@ void test('Ixian Skills samples preserve their full module on saved continuation
assert.equal(existsSync(unsupported), false);
});
void test('CHOAM 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', 'choam-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'choam-skills-2-basic', 1031, false);
const snapshot = join(out, 'failed-choam-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, 'choam-skills-2-basic', 1031, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'choam-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');