Plan ordinary promises across one native Charity claim
This commit is contained in:
1 parent
b81823bbec
commit
38738eabac
15 files changed
+831
-96
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import { createTruthTurnAmalFixture, closeTruthTurnAmal, settleTruthTurnAmal,
|
||||
truthTurnAmalFact, truthTurnAmalGroup, truthTurnAmalPayment,
|
||||
type TruthTurnAmalExpression } from './fixture-truth-turn-amal';
|
||||
import { actTruthTurnThumper, createTruthTurnThumperFixture, nextTruthTurnThumperNative,
|
||||
truthTurnThumperClean, truthTurnThumperInventory, truthTurnThumperPlayer, truthTurnThumperSpice,
|
||||
truthTurnThumperTraitors, type TruthTurnThumperStep } from './fixture-truth-turn-thumper';
|
||||
|
||||
export type TruthCharityPaymentsStep = TruthTurnThumperStep;
|
||||
export type TruthCharityPaymentsKind = 'turnActions' | 'bribe' | 'bribeExpression' | 'mixedBribe';
|
||||
export const actTruthCharityPayments = actTruthTurnThumper;
|
||||
export const truthCharityPaymentsClean = truthTurnThumperClean;
|
||||
export const truthCharityPaymentsPlayer = truthTurnThumperPlayer;
|
||||
export const truthCharityPaymentsInventory = truthTurnThumperInventory;
|
||||
export const truthCharityPaymentsTraitors = truthTurnThumperTraitors;
|
||||
export const truthCharityPaymentsSpice = truthTurnThumperSpice;
|
||||
export const truthCharityPaymentsFact = truthTurnAmalFact;
|
||||
export const truthCharityPaymentsGroup = truthTurnAmalGroup;
|
||||
export const truthCharityPaymentsPayment = truthTurnAmalPayment;
|
||||
export const truthCharityPaymentsShip = (territory = 'polar_sink', minimum = 1): TruthTurnAmalExpression =>
|
||||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||
export const nextTruthCharityPaymentsNative = nextTruthTurnThumperNative;
|
||||
export function nextTruthCharityPayments(game: Game): TruthCharityPaymentsStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id), guide = view.turnActionGuide ?? view.bribePromiseGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextTruthCharityPaymentsNative(game);
|
||||
}
|
||||
export function advanceTruthCharityPayments(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthCharityPaymentsStep[] = [], nativeOnly = false): Game {
|
||||
for (let count = 0; count < 1900; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Charity/payment source ended before its native boundary');
|
||||
const step = nativeOnly ? nextTruthCharityPaymentsNative(game) : nextTruthCharityPayments(game);
|
||||
assert.ok(step, 'Supply the actual Truthtrance question or original private plan');
|
||||
game = actTruthCharityPayments(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Charity/payment chronology did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthCharityPayments(game: Game, actions: TruthCharityPaymentsStep[] = []): Game {
|
||||
return advanceTruthCharityPayments(game, truthCharityPaymentsClean, actions, true);
|
||||
}
|
||||
export function reachTruthCharityPaymentsClaim(game: Game, actions: TruthCharityPaymentsStep[] = []): Game {
|
||||
return advanceTruthCharityPayments(game, state => state.phase === 2 && truthCharityPaymentsClean(state), actions, true);
|
||||
}
|
||||
|
||||
/** Genuine selected-Ix original deals only. Every smaller purse is earned by
|
||||
* actual Amal; no poverty, receipt, claim marker or pass list is assigned. */
|
||||
export function createTruthCharityPaymentsFixture(options: {
|
||||
source?: 'amalOpening' | 'afterAmal' | 'thumper' | 'phase2'; advanced?: boolean;
|
||||
} = {}) {
|
||||
const sourceKind = options.source ?? 'amalOpening';
|
||||
const amal = sourceKind === 'thumper' ? null : createTruthTurnAmalFixture({ phase: 1, advanced: options.advanced });
|
||||
const thumper = sourceKind === 'thumper' ? createTruthTurnThumperFixture({ advanced: options.advanced }) : null;
|
||||
const base = amal ?? thumper!;
|
||||
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length;
|
||||
let reduction: { before: Game; steps: TruthCharityPaymentsStep[]; after: Game } | null = null;
|
||||
let spiceOrder: ReturnType<typeof orderIxSpiceCards> = [];
|
||||
if (sourceKind === 'afterAmal' || sourceKind === 'phase2') {
|
||||
assert.ok(amal); const before = structuredClone(game), from = actions.length;
|
||||
game = actTruthCharityPayments(game, base.target, amal.nativeAction, actions);
|
||||
game = settleTruthTurnAmal(game, actions); game = closeTruthTurnAmal(game, amal, actions);
|
||||
reduction = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
assert.equal(truthCharityPaymentsPlayer(game, base.target).spice, 5); assert.equal(beforeIxSpiceDraw(game), true);
|
||||
spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(base.advanced ? [{ territory: 'broken_land' }] : [])]);
|
||||
if (sourceKind === 'phase2') game = reachTruthCharityPaymentsClaim(game, actions);
|
||||
}
|
||||
const asker = base.asker, target = base.target, recipient = asker, observer = base.observer;
|
||||
const minimum = sourceKind === 'thumper' ? 11 : 6;
|
||||
const printed = sourceKind === 'amalOpening' || sourceKind === 'thumper'
|
||||
? { kind: 'action', action: { kind: 'windowCardPlay', effect: base.effect, source: base.source } } as const : null;
|
||||
const mixed = printed ? truthCharityPaymentsGroup('and', printed, truthCharityPaymentsPayment(recipient, minimum))
|
||||
: truthCharityPaymentsPayment(recipient, minimum);
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
const spiceFact = { kind: 'spice', compare: 'eq', value: truthCharityPaymentsPlayer(game, target).spice } as const;
|
||||
assert.deepEqual(truthCharityPaymentsPlayer(game, asker).hand.map(card => card.id), base.truths);
|
||||
assert.notEqual(truthCharityPaymentsPlayer(game, target).charityTurn, game.turn);
|
||||
assert.deepEqual(truthCharityPaymentsInventory(game), base.originalCards);
|
||||
assert.deepEqual(truthCharityPaymentsTraitors(game), base.originalTraitors);
|
||||
assert.deepEqual(truthCharityPaymentsSpice(game), base.originalSpice);
|
||||
return { game, base, amal, thumper, sourceKind, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup,
|
||||
beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer, advanced: base.advanced,
|
||||
truths: base.truths, truth: base.truth, card: base.card, effect: base.effect, cardSource: base.source,
|
||||
nativeAction: base.nativeAction, printed, minimum, mixed, question, spiceFact, reduction, spiceOrder,
|
||||
payment: { type: 'bribe', target: recipient, amount: minimum } satisfies Action,
|
||||
charity: { type: 'charity' } satisfies Action,
|
||||
originalCards: base.originalCards, originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
|
||||
actualAmalReduction: reduction?.steps.length ?? 0 },
|
||||
sourceDisclosure: `${base.sourceDisclosure} This adapter follows only actual printed Amal, global opening passes and original native phase controls when requested. ` +
|
||||
'The ordinary phase2 Charity remains unclaimed. Smaller purses come from the real all-seat halving, not assigned wealth. Later prepayments, one actual claim, later installments and ordinary shipment remain caller controls. All future recipients share the same physical purse and one current-turn native claim.',
|
||||
};
|
||||
}
|
||||
export type TruthCharityPaymentsFixture = ReturnType<typeof createTruthCharityPaymentsFixture>;
|
||||
export function truthCharityPaymentsQuestion(f: TruthCharityPaymentsFixture, kind: TruthCharityPaymentsKind,
|
||||
minimum = f.minimum, recipient = f.recipient): TruthQuestion {
|
||||
if (kind === 'bribe') return { kind, target: f.target, turn: f.game.turn, recipient, minimum };
|
||||
if (kind === 'bribeExpression') return { kind, target: f.target, turn: f.game.turn, claim: { kind: 'payment', recipient, minimum } };
|
||||
if (kind === 'mixedBribe') return { kind, target: f.target, turn: f.game.turn, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'fact', fact: f.spiceFact }, { kind: 'action', action: { recipient, minimum } },
|
||||
] } };
|
||||
return { kind, target: f.target, turn: f.game.turn, mixed: truthCharityPaymentsPayment(recipient, minimum) };
|
||||
}
|
||||
export function declareTruthCharityPayments(f: TruthCharityPaymentsFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthCharityPaymentsStep[] = []) {
|
||||
let game = actTruthCharityPayments(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthCharityPayments(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
|
||||
}
|
||||
export function askTruthCharityPayments(game: Game, asker: string, question: TruthQuestion, actions: TruthCharityPaymentsStep[] = []): Game {
|
||||
return actTruthCharityPayments(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
|
||||
}
|
||||
export function answerTruthCharityPayments(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthCharityPaymentsStep[] = []): Game {
|
||||
return actTruthCharityPayments(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthCharityPayments(f: TruthCharityPaymentsFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthCharityPaymentsStep[] = []) {
|
||||
const declared = declareTruthCharityPayments(f, source, copy, actions), asked = askTruthCharityPayments(declared.game, f.asker, question, actions);
|
||||
return { game: answerTruthCharityPayments(asked, f.target, answer, actions), declared, asked, actions };
|
||||
}
|
||||
export function truthCharityPaymentsPromise(game: Game, kind: TruthCharityPaymentsKind = 'turnActions', target = game.truthHistory!.at(-1)!.question.target) {
|
||||
const row = kind === 'bribe' ? game.bribePromises?.find(candidate => candidate.player === target)
|
||||
: kind === 'bribeExpression' ? game.bribeExpressionPromises?.find(candidate => candidate.player === target)
|
||||
: kind === 'mixedBribe' ? viewGame(game, target).mixedBribePromises.find(candidate => candidate.player === target)
|
||||
: viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target);
|
||||
assert.ok(row); return row;
|
||||
}
|
||||
export function finishTruthCharityPayments(game: Game, actions: TruthCharityPaymentsStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthCharityPayments(game, state => state.turn === turn + 1 && state.phase === 0 && !!state.phaseOpening &&
|
||||
!state.response && !state.decision && !state.truthtrance && !state.pendingTreacheryDiscard, actions);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { splitOrdinaryPayments } from '../game/ordinary-payment-plan';
|
||||
|
||||
void test('payment installments conserve the exact original deficits across recipients', () => {
|
||||
const payments = [{ recipient: 'harkonnen', amount: 3 }, { recipient: 'atreides', amount: 3 }];
|
||||
const original = structuredClone(payments), result = splitOrdinaryPayments(payments, 5);
|
||||
assert.deepEqual(result, { before: [payments[0], { recipient: 'atreides', amount: 2 }],
|
||||
after: [{ recipient: 'atreides', amount: 1 }], total: 6 });
|
||||
for (const payment of payments) assert.equal([...result.before, ...result.after]
|
||||
.filter(part => part.recipient === payment.recipient).reduce((sum, part) => sum + part.amount, 0), payment.amount);
|
||||
assert.deepEqual(payments, original);
|
||||
});
|
||||
|
||||
void test('no cash defers the whole payment and enough cash needs no later installment', () => {
|
||||
const payments = [{ recipient: 'emperor', amount: 6 }];
|
||||
assert.deepEqual(splitOrdinaryPayments(payments, 0), { before: [], after: payments, total: 6 });
|
||||
assert.deepEqual(splitOrdinaryPayments(payments, 6), { before: payments, after: [], total: 6 });
|
||||
assert.deepEqual(splitOrdinaryPayments([], 2), { before: [], after: [], total: 0 });
|
||||
});
|
||||
|
||||
void test('invalid or overflowing payment partitions cannot become a funding witness', () => {
|
||||
for (const available of [-1, 1.5, Infinity]) assert.throws(() => splitOrdinaryPayments([], available));
|
||||
for (const payments of [[{ recipient: ' e', amount: 1 }], [{ recipient: 'e', amount: 0 }],
|
||||
[{ recipient: 'e', amount: Number.MAX_SAFE_INTEGER }, { recipient: 'a', amount: 1 }]])
|
||||
assert.throws(() => splitOrdinaryPayments(payments, 0));
|
||||
});
|
||||
@@ -66,6 +66,14 @@ function projected(phase = 3): GameView {
|
||||
game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-bribe-event' } };
|
||||
return game;
|
||||
}
|
||||
function charityGame(amount = 2): GameView {
|
||||
const game = projected(2), me = game.players.find(player => player.id === 'p')!;
|
||||
me.spice = 2 - amount; me.charityClaimed = false; game.ready = [];
|
||||
game.charity = { ...game.charity, ordinary: amount, homeworld: 0, total: amount, amount, incomePending: false };
|
||||
game.bribeOptions = { ...game.bribeOptions, available: me.spice, targets: [] };
|
||||
game.bribePromiseGuide = { action: { type: 'charity', bribePromiseEvent: 'controlled-bribe-event' } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('bribe claim fields keep the turn fixed and accept only current recipients and positive safe whole minima', () => {
|
||||
const value: BribeClaim = { turn: 2, recipient: 'r', minimum: 3 }, changes: BribeClaim[] = [];
|
||||
@@ -130,6 +138,53 @@ void test('actual guide callbacks forward exact ordinary bribes out of turn thro
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary payment guides forward exact currently quoted Charity claims and explain later installments', () => {
|
||||
for (const amount of [1, 2]) {
|
||||
const game = charityGame(amount), sent: Action[] = [], ownCash = game.players.find(player => player.id === 'p')!.spice;
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.bribePromiseGuide!.action);
|
||||
assert.deepEqual(sent[0], { type: 'charity', bribePromiseEvent: 'controlled-bribe-event' });
|
||||
assert.equal(game.players.find(player => player.id === 'p')!.spice, ownCash, 'The UI does not install future Charity as cash.');
|
||||
const html = markup(game); assert.match(html, new RegExp(`Claim ${amount} Charity spice to continue your payments`));
|
||||
assert.match(html, /payments now, one remaining Charity claim and later installments/);
|
||||
assert.match(html, /future claim is not current spendable spice/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Charity payment callbacks reject changed native claims, quotes, cash, Ready, phase and callback ownership', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; },
|
||||
(game: GameView) => { game.charity.amount = 0; },
|
||||
(game: GameView) => { game.charity = { ...game.charity }; },
|
||||
(game: GameView) => { game.charity.incomePending = true; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; },
|
||||
(game: GameView) => { game.ready.push('p'); },
|
||||
(game: GameView) => { game.phase = 3; },
|
||||
(game: GameView) => { game.bribePromises[0].fulfilled = true; },
|
||||
(game: GameView) => { game.bribePromiseGuide!.action.bribePromiseEvent = 'different-event'; },
|
||||
]) {
|
||||
const game = charityGame(), saved = guide({ game, busy: false, send() { assert.fail('A stale Charity continuation cannot send'); } });
|
||||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||||
}
|
||||
const pending = charityGame(); pending.charity.incomePending = true;
|
||||
assert.equal(guide({ game: pending, busy: false, send() { assert.fail('CHOAM income must finish before an ordinary claim'); } }).props.disabled, true);
|
||||
for (const game of [charityGame(), projected()]) {
|
||||
const props = { game, busy: false, send() { assert.fail('A saved payment callback cannot use a replaced sender'); } };
|
||||
const saved = guide(props); props.send = () => assert.fail('A replacement sender cannot receive an old quoted action'); press(saved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Charity payment guides reject extra native parameters and unrelated nonpayment fallbacks', () => {
|
||||
for (const extra of [{ target: 'r' }, { amount: 1 }, { card: 'private-card' }, { mode: 'shipment' }]) {
|
||||
const game = charityGame(); Object.assign(game.bribePromiseGuide!.action, extra);
|
||||
const button = guide({ game, busy: false, send() { assert.fail('Charity accepts only its exact native action and event'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const unrelated = projected(2); unrelated.bribePromiseGuide!.action.type = 'bid';
|
||||
const button = guide({ game: unrelated, busy: false, send() { assert.fail('An unrelated action cannot pass the ordinary bribe path'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
});
|
||||
|
||||
void test('controlled quiet Mentat guide forwards the exact payment for an already-ready payer and explains next-turn collection', () => {
|
||||
const game = projected(8), sent: Action[] = [];
|
||||
game.auction = null; game.active = null; game.ready = ['p'];
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { charityQuote } from '../game/charity';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
|
||||
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthCharityPayments as act, advanceTruthCharityPayments as advance, answerTruthCharityPayments as answer,
|
||||
askTruthCharityPayments as ask, bindTruthCharityPayments as bind, createTruthCharityPaymentsFixture as fixture,
|
||||
declareTruthCharityPayments as declare, finishTruthCharityPayments as finish,
|
||||
nextTruthCharityPaymentsNative as nativeStep, reachTruthCharityPaymentsClaim as reachClaim,
|
||||
settleTruthCharityPayments as settle, truthCharityPaymentsClean as clean, truthCharityPaymentsFact as fact,
|
||||
truthCharityPaymentsGroup as group, truthCharityPaymentsInventory as inventory,
|
||||
truthCharityPaymentsPayment as payment, truthCharityPaymentsPlayer as player,
|
||||
truthCharityPaymentsPromise as promise, truthCharityPaymentsQuestion as question,
|
||||
truthCharityPaymentsShip as ship, truthCharityPaymentsSpice as spice, truthCharityPaymentsTraitors as traitors,
|
||||
type TruthCharityPaymentsFixture, type TruthCharityPaymentsKind, type TruthCharityPaymentsStep,
|
||||
} from './fixture-truth-charity-payments';
|
||||
|
||||
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: TruthCharityPaymentsFixture, 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);
|
||||
assert.deepEqual(spice(game), f.originalSpice);
|
||||
}
|
||||
const pay = (game: Game, f: TruthCharityPaymentsFixture, amount: number, recipient = f.recipient,
|
||||
actions: TruthCharityPaymentsStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions);
|
||||
|
||||
void test('Charity installment sources preserve native Amal reductions, openings, original cards and an unused actual claim', () => {
|
||||
for (const source of ['amalOpening', 'afterAmal', 'thumper', 'phase2'] as const) {
|
||||
const f = fixture({ source }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
|
||||
assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
|
||||
assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.equal(f.game.phase, source === 'phase2' ? 2 : 1);
|
||||
assert.equal(!!f.game.phaseOpening, source === 'amalOpening');
|
||||
assert.equal(player(f.game, f.target).spice, source === 'amalOpening' || source === 'thumper' ? 10 : 5);
|
||||
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
|
||||
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.actualAmalReduction, f.reduction?.steps.length ?? 0);
|
||||
if (f.reduction) {
|
||||
assert.equal(player(f.reduction.before, f.target).spice, 10); assert.equal(player(f.reduction.after, f.target).spice, 5);
|
||||
assert.ok(f.reduction.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
|
||||
}
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
||||
assert.deepEqual(spice(f.game), f.originalSpice);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual Amal then five paid, one native Charity claim and one later installment fulfills six with frozen facts', () => {
|
||||
const f = fixture(), raw: TruthQuestion = { ...f.question, mixed: group('and', fact(f.spiceFact), f.mixed) };
|
||||
const actions: TruthCharityPaymentsStep[] = []; let game = bind(f, 'yes', raw, f.game, 0, actions).game;
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
|
||||
game = act(game, f.target, guide.action, actions); assert.equal(player(game, f.target).spice, 5);
|
||||
assert.ok(game.phaseOpening); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 5 }); game = settle(game, actions);
|
||||
game = pay(game, f, 5, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity', 'A future-phase quote is not an executable current action');
|
||||
game = reachClaim(game, actions); assert.notEqual(player(game, f.target).charityTurn, game.turn);
|
||||
assert.equal(charityQuote(game, player(game, f.target)).total, 2);
|
||||
const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity');
|
||||
assert.equal(claim.action.turnActionPromiseEvent, promise(game).event); assert.equal(claim.action.turnActionPromisePhase, 2);
|
||||
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, claim.action, actions);
|
||||
assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(player(game, f.target).spice, 2);
|
||||
assert.equal(game.turnActionPromises![0].payments.find(row => row.recipient === f.recipient)?.amount, 5);
|
||||
const final = viewGame(game, f.target).turnActionGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1);
|
||||
game = act(game, f.target, final.action, actions);
|
||||
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 6); assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(game.turnActionPromises![0].mixed, raw.kind === 'turnActions' ? raw.mixed : null);
|
||||
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
|
||||
rejected(game, f.target, f.charity); physical(game, f);
|
||||
});
|
||||
|
||||
void test('bare, grouped and mixed payment questions use the same real Charity installment path after actual Amal', () => {
|
||||
for (const kind of ['bribe', 'bribeExpression', 'mixedBribe'] as const) {
|
||||
const f = fixture({ source: 'afterAmal' }), raw = question(f, kind);
|
||||
let game = bind(f, 'yes', raw).game;
|
||||
const first = viewGame(game, f.target).bribePromiseGuide; assert.ok(first, kind);
|
||||
assert.equal(first.action.bribePromiseEvent, promise(game, kind).event); assert.notEqual(first.action.type, 'charity');
|
||||
game = pay(game, f, 5); game = reachClaim(game);
|
||||
const claim = viewGame(game, f.target).bribePromiseGuide; assert.ok(claim, kind); assert.equal(claim.action.type, 'charity');
|
||||
assert.equal(claim.action.bribePromiseEvent, promise(game, kind).event);
|
||||
game = act(game, f.target, claim.action); assert.equal(player(game, f.target).spice, 2);
|
||||
const final = viewGame(game, f.target).bribePromiseGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1);
|
||||
game = act(game, f.target, final.action);
|
||||
assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1);
|
||||
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('required printed Thumper occurs before future Charity and cumulative eleven is funded by ten plus one actual claim', () => {
|
||||
const f = fixture({ source: 'thumper' }); let game = bind(f).game;
|
||||
game = settle(act(game, f.target, f.nativeAction));
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(game.turnActionPromises![0].windowCards?.thumper?.played, true);
|
||||
assert.equal(player(game, f.target).spice, 10); assert.notEqual(player(game, f.target).charityTurn, game.turn);
|
||||
game = pay(game, f, 10); game = reachClaim(game);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity');
|
||||
game = act(game, f.target, guide.action); game = pay(game, f, 1);
|
||||
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 11);
|
||||
assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('different recipients share one purse and one actual Charity while their installments accumulate separately', () => {
|
||||
const f = fixture({ source: 'afterAmal' });
|
||||
const raw: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
|
||||
mixed: group('and', payment(f.recipient, 3), payment(f.observer, 3)) };
|
||||
let game = bind(f, 'yes', raw).game; game = pay(game, f, 3); game = pay(game, f, 2, f.observer);
|
||||
assert.equal(player(game, f.target).spice, 0); game = reachClaim(game); game = act(game, f.target, f.charity);
|
||||
game = pay(game, f, 1, f.observer);
|
||||
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.observer).bribes, 3);
|
||||
assert.deepEqual(game.turnActionPromises![0].payments.slice().sort((a, b) => a.recipient.localeCompare(b.recipient)),
|
||||
[{ recipient: f.observer, amount: 3 }, { recipient: f.recipient, amount: 3 }].sort((a, b) => a.recipient.localeCompare(b.recipient)));
|
||||
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, f.charity); physical(game, f);
|
||||
});
|
||||
|
||||
void test('a cumulative bare No cap survives the genuine Charity income and never treats that claim as another payment', () => {
|
||||
const f = fixture({ source: 'afterAmal' }), raw = question(f, 'bribe'); let game = bind(f, 'no', raw).game;
|
||||
game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity);
|
||||
assert.equal(player(game, f.target).spice, 2); assert.equal(game.bribePromises![0].progress, 5);
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
assert.ok(!game.bribePromises![0].released); game = finish(game);
|
||||
assert.equal(game.bribePromises![0].progress, 5); assert.equal(player(game, f.target).charityTurn, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('already claimed or genuinely expired Charity cannot be reserved again for a new positive payment', () => {
|
||||
const claimed = fixture({ source: 'phase2' }); let game = pay(claimed.game, claimed, 5);
|
||||
game = act(game, claimed.target, claimed.charity); game = pay(game, claimed, 2);
|
||||
assert.equal(player(game, claimed.target).spice, 0); assert.equal(player(game, claimed.target).charityTurn, game.turn);
|
||||
rejected(game, claimed.target, claimed.charity);
|
||||
let asked = ask(declare(claimed, game).game, claimed.asker, question(claimed, 'bribe', 1));
|
||||
assert.deepEqual(viewGame(asked, claimed.target).truthBribeAnswers, ['no']); rejected(asked, claimed.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(asked, claimed.target, 'no'); assert.equal(game.bribePromises![0].progress, 0); physical(game, claimed);
|
||||
const expired = fixture({ source: 'afterAmal' }); game = pay(expired.game, expired, 5);
|
||||
game = advance(game, state => state.phase === 3 && clean(state), [], true);
|
||||
assert.notEqual(player(game, expired.target).charityTurn, game.turn); assert.equal(player(game, expired.target).spice, 0);
|
||||
rejected(game, expired.target, expired.charity);
|
||||
asked = ask(declare(expired, game).game, expired.asker, question(expired, 'bribe', 1));
|
||||
assert.deepEqual(viewGame(asked, expired.target).truthBribeAnswers, ['no']);
|
||||
game = answer(asked, expired.target, 'no'); physical(game, expired);
|
||||
});
|
||||
|
||||
void test('a phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
|
||||
for (const kind of ['turnActions', 'bribe'] as const) {
|
||||
const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5);
|
||||
source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target));
|
||||
let game = bind(f, 'yes', question(f, kind, 1), source).game;
|
||||
assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
|
||||
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'charity'); game = act(game, f.target, guide.action);
|
||||
game = pay(game, f, 1); assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1);
|
||||
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => {
|
||||
const f = fixture({ source: 'afterAmal' });
|
||||
const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
|
||||
mixed: group('and', payment(f.recipient, 6), ship()) };
|
||||
const asked = ask(declare(f).game, f.asker, impossible);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const funded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
|
||||
mixed: group('and', payment(f.recipient, 5), ship()) };
|
||||
let game = bind(f, 'yes', funded).game; game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity);
|
||||
assert.equal(player(game, f.target).spice, 2);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
|
||||
game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }));
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
|
||||
const opening = fixture(); assert.ok(opening.printed);
|
||||
const seven: TruthQuestion = { kind: 'turnActions', target: opening.target, turn: opening.game.turn,
|
||||
mixed: group('and', opening.printed, payment(opening.recipient, 7)) };
|
||||
let manual = bind(opening, 'yes', seven).game;
|
||||
manual = settle(act(manual, opening.target, opening.nativeAction)); manual = pay(manual, opening, 4);
|
||||
manual = reachClaim(manual); assert.equal(player(manual, opening.target).spice, 1);
|
||||
assert.equal(charityQuote(manual, player(manual, opening.target)).total, 1);
|
||||
rejected(manual, opening.target, opening.charity);
|
||||
assert.notEqual(player(manual, opening.target).charityTurn, manual.turn); assert.ok(!promise(manual).released);
|
||||
const prepay = viewGame(manual, opening.target).turnActionGuide; assert.ok(prepay);
|
||||
assert.equal(prepay.action.type, 'bribe'); assert.equal(prepay.action.amount, 1);
|
||||
manual = act(manual, opening.target, prepay.action); assert.equal(player(manual, opening.target).spice, 0);
|
||||
const claim = viewGame(manual, opening.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity');
|
||||
manual = act(manual, opening.target, claim.action); assert.equal(player(manual, opening.target).spice, 2);
|
||||
const remaining = viewGame(manual, opening.target).turnActionGuide; assert.ok(remaining);
|
||||
assert.equal(remaining.action.type, 'bribe'); assert.equal(remaining.action.amount, 2);
|
||||
manual = act(manual, opening.target, remaining.action);
|
||||
assert.equal(player(manual, opening.target).spice, 0); assert.equal(player(manual, opening.recipient).bribes, 7);
|
||||
assert.equal(promise(manual).fulfilled, true); assert.ok(!promise(manual).released); physical(manual, opening);
|
||||
});
|
||||
|
||||
void test('two genuine Truths cannot reserve the same Charity twice across different recipient promises', () => {
|
||||
const f = fixture({ source: 'afterAmal' }); let game = bind(f, 'yes', question(f, 'bribe', 6)).game;
|
||||
const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]);
|
||||
const second = question(f, 'bribe', 2, f.observer), asked = ask(declare(f, game, 1).game, f.asker, second);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(asked, f.target, 'no'); assert.deepEqual(game.bribePromises![0], earlier); assert.deepEqual(game.truthHistory![0], history);
|
||||
assert.equal(player(game, f.target).spice, 5); assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2);
|
||||
});
|
||||
|
||||
void test('current Charity guide packets retain exact owner and original promise event through JSON and reject stale claims', () => {
|
||||
for (const kind of ['turnActions', 'bribe'] as const) {
|
||||
const f = fixture({ source: 'phase2' }); let game = bind(f, 'yes', question(f, kind)).game; game = pay(game, f, 5);
|
||||
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'charity');
|
||||
const event = kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent;
|
||||
assert.equal(event, promise(game, kind).event); rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-charity-event' }
|
||||
: { ...guide.action, bribePromiseEvent: 'stale-charity-event' });
|
||||
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
|
||||
rejected(game, f.target, packet); assert.equal(player(game, f.target).charityTurn, game.turn);
|
||||
const next = viewGame(game, f.target), final = kind === 'turnActions' ? next.turnActionGuide : next.bribePromiseGuide;
|
||||
assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1); game = act(game, f.target, final.action);
|
||||
assert.equal(promise(game, kind).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles execute actual prepayments, one Charity and later installments using current-phase guides', () => {
|
||||
for (const kind of ['turnActions', 'bribe'] as TruthCharityPaymentsKind[]) {
|
||||
const f = fixture({ source: kind === 'turnActions' ? 'amalOpening' : 'afterAmal' });
|
||||
const raw = kind === 'turnActions' ? f.question : question(f, kind), asked = ask(declare(f).game, f.asker, raw);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthCharityPaymentsStep[] = [];
|
||||
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, `${kind}/${difficulty}`);
|
||||
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
||||
for (let count = 0; count < 100 && !promise(game, kind).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
|
||||
if (guide) {
|
||||
const action = botActions(view).find(candidate => kind === 'turnActions'
|
||||
? candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent
|
||||
: candidate.bribePromiseEvent === guide.action.bribePromiseEvent);
|
||||
assert.ok(action, `${kind}/${difficulty}`); assert.deepEqual(action, guide.action);
|
||||
if (action.type === 'charity') assert.equal(game.phase, 2);
|
||||
game = act(game, f.target, action, actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next, `${kind}/${difficulty}`); game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`);
|
||||
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
|
||||
assert.ok(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length >= 2);
|
||||
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -85,6 +85,10 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
|
||||
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
|
||||
game.truthTurnActionAnswers = null;
|
||||
if (next.type === 'charity') {
|
||||
const me = game.players.find(player => player.id === 'p')!; me.spice = 0; me.charityClaimed = false;
|
||||
game.charity = { ...game.charity, ordinary: 2, homeworld: 0, total: 2, amount: 2, incomePending: false };
|
||||
}
|
||||
if (next.type === 'card' && typeof next.card === 'string') {
|
||||
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper', 'amal'] as const)
|
||||
.find(effect => cardId.includes(effect)) ?? 'karama';
|
||||
@@ -1455,6 +1459,41 @@ void test('turn-action current early native opportunities retain exact preparati
|
||||
assert.match(markup(auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' })), /Use the quoted Karama for the current auction payment/);
|
||||
});
|
||||
|
||||
void test('whole payment timelines forward an exact positive unused Charity quote without treating it as current cash', () => {
|
||||
const game = projected(2, { type: 'charity' }), sent: Action[] = [];
|
||||
game.turnActionPromises[0].mixed = payment(3); game.bribeOptions.available = 0; game.bribeOptions.targets = [];
|
||||
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.deepEqual(sent[0], { type: 'charity', turnActionPromiseEvent: event, turnActionPromisePhase: 2 });
|
||||
assert.equal(game.players.find(player => player.id === 'p')!.spice, 0);
|
||||
assert.match(markup(game), /Claim 2 Charity spice while preserving your whole answer/);
|
||||
assert.match(markup(game), /future claim is not current spendable spice/);
|
||||
});
|
||||
|
||||
void test('whole Charity guides reject claimed, stale, unready native quotes and extra fields without generic fallback', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; },
|
||||
(game: GameView) => { game.charity.amount = 0; },
|
||||
(game: GameView) => { game.charity.incomePending = true; },
|
||||
(game: GameView) => { game.charity = { ...game.charity }; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; },
|
||||
(game: GameView) => { game.ready.push('p'); },
|
||||
(game: GameView) => { game.phase = 3; },
|
||||
]) {
|
||||
const game = projected(2, { type: 'charity' }), saved = guide({ game, busy: false, act() { assert.fail('A stale whole-answer Charity claim cannot send'); } });
|
||||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||||
}
|
||||
for (const extra of [{ target: 'r' }, { amount: 2 }, { card: 'private-card' }, { mode: 'shipment' }]) {
|
||||
const game = projected(2, { type: 'charity' }); Object.assign(game.turnActionGuide!.action, extra);
|
||||
const button = guide({ game, busy: false, act() { assert.fail('Charity accepts no extra native parameters'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const pending = projected(2, { type: 'charity' }); pending.charity.incomePending = true;
|
||||
assert.equal(guide({ game: pending, busy: false, act() { assert.fail('Pending CHOAM income cannot be counted as an ordinary claim'); } }).props.disabled, true);
|
||||
const ready = projected(2, { type: 'charity' }); ready.ready.push('p');
|
||||
assert.equal(guide({ game: ready, busy: false, act() { assert.fail('A readied owner cannot open this preparation guide'); } }).props.disabled, true);
|
||||
});
|
||||
|
||||
void test('turn-action current paid Box choices are exact through phases zero to eight without private custody reads', () => {
|
||||
for (let phase = 0; phase <= 8; phase++) {
|
||||
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });
|
||||
|
||||
Reference in new issue
Block a user