Add final Nexus alliances to combined turn commitments

This commit is contained in:
Quintine committed 2026-10-26 22:32:59 +11:00
1 parent 921b4fe1e3
commit 1a79fd3131
15 files changed
+1143 -78

No files matched your search

+136
View File
@@ -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);
}
+125 -2
View File
@@ -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); }
+275
View File
@@ -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);
}
});
+126
View File
@@ -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);
});