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.
323 lines
17 KiB
TypeScript
323 lines
17 KiB
TypeScript
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);
|
|
});
|