Connect Basic Tleilaxu to the full Leader Skills prototype
Admit genuine Basic Tleilaxu/base setup with the full fourteen-skill deck, Ix card inventories and native three Face Dancers. Share public profile guards across existing skill effects while retaining public-start and Advanced gates. Record native own-leader skill offers at successful revival commitment so automatic Tleilaxu response chains preserve the optional private draw. Connect Rihani to unrevealed Face Dancer exchange with exact physical/status custody, private controls and existing legal AI choices. Release completed Rihani's live snapshot only after validated Face Dance admission; keep signed history and current census checks. Zoal Smuggler uses opposing printed disc value at reveal, excluding opposing skill bonuses and his separate revival value. Add genuine setup, response/cancellation, private choice, custody, Face Dance reset, Zoal, legal AI and authenticated concurrent recovery regressions. Add a reproducible two-through-six-player sample profile and saved continuation. Preserve unresolved skill effects, foreign-ghola boundaries and delayed AI tuning. Validation: types, lint, 5,058 offline tests and production build passed. Five full games completed with zero rejected actions and 74 JSON restores. Browser QA CRY4DD4J used genuine setup and an explicitly conserved staged battle; real Rihani draw/exchange and same-state refresh passed on mobile and desktop. Screenshot capture timed out; full visual acceptance remains open. No HTTP endpoint/auth/storage implementation changed; authenticated SQLite recovery was verified. All 1,581 prior rooms, 3,349 seats and auxiliary records were preserved. One new QA room/seat remains. No server restart, database reset or automation.
This commit is contained in:
1 parent
489bd3265d
commit
d3559f65ad
28 files changed
+1324
-97
No files matched your search
@@ -115,6 +115,26 @@ void test('Moritani Skills samples preserve their full module on saved continuat
|
||||
assert.equal(existsSync(unsupported), false);
|
||||
});
|
||||
|
||||
void test('Tleilaxu 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', 'tleilaxu-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
|
||||
assert.equal(result.status, 1);
|
||||
assertFailedEvidence(out, 'tleilaxu-skills-2-basic', 1021, false);
|
||||
const snapshot = join(out, 'failed-tleilaxu-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, 'tleilaxu-skills-2-basic', 1021, true);
|
||||
const unsupported = join(area, 'unsupported');
|
||||
const bad = run(unsupported, '--profile', 'tleilaxu-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');
|
||||
@@ -256,7 +276,7 @@ void test('selected genuine three-player Advanced base sample fails honestly, re
|
||||
join(corruptOut, 'results.json'),
|
||||
).results[0];
|
||||
assert.equal(corruptResult.actions, 0);
|
||||
assert.match(corruptResult.error ?? '', /physical base traitor custody/);
|
||||
assert.match(corruptResult.error ?? '', /physical traitor custody/);
|
||||
|
||||
for (const [label, args] of [
|
||||
['profile', ['--profile', 'base']],
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
beginRihani,
|
||||
chooseRihaniDraw,
|
||||
finishRihani,
|
||||
rihaniSignature,
|
||||
validateRihani,
|
||||
type RihaniReceipt,
|
||||
} from '../game/rihani-decipherer';
|
||||
import {
|
||||
validateNexusTraitorSnapshot,
|
||||
type NexusTraitorSnapshot,
|
||||
} from '../game/nexus-traitor-exchange';
|
||||
|
||||
const universe = Array.from({ length: 10 }, (_, index) => `leader-${index}`);
|
||||
const random = () => 0.37;
|
||||
function snapshot(): NexusTraitorSnapshot {
|
||||
return {
|
||||
reserve: universe.slice(4),
|
||||
players: [
|
||||
{
|
||||
id: 't', faction: 'tleilaxu', traitors: [],
|
||||
faceDancers: [
|
||||
{ leader: universe[0], revealed: false },
|
||||
{ leader: universe[1], revealed: true },
|
||||
{ leader: universe[2], revealed: false },
|
||||
],
|
||||
},
|
||||
{ id: 'a', faction: 'atreides', traitors: [universe[3]] },
|
||||
],
|
||||
};
|
||||
}
|
||||
function begin(normal = true, skilled = true, state = snapshot()) {
|
||||
return beginRihani(state, universe, {
|
||||
event: 'battle-rihani', turn: 2, owner: 't',
|
||||
skill: { leader: 'tleilaxu-0', normal, skilled },
|
||||
}, [], random);
|
||||
}
|
||||
function restore(receipt: RihaniReceipt): RihaniReceipt {
|
||||
return JSON.parse(JSON.stringify(receipt)) as RihaniReceipt;
|
||||
}
|
||||
function resigned(receipt: RihaniReceipt, change: (r: RihaniReceipt) => void) {
|
||||
const changed = restore(receipt);
|
||||
change(changed);
|
||||
changed.signature = rihaniSignature(changed);
|
||||
return changed;
|
||||
}
|
||||
function conserved(receipt: RihaniReceipt) {
|
||||
validateRihani(receipt, universe, receipt.state);
|
||||
validateNexusTraitorSnapshot(receipt.state, universe);
|
||||
assert.deepEqual(receipt.state.players[0].traitors, []);
|
||||
assert.deepEqual(receipt.state.players[1], receipt.before.players[1]);
|
||||
}
|
||||
|
||||
void test('native Tleilaxu Rihani inspects first, commits a separate draw and keeps one new unrevealed Face Dancer', () => {
|
||||
const original = snapshot(), frozen = structuredClone(original);
|
||||
let receipt = begin(true, true, original);
|
||||
assert.deepEqual(original, frozen);
|
||||
assert.equal(receipt.stage, 'offer');
|
||||
assert.equal(receipt.peeked.length, 2);
|
||||
assert.equal(new Set(receipt.peeked).size, 2);
|
||||
assert.ok(receipt.peeked.every((id) => original.reserve.includes(id)));
|
||||
assert.deepEqual(receipt.drawn, []);
|
||||
assert.deepEqual(receipt.eligible, [universe[0], universe[2]]);
|
||||
assert.deepEqual(receipt.state.players, original.players);
|
||||
conserved(receipt);
|
||||
|
||||
receipt = restore(receipt);
|
||||
const offered = structuredClone(receipt);
|
||||
const pending = chooseRihaniDraw(receipt, universe, receipt.state, true);
|
||||
assert.deepEqual(receipt, offered);
|
||||
assert.equal(pending.stage, 'return');
|
||||
assert.deepEqual(pending.drawn, offered.deckAfterPeek.slice(0, 2));
|
||||
assert.deepEqual(pending.state.players[0].faceDancers, [
|
||||
...original.players[0].faceDancers!,
|
||||
...pending.drawn.map((leader) => ({ leader, revealed: false })),
|
||||
]);
|
||||
conserved(restore(pending));
|
||||
|
||||
for (const kept of pending.drawn) {
|
||||
const restored = restore(pending), frozenPending = structuredClone(restored);
|
||||
const done = finishRihani(restored, universe, restored.state, kept, universe[0], random);
|
||||
assert.deepEqual(restored, frozenPending);
|
||||
assert.equal(done.stage, 'complete');
|
||||
assert.equal(done.kept, kept);
|
||||
assert.equal(done.given, universe[0]);
|
||||
assert.deepEqual(done.state.players[0].faceDancers, [
|
||||
...original.players[0].faceDancers!.slice(1),
|
||||
{ leader: kept, revealed: false },
|
||||
]);
|
||||
assert.ok(done.state.reserve.includes(universe[0]));
|
||||
assert.ok(done.state.reserve.includes(pending.drawn.find((id) => id !== kept)!));
|
||||
assert.ok(!done.state.reserve.includes(kept));
|
||||
conserved(restore(done));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Tleilaxu Rihani normal-only inspection, lower-only exchange and pre-draw decline preserve their distinct bands', () => {
|
||||
const normal = begin(true, false);
|
||||
assert.equal(normal.stage, 'complete');
|
||||
assert.equal(normal.peeked.length, 2);
|
||||
assert.deepEqual(normal.drawn, []);
|
||||
assert.deepEqual(normal.state.players, normal.before.players);
|
||||
conserved(normal);
|
||||
|
||||
const lower = begin(false, true);
|
||||
assert.equal(lower.stage, 'offer');
|
||||
assert.deepEqual(lower.peeked, []);
|
||||
assert.deepEqual(lower.state.reserve, lower.before.reserve);
|
||||
const drawn = chooseRihaniDraw(lower, universe, lower.state, true);
|
||||
const complete = finishRihani(drawn, universe, drawn.state, drawn.drawn[0], universe[2], random);
|
||||
conserved(complete);
|
||||
|
||||
const both = begin();
|
||||
const declined = chooseRihaniDraw(restore(both), universe, both.state, false);
|
||||
assert.equal(declined.stage, 'complete');
|
||||
assert.deepEqual(declined.state, both.state);
|
||||
assert.deepEqual(declined.peeked, both.peeked);
|
||||
assert.deepEqual(declined.drawn, []);
|
||||
conserved(restore(declined));
|
||||
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true), /already been answered/);
|
||||
});
|
||||
|
||||
void test('Rihani cannot replace a revealed Face Dancer or return both newly drawn cards', () => {
|
||||
const offer = begin();
|
||||
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
|
||||
for (const given of [universe[1], ...drawn.drawn]) {
|
||||
const before = structuredClone(drawn);
|
||||
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random), /unused Traitor/);
|
||||
assert.deepEqual(drawn, before);
|
||||
}
|
||||
const allRevealed = snapshot();
|
||||
allRevealed.players[0].faceDancers!.forEach((card) => { card.revealed = true; });
|
||||
const noExchange = begin(true, true, allRevealed);
|
||||
assert.deepEqual(noExchange.eligible, []);
|
||||
assert.equal(noExchange.stage, 'complete');
|
||||
assert.equal(noExchange.peeked.length, 2);
|
||||
assert.deepEqual(noExchange.state.players, allRevealed.players);
|
||||
conserved(noExchange);
|
||||
});
|
||||
|
||||
void test('Rihani rejects status changes in old, drawn and retained Face Dancers even with a recomputed signature', () => {
|
||||
const offer = begin();
|
||||
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
|
||||
const complete = finishRihani(drawn, universe, drawn.state, drawn.drawn[0], universe[0], random);
|
||||
const invalid = [
|
||||
resigned(offer, (r) => { r.state.players[0].faceDancers![1].revealed = false; }),
|
||||
resigned(drawn, (r) => { r.state.players[0].faceDancers![0].revealed = true; }),
|
||||
resigned(drawn, (r) => { r.state.players[0].faceDancers![3].revealed = true; }),
|
||||
resigned(complete, (r) => { r.state.players[0].faceDancers![0].revealed = false; }),
|
||||
resigned(complete, (r) => { r.state.players[0].faceDancers![2].revealed = true; }),
|
||||
resigned(drawn, (r) => { r.eligible.push(universe[1]); }),
|
||||
];
|
||||
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe), /custody|drawn cards|returned card|unused cards/);
|
||||
|
||||
const changedCurrent = structuredClone(drawn.state);
|
||||
changedCurrent.players[0].faceDancers![1].revealed = false;
|
||||
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random), /current Traitor custody/);
|
||||
const duplicate = resigned(drawn, (r) => { r.state.reserve.push(r.drawn[0]); });
|
||||
assert.throws(() => validateRihani(duplicate, universe), /census/);
|
||||
});
|
||||
|
||||
void test('ordinary Rihani receipts retain the existing used-Traitor exclusion and JSON transition', () => {
|
||||
const state = snapshot();
|
||||
state.players[0] = { id: 't', faction: 'harkonnen', traitors: universe.slice(0, 3) };
|
||||
const offer = beginRihani(state, universe, {
|
||||
event: 'legacy-rihani', turn: 1, owner: 't',
|
||||
skill: { leader: 'harkonnen-0', normal: true, skilled: true },
|
||||
}, [universe[1]], random);
|
||||
assert.deepEqual(offer.eligible, [universe[0], universe[2]]);
|
||||
const drawn = chooseRihaniDraw(restore(offer), universe, offer.state, true);
|
||||
assert.deepEqual(drawn.state.players[0].traitors, [...universe.slice(0, 3), ...drawn.drawn]);
|
||||
const done = finishRihani(restore(drawn), universe, drawn.state, drawn.drawn[1], universe[0], random);
|
||||
assert.deepEqual(done.state.players[0].traitors, [universe[1], universe[2], drawn.drawn[1]]);
|
||||
assert.equal(Object.hasOwn(done.state.players[0], 'faceDancers'), false);
|
||||
validateRihani(restore(done), universe, done.state);
|
||||
});
|
||||
@@ -109,7 +109,7 @@ void test('sample custody catches a duplicated base traitor after setup despite
|
||||
game.players[0].traitors[0] = other;
|
||||
assert.throws(
|
||||
() => verifySampleCustody(game, inventory),
|
||||
/physical base traitor custody/,
|
||||
/physical traitor custody/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -146,6 +146,6 @@ void test('resumed sample inventory cannot shrink with a deleted leader and matc
|
||||
player.traitors = player.traitors.filter((card) => card !== leader.id);
|
||||
assert.throws(
|
||||
() => verifySampleCustody(game, sampleInventory(game)),
|
||||
/physical base traitor custody/,
|
||||
/physical traitor custody/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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 TLEILAXU_SKILLS_SEATS = {
|
||||
tleilaxu: 't',
|
||||
atreides: 'a',
|
||||
emperor: 'e',
|
||||
} as const;
|
||||
|
||||
export type TleilaxuSkillsFaction = keyof typeof TLEILAXU_SKILLS_SEATS;
|
||||
export type TleilaxuSkillsFixtureOptions = {
|
||||
requestedSkill?: LeaderSkillId;
|
||||
skillOwner?: TleilaxuSkillsFaction;
|
||||
};
|
||||
|
||||
const names: Record<TleilaxuSkillsFaction, string> = {
|
||||
tleilaxu: 'Tleilaxu',
|
||||
atreides: 'Atreides',
|
||||
emperor: 'Emperor',
|
||||
};
|
||||
|
||||
export const reloadTleilaxuSkillsGame = (game: Game): Game =>
|
||||
JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
export function tleilaxuSkillsPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find((candidate) => candidate.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
export function rejectTleilaxuSkillsAction(
|
||||
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 assertTleilaxuSkillsCustody(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.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`,
|
||||
);
|
||||
|
||||
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 initializedTleilaxuSkillsOffers(
|
||||
options: TleilaxuSkillsFixtureOptions = {},
|
||||
): Game {
|
||||
const requestedSkill = options.requestedSkill ?? 'warmaster';
|
||||
const skillOwner = options.skillOwner ?? 'tleilaxu';
|
||||
const factions = (
|
||||
[skillOwner, 'tleilaxu', 'atreides', 'emperor'] as TleilaxuSkillsFaction[]
|
||||
).filter((faction, index, all) => all.indexOf(faction) === index);
|
||||
const first = factions[0];
|
||||
let game = createGame(
|
||||
'TLEISKIL',
|
||||
newPlayer(TLEILAXU_SKILLS_SEATS[first], names[first], first),
|
||||
false,
|
||||
['ix'],
|
||||
);
|
||||
for (const faction of factions.slice(1))
|
||||
joinGame(game, newPlayer(TLEILAXU_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, 'leaderSkills');
|
||||
assert.equal(game.leaderSkills!.offers[TLEILAXU_SKILLS_SEATS[skillOwner]].cards[0], requestedSkill);
|
||||
assertTleilaxuSkillsCustody(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Complete skill assignment, ordinary Traitor selection and three Face Dancers. */
|
||||
export function completedTleilaxuSkillsGame(
|
||||
options: TleilaxuSkillsFixtureOptions = {},
|
||||
): Game {
|
||||
const requestedSkill = options.requestedSkill ?? 'warmaster';
|
||||
const requestedOwner = TLEILAXU_SKILLS_SEATS[options.skillOwner ?? 'tleilaxu'];
|
||||
let game = initializedTleilaxuSkillsOffers(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(tleilaxuSkillsPlayer(game, 't').faceDancers, undefined);
|
||||
for (const player of game.players) {
|
||||
if (player.faction === 'tleilaxu') continue;
|
||||
const choice = tleilaxuSkillsPlayer(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(tleilaxuSkillsPlayer(game, 't').traitors, []);
|
||||
assert.equal(tleilaxuSkillsPlayer(game, 't').faceDancers!.length, 3);
|
||||
assertTleilaxuSkillsCustody(game);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createElement } from 'react';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { basicTleilaxuLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { registerHooks } from 'node:module';
|
||||
const aliases = registerHooks({resolve(specifier,context,next){return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier,context);}});
|
||||
const { RihaniChoice } = await import('../components/rihani-decipherer');
|
||||
aliases.deregister();
|
||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||
import { completedTleilaxuSkillsGame, initializedTleilaxuSkillsOffers, assertTleilaxuSkillsCustody as custody, tleilaxuSkillsPlayer as player, reloadTleilaxuSkillsGame as reload, rejectTleilaxuSkillsAction as reject } from './tleilaxu-skills-fixture';
|
||||
|
||||
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
|
||||
function stable(g: Game) {
|
||||
custody(g); verifySampleCustody(g, sampleInventory(g));
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
}
|
||||
// Explicit conserved positions after genuine setup; not claimed as played phase histories.
|
||||
function stage(g: Game, owner = 't', opponent = 'e') {
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand); p.hand = [];
|
||||
p.forces = [owner, opponent].includes(p.id) ? { 'wind_pass:14': 6, 'carthag:11': 1 } : {};
|
||||
p.reserves = [owner, opponent].includes(p.id) ? 13 : 20; p.tanks = 0; p.spice = 20;
|
||||
}
|
||||
Object.assign(g, { phase: 6, active: owner, storm: 18, order: [owner, opponent, ...g.players.map(p => p.id).filter(id => id !== owner && id !== opponent)],
|
||||
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
|
||||
return g;
|
||||
}
|
||||
function open(g: Game, owner = 't', opponent = 'e') {
|
||||
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
else if (g.decision?.kind === 'leaderSkillVisibility') g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
|
||||
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
else break;
|
||||
}
|
||||
assert.equal(g.decision, null); assert.equal(g.battle?.preparation, undefined);
|
||||
return g;
|
||||
}
|
||||
function plans(g: Game, own: Partial<Action> = {}, other: Partial<Action> = {}, owner = 't', opponent = 'e') {
|
||||
g = act(g, owner, { type: 'battlePlan', dial: 4, leader: player(g, owner).leaders[0].id, ...own });
|
||||
return act(g, opponent, { type: 'battlePlan', dial: 0, leader: player(g, opponent).leaders[1].id, ...other });
|
||||
}
|
||||
function resolve(g: Game, owner = 't', opponent = 'e') {
|
||||
g = act(g, owner, { type: 'traitorCall', call: false });
|
||||
return act(g, opponent, { type: 'traitorCall', call: false });
|
||||
}
|
||||
function exchange(g: Game, owner: string) {
|
||||
const event = g.rihaniHistory!.at(-1)!.event;
|
||||
g = act(g, owner, { type: 'decision', event, draw: true });
|
||||
const r = g.rihaniHistory!.at(-1)!;
|
||||
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', () => {
|
||||
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);
|
||||
assert.equal(basicTleilaxuLeaderSkillsProfile(game), true);
|
||||
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');},
|
||||
(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);
|
||||
const before = JSON.stringify(g);assert.throws(() => initializeLeaderSkillsGameForAudit(g));assert.equal(JSON.stringify(g),before);
|
||||
}
|
||||
const lobby = createGame('TLEIPUBL',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(lobby,newPlayer('e','Emperor','emperor'));lobby.players.forEach(p => {p.ready = true;});reject(lobby,'t',{type:'start'});
|
||||
const offer = initializedTleilaxuSkillsOffers({requestedSkill:'rihani-decipherer'});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(offer,'t');view.players.find(p => p.id === 't')!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'leaderSkill');assert.ok(action);assert.doesNotThrow(() => act(offer,'t',action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Rihani keeps a private Face Dancer, returns only an old unrevealed card and survives JSON across every choice', () => {
|
||||
let g = stage(completedTleilaxuSkillsGame({requestedSkill:'rihani-decipherer'}));
|
||||
player(g,'t').faceDancers![0].revealed = true; // Conserved pre-existing public reveal.
|
||||
const known = player(g,'t').faceDancers![0].leader;
|
||||
g = resolve(plans(open(g)));
|
||||
assert.equal(g.decision?.kind,'rihani');stable(g);
|
||||
const offered = g.rihaniHistory!.at(-1)!;
|
||||
assert.equal(offered.peeked.length,2);assert.equal(offered.drawn.length,0);assert.ok(!offered.eligible.includes(known));
|
||||
assert.deepEqual(viewGame(g,'e').rihani!.history,[]);
|
||||
reject(g,'e',{type:'decision',event:offered.event,draw:true});
|
||||
const markup = renderToStaticMarkup(createElement(RihaniChoice,{game:viewGame(g,'t'),act:()=>{},busy:false}));
|
||||
assert.match(markup,/Draw two Face Dancers/);assert.match(markup,/unrevealed/);
|
||||
g = act(g,'t',{type:'decision',event:offered.event,draw:true});stable(g);
|
||||
const drawn = g.rihaniHistory!.at(-1)!;
|
||||
assert.equal(player(g,'t').faceDancers!.length,5);assert.equal(player(g,'t').traitors.length,0);
|
||||
for (const observer of ['a','e']) {
|
||||
assert.deepEqual(viewGame(g,observer).rihani!.history,[]);
|
||||
assert.equal(viewGame(g,observer).rihani!.pending?.drawn,undefined);
|
||||
}
|
||||
reject(g,'t',{type:'decision',event:drawn.event,cards:[drawn.drawn[0],known]});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g,'t');view.players.find(p => p.id === 't')!.bot = difficulty;
|
||||
const action=botActions(view).find(a=>a.type==='decision');assert.ok(action);assert.doesNotThrow(()=>act(g,'t',action));
|
||||
}
|
||||
g=act(g,'t',{type:'decision',event:drawn.event,cards:[drawn.drawn[0],drawn.eligible[0]]});
|
||||
assert.equal(player(g,'t').faceDancers!.length,3);
|
||||
assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===known&&c.revealed));
|
||||
assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===drawn.drawn[0]&&!c.revealed));
|
||||
assert.ok(g.traitorReserve!.includes(drawn.drawn[1]));stable(g);
|
||||
});
|
||||
|
||||
void test('completed winner Rihani cleanup releases custody for later Face Dance and returns the killed leader skill once', () => {
|
||||
let g = stage(completedTleilaxuSkillsGame({requestedSkill:'rihani-decipherer',skillOwner:'atreides'}),'a','e');
|
||||
const target='atreides-0', dancer=player(g,'t').faceDancers![0];
|
||||
// Swap physical identities to target this battle without fabricating a card.
|
||||
const index=g.traitorReserve!.indexOf(target);
|
||||
if(index>=0) {g.traitorReserve![index]=dancer.leader;dancer.leader=target;}
|
||||
else {
|
||||
const held=g.players.find(p=>p.traitors.includes(target));
|
||||
if(held) {held.traitors[held.traitors.indexOf(target)]=dancer.leader;dancer.leader=target;}
|
||||
else assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===target));
|
||||
}
|
||||
g=resolve(plans(open(g,'a','e'),{},{},'a','e'),'a','e');
|
||||
g=exchange(g,'a');
|
||||
assert.equal(g.decision?.kind,'faceDance');assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted,true);stable(g);
|
||||
const before=structuredClone(g.rihaniHistory), spice=player(g,'t').spice;
|
||||
g=act(g,'t',{type:'decision',reveal:true,sources:{reserves:1},sector:14});
|
||||
assert.equal(g.phase,6);assert.equal(player(g,'a').leaders[0].dead,true);
|
||||
assert.ok(!g.leaderSkills!.assignments.some(a=>a.leader===target));
|
||||
assert.equal(g.leaderSkills!.deck.filter(s=>s==='rihani-decipherer').length,1);
|
||||
assert.deepEqual(g.rihaniHistory,before);assert.equal(player(g,'t').spice,spice);stable(g);
|
||||
});
|
||||
|
||||
void test('Zoal Smuggler collects copied opposing printed strength at reveal, including zero for Cheap Hero', () => {
|
||||
for (const strength of [5,3,0]) {
|
||||
let g=stage(completedTleilaxuSkillsGame({requestedSkill:'smuggler'}));g.spice={'wind_pass:14':9};
|
||||
let other:Partial<Action>;
|
||||
if(strength===0) {
|
||||
const i=g.deck.findIndex(c=>c.kind==='hero');assert.ok(i>=0);
|
||||
const card=g.deck.splice(i,1)[0];player(g,'e').hand.push(card);other={leader:card.id};
|
||||
} else {
|
||||
const disc=player(g,'e').leaders.find(l=>l.strength===strength);assert.ok(disc);other={leader:disc.id};
|
||||
}
|
||||
g=plans(open(g),{},other);
|
||||
assert.equal(g.battle!.smugglerCollection!.strength,strength);
|
||||
assert.equal(player(g,'t').spice,20); // Sealing/reveal records, settlement collects.
|
||||
g=resolve(g);assert.equal(player(g,'t').spice,20+strength);assert.equal(g.spice['wind_pass:14'],9-strength);stable(g);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { assertTleilaxuSkillsCustody } from './tleilaxu-skills-fixture';
|
||||
import type { Action } from '../game/engine';
|
||||
|
||||
void test('authenticated restart and concurrent Tleilaxu setup choices retain one skill and three private Face Dancers', async () => {
|
||||
const f = unitStore();
|
||||
const db: DatabaseSync = f.sqlite;
|
||||
const clock = { now: () => 10000, sleep: async () => {} };
|
||||
try {
|
||||
const created = await f.rooms.createRoom('Tleilaxu', 'tleilaxu', false, ['ix']);
|
||||
const code = created.view.code;
|
||||
const joined = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
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);
|
||||
assertTleilaxuSkillsCustody(initial);
|
||||
for (const auth of auths) {
|
||||
const view = await f.restart().readSeatView(code, auth);
|
||||
assert.deepEqual(view.leaderSkills!.offer, initial.leaderSkills!.offers[auth.playerId]);
|
||||
assert.equal('offers' in view.leaderSkills!, false);
|
||||
assert.equal('deck' in view.leaderSkills!, false);
|
||||
}
|
||||
const offer = initial.leaderSkills!.offers[auths[0].playerId];
|
||||
const choice = { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: 'tleilaxu-0' };
|
||||
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.leaderSkills!.assignments.filter(a => a.owner === auths[0].playerId).length, 1);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === offer.cards[1]).length, 1);
|
||||
const other = game.leaderSkills!.offers[auths[1].playerId];
|
||||
game = await act(1, { type: 'leaderSkill', event: other.event, skill: other.cards[0], leader: 'atreides-0' });
|
||||
game = await act(1, { type: 'traitor', leader: game.players.find(p => p.id === auths[1].playerId)!.traitorChoices[0] });
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.players.find(p => p.id === auths[0].playerId)!.faceDancers!.length, 3);
|
||||
const own = await f.restart().readSeatView(code, auths[0]);
|
||||
const otherView = await f.restart().readSeatView(code, auths[1]);
|
||||
assert.equal(own.players.find(p => p.id === auths[0].playerId)!.faceDancers!.length, 3);
|
||||
assert.equal(otherView.players.find(p => p.id === auths[0].playerId)!.faceDancers, undefined);
|
||||
assertTleilaxuSkillsCustody(game);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||
for (const auth of auths) {
|
||||
const view = await f.restart().readSeatView(code, auth);
|
||||
assert.equal(view.leaderSkills!.assignments.length, 2);
|
||||
assert.equal(view.leaderSkills!.offer, null);
|
||||
}
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, type Action, type Game } from '../game/engine';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import {
|
||||
assertTleilaxuSkillsCustody as custody,
|
||||
completedTleilaxuSkillsGame,
|
||||
reloadTleilaxuSkillsGame as reload,
|
||||
rejectTleilaxuSkillsAction as reject,
|
||||
tleilaxuSkillsPlayer as player,
|
||||
} from './tleilaxu-skills-fixture';
|
||||
|
||||
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
|
||||
|
||||
/** Conserved battle positions after genuine setup, with a second battle keeping phase 6 open. */
|
||||
function stage(g: Game, owner = 't') {
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand); p.hand = [];
|
||||
p.forces = [owner, 'e'].includes(p.id) ? { 'wind_pass:14': 6, 'carthag:11': 1 } : {};
|
||||
p.reserves = [owner, 'e'].includes(p.id) ? 13 : 20;
|
||||
p.tanks = 0; p.spice = 20;
|
||||
}
|
||||
Object.assign(g, { phase: 6, active: owner, storm: 18,
|
||||
order: [owner, 'e', ...g.players.map(p => p.id).filter(id => id !== owner && id !== 'e')],
|
||||
ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
|
||||
return g;
|
||||
}
|
||||
function open(g: Game, owner = 't') {
|
||||
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
else if (g.decision?.kind === 'leaderSkillVisibility')
|
||||
g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
|
||||
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
|
||||
else break;
|
||||
}
|
||||
assert.equal(g.decision, null); assert.equal(g.battle?.preparation, undefined);
|
||||
return g;
|
||||
}
|
||||
function resolve(g: Game, owner = 't') {
|
||||
g = act(g, owner, { type: 'traitorCall', call: false });
|
||||
return act(g, 'e', { type: 'traitorCall', call: false });
|
||||
}
|
||||
function assignPhysicalSkill(g: Game, owner: string, skill: LeaderSkillId) {
|
||||
const target = g.leaderSkills!.assignments.find(a => a.owner === owner)!;
|
||||
const previous = g.leaderSkills!.assignments.find(a => a.skill === skill);
|
||||
if (previous) [target.skill, previous.skill] = [previous.skill, target.skill];
|
||||
else {
|
||||
const index = g.leaderSkills!.deck.indexOf(skill);
|
||||
assert.ok(index >= 0);
|
||||
[target.skill, g.leaderSkills!.deck[index]] = [g.leaderSkills!.deck[index], target.skill];
|
||||
}
|
||||
custody(g);
|
||||
}
|
||||
function matchingFaceDancer(g: Game, target: string) {
|
||||
const dancers = player(g, 't').faceDancers!;
|
||||
if (dancers.some(card => card.leader === target)) return;
|
||||
const former = dancers[0].leader;
|
||||
const index = g.traitorReserve!.indexOf(target);
|
||||
if (index >= 0) g.traitorReserve![index] = former;
|
||||
else {
|
||||
const owner = g.players.find(p => p.traitors.includes(target));
|
||||
assert.ok(owner);
|
||||
owner.traitors[owner.traitors.indexOf(target)] = former;
|
||||
}
|
||||
dancers[0].leader = target;
|
||||
}
|
||||
|
||||
void test('Zoal Smuggler copies the opposing disc without the surviving Mentat battle bonus', () => {
|
||||
let g = stage(completedTleilaxuSkillsGame({ requestedSkill: 'smuggler' }));
|
||||
// This swaps the two actual cards; it creates neither a duplicate nor a reduced deck.
|
||||
assignPhysicalSkill(g, 'e', 'mentat');
|
||||
const printed = player(g, 'e').leaders.find(leader => leader.id === 'emperor-0')!.strength;
|
||||
g.spice = { 'wind_pass:14': 9 };
|
||||
g = open(g);
|
||||
g = act(g, 't', { type: 'battlePlan', dial: 4, leader: 'tleilaxu-0' });
|
||||
assert.equal(g.battle!.smugglerCollection, undefined, 'A sealed first plan cannot inspect the opposing disc.');
|
||||
g = act(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
|
||||
assert.equal(g.battle!.smugglerCollection!.strength, printed);
|
||||
assert.equal(g.battle!.smugglerCollection!.amount, printed);
|
||||
g = resolve(g);
|
||||
assert.ok(g.log.some(entry => /Emperor gained 2 battle strength from Mentat/.test(entry.text)));
|
||||
assert.equal(g.lastBattleContext!.winner, 't');
|
||||
assert.equal(g.lastBattleContext!.smugglerCollection!.strength, printed);
|
||||
assert.equal(player(g, 't').spice, 20 + printed);
|
||||
assert.equal(g.spice['wind_pass:14'], 9 - printed);
|
||||
custody(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
});
|
||||
|
||||
void test('Rihani keeps live custody through winner cleanup then survives all-three Face Dancer replacement as immutable history', () => {
|
||||
let g = stage(completedTleilaxuSkillsGame({ requestedSkill: 'rihani-decipherer', skillOwner: 'atreides' }), 'a');
|
||||
matchingFaceDancer(g, 'atreides-0');
|
||||
// Two earlier reveals are explicitly staged; the current Face Dance triggers the native reset.
|
||||
for (const card of player(g, 't').faceDancers!) card.revealed = card.leader !== 'atreides-0';
|
||||
const index = g.deck.findIndex(card => card.kind === 'worthless');
|
||||
assert.ok(index >= 0);
|
||||
const worthless = g.deck.splice(index, 1)[0]; player(g, 'a').hand.push(worthless);
|
||||
custody(g);
|
||||
g = open(g, 'a');
|
||||
g = act(g, 'a', { type: 'battlePlan', dial: 4, leader: 'atreides-0', weapon: worthless.id });
|
||||
g = act(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-1' });
|
||||
g = resolve(g, 'a');
|
||||
const event = g.rihaniHistory!.at(-1)!.event;
|
||||
g = act(g, 'a', { type: 'decision', event, draw: false });
|
||||
assert.equal(g.decision?.kind, 'battleCards');
|
||||
assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted, undefined);
|
||||
const shuffledEarly = reload(g);
|
||||
shuffledEarly.traitorReserve!.reverse();
|
||||
reject(shuffledEarly, 'a', { type: 'decision', discard: [] }, /current Traitor custody/);
|
||||
const prematureRelease = reload(g);
|
||||
prematureRelease.lastBattleContext!.rihani!.faceDanceStarted = true;
|
||||
reject(prematureRelease, 'a', { type: 'decision', discard: [] }, /obligation changed/);
|
||||
|
||||
g = act(g, 'a', { type: 'decision', discard: [] });
|
||||
assert.equal(g.decision?.kind, 'faceDance');
|
||||
assert.equal(g.lastBattleContext!.rihani!.faceDanceStarted, true);
|
||||
const history = structuredClone(g.rihaniHistory);
|
||||
const unmarked = reload(g);
|
||||
delete unmarked.lastBattleContext!.rihani!.faceDanceStarted;
|
||||
reject(unmarked, 't', { type: 'decision', reveal: false }, /obligation changed/);
|
||||
const changedHistory = reload(g);
|
||||
changedHistory.rihaniHistory![0].peeked.reverse();
|
||||
reject(changedHistory, 't', { type: 'decision', reveal: false }, /receipt is invalid/);
|
||||
const duplicated = reload(g);
|
||||
duplicated.traitorReserve!.push(player(duplicated, 't').faceDancers![0].leader);
|
||||
reject(duplicated, 't', { type: 'decision', reveal: false }, /census/);
|
||||
|
||||
g = act(g, 't', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 14 });
|
||||
assert.equal(g.phase, 6);
|
||||
assert.equal(g.pendingFaceDance, null);
|
||||
assert.equal(player(g, 't').faceDancers!.length, 3);
|
||||
assert.ok(player(g, 't').faceDancers!.every(card => !card.revealed));
|
||||
assert.deepEqual(g.rihaniHistory, history);
|
||||
assert.equal(g.leaderSkills!.deck.filter(skill => skill === 'rihani-decipherer').length, 1);
|
||||
custody(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import {
|
||||
assertTleilaxuSkillsCustody as conserved,
|
||||
completedTleilaxuSkillsGame,
|
||||
reloadTleilaxuSkillsGame as reload,
|
||||
rejectTleilaxuSkillsAction as reject,
|
||||
tleilaxuSkillsPlayer as player,
|
||||
} from './tleilaxu-skills-fixture';
|
||||
|
||||
const act = (game: Game, owner: string, action: Action): Game =>
|
||||
applyAction(reload(game), owner, action);
|
||||
|
||||
function take(game: Game, owner: string, predicate: (card: Card) => boolean): Card {
|
||||
const index = game.deck.findIndex(predicate);
|
||||
assert.ok(index >= 0, 'The selected physical Treachery Card must exist in the real deck.');
|
||||
const [card] = game.deck.splice(index, 1);
|
||||
player(game, owner).hand.push(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
function passWindow(state: Game): Game {
|
||||
let game = state;
|
||||
const kind = game.response!.kind;
|
||||
for (let step = 0; game.response?.kind === kind && step < 8; step++) {
|
||||
const responder = game.players.find((candidate) => !game.response!.passed.includes(candidate.id));
|
||||
assert.ok(responder);
|
||||
game = act(game, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Stage conserved armies, then kill the original skilled leader in a real battle. */
|
||||
function killedOriginalSkill(owner: 't' | 'a' = 't'): Game {
|
||||
let game = completedTleilaxuSkillsGame({ skillOwner: owner === 't' ? 'tleilaxu' : 'atreides' });
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand);
|
||||
p.hand = [];
|
||||
p.forces = [owner, 'e'].includes(p.id)
|
||||
? { 'wind_pass:14': 5, 'carthag:11': 1 }
|
||||
: { 'polar_sink:0': 1 };
|
||||
p.reserves = [owner, 'e'].includes(p.id) ? 14 : 19;
|
||||
p.tanks = 0;
|
||||
p.spice = 20;
|
||||
}
|
||||
Object.assign(game, {
|
||||
phase: 6, storm: 18, order: [owner, 'e', owner === 't' ? 'a' : 't'],
|
||||
active: owner, ready: [], decision: null, response: null, phaseOpening: null,
|
||||
});
|
||||
const poison = take(game, 'e', (card) => card.kind === 'poison');
|
||||
game = act(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
|
||||
for (let step = 0; step < 20; step++) {
|
||||
if (game.response) game = passWindow(game);
|
||||
else if (game.decision?.kind === 'leaderSkillVisibility') {
|
||||
const decision = game.decision;
|
||||
game = act(game, decision.player, {
|
||||
type: 'leaderSkillVisibility', event: 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);
|
||||
const leader = player(game, owner).leaders[0].id;
|
||||
game = act(game, owner, { type: 'battlePlan', dial: 0, leader });
|
||||
game = act(game, 'e', {
|
||||
type: 'battlePlan', dial: 1, leader: 'emperor-1', weapon: poison.id,
|
||||
});
|
||||
game = act(game, owner, { type: 'traitorCall', call: false });
|
||||
game = act(game, 'e', { type: 'traitorCall', call: false });
|
||||
for (let step = 0; game.decision && step < 10; step++) {
|
||||
const decision = game.decision;
|
||||
if (decision.kind === 'battleCards')
|
||||
game = act(game, decision.player, { type: 'decision', discard: [] });
|
||||
else if (decision.kind === 'faceDance')
|
||||
game = act(game, decision.player, { type: 'decision', reveal: false });
|
||||
else assert.fail(`Unexpected postbattle choice: ${decision.kind}`);
|
||||
}
|
||||
assert.equal(player(game, owner).leaders[0].dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some((assignment) => assignment.owner === owner), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length, 1);
|
||||
assert.equal(game.leaderSkills!.offers[owner], undefined);
|
||||
conserved(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** A clean Revival boundary retains every card, disc and force from the battle. */
|
||||
function revivalGame(owner: 't' | 'a' = 't', heldKarama = false): Game {
|
||||
const game = killedOriginalSkill(owner);
|
||||
Object.assign(game, {
|
||||
phase: 4, active: null, ready: [], decision: null, response: null,
|
||||
phaseOpening: null, revivalRules: newRevivalRules(),
|
||||
});
|
||||
for (const p of game.players) p.leaderRevived = false;
|
||||
if (heldKarama) take(game, 'e', (card) => card.effect === 'karama');
|
||||
conserved(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
function reviveOwn(game: Game): Game {
|
||||
return act(game, 't', { type: 'reviveLeader', leader: 'tleilaxu-0' });
|
||||
}
|
||||
|
||||
function negotiatedRevival(game: Game, amount = 7): Game {
|
||||
game = act(game, 'a', { type: 'requestLeaderRevival', leader: 'atreides-0' });
|
||||
game = act(game, 't', { type: 'quoteLeaderRevival', target: 'a', amount });
|
||||
return act(game, 'a', { type: 'acceptLeaderRevival' });
|
||||
}
|
||||
|
||||
function assertOffer(game: Game, owner: 't' | 'a'): string {
|
||||
const offer = game.leaderSkills!.offers[owner];
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.leader, player(game, owner).leaders[0].id);
|
||||
assert.deepEqual(offer.cards, []);
|
||||
for (const viewer of game.players) {
|
||||
const projection = viewGame(reload(game), viewer.id);
|
||||
assert.deepEqual(projection.leaderSkills!.offer, viewer.id === owner ? offer : null);
|
||||
assert.equal('deck' in projection.leaderSkills!, false);
|
||||
assert.equal('offers' in projection.leaderSkills!, false);
|
||||
}
|
||||
conserved(game);
|
||||
return offer.event;
|
||||
}
|
||||
|
||||
void test('genuine Basic Tleilaxu battle death returns the original physical skill exactly once', () => {
|
||||
const game = killedOriginalSkill();
|
||||
const restored = normalizeAutomaticGame(reload(game));
|
||||
assert.deepEqual(restored, game);
|
||||
assert.equal(game.log.filter((entry) => entry.text.includes('Warmaster returned')).length, 1);
|
||||
assert.equal(player(game, 't').faceDancers!.length, 3);
|
||||
assert.deepEqual(player(game, 't').traitors, []);
|
||||
conserved(restored);
|
||||
});
|
||||
|
||||
void test('automatic Tleilaxu early permission and discount keep a private undrawn skill offer after own revival', () => {
|
||||
const before = revivalGame();
|
||||
const previousDeck = [...before.leaderSkills!.deck];
|
||||
const original = player(before, 't').leaders[0];
|
||||
const cost = Math.ceil(original.strength / 2);
|
||||
const game = reviveOwn(before);
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.pendingRevival, null);
|
||||
assert.equal(player(game, 't').leaders[0].dead, false);
|
||||
assert.equal(player(game, 't').spice, player(before, 't').spice - cost);
|
||||
assert.equal(player(game, 'e').spice, player(before, 'e').spice);
|
||||
assert.equal(game.decision?.kind, 'leaderSkillRevival');
|
||||
assertOffer(game, 't');
|
||||
assert.deepEqual(game.leaderSkills!.deck, previousDeck, 'An optional draw must not preview or remove cards.');
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
assert.equal(before.leaderSkills!.offers.t, undefined, 'Applying revival must leave its input unchanged.');
|
||||
});
|
||||
|
||||
void test('held Karama preserves early permission and discount as separate saved windows before the own-leader offer', () => {
|
||||
const before = revivalGame('t', true);
|
||||
let game = reviveOwn(before);
|
||||
assert.equal(game.response?.kind, 'earlyRevival');
|
||||
assert.equal(player(game, 't').leaders[0].dead, true);
|
||||
assert.equal(game.leaderSkills!.offers.t, undefined);
|
||||
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
||||
reject(game, 't', { type: 'leaderSkill', event: 'not-yet-created', mode: 'draw' });
|
||||
game = passWindow(game);
|
||||
assert.equal(game.response?.kind, 'revivalDiscount');
|
||||
assert.equal(player(game, 't').leaders[0].dead, true);
|
||||
assert.equal(game.leaderSkills!.offers.t, undefined);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
game = passWindow(game);
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.decision?.kind, 'leaderSkillRevival');
|
||||
const event = assertOffer(game, 't');
|
||||
const restored = normalizeAutomaticGame(normalizeAutomaticGame(reload(game)));
|
||||
assert.equal(restored.leaderSkills!.offers.t.event, event);
|
||||
assert.deepEqual(restored, game);
|
||||
});
|
||||
|
||||
void test('declining the optional own-leader skill draw keeps deck order and never reopens after JSON normalization', () => {
|
||||
let game = reviveOwn(revivalGame());
|
||||
const event = assertOffer(game, 't');
|
||||
const deck = [...game.leaderSkills!.deck];
|
||||
game = act(game, 't', { type: 'leaderSkill', event, mode: 'decline' });
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.leaderSkills!.offers.t, undefined);
|
||||
assert.deepEqual(game.leaderSkills!.deck, deck);
|
||||
assert.equal(game.leaderSkills!.assignments.some((assignment) => assignment.owner === 't'), false);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
reject(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
void test('saved optional draw rejects stale, alien and wrong-leader choices and binds one dealt card to the same revived disc', () => {
|
||||
let game = reviveOwn(revivalGame());
|
||||
const event = assertOffer(game, 't');
|
||||
reject(game, 'e', { type: 'leaderSkill', event, mode: 'draw' });
|
||||
reject(game, 't', { type: 'leaderSkill', event: `${event}-old`, mode: 'draw' });
|
||||
reject(game, 't', { type: 'leaderSkill', event, skill: game.leaderSkills!.deck[0], leader: 'tleilaxu-0' });
|
||||
const deck = [...game.leaderSkills!.deck];
|
||||
game = act(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
|
||||
assert.deepEqual(game.leaderSkills!.offers.t.cards, deck.slice(0, 2));
|
||||
assert.deepEqual(game.leaderSkills!.deck, deck.slice(2));
|
||||
assert.equal(viewGame(reload(game), 'e').leaderSkills!.offer, null);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
const [chosen, returned] = game.leaderSkills!.offers.t.cards;
|
||||
reject(game, 't', { type: 'leaderSkill', event, mode: 'draw' });
|
||||
reject(game, 't', { type: 'leaderSkill', event, mode: 'decline' });
|
||||
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-1' });
|
||||
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'emperor-0' });
|
||||
reject(game, 't', { type: 'leaderSkill', event, skill: game.leaderSkills!.deck[0], leader: 'tleilaxu-0' });
|
||||
game = act(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-0' });
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.leaderSkills!.offers.t, undefined);
|
||||
assert.deepEqual(game.leaderSkills!.assignments.find((assignment) => assignment.owner === 't'), {
|
||||
owner: 't', leader: 'tleilaxu-0', skill: chosen,
|
||||
});
|
||||
assert.equal(game.leaderSkills!.deck.filter((skill) => skill === returned).length, 1);
|
||||
assert.equal(game.leaderSkills!.deck.includes(chosen), false);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
reject(game, 't', { type: 'leaderSkill', event, skill: chosen, leader: 'tleilaxu-0' });
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
for (const heldKarama of [false, true])
|
||||
void test(`negotiated original-leader revival retains one offer through native Tleilaxu income ${heldKarama ? 'with held Karama' : 'automatically'}`, () => {
|
||||
const before = revivalGame('a', heldKarama);
|
||||
let game = negotiatedRevival(before);
|
||||
if (heldKarama) {
|
||||
assert.equal(game.response?.kind, 'earlyRevival');
|
||||
assert.equal(game.leaderSkills!.offers.a, undefined);
|
||||
assert.equal(player(game, 'a').leaders[0].dead, true);
|
||||
game = passWindow(game);
|
||||
assert.equal(game.response?.kind, 'revivalIncome');
|
||||
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
||||
const event = assertOffer(game, 'a');
|
||||
reject(game, 'a', { type: 'leaderSkill', event, mode: 'draw' });
|
||||
game = passWindow(game);
|
||||
assert.equal(assertOffer(game, 'a'), event);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.pendingRevival, null);
|
||||
assert.equal(game.decision?.kind, 'leaderSkillRevival');
|
||||
assert.equal(player(game, 'a').leaders[0].dead, false);
|
||||
assert.equal(player(game, 'a').spice, player(before, 'a').spice - 7);
|
||||
assert.equal(player(game, 't').spice, player(before, 't').spice + 7);
|
||||
assertOffer(game, 'a');
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
for (const owner of ['t', 'a'] as const)
|
||||
void test(`Karama cancellation of ${owner === 't' ? 'native Tleilaxu' : 'negotiated opponent'} early permission leaves the leader dead and creates no skill offer`, () => {
|
||||
const before = revivalGame(owner, true);
|
||||
let game = owner === 't' ? reviveOwn(before) : negotiatedRevival(before);
|
||||
assert.equal(game.response?.kind, 'earlyRevival');
|
||||
const karama = player(game, 'e').hand.find((card) => card.effect === 'karama')!;
|
||||
game = act(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
assert.equal(game.pendingRevival, null);
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(player(game, owner).leaders[0].dead, true);
|
||||
assert.equal(game.leaderSkills!.offers[owner], undefined);
|
||||
assert.equal(player(game, owner).spice, player(before, owner).spice);
|
||||
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
||||
assert.equal(game.discard.filter((card) => card.id === karama.id).length, 1);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
void test('canceling an unaffordable Tleilaxu discount aborts before revival and cannot create a skill offer', () => {
|
||||
const before = revivalGame('t', true);
|
||||
player(before, 't').spice = Math.ceil(player(before, 't').leaders[0].strength / 2);
|
||||
let game = passWindow(reviveOwn(before));
|
||||
assert.equal(game.response?.kind, 'revivalDiscount');
|
||||
const karama = player(game, 'e').hand.find((card) => card.effect === 'karama')!;
|
||||
game = act(game, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
assert.equal(game.pendingRevival, null);
|
||||
assert.equal(player(game, 't').leaders[0].dead, true);
|
||||
assert.equal(player(game, 't').spice, player(before, 't').spice);
|
||||
assert.equal(game.leaderSkills!.offers.t, undefined);
|
||||
assert.equal(game.decision, null);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
void test('each AI level can complete the private original-leader draw with legal saved actions', () => {
|
||||
const revived = reviveOwn(revivalGame());
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = reload(revived);
|
||||
player(game, 't').bot = difficulty;
|
||||
for (let step = 0; game.leaderSkills!.offers.t && step < 3; step++) {
|
||||
const action = botActions(viewGame(reload(game), 't'))[0];
|
||||
assert.ok(action);
|
||||
assert.equal(action.type, 'leaderSkill');
|
||||
game = act(game, 't', action);
|
||||
}
|
||||
assert.equal(game.leaderSkills!.offers.t, undefined, difficulty);
|
||||
assert.equal(game.decision, null);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
conserved(game);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user