Connect Richese Nexus to ordinary auction purchases
This commit is contained in:
1 parent
724d9dce1e
commit
cf268243f3
19 files changed
+1241
-46
No files matched your search
@@ -0,0 +1,244 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame, initializeRicheseBetrayalGameForAudit, joinGame, newPlayer, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { richeseCardDefinition } from '../game/richese-cards';
|
||||
import { actTruthMovement } from './fixture-truth-movement';
|
||||
import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
|
||||
import {
|
||||
richeseHomeworldShipmentNexusInventory,
|
||||
} from './fixture-richese-homeworld-shipment';
|
||||
|
||||
export type RicheseNormalBetrayalStep = { actor: string; action: Action };
|
||||
export type RicheseNormalBetrayalTransition = { before: Game; steps: RicheseNormalBetrayalStep[]; after: Game };
|
||||
export const actRicheseNormalBetrayal = actTruthMovement;
|
||||
export const richeseNormalBetrayalPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const richeseNormalBetrayalClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRicheseBetrayal;
|
||||
export function richeseNormalBetrayalInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original main/cache face retains one physical location');
|
||||
return ids;
|
||||
}
|
||||
export const richeseNormalBetrayalNexusInventory = richeseHomeworldShipmentNexusInventory;
|
||||
|
||||
/** Actual native controls only. The original blind counter, current payment
|
||||
* chooser and closing Nexus draws stay caller-owned. */
|
||||
export function nextRicheseNormalBetrayal(game: Game): RicheseNormalBetrayalStep | null {
|
||||
if (game.pendingRicheseBetrayal || game.truthtrance ||
|
||||
game.nexusCards?.phase?.stage === 'drawing') return null;
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'auctionPayment') return null;
|
||||
if (game.phase === 5 && !game.decision) return { actor: game.active!, action: { type: 'endMovement' } };
|
||||
}
|
||||
return nextTruthMovementNativeEarlyStep(game, {
|
||||
position: 'first', method: 'onceAround', direction: 'clockwise',
|
||||
keepUnbid: game.turn === 1 && game.richeseAuction?.cardId === 'richese-karama',
|
||||
card: game.richeseCache?.find(card => card.id === 'richese-karama')?.id ?? game.richeseCache?.[0]?.id,
|
||||
});
|
||||
}
|
||||
export function advanceRicheseNormalBetrayal(game: Game, until: (state: Game) => boolean,
|
||||
actions: RicheseNormalBetrayalStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
const next = nextRicheseNormalBetrayal(game);
|
||||
assert.ok(next, `Supply the original Nexus/bid/payment control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actRicheseNormalBetrayal(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Richese normal-purchase programme did not reach its finite boundary');
|
||||
}
|
||||
export function passRicheseNormalBetrayal(game: Game, actions: RicheseNormalBetrayalStep[] = []): Game {
|
||||
const event = game.pendingRicheseBetrayal?.receipt.event; assert.ok(event);
|
||||
for (let count = 0; game.pendingRicheseBetrayal?.receipt.event === event && count < 12; count++) {
|
||||
const receipt = game.pendingRicheseBetrayal.receipt;
|
||||
const actor = receipt.required.find(id => !receipt.passed.includes(id)); assert.ok(actor);
|
||||
game = actRicheseNormalBetrayal(game, actor, { type: 'richeseBetrayalPass', event }, actions);
|
||||
}
|
||||
assert.notEqual(game.pendingRicheseBetrayal?.receipt.event, event); return game;
|
||||
}
|
||||
/** Finish the original post-payment responses, stopping at the next quiet lot.
|
||||
* This never chooses spice/Karama or acknowledges another Betrayal event. */
|
||||
export function finishRicheseNormalBetrayal(game: Game, actions: RicheseNormalBetrayalStep[] = []): Game {
|
||||
return advanceRicheseNormalBetrayal(game, richeseNormalBetrayalClean, actions);
|
||||
}
|
||||
export function payRicheseNormalBetrayal(game: Game, fixture: { target: string; karama: string | null },
|
||||
karama = false, actions: RicheseNormalBetrayalStep[] = []): Game {
|
||||
if (game.decision?.kind === 'auctionPayment') {
|
||||
assert.equal(game.decision.player, fixture.target);
|
||||
assert.ok(!karama || fixture.karama);
|
||||
game = actRicheseNormalBetrayal(game, fixture.target,
|
||||
{ type: 'decision', karama, ...(karama ? { card: fixture.karama! } : {}) }, actions);
|
||||
}
|
||||
return finishRicheseNormalBetrayal(game, actions);
|
||||
}
|
||||
|
||||
/** Original audit admission, setup and two-turn chronology are genuine.
|
||||
* A single LABELLED controlled recycle moves the first native cache discard
|
||||
* to the still-unopened ordinary pool. No receipt, auction, resource or dealt
|
||||
* card is installed; real normal bids and passes earn the new interruption. */
|
||||
export function createRicheseNormalBetrayalFixture(options: {
|
||||
advanced?: boolean; richeseCard?: boolean; karama?: boolean; holderNexus?: 'richese' | 'guild'; allyPayment?: 0 | 2;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, family = options.richeseCard ?? true;
|
||||
const withKarama = options.karama ?? true, allyPayment = options.allyPayment ?? 0, price = 3;
|
||||
const target = 'richese', holder = 'choam', emperor = 'emperor', atreides = 'atreides';
|
||||
const ally = allyPayment ? atreides : null, nexusFace = options.holderNexus ?? 'richese';
|
||||
let game = createGame('RICHESENORMALBETRAYAL', newPlayer(target, 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(holder, 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), actions: RicheseNormalBetrayalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actRicheseNormalBetrayal(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceRicheseNormalBetrayal(game, until, actions); };
|
||||
const transition = (run: () => void): RicheseNormalBetrayalTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
|
||||
for (const seat of game.players) act(seat.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = initializeRicheseBetrayalGameForAudit(game);
|
||||
const initialization = { before: lobby, after: structuredClone(game) }, setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.richeseBetrayalPreview, true);
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
assert.ok(game.players.every(seat => !seat.hand.length));
|
||||
const originalInventory = richeseNormalBetrayalInventory(game);
|
||||
const originalNexusInventory = richeseNormalBetrayalNexusInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(originalNexusInventory.length, 12);
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 4); assert.equal(ordinary.length, 4);
|
||||
const selected = withKarama ? [karama, ...ordinary.slice(0, 3)] : ordinary;
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(seat => seat.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
const order = (worm: boolean, position: number) => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed
|
||||
: 'territory' in card && !card.discovery && card.territory !== 'arrakeen'));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
|
||||
};
|
||||
order(false, 0); if (advanced) order(false, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 2 && richeseNormalBetrayalClean(state));
|
||||
const firstNormalFrom = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(firstNormalFrom >= 0);
|
||||
const firstNormalCard = game.deck.splice(firstNormalFrom, 1)[0]; game.deck.unshift(firstNormalCard);
|
||||
const firstNormalOrder = { position: 0, from: firstNormalFrom, card: firstNormalCard.id,
|
||||
timing: 'turn1-before-bidding', source: 'unopened-main' };
|
||||
assert.deepEqual(richeseNormalBetrayalInventory(game), originalInventory);
|
||||
reach(state => state.turn === 1 && state.phase === 3 && !!state.auction && richeseNormalBetrayalClean(state));
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, firstNormalCard.id);
|
||||
assert.ok(richeseNormalBetrayalPlayer(game, target).hand.some(card => card.id === 'richese-karama'));
|
||||
const beforeFirstPurchase = structuredClone(game), firstPurchaseStart = actions.length;
|
||||
for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 16; count++) {
|
||||
assert.ok(game.auction && !game.response && !game.pendingRicheseBetrayal);
|
||||
const actor = game.auction.active;
|
||||
act(actor, actor === target && game.auction.bidder !== target
|
||||
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' });
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision?.player, target);
|
||||
act(target, { type: 'decision', karama: true, card: 'richese-karama' });
|
||||
reach(richeseNormalBetrayalClean);
|
||||
const firstPurchase: RicheseNormalBetrayalTransition = {
|
||||
before: beforeFirstPurchase, steps: structuredClone(actions.slice(firstPurchaseStart)), after: structuredClone(game),
|
||||
};
|
||||
reach(state => state.turn === 1 && state.phase === 4 && richeseNormalBetrayalClean(state));
|
||||
const afterFirstBidding = structuredClone(game);
|
||||
const recycled = game.discard.find(card => card.id === 'richese-karama'); assert.ok(recycled);
|
||||
assert.equal(game.richeseCache!.length, 9);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && richeseNormalBetrayalClean(state) &&
|
||||
!state.nexus && !state.spiceWindow && !state.spiceResolution && !state.spiceSequence && !state.ready.length);
|
||||
const beforeWorm = structuredClone(game); order(true, 0); order(false, 1); if (advanced) order(false, 2);
|
||||
const beforeNexus = structuredClone(game);
|
||||
reach(state => state.nexus && richeseNormalBetrayalClean(state) && !state.spiceWindow && !state.spiceResolution);
|
||||
const alliance = transition(() => {
|
||||
const first = ally ? target : emperor;
|
||||
act(first, { type: 'alliance', target: atreides }); act(atreides, { type: 'alliance', target: first });
|
||||
});
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && richeseNormalBetrayalClean(state));
|
||||
const nexusDraws: RicheseNormalBetrayalTransition[] = [], nexusOrder: { actor: string; from: number; card: string }[] = [];
|
||||
for (let count = 0; game.nexusCards?.phase?.stage === 'drawing' && count < 6; count++) {
|
||||
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
|
||||
const face = actor === holder ? nexusFace : nexusFace === 'guild' ? 'fremen' : 'guild';
|
||||
const cards = game.nexusCards.cards!, from = cards.deck.indexOf(face); assert.ok(from >= 0);
|
||||
assert.equal(cards.hands[actor], null);
|
||||
cards.deck.unshift(cards.deck.splice(from, 1)[0]); nexusOrder.push({ actor, from, card: face });
|
||||
nexusDraws.push(transition(() => { act(actor, { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: null, choice: 'draw', ownRedraws: 0 }); }));
|
||||
}
|
||||
assert.equal(game.nexusCards!.cards!.hands[holder], nexusFace);
|
||||
assert.equal(richeseNormalBetrayalPlayer(game, holder).ally, null);
|
||||
reach(state => state.turn === 2 && state.phase === 2 && richeseNormalBetrayalClean(state));
|
||||
const beforeRecycle = structuredClone(game);
|
||||
let card: string, recycledFrom: number;
|
||||
if (family) {
|
||||
recycledFrom = game.discard.findIndex(card => card.id === recycled.id); assert.ok(recycledFrom >= 0);
|
||||
game.deck.unshift(game.discard.splice(recycledFrom, 1)[0]); card = recycled.id;
|
||||
} else {
|
||||
recycledFrom = game.deck.findIndex(card => card.kind === 'shield'); assert.ok(recycledFrom >= 0);
|
||||
const ordinaryCard = game.deck.splice(recycledFrom, 1)[0]; game.deck.unshift(ordinaryCard); card = ordinaryCard.id;
|
||||
}
|
||||
const recycle = { before: beforeRecycle, after: structuredClone(game), card,
|
||||
from: family ? 'discard' : 'unopened-main', fromIndex: recycledFrom, to: 'unopened-main-top', controlled: true };
|
||||
assert.equal(!!richeseCardDefinition(game.deck[0]), family);
|
||||
assert.deepEqual(richeseNormalBetrayalInventory(game), originalInventory);
|
||||
let funding: RicheseNormalBetrayalTransition | null = null;
|
||||
reach(state => state.turn === 2 && state.phase === 3 && !!state.auction && richeseNormalBetrayalClean(state));
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, card);
|
||||
if (ally) funding = transition(() => { act(ally, { type: 'pledgeAid', amount: allyPayment }); });
|
||||
const beforeBids = structuredClone(game), biddingStart = actions.length;
|
||||
let beforeTrigger: Game | undefined, trigger: RicheseNormalBetrayalStep | undefined;
|
||||
for (let count = 0; !game.pendingRicheseBetrayal && count < 16; count++) {
|
||||
assert.ok(game.auction && !game.decision && !game.response, 'Native bids must open Betrayal before the payment chooser');
|
||||
const actor = game.auction.active;
|
||||
const action: Action = actor === target && game.auction.bidder !== target
|
||||
? { type: 'bid', amount: price, allyPayment } : { type: 'passBid' };
|
||||
beforeTrigger = structuredClone(game); trigger = { actor, action };
|
||||
act(actor, action);
|
||||
}
|
||||
assert.ok(game.pendingRicheseBetrayal && beforeTrigger && trigger);
|
||||
const event = game.pendingRicheseBetrayal.receipt.event, source = structuredClone(game);
|
||||
assert.equal(game.pendingRicheseBetrayal.receipt.invoice.source, 'normal');
|
||||
assert.equal(game.pendingRicheseBetrayal.receipt.invoice.card.id, card);
|
||||
assert.equal(game.auction!.bidder, target); assert.equal(game.auction!.bid, price);
|
||||
assert.equal(game.decision, null); assert.ok(!game.currentAuctionSale);
|
||||
assert.deepEqual(richeseNormalBetrayalInventory(game), originalInventory);
|
||||
assert.deepEqual(richeseNormalBetrayalNexusInventory(game), originalNexusInventory);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
return {
|
||||
game, source, beforePaymentChoice: source, initial, lobby, initialization, setup, beforeStartingDeal, afterSetup,
|
||||
firstPurchase, afterFirstBidding, beforeWorm, beforeNexus, alliance, nexusDraws, nexusOrder,
|
||||
beforeRecycle, recycle, funding, beforeBids, beforeTrigger, trigger,
|
||||
bidding: { before: beforeBids, steps: structuredClone(actions.slice(biddingStart)), after: source },
|
||||
target, buyer: target, richese: target, owner: target, holder, other: ally ? emperor : target,
|
||||
emperor, atreides, ally, price, allyPayment, family, advanced, card, recycledCard: recycled.id,
|
||||
karama: withKarama ? karama.id : null, holderNexus: nexusFace, event,
|
||||
useAction: { type: 'richeseBetrayalUse', event } as Action,
|
||||
passActions: game.pendingRicheseBetrayal.receipt.required.map(actor => ({ actor,
|
||||
action: { type: 'richeseBetrayalPass', event } as Action })),
|
||||
originalInventory, originalCards: originalInventory, originalNexusInventory, originalSpice, spiceOrder, dealOrder,
|
||||
firstNormalOrder,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Fresh original Richese Betrayal audit admission with Richese/CHOAM/Emperor/Atreides, original main35/cache10, all12 Nexus singletons and no other modules. Public Nexus lobby selection and readiness precede the existing explicit audit initializer; this is not a newly admitted public start. Only original unopened Treachery/Spice/Nexus positions and native scalar lotteries are selected for setup and the real worm/draw prefix. Original hands, native wallets, forces, clocks and all allowances follow actual setup/actions.',
|
||||
'Native first-turn cache auction selects Richese Karama; all four actual Once Around bidders pass, and Richese really keeps the unbid card. Cache decreases to nine. Before that Bidding phase deals the normal pool, select an actual still-unopened Shield at main-deck top, recorded separately as firstNormalOrder. Richese then really wins that first normal Shield lot for one and pays with the exact cache Karama at the original winning-bid payment chooser, putting the physical Richese card into normal discard without a spice debit. Thus karama:false does not acquire another Karama from its real purchase; karama:true retains its genuinely dealt starting one. This is the existing post-winning payment route, not proactive Karama purchase. Native Storm/Spice/Charity/auctions/Revival/movement/Mentat progress to a real second-turn worm. Emperor/Atreides actually ally by default; the aid branch instead earns Richese/Atreides alliance and an actual two-spice pledge. Unallied CHOAM actually draws its original Richese Nexus (or the explicitly selected irrelevant Guild singleton). No Nexus card is installed or duplicated.',
|
||||
family
|
||||
? 'CONTROLLED RECYCLED ORDINARY SOURCE: before turn2 Bidding and before any normal auction/receipt exists, move the already-discarded original Richese Karama into the unopened main-deck top. Preserve every physical main/cache card and the genuinely decremented cache; this replaces a costly multi-turn exhaustion/reshuffle programme and is not claimed as earned deck recycling.'
|
||||
: 'ORDINARY MISS CONTROL: before turn2 Bidding, select one actual still-unopened ordinary Shield at the main-deck top. The previously spent Richese Karama stays in discard. No family inference is used to manufacture the later reaction window.',
|
||||
'Native turn2 cache passes/declined retention complete before the ordinary pool opens. Richese really bids three (with actual aid two only when requested); other native bidders pass. Stop at the engine-created neutral normal-purchase Betrayal window before any spice/Karama payment choice. No auction, paused receipt, wallet, alliance, dealt hand, invoice or sale is installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type RicheseNormalBetrayalFixture = ReturnType<typeof createRicheseNormalBetrayalFixture>;
|
||||
@@ -77,7 +77,7 @@ void test('Tleilax Homeworld transfers preserve exact custody, permit own-world
|
||||
assert.deepEqual(g, allied);
|
||||
});
|
||||
|
||||
void test('storm uses the selected actual sector and mobile stronghold pointer; its classification remains gated', () => {
|
||||
void test('revival keeps storm and HMS route restrictions distinct, including the sheltered interior', () => {
|
||||
for (const faction of ['fremen', 'tleilaxu'] as const) {
|
||||
const { g, grant } = fixture(faction);
|
||||
g.storm = 10;
|
||||
@@ -85,11 +85,16 @@ void test('storm uses the selected actual sector and mobile stronghold pointer;
|
||||
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
|
||||
let choices = homeworldRevivalDestinations(g, 'p', grant);
|
||||
assert.match(choices.find((c) => c.id === 'arrakeen:10')!.blocked!, /storm/);
|
||||
assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /storm/);
|
||||
const route = faction === 'fremen' ? /Force Movement/ : /Only Ixians/;
|
||||
assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, route);
|
||||
assert.doesNotMatch(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /storm|await|ruling/);
|
||||
assert.equal(choices.find((c) => c.id === 'polar_sink:0')!.blocked, null);
|
||||
g.storm = 18;
|
||||
choices = homeworldRevivalDestinations(g, 'p', grant);
|
||||
assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, /classification|ruling/);
|
||||
assert.match(choices.find((c) => c.id === 'hidden_mobile_stronghold:0')!.blocked!, route);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'hidden_mobile_stronghold:0'), route);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ void test('orphaned/corrupt source receipts fail before projection, normalizatio
|
||||
g => {delete g.pendingRicheseBetrayal;},
|
||||
g => {delete g.richeseBetrayal;},
|
||||
g => {g.richeseBetrayal!.current = null;},
|
||||
g => {g.pendingRicheseBetrayal!.quote.amount++;},
|
||||
g => {const frame=g.pendingRicheseBetrayal!;assert.ok('quote' in frame);frame.quote.amount++;},
|
||||
g => {g.pendingRicheseBetrayal!.receipt.invoice.card.name = 'forged face';},
|
||||
g => {player(g,f.buyer).spice--;},
|
||||
g => {player(g,f.target).hand.splice(0,1);},
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { botNexusRicheseBetrayalActions } from '../game/bot-nexus-richese-betrayal';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal');
|
||||
|
||||
/** A normal-source view override isolates the connected controls and policy
|
||||
* consumers. The engine suite supplies the actual ordinary purchase producer. */
|
||||
function projected(): GameView {
|
||||
const fixture = createRicheseBetrayalFixture('purchase');
|
||||
const game = viewGame(fixture.game, fixture.holder);
|
||||
assert.ok(game.richeseBetrayalReaction);
|
||||
game.richeseBetrayalReaction.source = 'normal';
|
||||
return game;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, busy, act() {} }));
|
||||
}
|
||||
|
||||
function click(node: ReactNode, prefix: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
|
||||
if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function choose(game: GameView, prefix: string, busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(NexusRicheseBetrayal({ game, busy, act(action) { actions.push(action); } }), prefix);
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('ordinary concealed purchases explain both outcomes and the provisional spend-first cost persistently', () => {
|
||||
const game = projected();
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /concealed ordinary auction lot/);
|
||||
assert.match(html, /does not disclose the card’s family or grant inspection before you spend/);
|
||||
assert.match(html, /Provisional online rule: spend the Richese Nexus before learning whether this purchase can be stopped/);
|
||||
assert.match(html, /If it is a Richese card, that card is discarded without payment or delivery/);
|
||||
assert.match(html, /Otherwise your Nexus remains spent and the original purchase continues/);
|
||||
assert.match(html, /Spend Richese Nexus · attempt to stop this purchase/);
|
||||
assert.match(html, /Pass · allow the original purchase/);
|
||||
assert.doesNotMatch(html, /public cache lot|buying its own cache card|concealed Black Market lot/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual normal-source Spend and Pass callbacks send one unchanged event-bound action', () => {
|
||||
const game = projected(), event = game.richeseBetrayalReaction!.event;
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), [{ type: 'richeseBetrayalUse', event }]);
|
||||
assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]);
|
||||
});
|
||||
|
||||
void test('busy, blocked, passed and unentitled normal controls cannot commit the blind cost', () => {
|
||||
const game = projected(), event = game.richeseBetrayalReaction!.event;
|
||||
assert.match(markup(game, true), /disabled=""[^>]*>Spend Richese Nexus/);
|
||||
assert.match(markup(game, true), /disabled=""[^>]*>Pass/);
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus', true), []);
|
||||
assert.deepEqual(choose(game, 'Pass', true), []);
|
||||
game.richeseBetrayalReaction!.blocked = 'Your accepted promise reserves this Nexus card.';
|
||||
assert.match(markup(game), /accepted promise reserves this Nexus card/);
|
||||
assert.match(markup(game), /Provisional online rule/);
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||||
assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]);
|
||||
game.richeseBetrayalReaction!.blocked = null;
|
||||
game.richeseBetrayalReaction!.canUse = false;
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||||
game.richeseBetrayalReaction!.canUse = true;
|
||||
game.richeseBetrayalReaction!.hasPassed = true;
|
||||
assert.match(markup(game), /You have passed/);
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||||
assert.deepEqual(choose(game, 'Pass'), []);
|
||||
game.richeseBetrayalReaction!.hasPassed = false;
|
||||
game.richeseBetrayalReaction!.canPass = false;
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||||
game.richeseBetrayalReaction!.canPass = true;
|
||||
game.status = 'finished';
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||||
assert.deepEqual(choose(game, 'Pass'), []);
|
||||
});
|
||||
|
||||
void test('the ordinary response never reads a face or rival resources to infer the hidden card family', () => {
|
||||
const game = projected();
|
||||
Object.defineProperty(game, 'richeseAuction', { get() { throw new Error('public cache is unrelated to this ordinary purchase'); } });
|
||||
Object.defineProperty(game, 'auction', { get() { throw new Error('ordinary face is not response entitlement'); } });
|
||||
for (const player of game.players)
|
||||
for (const field of ['hand', 'spice'])
|
||||
Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
|
||||
assert.match(markup(game), /concealed ordinary auction lot/);
|
||||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), [{ type: 'richeseBetrayalUse', event: game.richeseBetrayalReaction!.event }]);
|
||||
});
|
||||
|
||||
void test('every bot profile and autopilot passes a blind ordinary purchase even when Use is offered', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const game = projected(), own = game.players.find(player => player.id === game.me)!;
|
||||
own.bot = profile;
|
||||
assert.equal(game.richeseBetrayalReaction!.canUse, true);
|
||||
const expected = [{ type: 'richeseBetrayalPass', event: game.richeseBetrayalReaction!.event }];
|
||||
assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
|
||||
assert.deepEqual(botActions(game), expected);
|
||||
delete own.bot;
|
||||
own.autopilot = profile;
|
||||
assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
|
||||
}
|
||||
});
|
||||
|
||||
void test('minimal bot responses still obey current acknowledgement eligibility and preserve old-source Use', () => {
|
||||
const game = projected(), own = game.players.find(player => player.id === game.me)!;
|
||||
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
|
||||
own.bot = 'Easy';
|
||||
game.richeseBetrayalReaction!.hasPassed = true;
|
||||
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
|
||||
game.richeseBetrayalReaction!.hasPassed = false;
|
||||
game.richeseBetrayalReaction!.canPass = false;
|
||||
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
|
||||
for (const kind of ['purchase', 'sale'] as const) {
|
||||
const fixture = createRicheseBetrayalFixture(kind);
|
||||
const old = viewGame(fixture.game, fixture.holder);
|
||||
old.players.find(player => player.id === old.me)!.bot = 'Easy';
|
||||
assert.deepEqual(botNexusRicheseBetrayalActions(old), [{ type: 'richeseBetrayalUse', event: old.richeseBetrayalReaction!.event }]);
|
||||
assert.match(markup(old), /Use Richese Nexus/);
|
||||
assert.doesNotMatch(markup(old), /Provisional online rule: spend the Richese Nexus/);
|
||||
assert.match(markup(old), kind === 'purchase' ? /public cache lot/ : /concealed Black Market lot/);
|
||||
}
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Card } from '../game/cards';
|
||||
import { NEXUS_FACTIONS, type NexusPlayer, type NexusState } from '../game/nexus-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
createRicheseBetrayalReceipt, quoteRicheseNormalBetrayalInvoice,
|
||||
requiredRicheseBetrayalResponders, richeseBetrayalEligibility,
|
||||
richeseBetrayalEvent, richeseBetrayalVetoesPurchase,
|
||||
validateRicheseBetrayalCursor, validateRicheseBetrayalReceipt,
|
||||
RicheseBetrayalError, type RicheseBetrayalInvoice,
|
||||
} from '../game/nexus-richese-betrayal';
|
||||
|
||||
const players: NexusPlayer[] = [
|
||||
{ id: 'richese', faction: 'richese' },
|
||||
{ id: 'holder', faction: 'fremen' },
|
||||
{ id: 'emperor', faction: 'emperor' },
|
||||
];
|
||||
const ordinary: Card = { id: 'normal-weapon', name: 'Maula Pistol', kind: 'projectile' };
|
||||
const other: Card = { id: 'other-auction-card', name: 'Shield', kind: 'shield' };
|
||||
type NormalInput = Parameters<typeof quoteRicheseNormalBetrayalInvoice>[0];
|
||||
|
||||
function normalInput(card: Card = richeseCards()[0], allyPayment = 0): NormalInput {
|
||||
return {
|
||||
players: players.map(player => ({ ...player, ...(allyPayment ?
|
||||
player.id === 'richese' ? { ally: 'emperor' } :
|
||||
player.id === 'emperor' ? { ally: 'richese' } : {} : {}) })),
|
||||
auction: { cards: [{ ...other }, { ...card }], index: 1, bid: 5, bidder: 'richese',
|
||||
...(allyPayment ? { allyPayment } : {}) },
|
||||
donor: allyPayment ? 'emperor' : null,
|
||||
originalRecipient: 'emperor', physicalCards: [{ ...other }, { ...card }],
|
||||
};
|
||||
}
|
||||
function usedReceipt(invoice: RicheseBetrayalInvoice) {
|
||||
return createRicheseBetrayalReceipt({ turn: 2, players }, {
|
||||
event: richeseBetrayalEvent(2, 1, 'round-2', 'lot-2'), turn: 2, sequence: 1,
|
||||
round: 'round-2', lot: 'lot-2', invoice,
|
||||
required: ['holder'], passed: [], stage: 'used', actor: 'holder', nexusDiscardIndex: 0,
|
||||
});
|
||||
}
|
||||
function reject(input: NormalInput) {
|
||||
const before = JSON.stringify(input);
|
||||
assert.throws(() => quoteRicheseNormalBetrayalInvoice(input), RicheseBetrayalError);
|
||||
assert.equal(JSON.stringify(input), before);
|
||||
}
|
||||
|
||||
void test('ordinary Richese purchases bind the same invoice for either concealed card family', () => {
|
||||
for (const card of [richeseCards()[0], ordinary]) {
|
||||
const input = normalInput(card, 2);
|
||||
const before = JSON.stringify(input);
|
||||
const invoice = quoteRicheseNormalBetrayalInvoice(input);
|
||||
assert.deepEqual(invoice, {
|
||||
kind: 'purchase', target: 'richese', buyer: 'richese', source: 'normal',
|
||||
price: 5, ownPayment: 3, allyPayment: 2, donor: 'emperor',
|
||||
originalRecipient: 'emperor', card,
|
||||
});
|
||||
assert.equal(JSON.stringify(input), before);
|
||||
assert.notEqual(invoice.card, input.auction.cards[1]);
|
||||
assert.notEqual(invoice.card, input.physicalCards[1]);
|
||||
invoice.card.name = 'Changed output';
|
||||
assert.equal(JSON.stringify(input), before);
|
||||
}
|
||||
const bank = normalInput();
|
||||
bank.players = bank.players.filter(player => player.id !== 'emperor');
|
||||
bank.originalRecipient = null;
|
||||
assert.equal(quoteRicheseNormalBetrayalInvoice(bank).originalRecipient, null);
|
||||
});
|
||||
|
||||
void test('normal source requires the actual native winner, a positive safe price and a current card index', () => {
|
||||
for (const index of [-1, 2, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const input = normalInput();
|
||||
input.auction.index = index;
|
||||
reject(input);
|
||||
}
|
||||
for (const bid of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const input = normalInput();
|
||||
input.auction.bid = bid;
|
||||
reject(input);
|
||||
}
|
||||
for (const bidder of [null, 'holder', 'unknown']) {
|
||||
const input = normalInput();
|
||||
input.auction.bidder = bidder;
|
||||
reject(input);
|
||||
}
|
||||
const noCards = normalInput();
|
||||
noCards.auction.cards = [];
|
||||
reject(noCards);
|
||||
const wrongRecipient = normalInput();
|
||||
wrongRecipient.originalRecipient = 'holder';
|
||||
reject(wrongRecipient);
|
||||
const missingEmperorPayment = normalInput();
|
||||
missingEmperorPayment.originalRecipient = null;
|
||||
reject(missingEmperorPayment);
|
||||
});
|
||||
|
||||
void test('normal source binds one exact physical card without manufacturing or substituting its face', () => {
|
||||
const absent = normalInput();
|
||||
absent.physicalCards = absent.physicalCards.slice(0, -1);
|
||||
reject(absent);
|
||||
const duplicate = normalInput();
|
||||
duplicate.physicalCards = [...duplicate.physicalCards, { ...duplicate.auction.cards[1] }];
|
||||
reject(duplicate);
|
||||
const duplicatedLot = normalInput();
|
||||
duplicatedLot.auction.cards.push({ ...duplicatedLot.auction.cards[1] });
|
||||
reject(duplicatedLot);
|
||||
const substituted = normalInput();
|
||||
substituted.physicalCards[1].name = 'Different face with the same physical ID';
|
||||
reject(substituted);
|
||||
const malformed = normalInput();
|
||||
malformed.auction.cards[1] = { ...ordinary, kind: 'invalid' } as unknown as Card;
|
||||
reject(malformed);
|
||||
});
|
||||
|
||||
void test('normal funding preserves the actual whole split and reciprocal donor', () => {
|
||||
for (const allyPayment of [0, 2, 5]) {
|
||||
const invoice = quoteRicheseNormalBetrayalInvoice(normalInput(ordinary, allyPayment));
|
||||
assert.equal(invoice.ownPayment, 5 - allyPayment);
|
||||
assert.equal(invoice.allyPayment, allyPayment);
|
||||
}
|
||||
for (const allyPayment of [-1, 6, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const input = normalInput();
|
||||
input.auction.allyPayment = allyPayment;
|
||||
reject(input);
|
||||
}
|
||||
const noDonor = normalInput(ordinary, 2);
|
||||
noDonor.donor = null;
|
||||
reject(noDonor);
|
||||
const extraDonor = normalInput();
|
||||
extraDonor.donor = 'emperor';
|
||||
reject(extraDonor);
|
||||
const unrelated = normalInput(ordinary, 2);
|
||||
unrelated.donor = 'holder';
|
||||
reject(unrelated);
|
||||
const unilateral = normalInput(ordinary, 2);
|
||||
unilateral.players = unilateral.players.map(player => player.id === 'emperor'
|
||||
? { id: player.id, faction: player.faction } : player);
|
||||
reject(unilateral);
|
||||
});
|
||||
|
||||
void test('concealed family does not determine the responder set or physical Nexus eligibility', () => {
|
||||
const cards: NexusState = {
|
||||
version: 1, deck: NEXUS_FACTIONS.filter(card => card !== 'richese'), discard: [],
|
||||
hands: { richese: null, holder: 'richese', emperor: null },
|
||||
};
|
||||
const before = JSON.stringify(cards);
|
||||
for (const card of [richeseCards()[0], ordinary]) {
|
||||
const input = normalInput(card);
|
||||
quoteRicheseNormalBetrayalInvoice(input);
|
||||
assert.deepEqual(requiredRicheseBetrayalResponders(input.players.map(player => ({
|
||||
...player, nexusHeld: player.id === 'holder',
|
||||
}))), ['holder']);
|
||||
assert.equal(richeseBetrayalEligibility({ players: input.players, cards }, 'holder'), null);
|
||||
}
|
||||
assert.equal(JSON.stringify(cards), before);
|
||||
});
|
||||
|
||||
void test('a committed normal miss is valid used history with the same physical Nexus cost', () => {
|
||||
for (const [card, vetoes] of [[richeseCards()[0], true], [ordinary, false]] as const) {
|
||||
const receipt = usedReceipt(quoteRicheseNormalBetrayalInvoice(normalInput(card)));
|
||||
assert.equal(richeseBetrayalVetoesPurchase(receipt.invoice), vetoes);
|
||||
assert.equal(receipt.stage, 'used');
|
||||
assert.equal(receipt.actor, 'holder');
|
||||
assert.equal(receipt.nexusDiscardIndex, 0);
|
||||
validateRicheseBetrayalReceipt({ turn: 3, players }, JSON.parse(JSON.stringify(receipt)));
|
||||
validateRicheseBetrayalCursor({ turn: 3, players }, {
|
||||
sequence: 1, current: null, completed: [receipt],
|
||||
}, null);
|
||||
const wrongCost = { ...receipt, nexusDiscardIndex: null };
|
||||
assert.throws(() => validateRicheseBetrayalReceipt({ turn: 3, players }, wrongCost), RicheseBetrayalError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('normal purchase history cannot become a normal sale or a hidden Black Market self-purchase', () => {
|
||||
const invoice = quoteRicheseNormalBetrayalInvoice(normalInput(ordinary));
|
||||
const invalid: RicheseBetrayalInvoice[] = [
|
||||
{ ...invoice, kind: 'sale', buyer: 'emperor', originalRecipient: 'richese' },
|
||||
{ ...invoice, source: 'blackMarket' },
|
||||
{ ...invoice, source: 'cache' },
|
||||
{ ...invoice, source: 'unknown' as RicheseBetrayalInvoice['source'] },
|
||||
];
|
||||
for (const changed of invalid) {
|
||||
const before = JSON.stringify(changed);
|
||||
assert.throws(() => usedReceipt(changed), RicheseBetrayalError);
|
||||
assert.equal(JSON.stringify(changed), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('old cache and Black Market receipt signature bytes and outcome meanings stay unchanged', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
const invoice: RicheseBetrayalInvoice = source === 'cache' ? {
|
||||
kind: 'purchase', target: 'richese', buyer: 'richese', source,
|
||||
price: 5, ownPayment: 5, allyPayment: 0, donor: null,
|
||||
originalRecipient: 'emperor', card: richeseCards()[0],
|
||||
} : {
|
||||
kind: 'sale', target: 'richese', buyer: 'emperor', source,
|
||||
price: 5, ownPayment: 5, allyPayment: 0, donor: null,
|
||||
originalRecipient: 'richese', card: { ...ordinary },
|
||||
};
|
||||
const receipt = usedReceipt(invoice);
|
||||
const event = JSON.stringify(['nexusRicheseBetrayal', 2, 1, 'round-2', 'lot-2']);
|
||||
assert.equal(receipt.signature, JSON.stringify([
|
||||
'richeseBetrayalReceipt', 1, event, 2, 1, 'round-2', 'lot-2',
|
||||
invoice.kind, 'richese', invoice.buyer, source, 5, 5, 0, null, invoice.originalRecipient,
|
||||
[invoice.card.id, invoice.card.name, invoice.card.kind, invoice.card.effect ?? null],
|
||||
['holder'], [], 'used', 'holder', 0,
|
||||
]));
|
||||
assert.equal(richeseBetrayalVetoesPurchase(receipt.invoice), source === 'cache');
|
||||
}
|
||||
});
|
||||
@@ -420,8 +420,8 @@ for (const kind of ['purchase', 'sale'] as const) {
|
||||
{ name: 'changed payer split', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.ownPayment++; } },
|
||||
{ name: 'changed physical face', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.card.name += ' forged'; } },
|
||||
{ name: 'foreign buyer', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.buyer = f.holder; } },
|
||||
{ name: 'changed source quote', change: g => { g.pendingRicheseBetrayal!.quote.amount++; } },
|
||||
{ name: 'changed original lot', change: g => { g.pendingRicheseBetrayal!.lot.cardId = g.deck[0].id; } },
|
||||
{ name: 'changed source quote', change: g => { const frame=g.pendingRicheseBetrayal!;assert.ok('quote' in frame);frame.quote.amount++; } },
|
||||
{ name: 'changed original lot', change: g => { const frame=g.pendingRicheseBetrayal!;assert.ok('lot' in frame);frame.lot.cardId = g.deck[0].id; } },
|
||||
{ name: 'foreign required acknowledgement', change: g => { g.pendingRicheseBetrayal!.receipt.required.push(f.target); } },
|
||||
{ name: 'forged pass', change: g => { g.pendingRicheseBetrayal!.receipt.passed.push(f.holder); } },
|
||||
{ name: 'lost exact source custody', change: g => {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createRicheseNormalBetrayalFixture as fixture,
|
||||
actRicheseNormalBetrayal as act,
|
||||
passRicheseNormalBetrayal as pass,
|
||||
payRicheseNormalBetrayal as pay,
|
||||
finishRicheseNormalBetrayal as finish,
|
||||
richeseNormalBetrayalPlayer as player,
|
||||
richeseNormalBetrayalInventory as inventory,
|
||||
richeseNormalBetrayalNexusInventory as nexusInventory,
|
||||
} from './fixture-richese-normal-betrayal';
|
||||
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function source(f: ReturnType<typeof fixture>) {
|
||||
const receipt = f.game.pendingRicheseBetrayal!.receipt;
|
||||
assert.equal(receipt.invoice.source, 'normal'); assert.equal(receipt.invoice.kind, 'purchase');
|
||||
assert.equal(receipt.invoice.buyer, f.target); assert.equal(receipt.invoice.price, 3);
|
||||
assert.equal(receipt.invoice.card.id, f.card);
|
||||
assert.equal(f.game.auction!.bidder, f.target); assert.equal(f.game.auction!.cards[f.game.auction!.index].id, f.card);
|
||||
assert.equal(f.game.decision, null); assert.ok(!f.game.currentAuctionSale);
|
||||
assert.equal(player(f.game, f.holder).ally, null);
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.holder], f.holderNexus);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(nexusInventory(f.game), f.originalNexusInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const mode = advanced ? 'Advanced' : 'Basic';
|
||||
|
||||
void test(`${mode} normal Richese-family self-purchase veto spends its actual Nexus and skips the unpaid card`, () => {
|
||||
const f = fixture({ advanced }), receipt = source(f), before = wallets(f.game), index = f.game.auction!.index;
|
||||
assert.equal(f.initialization.before.status, 'lobby'); assert.equal(f.afterSetup.status, 'playing');
|
||||
assert.equal(f.firstPurchase.before.richeseCache!.length, 9);
|
||||
assert.equal(f.afterFirstBidding.discard.filter(card => card.id === f.recycledCard).length, 1);
|
||||
assert.ok(f.firstPurchase.steps.some(step => step.actor === f.target && step.action.type === 'decision' &&
|
||||
step.action.karama === true && step.action.card === f.recycledCard));
|
||||
assert.equal(f.recycle.controlled, true); assert.equal(f.recycle.from, 'discard');
|
||||
assert.equal(f.beforeRecycle.pendingRicheseBetrayal, null); assert.ok(!f.beforeRecycle.auction);
|
||||
const done = finish(act(f.game, f.holder, f.useAction));
|
||||
assert.deepEqual(wallets(done), before);
|
||||
assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(done, f.target).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used');
|
||||
assert.equal(done.richeseBetrayal!.completed.at(-1)!.event, receipt.event);
|
||||
assert.equal(done.richeseBetrayal!.current, null); assert.ok(!done.currentAuctionSale);
|
||||
assert.equal(done.auction!.index, index + 1);
|
||||
assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} all normal acknowledgements keep the Nexus and restore the original spice-or-Karama payment choice`, () => {
|
||||
const f = fixture({ advanced }), before = wallets(f.game); source(f);
|
||||
const pending = pass(f.game);
|
||||
assert.equal(pending.decision?.kind, 'auctionPayment'); assert.equal(pending.decision?.player, f.target);
|
||||
assert.deepEqual(wallets(pending), before);
|
||||
assert.equal(pending.nexusCards!.cards!.hands[f.holder], 'richese');
|
||||
assert.equal(player(pending, f.target).hand.some(card => card.id === f.card), false);
|
||||
const done = pay(pending, f);
|
||||
assert.equal(player(done, f.target).spice, before[f.target] - f.price);
|
||||
assert.equal(player(done, f.emperor).spice, before[f.emperor] + f.price);
|
||||
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(done.discard.some(card => card.id === f.card), false);
|
||||
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'passed');
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
|
||||
assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} ordinary-face MISS spends the blind Nexus then preserves one native purchase and payment`, () => {
|
||||
const f = fixture({ advanced, richeseCard: false }), before = wallets(f.game); source(f);
|
||||
assert.equal(viewGame(f.game, f.holder).richeseBetrayalReaction!.canUse, true);
|
||||
const pending = act(f.game, f.holder, f.useAction);
|
||||
assert.equal(pending.decision?.kind, 'auctionPayment'); assert.deepEqual(wallets(pending), before);
|
||||
assert.equal(pending.nexusCards!.cards!.hands[f.holder], null);
|
||||
assert.equal(player(pending, f.target).hand.some(card => card.id === f.card), false);
|
||||
const done = pay(pending, f);
|
||||
assert.equal(player(done, f.target).spice, before[f.target] - 3);
|
||||
assert.equal(player(done, f.emperor).spice, before[f.emperor] + 3);
|
||||
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(done.discard.some(card => card.id === f.card), false);
|
||||
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used');
|
||||
assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} normal all-pass without a retained Karama follows the native automatic spice payment`, () => {
|
||||
const f = fixture({ advanced, karama: false }), before = wallets(f.game); source(f);
|
||||
assert.equal(player(f.game, f.target).hand.some(card => card.effect === 'karama'), false);
|
||||
const done = finish(pass(f.game));
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(player(done, f.target).spice, before[f.target] - 3);
|
||||
assert.equal(player(done, f.emperor).spice, before[f.emperor] + 3);
|
||||
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} original winning-bid Karama remains usable after the normal blind counter passes`, () => {
|
||||
const f = fixture({ advanced }), before = wallets(f.game); assert.ok(f.karama);
|
||||
const pending = pass(f.game); assert.equal(pending.decision?.kind, 'auctionPayment');
|
||||
const done = pay(pending, f, true);
|
||||
assert.deepEqual(wallets(done), before);
|
||||
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'passed');
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
});
|
||||
}
|
||||
|
||||
void test('genuine Richese ally pledge remains unused on a normal veto and pays its original split after a MISS', () => {
|
||||
for (const family of [true, false]) {
|
||||
const f = fixture({ advanced: true, allyPayment: 2, richeseCard: family }), receipt = source(f); assert.ok(f.ally && f.funding);
|
||||
assert.equal(player(f.game, f.target).ally, f.ally); assert.equal(player(f.game, f.ally).ally, f.target);
|
||||
assert.deepEqual(f.funding.steps, [{ actor: f.ally, action: { type: 'pledgeAid', amount: 2 } }]);
|
||||
assert.equal(receipt.invoice.ownPayment, 1); assert.equal(receipt.invoice.allyPayment, 2);
|
||||
assert.equal(receipt.invoice.donor, f.ally);
|
||||
const before = wallets(f.game), credit = f.game.aid[f.ally].amount;
|
||||
const pending = act(f.game, f.holder, f.useAction), done = family ? finish(pending) : pay(pending, f);
|
||||
assert.equal(player(done, f.target).spice, before[f.target] - (family ? 0 : 1));
|
||||
assert.equal(player(done, f.ally).spice, before[f.ally]);
|
||||
assert.equal(player(done, f.emperor).spice, before[f.emperor] + (family ? 0 : 3));
|
||||
assert.equal(done.aid[f.ally].amount, credit - (family ? 0 : 2));
|
||||
assert.equal(player(done, f.target).hand.some(card => card.id === f.card), !family);
|
||||
assert.deepEqual(inventory(done), f.originalInventory); assert.deepEqual(nexusInventory(done), f.originalNexusInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary self-purchase acknowledgement is neutral for either card family and an irrelevant actually drawn Nexus', () => {
|
||||
const neutral: unknown[] = [];
|
||||
for (const family of [true, false]) for (const holderNexus of ['richese', 'guild'] as const) {
|
||||
const f = fixture({ richeseCard: family, holderNexus }); source(f);
|
||||
const reaction = viewGame(f.game, f.holder).richeseBetrayalReaction!;
|
||||
assert.equal(reaction.source, 'normal'); assert.equal(reaction.canPass, true); assert.equal(reaction.hasPassed, false);
|
||||
assert.equal(reaction.canUse, holderNexus === 'richese');
|
||||
assert.equal(Object.hasOwn(reaction, 'card'), false); assert.equal(Object.hasOwn(reaction, 'cardId'), false);
|
||||
assert.equal(reaction.event.includes(f.card), false);
|
||||
neutral.push({ kind: reaction.kind, target: reaction.target, buyer: reaction.buyer, source: reaction.source,
|
||||
price: reaction.price, canPass: reaction.canPass, hasPassed: reaction.hasPassed });
|
||||
const done = pay(pass(f.game), f); assert.equal(done.nexusCards!.cards!.hands[f.holder], holderNexus);
|
||||
}
|
||||
assert.ok(neutral.every(value => JSON.stringify(value) === JSON.stringify(neutral[0])));
|
||||
});
|
||||
|
||||
void test('all four minimal policies make legal normal-counter choices for a Richese face and an ordinary MISS', () => {
|
||||
for (const family of [true, false]) {
|
||||
const f = fixture({ richeseCard: family });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const own = viewGame(f.game, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[0]; assert.ok(action, difficulty);
|
||||
assert.ok(action.type === 'richeseBetrayalPass' || action.type === 'richeseBetrayalUse', difficulty);
|
||||
assert.equal(action.event, f.event);
|
||||
let game = act(f.game, f.holder, action);
|
||||
if (game.decision?.kind === 'auctionPayment') game = pay(game, f);
|
||||
else game = finish(game);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.holder], action.type === 'richeseBetrayalUse' ? null : 'richese');
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a normal veto advances the real next lot without reopening its old acknowledgement or accepting stale use', () => {
|
||||
const f = fixture(), originalIndex = f.game.auction!.index;
|
||||
reject(f.game, f.target, f.useAction);
|
||||
reject(f.game, f.holder, { ...f.useAction, event: `${f.event}:stale` });
|
||||
let game = finish(act(f.game, f.holder, f.useAction));
|
||||
assert.equal(game.auction!.index, originalIndex + 1);
|
||||
const nextCard = game.auction!.cards[game.auction!.index].id;
|
||||
for (let count = 0; !game.decision && count < 16; count++) {
|
||||
assert.ok(game.auction); const actor = game.auction.active;
|
||||
game = act(game, actor, actor === f.target && game.auction.bidder !== f.target
|
||||
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' });
|
||||
assert.equal(game.pendingRicheseBetrayal, null);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||
assert.equal(game.richeseBetrayal!.completed.filter(receipt => receipt.event === f.event).length, 1);
|
||||
reject(game, f.holder, f.useAction);
|
||||
game = pay(game, f);
|
||||
assert.equal(player(game, f.target).hand.filter(card => card.id === nextCard).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(nexusInventory(game), f.originalNexusInventory);
|
||||
});
|
||||
Reference in new issue
Block a user