Apply supplied Advanced Ixian Technology Richese-lot exclusion
This commit is contained in:
1 parent
0315917987
commit
4ed6beb33a
16 files changed
+40
-1356
No files matched your search
@@ -431,7 +431,7 @@ void test('six-seat Advanced Ixian/Tleilaxu game resolves Technology and Face Da
|
||||
assert.ok(evidence.restores > 0);
|
||||
});
|
||||
|
||||
void test('combined-expansion Advanced sample completes with Richese Technology declines and saved continuation', (t) => {
|
||||
void test('combined-expansion Advanced sample completes with Richese lots and saved continuation', (t) => {
|
||||
const out = join(temporary(t), 'combined-complete');
|
||||
const result = run(out, '--profile', 'combined', '--rules', 'advanced', '--seed', '20260927');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
@@ -445,7 +445,6 @@ void test('combined-expansion Advanced sample completes with Richese Technology
|
||||
assert.equal(report.sourceUnchanged, true);
|
||||
assert.equal(game.name, 'combined-advanced');
|
||||
assert.equal(game.outcome, 'complete');
|
||||
assert.ok(game.used['decision:ixRicheseTechnology'] > 0);
|
||||
assert.ok(game.used['decision:ecazAmbassador'] > 0);
|
||||
assert.ok(game.used['decision:faceDance'] > 0);
|
||||
assert.deepEqual(game.rejected, {});
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
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 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';
|
||||
},
|
||||
];
|
||||
|
||||
async function rejectSaved(game: 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);
|
||||
}
|
||||
for (const corrupt of corruptions) {
|
||||
const game = structuredClone(genuine);
|
||||
corrupt(game);
|
||||
await rejectSaved(game);
|
||||
}
|
||||
|
||||
// A real paid Box search holds this exact Ix decision. Duplicating the live
|
||||
// decision now exercises Ix uniqueness without fabricating a Harkonnen parent.
|
||||
const staged = structuredClone(genuine);
|
||||
const boxIndex = staged.richeseCache!.findIndex(card => card.effect === 'nullentropyBox');
|
||||
assert.ok(boxIndex >= 0);
|
||||
const box = staged.richeseCache!.splice(boxIndex, 1)[0];
|
||||
player(staged, f.observerAuth.playerId).hand.push(box);
|
||||
// Two eligible physical cards keep the paid search as a genuine private choice.
|
||||
staged.discard.push(take(staged, card => card.kind === 'worthless'),
|
||||
take(staged, card => card.kind === 'worthless'));
|
||||
assert.deepEqual(physicalCards(staged), physicalCards(genuine));
|
||||
f.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(staged), staged.version, f.code);
|
||||
await f.act(f.observerAuth, { type: 'card', card: box.id });
|
||||
const suspended = await f.restart().readRoom(f.code);
|
||||
assert.equal(suspended.decision?.kind, 'nullentropy');
|
||||
assert.equal(player(suspended, f.observerAuth.playerId).spice,
|
||||
player(staged, f.observerAuth.playerId).spice - 2);
|
||||
assert.deepEqual(suspended.pendingNullentropy!.resume.decision, genuine.decision);
|
||||
assert.deepEqual(physicalCards(suspended), physicalCards(genuine));
|
||||
assert.doesNotThrow(() => viewGame(suspended, f.ixAuth.playerId));
|
||||
const duplicated = structuredClone(suspended);
|
||||
duplicated.decision = structuredClone(genuine.decision);
|
||||
await rejectSaved(duplicated);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -1,522 +0,0 @@
|
||||
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,7 +320,7 @@ 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 pauses special lots for an explicit per-lot decline', () => {
|
||||
void test('advanced unused Ixian Technology does not pause Richese special lots', () => {
|
||||
let g = fixture(true);
|
||||
g.players[2].faction = 'ixians';
|
||||
g.players[2].hand = [g.deck.shift()!];
|
||||
@@ -330,15 +330,9 @@ void test('advanced unused Ixian Technology pauses special lots for an explicit
|
||||
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);
|
||||
assert.equal(blackMarket.decision, null);
|
||||
assert.equal(blackMarket.richeseAuction?.source, 'blackMarket');
|
||||
assert.equal(blackMarket.ixTechnologyTurn, g.ixTechnologyTurn);
|
||||
g = decision(g, { decline: true });
|
||||
g = decision(g, { position: 'first' });
|
||||
const cache = applyAction(g, 'r', {
|
||||
@@ -347,15 +341,9 @@ void test('advanced unused Ixian Technology pauses special lots for an explicit
|
||||
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);
|
||||
assert.equal(cache.decision, null);
|
||||
assert.equal(cache.richeseAuction?.source, 'cache');
|
||||
assert.equal(cache.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