Compose purchased Karama rates with Richese Cunning
This commit is contained in:
1 parent
61c269123c
commit
96de962622
16 files changed
+660
-19
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { territory } from '../game/board';
|
||||
import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment';
|
||||
import {
|
||||
createRicheseHomeworldCunningFixture,
|
||||
actRicheseHomeworldCunning, nextRicheseHomeworldCunning, advanceRicheseHomeworldCunning,
|
||||
passRicheseHomeworldCunning, finishRicheseHomeworldCunning,
|
||||
commitRicheseHomeworldCunning, revealRicheseHomeworldCunning,
|
||||
richeseHomeworldCunningPlayer, richeseHomeworldCunningClean,
|
||||
richeseHomeworldCunningInventory, richeseHomeworldCunningNexusInventory,
|
||||
richeseHomeworldCunningPopulation,
|
||||
} from './fixture-richese-homeworld-cunning';
|
||||
import type { RicheseHomeworldShipmentStep } from './fixture-richese-homeworld-shipment';
|
||||
|
||||
export const actRicheseCunningPurchasedRate = actRicheseHomeworldCunning;
|
||||
export const nextRicheseCunningPurchasedRate = nextRicheseHomeworldCunning;
|
||||
export const advanceRicheseCunningPurchasedRate = advanceRicheseHomeworldCunning;
|
||||
export const passRicheseCunningPurchasedRate = passRicheseHomeworldCunning;
|
||||
export const finishRicheseCunningPurchasedRate = finishRicheseHomeworldCunning;
|
||||
export const commitRicheseCunningPurchasedRate = commitRicheseHomeworldCunning;
|
||||
export const revealRicheseCunningPurchasedRate = revealRicheseHomeworldCunning;
|
||||
export const richeseCunningPurchasedRatePlayer = richeseHomeworldCunningPlayer;
|
||||
export const richeseCunningPurchasedRateClean = richeseHomeworldCunningClean;
|
||||
export const richeseCunningPurchasedRateInventory = richeseHomeworldCunningInventory;
|
||||
export const richeseCunningPurchasedRateNexusInventory = richeseHomeworldCunningNexusInventory;
|
||||
export const richeseCunningPurchasedRatePopulation = richeseHomeworldCunningPopulation;
|
||||
|
||||
/** Native original sources plus Emperor's actual printed Karama purchase for
|
||||
* active Richese. No source fields are added to the client pair declaration.
|
||||
* The optional costClaimed flag is an explicitly controlled supplement only. */
|
||||
export function createRicheseCunningPurchasedRateFixture(options: {
|
||||
advanced?: boolean; companions?: number; guild?: boolean; costClaimed?: boolean;
|
||||
} = {}) {
|
||||
const companions = options.companions ?? 11;
|
||||
assert.ok(Number.isSafeInteger(companions) && companions >= 0 && companions <= 16);
|
||||
const f = createRicheseHomeworldCunningFixture({ advanced: options.advanced,
|
||||
companions: Math.max(1, companions), guild: options.guild });
|
||||
const actions: RicheseHomeworldShipmentStep[] = structuredClone(f.actions);
|
||||
const beforePurchase = structuredClone(f.game), activationAction: Action = {
|
||||
type: 'card', mode: 'shipment', card: f.karama, target: f.owner,
|
||||
};
|
||||
let game = actRicheseCunningPurchasedRate(f.game, f.holder, activationAction, actions);
|
||||
game = finishRicheseCunningPurchasedRate(game, actions);
|
||||
const activation = { before: beforePurchase, steps: structuredClone(actions.slice(f.actions.length)), after: structuredClone(game) };
|
||||
assert.deepEqual(game.karamaShipping, { player: f.owner, owner: f.holder, card: f.karama });
|
||||
assert.equal(richeseCunningPurchasedRatePlayer(game, f.holder).hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
if (options.costClaimed) Object.assign(game.karamaShipping!, { costClaimed: true as const });
|
||||
const p = richeseCunningPurchasedRatePlayer(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning;
|
||||
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The original purchased rate leaves native Cunning available');
|
||||
const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: p.reserves, amount: companions, revealedValue: f.revealedValue });
|
||||
const action: Action = { type: 'ship', noField: f.concealedToken, event: p.noFieldEvent,
|
||||
revealedToken: f.revealedToken, nexus: offer.event,
|
||||
...(companions ? { noFieldReserves: companions } : {}), territory: f.territory, sector: f.sector, allyPayment: 0 };
|
||||
const cost = reserveShipmentCost({ faction: 'richese', halfRate: true }, territory(f.territory).type, quote.priced);
|
||||
const guildCancel: Action | null = f.guildKarama ? { type: 'card', card: f.guildKarama, mode: 'cancel' } : null;
|
||||
const guildStop: Action | null = f.guildKarama ? { type: 'card', card: f.guildKarama, mode: 'special' } : null;
|
||||
assert.deepEqual(richeseCunningPurchasedRateInventory(game), f.originalInventory);
|
||||
assert.deepEqual(richeseCunningPurchasedRateNexusInventory(game), f.originalNexusInventory);
|
||||
return { ...f, game, source: structuredClone(game), beforeShipment: structuredClone(game), beforePurchase,
|
||||
beforeActivation: beforePurchase, activation, activationAction,
|
||||
purchaser: f.holder, shipper: f.owner, purchasedRate: structuredClone(game.karamaShipping!),
|
||||
grant: structuredClone(game.karamaShipping!), controlledCostClaimed: !!options.costClaimed,
|
||||
companions, noFieldReserves: companions, amount: companions,
|
||||
action, shipmentAction: action, shipmentStep: { actor: f.owner, action },
|
||||
pairQuote: quote, paid: quote.paid, physical: quote.physical, priced: quote.priced, revealedForces: quote.revealed,
|
||||
fullCost: reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(f.territory).type, quote.priced),
|
||||
cost, shipmentCost: cost, nexusEvent: offer.event, offer: structuredClone(offer), guildCancel, guildStop,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [...f.staging,
|
||||
'After the actual original alliance, closing Richese draw and funded unused turn, Emperor plays its genuinely dealt Karama in shipment mode for active Richese. That exact physical card enters discard once and the original owner-labelled purchased grant is retained. The pair action carries only original client fields; the server owns its price-source record.',
|
||||
'Optional Guild really receives the other starting Karama and passes its empty Nexus choice. Its original No-Field counter, Advanced shipment interception, bank routing and unclaimed-activation refund remain caller controls. No faction, card, force, alliance, phase or grant is installed.',
|
||||
...(options.costClaimed ? ['CONTROLLED SUPPLEMENT: after that genuine activation, set only its existing costClaimed flag true to represent previously claimed disposal cost. No physical card, duplicate identity, owner, phase, receipt or payment is created. This is not a claimed original Nullentropy programme.'] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
export type RicheseCunningPurchasedRateFixture = ReturnType<typeof createRicheseCunningPurchasedRateFixture>;
|
||||
@@ -29,12 +29,12 @@ export const richeseHomeworldCunningPopulation = richeseHomeworldShipmentPopulat
|
||||
* declaration. Emperor and Atreides actually ally at the turn-two worm; the
|
||||
* unallied Richese really draws its original Cunning singleton afterward. */
|
||||
export function createRicheseHomeworldCunningFixture(options: {
|
||||
advanced?: boolean; companions?: number; revealedValue?: NoFieldValue; concealedValue?: NoFieldValue;
|
||||
advanced?: boolean; companions?: number; revealedValue?: NoFieldValue; concealedValue?: NoFieldValue; guild?: boolean;
|
||||
} = {}) {
|
||||
const revealedValue = options.revealedValue ?? 5, concealedValue = options.concealedValue ?? 3;
|
||||
assert.notEqual(revealedValue, concealedValue, 'The two original physical tokens must be distinct');
|
||||
const f = createRicheseHomeworldShipmentFixture({ advanced: options.advanced,
|
||||
companions: options.companions, value: concealedValue, nexus: true });
|
||||
companions: options.companions, value: concealedValue, nexus: true, guild: options.guild });
|
||||
assert.ok(f.nexusDraw && f.nexusAlliance && f.observer);
|
||||
const p = richeseHomeworldCunningPlayer(f.game, f.owner), offer = viewGame(f.game, f.owner).nexusRicheseCunning;
|
||||
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The actually drawn native Richese Cunning is live');
|
||||
@@ -53,7 +53,7 @@ export function createRicheseHomeworldCunningFixture(options: {
|
||||
value: concealedValue, markerValue: concealedValue, token: concealed.id, tokenId: concealed.id,
|
||||
paid: quote.paid, physical: quote.physical, priced: quote.priced, revealedForces: quote.revealed, pairQuote: quote,
|
||||
staging: [...f.staging,
|
||||
'This native three-seat branch stops before the original two-token Cunning declaration. Its Emperor/Atreides alliance is earned, Richese is unallied, the original Richese Nexus is actually held and empty Carthag11 is the funded destination. No two-seat all-unallied draw is manufactured.',
|
||||
`This native ${f.guild ? 'four' : 'three'}-seat branch stops before the original two-token Cunning declaration. Its Emperor/Atreides alliance is earned, Richese is unallied, the original Richese Nexus is actually held and empty Carthag11 is the funded destination. No two-seat all-unallied draw is manufactured.`,
|
||||
'The proposed pair uses one concealed three token and one immediate faceup five token by default, plus eleven paid original reserves. Root’s explicitly provisional composition reserves the paid group first, materializes only the remaining stock for the immediate token and leaves the other token concealed. The original Nexus spend, No-Field Karama counter, one combined invoice and later reserve-capped reveal remain real caller actions.',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -50,6 +50,8 @@ export const richeseHomeworldShipmentNexusInventory = (game: Game) => {
|
||||
* really takes ordinary Charity when eligible; no balance is installed. */
|
||||
export function nextRicheseHomeworldShipment(game: Game): RicheseHomeworldShipmentStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
if (game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn,
|
||||
@@ -96,10 +98,12 @@ export function finishRicheseHomeworldShipment(game: Game, actions: RicheseHomew
|
||||
* cards and undeployed original No-Fields. Native deferred bribes fund one
|
||||
* combined invoice without changing either physical army. Stop BEFORE ship. */
|
||||
export function createRicheseHomeworldShipmentFixture(options: {
|
||||
advanced?: boolean; companions?: number; value?: NoFieldValue; nexus?: boolean;
|
||||
advanced?: boolean; companions?: number; value?: NoFieldValue; nexus?: boolean; guild?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, companions = options.companions ?? 11, value = options.value ?? 5;
|
||||
const nexus = options.nexus ?? false;
|
||||
const guild = options.guild ? 'guild' : null;
|
||||
assert.ok(!guild || nexus, 'The bounded optional Guild source retains its actual Nexus programme');
|
||||
assert.ok(Number.isSafeInteger(companions) && companions >= 1 && companions <= 16,
|
||||
'The bounded native funding programme supports one through sixteen companions');
|
||||
const owner = 'richese', holder = 'emperor', observer = nexus ? 'atreides' : null;
|
||||
@@ -108,6 +112,7 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
let game = createGame('RICHESEHOMEWORLDSHIPMENT', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(holder, 'Emperor', 'emperor'));
|
||||
if (observer) joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
|
||||
if (guild) joinGame(game, newPlayer(guild, 'Guild', 'guild'));
|
||||
const initial = structuredClone(game), actions: RicheseHomeworldShipmentStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actRicheseHomeworldShipment(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceRicheseHomeworldShipment(game, until, actions); };
|
||||
@@ -133,7 +138,9 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama);
|
||||
const observerCard = observer ? game.deck.find(card => card.kind === 'shield') : null;
|
||||
assert.ok(!observer || observerCard);
|
||||
const deal = [ordinary.id, karama.id, ...(observerCard ? [observerCard.id] : [])];
|
||||
const guildCard = guild ? game.deck.find(card => card.effect === 'karama' && card.id !== karama.id) : null;
|
||||
assert.ok(!guild || guildCard);
|
||||
const deal = [ordinary.id, karama.id, ...(observerCard ? [observerCard.id] : []), ...(guildCard ? [guildCard.id] : [])];
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
@@ -149,6 +156,8 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
assert.deepEqual(richeseHomeworldShipmentPlayer(game, holder).hand.map(card => card.id), [karama.id]);
|
||||
if (observer && observerCard)
|
||||
assert.deepEqual(richeseHomeworldShipmentPlayer(game, observer).hand.map(card => card.id), [observerCard.id]);
|
||||
if (guild && guildCard)
|
||||
assert.deepEqual(richeseHomeworldShipmentPlayer(game, guild).hand.map(card => card.id), [guildCard.id]);
|
||||
assert.equal(richeseHomeworldShipmentPlayer(game, owner).reserves, 20);
|
||||
assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).forces, {});
|
||||
const originalInventory = richeseHomeworldShipmentInventory(game); assert.equal(originalInventory.length, 45);
|
||||
@@ -165,6 +174,7 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
const beforeFunding = structuredClone(game), funding: RicheseHomeworldShipmentTransition[] = [];
|
||||
let beforeWorm: Game | null = null, beforeNexusDraw: Game | null = null;
|
||||
let nexusAlliance: RicheseHomeworldShipmentTransition | null = null, nexusDraw: RicheseHomeworldShipmentTransition | null = null;
|
||||
let guildNexusPass: RicheseHomeworldShipmentTransition | null = null;
|
||||
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
|
||||
let nexusOrder: { position: number; from: number; card: 'richese' } | null = null;
|
||||
const wormFollowupOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
@@ -200,6 +210,10 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
nexusDraw = transition(() => { act(owner, { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: null, choice: 'draw', ownRedraws: 0 }); });
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'richese');
|
||||
if (guild) guildNexusPass = transition(() => {
|
||||
assert.equal(game.nexusCards!.cards!.hands[guild], null);
|
||||
act(guild, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 });
|
||||
});
|
||||
}
|
||||
reach(state => state.turn === turn && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state));
|
||||
};
|
||||
@@ -236,8 +250,10 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
nexusAlliance: nexusAlliance as RicheseHomeworldShipmentTransition | null,
|
||||
beforeNexusDraw: beforeNexusDraw as Game | null,
|
||||
nexusDraw: nexusDraw as RicheseHomeworldShipmentTransition | null,
|
||||
guildNexusPass: guildNexusPass as RicheseHomeworldShipmentTransition | null,
|
||||
nexusOrder: nexusOrder as { position: number; from: number; card: 'richese' } | null,
|
||||
advanced, nexus, owner, richese: owner, actor: owner, holder, emperor: holder, observer, atreides: observer,
|
||||
guild, guildKarama: guildCard?.id ?? null,
|
||||
companions, noFieldReserves: companions, amount: companions, physical: companions, priced: companions + 1,
|
||||
cost, shipmentCost: cost, value, markerValue: value, token: token.id, tokenId: token.id, event, shipmentEvent: event,
|
||||
territory, sector, location, action, shipmentAction: action,
|
||||
@@ -249,12 +265,15 @@ export function createRicheseHomeworldShipmentFixture(options: {
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
nexus
|
||||
? 'Actual public Basic/Advanced Richese/Emperor/Atreides lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, three unopened Treachery positions and six unopened ordinary Spice positions are selected.'
|
||||
? guild
|
||||
? 'Actual public Basic/Advanced Richese/Emperor/Atreides/Guild lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, four unopened Treachery positions and six unopened ordinary Spice positions are selected.'
|
||||
: 'Actual public Basic/Advanced Richese/Emperor/Atreides lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, three unopened Treachery positions and six unopened ordinary Spice positions are selected.'
|
||||
: 'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.',
|
||||
'Richese really receives an ordinary projectile and Emperor its printed original Karama. Every original Storm, Spice, Charity, native cache/normal auction, Black Market decline in Advanced, Revival, Movement ending and Mentat control is used. Real cache lots are passed and retired, never replaced or acquired by assigning a hand.',
|
||||
'The default eleven-companion stronghold invoice needs twelve spice. Emperor really pays Richese a seven-spice bribe during turn one; its pending recipient share becomes available only at the actual Mentat. Richese’s next unused turn retains all twenty original reserves and HIGH population before declaration.',
|
||||
'The sixteen-companion cap variant uses an actual ten-spice first bribe, next-turn Emperor Charity for two and a second two-spice bribe, both completed through real Mentat. Its third unused turn has seventeen spendable spice and the same untouched original twenty reserves. Smaller companion requests use only the native funding they need.',
|
||||
...(nexus ? ['The original turn-two unopened Spice order supplies a real Shai-Hulud and ordinary land follow-ups. Emperor actually proposes and Atreides accepts alliance; Richese stays unallied. Only the unopened Richese Nexus singleton is moved to the next original draw position, then Richese really draws it at the closing Nexus choice. Two seats cannot earn that unallied draw; the native third seat supplies its real eligibility. Empty Carthag11 avoids Atreides’ original Arrakeen army.'] : []),
|
||||
...(guild ? ['The optional fourth Guild seat really receives the second original printed Karama in its unopened starting deal. Atreides keeps its original Shield; neither card is moved between hands. Guild explicitly keeps its empty position at the real Nexus closing choice and declines native early timing before the source. Its actual native five Tuek counters and starting resources remain earned state.'] : []),
|
||||
'Stop BEFORE the marker/physical shipment. The original token/event, opponent-held Karama, quiet unused shipment and exact funded combined invoice remain live caller inputs. No force, wallet, phase, hand, stance, token custody, canceled history or pending proof is installed.',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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 { territory } from '../game/board';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment';
|
||||
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
|
||||
import type { RicheseCunningRateSource } from '../game/richese-cunning-rate';
|
||||
import {
|
||||
createRicheseCunningPurchasedRateFixture as fixture,
|
||||
actRicheseCunningPurchasedRate as act,
|
||||
passRicheseCunningPurchasedRate as pass,
|
||||
finishRicheseCunningPurchasedRate as finish,
|
||||
commitRicheseCunningPurchasedRate as commit,
|
||||
richeseCunningPurchasedRatePlayer as player,
|
||||
richeseCunningPurchasedRateInventory as inventory,
|
||||
richeseCunningPurchasedRateNexusInventory as nexusInventory,
|
||||
} from './fixture-richese-cunning-purchased-rate';
|
||||
|
||||
function frame(game: Game) {
|
||||
const record = game.nexusRicheseCunningHistory?.at(-1); assert.ok(record);
|
||||
return JSON.parse(record.frame) as { cost: number; richesePair: { karama?: RicheseCunningRateSource } };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
|
||||
const before = structuredClone(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native three-seat Cunning uses Emperor's actual purchased rate for the combined twelve-unit invoice`, () => {
|
||||
const f = fixture({ advanced }), before = structuredClone(player(f.game, f.owner));
|
||||
assert.equal(f.guild, null); assert.ok(f.observer);
|
||||
assert.equal(player(f.beforePurchase, f.holder).hand.some(card => card.id === f.karama), true);
|
||||
assert.deepEqual(f.activation.steps[0], { actor: f.holder, action: f.activationAction });
|
||||
assert.equal(f.activationAction.mode, 'shipment'); assert.equal(f.activationAction.target, f.owner);
|
||||
assert.deepEqual(f.purchasedRate, { player: f.owner, owner: f.holder, card: f.karama });
|
||||
assert.equal(viewGame(f.game, f.owner).nexusRicheseCunning?.blocked, null);
|
||||
assert.equal(f.fullCost, 12); assert.equal(f.cost, 6);
|
||||
const result = commit(f.game, f), after = player(result.game, f.owner), saved = frame(result.game);
|
||||
assert.equal(saved.cost, 6); assert.deepEqual(saved.richesePair.karama, f.purchasedRate);
|
||||
assert.equal(Object.hasOwn(saved.richesePair.karama!, 'costClaimed'), false);
|
||||
assert.equal(before.spice - after.spice, 6);
|
||||
assert.equal(after.reserves, 4); assert.deepEqual(after.forces, { [f.location]: 16 });
|
||||
assert.equal(after.shipped, true); assert.equal(after.moved, 0);
|
||||
assert.equal(after.noField?.deployed?.tokenId, f.concealedToken);
|
||||
assert.equal(result.game.karamaShipping, null);
|
||||
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
|
||||
assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(result.game, f.holder).hand.some(card => card.id === f.karama), false);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a genuine fourth Guild seat permits the purchased pair but receives none of its bank-paid fee', () => {
|
||||
const f = fixture({ advanced: true, guild: true }); assert.ok(f.guild && f.guildKarama && f.guildNexusPass);
|
||||
assert.notEqual(f.guildKarama, f.karama);
|
||||
assert.deepEqual(f.guildNexusPass.steps[0], { actor: f.guild, action: {
|
||||
type: 'nexusCardChoice', turn: 2, card: null, choice: 'keep', ownRedraws: 0,
|
||||
} });
|
||||
const guildSpice = player(f.game, f.guild).spice;
|
||||
const pending = act(f.game, f.owner, f.action);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
assert.equal(pending.pendingShipment?.cost, 6);
|
||||
assert.deepEqual(pending.pendingShipment?.richesePair?.karama, f.purchasedRate);
|
||||
const interception = pass(pending);
|
||||
assert.equal(interception.decision?.kind, 'guildShipment'); assert.equal(interception.decision?.player, f.guild);
|
||||
const result = finish(act(interception, f.guild, { type: 'decision', allow: true }));
|
||||
assert.equal(player(result, f.owner).spice, player(f.game, f.owner).spice - 6);
|
||||
assert.equal(player(result, f.guild).spice, guildSpice);
|
||||
assert.ok(player(result, f.guild).hand.some(card => card.id === f.guildKarama));
|
||||
assert.equal(result.karamaShipping, null);
|
||||
assert.equal(result.nexusRicheseCunningLast?.stage, 'shipped');
|
||||
assert.notEqual(result.response?.kind, 'guildIncome');
|
||||
assert.notEqual(result.response?.kind, 'guildRate');
|
||||
assert.deepEqual(inventory(result), f.originalInventory);
|
||||
assert.deepEqual(nexusInventory(result), f.originalNexusInventory);
|
||||
});
|
||||
|
||||
void test('Guild cancelling the original No-Field retains Emperor’s grant for an ordinary bank-rate replacement', () => {
|
||||
const f = fixture({ advanced: true, guild: true }); assert.ok(f.guild && f.guildCancel);
|
||||
const before = structuredClone(player(f.game, f.owner)), guildSpice = player(f.game, f.guild).spice;
|
||||
const pending = act(f.game, f.owner, f.action);
|
||||
const stopped = finish(act(pending, f.guild, f.guildCancel)), after = player(stopped, f.owner);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves);
|
||||
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.noField, before.noField);
|
||||
assert.equal(after.shipped, false); assert.equal(after.moved, 0);
|
||||
assert.deepEqual(stopped.karamaShipping, f.purchasedRate);
|
||||
assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped');
|
||||
assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(stopped.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
|
||||
const replaced = finish(act(stopped, f.owner, f.replacement));
|
||||
assert.equal(player(replaced, f.owner).spice, before.spice - 1);
|
||||
assert.equal(player(replaced, f.owner).reserves, 19);
|
||||
assert.deepEqual(player(replaced, f.owner).forces, { [f.location]: 1 });
|
||||
assert.equal(player(replaced, f.guild).spice, guildSpice);
|
||||
assert.equal(replaced.karamaShipping, null);
|
||||
assert.equal(replaced.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.deepEqual(inventory(replaced), f.originalInventory);
|
||||
assert.deepEqual(nexusInventory(replaced), f.originalNexusInventory);
|
||||
});
|
||||
|
||||
void test('native Guild special prevention consumes the pair opportunity and refunds only the unclaimed original activation card', () => {
|
||||
const f = fixture({ advanced: true, guild: true }); assert.ok(f.guild && f.guildStop && f.guildKarama);
|
||||
const before = structuredClone(player(f.game, f.owner));
|
||||
const interception = pass(act(f.game, f.owner, f.action));
|
||||
assert.equal(interception.decision?.kind, 'guildShipment');
|
||||
const stopped = finish(act(interception, f.guild, f.guildStop)), after = player(stopped, f.owner);
|
||||
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves);
|
||||
assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.noField, before.noField);
|
||||
assert.equal(after.shipped, true); assert.equal(after.moved, 0);
|
||||
assert.equal(stopped.karamaShipping, null); assert.equal(stopped.pendingShipment ?? null, null);
|
||||
assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped');
|
||||
assert.equal(stopped.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
assert.ok(player(stopped, f.holder).hand.some(card => card.id === f.karama));
|
||||
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 0);
|
||||
assert.equal(stopped.discard.filter(card => card.id === f.guildKarama).length, 1);
|
||||
assert.equal(player(stopped, f.guild).specialKaramaUsed, true);
|
||||
assert.deepEqual(inventory(stopped), f.originalInventory);
|
||||
assert.deepEqual(nexusInventory(stopped), f.originalNexusInventory);
|
||||
});
|
||||
|
||||
void test('one paid companion and legacy zero-companion city pairs each cost one and still route the purchased fee to Bank', () => {
|
||||
for (const companions of [0, 1]) {
|
||||
const f = fixture({ companions, guild: true }); assert.ok(f.guild);
|
||||
const guildSpice = player(f.game, f.guild).spice;
|
||||
assert.equal(f.cost, 1);
|
||||
const result = commit(f.game, f), saved = frame(result.game), after = player(result.game, f.owner);
|
||||
assert.deepEqual(saved.richesePair.karama, f.purchasedRate);
|
||||
assert.equal(saved.cost, 1);
|
||||
assert.equal(player(f.game, f.owner).spice - after.spice, 1);
|
||||
assert.equal(after.reserves, 20 - (companions + 5));
|
||||
assert.deepEqual(after.forces, { [f.location]: companions + 5 });
|
||||
assert.equal(player(result.game, f.guild).spice, guildSpice);
|
||||
assert.equal(result.game.karamaShipping, null);
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('CONTROLLED claimed-cost metadata preserves the actual rate identity but prevents another activation-card refund', () => {
|
||||
const f = fixture({ advanced: true, guild: true, costClaimed: true }); assert.ok(f.guild && f.guildStop && f.guildKarama);
|
||||
assert.equal(f.controlledCostClaimed, true); assert.equal(f.game.karamaShipping?.costClaimed, true);
|
||||
assert.ok(f.staging.some(line => line.startsWith('CONTROLLED SUPPLEMENT:')));
|
||||
const pending = act(f.game, f.owner, f.action);
|
||||
assert.equal(pending.pendingShipment?.cost, 6);
|
||||
assert.deepEqual(pending.pendingShipment?.richesePair?.karama,
|
||||
{ player: f.owner, owner: f.holder, card: f.karama });
|
||||
const interception = pass(pending); assert.equal(interception.decision?.kind, 'guildShipment');
|
||||
const stopped = finish(act(interception, f.guild, f.guildStop));
|
||||
assert.equal(player(stopped, f.owner).reserves, 20);
|
||||
assert.deepEqual(player(stopped, f.owner).forces, {});
|
||||
assert.equal(player(stopped, f.holder).hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(stopped.discard.filter(card => card.id === f.guildKarama).length, 1);
|
||||
assert.equal(stopped.karamaShipping, null);
|
||||
assert.deepEqual(inventory(stopped), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('CONTROLLED pending-origin changes reject even when the city invoice remains one spice', () => {
|
||||
const f = fixture({ companions: 0, guild: true }); assert.ok(f.guild && f.guildKarama);
|
||||
const pending = act(f.game, f.owner, f.action);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField'); assert.equal(pending.pendingShipment?.cost, 1);
|
||||
for (const changed of [null, { ...f.purchasedRate, owner: f.guild }, { ...f.purchasedRate, card: f.guildKarama }]) {
|
||||
// CONTROLLED metadata edit to an actually declared native pending pair;
|
||||
// no card, force, phase or original fixture source is constructed here.
|
||||
const controlled = structuredClone(pending); controlled.karamaShipping = changed;
|
||||
reject(controlled, f.owner, { type: 'passResponse' });
|
||||
}
|
||||
const legacy: Action = { type: 'ship', noField: f.concealedToken, event: f.event,
|
||||
revealedToken: f.revealedToken, nexus: f.nexusEvent, territory: f.territory, sector: f.sector, allyPayment: 0 };
|
||||
const originallyFullPrice = act(f.beforePurchase, f.owner, legacy);
|
||||
assert.equal(originallyFullPrice.pendingShipment?.cost, 1);
|
||||
assert.equal(originallyFullPrice.pendingShipment?.richesePair?.karama, undefined);
|
||||
// CONTROLLED later card-omitted legacy grant. The pair itself was genuinely
|
||||
// declared without a purchase; no physical card moves or gets duplicated.
|
||||
const later = structuredClone(originallyFullPrice);
|
||||
later.karamaShipping = { player: f.owner, owner: f.holder };
|
||||
reject(later, f.owner, { type: 'passResponse' }, /original purchased Karama rate source/);
|
||||
});
|
||||
|
||||
void test('the shared payment filter accepts a real sixteen-companion group affordable only under its purchased rate', () => {
|
||||
const f = fixture(), action = { ...f.action, noFieldReserves: 16 };
|
||||
assert.equal(player(f.game, f.owner).spice, 12);
|
||||
assert.equal(botHomeworldShipmentPaymentAllowed(viewGame(f.game, f.owner), action), true);
|
||||
assert.equal(botHomeworldShipmentPaymentAllowed(viewGame(f.beforePurchase, f.owner), action), false);
|
||||
const result = commit(f.game, f, action);
|
||||
assert.equal(player(result.game, f.owner).spice, 3);
|
||||
assert.equal(player(result.game, f.owner).reserves, 0);
|
||||
assert.deepEqual(player(result.game, f.owner).forces, { [f.location]: 20 });
|
||||
assert.equal(result.game.karamaShipping, null);
|
||||
});
|
||||
|
||||
void test('all four existing profiles retain legal positive Cunning pair choices under the actual purchased rate', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'ship' && action.revealedToken &&
|
||||
action.nexus === f.nexusEvent && typeof action.noFieldReserves === 'number' && action.noFieldReserves > 0);
|
||||
assert.ok(action, `${difficulty} proposes a legal positive paid-group pair using the original purchased grant`);
|
||||
const revealed = player(f.game, f.owner).noField!.tokens.find(token => token.id === action.revealedToken); assert.ok(revealed);
|
||||
const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: 20,
|
||||
amount: Number(action.noFieldReserves), revealedValue: revealed.value });
|
||||
const cost = reserveShipmentCost({ faction: 'richese', halfRate: true }, territory(String(action.territory)).type, quote.priced);
|
||||
const result = commit(structuredClone(f.game), f, action);
|
||||
assert.equal(player(result.game, f.owner).spice, player(f.game, f.owner).spice - cost);
|
||||
assert.equal(player(result.game, f.owner).reserves, 20 - quote.physical);
|
||||
assert.equal(result.game.karamaShipping, null);
|
||||
assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped');
|
||||
assert.deepEqual(inventory(result.game), f.originalInventory);
|
||||
assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
sourceForRicheseCunning, validRicheseCunningRateSource, richeseCunningRateMatches,
|
||||
RicheseCunningRateError, type RicheseCunningRateSource, type RicheseCunningRateGrant,
|
||||
} from '../game/richese-cunning-rate';
|
||||
|
||||
const grant = (patch: Partial<RicheseCunningRateGrant> = {}): RicheseCunningRateGrant => ({
|
||||
player: 'richese', owner: 'emperor', card: 'original-karama', ...patch,
|
||||
});
|
||||
|
||||
void test('source snapshots clone only the exact origin without changing live cost-claim history', () => {
|
||||
const active = Object.freeze(grant({ costClaimed: true })), before = structuredClone(active);
|
||||
const source = sourceForRicheseCunning(active, 'richese');
|
||||
assert.deepEqual(source, { player: 'richese', owner: 'emperor', card: 'original-karama' });
|
||||
assert.notEqual(source, active);
|
||||
assert.ok(validRicheseCunningRateSource(source));
|
||||
assert.deepEqual(active, before);
|
||||
source!.owner = 'changed-snapshot-owner';
|
||||
assert.deepEqual(active, before);
|
||||
});
|
||||
|
||||
void test('owner, physical card, beneficiary and lost-grant changes cannot retain a source', () => {
|
||||
const source = sourceForRicheseCunning(grant(), 'richese');
|
||||
assert.equal(richeseCunningRateMatches(source, grant(), 'richese'), true);
|
||||
for (const active of [grant({ owner: 'other-owner' }), grant({ card: 'replacement-karama' }),
|
||||
grant({ player: 'other-player' }), null, undefined])
|
||||
assert.equal(richeseCunningRateMatches(source, active, 'richese'), false);
|
||||
assert.equal(richeseCunningRateMatches(source, grant(), 'other-player'), false);
|
||||
const foreign = sourceForRicheseCunning(grant({ player: 'other-player' }), 'other-player');
|
||||
assert.equal(richeseCunningRateMatches(foreign, grant({ player: 'other-player' }), 'richese'), false);
|
||||
});
|
||||
|
||||
void test('absent snapshots match only absence of a current grant for that beneficiary', () => {
|
||||
assert.equal(sourceForRicheseCunning(null, 'richese'), undefined);
|
||||
assert.equal(sourceForRicheseCunning(undefined, 'richese'), undefined);
|
||||
const foreign = grant({ player: 'other-player' }), before = structuredClone(foreign);
|
||||
assert.equal(sourceForRicheseCunning(foreign, 'richese'), undefined);
|
||||
for (const active of [null, undefined, foreign])
|
||||
assert.equal(richeseCunningRateMatches(undefined, active, 'richese'), true);
|
||||
assert.equal(richeseCunningRateMatches(undefined, grant(), 'richese'), false);
|
||||
assert.deepEqual(foreign, before);
|
||||
});
|
||||
|
||||
void test('a later claimed-cost flag leaves origin matching intact and remains sticky on the live grant', () => {
|
||||
const active = grant(), source = sourceForRicheseCunning(active, 'richese');
|
||||
assert.equal(richeseCunningRateMatches(source, active, 'richese'), true);
|
||||
active.costClaimed = true;
|
||||
const claimed = structuredClone(active);
|
||||
assert.equal(richeseCunningRateMatches(source, active, 'richese'), true);
|
||||
assert.deepEqual(sourceForRicheseCunning(active, 'richese'), source);
|
||||
assert.deepEqual(active, claimed);
|
||||
assert.equal(active.costClaimed, true);
|
||||
assert.ok(!Object.hasOwn(source!, 'costClaimed'));
|
||||
});
|
||||
|
||||
void test('serialized legacy grants may omit the original card but cannot silently acquire a replacement origin', () => {
|
||||
const active: RicheseCunningRateGrant = { player: 'richese', owner: 'emperor' };
|
||||
const source = JSON.parse(JSON.stringify(sourceForRicheseCunning(active, 'richese'))) as RicheseCunningRateSource;
|
||||
assert.ok(validRicheseCunningRateSource(source));
|
||||
assert.deepEqual(source, active);
|
||||
assert.equal(richeseCunningRateMatches(source, { ...active, costClaimed: true }, 'richese'), true);
|
||||
assert.equal(richeseCunningRateMatches(source, grant(), 'richese'), false);
|
||||
assert.equal(richeseCunningRateMatches(sourceForRicheseCunning(grant(), 'richese'), active, 'richese'), false);
|
||||
assert.deepEqual(sourceForRicheseCunning({ ...active, card: undefined }, 'richese'), active);
|
||||
});
|
||||
|
||||
void test('malformed or expanded snapshots reject rather than matching by beneficiary or invoice alone', () => {
|
||||
const bad: unknown[] = [null, [], {}, { player: 'richese' }, { owner: 'emperor' },
|
||||
{ player: '', owner: 'emperor' }, { player: 'richese', owner: ' ' },
|
||||
{ player: 'richese', owner: 'emperor', card: '' },
|
||||
{ player: 'richese', owner: 'emperor', card: undefined },
|
||||
{ ...grant(), costClaimed: true }, { ...grant(), price: 1 }, { ...grant(), discard: [] }];
|
||||
for (const source of bad) {
|
||||
assert.equal(validRicheseCunningRateSource(source), false);
|
||||
assert.equal(richeseCunningRateMatches(source as RicheseCunningRateSource, grant(), 'richese'), false);
|
||||
}
|
||||
for (const active of [grant({ owner: '' }), grant({ card: ' ' }), grant({ player: '' })]) {
|
||||
const before = structuredClone(active);
|
||||
assert.throws(() => sourceForRicheseCunning(active, 'richese'), RicheseCunningRateError);
|
||||
assert.equal(richeseCunningRateMatches(undefined, active, 'richese'), false);
|
||||
assert.deepEqual(active, before);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user