Bind Truthtrance Karama promises to shipment beneficiaries
This commit is contained in:
1 parent
fd0b2f0fa5
commit
fd38c51ffd
19 files changed
+1148
-41
No files matched your search
@@ -54,12 +54,14 @@ export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
|
||||
export function createTruthBidFixture(options: {
|
||||
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
|
||||
firstLotEffect?: 'hajr';
|
||||
extraGuild?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
|
||||
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
|
||||
let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
|
||||
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
|
||||
if (options.extraGuild) joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
const initial = structuredClone(game), actions: TruthBidStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
|
||||
@@ -75,7 +77,9 @@ export function createTruthBidFixture(options: {
|
||||
const lotCards = [game.deck.find(card => options.firstLotEffect ? card.effect === options.firstLotEffect : card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
|
||||
game.deck.find(card => card.kind === 'snooper')];
|
||||
assert.ok(filler && targetCard && lotCards.every(Boolean));
|
||||
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
|
||||
const guildCard = options.extraGuild ? game.deck.find(card => card.kind === 'hero') : null;
|
||||
assert.ok(!options.extraGuild || guildCard);
|
||||
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id, ...(guildCard ? [guildCard.id] : [])];
|
||||
const prefix = [...deal, ...lotCards.map(card => card!.id)];
|
||||
const dealOrder = prefix.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
@@ -88,6 +92,7 @@ export function createTruthBidFixture(options: {
|
||||
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
|
||||
assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
|
||||
assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
|
||||
if (guildCard) assert.deepEqual(truthBidPlayer(game, 'guild').hand.map(card => card.id), [guildCard.id]);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
@@ -111,7 +116,7 @@ export function createTruthBidFixture(options: {
|
||||
lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
|
||||
deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||
staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
|
||||
staging: [] as string[], sourceDisclosure: `Genuine public ${options.extraGuild ? 'H/A/E/G' : 'H/A/E'} base33 setup. Only original unopened ${options.extraGuild ? 'five' : 'four'}-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.` };
|
||||
}
|
||||
export type TruthBidFixture = ReturnType<typeof createTruthBidFixture>;
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import {
|
||||
actTruthTurnKarama, advanceTruthTurnKarama, closeTruthTurnKaramaLot, createTruthTurnKaramaFixture,
|
||||
declareTruthTurnKarama, finishTruthTurnKarama, nextTruthTurnKarama, nextTruthTurnKaramaNative,
|
||||
settleTruthTurnKarama, truthTurnKaramaClean, truthTurnKaramaInventory, truthTurnKaramaPlayer,
|
||||
truthTurnKaramaPromise, truthTurnKaramaTraitors, type TruthTurnKaramaLeaf, type TruthTurnKaramaStep,
|
||||
} from './fixture-truth-turn-karama';
|
||||
|
||||
export type TruthTurnKaramaShipmentLeaf = TruthTurnKaramaLeaf |
|
||||
{ kind: 'windowCardPlay'; effect: 'karamaShipment'; source: string };
|
||||
export type TruthTurnKaramaShipmentExpression = MixedActionExpression<TruthTurnKaramaShipmentLeaf>;
|
||||
export type TruthTurnKaramaShipmentStep = TruthTurnKaramaStep;
|
||||
export const actTruthTurnKaramaShipment = actTruthTurnKarama;
|
||||
export const truthTurnKaramaShipmentClean = truthTurnKaramaClean;
|
||||
export const truthTurnKaramaShipmentPlayer = truthTurnKaramaPlayer;
|
||||
export const truthTurnKaramaShipmentInventory = truthTurnKaramaInventory;
|
||||
export const truthTurnKaramaShipmentTraitors = truthTurnKaramaTraitors;
|
||||
export const truthTurnKaramaShipmentPromise = truthTurnKaramaPromise;
|
||||
export const nextTruthTurnKaramaShipment = nextTruthTurnKarama;
|
||||
export const nextTruthTurnKaramaShipmentNative = nextTruthTurnKaramaNative;
|
||||
export const advanceTruthTurnKaramaShipment = advanceTruthTurnKarama;
|
||||
export const settleTruthTurnKaramaShipment = settleTruthTurnKarama;
|
||||
export const finishTruthTurnKaramaShipment = finishTruthTurnKarama;
|
||||
export const truthTurnKaramaShipmentFact = (fact: TruthFact): TruthTurnKaramaShipmentExpression => ({ kind: 'fact', fact });
|
||||
export const truthTurnKaramaShipmentPlay = (source: string): TruthTurnKaramaShipmentExpression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source } });
|
||||
export const truthTurnKaramaShipmentPayment = (recipient: string, minimum = 2): TruthTurnKaramaShipmentExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthTurnKaramaShipmentShip = (territory = 'polar_sink', minimum = 1): TruthTurnKaramaShipmentExpression =>
|
||||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||
export const truthTurnKaramaShipmentGroup = (kind: 'and' | 'or', ...terms: TruthTurnKaramaShipmentExpression[]): TruthTurnKaramaShipmentExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
/** Original H/A/E two-Truth source, followed only by native passes/readiness
|
||||
* and actual prior Movement endings. The optional earlier printed auction use
|
||||
* really spends that original card; it supplies no shipment-role receipt. */
|
||||
export function createTruthTurnKaramaShipmentFixture(options: {
|
||||
beneficiary?: 'own' | 'foreign'; advanced?: boolean; priorAuctionSpend?: boolean;
|
||||
} = {}) {
|
||||
const base = createTruthTurnKaramaFixture({ advanced: options.advanced, window: 'active' });
|
||||
const kind = options.beneficiary ?? 'own', beneficiary = kind === 'own' ? base.target : base.asker;
|
||||
const priorAuctionSpend = options.priorAuctionSpend ?? false;
|
||||
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
|
||||
let auctionCost: { before: Game; steps: TruthTurnKaramaShipmentStep[]; after: Game } | null = null;
|
||||
if (priorAuctionSpend) {
|
||||
const before = structuredClone(game), from = actions.length;
|
||||
game = actTruthTurnKaramaShipment(game, base.target, base.purchase, actions);
|
||||
game = settleTruthTurnKaramaShipment(game, actions);
|
||||
auctionCost = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
assert.equal(game.discard.filter(card => card.id === base.karama).length, 1);
|
||||
assert.ok(truthTurnKaramaShipmentPlayer(game, base.target).hand.some(card => card.id === base.lotCard));
|
||||
}
|
||||
const beforeAuctionClose = structuredClone(game), closeAt = actions.length;
|
||||
game = closeTruthTurnKaramaLot(game, base, actions);
|
||||
const auctionClose = { before: beforeAuctionClose, steps: structuredClone(actions.slice(closeAt)), after: structuredClone(game) };
|
||||
game = advanceTruthTurnKaramaShipment(game,
|
||||
state => state.phase === 5 && state.active === beneficiary && truthTurnKaramaShipmentClean(state), actions, true);
|
||||
const opportunity = windowCardOpportunity(game, 'karamaShipment'); assert.ok(opportunity?.effect === 'karamaShipment');
|
||||
assert.equal(opportunity.recipient, beneficiary);
|
||||
assert.equal(opportunity.source, JSON.stringify(['karamaShipment', game.turn, beneficiary, 'original']));
|
||||
assert.equal(truthTurnKaramaShipmentPlayer(game, beneficiary).shipped, false); assert.equal(game.karamaShipping, null);
|
||||
assert.deepEqual(truthTurnKaramaShipmentPlayer(game, base.asker).hand.map(card => card.id), base.truths);
|
||||
assert.equal(truthTurnKaramaShipmentPlayer(game, base.target).hand.some(card => card.id === base.karama), !priorAuctionSpend);
|
||||
assert.deepEqual(truthTurnKaramaShipmentInventory(game), base.originalCards);
|
||||
assert.deepEqual(truthTurnKaramaShipmentTraitors(game), base.originalTraitors);
|
||||
const source = opportunity.source, asker = base.asker, target = base.target, recipient = asker;
|
||||
const mixed = truthTurnKaramaShipmentGroup('and', truthTurnKaramaShipmentPlay(source), truthTurnKaramaShipmentPayment(recipient));
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnKaramaShipmentPlayer(game, target).spice };
|
||||
const grant: Action = { type: 'card', card: base.karama, mode: 'shipment', target: beneficiary };
|
||||
const shipment: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
|
||||
return { game, base, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
|
||||
beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer: base.observer,
|
||||
truths: base.truths, truth: base.truth, karama: base.karama, card: base.karama,
|
||||
beneficiary, kind, priorAuctionSpend, opportunity, source, effect: 'karamaShipment' as const,
|
||||
mixed, question, spiceFact, grant, shipment, ownGrant: { ...grant, target } satisfies Action,
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
endMovement: { type: 'endMovement' } satisfies Action,
|
||||
auctionCost, beforeAuctionClose, auctionClose, originalCards: base.originalCards,
|
||||
originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
|
||||
actualPriorAuctionCost: auctionCost?.steps.length ?? 0, actualAuctionClose: auctionClose.steps.length },
|
||||
sourceDisclosure: `${base.sourceDisclosure} Actually finish the ordinary auction using native passes, readiness and any original responses; ` +
|
||||
`stop at ${kind === 'own' ? 'Emperor' : 'Harkonnen'}'s real original unused Shipment turn. ` +
|
||||
(priorAuctionSpend ? 'Before advancing, Emperor actually spends its original printed Karama for the original free auction card; the later Shipment source still has no future printed card or receipt. '
|
||||
: 'Emperor retains its original printed Karama; Harkonnen retains both actual opening Truthtrances. ') +
|
||||
'Only original array-recorded actions are continued. A later actual printed price spend is independent of delivery; its native desert tariff halves from two to one. No hand, wallet, army, phase, shipment, price grant, cost or promise is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTurnKaramaShipmentFixture = ReturnType<typeof createTruthTurnKaramaShipmentFixture>;
|
||||
export function declareTruthTurnKaramaShipment(f: TruthTurnKaramaShipmentFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthTurnKaramaShipmentStep[] = []) {
|
||||
return declareTruthTurnKarama(f.base, source, copy, actions);
|
||||
}
|
||||
export function askTruthTurnKaramaShipment(game: Game, asker: string, target: string, mixed: TruthTurnKaramaShipmentExpression,
|
||||
actions: TruthTurnKaramaShipmentStep[] = []): Game {
|
||||
return actTruthTurnKaramaShipment(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTurnKaramaShipment(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
|
||||
actions: TruthTurnKaramaShipmentStep[] = []): Game {
|
||||
return actTruthTurnKaramaShipment(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTurnKaramaShipment(f: TruthTurnKaramaShipmentFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnKaramaShipmentStep[] = []) {
|
||||
const declared = declareTruthTurnKaramaShipment(f, source, copy, actions);
|
||||
const asked = askTruthTurnKaramaShipment(declared.game, f.asker, f.target, mixed, actions);
|
||||
return { game: answerTruthTurnKaramaShipment(asked, f.target, answer, actions), declared, asked, actions };
|
||||
}
|
||||
export function closeTruthTurnKaramaShipment(game: Game, f: TruthTurnKaramaShipmentFixture,
|
||||
actions: TruthTurnKaramaShipmentStep[] = []): Game {
|
||||
return advanceTruthTurnKaramaShipment(game, state => truthTurnKaramaShipmentClean(state) &&
|
||||
!windowCardOpportunity(state, 'karamaShipment', f.source), actions, true);
|
||||
}
|
||||
@@ -32,8 +32,10 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
|
||||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
|
||||
...(cunning ? ['cunning', cunning] : ['original'])]);
|
||||
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
|
||||
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : 'storm:2'): Expression =>
|
||||
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource() : 'storm:2'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
|
||||
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||||
@@ -113,6 +115,15 @@ function auctionKarama(invoice = false): GameView {
|
||||
if (!invoice) { game.auction!.active = 'a'; game.active = 'a'; }
|
||||
return game;
|
||||
}
|
||||
function shipmentKarama(recipient = 'p', cunning?: string): GameView {
|
||||
const game = projected(5, { type: 'card', card: 'controlled-printed-karama', mode: 'shipment', target: recipient });
|
||||
game.active = recipient; game.movementRemaining = ['p', 'a', 'r']; game.karamaShipping = null;
|
||||
game.karamaShipmentWindows = game.movementRemaining.map(player => ({ effect: 'karamaShipment' as const,
|
||||
turn: 2, recipient: player, source: shipmentPriceSource(player, player === recipient ? cunning : undefined) }));
|
||||
game.karamaShipmentCards = ['controlled-printed-karama'];
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaShipment', shipmentPriceSource(recipient, cunning)), payment()] };
|
||||
return game;
|
||||
}
|
||||
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
|
||||
const game = projected(1, next);
|
||||
game.nexus = true; game.spiceWindow = null; game.active = 'a';
|
||||
@@ -919,6 +930,96 @@ void test('current ordinary-lot printed-role questions retain quoted owned Box r
|
||||
}
|
||||
});
|
||||
|
||||
void test('shipment-price picker names the current public beneficiary without reading card or native funding quotes', () => {
|
||||
const game = shipmentKarama('a'), e = editor(physical(), false, game);
|
||||
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors', 'reserves'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private shipment-price authoring ${key} read`); }, configurable: true });
|
||||
Object.defineProperty(game, 'karamaShipmentCards', { get() { throw Error('The public picker must not inspect the giver’s native card quote'); } });
|
||||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('karamaShipment', shipmentPriceSource('a')));
|
||||
assert.equal(e.error(), null); assert.match(e.html(), /Asker’s current native shipment price/);
|
||||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||||
select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.ok(e.error(), 'An inactive entitlement is not a new public picker option.');
|
||||
select(e.tree(), '1', 'window-source', shipmentPriceSource('a')); assert.equal(e.error(), null);
|
||||
game.active = 'p'; assert.ok(e.error()); select(e.tree(), '1', 'window-source', shipmentPriceSource('p')); assert.equal(e.error(), null);
|
||||
const cunning = shipmentPriceSource('p', 'controlled-cunning-window');
|
||||
game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === 'p' ? { ...window, source: cunning } : window);
|
||||
assert.ok(e.error()); select(e.tree(), '1', 'window-source', cunning); assert.equal(e.error(), null);
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaShipment', source: cunning, target: 'p' } }, game, 'p'));
|
||||
const fullGame = shipmentKarama(), full = editor({ kind: 'or', terms: [windowClaim('karamaShipment'), fact()] }, false, fullGame);
|
||||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('karamaShipment')] }, false, fullGame);
|
||||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
});
|
||||
|
||||
void test('printed shipment-price guides forward exact own or foreign-giver grants and explain the distinct actual shipment', () => {
|
||||
for (const recipient of ['p', 'a']) for (const cunning of [undefined, 'controlled-cunning-window']) {
|
||||
const game = shipmentKarama(recipient, cunning), sent: Action[] = [];
|
||||
for (const key of ['windowCards', 'compiled', 'preparation'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} shipment-price read`); }, configurable: true });
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
assert.equal(sent[0].type, 'card'); assert.equal(sent[0].mode, 'shipment'); assert.equal(sent[0].target, recipient);
|
||||
assert.equal(sent[0].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, 5);
|
||||
assert.match(renderToStaticMarkup(button), /grant .* this native shipment price/);
|
||||
game.turnActionGuide = null;
|
||||
const html = markup(game); assert.match(html, /price grant counts separately from an actual force shipment/);
|
||||
assert.match(html, /does not require the recipient to ship forces/); assert.match(html, /original shipment-price opportunity ends/);
|
||||
assert.doesNotMatch(html, /windowCards|compiled|controlled-printed-karama/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('saved shipment-price guides expire with the active source, target, quote, physical card and grant state', () => {
|
||||
for (const recipient of ['p', 'a']) for (const change of [
|
||||
(game: GameView) => { game.active = recipient === 'p' ? 'a' : 'p'; },
|
||||
(game: GameView) => { game.karamaShipmentWindows = game.karamaShipmentWindows.map(window => window.recipient === recipient
|
||||
? { ...window, source: shipmentPriceSource(recipient, 'later-cunning-window') } : window); },
|
||||
(game: GameView) => { game.turnActionGuide!.action.target = recipient === 'p' ? 'a' : 'p'; },
|
||||
(game: GameView) => { game.karamaShipmentCards = []; },
|
||||
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.kind = 'worthless'; },
|
||||
(game: GameView) => { game.karamaShipping = { player: recipient, owner: 'p' }; },
|
||||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('karamaShipment', shipmentPriceSource('r')); },
|
||||
]) {
|
||||
const game = shipmentKarama(recipient), saved = guide({ game, busy: false, act() { assert.fail('A stale shipment-price grant cannot send'); } });
|
||||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||||
}
|
||||
const game = shipmentKarama('a'), props: Props = { game, busy: false, act() { assert.fail('A replaced grant callback cannot send'); } };
|
||||
const saved = guide(props); props.act = () => assert.fail('A replaced callback cannot receive an old grant'); press(saved);
|
||||
});
|
||||
|
||||
void test('current printed shipment roles reject forged grants and inactive targets without blocking older native preparation guides', () => {
|
||||
for (const extra of [{ target: 'r' }, { target: undefined }, { amount: 3 }, { leader: 'foreign-leader' }, { mode: 'purchase' }]) {
|
||||
const game = shipmentKarama(); Object.assign(game.turnActionGuide!.action, extra);
|
||||
const button = guide({ game, busy: false, act() { assert.fail('An unsupported shipment-price packet cannot send'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const converted = shipmentKarama(), card = converted.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card);
|
||||
card.kind = 'worthless'; delete card.effect;
|
||||
assert.equal(guide({ game: converted, busy: false, act() { assert.fail('Worthless conversion is not printed shipment-role use'); } }).props.disabled, true);
|
||||
const unquoted = shipmentKarama(); unquoted.karamaShipmentCards = [];
|
||||
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('An absent native shipment card quote cannot be bypassed'); } }).props.disabled, true);
|
||||
const inactive = shipmentKarama('a'); inactive.active = 'p';
|
||||
assert.equal(guide({ game: inactive, busy: false, act() { assert.fail('An inactive beneficiary cannot receive this grant control'); } }).props.disabled, true);
|
||||
for (const mixed of [payment(), windowClaim('karamaShipment', shipmentPriceSource('a'))]) {
|
||||
const legacy = shipmentKarama(); legacy.turnActionPromises[0].mixed = mixed; legacy.karamaShipmentCards = [];
|
||||
const sent: Action[] = [], button = guide({ game: legacy, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], legacy.turnActionGuide!.action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a current shipment-price question retains a quoted Box route for an owned or foreign giver', () => {
|
||||
for (const recipient of ['p', 'a']) {
|
||||
const game = shipmentKarama(recipient), me = game.players.find(player => player.id === 'p')!;
|
||||
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
|
||||
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
|
||||
game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
game.nullentropy.blocked = 'The native Box is no longer available.'; press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { createTruthBidFixture, declareTruthBid, actTruthBid as act, advanceTruthBid as advance,
|
||||
truthBidClean as clean, truthBidPlayer as player, truthBidInventory as inventory } from './fixture-truth-bid';
|
||||
import { truthTraitorDecisionTraitors as traitors } from './fixture-truth-traitor-decision';
|
||||
import { askTruthTurnKaramaShipment as ask, truthTurnKaramaShipmentGroup as group,
|
||||
truthTurnKaramaShipmentPlay as play, truthTurnKaramaShipmentPayment as payment } from './fixture-truth-turn-karama-shipment';
|
||||
|
||||
/** Original unopened H2/A1/E1/G1 deal, genuine remaining auction/Revival turns,
|
||||
* and actual pre-answer bribe. No played purse, response or promise is installed. */
|
||||
function source(remaining: 2 | 4) {
|
||||
const f = createTruthBidFixture({ advanced: true, extraGuild: true, targetKarama: true, window: 'active' });
|
||||
const actions = structuredClone(f.sourceActions);
|
||||
let game = advance(f.game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 10 - remaining }, actions);
|
||||
assert.equal(player(game, f.target).spice, remaining);
|
||||
assert.equal(traitors(game).length, 20); assert.deepEqual(inventory(game), f.originalCards);
|
||||
const window = windowCardOpportunity(game, 'karamaShipment'); assert.ok(window);
|
||||
const declared = declareTruthBid(f, game); actions.push(...declared.actions);
|
||||
game = ask(declared.game, f.asker, f.target, group('or', play(window.source), payment(f.asker)), actions);
|
||||
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
|
||||
return { f, game, actions, window };
|
||||
}
|
||||
|
||||
void test('an own shipment cannot spend its remaining payment alternative before a foreign Guild stop decision', () => {
|
||||
const { f, game, actions } = source(2), before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0,
|
||||
amount: 1, elite: 0, allyPayment: 0 }, actions), /Truthtrance|completion/);
|
||||
assert.deepEqual(game, before);
|
||||
let paid = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
|
||||
assert.equal(paid.turnActionPromises![0].fulfilled, true); assert.equal(player(paid, f.target).spice, 0);
|
||||
paid = act(paid, f.target, { type: 'endMovement' }, actions);
|
||||
assert.equal(paid.discard.filter(card => card.id === f.karama).length, 0);
|
||||
assert.deepEqual(inventory(paid), f.originalCards); assert.equal(traitors(paid).length, 20);
|
||||
});
|
||||
|
||||
void test('a funded payment alternative survives actual unpriced delivery and Guild income before being paid', () => {
|
||||
const { f, actions, game: initial } = source(4);
|
||||
let game = initial;
|
||||
game = act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0,
|
||||
amount: 1, elite: 0, allyPayment: 0 }, actions);
|
||||
assert.equal(game.decision?.kind, 'guildShipment');
|
||||
assert.equal(game.decision?.player, 'guild'); assert.equal(player(game, f.target).spice, 4);
|
||||
game = advance(game, clean, actions);
|
||||
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.equal(game.turnActionPromises![0].released, false);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(game.turnActionPromises![0].fulfilled, true);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.equal(traitors(game).length, 20);
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { truthFactAnswer } from '../game/truthtrance';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import {
|
||||
actTruthTurnKaramaShipment as act, advanceTruthTurnKaramaShipment as advance,
|
||||
answerTruthTurnKaramaShipment as answer, askTruthTurnKaramaShipment as ask,
|
||||
bindTruthTurnKaramaShipment as bind, closeTruthTurnKaramaShipment as close,
|
||||
createTruthTurnKaramaShipmentFixture as fixture, declareTruthTurnKaramaShipment as declare,
|
||||
finishTruthTurnKaramaShipment as finish, nextTruthTurnKaramaShipmentNative as nativeStep,
|
||||
settleTruthTurnKaramaShipment as settle, truthTurnKaramaShipmentClean as clean,
|
||||
truthTurnKaramaShipmentFact as fact, truthTurnKaramaShipmentGroup as group,
|
||||
truthTurnKaramaShipmentInventory as inventory, truthTurnKaramaShipmentPayment as payment,
|
||||
truthTurnKaramaShipmentPlay as play, truthTurnKaramaShipmentPlayer as player,
|
||||
truthTurnKaramaShipmentPromise as promise, truthTurnKaramaShipmentShip as ship,
|
||||
truthTurnKaramaShipmentTraitors as traitors,
|
||||
type TruthTurnKaramaShipmentFixture, type TruthTurnKaramaShipmentStep,
|
||||
} from './fixture-truth-turn-karama-shipment';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function physical(game: Game, f: TruthTurnKaramaShipmentFixture, copies: 1 | 2 = 1) {
|
||||
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
}
|
||||
function receipt(game: Game, index = 0) {
|
||||
const stage = game.turnActionPromises![index].windowCards?.karamaShipment; assert.ok(stage); return stage;
|
||||
}
|
||||
const armies = (game: Game) => game.players.map(candidate => ({ id: candidate.id, forces: candidate.forces,
|
||||
reserves: candidate.reserves, tanks: candidate.tanks, elites: candidate.elites }));
|
||||
|
||||
void test('shipment-price sources keep original dealt Truths and Karama through actual all-pass and Movement prefixes', () => {
|
||||
for (const advanced of [false, true]) for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ advanced, beneficiary }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15);
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.karama]);
|
||||
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.beneficiary); assert.equal(f.game.advanced, advanced);
|
||||
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.beneficiary).shipped, false);
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.inherited, f.base.sourceActionCount);
|
||||
assert.equal(f.sourceCounts.nativeContinuation, f.sourceActions.length - f.base.sourceActionCount);
|
||||
assert.equal(f.sourceCounts.actualAuctionClose, f.auctionClose.steps.length);
|
||||
assert.ok(f.sourceActions.some(step => step.actor === f.target && step.action.type === 'passBid'));
|
||||
assert.deepEqual(windowCardOpportunity(f.game, 'karamaShipment'), f.opportunity);
|
||||
assert.equal(f.source, JSON.stringify(['karamaShipment', 1, f.beneficiary, 'original']));
|
||||
const own = viewGame(f.game, f.target); assert.deepEqual(own.karamaShipmentCards, [f.karama]);
|
||||
assert.ok(own.karamaShipmentWindows.some(window => window.source === f.source && window.recipient === f.beneficiary));
|
||||
assert.deepEqual(viewGame(f.game, f.asker).karamaShipmentCards, []);
|
||||
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual self or foreign printed price grant fulfills independently of spice, movement and delivery', () => {
|
||||
for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ beneficiary }); let game = bind(f, 'yes', play(f.source)).game;
|
||||
const beforeArmies = structuredClone(armies(game)), purses = game.players.map(candidate => candidate.spice);
|
||||
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
|
||||
game = settle(act(game, f.target, f.grant));
|
||||
assert.equal(game.karamaShipping?.owner, f.target); assert.equal(game.karamaShipping?.player, f.beneficiary);
|
||||
assert.equal(game.karamaShipping?.card, f.karama); assert.equal(receipt(game).played, true);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(armies(game), beforeArmies);
|
||||
assert.deepEqual(game.players.map(candidate => candidate.spice), purses);
|
||||
assert.equal(player(game, f.beneficiary).shipped, false); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
game = act(game, f.beneficiary, f.endMovement);
|
||||
assert.equal(game.karamaShipping, null); assert.deepEqual(armies(game), beforeArmies);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real own and foreign shipments pay the native half-price bank tariff after the printed cost', () => {
|
||||
for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ beneficiary }); let game = bind(f, 'yes', play(f.source)).game;
|
||||
game = settle(act(game, f.target, f.grant)); const purse = player(game, f.beneficiary).spice;
|
||||
const reserves = player(game, f.beneficiary).reserves;
|
||||
game = settle(act(game, f.beneficiary, f.shipment));
|
||||
assert.equal(player(game, f.beneficiary).spice, purse - 1);
|
||||
assert.equal(player(game, f.beneficiary).reserves, reserves - 1);
|
||||
assert.equal(player(game, f.beneficiary).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(game.karamaShipping, null);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('printed price AND actual shipment AND payment share the exact funded purse while facts freeze before payment', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.spiceFact), play(f.source), ship(), payment(f.recipient, 9));
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 9 });
|
||||
assert.equal(player(game, f.target).spice, 1); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
game = settle(act(game, f.target, f.grant)); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.shipment));
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 9);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(receipt(game).played, true);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('printed shipment funding reserves the half-price fee from the actual beneficiary rather than a foreign card giver', () => {
|
||||
const own = fixture(), unaffordable = group('and', play(own.source), ship(), payment(own.recipient, 10));
|
||||
const asked = ask(declare(own).game, own.asker, own.target, unaffordable);
|
||||
assert.deepEqual(viewGame(asked, own.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, own.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const foreign = fixture({ beneficiary: 'foreign' });
|
||||
const mixed = group('and', play(foreign.source), payment(foreign.recipient, 10));
|
||||
let game = bind(foreign, 'yes', mixed).game;
|
||||
const beneficiaryPurse = player(game, foreign.beneficiary).spice;
|
||||
game = act(game, foreign.target, { type: 'bribe', target: foreign.recipient, amount: 10 });
|
||||
assert.equal(player(game, foreign.target).spice, 0);
|
||||
assert.equal(player(game, foreign.beneficiary).spice, beneficiaryPurse, 'The actual bribe receipt remains deferred');
|
||||
game = settle(act(game, foreign.target, foreign.grant));
|
||||
assert.equal(promise(game, foreign.target).fulfilled, true); assert.ok(!promise(game, foreign.target).released);
|
||||
game = settle(act(game, foreign.beneficiary, foreign.shipment));
|
||||
assert.equal(player(game, foreign.target).spice, 0);
|
||||
assert.equal(player(game, foreign.beneficiary).spice, beneficiaryPurse - 1);
|
||||
assert.equal(player(game, foreign.beneficiary).forces['polar_sink:0'], 1);
|
||||
assert.equal(game.discard.filter(card => card.id === foreign.karama).length, 1);
|
||||
assert.equal(promise(game, foreign.target).fulfilled, true); assert.ok(!promise(game, foreign.target).released);
|
||||
physical(game, foreign);
|
||||
});
|
||||
|
||||
void test('whole price OR payment Yes preserves the manual grant route and payment route for either current beneficiary', () => {
|
||||
for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ beneficiary }), mixed = group('or', play(f.source), payment(f.recipient));
|
||||
const bound = bind(f, 'yes', mixed).game;
|
||||
const granted = settle(act(bound, f.target, f.grant));
|
||||
assert.equal(promise(granted, f.target).fulfilled, true); assert.equal(player(granted, f.recipient).bribes, 0);
|
||||
let paid = act(bound, f.target, f.payment); paid = act(paid, f.beneficiary, f.endMovement);
|
||||
assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(receipt(paid).played, false);
|
||||
assert.ok(player(paid, f.target).hand.some(card => card.id === f.karama)); physical(granted, f); physical(paid, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole price AND payment No preserves either earlier action and rejects the real completing grant or payment', () => {
|
||||
const f = fixture();
|
||||
for (const first of ['payment', 'grant'] as const) {
|
||||
let game = bind(f, 'no').game;
|
||||
if (first === 'payment') {
|
||||
game = act(game, f.target, f.payment); rejected(game, f.target, f.grant);
|
||||
} else {
|
||||
game = settle(act(game, f.target, f.grant)); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
}
|
||||
assert.ok(!promise(game, f.target).released); game = finish(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole price OR payment No keeps the actual cumulative payment cap after the beneficiary closes the source', () => {
|
||||
for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ beneficiary }), mixed = group('or', play(f.source), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.grant); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
|
||||
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
assert.ok(!promise(game, f.target).released); game = finish(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('own shipment or Movement end cannot evade a required printed price while the original path remains feasible', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', play(f.source)).game;
|
||||
rejected(game, f.target, f.shipment); rejected(game, f.target, f.endMovement);
|
||||
assert.equal(player(game, f.target).spice, 10); assert.equal(player(game, f.target).shipped, false);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
|
||||
game = settle(act(game, f.target, f.grant)); game = act(game, f.target, f.endMovement);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('whole No price AND shipment checks the prospective physical shipment before closing its price source', () => {
|
||||
const f = fixture(), mixed = group('and', play(f.source), ship());
|
||||
let granted = bind(f, 'no', mixed).game; granted = settle(act(granted, f.target, f.grant));
|
||||
rejected(granted, f.target, f.shipment); assert.equal(player(granted, f.target).spice, 10);
|
||||
assert.equal(player(granted, f.target).forces['polar_sink:0'] ?? 0, 0);
|
||||
granted = act(granted, f.target, f.endMovement); granted = finish(granted);
|
||||
assert.equal(promise(granted, f.target).fulfilled, true); assert.equal(receipt(granted).played, true); physical(granted, f);
|
||||
let shipped = bind(f, 'no', mixed).game; shipped = settle(act(shipped, f.target, f.shipment));
|
||||
assert.equal(player(shipped, f.target).spice, 8); assert.equal(receipt(shipped).played, false);
|
||||
assert.ok(player(shipped, f.target).hand.some(card => card.id === f.karama));
|
||||
shipped = finish(shipped); assert.equal(promise(shipped, f.target).fulfilled, true); physical(shipped, f);
|
||||
});
|
||||
|
||||
void test('foreign shipment or end stays legal and releases only that source before a second genuine own-beneficiary question', () => {
|
||||
const f = fixture({ beneficiary: 'foreign' });
|
||||
for (const action of [f.shipment, f.endMovement]) {
|
||||
const actions: TruthTurnKaramaShipmentStep[] = [];
|
||||
let game = bind(f, 'yes', play(f.source), f.game, 0, actions).game;
|
||||
game = settle(act(game, f.beneficiary, action, actions), actions);
|
||||
assert.equal(promise(game, f.target).released, true); assert.equal(receipt(game).played, false);
|
||||
assert.equal(receipt(game).closed, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.karama));
|
||||
const earlier = structuredClone(receipt(game));
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions, true);
|
||||
const own = windowCardOpportunity(game, 'karamaShipment'); assert.ok(own?.effect === 'karamaShipment');
|
||||
assert.equal(own.recipient, f.target); assert.notEqual(own.source, f.source);
|
||||
game = bind(f, 'yes', play(own.source), game, 1, actions).game;
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.target, f.target); assert.equal(guide.event, game.turnActionPromises![1].event);
|
||||
game = settle(act(game, f.target, guide.action, actions), actions);
|
||||
assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
|
||||
assert.deepEqual(receipt(game), earlier); assert.equal(game.turnActionPromises![0].released, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); physical(game, f, 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a foreign closed price OR remains payable late even after the same actual Karama prices the own shipment instead', () => {
|
||||
const f = fixture({ beneficiary: 'foreign' }), mixed = group('or', play(f.source), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = act(game, f.beneficiary, f.endMovement);
|
||||
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
|
||||
assert.ok(!promise(game, f.target).released); const evidence = structuredClone(receipt(game));
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
|
||||
game = settle(act(game, f.target, f.ownGrant)); game = settle(act(game, f.target, f.shipment));
|
||||
assert.equal(player(game, f.target).spice, 9); assert.deepEqual(receipt(game), evidence);
|
||||
assert.ok(!promise(game, f.target).fulfilled); game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game), evidence); physical(game, f);
|
||||
});
|
||||
|
||||
void test('a real prior auction cost never counts as future printed Karama shipment spending', () => {
|
||||
const f = fixture({ priorAuctionSpend: true }); assert.ok(f.auctionCost);
|
||||
assert.equal(f.sourceCounts.actualPriorAuctionCost, f.auctionCost.steps.length);
|
||||
assert.ok(f.auctionCost.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.mode === 'purchase'));
|
||||
assert.equal(f.game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.base.lotCard));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, play(f.source));
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'no'); assert.equal(receipt(game).played, false);
|
||||
game = close(game, f); assert.equal(receipt(game).played, false); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
physical(game, f);
|
||||
});
|
||||
|
||||
void test('printed shipment guides carry exact beneficiary source and event while cards remain viewer-owned', () => {
|
||||
for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ beneficiary }), declared = declare(f);
|
||||
for (const source of [JSON.stringify(['karamaShipment', f.game.turn + 1, f.beneficiary, 'original']),
|
||||
JSON.stringify(['karamaShipment', f.game.turn, 'absent-beneficiary', 'original']),
|
||||
JSON.stringify(['karamaShipment', f.game.turn, f.beneficiary, 'cunning', 'invented-event'])])
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(source) } });
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaShipment', source: f.source, recipient: f.beneficiary,
|
||||
} } } });
|
||||
let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||
const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama);
|
||||
assert.equal(guide.action.mode, 'shipment'); assert.equal(guide.action.target, f.beneficiary);
|
||||
assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, 5); assert.deepEqual(own.karamaShipmentCards, [f.karama]);
|
||||
assert.deepEqual(viewGame(game, f.asker).karamaShipmentCards, []);
|
||||
assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
|
||||
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, target: f.observer });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-price-event' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 3 });
|
||||
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
|
||||
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles execute native printed shipment-price guides for own and foreign beneficiaries', () => {
|
||||
for (const beneficiary of ['own', 'foreign'] as const) {
|
||||
const f = fixture({ beneficiary }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTurnKaramaShipmentStep[] = [];
|
||||
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
|
||||
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
||||
for (let count = 0; count < 20 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = view.turnActionGuide;
|
||||
if (guide) {
|
||||
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
|
||||
assert.ok(action, `${beneficiary}/${difficulty}`); assert.deepEqual(action, guide.action);
|
||||
game = act(game, f.target, action, actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next, `${beneficiary}/${difficulty}`);
|
||||
game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, `${beneficiary}/${difficulty}`);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.karama &&
|
||||
step.action.mode === 'shipment' && step.action.target === f.beneficiary));
|
||||
game = settle(game); physical(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -59,7 +59,8 @@ const call = (call = true, battle = 'battle:1'): Expression =>
|
||||
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
|
||||
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
|
||||
@@ -1466,3 +1467,56 @@ void test('closing the auction role preserves a later payment alternative and fu
|
||||
assert.throws(() => turnActionValue(windowed(group('or', windowCard('karamaAuction'),
|
||||
windowCard('karamaAuction', 'ordinary-auction:1:2')))), TurnActionError);
|
||||
});
|
||||
|
||||
void test('shipment-price receipt records the actual printed grant without requiring use and keeps whole transport correlation', () => {
|
||||
const source = windowSource('karamaShipment');
|
||||
const grantOnly = windowed(windowCard('karamaShipment'));
|
||||
const joint = windowed(group('and', windowCard('karamaShipment'), travel('ship'), pay()), true, 'turn-action:2');
|
||||
const granted = recordTurnActionWindowCardPlay([grantOnly, joint], 'respondent', 1, 'karamaShipment', source);
|
||||
assert.equal(granted[0].fulfilled, true, 'The accepted price grant counts even if its beneficiary never ships');
|
||||
assert.deepEqual(branches(granted[1]), [[signedTravel('ship'), signedPay()]]);
|
||||
const shipped = recordTurnActionShipment(granted, 'respondent', 1, event());
|
||||
assert.deepEqual(branches(shipped[1]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(shipped, 'respondent', 1, payment())[1].fulfilled, true);
|
||||
assert.equal(joint.windowCards!.karamaShipment!.played, false);
|
||||
validateTurnActionPromises(granted, seats);
|
||||
});
|
||||
|
||||
void test('shipment-price No remains attached to its original recipient while another recipient has a compatible live interval', () => {
|
||||
const first = windowSource('karamaShipment');
|
||||
const later = JSON.stringify(['karamaShipment', 1, 'other', 'original']);
|
||||
const no = windowed(windowCard('karamaShipment', first), false);
|
||||
const yes = windowed(group('or', windowCard('karamaShipment', later), pay()), true, 'turn-action:2');
|
||||
const granted = recordTurnActionWindowCardPlay([no, yes], 'respondent', 1, 'karamaShipment', later);
|
||||
assert.equal(granted[0], no);
|
||||
assert.equal(granted[1].fulfilled, true);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', first), TurnActionError);
|
||||
assert.equal(no.windowCards!.karamaShipment!.played, false);
|
||||
const cunning = JSON.stringify(['karamaShipment', 1, 'recipient', 'cunning', 'cunning:1']);
|
||||
assert.equal(recordTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', cunning)[0], no);
|
||||
assert.equal(closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', later)[0], no);
|
||||
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', first);
|
||||
assert.equal(closed[0].fulfilled, true);
|
||||
assert.equal(closed[1], granted[1]);
|
||||
validateTurnActionPromises(closed, seats);
|
||||
});
|
||||
|
||||
void test('closing a shipment-price opportunity retains actual play and later whole payment alternatives through turn expiry', () => {
|
||||
const source = windowSource('karamaShipment');
|
||||
const yes = windowed(group('and', windowCard('karamaShipment'), pay()));
|
||||
const no = windowed(group('and', windowCard('karamaShipment'), pay()), false, 'turn-action:2');
|
||||
const granted = recordTurnActionWindowCardPlay([yes, no], 'respondent', 1, 'karamaShipment', source);
|
||||
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'karamaShipment', source);
|
||||
assert.deepEqual(closed[0].windowCards!.karamaShipment, { source, played: true, closed: true });
|
||||
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
|
||||
assert.equal(closed[1].fulfilled, false);
|
||||
assert.throws(() => recordTurnActionPayment(closed, 'respondent', 1, payment()), TurnActionError);
|
||||
assert.equal(recordTurnActionPayment([closed[0]], 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const alternative = windowed(group('or', windowCard('karamaShipment'), pay()), true, 'turn-action:3');
|
||||
const absent = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'karamaShipment', source);
|
||||
assert.deepEqual(branches(absent[0]), [[signedPay()]]);
|
||||
const expired = finishTurnActionTurn([no], 'respondent', 1)[0];
|
||||
assert.equal(expired.fulfilled, true);
|
||||
assert.deepEqual(expired.windowCards!.karamaShipment, { source, played: false, closed: true });
|
||||
validateTurnActionPromises([expired], seats);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, karamaShipmentOpportunities, parseKaramaShipmentSource, windowCardNativeEffect, windowCardOpportunity,
|
||||
type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
function storm(): WindowCardGame {
|
||||
return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false };
|
||||
@@ -14,10 +15,133 @@ function auction(): WindowCardGame {
|
||||
function publicAuction(): WindowCardGame {
|
||||
return { ...storm(), phase: 3, auction: { bid: 4, bidder: 'respondent', remaining: 2 }, bidPromiseLot: { turn: 3, lot: 1 } };
|
||||
}
|
||||
const shipmentSource = (recipient: string, turn = 3) => JSON.stringify(['karamaShipment', turn, recipient, 'original']);
|
||||
function shipments(): WindowCardGame {
|
||||
return { ...storm(), phase: 5, active: 'first', movementRemaining: ['first', 'later', 'done'],
|
||||
players: [{ id: 'first', shipped: false }, { id: 'later', shipped: false }, { id: 'done', shipped: true }],
|
||||
karamaShipping: null };
|
||||
}
|
||||
|
||||
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama']);
|
||||
});
|
||||
|
||||
void test('shipment-price windows retain every unused original entitlement while new questions select only the active recipient', () => {
|
||||
const source = shipments(), before = structuredClone(source);
|
||||
assert.deepEqual(karamaShipmentOpportunities(source), [
|
||||
{ effect: 'karamaShipment', turn: 3, source: shipmentSource('first'), recipient: 'first' },
|
||||
{ effect: 'karamaShipment', turn: 3, source: shipmentSource('later'), recipient: 'later' },
|
||||
]);
|
||||
assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('first'));
|
||||
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later'));
|
||||
assert.equal(windowCardOpportunity({ ...source, active: 'later' }, 'karamaShipment', shipmentSource('first'))!.source,
|
||||
shipmentSource('first'));
|
||||
assert.deepEqual(source, before);
|
||||
});
|
||||
|
||||
void test('shipment source decoding identifies original and Cunning beneficiaries even after a grant or shipment closes them', () => {
|
||||
assert.deepEqual(parseKaramaShipmentSource(shipmentSource('first')), { turn: 3, recipient: 'first', kind: 'original' });
|
||||
const source = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']);
|
||||
assert.deepEqual(parseKaramaShipmentSource(source), { turn: 3, recipient: 'first', kind: 'cunning', event: 'cunning:1' });
|
||||
for (const invalid of [undefined, null, [], '{}', `${source} `,
|
||||
JSON.stringify(['karamaShipment', 0, 'first', 'original']),
|
||||
JSON.stringify(['karamaShipment', 3.5, 'first', 'original']),
|
||||
JSON.stringify(['karamaShipment', Number.MAX_SAFE_INTEGER + 1, 'first', 'original']),
|
||||
JSON.stringify(['karamaShipment', 3, ' first', 'original']),
|
||||
JSON.stringify(['karamaShipment', 3, 'first', 'cunning']),
|
||||
JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']),
|
||||
JSON.stringify(['karamaShipment', 3, 'first', 'original', 'extra'])])
|
||||
assert.equal(parseKaramaShipmentSource(invalid), null);
|
||||
assert.equal(windowCardOpportunity({ ...shipments(), karamaShipping: { player: 'first' } }, 'karamaShipment',
|
||||
shipmentSource('first')), null);
|
||||
assert.equal(parseKaramaShipmentSource(shipmentSource('first'))!.recipient, 'first');
|
||||
});
|
||||
|
||||
void test('a shipment-price grant closes only its beneficiary and actual shipment or turn completion closes that original source', () => {
|
||||
const source = shipments();
|
||||
const granted = { ...source, karamaShipping: { player: 'first' } };
|
||||
assert.equal(windowCardOpportunity(granted, 'karamaShipment'), null);
|
||||
assert.deepEqual(karamaShipmentOpportunities(granted).map(window => window.recipient), ['later']);
|
||||
const shipped = { ...source, players: source.players!.map(player => ({ ...player, shipped: true })) };
|
||||
assert.deepEqual(karamaShipmentOpportunities(shipped), []);
|
||||
const finished = { ...source, active: 'later', movementRemaining: ['later', 'done'] };
|
||||
assert.equal(windowCardOpportunity(finished, 'karamaShipment', shipmentSource('first')), null);
|
||||
assert.equal(windowCardOpportunity({ ...source, phase: 6 }, 'karamaShipment', shipmentSource('first')), null);
|
||||
assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'karamaShipment', shipmentSource('first')), null);
|
||||
});
|
||||
|
||||
void test('temporary native modal priority preserves original shipment entitlements without offering an inactive question', () => {
|
||||
for (const modal of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }]) {
|
||||
const source = { ...shipments(), active: 'later', ...modal };
|
||||
assert.equal(windowCardOpportunity(source, 'karamaShipment')!.source, shipmentSource('later'));
|
||||
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('first'))!.source, shipmentSource('first'));
|
||||
}
|
||||
assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:3')!.source, 'storm:3');
|
||||
assert.equal(windowCardOpportunity(storm(), 'weather', 'storm:4'), null);
|
||||
assert.equal(windowCardOpportunity(auction(), 'karamaAuction', 'ordinary-auction:3:2'), null);
|
||||
});
|
||||
|
||||
void test('current Cunning second shipment has its own event source and never reopens an unused original shipment', () => {
|
||||
const source = shipments(), receipt = { turn: 3, owner: 'first', event: 'cunning:1' };
|
||||
const second = { ...source, nexusGuildCunningHistory: [{ receipt, stage: 'secondShipment' }] };
|
||||
const expected = JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']);
|
||||
assert.equal(windowCardOpportunity(second, 'karamaShipment')!.source, expected);
|
||||
const projected = { ...source, players: source.players!.map(({ id }) => ({ id })),
|
||||
karamaShipmentWindows: karamaShipmentOpportunities(second) };
|
||||
assert.equal(windowCardOpportunity(projected, 'karamaShipment')!.source, expected);
|
||||
assert.equal(windowCardOpportunity(second, 'karamaShipment', shipmentSource('first')), null);
|
||||
assert.equal(windowCardOpportunity({ ...second, active: 'later' }, 'karamaShipment', expected), null);
|
||||
assert.equal(windowCardOpportunity({ ...second, karamaShipping: { player: 'first' } }, 'karamaShipment', expected), null);
|
||||
for (const stage of ['pending', 'extraMove', 'completed', 'canceled']) {
|
||||
const closed = { ...source, nexusGuildCunningHistory: [{ receipt, stage }] };
|
||||
assert.equal(windowCardOpportunity(closed, 'karamaShipment'), null);
|
||||
assert.equal(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('first')), null);
|
||||
assert.ok(windowCardOpportunity(closed, 'karamaShipment', shipmentSource('later')));
|
||||
}
|
||||
const old = { ...source, nexusGuildCunningHistory: [{ receipt: { ...receipt, turn: 2 }, stage: 'completed' }] };
|
||||
assert.equal(windowCardOpportunity(old, 'karamaShipment')!.source, shipmentSource('first'));
|
||||
});
|
||||
|
||||
void test('public shipment projections require no shipped history and return copies of strictly coherent source rows', () => {
|
||||
const native = shipments(), windows = karamaShipmentOpportunities(native);
|
||||
const source = { ...native, players: native.players!.map(({ id }) => ({ id })), karamaShipmentWindows: windows };
|
||||
const before = structuredClone(source), projected = karamaShipmentOpportunities(source);
|
||||
assert.deepEqual(projected, windows);
|
||||
assert.notEqual(projected, windows);
|
||||
assert.notEqual(projected[0], windows[0]);
|
||||
projected[0].recipient = 'changed-copy';
|
||||
assert.deepEqual(source, before);
|
||||
assert.equal(windowCardOpportunity(source, 'karamaShipment', shipmentSource('later'))!.source, shipmentSource('later'));
|
||||
});
|
||||
|
||||
void test('malformed public shipment windows cannot invent a recipient turn entitlement or Cunning source', () => {
|
||||
const native = shipments(), window = karamaShipmentOpportunities(native)[0];
|
||||
for (const invalid of [{ ...window, recipient: 'unseated' }, { ...window, turn: 2 }, { ...window, effect: 'karamaAuction' },
|
||||
{ ...window, source: '' }, { ...window, source: `${window.source} ` }, { ...window, source: shipmentSource('later') },
|
||||
{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning']) },
|
||||
{ ...window, source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', '']) },
|
||||
{ ...window, card: 'private-card' }])
|
||||
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: [invalid] } as WindowCardGame), []);
|
||||
for (const invalid of [[window, window], [window, { ...window,
|
||||
source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }]])
|
||||
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipmentWindows: invalid }), []);
|
||||
assert.deepEqual(karamaShipmentOpportunities({ ...native, active: 'later', karamaShipmentWindows: [{ ...window,
|
||||
source: JSON.stringify(['karamaShipment', 3, 'first', 'cunning', 'cunning:1']) }] }), []);
|
||||
assert.deepEqual(karamaShipmentOpportunities({ ...native, movementRemaining: ['later'], active: 'later',
|
||||
karamaShipmentWindows: [window] }), []);
|
||||
assert.deepEqual(karamaShipmentOpportunities({ ...native, karamaShipping: { player: 'first' },
|
||||
karamaShipmentWindows: [window] }), []);
|
||||
});
|
||||
|
||||
void test('malformed native shipment metadata cannot create a price opportunity', () => {
|
||||
const source = shipments();
|
||||
for (const invalid of [{ players: [{ id: 'first' }] }, { players: [{ id: 'first', shipped: false }, { id: 'first', shipped: false }] },
|
||||
{ movementRemaining: ['first', 'first'] }, { movementRemaining: ['missing'] }, { movementRemaining: null },
|
||||
{ active: 'missing' }, { karamaShipping: { player: 'missing' } }, { status: 'setup' }, { turn: 0 },
|
||||
{ nexusGuildCunningHistory: [{ receipt: { turn: 4, owner: 'first', event: 'cunning:1' }, stage: 'secondShipment' }] },
|
||||
{ nexusGuildCunningHistory: [{ receipt: { turn: 3, owner: 'missing', event: 'cunning:1' }, stage: 'secondShipment' }] }])
|
||||
assert.deepEqual(karamaShipmentOpportunities({ ...source, ...invalid }), []);
|
||||
});
|
||||
|
||||
void test('ordinary Karama binds the same public current-lot source from native and projected auction shapes', () => {
|
||||
|
||||
Reference in new issue
Block a user