Connect expansion faction setup and saved Ixian auction decline
Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed. Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation. Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh. Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias. After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication. Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
This commit is contained in:
1 parent
f067950f5e
commit
cab2f1c42d
26 files changed
+3001
-58
No files matched your search
@@ -37,13 +37,34 @@ void test('the verified Ecaz variant has one physical special of each identity a
|
||||
assert.equal(new Set(combined.map((card) => card.id)).size, 50);
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
for (const unsupported of ['ecaz', ECAZ_TREACHERY_VARIANT.id])
|
||||
assert.equal(treacheryDeck(['ecaz']).length, 33);
|
||||
for (const unsupported of [ECAZ_TREACHERY_VARIANT.id, 'unknown'])
|
||||
assert.throws(() => treacheryDeck([unsupported]), /not implemented/);
|
||||
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfFactions, true);
|
||||
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfOtherVariants, true);
|
||||
assert.equal(ECAZ_TREACHERY_VARIANT.activation, 'not-implemented');
|
||||
});
|
||||
|
||||
void test('Ecaz faction selection preserves each ordinary deck and never includes its separate unfinished variant', () => {
|
||||
for (const other of [[], ['ix'], ['choam'], ['ix', 'choam']]) {
|
||||
const cards = treacheryDeck([...other, 'ecaz']);
|
||||
assert.deepEqual(cards, treacheryDeck(other));
|
||||
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
|
||||
assert.equal(
|
||||
cards.some((card) => card.id.startsWith('ecaz-')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
cards.length,
|
||||
other.includes('ix') ? 47 : other.includes('choam') ? 35 : 33,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => treacheryDeck(['ecaz', ECAZ_TREACHERY_VARIANT.id]),
|
||||
/not implemented/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('factory instances and their mutable cards are independent of canonical definitions', () => {
|
||||
const first = ecazTreacheryCards();
|
||||
first[0].name = 'Changed';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { EcazSetup } from '../components/ecaz-setup';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { ECAZ_START_LOCATIONS } from '../game/ecaz-setup';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
function pending(advanced = false) {
|
||||
let game = createGame('ECAZSETU', newPlayer('e', 'Ecaz', 'ecaz'), advanced, [
|
||||
'ecaz',
|
||||
]);
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeFactionExpansionsGameForAudit(game);
|
||||
while (game.setupStage === 'traitors') {
|
||||
const player = game.players.find((p) => p.traitorChoices.length)!;
|
||||
game = applyAction(game, player.id, {
|
||||
type: 'traitor',
|
||||
leader: player.traitorChoices[0],
|
||||
});
|
||||
}
|
||||
assert.equal(game.setupStage, 'forces');
|
||||
assert.deepEqual(
|
||||
game.players.map((p) => p.hand.length),
|
||||
[0, 0],
|
||||
);
|
||||
assert.deepEqual(game.setupStage && viewGame(game, 'e').setupPending, ['e']);
|
||||
return game;
|
||||
}
|
||||
|
||||
function markup(game: Game, viewer = 'e', busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(EcazSetup, { game: viewGame(game, viewer), act() {}, busy }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Ecaz setup presents three sector allocations only to its pending owner, before cards are dealt', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = pending(advanced);
|
||||
const html = markup(game);
|
||||
assert.match(html, /Ecaz starting forces/);
|
||||
assert.match(html, /fourteen forces remain in reserves/);
|
||||
for (const location of ECAZ_START_LOCATIONS)
|
||||
assert.ok(html.includes(`ecaz-start-${location}`));
|
||||
assert.match(html, /6 \/ 6 forces assigned/);
|
||||
assert.match(html, /Place Ecaz starting forces/);
|
||||
assert.equal(markup(game, 'g'), '');
|
||||
assert.equal(
|
||||
(
|
||||
markup(game, 'e', true).match(
|
||||
/<(?:input|button)\b[^>]*\sdisabled=""/g,
|
||||
) ?? []
|
||||
).length,
|
||||
4,
|
||||
);
|
||||
const placed = applyAction(JSON.parse(JSON.stringify(game)), 'e', {
|
||||
type: 'ecazSetup',
|
||||
placements: Object.fromEntries(
|
||||
ECAZ_START_LOCATIONS.map((location) => [location, 2]),
|
||||
),
|
||||
});
|
||||
assert.equal(markup(placed), '');
|
||||
assert.equal(placed.players[0].reserves, 14);
|
||||
assert.deepEqual(
|
||||
placed.players.map((p) => p.hand.length),
|
||||
[1, 1],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('every AI profile supplies a legal Ecaz allocation and other seats wait for placement', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = pending(true);
|
||||
const view = viewGame(game, 'e');
|
||||
view.players[0].bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].type, 'ecazSetup');
|
||||
const result = applyAction(game, 'e', actions[0]);
|
||||
assert.equal(result.players[0].reserves, 14);
|
||||
assert.equal(
|
||||
Object.values(result.players[0].forces).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
),
|
||||
6,
|
||||
);
|
||||
const other = viewGame(game, 'g');
|
||||
other.players[1].bot = difficulty;
|
||||
assert.deepEqual(botActions(other), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
initializeHomeworldGameForAudit,
|
||||
initializeNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { FACTIONS, faction, type FactionId } from '../game/catalog';
|
||||
import { CHOAM_AUDITOR_ID, spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import {
|
||||
ECAZ_START_FORCES,
|
||||
ECAZ_START_LOCATIONS,
|
||||
quoteEcazStartingForces,
|
||||
} from '../game/ecaz-setup';
|
||||
|
||||
const combinations = [
|
||||
['ix'],
|
||||
['choam'],
|
||||
['ecaz'],
|
||||
['ix', 'choam'],
|
||||
['ix', 'ecaz'],
|
||||
['choam', 'ecaz'],
|
||||
['ix', 'choam', 'ecaz'],
|
||||
];
|
||||
const sum = (forces: Record<string, number>) =>
|
||||
Object.values(forces).reduce((a, b) => a + b, 0);
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function rosterFor(expansions: string[]): FactionId[] {
|
||||
const selected = FACTIONS.filter((f) => expansions.includes(f.expansion)).map(
|
||||
(f) => f.id,
|
||||
);
|
||||
return [
|
||||
...selected,
|
||||
...(
|
||||
['fremen', 'beneGesserit', 'atreides', 'harkonnen'] as FactionId[]
|
||||
).slice(0, 6 - selected.length),
|
||||
];
|
||||
}
|
||||
function lobby(
|
||||
expansions = ['choam'],
|
||||
advanced = false,
|
||||
roster = rosterFor(expansions),
|
||||
): Game {
|
||||
let g = createGame(
|
||||
'FACTIONS',
|
||||
newPlayer(roster[0], roster[0], roster[0]),
|
||||
advanced,
|
||||
expansions,
|
||||
);
|
||||
for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
function privateViews(g: Game) {
|
||||
const saved = reload(g);
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
assert.deepEqual(viewGame(saved, p.id), view);
|
||||
assert.equal('deck' in view, false);
|
||||
assert.equal('traitorReserve' in view, false);
|
||||
for (const other of view.players.filter((other) => other.id !== p.id)) {
|
||||
assert.equal(other.hand, undefined);
|
||||
assert.equal(other.traitors, undefined);
|
||||
assert.equal(other.traitorChoices, undefined);
|
||||
assert.equal(other.faceDancers, undefined);
|
||||
assert.equal(other.spice, undefined);
|
||||
}
|
||||
if (g.ixSetupCards && p.faction !== 'ixians')
|
||||
assert.equal(view.ixTechnology, null);
|
||||
if (g.players.some((other) => other.noField)) {
|
||||
assert.equal(!!view.richeseNoField?.private, p.faction === 'richese');
|
||||
if (p.faction !== 'richese')
|
||||
for (const token of g.players.find(
|
||||
(other) => other.faction === 'richese',
|
||||
)!.noField!.tokens)
|
||||
assert.equal(JSON.stringify(view).includes(token.id), false);
|
||||
}
|
||||
if (g.moritaniTerror) {
|
||||
assert.equal(
|
||||
view.moritaniTerror!.tokens.length,
|
||||
p.faction === 'moritani' ? 6 : 0,
|
||||
);
|
||||
if (p.faction === 'moritani')
|
||||
assert.ok(
|
||||
view.moritaniTerror!.tokens.every((token) => 'kind' in token),
|
||||
);
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
function inventory(g: Game) {
|
||||
const expected = [
|
||||
...treacheryDeck(g.expansions),
|
||||
...(g.players.some((p) => p.faction === 'richese') ? richeseCards() : []),
|
||||
];
|
||||
const cards = [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...(g.ixSetupCards ?? []),
|
||||
...(g.richeseCache ?? []),
|
||||
];
|
||||
assert.deepEqual(
|
||||
cards.map((c) => c.id).sort(),
|
||||
expected.map((c) => c.id).sort(),
|
||||
);
|
||||
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
|
||||
assert.ok(cards.every((c) => !c.id.startsWith('ecaz-')));
|
||||
assert.equal(
|
||||
g.spiceDeck.length + g.spiceDiscard.flat().length,
|
||||
spiceDeck(g.expansions.includes('ix')).length,
|
||||
);
|
||||
for (const p of g.players)
|
||||
assert.equal(p.reserves + p.tanks + sum(p.forces), 20, p.id);
|
||||
if (g.setupStage !== 'prediction') {
|
||||
const traitors = [
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => [
|
||||
...p.traitors,
|
||||
...p.traitorChoices,
|
||||
...(p.faceDancers ?? []).map((c) => c.leader),
|
||||
]),
|
||||
];
|
||||
assert.deepEqual(
|
||||
traitors.slice().sort(),
|
||||
traitorDeck(g.players, g.expansions.includes('ix')).sort(),
|
||||
);
|
||||
assert.equal(new Set(traitors).size, traitors.length);
|
||||
}
|
||||
}
|
||||
function setup(state: Game, difficulty: Difficulty) {
|
||||
let g = state;
|
||||
const history: string[] = [];
|
||||
for (let step = 0; g.status === 'setup' && step < 40; step++) {
|
||||
inventory(g);
|
||||
g = privateViews(g);
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((player) => player.id === p.id)!.bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
for (const action of actions) {
|
||||
const candidate = applyAction(g, p.id, action);
|
||||
next ??= candidate;
|
||||
}
|
||||
if (next) {
|
||||
const action = actions[0];
|
||||
history.push(
|
||||
action.type === 'decision' ? g.decision!.kind : action.type,
|
||||
);
|
||||
if (g.decision?.kind === 'moritaniSetup') {
|
||||
assert.equal(g.setupStage, undefined);
|
||||
assert.ok(
|
||||
g.players.every(
|
||||
(player) =>
|
||||
player.hand.length === (player.faction === 'harkonnen' ? 2 : 1),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
g.players.every(
|
||||
(player) => player.faction !== 'ecaz' || player.reserves === 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`${g.expansions.join(',')}/${difficulty}: stalled ${g.setupStage}/${g.decision?.kind}/${viewGame(g, g.host).setupPending.join(',')}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.equal(g.turn, 1);
|
||||
assert.equal(g.phase, 0);
|
||||
inventory(g);
|
||||
privateViews(g);
|
||||
return { game: g, history };
|
||||
}
|
||||
function reject(g: Game, operation = initializeFactionExpansionsGameForAudit) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => operation(g));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function rejectAction(g: Game, player: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, player, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
for (const expansions of combinations)
|
||||
void test(`genuine ${expansions.join('+')} faction setup supports Basic and Advanced with every AI profile and private JSON continuation`, () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const initial = lobby(expansions, advanced),
|
||||
before = structuredClone(initial);
|
||||
const started = initializeFactionExpansionsGameForAudit(initial);
|
||||
assert.deepEqual(initial, before);
|
||||
assert.equal(started.leaderSkills, undefined);
|
||||
assert.equal(
|
||||
started.deck.length,
|
||||
expansions.includes('ix')
|
||||
? 47
|
||||
: expansions.includes('choam')
|
||||
? 35
|
||||
: 33,
|
||||
);
|
||||
const { game: g, history } = setup(started, difficulty);
|
||||
assert.deepEqual(g.expansions, expansions);
|
||||
for (const p of g.players) {
|
||||
assert.equal(p.spice, faction(p.faction).spice);
|
||||
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
|
||||
}
|
||||
const ecaz = g.players.find((p) => p.faction === 'ecaz');
|
||||
if (ecaz) {
|
||||
assert.equal(ecaz.reserves, 14);
|
||||
assert.equal(sum(ecaz.forces), 6);
|
||||
assert.ok(
|
||||
Object.keys(ecaz.forces).every((key) =>
|
||||
ECAZ_START_LOCATIONS.includes(key),
|
||||
),
|
||||
);
|
||||
assert.equal(g.ecazAmbassadors!.tokens.length, 11);
|
||||
assert.equal(
|
||||
g.ecazAmbassadors!.tokens.filter((t) => t.zone === 'supply').length,
|
||||
6,
|
||||
);
|
||||
assert.equal(g.ecazAmbassadors!.cohort.length, 5);
|
||||
if (g.players.some((p) => p.faction === 'fremen'))
|
||||
assert.ok(
|
||||
history.indexOf('fremenSetup') < history.indexOf('ecazSetup'),
|
||||
);
|
||||
if (advanced && g.players.some((p) => p.faction === 'beneGesserit'))
|
||||
assert.ok(
|
||||
history.indexOf('ecazSetup') < history.indexOf('advisorSetup'),
|
||||
);
|
||||
}
|
||||
const moritani = g.players.find((p) => p.faction === 'moritani');
|
||||
if (moritani) {
|
||||
assert.equal(moritani.reserves, 14);
|
||||
assert.equal(sum(moritani.forces), 6);
|
||||
assert.equal(g.moritaniTerror!.tokens.length, 6);
|
||||
assert.equal(history.at(-1), 'moritaniSetup');
|
||||
assert.ok(g.dukeVidal);
|
||||
assert.ok(
|
||||
g.players.every(
|
||||
(p) => !p.leaders.some((l) => l.id === g.dukeVidal!.leader.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
const richese = g.players.find((p) => p.faction === 'richese');
|
||||
if (richese) {
|
||||
assert.equal(g.richeseCache!.length, 10);
|
||||
assert.deepEqual(
|
||||
richese.noField!.tokens.map((t) => t.value).sort((a, b) => a - b),
|
||||
[0, 3, 5],
|
||||
);
|
||||
assert.equal(richese.reserves, 20);
|
||||
}
|
||||
const choam = g.players.find((p) => p.faction === 'choam');
|
||||
if (choam)
|
||||
assert.equal(
|
||||
choam.leaders.some((l) => l.id === CHOAM_AUDITOR_ID),
|
||||
advanced,
|
||||
);
|
||||
const ix = g.players.find((p) => p.faction === 'ixians');
|
||||
if (ix) {
|
||||
assert.deepEqual(ix.forces, { [MOBILE_LOCATION]: 6 });
|
||||
assert.deepEqual(ix.elites!.forces, { [MOBILE_LOCATION]: 3 });
|
||||
assert.equal(ix.elites!.reserves, 4);
|
||||
assert.equal(g.mobileStronghold!.location, null);
|
||||
}
|
||||
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
|
||||
if (tleilaxu) assert.equal(tleilaxu.faceDancers!.length, 3);
|
||||
reject(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('faction prototype handles two through six seats and preserves the public start gates', () => {
|
||||
const roster: FactionId[] = [
|
||||
'choam',
|
||||
'richese',
|
||||
'atreides',
|
||||
'harkonnen',
|
||||
'guild',
|
||||
'emperor',
|
||||
];
|
||||
for (const count of [2, 3, 4, 5, 6])
|
||||
for (const advanced of [false, true]) {
|
||||
const g = lobby(['choam'], advanced, roster.slice(0, count));
|
||||
rejectAction(g, g.host, { type: 'start' });
|
||||
assert.equal(
|
||||
setup(initializeFactionExpansionsGameForAudit(g), 'Medium').game.players
|
||||
.length,
|
||||
count,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Ecaz starting-force quote accepts exact six-force Basin allocations and rejects invalid or ambiguous input purely', () => {
|
||||
assert.equal(ECAZ_START_FORCES, 6);
|
||||
assert.deepEqual(ECAZ_START_LOCATIONS, [
|
||||
'imperial_basin:9',
|
||||
'imperial_basin:10',
|
||||
'imperial_basin:11',
|
||||
]);
|
||||
const input = {
|
||||
'imperial_basin:9': 2,
|
||||
'imperial_basin:10': 4,
|
||||
'imperial_basin:11': 0,
|
||||
};
|
||||
assert.deepEqual(quoteEcazStartingForces(input), {
|
||||
'imperial_basin:9': 2,
|
||||
'imperial_basin:10': 4,
|
||||
});
|
||||
for (const bad of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ 'imperial_basin:9': 5 },
|
||||
{ 'imperial_basin:9': 7 },
|
||||
{ 'imperial_basin:9': 5.5, 'imperial_basin:10': 0.5 },
|
||||
{ 'imperial_basin:9': -1, 'imperial_basin:10': 7 },
|
||||
{ 'imperial_basin:09': 6 },
|
||||
{ imperial_basin: 6 },
|
||||
{ 'arrakeen:10': 6 },
|
||||
{ 'imperial_basin:9': '6' },
|
||||
]) {
|
||||
const before = structuredClone(bad);
|
||||
assert.throws(() => quoteEcazStartingForces(bad));
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Ecaz splits six forces once between Fremen and Advanced BG, then excludes Imperial Basin from Moritani final placement', () => {
|
||||
let g = initializeFactionExpansionsGameForAudit(
|
||||
lobby(['ecaz'], true, ['ecaz', 'moritani', 'fremen', 'beneGesserit']),
|
||||
);
|
||||
const actionsFor = (id: string) => {
|
||||
const view = viewGame(g, id);
|
||||
view.players.find((p) => p.id === id)!.bot = 'Medium';
|
||||
return botActions(view);
|
||||
};
|
||||
for (
|
||||
let step = 0;
|
||||
!(
|
||||
g.setupStage === 'forces' &&
|
||||
viewGame(g, g.host).setupPending[0] === 'ecaz'
|
||||
) && step < 20;
|
||||
step++
|
||||
) {
|
||||
const actor = g.players.find((p) => actionsFor(p.id).length > 0)!;
|
||||
assert.ok(actor);
|
||||
g = applyAction(reload(g), actor.id, actionsFor(actor.id)[0]);
|
||||
}
|
||||
assert.deepEqual(viewGame(g, g.host).setupPending, ['ecaz']);
|
||||
assert.equal(g.players.find((p) => p.faction === 'fremen')!.reserves, 10);
|
||||
assert.ok(g.players.every((p) => p.hand.length === 0));
|
||||
const split = {
|
||||
type: 'ecazSetup',
|
||||
placements: {
|
||||
'imperial_basin:9': 2,
|
||||
'imperial_basin:10': 2,
|
||||
'imperial_basin:11': 2,
|
||||
},
|
||||
};
|
||||
rejectAction(g, 'beneGesserit', {
|
||||
type: 'advisorSetup',
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
});
|
||||
rejectAction(g, 'fremen', split);
|
||||
rejectAction(g, 'ecaz', {
|
||||
type: 'ecazSetup',
|
||||
placements: { 'imperial_basin:9': 5 },
|
||||
});
|
||||
rejectAction(g, 'ecaz', { ...split, elitePlacements: {} });
|
||||
g = applyAction(privateViews(g), 'ecaz', split);
|
||||
assert.deepEqual(g.players[0].forces, split.placements);
|
||||
assert.equal(g.players[0].reserves, 14);
|
||||
assert.deepEqual(viewGame(g, g.host).setupPending, ['beneGesserit']);
|
||||
rejectAction(g, 'ecaz', split);
|
||||
g = applyAction(privateViews(g), 'beneGesserit', {
|
||||
type: 'advisorSetup',
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
});
|
||||
assert.deepEqual(g.decision, { kind: 'moritaniSetup', player: 'moritani' });
|
||||
assert.ok(g.players.every((p) => p.hand.length === 1));
|
||||
rejectAction(g, 'moritani', {
|
||||
type: 'decision',
|
||||
territory: 'imperial_basin',
|
||||
sector: 9,
|
||||
});
|
||||
rejectAction(g, 'moritani', {
|
||||
type: 'decision',
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
});
|
||||
for (const [reserves, forces] of [
|
||||
[20, {}],
|
||||
[14, { 'arrakeen:10': 6 }],
|
||||
] as const) {
|
||||
const incomplete = reload(g);
|
||||
incomplete.players[0].reserves = reserves;
|
||||
incomplete.players[0].forces = forces;
|
||||
rejectAction(incomplete, 'moritani', {
|
||||
type: 'decision',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
});
|
||||
incomplete.decision = null;
|
||||
const before = structuredClone(incomplete),
|
||||
normalized = normalizeAutomaticGame(incomplete);
|
||||
assert.deepEqual(incomplete, before);
|
||||
assert.equal(normalized.status, 'setup');
|
||||
assert.equal(normalized.decision, null);
|
||||
}
|
||||
g = applyAction(privateViews(g), 'moritani', {
|
||||
type: 'decision',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
});
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.equal(g.players[1].reserves, 14);
|
||||
assert.deepEqual(g.players[1].forces, { 'arrakeen:10': 6 });
|
||||
rejectAction(g, 'ecaz', split);
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
void test('a stale or partially played lobby cannot redeal faction inventories, leaders, or optional modules', () => {
|
||||
const mutations: Array<(g: Game) => void> = [
|
||||
(g) => {
|
||||
g.expansions = [];
|
||||
},
|
||||
(g) => {
|
||||
g.expansions = ['ecaz-treachery'];
|
||||
},
|
||||
(g) => {
|
||||
g.expansions.push('choam');
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].ready = false;
|
||||
},
|
||||
(g) => {
|
||||
g.host = 'missing';
|
||||
},
|
||||
(g) => {
|
||||
g.turn = 2;
|
||||
},
|
||||
(g) => {
|
||||
g.advanced = 'advanced' as unknown as boolean;
|
||||
},
|
||||
(g) => {
|
||||
g.status = 'setup';
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].spice = 1;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].reserves = 19;
|
||||
},
|
||||
(g) => {
|
||||
g.richeseCache = [];
|
||||
},
|
||||
(g) => {
|
||||
g.richeseRemoved = [];
|
||||
},
|
||||
(g) => {
|
||||
g.ecazAmbassadors = { tokens: [], cohort: [], placement: null };
|
||||
},
|
||||
(g) => {
|
||||
g.moritaniTerror = { tokens: [] };
|
||||
},
|
||||
(g) => {
|
||||
g.dukeVidal = {} as NonNullable<Game['dukeVidal']>;
|
||||
},
|
||||
(g) => {
|
||||
g.mobileStronghold = { location: null };
|
||||
},
|
||||
(g) => {
|
||||
g.ixSetupCards = [];
|
||||
},
|
||||
(g) => {
|
||||
g.players[1].noField = {} as NonNullable<
|
||||
Game['players'][number]['noField']
|
||||
>;
|
||||
},
|
||||
(g) => {
|
||||
g.players[1].noFieldEvent = 'old';
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].faceDancers = [];
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaders[0].capturedBy = g.players[1].id;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaders[0].gholaBy = g.players[1].id;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaders[0].dead = true;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaders[0].deaths = 1;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaders[0].usedAt = 'arrakeen';
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaders[0].strength++;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].revived = 1;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].freeForcesRevived = 1;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].leaderRevived = true;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].revivalCycle = 1;
|
||||
},
|
||||
(g) => {
|
||||
g.homeworlds = { custody: null };
|
||||
},
|
||||
(g) => {
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
},
|
||||
(g) => {
|
||||
g.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
|
||||
},
|
||||
(g) => {
|
||||
g.discoveryEnabled = true;
|
||||
},
|
||||
(g) => {
|
||||
g.discoveries = {} as NonNullable<Game['discoveries']>;
|
||||
},
|
||||
(g) => {
|
||||
g.techTokens = {} as NonNullable<Game['techTokens']>;
|
||||
},
|
||||
(g) => {
|
||||
g.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = lobby();
|
||||
mutate(g);
|
||||
reject(g);
|
||||
}
|
||||
const absentFaction = lobby(['choam']);
|
||||
absentFaction.players[0] = {
|
||||
...newPlayer('ecaz', 'Ecaz', 'ecaz'),
|
||||
ready: true,
|
||||
};
|
||||
absentFaction.host = 'ecaz';
|
||||
reject(absentFaction);
|
||||
const tooMany = lobby(['ix']);
|
||||
tooMany.players.push({
|
||||
...newPlayer('guild', 'Guild', 'guild'),
|
||||
ready: true,
|
||||
});
|
||||
reject(tooMany);
|
||||
const alone = lobby(['choam'], false, ['choam']);
|
||||
reject(alone);
|
||||
});
|
||||
|
||||
void test('accepting the Ecaz deck selector does not open existing Homeworld or Nexus expansion gates', () => {
|
||||
for (const expansions of [['choam'], ['ecaz'], ['ix', 'ecaz']]) {
|
||||
const home = lobby(expansions, true);
|
||||
home.homeworlds = { custody: null };
|
||||
reject(home, initializeHomeworldGameForAudit);
|
||||
const nexus = lobby(expansions, true);
|
||||
nexus.nexusCards = { cards: null, phase: null };
|
||||
reject(nexus, initializeNexusGameForAudit);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Action, Game, GameView } from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: Rooms.RoomsClock = {
|
||||
now: () => 12_345,
|
||||
sleep: async () => {},
|
||||
};
|
||||
|
||||
const MIXED_ROSTER: readonly FactionId[] = [
|
||||
'ixians',
|
||||
'tleilaxu',
|
||||
'choam',
|
||||
'richese',
|
||||
'ecaz',
|
||||
'moritani',
|
||||
];
|
||||
|
||||
type Store = ReturnType<typeof unitStore>;
|
||||
type RoomRow = {
|
||||
code: string;
|
||||
state: string;
|
||||
version: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
let receiptSequence = 0;
|
||||
function entry() {
|
||||
receiptSequence++;
|
||||
return {
|
||||
operationId: `00000000-0000-4000-8000-${receiptSequence
|
||||
.toString(16)
|
||||
.padStart(12, '0')}`,
|
||||
sessionToken: receiptSequence.toString(16).padStart(64, '0'),
|
||||
};
|
||||
}
|
||||
|
||||
function roomRow(db: DatabaseSync, code: string): RoomRow {
|
||||
const row = db
|
||||
.prepare('SELECT code,state,version,updated_at FROM rooms WHERE code = ?')
|
||||
.get(code) as RoomRow | undefined;
|
||||
assert.ok(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function storageSnapshot(db: DatabaseSync) {
|
||||
return {
|
||||
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
|
||||
seats: db.prepare('SELECT * FROM seats ORDER BY room_code,player_id').all(),
|
||||
receipts: db
|
||||
.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id')
|
||||
.all(),
|
||||
};
|
||||
}
|
||||
|
||||
async function readyRoom(
|
||||
store: Store,
|
||||
expansions: string[],
|
||||
factions: readonly FactionId[],
|
||||
advanced = false,
|
||||
) {
|
||||
const made = await store.rooms.createRoom(
|
||||
`${factions[0]} host`,
|
||||
factions[0],
|
||||
advanced,
|
||||
expansions,
|
||||
entry(),
|
||||
);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of factions.slice(1)) {
|
||||
const joined = await store.rooms.joinRoom(
|
||||
code,
|
||||
`${faction} seat`,
|
||||
faction,
|
||||
entry(),
|
||||
);
|
||||
assert.ok(joined.token);
|
||||
tokens.push(joined.token);
|
||||
}
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
for (const auth of auths) {
|
||||
const current = await store.rooms.readRoom(code);
|
||||
await store.rooms.act(
|
||||
code,
|
||||
auth,
|
||||
current.version,
|
||||
{ type: 'ready' },
|
||||
clock,
|
||||
);
|
||||
}
|
||||
const source = await store.rooms.readRoom(code);
|
||||
const raw = roomRow(store.sqlite, code);
|
||||
assert.equal(source.version, raw.version);
|
||||
assert.equal((JSON.parse(raw.state) as Game).version, raw.version);
|
||||
assert.equal(source.status, 'lobby');
|
||||
assert.ok(source.players.every((player) => player.ready));
|
||||
return { code, tokens, auths, source };
|
||||
}
|
||||
|
||||
function ownPlayer(view: GameView) {
|
||||
return view.players.find((player) => player.id === view.me)!;
|
||||
}
|
||||
|
||||
async function assertPrivateSetupViews(
|
||||
rooms: Store['rooms'],
|
||||
code: string,
|
||||
auths: Rooms.SeatAuth[],
|
||||
game: Game,
|
||||
) {
|
||||
const views = await Promise.all(
|
||||
auths.map((auth) => rooms.readSeatView(code, auth)),
|
||||
);
|
||||
for (const [index, view] of views.entries()) {
|
||||
const owner = game.players[index];
|
||||
assert.equal(view.me, owner.id);
|
||||
assert.deepEqual(ownPlayer(view).hand, owner.hand);
|
||||
assert.deepEqual(ownPlayer(view).traitors, owner.traitors);
|
||||
assert.deepEqual(ownPlayer(view).traitorChoices, owner.traitorChoices);
|
||||
assert.deepEqual(ownPlayer(view).faceDancers, owner.faceDancers);
|
||||
assert.equal(Object.hasOwn(view, 'deck'), false);
|
||||
for (const other of view.players.filter(
|
||||
(player) => player.id !== view.me,
|
||||
)) {
|
||||
assert.equal(other.hand, undefined);
|
||||
assert.equal(other.traitors, undefined);
|
||||
assert.equal(other.traitorChoices, undefined);
|
||||
assert.equal(other.faceDancers, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const richeseIndex = game.players.findIndex(
|
||||
(player) => player.faction === 'richese',
|
||||
);
|
||||
const richese = game.players[richeseIndex];
|
||||
if (richese.noField) {
|
||||
assert.deepEqual(
|
||||
views[richeseIndex].richeseNoField?.private,
|
||||
richese.noField,
|
||||
);
|
||||
const secretIds = richese.noField.tokens.map((token) => token.id);
|
||||
for (const [index, view] of views.entries()) {
|
||||
if (index === richeseIndex) continue;
|
||||
assert.equal(view.richeseNoField?.private, null);
|
||||
const projected = JSON.stringify(view);
|
||||
for (const id of secretIds) assert.equal(projected.includes(id), false);
|
||||
}
|
||||
}
|
||||
|
||||
const moritaniIndex = game.players.findIndex(
|
||||
(player) => player.faction === 'moritani',
|
||||
);
|
||||
if (game.moritaniTerror && moritaniIndex >= 0) {
|
||||
assert.deepEqual(views[moritaniIndex].moritaniTerror, game.moritaniTerror);
|
||||
for (const [index, view] of views.entries()) {
|
||||
if (index === moritaniIndex) continue;
|
||||
assert.deepEqual(view.moritaniTerror?.tokens, []);
|
||||
}
|
||||
}
|
||||
|
||||
if (game.ixSetupCards) {
|
||||
const ixianIndex = game.players.findIndex(
|
||||
(player) => player.faction === 'ixians',
|
||||
);
|
||||
assert.deepEqual(views[ixianIndex].ixTechnology?.setup, game.ixSetupCards);
|
||||
for (const [index, view] of views.entries())
|
||||
if (index !== ixianIndex) assert.equal(view.ixTechnology, null);
|
||||
}
|
||||
}
|
||||
|
||||
function setupAction(view: GameView): Action | null {
|
||||
const copy = structuredClone(view);
|
||||
ownPlayer(copy).bot = 'Medium';
|
||||
return botActions(copy)[0] ?? null;
|
||||
}
|
||||
|
||||
async function finishPersistedSetup(
|
||||
store: Store,
|
||||
code: string,
|
||||
auths: Rooms.SeatAuth[],
|
||||
) {
|
||||
let sawPrivateIxChoice = false;
|
||||
for (let step = 0; step < 64; step++) {
|
||||
const game = await store.restart().readRoom(code);
|
||||
const raw = roomRow(store.sqlite, code);
|
||||
assert.equal(game.version, raw.version);
|
||||
assert.equal((JSON.parse(raw.state) as Game).version, raw.version);
|
||||
await assertPrivateSetupViews(store.restart(), code, auths, game);
|
||||
if (game.ixSetupCards) sawPrivateIxChoice = true;
|
||||
if (game.status === 'playing') return { game, sawPrivateIxChoice };
|
||||
|
||||
let progressed = false;
|
||||
for (const auth of auths) {
|
||||
const rooms = store.restart();
|
||||
const view = await rooms.readSeatView(code, auth);
|
||||
const action = setupAction(view);
|
||||
if (!action) continue;
|
||||
await rooms.act(code, auth, view.version, action, clock);
|
||||
progressed = true;
|
||||
break;
|
||||
}
|
||||
assert.ok(
|
||||
progressed,
|
||||
`Persisted faction setup stalled at ${game.setupStage}/${game.decision?.kind ?? 'no decision'}.`,
|
||||
);
|
||||
}
|
||||
assert.fail('Persisted faction setup exceeded its bounded action count.');
|
||||
}
|
||||
|
||||
void test('faction prototype preserves room entry state and completes a private mixed setup after reloads', async (t) => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const main = await readyRoom(
|
||||
store,
|
||||
['ix', 'choam', 'ecaz'],
|
||||
MIXED_ROSTER,
|
||||
true,
|
||||
);
|
||||
const neighbor = await store.rooms.createRoom(
|
||||
'Unchanged neighboring room',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
entry(),
|
||||
);
|
||||
const neighborRow = roomRow(store.sqlite, neighbor.view.code);
|
||||
const seatsBefore = storageSnapshot(store.sqlite).seats;
|
||||
const receiptsBefore = storageSnapshot(store.sqlite).receipts;
|
||||
|
||||
const started = startPrototypeRoom(
|
||||
store.sqlite,
|
||||
main.code,
|
||||
main.source.version,
|
||||
'factions',
|
||||
);
|
||||
assert.equal(started.version, main.source.version + 1);
|
||||
assert.equal(started.status, 'setup');
|
||||
assert.equal(started.players, 6);
|
||||
const startedRow = roomRow(store.sqlite, main.code);
|
||||
assert.equal(startedRow.version, started.version);
|
||||
assert.equal((JSON.parse(startedRow.state) as Game).version, started.version);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore);
|
||||
assert.deepEqual(roomRow(store.sqlite, neighbor.view.code), neighborRow);
|
||||
|
||||
const { game, sawPrivateIxChoice } = await finishPersistedSetup(
|
||||
store,
|
||||
main.code,
|
||||
main.auths,
|
||||
);
|
||||
assert.equal(sawPrivateIxChoice, true);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.deepEqual(game.expansions, ['ix', 'choam', 'ecaz']);
|
||||
assert.equal(game.setupStage, undefined);
|
||||
assert.equal(game.ixSetupCards, null);
|
||||
assert.equal(game.richeseCache?.length, 10);
|
||||
assert.equal(game.richeseRemoved?.length, 0);
|
||||
assert.equal(game.ecazAmbassadors?.tokens.length, 11);
|
||||
assert.equal(game.ecazAmbassadors?.cohort.length, 5);
|
||||
assert.equal(game.moritaniTerror?.tokens.length, 6);
|
||||
assert.equal(game.dukeVidal?.leader.id, 'duke-vidal');
|
||||
assert.equal(
|
||||
game.players.find((player) => player.faction === 'tleilaxu')?.faceDancers
|
||||
?.length,
|
||||
3,
|
||||
);
|
||||
const ecaz = game.players.find((player) => player.faction === 'ecaz')!;
|
||||
assert.equal(ecaz.reserves, 14);
|
||||
assert.equal(
|
||||
Object.entries(ecaz.forces)
|
||||
.filter(([key]) => key.startsWith('imperial_basin:'))
|
||||
.reduce((sum, [, amount]) => sum + amount, 0),
|
||||
6,
|
||||
);
|
||||
const expectedDeck = treacheryDeck(['ix', 'choam', 'ecaz']);
|
||||
const heldDeck = [
|
||||
...game.deck,
|
||||
...game.players.flatMap((player) => player.hand),
|
||||
];
|
||||
assert.equal(expectedDeck.length, 47);
|
||||
assert.equal(heldDeck.length, 47);
|
||||
assert.deepEqual(
|
||||
heldDeck.map((card) => card.id).sort(),
|
||||
expectedDeck.map((card) => card.id).sort(),
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore);
|
||||
assert.deepEqual(roomRow(store.sqlite, neighbor.view.code), neighborRow);
|
||||
});
|
||||
|
||||
void test('faction prototype rejects stale, wrong-profile, optional-module and redeal starts without writes', async (t) => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const main = await readyRoom(store, ['choam', 'ecaz'], ['choam', 'ecaz']);
|
||||
|
||||
let before = storageSnapshot(store.sqlite);
|
||||
assert.throws(
|
||||
() =>
|
||||
startPrototypeRoom(
|
||||
store.sqlite,
|
||||
main.code,
|
||||
main.source.version - 1,
|
||||
'factions',
|
||||
),
|
||||
/missing or changed/,
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
startPrototypeRoom(store.sqlite, main.code, main.source.version, 'ix'),
|
||||
/requires exactly/,
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite), before);
|
||||
|
||||
const unsupported = await readyRoom(store, ['ecaz'], ['ecaz', 'atreides']);
|
||||
const unsupportedRow = roomRow(store.sqlite, unsupported.code);
|
||||
const unsupportedState = JSON.parse(unsupportedRow.state) as Game;
|
||||
unsupportedState.discoveryEnabled = true;
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(unsupportedState), unsupported.code);
|
||||
before = storageSnapshot(store.sqlite);
|
||||
assert.throws(
|
||||
() =>
|
||||
startPrototypeRoom(
|
||||
store.sqlite,
|
||||
unsupported.code,
|
||||
unsupported.source.version,
|
||||
'factions',
|
||||
),
|
||||
/excludes optional modules/,
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite), before);
|
||||
|
||||
const result = startPrototypeRoom(
|
||||
store.sqlite,
|
||||
main.code,
|
||||
main.source.version,
|
||||
'factions',
|
||||
);
|
||||
before = storageSnapshot(store.sqlite);
|
||||
assert.throws(
|
||||
() =>
|
||||
startPrototypeRoom(store.sqlite, main.code, result.version, 'factions'),
|
||||
/fresh lobby|overwrite|redeal/,
|
||||
);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite), before);
|
||||
});
|
||||
|
||||
void test('faction prototype start CAS preserves a concurrent fresh-lobby winner', async (t) => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const main = await readyRoom(store, ['ecaz'], ['ecaz', 'atreides']);
|
||||
const seatsBefore = storageSnapshot(store.sqlite).seats;
|
||||
const receiptsBefore = storageSnapshot(store.sqlite).receipts;
|
||||
let raced = false;
|
||||
const racing = {
|
||||
prepare(sql: string) {
|
||||
const statement = store.sqlite.prepare(sql);
|
||||
if (!sql.startsWith('UPDATE rooms SET state = ?, version = ?'))
|
||||
return statement;
|
||||
return {
|
||||
run: (...values: (string | number | null)[]) => {
|
||||
if (!raced) {
|
||||
raced = true;
|
||||
const current = roomRow(store.sqlite, main.code);
|
||||
const winner = JSON.parse(current.state) as Game;
|
||||
winner.version = current.version + 1;
|
||||
winner.log.push({
|
||||
seq: (winner.log.at(-1)?.seq ?? 0) + 1,
|
||||
text: 'Concurrent fresh-lobby edit won the version fence.',
|
||||
});
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = ?, updated_at = ? WHERE code = ?',
|
||||
)
|
||||
.run(
|
||||
JSON.stringify(winner),
|
||||
winner.version,
|
||||
current.updated_at + 1,
|
||||
main.code,
|
||||
);
|
||||
}
|
||||
return statement.run(...values);
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as DatabaseSync;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
startPrototypeRoom(racing, main.code, main.source.version, 'factions'),
|
||||
/changed during setup/,
|
||||
);
|
||||
assert.equal(raced, true);
|
||||
const saved = roomRow(store.sqlite, main.code);
|
||||
const winner = JSON.parse(saved.state) as Game;
|
||||
assert.equal(saved.version, main.source.version + 1);
|
||||
assert.equal(winner.version, saved.version);
|
||||
assert.equal(winner.status, 'lobby');
|
||||
assert.equal(winner.setupStage, undefined);
|
||||
assert.match(winner.log.at(-1)!.text, /Concurrent fresh-lobby edit/);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore);
|
||||
assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore);
|
||||
});
|
||||
@@ -109,7 +109,7 @@ void test('the Ix expansion has fourteen distinct cards and combines into a fort
|
||||
baseDeck().some((c) => c.name === 'Basilia Weapon'),
|
||||
false,
|
||||
);
|
||||
for (const expansion of ['ecaz', 'unknown'])
|
||||
for (const expansion of ['ecaz-treachery', 'unknown'])
|
||||
assert.throws(() => treacheryDeck([expansion]), /not implemented/);
|
||||
});
|
||||
void test('CHOAM & Richese adds or replaces the same two battle cards without duplicating physical custody', () => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { IxRicheseTechnology } from '../components/ix-richese-technology';
|
||||
import { RicheseAuctionDecision } from '../components/richese-auctions';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
function until(state: Game, kind: string) {
|
||||
let game = state;
|
||||
for (let step = 0; step < 100; step++) {
|
||||
if (game.decision?.kind === kind) return game;
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const action = botActions(viewGame(game, player.id))[0];
|
||||
if (action) {
|
||||
next = applyAction(game, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, `No action before ${kind}`);
|
||||
game = next;
|
||||
}
|
||||
throw new Error(`Did not reach ${kind}`);
|
||||
}
|
||||
|
||||
function offer(source: 'cache' | 'blackMarket') {
|
||||
let game = createGame(
|
||||
'IXRICHUI',
|
||||
newPlayer('r', 'Richese', 'richese'),
|
||||
true,
|
||||
['ix', 'choam'],
|
||||
);
|
||||
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
for (const player of game.players) {
|
||||
player.ready = true;
|
||||
player.bot = 'Medium';
|
||||
}
|
||||
game = until(
|
||||
initializeFactionExpansionsGameForAudit(game),
|
||||
'richeseBlackMarket',
|
||||
);
|
||||
if (source === 'cache') {
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
decline: true,
|
||||
});
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
position: 'first',
|
||||
});
|
||||
game = until(game, 'richeseCache');
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(RicheseAuctionDecision, {
|
||||
game: viewGame(game, 'r'),
|
||||
act() {},
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
assert.match(markup, /Offer (?:cache|Black Market) card/);
|
||||
assert.doesNotMatch(markup, /<button[^>]*\sdisabled=""[^>]*>Offer /);
|
||||
const card =
|
||||
source === 'cache' ? game.richeseCache![0] : game.players[0].hand[0];
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
card: card.id,
|
||||
method: 'silent',
|
||||
...(source === 'blackMarket' ? { claim: 'Private card claim' } : {}),
|
||||
});
|
||||
assert.equal(game.decision?.kind, 'ixRicheseTechnology');
|
||||
return { game, card };
|
||||
}
|
||||
|
||||
function render(game: Game, viewer = 'i', busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(IxRicheseTechnology, {
|
||||
game: viewGame(game, viewer),
|
||||
act() {},
|
||||
busy,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Ixian special-lot decline is owned, available after reload and disabled during submission', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
const { game, card } = offer(source);
|
||||
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
||||
const html = render(restored);
|
||||
assert.match(html, /Decline Technology for this lot/);
|
||||
assert.match(html, /once-per-round Technology use available/);
|
||||
assert.match(html, /custody/);
|
||||
assert.doesNotMatch(html, new RegExp(card.id));
|
||||
if (source === 'blackMarket') assert.ok(!html.includes(card.name));
|
||||
assert.equal(render(restored, 'r'), '');
|
||||
assert.equal(render(restored, 'a'), '');
|
||||
assert.match(render(restored, 'i', true), /<button[^>]*\sdisabled=""/);
|
||||
const completed = applyAction(restored, 'i', {
|
||||
type: 'decision',
|
||||
event: viewGame(restored, 'i').ixRicheseTechnology!.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(render(completed), '');
|
||||
assert.equal(completed.ixTechnologyTurn, game.ixTechnologyTurn);
|
||||
assert.equal(completed.richeseAuction?.cardId, card.id);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four AI levels decline only this lot using the private owner view', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
const { game, card } = offer(source);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'i');
|
||||
view.players.find((player) => player.id === 'i')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(actions, [
|
||||
{
|
||||
type: 'decision',
|
||||
event: view.ixRicheseTechnology!.event,
|
||||
decline: true,
|
||||
},
|
||||
]);
|
||||
const result = applyAction(game, 'i', actions[0]);
|
||||
assert.equal(result.richeseAuction?.cardId, card.id);
|
||||
assert.equal(result.ixTechnologyTurn, game.ixTechnologyTurn);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import {
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Decision,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: Rooms.RoomsClock = {
|
||||
now: () => 22_000,
|
||||
sleep: async () => {},
|
||||
};
|
||||
|
||||
type Store = ReturnType<typeof unitStore>;
|
||||
type Source = 'cache' | 'blackMarket';
|
||||
|
||||
function player(g: Game, id: string) {
|
||||
return g.players.find((candidate) => candidate.id === id)!;
|
||||
}
|
||||
|
||||
function take(g: Game, predicate: (card: Card) => boolean) {
|
||||
const index = g.deck.findIndex(predicate);
|
||||
assert.ok(index >= 0);
|
||||
return g.deck.splice(index, 1)[0];
|
||||
}
|
||||
|
||||
function physicalCards(g: Game) {
|
||||
return [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.richeseRemoved ?? []),
|
||||
...g.players.flatMap((seat) => seat.hand),
|
||||
...(g.auction?.cards.slice(g.auction.index) ?? []),
|
||||
...(g.ixAuction?.cards ?? []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function storageRow(store: Store, code: string) {
|
||||
const row = store.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(code) as { state: string; version: number } | undefined;
|
||||
assert.ok(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function snapshot(store: Store, code: string) {
|
||||
const row = storageRow(store, code);
|
||||
return { state: row.state, version: row.version };
|
||||
}
|
||||
|
||||
function barrier() {
|
||||
let release!: () => void;
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrivals = 0;
|
||||
return async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await wait;
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t: test.TestContext) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Richese seller', 'richese', true, [
|
||||
'ix',
|
||||
'choam',
|
||||
]);
|
||||
const code = made.view.code;
|
||||
const ixJoined = await store.rooms.joinRoom(code, 'Ixian choice', 'ixians');
|
||||
const observerJoined = await store.rooms.joinRoom(
|
||||
code,
|
||||
'Outside observer',
|
||||
'emperor',
|
||||
);
|
||||
assert.ok(ixJoined.token);
|
||||
assert.ok(observerJoined.token);
|
||||
const auths = await Promise.all(
|
||||
[made.token, ixJoined.token, observerJoined.token].map((token) =>
|
||||
store.rooms.authenticate(code, token),
|
||||
),
|
||||
);
|
||||
const [richeseAuth, ixAuth, observerAuth] = auths;
|
||||
const initial = await store.rooms.readRoom(code);
|
||||
Object.assign(initial, {
|
||||
status: 'playing',
|
||||
advanced: true,
|
||||
turn: 2,
|
||||
phase: 2,
|
||||
order: auths.map((auth) => auth.playerId),
|
||||
active: null,
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
deck: treacheryDeck(['ix', 'choam']),
|
||||
discard: [],
|
||||
richeseCache: richeseCards(),
|
||||
richeseRemoved: [],
|
||||
richeseBidding: null,
|
||||
richeseAuction: null,
|
||||
richeseFunding: {},
|
||||
});
|
||||
for (const seat of initial.players) {
|
||||
seat.hand = [];
|
||||
seat.spice = 20;
|
||||
seat.traitors = [seat.leaders[0].id];
|
||||
seat.traitorChoices = [];
|
||||
}
|
||||
const blackMarketCard = take(
|
||||
initial,
|
||||
(card) => card.effect !== 'karama' && card.kind !== 'worthless',
|
||||
);
|
||||
player(initial, richeseAuth.playerId).hand.push(blackMarketCard);
|
||||
player(initial, ixAuth.playerId).hand.push(
|
||||
take(initial, (card) => card.effect !== 'karama'),
|
||||
);
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(initial), initial.version, code);
|
||||
|
||||
async function act(
|
||||
auth: Rooms.SeatAuth,
|
||||
action: Action,
|
||||
rooms = store.rooms,
|
||||
version?: number,
|
||||
) {
|
||||
const currentVersion = version ?? (await rooms.readRoom(code)).version;
|
||||
return rooms.act(code, auth, currentVersion, action, clock);
|
||||
}
|
||||
for (const auth of auths) await act(auth, { type: 'ready' });
|
||||
let opened = await store.rooms.readRoom(code);
|
||||
for (const auth of auths) {
|
||||
if (!opened.phaseOpening) break;
|
||||
await act(auth, { type: 'ready' });
|
||||
opened = await store.rooms.readRoom(code);
|
||||
}
|
||||
store.writes.length = 0;
|
||||
opened = await store.restart().readRoom(code);
|
||||
assert.equal(opened.phase, 3);
|
||||
assert.equal(opened.decision?.kind, 'richeseBlackMarket');
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
auths,
|
||||
richeseAuth,
|
||||
ixAuth,
|
||||
observerAuth,
|
||||
blackMarketCard,
|
||||
act,
|
||||
};
|
||||
}
|
||||
|
||||
async function openIxChoice(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
source: Source,
|
||||
) {
|
||||
let game = await f.rooms.readRoom(f.code);
|
||||
if (source === 'cache') {
|
||||
await f.act(f.richeseAuth, {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
decline: true,
|
||||
});
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
await f.act(f.richeseAuth, {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
position: 'first',
|
||||
});
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
while (game.response) {
|
||||
const auth = f.auths.find(
|
||||
(candidate) => !game.response!.passed.includes(candidate.playerId),
|
||||
);
|
||||
assert.ok(auth);
|
||||
await f.act(auth, { type: 'passResponse' });
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
}
|
||||
const offered =
|
||||
source === 'cache' ? game.richeseCache![3] : f.blackMarketCard;
|
||||
const roundEvent = game.richeseBidding!.event;
|
||||
await f.act(f.richeseAuth, {
|
||||
type: 'decision',
|
||||
event: roundEvent,
|
||||
card: offered.id,
|
||||
method: 'onceAround',
|
||||
direction: source === 'cache' ? 'clockwise' : 'counterclockwise',
|
||||
...(source === 'blackMarket' ? { claim: ' concealed claim ' } : {}),
|
||||
});
|
||||
game = await f.restart().readRoom(f.code);
|
||||
assert.deepEqual(game.decision, {
|
||||
kind: 'ixRicheseTechnology',
|
||||
player: f.ixAuth.playerId,
|
||||
event: game.pendingIxRicheseTechnology!.event,
|
||||
source,
|
||||
});
|
||||
assert.equal(game.pendingIxRicheseTechnology?.round, roundEvent);
|
||||
assert.equal(game.pendingIxRicheseTechnology?.card.id, offered.id);
|
||||
assert.equal(
|
||||
game.pendingIxRicheseTechnology?.direction,
|
||||
source === 'cache' ? 'clockwise' : 'counterclockwise',
|
||||
);
|
||||
assert.equal(
|
||||
game.pendingIxRicheseTechnology?.claim,
|
||||
source === 'blackMarket' ? 'concealed claim' : null,
|
||||
);
|
||||
return { game, offered };
|
||||
}
|
||||
|
||||
function decline(game: Game): Action {
|
||||
return {
|
||||
type: 'decision',
|
||||
event: game.pendingIxRicheseTechnology!.event,
|
||||
decline: true,
|
||||
};
|
||||
}
|
||||
|
||||
void test('restored Ixian declines resume the exact cache and Black Market lots once without spending Technology or exposing cards', async (t) => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
await t.test(source, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { game: pending, offered } = await openIxChoice(f, source);
|
||||
const pendingVersion = pending.version;
|
||||
const inventory = physicalCards(pending);
|
||||
const counts = {
|
||||
deck: pending.deck.length,
|
||||
cache: pending.richeseCache?.length,
|
||||
richeseHand: player(pending, f.richeseAuth.playerId).hand.length,
|
||||
ixHand: player(pending, f.ixAuth.playerId).hand.length,
|
||||
auctionPool: pending.auction?.cards.length ?? 0,
|
||||
ixPool: pending.ixAuction?.cards.length ?? 0,
|
||||
};
|
||||
assert.equal(pending.ixTechnologyTurn, undefined);
|
||||
|
||||
for (const auth of f.auths) {
|
||||
const view = await f.restart().readSeatView(f.code, auth);
|
||||
assert.deepEqual(view.ixRicheseTechnology, {
|
||||
event: pending.pendingIxRicheseTechnology!.event,
|
||||
player: f.ixAuth.playerId,
|
||||
owner: f.richeseAuth.playerId,
|
||||
source,
|
||||
exchangeBlocked:
|
||||
'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.',
|
||||
});
|
||||
assert.equal(Object.hasOwn(view, 'pendingIxRicheseTechnology'), false);
|
||||
assert.equal(Object.hasOwn(view, 'ixRicheseTechnologyEvent'), false);
|
||||
if (auth.playerId !== f.richeseAuth.playerId)
|
||||
assert.equal(JSON.stringify(view).includes(offered.id), false);
|
||||
}
|
||||
const ixView = await f.rooms.readSeatView(f.code, f.ixAuth);
|
||||
const botView = structuredClone(ixView);
|
||||
botView.players.find((seat) => seat.id === f.ixAuth.playerId)!.bot =
|
||||
'Medium';
|
||||
assert.deepEqual(botActions(botView), [decline(pending)]);
|
||||
|
||||
if (source === 'blackMarket') {
|
||||
const beforeRejected = snapshot(f, f.code);
|
||||
await assert.rejects(
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.observerAuth,
|
||||
pendingVersion,
|
||||
decline(pending),
|
||||
clock,
|
||||
),
|
||||
/pending decision/,
|
||||
);
|
||||
for (const action of [
|
||||
{
|
||||
type: 'decision',
|
||||
event: pending.pendingIxRicheseTechnology!.event + '-stale',
|
||||
decline: true,
|
||||
},
|
||||
{
|
||||
type: 'decision',
|
||||
event: pending.pendingIxRicheseTechnology!.event,
|
||||
decline: false,
|
||||
},
|
||||
{
|
||||
...decline(pending),
|
||||
card: pending.pendingIxRicheseTechnology!.card.id,
|
||||
},
|
||||
])
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.ixAuth, pendingVersion, action, clock),
|
||||
/Explicitly continue/,
|
||||
);
|
||||
assert.deepEqual(snapshot(f, f.code), beforeRejected);
|
||||
}
|
||||
|
||||
await f.act(f.ixAuth, decline(pending), f.restart(), pendingVersion);
|
||||
const resumed = await f.restart().readRoom(f.code);
|
||||
assert.equal(resumed.version, pendingVersion + 1);
|
||||
assert.equal(resumed.pendingIxRicheseTechnology, undefined);
|
||||
assert.equal(resumed.ixRicheseTechnologyEvent, undefined);
|
||||
assert.equal(resumed.ixTechnologyTurn, undefined);
|
||||
assert.equal(resumed.richeseAuction?.source, source);
|
||||
assert.equal(resumed.richeseAuction?.cardId, offered.id);
|
||||
assert.equal(resumed.richeseAuction?.method, 'onceAround');
|
||||
assert.deepEqual(physicalCards(resumed), inventory);
|
||||
assert.deepEqual(
|
||||
{
|
||||
deck: resumed.deck.length,
|
||||
cache: resumed.richeseCache?.length,
|
||||
richeseHand: player(resumed, f.richeseAuth.playerId).hand.length,
|
||||
ixHand: player(resumed, f.ixAuth.playerId).hand.length,
|
||||
auctionPool: resumed.auction?.cards.length ?? 0,
|
||||
ixPool: resumed.ixAuction?.cards.length ?? 0,
|
||||
},
|
||||
counts,
|
||||
);
|
||||
const outside = await f.restart().readSeatView(f.code, f.observerAuth);
|
||||
if (source === 'blackMarket') {
|
||||
assert.equal(outside.richeseAuction?.card, null);
|
||||
assert.equal(outside.richeseAuction?.cardId, null);
|
||||
assert.equal(JSON.stringify(outside).includes(offered.id), false);
|
||||
}
|
||||
|
||||
const saved = snapshot(f, f.code);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.ixAuth, pendingVersion, decline(pending), clock),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.ixAuth, resumed.version, decline(pending), clock),
|
||||
);
|
||||
assert.deepEqual(snapshot(f, f.code), saved);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void test('forged or missing saved Ixian lot decisions fail before projection, action and normalization without writes', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { game: pending } = await openIxChoice(f, 'blackMarket');
|
||||
const genuine = structuredClone(pending);
|
||||
const corruptions: Array<(game: Game) => void> = [
|
||||
(game) => {
|
||||
game.decision = null;
|
||||
},
|
||||
(game) => {
|
||||
delete game.pendingIxRicheseTechnology;
|
||||
},
|
||||
(game) => {
|
||||
if (game.decision?.kind === 'ixRicheseTechnology')
|
||||
game.decision.event += '-forged';
|
||||
},
|
||||
(game) => {
|
||||
game.ixRicheseTechnologyEvent += '-forged';
|
||||
},
|
||||
(game) => {
|
||||
const duplicate = structuredClone(game.decision) as Decision;
|
||||
game.pendingExchange = { response: null, decision: duplicate };
|
||||
},
|
||||
];
|
||||
|
||||
for (const corrupt of corruptions) {
|
||||
const game = structuredClone(genuine);
|
||||
corrupt(game);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), game.version, f.code);
|
||||
const before = snapshot(f, f.code);
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => viewGame(game, f.ixAuth.playerId), /saved Ixian/);
|
||||
assert.deepEqual(game, original);
|
||||
assert.throws(() => normalizeAutomaticGame(game), /saved Ixian/);
|
||||
assert.deepEqual(game, original);
|
||||
await assert.rejects(f.rooms.readSeatView(f.code, f.ixAuth), /saved Ixian/);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.ixAuth, game.version, decline(genuine), clock),
|
||||
/saved Ixian/,
|
||||
);
|
||||
assert.deepEqual(snapshot(f, f.code), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent authenticated declines persist one Richese lot continuation behind the room CAS', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { game: pending, offered } = await openIxChoice(f, 'blackMarket');
|
||||
const inventory = physicalCards(pending);
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const attempts = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.ixAuth, pending.version, decline(pending), clock),
|
||||
f.restart().act(f.code, f.ixAuth, pending.version, decline(pending), clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
attempts.filter((attempt) => attempt.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
const resumed = await f.restart().readRoom(f.code);
|
||||
assert.equal(resumed.version, pending.version + 1);
|
||||
assert.equal(resumed.richeseAuction?.cardId, offered.id);
|
||||
assert.equal(resumed.richeseAuction?.source, 'blackMarket');
|
||||
assert.equal(resumed.pendingIxRicheseTechnology, undefined);
|
||||
assert.equal(resumed.ixTechnologyTurn, undefined);
|
||||
assert.deepEqual(physicalCards(resumed), inventory);
|
||||
assert.equal(
|
||||
resumed.log.filter((entry) =>
|
||||
entry.text.includes('continued without exchanging this Richese lot'),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
prepareSpecialKaramaIntent,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function fixture(advanced = true, harkonnen = false) {
|
||||
let g = createGame('IXRICH', newPlayer('r', 'Richese', 'richese'), advanced, [
|
||||
'ix',
|
||||
'choam',
|
||||
]);
|
||||
joinGame(g, newPlayer('i', 'Ixians', 'ixians'));
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
if (harkonnen) joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 20; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((p) => p.id === view.me)!.bot = 'Medium';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
// Focus the genuine initialized inventory on the Charity-to-Bidding transition.
|
||||
Object.assign(g, {
|
||||
phase: 2,
|
||||
ready: [],
|
||||
phaseOpening: null,
|
||||
stormPending: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
});
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand);
|
||||
p.hand = [];
|
||||
p.spice = 20;
|
||||
}
|
||||
for (const [index, kind] of ['shield', 'snooper', 'projectile'].entries()) {
|
||||
const card = g.deck.findIndex((c) => c.kind === kind);
|
||||
assert.ok(card >= 0);
|
||||
g.players[index].hand.push(...g.deck.splice(card, 1));
|
||||
}
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 3);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
function richese(g: Game, fields: Omit<Action, 'type'>) {
|
||||
return applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
...fields,
|
||||
});
|
||||
}
|
||||
function offer(source: 'cache' | 'blackMarket', method = 'silent') {
|
||||
let g = fixture();
|
||||
if (source === 'cache') {
|
||||
g = richese(g, { decline: true });
|
||||
g = richese(g, { position: 'first' });
|
||||
}
|
||||
const card = source === 'cache' ? g.richeseCache![0] : g.players[0].hand[0];
|
||||
const before = structuredClone(g);
|
||||
g = richese(g, {
|
||||
card: card.id,
|
||||
method,
|
||||
direction: 'counterclockwise',
|
||||
claim: ' a private draft claim ',
|
||||
});
|
||||
return { g, card, before };
|
||||
}
|
||||
function decline(g: Game) {
|
||||
return applyAction(reload(g), 'i', {
|
||||
type: 'decision',
|
||||
event: g.pendingIxRicheseTechnology!.event,
|
||||
decline: true,
|
||||
});
|
||||
}
|
||||
function stock(g: Game) {
|
||||
return {
|
||||
deck: g.deck,
|
||||
discard: g.discard,
|
||||
hands: g.players.map((p) => p.hand),
|
||||
spice: g.players.map((p) => p.spice),
|
||||
cache: g.richeseCache,
|
||||
removed: g.richeseRemoved,
|
||||
normalCount: g.richeseBidding!.normalCount,
|
||||
blackMarketSold: g.richeseBidding!.blackMarketSold,
|
||||
ixTechnologyTurn: g.ixTechnologyTurn,
|
||||
};
|
||||
}
|
||||
function reject(g: Game, player: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, player, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
for (const source of ['cache', 'blackMarket'] as const)
|
||||
void test(`${source} offer pauses before bidding or inspection and saved explicit Ixian decline preserves all custody, funds and Technology`, () => {
|
||||
for (const method of source === 'cache'
|
||||
? ['silent', 'onceAround']
|
||||
: ['silent', 'onceAround', 'normal']) {
|
||||
const { g, card, before } = offer(source, method);
|
||||
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
|
||||
assert.equal(g.decision?.player, 'i');
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.richeseAuction, null);
|
||||
assert.equal(g.auction, null);
|
||||
assert.deepEqual(stock(g), stock(before));
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
const event = g.pendingIxRicheseTechnology!.event;
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(reload(g), p.id), viewGame(g, p.id));
|
||||
const resumed = decline(g);
|
||||
assert.equal(resumed.pendingIxRicheseTechnology, undefined);
|
||||
assert.equal(resumed.ixRicheseTechnologyEvent, undefined);
|
||||
assert.deepEqual(stock(resumed), stock(g));
|
||||
assert.equal(resumed.richeseAuction!.cardId, card.id);
|
||||
assert.equal(resumed.richeseAuction!.source, source);
|
||||
assert.equal(resumed.richeseAuction!.method, method);
|
||||
assert.equal(
|
||||
resumed.richeseClaim,
|
||||
source === 'blackMarket' ? 'a private draft claim' : null,
|
||||
);
|
||||
assert.equal(viewGame(resumed, 'a').richeseAuction!.card?.id, card.id);
|
||||
reject(resumed, 'i', { type: 'decision', event, decline: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('Black Market pending projections conceal card identity, terms and receipt from Ixians and Atreides before inspection', () => {
|
||||
const { g, card } = offer('blackMarket');
|
||||
for (const id of ['i', 'a']) {
|
||||
const view = viewGame(g, id),
|
||||
json = JSON.stringify(view);
|
||||
assert.equal(json.includes(card.id), false);
|
||||
assert.equal(json.includes('a private draft claim'), false);
|
||||
assert.equal('pendingIxRicheseTechnology' in view, false);
|
||||
assert.equal('ixRicheseTechnologyEvent' in view, false);
|
||||
assert.deepEqual(view.ixRicheseTechnology, {
|
||||
event: g.pendingIxRicheseTechnology!.event,
|
||||
player: 'i',
|
||||
owner: 'r',
|
||||
source: 'blackMarket',
|
||||
exchangeBlocked: view.ixRicheseTechnology!.exchangeBlocked,
|
||||
});
|
||||
assert.equal(view.richeseBidding!.offerBlocked, null);
|
||||
}
|
||||
assert.equal(JSON.stringify(g.log).includes(card.id), false);
|
||||
assert.equal(JSON.stringify(g.log).includes('a private draft claim'), false);
|
||||
});
|
||||
|
||||
void test('decline requires the current Ixian owner and exact explicit action; exchange and stale offers remain guarded atomically', () => {
|
||||
const { g } = offer('cache'),
|
||||
event = g.pendingIxRicheseTechnology!.event;
|
||||
for (const action of [
|
||||
{ type: 'decision', event },
|
||||
{ type: 'decision', event, decline: false },
|
||||
{ type: 'decision', event, card: g.players[1].hand[0].id },
|
||||
{ type: 'decision', event, decline: true, card: 'extra' },
|
||||
{ type: 'decision', event: 'expired', decline: true },
|
||||
{ type: 'ready' },
|
||||
])
|
||||
reject(g, 'i', action);
|
||||
reject(g, 'r', { type: 'decision', event, decline: true });
|
||||
reject(g, 'a', { type: 'decision', event, decline: true });
|
||||
reject(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: g.richeseCache![1].id,
|
||||
method: 'silent',
|
||||
});
|
||||
reject(g, 'a', { type: 'richeseBid', event, amount: 1 });
|
||||
});
|
||||
|
||||
void test('saved receipt, independent marker, unique decision and exact physical offer cannot be forged or lost before view, action or normalization', () => {
|
||||
const { g } = offer('blackMarket');
|
||||
const mutations: Array<(g: Game) => void> = [
|
||||
(g) => {
|
||||
delete g.pendingIxRicheseTechnology;
|
||||
},
|
||||
(g) => {
|
||||
delete g.ixRicheseTechnologyEvent;
|
||||
},
|
||||
(g) => {
|
||||
g.decision = null;
|
||||
},
|
||||
(g) => {
|
||||
g.decision!.player = 'r';
|
||||
},
|
||||
(g) => {
|
||||
(
|
||||
g.decision as Extract<
|
||||
NonNullable<Game['decision']>,
|
||||
{ kind: 'ixRicheseTechnology' }
|
||||
>
|
||||
).event = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxRicheseTechnology!.event = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxRicheseTechnology!.source = 'cache';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxRicheseTechnology!.method = 'normal';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxRicheseTechnology!.claim = 'changed';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxRicheseTechnology!.card.name = 'changed';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingIxRicheseTechnology!.round = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.turn++;
|
||||
},
|
||||
(g) => {
|
||||
g.richeseBidding!.normalCount = 4;
|
||||
},
|
||||
(g) => {
|
||||
g.richeseBidding!.event = 'replaced';
|
||||
},
|
||||
(g) => {
|
||||
g.ixTechnologyTurn = g.turn;
|
||||
},
|
||||
(g) => {
|
||||
g.deck.push(g.players[0].hand[0]);
|
||||
},
|
||||
(g) => {
|
||||
g.deck.push(...g.players[0].hand.splice(0, 1));
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].hand[0].name = 'changed physical card';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingExchange = {
|
||||
response: null,
|
||||
decision: structuredClone(g.decision),
|
||||
};
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const bad = reload(g);
|
||||
mutate(bad);
|
||||
const before = structuredClone(bad);
|
||||
assert.throws(() => viewGame(bad, 'i'));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
assert.throws(() =>
|
||||
applyAction(bad, 'i', {
|
||||
type: 'decision',
|
||||
event: g.pendingIxRicheseTechnology!.event,
|
||||
decline: true,
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Black Market decline does not use Technology; a later cache has a fresh choice and normal lots still offer their original exchange', () => {
|
||||
let { g } = offer('blackMarket');
|
||||
const first = g.pendingIxRicheseTechnology!.event;
|
||||
g = decline(g);
|
||||
for (const p of g.players)
|
||||
g = applyAction(g, p.id, {
|
||||
type: 'richeseBid',
|
||||
event: g.richeseAuction!.event,
|
||||
amount: 0,
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'richeseDeclaration');
|
||||
g = richese(g, { position: 'first' });
|
||||
g = richese(g, { card: g.richeseCache![0].id, method: 'silent' });
|
||||
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
|
||||
assert.notEqual(g.pendingIxRicheseTechnology!.event, first);
|
||||
reject(g, 'i', { type: 'decision', event: first, decline: true });
|
||||
g = decline(g);
|
||||
for (const p of g.players)
|
||||
g = applyAction(g, p.id, {
|
||||
type: 'richeseBid',
|
||||
event: g.richeseAuction!.event,
|
||||
amount: 0,
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'richeseUnbid');
|
||||
g = richese(g, { keep: false });
|
||||
assert.equal(g.decision?.kind, 'ixAuction');
|
||||
const cards = g.ixAuction!.cards!;
|
||||
g = applyAction(g, 'i', {
|
||||
type: 'decision',
|
||||
card: cards[0].id,
|
||||
position: 'bottom',
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'ixTechnology');
|
||||
const replacement = g.players[1].hand[0],
|
||||
taken = g.auction!.cards[g.auction!.index];
|
||||
g = applyAction(g, 'i', { type: 'decision', card: replacement.id });
|
||||
assert.equal(g.ixTechnologyTurn, g.turn);
|
||||
assert.ok(g.players[1].hand.some((c) => c.id === taken.id));
|
||||
assert.equal(g.auction!.cards[g.auction!.index].id, replacement.id);
|
||||
});
|
||||
|
||||
void test('Basic offers and Advanced already-used Technology keep the original direct special-lot path', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let g = fixture(advanced);
|
||||
if (advanced) {
|
||||
g.ixTechnologyTurn = g.turn;
|
||||
g = richese(g, { decline: true });
|
||||
}
|
||||
g = richese(g, { position: 'first' });
|
||||
g = richese(g, { card: g.richeseCache![0].id, method: 'silent' });
|
||||
assert.ok(g.richeseAuction);
|
||||
assert.equal(g.pendingIxRicheseTechnology, undefined);
|
||||
assert.equal(viewGame(g, 'i').ixRicheseTechnology, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an independent Richese gift can suspend the Ixian lot choice and restore the same private commitment after a saved response', () => {
|
||||
let g = fixture();
|
||||
g.players[0].ally = 'i';
|
||||
g.players[1].ally = 'r';
|
||||
const gift = g.richeseCache!.splice(0, 1)[0];
|
||||
g.players[0].hand.push(gift);
|
||||
const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
g.players[2].hand.push(...g.deck.splice(karamaIndex, 1));
|
||||
g = richese(g, { card: g.players[0].hand[0].id, method: 'silent' });
|
||||
const commitment = structuredClone(g.pendingIxRicheseTechnology);
|
||||
g = applyAction(g, 'r', { type: 'richeseGift', card: gift.id });
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.response?.kind, 'richeseGift');
|
||||
assert.equal(
|
||||
g.pendingRicheseGift!.resume.decision?.kind,
|
||||
'ixRicheseTechnology',
|
||||
);
|
||||
assert.deepEqual(g.pendingIxRicheseTechnology, commitment);
|
||||
for (const id of ['r', 'i', 'a'])
|
||||
assert.deepEqual(viewGame(reload(g), id), viewGame(g, id));
|
||||
g = applyAction(reload(g), 'a', { type: 'passResponse' });
|
||||
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
|
||||
assert.deepEqual(g.pendingIxRicheseTechnology, commitment);
|
||||
assert.ok(g.players[1].hand.some((c) => c.id === gift.id));
|
||||
g = decline(g);
|
||||
assert.equal(g.richeseAuction!.cardId, commitment!.card.id);
|
||||
assert.equal(g.ixTechnologyTurn, undefined);
|
||||
});
|
||||
|
||||
void test('the pending physical card cannot begin a competing gift, paid Box search, or cache acquisition response', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
let g = fixture();
|
||||
g.players[0].ally = 'i';
|
||||
g.players[1].ally = 'r';
|
||||
const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama');
|
||||
const karama = g.deck.splice(karamaIndex, 1)[0];
|
||||
g.players[0].hand.push(karama);
|
||||
if (source === 'cache') {
|
||||
g = richese(g, { decline: true });
|
||||
g = richese(g, { position: 'first' });
|
||||
for (const p of g.players)
|
||||
if (g.response && !g.response.passed.includes(p.id))
|
||||
g = applyAction(g, p.id, { type: 'passResponse' });
|
||||
g = richese(g, { card: g.richeseCache![0].id, method: 'silent' });
|
||||
reject(g, 'r', {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
acquire: g.pendingIxRicheseTechnology!.card.id,
|
||||
});
|
||||
} else {
|
||||
const index = g.richeseCache!.findIndex(
|
||||
(c) => c.effect === 'nullentropyBox',
|
||||
);
|
||||
const card = g.richeseCache!.splice(index, 1)[0];
|
||||
g.players[0].hand.push(card);
|
||||
g.discard.push(...g.deck.splice(0, 1));
|
||||
g = richese(g, { card: card.id, method: 'silent' });
|
||||
reject(g, 'r', { type: 'richeseGift', card: card.id });
|
||||
reject(g, 'r', { type: 'card', card: card.id });
|
||||
assert.equal(
|
||||
viewGame(g, 'r').richeseGift?.cards.some((c) => c.id === card.id),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingNullentropy, undefined);
|
||||
assert.equal(g.pendingRicheseGift, undefined);
|
||||
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
|
||||
}
|
||||
});
|
||||
|
||||
void test('spending the last Ixian hand card in an independent Truthtrance leaves the saved per-lot decline available', () => {
|
||||
let g = fixture();
|
||||
g.deck.push(...g.players[1].hand.splice(0));
|
||||
const index = g.deck.findIndex((c) => c.effect === 'truthtrance');
|
||||
const truth = g.deck.splice(index, 1)[0];
|
||||
g.players[1].hand.push(truth);
|
||||
g = richese(g, { card: g.players[0].hand[0].id, method: 'silent' });
|
||||
const receipt = structuredClone(g.pendingIxRicheseTechnology);
|
||||
g = applyAction(g, 'i', { type: 'card', card: truth.id });
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const player = g.players.find(
|
||||
(p) => !g.truthtrance!.passed.includes(p.id),
|
||||
)!;
|
||||
g = applyAction(g, player.id, { type: 'truthPass' });
|
||||
}
|
||||
g = applyAction(g, 'i', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: 'a',
|
||||
fact: { kind: 'hand', name: g.players[2].hand[0].name },
|
||||
},
|
||||
});
|
||||
g = applyAction(reload(g), 'a', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(g.players[1].hand.length, 0);
|
||||
assert.deepEqual(g.pendingIxRicheseTechnology, receipt);
|
||||
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
|
||||
g = decline(g);
|
||||
assert.equal(g.richeseAuction!.cardId, receipt!.card.id);
|
||||
assert.equal(g.ixTechnologyTurn, undefined);
|
||||
});
|
||||
|
||||
void test('Harkonnen random exchange rejects the reserved Black Market hand before RNG in live and suspended Ixian choices', (t) => {
|
||||
const g = fixture(true, true);
|
||||
const karama = g.deck.splice(
|
||||
g.deck.findIndex((c) => c.effect === 'karama'),
|
||||
1,
|
||||
)[0];
|
||||
g.players[3].hand.push(karama);
|
||||
g.players[0].hand.push(
|
||||
g.deck.splice(
|
||||
g.deck.findIndex((c) => c.kind === 'worthless'),
|
||||
1,
|
||||
)[0],
|
||||
);
|
||||
const box = g.richeseCache!.splice(
|
||||
g.richeseCache!.findIndex((c) => c.effect === 'nullentropyBox'),
|
||||
1,
|
||||
)[0];
|
||||
g.players[1].hand.push(box);
|
||||
g.discard.push(
|
||||
g.deck.splice(
|
||||
g.deck.findIndex((c) => c.kind === 'worthless'),
|
||||
1,
|
||||
)[0],
|
||||
);
|
||||
g.discard.push(
|
||||
g.deck.splice(
|
||||
g.deck.findIndex((c) => c.kind === 'shield'),
|
||||
1,
|
||||
)[0],
|
||||
);
|
||||
const original = reload(g);
|
||||
const games: Game[] = [];
|
||||
for (const card of original.players[0].hand) {
|
||||
const live = richese(original, { card: card.id, method: 'silent' });
|
||||
const suspended = applyAction(live, 'i', { type: 'card', card: box.id });
|
||||
assert.equal(
|
||||
suspended.pendingNullentropy!.resume.decision?.kind,
|
||||
'ixRicheseTechnology',
|
||||
);
|
||||
assert.ok(viewGame(suspended, 'i').ixRicheseTechnology);
|
||||
games.push(live, suspended);
|
||||
}
|
||||
let cache = richese(original, { decline: true });
|
||||
cache = richese(cache, { position: 'first' });
|
||||
cache = applyAction(cache, 'h', { type: 'passResponse' });
|
||||
cache = richese(cache, { card: cache.richeseCache![0].id, method: 'silent' });
|
||||
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
||||
throw new Error('Unexpected random selection');
|
||||
});
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw new Error('Unexpected event allocation');
|
||||
});
|
||||
for (const state of games)
|
||||
for (const amount of [1, 2]) {
|
||||
const action = {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
target: 'r',
|
||||
amount,
|
||||
};
|
||||
const before = structuredClone(state);
|
||||
assert.throws(
|
||||
() => prepareSpecialKaramaIntent(state, 'h', action),
|
||||
/pending Black Market Ixian choice/,
|
||||
);
|
||||
assert.throws(() => applyAction(state, 'h', action));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
// A cache lot reserves the separate cache card, not Richese's unrelated hand.
|
||||
assert.equal(
|
||||
prepareSpecialKaramaIntent(cache, 'h', {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
target: 'r',
|
||||
amount: 1,
|
||||
}).kind,
|
||||
'harkonnen',
|
||||
);
|
||||
assert.equal(random.mock.callCount(), 0);
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
});
|
||||
@@ -320,34 +320,42 @@ void test('normal paid auctions after a cache sale route income to Emperor witho
|
||||
assert.equal(g.auction!.index, 1);
|
||||
});
|
||||
|
||||
void test('advanced unused Ixian Technology explicitly fences special lots when Ixians hold a replacement', () => {
|
||||
void test('advanced unused Ixian Technology pauses special lots for an explicit per-lot decline', () => {
|
||||
let g = fixture(true);
|
||||
g.players[2].faction = 'ixians';
|
||||
g.players[2].hand = [g.deck.shift()!];
|
||||
reject(
|
||||
g,
|
||||
'r',
|
||||
{
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: g.players[0].hand[0].id,
|
||||
method: 'silent',
|
||||
},
|
||||
/Ixian Technology/,
|
||||
);
|
||||
const blackMarket = applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: g.players[0].hand[0].id,
|
||||
method: 'silent',
|
||||
});
|
||||
assert.equal(blackMarket.decision?.kind, 'ixRicheseTechnology');
|
||||
assert.equal(blackMarket.richeseAuction, null);
|
||||
const resumed = applyAction(blackMarket, g.players[2].id, {
|
||||
type: 'decision',
|
||||
event: blackMarket.pendingIxRicheseTechnology!.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(resumed.richeseAuction?.source, 'blackMarket');
|
||||
assert.equal(resumed.ixTechnologyTurn, g.ixTechnologyTurn);
|
||||
g = decision(g, { decline: true });
|
||||
g = decision(g, { position: 'first' });
|
||||
reject(
|
||||
g,
|
||||
'r',
|
||||
{
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: g.richeseCache![0].id,
|
||||
method: 'silent',
|
||||
},
|
||||
/Ixian Technology/,
|
||||
);
|
||||
const cache = applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: g.richeseCache![0].id,
|
||||
method: 'silent',
|
||||
});
|
||||
assert.equal(cache.decision?.kind, 'ixRicheseTechnology');
|
||||
assert.equal(cache.richeseAuction, null);
|
||||
const resumedCache = applyAction(cache, g.players[2].id, {
|
||||
type: 'decision',
|
||||
event: cache.pendingIxRicheseTechnology!.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(resumedCache.richeseAuction?.source, 'cache');
|
||||
assert.equal(resumedCache.ixTechnologyTurn, g.ixTechnologyTurn);
|
||||
g.ixTechnologyTurn = g.turn;
|
||||
g = decision(g, { card: g.richeseCache![0].id, method: 'silent' });
|
||||
assert.equal(g.richeseAuction!.source, 'cache');
|
||||
|
||||
Reference in new issue
Block a user