Add final Nexus alliances to combined turn commitments
This commit is contained in:
1 parent
921b4fe1e3
commit
1a79fd3131
15 files changed
+1143
-78
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { nextTruthBid, type TruthBidStep } from './fixture-truth-bid';
|
||||
import { createTruthMixedAllianceFixture } from './fixture-truth-alliance-expression';
|
||||
import { truthAllianceNexus } from './fixture-truth-alliance';
|
||||
import {
|
||||
actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer,
|
||||
} from './fixture-truth-turn-action';
|
||||
import type { TruthTurnPreparationLeaf } from './fixture-truth-turn-preparation';
|
||||
|
||||
export type TruthTurnAllianceLeaf = TruthTurnPreparationLeaf | { kind: 'alliance'; partner: string };
|
||||
export type TruthTurnAllianceExpression = MixedActionExpression<TruthTurnAllianceLeaf>;
|
||||
export type TruthTurnAllianceStep = TruthBidStep;
|
||||
export const actTruthTurnAlliance = actTruthTurnAction;
|
||||
export const truthTurnAllianceInventory = truthTurnActionInventory;
|
||||
export const truthTurnAlliancePlayer = truthTurnActionPlayer;
|
||||
export const truthTurnAllianceNexus = truthAllianceNexus;
|
||||
export const truthTurnAllianceClean = (game: Game) => !game.truthtrance && !game.phaseOpening &&
|
||||
!game.response && !game.decision && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
|
||||
export const truthTurnAllianceFact = (fact: TruthFact): TruthTurnAllianceExpression => ({ kind: 'fact', fact });
|
||||
export const truthTurnAlliancePartner = (partner: string): TruthTurnAllianceExpression =>
|
||||
({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||
export const truthTurnAlliancePayment = (recipient: string, minimum = 2): TruthTurnAllianceExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthTurnAllianceShip = (territory = 'polar_sink', minimum = 1): TruthTurnAllianceExpression =>
|
||||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||
export const truthTurnAllianceGroup = (kind: 'and' | 'or', ...terms: TruthTurnAllianceExpression[]): TruthTurnAllianceExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
/** Reuse the original H/A/E base33, two actual opening Truths, real first turn,
|
||||
* later Worm and native reciprocal invitation/acceptance. Only the raw question
|
||||
* changes; payment names the third seat so forming this pair leaves a legal
|
||||
* ordinary payment recipient. No played state or private progress is set. */
|
||||
export function createTruthTurnAllianceFixture(options: {
|
||||
advanced?: boolean; consent?: 'none' | 'offered' | 'allied'; secondOffer?: boolean;
|
||||
mixed?: TruthTurnAllianceExpression;
|
||||
} = {}) {
|
||||
const { mixed: suppliedMixed, ...sourceOptions } = options;
|
||||
const base = createTruthMixedAllianceFixture(sourceOptions), game = structuredClone(base.game);
|
||||
const asker = base.asker, target = base.target, partner = base.partner;
|
||||
const recipient = game.players.find(candidate => candidate.id !== target && candidate.id !== partner)!.id;
|
||||
const mixed = structuredClone(suppliedMixed ?? truthTurnAllianceGroup('and', truthTurnAlliancePartner(partner),
|
||||
truthTurnAlliancePayment(recipient), truthTurnAllianceShip()));
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
const actions = structuredClone(base.sourceActions);
|
||||
const falseSpiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 11 };
|
||||
assert.ok(truthTurnAllianceNexus(game)); assert.equal(truthTurnAlliancePlayer(game, target).spice, 10);
|
||||
assert.ok(base.truths.every(truth => truthTurnAlliancePlayer(game, asker).hand.some(card => card.id === truth)));
|
||||
assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(truthTurnAllianceInventory(game), base.originalCards);
|
||||
return {
|
||||
game, source: structuredClone(game), base, asker, target, partner, recipient,
|
||||
truths: structuredClone(base.truths), truth: base.truths[0], mixed, question,
|
||||
initial: base.initial, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal, afterSetup: base.afterSetup,
|
||||
originalCards: base.originalCards, originalSpice: base.originalSpice, staging: base.staging,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited: base.sourceCounts.inherited, additionalInvitations: base.sourceCounts.additionalInvitations,
|
||||
selected: actions.length },
|
||||
spiceFact: base.spiceFact, falseSpiceFact, shieldFact: base.shieldFact, hajrFact: base.hajrFact,
|
||||
allianceAction: { type: 'alliance', target: partner } satisfies Action,
|
||||
breakAction: { type: 'alliance', target: null } satisfies Action,
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw combined turn-action question. ` +
|
||||
'The native ordinary Nexus closing event alone freezes final membership. A proposal, temporary pair or own Ready is not that event. ' +
|
||||
'All source counts derive from original recorded arrays; no hand, card, cash, force, offer, alliance, clock, promise or receipt is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTurnAllianceFixture = ReturnType<typeof createTruthTurnAllianceFixture>;
|
||||
|
||||
export function declareTruthTurnAlliance(f: TruthTurnAllianceFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthTurnAllianceStep[] = []) {
|
||||
const before = structuredClone(source), from = actions.length;
|
||||
let game = actTruthTurnAlliance(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||
game = actTruthTurnAlliance(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, actions, actionCount: actions.length - from };
|
||||
}
|
||||
export function askTruthTurnAlliance(game: Game, asker: string, target: string, mixed: TruthTurnAllianceExpression,
|
||||
actions: TruthTurnAllianceStep[] = []): Game {
|
||||
return actTruthTurnAlliance(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTurnAlliance(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
|
||||
actions: TruthTurnAllianceStep[] = []): Game {
|
||||
return actTruthTurnAlliance(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTurnAlliance(f: TruthTurnAllianceFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAllianceStep[] = []) {
|
||||
const from = actions.length, declared = declareTruthTurnAlliance(f, source, copy, actions);
|
||||
const asked = askTruthTurnAlliance(declared.game, f.asker, f.target, mixed, actions);
|
||||
const game = answerTruthTurnAlliance(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length - from };
|
||||
}
|
||||
export function truthTurnAlliancePromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
|
||||
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
|
||||
}
|
||||
export function nextTruthTurnAllianceNative(game: Game): TruthTurnAllianceStep | null {
|
||||
return nextTruthBid(game);
|
||||
}
|
||||
export function nextTruthTurnAlliance(game: Game): TruthTurnAllianceStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).turnActionGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextTruthTurnAllianceNative(game);
|
||||
}
|
||||
export function advanceTruthTurnAlliance(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTurnAllianceStep[] = [], nativeOnly = false): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original alliance source ended before its requested native boundary');
|
||||
const next = nativeOnly ? nextTruthTurnAllianceNative(game) : nextTruthTurnAlliance(game);
|
||||
assert.ok(next, 'Supply the original Truthtrance question, answer or saved-card decision');
|
||||
game = actTruthTurnAlliance(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The genuine combined alliance source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTurnAlliance(game: Game, actions: TruthTurnAllianceStep[] = []): Game {
|
||||
return advanceTruthTurnAlliance(game, truthTurnAllianceClean, actions);
|
||||
}
|
||||
export function closeTruthTurnAllianceNexus(game: Game, actions: TruthTurnAllianceStep[] = [], nativeOnly = false): Game {
|
||||
assert.ok(truthTurnAllianceNexus(game)); const turn = game.turn;
|
||||
return advanceTruthTurnAlliance(game, state => state.turn === turn && !state.nexus && truthTurnAllianceClean(state), actions, nativeOnly);
|
||||
}
|
||||
export function finishTruthTurnAlliance(game: Game, actions: TruthTurnAllianceStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthTurnAlliance(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnAllianceClean(state), actions);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const bid = (minimum = 3, lot = 2): Expression => ({ kind: 'action', action: { k
|
||||
const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum = 1): Expression =>
|
||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
|
||||
|
||||
function elements(node: ReactNode): Element[] {
|
||||
@@ -75,6 +76,14 @@ function auction(next: Action = { type: 'passBid' }): GameView {
|
||||
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
|
||||
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';
|
||||
game.alliancePromiseNexus = { turn: 2 }; game.truthMixedAllianceTargets = ['p', 'a', 'r'];
|
||||
game.allianceOffers = { r: 'p' };
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [finalAlliance(), payment()] };
|
||||
return game;
|
||||
}
|
||||
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
|
||||
const game = projected(4, next);
|
||||
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
|
||||
@@ -251,6 +260,120 @@ void test('turn-action preparation history and guides use original public leaves
|
||||
assert.match(answerMarkup, /ordinary force returns add together/); assert.match(answerMarkup, /Bids close with their lot/);
|
||||
});
|
||||
|
||||
void test('turn-action alliance fields offer every public other partner including the current ally only in the open native Nexus', () => {
|
||||
const game = nexus(); game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p';
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'leaders', 'tanks'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
Object.defineProperty(game.turnActionPromises[0], 'alliance', { get() { throw Error('No private alliance binding'); } });
|
||||
const e = editor(physical(), false, game);
|
||||
select(e.tree(), '1', 'action', 'alliance'); assert.deepEqual(e.value, finalAlliance('')); assert.ok(e.error());
|
||||
const options = elements(find(e.tree(), 'id', 'truth-turn-action-1-partner')).filter(element => element.type === 'option');
|
||||
assert.deepEqual(options.map(option => option.props.value), ['', 'a', 'r']);
|
||||
select(e.tree(), '1', 'partner', 'r'); assert.equal(e.error(), null); assert.deepEqual(e.value, finalAlliance());
|
||||
select(e.tree(), '1', 'partner', 'p'); select(e.tree(), '1', 'partner', 'missing'); assert.deepEqual(e.value, finalAlliance());
|
||||
for (const partner of ['', 'p', 'missing', ' r ']) assert.ok(turnActionInputError(finalAlliance(partner), game, 'p'));
|
||||
select(e.tree(), '1', 'partner', ''); assert.ok(e.error()); select(e.tree(), '1', 'partner', 'a'); assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /actual final membership when this current Nexus closes/);
|
||||
assert.match(e.html(), /No can allow temporary membership followed by a break/);
|
||||
const frozen = editor(finalAlliance(), true, game); select(frozen.tree(), '1', 'partner', 'a'); select(frozen.tree(), '1', 'action', 'payment');
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, finalAlliance());
|
||||
for (const change of [
|
||||
(current: GameView) => { current.status = 'setup'; }, (current: GameView) => { current.phase = 2; },
|
||||
(current: GameView) => { current.nexus = false; },
|
||||
(current: GameView) => { current.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
|
||||
(current: GameView) => { current.alliancePromiseNexus = null; }, (current: GameView) => { current.alliancePromiseNexus!.turn = 1; },
|
||||
(current: GameView) => { current.truthMixedAllianceTargets = []; },
|
||||
(current: GameView) => { current.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
(current: GameView) => { current.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(current: GameView) => { current.phaseOpening = { passed: [] }; },
|
||||
(current: GameView) => { current.automaticContinuationPending = true; },
|
||||
]) {
|
||||
const current = nexus(); change(current); const invalid = editor(finalAlliance(), false, current);
|
||||
assert.ok(invalid.error()); assert.match(invalid.html(), /Alliance conditions require the current open Nexus/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('turn-action alliance leaves retain the complete sixteen-clause and four-level optional-fact expression', () => {
|
||||
const game = nexus(), e = editor({ kind: 'or', terms: [finalAlliance(), fact()] }, false, game);
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'action', 'alliance');
|
||||
select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'a' : 'r');
|
||||
}
|
||||
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
|
||||
const full = structuredClone(e.value), changes = e.changes;
|
||||
for (const key of ['add-action-1', 'add-fact-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.deepEqual(e.value, full); assert.equal(e.changes, changes);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), finalAlliance()] }, false, game);
|
||||
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.match(deep.html(), /4 \/ 4 levels of grouping/); assert.equal(deep.error(), null);
|
||||
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
|
||||
});
|
||||
|
||||
void test('turn-action Nexus guide forwards only exact breaks, withdrawals, reciprocal acceptances and Ready without private alliance reads', () => {
|
||||
for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'withdraw', 'ready'] as const) {
|
||||
const game = nexus(kind === 'ready' ? { type: 'ready' } : { type: 'alliance', target: kind === 'accept' ? 'r' : null });
|
||||
game.advanced = advanced;
|
||||
if (kind === 'break') { game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p'; }
|
||||
if (kind === 'withdraw') game.allianceOffers = { p: 'r' };
|
||||
if (kind === 'ready') {
|
||||
game.players.find(player => player.id === 'p')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'p';
|
||||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [finalAlliance(), payment()] }; game.turnActionPromises[0].answer = false;
|
||||
}
|
||||
for (const key of ['alliance', 'compiled', 'preparation', 'progress', 'payments'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
assert.equal(game.turnActionPromises[0].fulfilled, false, 'A guide dispatch does not announce final Nexus fulfillment.');
|
||||
const html = markup(game);
|
||||
assert.match(html, kind === 'accept' ? /Accept Recipient’s alliance invitation/ : kind === 'break' ? /Break your current alliance/
|
||||
: kind === 'withdraw' ? /Withdraw your alliance offer/ : /Confirm Ready for this Nexus/);
|
||||
assert.match(html, /forming an alliance does not fulfill it early/);
|
||||
assert.doesNotMatch(html, /Fulfilled: this whole commitment is complete/);
|
||||
}
|
||||
const invalid = [nexus(), nexus(), nexus(), nexus()];
|
||||
invalid[0].allianceOffers = {};
|
||||
invalid[1].turnActionGuide!.action.target = 'p';
|
||||
invalid[2].turnActionGuide!.action.target = 'missing';
|
||||
invalid[3].players.find(player => player.id === 'r')!.ally = 'a';
|
||||
for (const game of invalid) {
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('turn-action Nexus callbacks expire with public consent, membership, readiness, source and quiet-window changes', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.nexus = false; }, game => { game.phase = 2; }, game => { game.turn = 3; }, game => { game.me = 'a'; },
|
||||
game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
|
||||
game => { game.alliancePromiseNexus = null; }, game => { game.alliancePromiseNexus = { turn: 2 }; },
|
||||
game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; }, game => { game.allianceOffers.a = 'p'; },
|
||||
game => { game.ready.push('p'); }, game => { game.ready.push('a'); },
|
||||
game => { game.players.find(player => player.id === 'p')!.ally = 'a'; }, game => { game.players.find(player => player.id === 'a')!.ally = 'r'; },
|
||||
game => { game.players = game.players.filter(player => player.id !== 'r'); },
|
||||
game => { game.turnActionGuide = { ...game.turnActionGuide! }; }, game => { game.turnActionGuide!.action = { ...game.turnActionGuide!.action }; },
|
||||
game => { game.turnActionGuide!.action.turnActionPromisePhase = 2; }, game => { game.turnActionGuide!.action.turnActionPromiseEvent = 'stale'; },
|
||||
game => { game.turnActionPromises[0].event = 'stale'; }, game => { game.turnActionPromises[0].fulfilled = true; },
|
||||
game => { game.truthtrance = answering(1).truthtrance; }, game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.automaticContinuationPending = true; }, game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
];
|
||||
for (const action of [{ type: 'alliance', target: 'r' }, { type: 'ready' }] satisfies Action[]) for (const change of changes) {
|
||||
const game = nexus(action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const busy = { game: nexus(), busy: false, act() { assert.fail('Busy Nexus guide cannot send'); } };
|
||||
const button = guide(busy); busy.busy = true; press(button);
|
||||
const replaced = { game: nexus(), busy: false, act() { assert.fail('Replaced Nexus view cannot send'); } };
|
||||
const old = guide(replaced); replaced.game = { ...replaced.game }; press(old);
|
||||
const source = nexus({ type: 'ready' }); source.nexus = false;
|
||||
const retained = guide({ game: source, busy: false, act() { assert.fail('A closed rendered Nexus cannot become a fresh opportunity'); } });
|
||||
source.nexus = true; press(retained);
|
||||
});
|
||||
|
||||
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
|
||||
const game = projected(); game.turnActionGuide = null;
|
||||
game.turnActionPromises.push(
|
||||
@@ -259,7 +382,7 @@ void test('turn-action history retains original own whole questions and terminal
|
||||
{ ...game.turnActionPromises[0], event: 'controlled-release', released: true },
|
||||
{ ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } },
|
||||
);
|
||||
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders'])
|
||||
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress', 'preparation', 'prep', 'bids', 'returns', 'leaders', 'alliance'])
|
||||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const html = markup(game);
|
||||
for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /,
|
||||
@@ -314,7 +437,7 @@ void test('turn-action ordinary-payment guide retains native out-of-turn timing
|
||||
assert.match(markup(game), /Ready to end this turn while honoring your whole answer/);
|
||||
game.ready.push('p'); press(button); assert.equal(sent.length, 1);
|
||||
for (const phase of [1, 7]) {
|
||||
const current = projected(phase, { type: 'ready' }); current.nexus = true; current.spiceWindow = null;
|
||||
const current = phase === 1 ? nexus({ type: 'ready' }) : projected(phase, { type: 'ready' });
|
||||
const dispatched: Action[] = [], ready = guide({ game: current, busy: false, act(value) { dispatched.push(value); } });
|
||||
assert.equal(ready.props.disabled, false); press(ready); assert.equal(dispatched[0], current.turnActionGuide!.action);
|
||||
if (phase === 1) { current.nexus = false; press(ready); assert.equal(dispatched.length, 1); }
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
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 {
|
||||
actTruthTurnAlliance as act, advanceTruthTurnAlliance as advance, answerTruthTurnAlliance as answer,
|
||||
askTruthTurnAlliance as ask, bindTruthTurnAlliance as bind, closeTruthTurnAllianceNexus as close,
|
||||
createTruthTurnAllianceFixture as fixture, declareTruthTurnAlliance as declare,
|
||||
finishTruthTurnAlliance as finish, nextTruthTurnAllianceNative as nativeStep,
|
||||
settleTruthTurnAlliance as settle, truthTurnAllianceClean as clean, truthTurnAllianceFact as fact,
|
||||
truthTurnAllianceGroup as group, truthTurnAllianceInventory as inventory, truthTurnAllianceNexus as nexus,
|
||||
truthTurnAlliancePartner as partner, truthTurnAlliancePayment as payment, truthTurnAlliancePlayer as player,
|
||||
truthTurnAlliancePromise as promise, truthTurnAllianceShip as ship, type TruthTurnAllianceFixture, type TruthTurnAllianceStep,
|
||||
} from './fixture-truth-turn-alliance';
|
||||
|
||||
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: TruthTurnAllianceFixture) {
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
|
||||
void test('native combined alliance sources retain the original Worm, actual invitations and two physically dealt Truths', () => {
|
||||
for (const secondOffer of [false, true]) {
|
||||
const f = fixture({ secondOffer }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
|
||||
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.ok(nexus(f.game)); assert.equal(f.game.turn, 2); assert.deepEqual(f.staging, []);
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(f.sourceCounts.selected, f.sourceActionCount);
|
||||
assert.equal(f.sourceCounts.inherited + Number(secondOffer), f.sourceActionCount);
|
||||
assert.equal(f.game.allianceOffers[f.partner], f.target);
|
||||
if (secondOffer) assert.equal(f.game.allianceOffers[f.asker], f.target);
|
||||
assert.equal(player(f.game, f.target).ally, null); assert.equal(player(f.game, f.target).spice, 10);
|
||||
assert.equal(f.target, 'emperor'); assert.equal(f.partner, 'atreides'); assert.equal(f.recipient, 'harkonnen');
|
||||
assert.equal(player(f.game, f.target).reserves, 20); assert.deepEqual(player(f.game, f.target).forces, {});
|
||||
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(inventory(f.game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native AND combines final original Nexus membership, a real third-seat payment and later paid reserve shipment', () => {
|
||||
const f = fixture(), actions: TruthTurnAllianceStep[] = [];
|
||||
let game = bind(f, 'yes', f.mixed, f.game, 0, actions).game;
|
||||
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment, actions);
|
||||
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.recipient).bribes, 2);
|
||||
game = act(game, f.target, f.allianceAction, actions);
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.partner).ally, f.target);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, false);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.partner, null);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = act(game, f.target, { type: 'ready' }, actions); assert.ok(game.ready.includes(f.target));
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, false, 'Own Ready is not the original Nexus closing event');
|
||||
game = close(game, actions);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, true);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.partner, f.partner);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
game = settle(act(game, f.target, f.shipment, actions), actions);
|
||||
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 19);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native final-partner OR payment retains both manual routes without counting temporary membership', () => {
|
||||
const f = fixture(), mixed = group('or', partner(f.partner), payment(f.recipient)), bound = bind(f, 'yes', mixed).game;
|
||||
let allied = act(bound, f.target, f.allianceAction);
|
||||
assert.ok(!promise(allied, f.target).fulfilled); assert.equal(gameAlliance(allied), null);
|
||||
allied = close(allied); assert.equal(promise(allied, f.target).fulfilled, true);
|
||||
assert.equal(player(allied, f.target).ally, f.partner); assert.equal(player(allied, f.recipient).bribes, 0);
|
||||
let paid = act(bound, f.target, f.payment);
|
||||
assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(player(paid, f.target).ally, null);
|
||||
paid = close(paid); assert.equal(player(paid, f.target).ally, null);
|
||||
for (const state of [allied, paid]) { assert.deepEqual(promise(state, f.target).mixed, mixed); physical(state, f); }
|
||||
});
|
||||
function gameAlliance(game: Game) { return game.turnActionPromises![0].alliance?.partner; }
|
||||
|
||||
void test('native whole AND No permits an actually paid temporary pair and its later break before final closure', () => {
|
||||
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
game = act(game, f.target, f.payment); game = act(game, f.target, f.allianceAction);
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, false); rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, f.breakAction); game = close(game);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, true);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.partner, null); assert.ok(!promise(game, f.target).released);
|
||||
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
game = act(game, f.target, { ...f.payment, amount: 3 });
|
||||
assert.ok(!promise(game, f.target).released); game = finish(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native final unpaired Nexus outcome leaves a payment OR available through actual late Mentat', () => {
|
||||
const f = fixture(), mixed = group('or', partner(f.partner), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = close(game, [], true);
|
||||
assert.equal(player(game, f.target).ally, null);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, true);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.partner, null);
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
|
||||
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); physical(game, f);
|
||||
});
|
||||
|
||||
for (const consent of ['offered', 'allied'] as const)
|
||||
void test(`native foreign ${consent === 'offered' ? 'invitation withdrawal' : 'actual departure'} releases a required final partnership without inventing renewed consent`, () => {
|
||||
const f = fixture({ consent }); let game = bind(f).game;
|
||||
game = act(game, f.partner, { type: 'alliance', target: null });
|
||||
assert.equal(player(game, f.target).ally, null); assert.equal(promise(game, f.target).released, true);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
const released = structuredClone(game.turnActionPromises![0]);
|
||||
game = act(game, f.partner, { type: 'alliance', target: f.target });
|
||||
assert.equal(promise(game, f.target).released, true); assert.deepEqual(game.turnActionPromises![0], released);
|
||||
game = act(game, f.target, f.allianceAction); game = close(game);
|
||||
assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native foreign invitation withdrawal preserves a funded payment OR and never supplies an assumed alliance', () => {
|
||||
const f = fixture(), mixed = group('or', partner(f.partner), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = act(game, f.partner, { type: 'alliance', target: null });
|
||||
assert.equal(player(game, f.target).ally, null); assert.ok(!promise(game, f.target).released);
|
||||
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = close(game); assert.equal(player(game, f.target).ally, null); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native missing reciprocal consent never permits a definite combined final-partner Yes or consumes the card at rejected answer', () => {
|
||||
const f = fixture({ consent: 'none' }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no', 'unknown']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.deepEqual(asked.allianceOffers, {}); assert.equal(player(asked, f.target).ally, null);
|
||||
assert.equal(asked.turnActionPromises?.length ?? 0, 0);
|
||||
assert.equal(asked.discard.filter(card => card.id === f.truth).length, 0);
|
||||
assert.deepEqual(player(asked, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.deepEqual(inventory(asked), f.originalCards);
|
||||
let game = answer(asked, f.target, 'unknown'); assert.equal(game.truthtrance?.stage, 'unknown');
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 0);
|
||||
game = act(game, f.asker, { type: 'truthSave' }); assert.equal(game.truthtrance, null);
|
||||
assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.equal(game.turnActionPromises?.length ?? 0, 0); assert.equal(player(game, f.target).ally, null);
|
||||
assert.deepEqual(game.allianceOffers, {}); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native known unaffordable payment with existing real consent offers No rather than treating finance as unknown consent', () => {
|
||||
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const game = answer(asked, f.target, 'no'); assert.equal(promise(game, f.target).answer, false); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native missing consent cannot conceal a known unaffordable whole payment branch as Unknown', () => {
|
||||
const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
|
||||
const game = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'unknown' });
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truth));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native payment after own Nexus Ready uniformly reopens readiness and preserves a required later break', () => {
|
||||
const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target));
|
||||
game = act(game, f.target, f.payment);
|
||||
assert.ok(!game.ready.includes(f.target)); assert.ok(!promise(game, f.target).released);
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null);
|
||||
game = act(game, f.target, guide.action); game = close(game);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, true);
|
||||
assert.equal(game.turnActionPromises![0].alliance?.partner, null); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native prospective alliance proposal preserves a still-owed ordinary payment before forming with its recipient', () => {
|
||||
const f = fixture({ consent: 'none' }), mixed = group('and', payment(f.partner), ship());
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
rejected(game, f.target, f.allianceAction);
|
||||
game = act(game, f.target, { ...f.payment, target: f.partner });
|
||||
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.partner).bribes, 2);
|
||||
game = act(game, f.target, f.allianceAction);
|
||||
game = act(game, f.partner, { type: 'alliance', target: f.target });
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).released);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
||||
game = settle(act(game, f.target, f.shipment));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 6); physical(game, f);
|
||||
});
|
||||
|
||||
for (const response of ['yes', 'no'] as const)
|
||||
void test(`native ${response} frozen-constant alliance history also reopens own Ready after an ordinary payment`, () => {
|
||||
const f = fixture(), mixed = response === 'yes'
|
||||
? group('or', fact(f.spiceFact), partner(f.partner)) : group('and', fact(f.falseSpiceFact), partner(f.partner));
|
||||
let game = bind(f, response, mixed).game;
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
const receipt = structuredClone(game.turnActionPromises![0]);
|
||||
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target));
|
||||
game = act(game, f.target, f.payment);
|
||||
assert.ok(!game.ready.includes(f.target)); assert.deepEqual(game.turnActionPromises![0], receipt);
|
||||
physical(game, f);
|
||||
});
|
||||
|
||||
void test('native definite final-alliance answers uniformly reopen only respondent Ready even for folded current-fact constants', () => {
|
||||
const f = fixture();
|
||||
for (const response of ['yes', 'no'] as const) {
|
||||
const mixed = response === 'yes' ? group('or', fact(f.spiceFact), partner(f.partner))
|
||||
: group('and', fact(f.falseSpiceFact), partner(f.partner));
|
||||
let game = act(f.game, f.target, { type: 'ready' }); game = act(game, f.partner, { type: 'ready' });
|
||||
assert.ok(game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner));
|
||||
game = bind(f, response, mixed, game).game;
|
||||
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native exact alliance and Ready guides retain current source phase owner and raw private history through actual closure', () => {
|
||||
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
|
||||
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
|
||||
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
|
||||
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, { ...f.question, mixed });
|
||||
for (const actor of [f.asker, f.partner]) {
|
||||
assert.deepEqual(viewGame(game, actor).turnActionPromises, []); assert.equal(viewGame(game, actor).turnActionGuide, null);
|
||||
}
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner);
|
||||
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, 1);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-combined-alliance-event' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 2 });
|
||||
rejected(game, f.target, { ...guide.action, target: f.asker });
|
||||
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
|
||||
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, wire);
|
||||
const ready = viewGame(game, f.target).turnActionGuide; assert.ok(ready);
|
||||
assert.equal(ready.action.type, 'ready'); assert.equal(ready.action.turnActionPromisePhase, 1);
|
||||
game = act(game, f.target, JSON.parse(JSON.stringify(ready.action)));
|
||||
assert.equal(game.turnActionPromises![0].alliance?.closed, false); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, ready.action); game = close(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); rejected(game, f.target, wire); physical(game, f);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles actually answer and complete combined original alliance payment Ready and later shipment guides', () => {
|
||||
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTurnAllianceStep[] = [];
|
||||
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 < 160 && !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, difficulty); assert.deepEqual(action, guide.action);
|
||||
game = settle(act(game, f.target, action, actions), actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next, difficulty); game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
|
||||
assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.target).spice, 6);
|
||||
for (const type of ['alliance', 'bribe', 'ready', 'ship'])
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === type), `${difficulty}: ${type}`);
|
||||
physical(game, f);
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
recordTurnActionPayment,
|
||||
recordTurnActionShipment,
|
||||
recordTurnActionRevival,
|
||||
settleTurnActionAlliance,
|
||||
turnActionBranches,
|
||||
turnActionHasPayments,
|
||||
turnActionValue,
|
||||
@@ -38,6 +39,7 @@ const bid = (minimum = 3, lot = 1): Expression => ({ kind: 'action', action: { k
|
||||
const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
const leader = (leader = 'atreides-0'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const alliance = (partner = 'recipient'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
||||
@@ -61,6 +63,9 @@ function prepared(mixed: Expression, answer = true, id = 'turn-action:1'): TurnA
|
||||
revival: { total: 0, normal: 0, elite: 0, leaders: [], closed: false },
|
||||
} };
|
||||
}
|
||||
function nexus(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
|
||||
return { ...promise(mixed, answer, id), alliance: { source: 'nexus:1', partner: null, closed: false } };
|
||||
}
|
||||
|
||||
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
|
||||
for (const kind of ['ship', 'move'] as const) {
|
||||
@@ -557,3 +562,124 @@ void test('preparation metadata and actual events reject foreign sources, mismat
|
||||
assert.throws(() => recordTurnActionRevival([full], 'respondent', 1, { kind: 'forces', amount: 1, elite: 0 }), TurnActionError);
|
||||
assert.deepEqual(full, before);
|
||||
});
|
||||
|
||||
void test('final-alliance leaves use exact public partner fields and require a private original Nexus stage', () => {
|
||||
const leaf = { kind: 'alliance', partner: 'recipient' };
|
||||
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
|
||||
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
|
||||
for (const invalid of [{ ...leaf, partner: '' }, { ...leaf, partner: ' recipient' },
|
||||
{ ...leaf, source: 'nexus:1' }, { ...leaf, closed: true }, { ...leaf, answer: true },
|
||||
{ ...leaf, turn: 1 }, { ...leaf, minimum: 1 }])
|
||||
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(alliance())), TurnActionError);
|
||||
const folded = promise(group('or', fact(), alliance()));
|
||||
assert.equal(folded.compiled.kind, 'constant');
|
||||
assert.throws(() => turnActionValue(folded), TurnActionError, 'Original alliance clauses require their source even after folding');
|
||||
validateTurnActionPromises([nexus(group('and', alliance(), pay()))], seats);
|
||||
validateTurnActionPromises([{ ...prepared(group('and', bid(), forces())),
|
||||
alliance: { source: 'nexus:1', partner: null, closed: false } }], seats);
|
||||
assert.equal('alliance' in closeTurnActionTransport([promise(pay())], 'respondent', 1)[0], false);
|
||||
});
|
||||
|
||||
void test('open final membership remains unresolved and lazy branches retain whole AND OR and No meaning', () => {
|
||||
const original = nexus(group('or', alliance(), pay()));
|
||||
assert.equal(turnActionValue(original), false);
|
||||
assert.deepEqual(branches(original), [[{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: true }], [signedPay()]]);
|
||||
assert.deepEqual(branches(nexus(group('and', alliance(), pay()), false)), [
|
||||
[{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: false }],
|
||||
[signedPay('recipient', 3, 0, false)],
|
||||
]);
|
||||
assert.deepEqual(branches(nexus(group('or', alliance(), pay()), false)), [[
|
||||
{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: false }, signedPay('recipient', 3, 0, false),
|
||||
]]);
|
||||
assert.deepEqual(branches(nexus(group('and', alliance(), alliance()))), [[
|
||||
{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: true },
|
||||
]]);
|
||||
assert.deepEqual(branches(nexus(group('and', alliance(), alliance('other')))), [],
|
||||
'One final source has only one final partner');
|
||||
assert.deepEqual(branches(nexus(group('or', alliance(), alliance('other')))), [
|
||||
[{ kind: 'alliance', partner: 'recipient', source: 'nexus:1', answer: true }],
|
||||
[{ kind: 'alliance', partner: 'other', source: 'nexus:1', answer: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
void test('exact Nexus closure snapshots final membership once and preserves later payment alternatives', () => {
|
||||
const original = nexus(group('and', alliance(), pay()));
|
||||
assert.equal(settleTurnActionAlliance([original], 1, 'other-nexus', { respondent: 'recipient' })[0], original);
|
||||
assert.equal(settleTurnActionAlliance([original], 2, 'nexus:1', { respondent: 'recipient' })[0], original);
|
||||
const closed = settleTurnActionAlliance([original], 1, 'nexus:1', { respondent: 'recipient' });
|
||||
assert.deepEqual(closed[0].alliance, { source: 'nexus:1', partner: 'recipient', closed: true });
|
||||
assert.equal(closed[0].fulfilled, false);
|
||||
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
|
||||
assert.equal(settleTurnActionAlliance(closed, 1, 'nexus:1', { respondent: 'other' })[0], closed[0]);
|
||||
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.deepEqual(original.alliance, { source: 'nexus:1', partner: null, closed: false });
|
||||
const alternative = settleTurnActionAlliance([nexus(group('or', alliance(), pay()))], 1, 'nexus:1', { respondent: null });
|
||||
assert.equal(alternative[0].fulfilled, false);
|
||||
assert.equal(alternative[0].released, false);
|
||||
assert.deepEqual(branches(alternative[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(alternative, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
validateTurnActionPromises(alternative, seats);
|
||||
});
|
||||
|
||||
void test('a whole No made true by final membership releases while later actual payments still reject live No violations', () => {
|
||||
const no = nexus(alliance(), false);
|
||||
const conflict = settleTurnActionAlliance([no], 1, 'nexus:1', { respondent: 'recipient' });
|
||||
assert.equal(conflict[0].released, true);
|
||||
assert.equal(conflict[0].fulfilled, false);
|
||||
assert.equal(turnActionValue(conflict[0]), true);
|
||||
validateTurnActionPromises(conflict, seats);
|
||||
const beforePayment = nexus(group('and', alliance(), pay()), false);
|
||||
const matching = settleTurnActionAlliance([beforePayment], 1, 'nexus:1', { respondent: 'recipient' });
|
||||
assert.equal(matching[0].released, false);
|
||||
assert.equal(matching[0].fulfilled, false);
|
||||
assert.deepEqual(branches(matching[0]), [[signedPay('recipient', 3, 0, false)]]);
|
||||
assert.throws(() => recordTurnActionPayment(matching, 'respondent', 1, payment()), TurnActionError);
|
||||
const paidFirst = recordTurnActionPayment([beforePayment], 'respondent', 1, payment());
|
||||
assert.equal(paidFirst[0].released, false);
|
||||
const conflictAfterPayment = settleTurnActionAlliance(paidFirst, 1, 'nexus:1', { respondent: 'recipient' });
|
||||
assert.equal(conflictAfterPayment[0].released, true);
|
||||
const nonmatching = settleTurnActionAlliance([beforePayment], 1, 'nexus:1', { respondent: null });
|
||||
assert.equal(nonmatching[0].fulfilled, false, 'Whole No still waits for its remaining payment opportunity');
|
||||
const finished = finishTurnActionTurn(nonmatching, 'respondent', 1);
|
||||
assert.equal(finished[0].fulfilled, true);
|
||||
const plainNo = settleTurnActionAlliance([no], 1, 'nexus:1', { respondent: 'other' });
|
||||
assert.equal(plainNo[0].fulfilled, true);
|
||||
assert.equal(plainNo[0].released, false);
|
||||
});
|
||||
|
||||
void test('expiry releases an unresolved original Nexus without inventing membership and preserves every terminal row', () => {
|
||||
const originals = [nexus(alliance()), nexus(alliance(), false, 'turn-action:2')];
|
||||
const finished = finishTurnActionTurn(originals, 'respondent', 1);
|
||||
for (const record of finished) {
|
||||
assert.equal(record.released, true);
|
||||
assert.equal(record.fulfilled, false);
|
||||
assert.deepEqual(record.alliance, { source: 'nexus:1', partner: null, closed: false });
|
||||
}
|
||||
const paid = recordTurnActionPayment([nexus(group('or', alliance(), pay()), true, 'turn-action:3')],
|
||||
'respondent', 1, payment())[0];
|
||||
assert.equal(paid.fulfilled, true);
|
||||
assert.equal(finishTurnActionTurn([paid], 'respondent', 1)[0], paid);
|
||||
const terminal = [...finished, paid];
|
||||
assert.ok(settleTurnActionAlliance(terminal, 1, 'nexus:1', { respondent: 'recipient' })
|
||||
.every((row, index) => row === terminal[index]));
|
||||
assert.ok(recordTurnActionPayment(terminal, 'respondent', 1, payment())
|
||||
.every((row, index) => row === terminal[index]));
|
||||
validateTurnActionPromises(terminal, seats);
|
||||
});
|
||||
|
||||
void test('final-membership stages reject temporary pairs, extra metadata, missing snapshot players and foreign partner clauses', () => {
|
||||
const original = nexus(group('and', alliance(), pay()));
|
||||
for (const stage of [undefined, null, {}, { source: '', partner: null, closed: false },
|
||||
{ source: 'nexus:1', partner: 'recipient', closed: false }, { source: 'nexus:1', partner: 'respondent', closed: true },
|
||||
{ source: 'nexus:1', partner: null, closed: 'yes' }, { source: 'nexus:1', partner: null, closed: false, offers: {} }])
|
||||
assert.throws(() => turnActionValue({ ...original, alliance: stage } as TurnActionPromise), TurnActionError);
|
||||
assert.throws(() => settleTurnActionAlliance([original], 1, 'nexus:1', {}), TurnActionError);
|
||||
assert.throws(() => settleTurnActionAlliance([original], 1, 'nexus:1', { respondent: 'respondent' }), TurnActionError);
|
||||
for (const partner of ['missing', 'respondent'])
|
||||
assert.throws(() => validateTurnActionPromises([nexus(alliance(partner))], seats), TurnActionError);
|
||||
const foreignSnapshot = { ...original, alliance: { source: 'nexus:1', partner: 'missing', closed: true } };
|
||||
assert.throws(() => validateTurnActionPromises([foreignSnapshot], seats), TurnActionError);
|
||||
const foreignCompiled = { ...original, compiled: { kind: 'action', action: { kind: 'alliance', partner: 'other' } } };
|
||||
assert.throws(() => turnActionValue(foreignCompiled as TurnActionPromise), TurnActionError);
|
||||
});
|
||||
Reference in new issue
Block a user