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:
Quintine committed 2026-09-13 22:51:19 +10:00
1 parent f067950f5e
commit cab2f1c42d
26 files changed
+3001 -58

No files matched your search

+22 -1
View File
@@ -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';
+104
View File
@@ -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), []);
}
});
+594
View File
@@ -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);
}
});
+415
View File
@@ -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);
});
+1 -1
View File
@@ -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,
);
});
+522
View File
@@ -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);
});
+31 -23
View File
@@ -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');