Bind Truthtrance alliance promises to actual Nexus closure

This commit is contained in:
Quintine committed 2026-10-26 14:22:46 +11:00
1 parent 335b4ce638
commit 5627512d16
19 files changed
+1485 -30

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AlliancePromiseError,
allianceClaimText,
alliancePromiseModeSupported,
alliancePromiseRequirements,
alliancePromisesMatch,
findAlliancePromisePlan,
liveAlliancePromises,
parseAllianceClaim,
validateAlliancePromises,
type AllianceOpportunity,
type AlliancePromise,
} from '../game/alliance-promises';
import { quoteNexusAlliance } from '../game/nexus-alliance';
const seats = ['respondent', 'partner-a', 'partner-b', 'asker'] as const;
const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' });
function promise(event: string, partner = 'partner-a', extra: Partial<AlliancePromise> = {}): AlliancePromise {
return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker',
partner, answer: true, ...extra };
}
function context() {
return { players: seats.map(id => ({ id, ally: null as string | null })), offers: {} as Record<string, string> };
}
function executePlan(state: ReturnType<typeof context>, actions: { type: 'alliance'; target: string | null }[]) {
let current = structuredClone(state);
for (const action of actions) {
const quote = quoteNexusAlliance({ ...current, actor: 'respondent', target: action.target });
current = { players: current.players.map(player => ({ ...player, ally: quote.allies[player.id] })), offers: quote.offers };
}
return current;
}
void test('alliance claims normalize current-turn partner identity without retaining private Nexus sources', () => {
const input = { turn: 2, partner: 'partner-a', source: 'private-source', event: 'private-event' };
assert.deepEqual(parseAllianceClaim(input), { turn: 2, partner: 'partner-a' });
assert.deepEqual(parseAllianceClaim({ turn: Number.MAX_SAFE_INTEGER, partner: 'partner-a' }),
{ turn: Number.MAX_SAFE_INTEGER, partner: 'partner-a' });
for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
assert.throws(() => parseAllianceClaim({ ...input, turn }), AlliancePromiseError);
for (const partner of ['', ' ', ' partner-a', 'partner-a ', 1, null, undefined])
assert.throws(() => parseAllianceClaim({ ...input, partner }), AlliancePromiseError);
for (const value of [null, undefined, [], 'partner-a', 2]) assert.throws(() => parseAllianceClaim(value), AlliancePromiseError);
const text = allianceClaimText(parseAllianceClaim(input), id => id === 'partner-a' ? 'Atreides' : id);
assert.match(text, /be allied with Atreides when this Nexus ends/);
assert.doesNotMatch(text, /private-source|private-event/);
});
void test('alliance promises retain bare native Basic and Advanced scope and do not require optional Nexus Cards', () => {
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
]) assert.equal(alliancePromiseModeSupported(game), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(alliancePromiseModeSupported({ ...native, [option]: true }), false, option);
for (const game of [
{ ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] },
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
{ ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] },
{ ...classic, advanced: 'basic' as unknown as boolean },
]) assert.equal(alliancePromiseModeSupported(game), false);
});
void test('joint answers require one distinct Yes partner while independent No partners remain forbidden', () => {
const records = [promise('yes-a'), promise('yes-a-again'),
promise('no-b', 'partner-b', { answer: false }), promise('no-b-again', 'partner-b', { answer: false }),
promise('no-asker', 'asker', { answer: false })];
assert.deepEqual(alliancePromiseRequirements(records, 'respondent', opportunity()),
{ partner: 'partner-a', forbidden: ['partner-b', 'asker'] });
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), false);
assert.equal(alliancePromiseRequirements([...records, promise('yes-b', 'partner-b')], 'respondent', opportunity()), null);
assert.equal(alliancePromiseRequirements([...records, promise('no-a', 'partner-a', { answer: false })], 'respondent', opportunity()), null);
assert.equal(findAlliancePromisePlan([...records, promise('yes-b', 'partner-b')], 'respondent', opportunity(), context()), null);
});
void test('an unallied Yes target requires actual outgoing consent and never assumes a future foreign break or acceptance', () => {
const records = [promise('yes-a')]; const none = context();
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), none), null);
const ownOffer = { ...none, offers: { respondent: 'partner-a' } };
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), ownOffer), null,
'An own proposal does not control the other player’s future acceptance');
const incoming = { ...none, offers: { 'partner-a': 'respondent' } };
const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), incoming);
assert.deepEqual(plan, [{ type: 'alliance', target: 'partner-a' }]); assert.ok(plan);
const completed = executePlan(incoming, plan);
assert.equal(completed.players.find(player => player.id === 'respondent')!.ally, 'partner-a');
const occupied = structuredClone(incoming);
occupied.players.find(player => player.id === 'partner-a')!.ally = 'partner-b';
occupied.players.find(player => player.id === 'partner-b')!.ally = 'partner-a';
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), occupied), null,
'Even an outstanding offer does not authorize breaking the desired partner’s current alliance');
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), { ...none, offers: { 'partner-a': 'asker' } }), null);
});
void test('an owned break can precede acceptance only while the desired partner offer survives the canonical quotes', () => {
const state = context();
state.players.find(player => player.id === 'respondent')!.ally = 'partner-b';
state.players.find(player => player.id === 'partner-b')!.ally = 'respondent';
state.offers = { respondent: 'asker', 'partner-a': 'respondent', 'partner-b': 'asker', asker: 'partner-b' };
const before = structuredClone(state); state.players.forEach(Object.freeze); Object.freeze(state.players);
Object.freeze(state.offers); Object.freeze(state);
const records = [promise('yes-a'), promise('no-b', 'partner-b', { answer: false })];
const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), state);
assert.deepEqual(plan, [{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-a' }]); assert.ok(plan);
const completed = executePlan(state, plan);
assert.deepEqual(completed.players.map(player => [player.id, player.ally]), [
['respondent', 'partner-a'], ['partner-a', 'respondent'], ['partner-b', null], ['asker', null],
]);
assert.deepEqual(completed.offers, { 'partner-b': 'asker', asker: 'partner-b' },
'An unrelated reciprocal proposal is not executed on either foreign player’s behalf');
assert.deepEqual(state, before);
});
void test('No constrains final membership and permits temporary proposals or a currently different alliance', () => {
const records = [promise('no-a', 'partner-a', { answer: false })];
const unallied = { ...context(), offers: { respondent: 'partner-a' } };
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), true);
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), unallied), [],
'A proposal itself is not final alliance membership');
const other = context();
other.players.find(player => player.id === 'respondent')!.ally = 'partner-b';
other.players.find(player => player.id === 'partner-b')!.ally = 'respondent';
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), other), []);
const forbidden = context();
forbidden.players.find(player => player.id === 'respondent')!.ally = 'partner-a';
forbidden.players.find(player => player.id === 'partner-a')!.ally = 'respondent';
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), false);
const plan = findAlliancePromisePlan(records, 'respondent', opportunity(), forbidden);
assert.deepEqual(plan, [{ type: 'alliance', target: null }]); assert.ok(plan);
const final = executePlan(forbidden, plan).players.find(player => player.id === 'respondent')!.ally;
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), final), true);
});
void test('an already held required partner needs no action but a foreign break removes the owned completion until renewed consent', () => {
const state = context();
state.players.find(player => player.id === 'respondent')!.ally = 'partner-a';
state.players.find(player => player.id === 'partner-a')!.ally = 'respondent';
const records = [promise('yes-a')];
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), state), []);
const broken = quoteNexusAlliance({ ...state, actor: 'partner-a', target: null });
const afterBreak = { players: state.players.map(player => ({ ...player, ally: broken.allies[player.id] })), offers: broken.offers };
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), null), false);
assert.equal(findAlliancePromisePlan(records, 'respondent', opportunity(), afterBreak), null);
const renewed = quoteNexusAlliance({ ...afterBreak, actor: 'partner-a', target: 'respondent' });
const afterOffer = { players: afterBreak.players.map(player => ({ ...player, ally: renewed.allies[player.id] })), offers: renewed.offers };
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), afterOffer), [{ type: 'alliance', target: 'partner-a' }]);
assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined,
'Planning never records the real interval’s completion or forced release');
});
void test('turn, Nexus source, respondent and terminal records isolate the current final-membership obligation', () => {
const live = promise('live');
const records = [live, promise('other-turn', 'partner-b', { turn: 1 }),
promise('other-source', 'partner-b', { source: 'different-worm-same-turn' }),
promise('other-player', 'partner-b', { player: 'partner-a' }),
promise('fulfilled', 'partner-b', { fulfilled: true }), promise('released', 'partner-b', { released: true })];
const before = structuredClone(records); records.forEach(Object.freeze); Object.freeze(records);
assert.deepEqual(liveAlliancePromises(records, 'respondent', opportunity()), [live]);
assert.deepEqual(alliancePromiseRequirements(records, 'respondent', opportunity()), { partner: 'partner-a', forbidden: [] });
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
assert.equal(alliancePromisesMatch(records, 'respondent', opportunity(), 'partner-b'), false);
assert.deepEqual(alliancePromiseRequirements(records, 'absent', opportunity()), { partner: null, forbidden: [] });
assert.equal(alliancePromisesMatch(records, 'absent', opportunity(), null), true);
assert.deepEqual(records, before);
});
void test('no clauses or only terminal answers need no owned change and unknown actors cannot receive a plan', () => {
assert.deepEqual(findAlliancePromisePlan([], 'respondent', opportunity(), context()), []);
const records = [promise('old-yes', 'partner-a', { fulfilled: true }),
promise('released-no', 'partner-b', { answer: false, released: true })];
assert.deepEqual(findAlliancePromisePlan(records, 'respondent', opportunity(), context()), []);
assert.equal(findAlliancePromisePlan([], 'absent', opportunity(), context()), null);
assert.deepEqual(alliancePromiseRequirements([promise('no-a', 'partner-a', { answer: false }),
promise('no-b', 'partner-b', { answer: false })], 'respondent', opportunity()),
{ partner: null, forbidden: ['partner-a', 'partner-b'] });
});
void test('record validation retains original interval, event and participants without rechecking historical terminal membership', () => {
const valid = [promise('live'), promise('fulfilled-yes', 'partner-b', { fulfilled: true }),
promise('released-yes', 'partner-a', { released: true }),
promise('fulfilled-no', 'partner-b', { answer: false, fulfilled: true }),
promise('released-no', 'partner-a', { answer: false, released: true }),
promise('asker-is-partner', 'asker')];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateAlliancePromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateAlliancePromises(JSON.parse(JSON.stringify(valid)) as AlliancePromise[], seats));
assert.doesNotThrow(() => validateAlliancePromises([], seats));
const base = promise('event');
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { event: ' ' }, { source: '' }, { source: ' ' },
{ player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' },
{ partner: 'outsider' }, { partner: 'respondent' }, { partner: ' partner-a' },
{ answer: 'yes' }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true },
{ turn: 0 }].map(extra => [{ ...base, ...extra }])];
for (const records of invalid)
assert.throws(() => validateAlliancePromises(records as AlliancePromise[], seats), AlliancePromiseError);
assert.deepEqual(valid, before);
});
+123
View File
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer, type TruthBidStep,
} from './fixture-truth-bid';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
export type TruthAllianceStep = TruthBidStep;
export type TruthAllianceTransition = { before: Game; steps: TruthAllianceStep[]; after: Game };
export const actTruthAlliance = actTruthBid;
export const truthAllianceClean = truthBidClean;
export const truthAllianceInventory = truthBidInventory;
export const truthAlliancePlayer = truthBidPlayer;
export function truthAllianceNexus(game: Game) {
return game.phase === 1 && game.nexus && !game.spiceWindow && !game.spiceResolution && truthAllianceClean(game);
}
export function nextTruthAlliance(game: Game): TruthAllianceStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).alliancePromiseGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthBid(game);
}
export function advanceTruthAlliance(game: Game, until: (state: Game) => boolean, actions: TruthAllianceStep[] = []): Game {
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine alliance source ended before its owned control');
const next = nextTruthAlliance(game); assert.ok(next, 'The original Truthtrance question, answer or saved card belongs to its caller');
game = actTruthAlliance(game, next.actor, next.action, actions);
}
throw Error('The genuine alliance source did not reach its finite boundary');
}
export function settleTruthAlliance(game: Game, actions: TruthAllianceStep[] = []): Game {
return advanceTruthAlliance(game, truthAllianceClean, actions);
}
export function closeTruthAllianceNexus(game: Game, actions: TruthAllianceStep[] = []): Game {
assert.ok(truthAllianceNexus(game)); const turn = game.turn;
return advanceTruthAlliance(game, state => state.turn === turn && !state.nexus && truthAllianceClean(state), actions);
}
export function finishTruthAlliance(game: Game, actions: TruthAllianceStep[] = []): Game {
const turn = game.turn;
return advanceTruthAlliance(game, state => state.turn === turn && state.phase === 2 && truthAllianceClean(state), actions);
}
/** Original public H/A/E base33, two actual Harkonnen Truthtrances and no
* optional Nexus Cards. Finish the original first turn and real later Storm;
* select only an unopened original worm plus ordinary replacements, then open
* the real ordinary Nexus. Consent is a native partner offer and reciprocal
* acceptance, never an installed ally/offer or assumed foreign decision. */
export function createTruthAllianceFixture(options: {
advanced?: boolean; consent?: 'none' | 'offered' | 'allied'; partner?: 'atreides' | 'harkonnen';
} = {}) {
const advanced = options.advanced ?? false, consent = options.consent ?? 'offered';
const base = createTruthBidFixture({ advanced, window: 'waiting' });
const partner = options.partner ?? base.opponent;
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions);
const act = (actor: string, action: Action) => { game = actTruthAlliance(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthAlliance(game, until, actions); };
const transition = (run: () => void): TruthAllianceTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const nextStorm = transition(() => {
reach(state => state.turn === 2 && beforeIxSpiceDraw(state));
});
assert.ok(game.players.every(player => player.ally === null));
const nexusSpiceOrder = orderIxSpiceCards(game, ['worm', 'land', ...(advanced ? ['land' as const] : [])]);
const beforeWorm = structuredClone(game);
const encounter = transition(() => { reach(truthAllianceNexus); });
assert.equal(game.turn, 2); assert.equal(game.phase, 1); assert.equal(game.nexus, true);
assert.ok(game.spiceDiscard.flat().some(card => 'worm' in card && !card.suppressed));
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const beforeConsent = structuredClone(game);
let offer: TruthAllianceTransition | null = null, acceptance: TruthAllianceTransition | null = null;
if (consent !== 'none') {
offer = transition(() => { act(partner, { type: 'alliance', target: base.target }); });
assert.equal(game.allianceOffers[partner], base.target); assert.equal(truthAlliancePlayer(game, base.target).ally, null);
}
if (consent === 'allied') {
acceptance = transition(() => { act(base.target, { type: 'alliance', target: partner }); });
assert.equal(truthAlliancePlayer(game, base.target).ally, partner);
assert.equal(truthAlliancePlayer(game, partner).ally, base.target);
}
assert.deepEqual(truthAllianceInventory(game), base.originalCards);
assert.equal(truthAlliancePlayer(game, base.target).spice, 10);
assert.equal(viewGame(game, base.target).alliancePromiseNexus?.turn, game.turn);
const question = { kind: 'alliance' as const, target: base.target, turn: game.turn, partner };
return { ...base, game, sourceGame: structuredClone(game), base, advanced, consent, partner,
nextStorm, beforeWorm, nexusSpiceOrder, encounter, beforeConsent, offer, acceptance,
question, questionAction: { type: 'truthAsk', question } as Action,
allianceAction: { type: 'alliance', target: partner } as Action,
breakAction: { type: 'alliance', target: null } as Action,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Genuine public original H/A/E base33 and native first-turn/later-Storm controls. Only inherited unopened original deal/lot/Spice positions, the later original worm/replacement prefix and scalar lotteries are selected. The actual ordinary worm opens this Nexus; native reciprocal offers earn any existing alliance. No clock, hand, wallet, offer, ally or promise is installed, and no foreign consent is invented.' };
}
export type TruthAllianceFixture = ReturnType<typeof createTruthAllianceFixture>;
export function declareTruthAlliance(f: TruthAllianceFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
let game = state; const before = structuredClone(game), actions: TruthAllianceStep[] = [];
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
game = actTruthAlliance(game, f.asker, declaration, actions);
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthAlliance(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, declaration, actions, actionCount: actions.length };
}
export function askTruthAlliance(game: Game, asker: string, target: string, partner: string,
actions: TruthAllianceStep[] = []): Game {
return actTruthAlliance(game, asker, { type: 'truthAsk', question: { kind: 'alliance', target, turn: game.turn, partner } }, actions);
}
export function answerTruthAlliance(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthAllianceStep[] = []): Game {
return actTruthAlliance(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthAlliance(f: TruthAllianceFixture, answer: 'yes' | 'no' = 'yes', partner = f.partner, state = f.game) {
const declared = declareTruthAlliance(f, state), actions = [...declared.actions];
const asked = askTruthAlliance(declared.game, f.asker, f.target, partner, actions);
const game = answerTruthAlliance(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AllianceClaimFields, allianceClaimInputError } from '../components/alliance-claim-fields';
import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { AllianceClaim } from '../game/alliance-promises';
import { createTechTokens } from '../game/tech-tokens';
import {
createTruthAllianceFixture, bindTruthAlliance, actTruthAlliance, settleTruthAlliance,
closeTruthAllianceNexus, truthAllianceInventory,
} from './fixture-truth-alliance';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function partner(props: Parameters<typeof AllianceClaimFields>[0], value: string) {
const input = elements(AllianceClaimFields(props)).find(element => element.props.id === 'truth-alliance-partner');
assert.ok(input);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function guide(props: Parameters<typeof AlliancePromiseGuideButton>[0]) {
const button = AlliancePromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(AlliancePromises, { game, busy, send() {} }));
/** Controlled personalized Nexus projections exercise UI guards only; the
* native source below earns its worm, consent, Truthtrance and final close. */
function projected(kind: 'accept' | 'break' | 'ready' = 'accept'): GameView {
const source = createGame('TRUTHALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'harkonnen'));
joinGame(source, newPlayer('r', 'Partner', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null;
game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 };
game.alliancePromises = [{ event: 'controlled-alliance-event', player: 'p', asker: 'a', turn: 2,
partner: 'r', answer: kind !== 'break' }];
game.allianceOffers = kind === 'accept' ? { r: 'p' } : {};
if (kind !== 'accept') {
game.players.find(player => player.id === 'p')!.ally = 'r';
game.players.find(player => player.id === 'r')!.ally = 'p';
}
game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance',
...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-alliance-event' } };
return game;
}
void test('alliance claim fields fix this Nexus turn and accept only a current named other partner', () => {
const value: AllianceClaim = { turn: 2, partner: 'r' }, changes: AllianceClaim[] = [];
const partners = [{ id: 'r', name: 'Partner' }, { id: 'a', name: 'Asker' }];
const props = { value, partners, onChange(next: AllianceClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(AllianceClaimFields, props));
assert.match(html, /Turn 2 · Current Nexus/); assert.match(html, /Named alliance partner/);
assert.match(html, /actual membership when this Nexus closes/); assert.match(html, /Offers alone are not alliances/);
assert.match(html, /cannot promise the other player’s consent/);
assert.equal(elements(AllianceClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
partner(props, 'a'); assert.deepEqual(changes, [{ turn: 2, partner: 'a' }]);
for (const invalid of ['missing', '', ' r ']) partner(props, invalid);
partner({ ...props, disabled: true }, 'a'); partner({ ...props, partners: [] }, 'a');
partner({ ...props, value: { ...value, turn: 0 } }, 'a'); assert.equal(changes.length, 1);
assert.ok(allianceClaimInputError(value, []));
assert.match(renderToStaticMarkup(createElement(AllianceClaimFields, { ...props, partners: [] })), /role="alert"/);
});
void test('own alliance promises describe final membership and remain live after formation until the actual Nexus close', () => {
const game = projected('ready');
game.alliancePromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, partner: 'a', answer: false },
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 1, partner: 'a', answer: true, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, partner: 'r', answer: true },
);
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your alliance promises/); assert.match(html, /be allied with Partner when this Nexus ends/);
assert.match(html, /Yes remains live after forming an alliance and settles only at the close/);
assert.match(html, /a temporary alliance can still be ended before the close/);
assert.match(html, /Offers alone are not alliances/); assert.match(html, /Neither answer promises another player’s consent/);
assert.match(html, /Honor any earlier promises first/); assert.match(html, /Fulfilled at the Nexus close/);
assert.match(html, /href="\/rules#truthtrance-alliance"/);
assert.doesNotMatch(html, /foreign-event|controlled-alliance-event/);
}
assert.deepEqual(game, before);
assert.equal(AlliancePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('actual guide callbacks forward reciprocal acceptance, explicit-null break and Ready out of turn', () => {
for (const kind of ['accept', 'break', 'ready'] as const) {
const game = projected(kind), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]); assert.equal(sent[0], game.alliancePromiseGuide!.action);
assert.equal(game.active, 'a'); assert.notEqual(game.active, game.me);
if (kind === 'break') assert.equal(sent[0].target, null);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
});
void test('stale guides cannot cross Nexus closure, readiness, membership, consent, current event or quiet windows', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 2; },
game => { game.status = 'setup'; }, game => { game.nexus = false; }, game => { game.ready = ['p']; },
game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
game => { game.alliancePromiseNexus = null; }, game => { game.alliancePromiseNexus!.turn = 1; },
game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; },
game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; },
game => { game.alliancePromises[0].event = 'expired'; }, game => { game.alliancePromises[0].player = 'a'; },
game => { game.alliancePromises[0].fulfilled = true; }, game => { game.alliancePromises[0].released = true; },
game => { game.alliancePromises = []; }, game => { game.allianceOffers = {}; },
game => { game.players = game.players.filter(player => player.id !== 'r'); },
game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
}
const ready = projected('ready');
ready.players.find(player => player.id === 'p')!.ally = null; ready.players.find(player => player.id === 'r')!.ally = null;
const sent: Action[] = []; press(guide({ game: ready, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
const noReady = projected('break'); noReady.alliancePromiseGuide!.action = { type: 'ready', alliancePromiseEvent: 'controlled-alliance-event' };
press(guide({ game: noReady, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
const noConsent = projected(); noConsent.allianceOffers = {};
press(guide({ game: noConsent, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
for (const target of ['p', 'missing']) {
const game = projected(); game.alliancePromiseGuide!.action.target = target;
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.equal(sent.length, 0);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy alliance guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
});
void test('alliance guidance uses public membership and offers without inspecting private sources, supplies or card piles', () => {
const game = projected(), sent: Action[] = [];
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks'])
Object.defineProperty(seat, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['spiceDeck', 'deck', 'discard']) Object.defineProperty(game, key, {
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
});
Object.defineProperty(game.alliancePromises[0], 'source', { get() { throw Error('No private Nexus source inspection'); } });
assert.match(markup(game), /Accept Partner’s alliance invitation/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
});
void test('genuine Basic and Advanced guides accept actual consent and settle Yes only at the original Nexus close', () => {
for (const advanced of [false, true]) {
const f = createTruthAllianceFixture({ advanced, consent: 'offered' }), bound = bindTruthAlliance(f, 'yes', f.partner);
const respondent = viewGame(bound.asked, f.target);
assert.ok(respondent.truthAllianceAnswers?.includes('yes'));
assert.equal(viewGame(bound.asked, f.asker).truthAllianceAnswers, null);
const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/);
let game = bound.game;
const dispatch = (type: string) => {
const view = viewGame(game, f.target), sent: Action[] = [], next = view.alliancePromiseGuide; assert.ok(next);
assert.equal(next.action.type, type);
press(guide({ game: view, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [next.action]); game = settleTruthAlliance(actTruthAlliance(game, f.target, sent[0]));
};
assert.equal(game.allianceOffers[f.partner], f.target);
assert.equal(game.players.find(player => player.id === f.target)!.ally, null, 'A real offer alone has not formed the alliance.');
dispatch('alliance');
assert.equal(game.players.find(player => player.id === f.target)!.ally, f.partner);
assert.notEqual(game.alliancePromises!.find(promise => promise.player === f.target)!.fulfilled, true);
assert.match(markup(viewGame(game, f.target)), /This answer stays binding until this Nexus closes/);
dispatch('ready'); game = closeTruthAllianceNexus(game);
assert.equal(game.nexus, false);
assert.equal(game.alliancePromises!.find(promise => promise.player === f.target)!.fulfilled, true);
assert.deepEqual(truthAllianceInventory(game), f.originalCards);
assert.match(markup(viewGame(game, f.target)), /Fulfilled at the Nexus close/);
assert.equal(AlliancePromises({ game: viewGame(game, f.asker), busy: false, send() {} }), null);
}
});
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import {
createTruthAllianceFixture as fixture, bindTruthAlliance as bind,
declareTruthAlliance as declare, askTruthAlliance as ask, answerTruthAlliance as answer,
actTruthAlliance as act, closeTruthAllianceNexus as close, truthAlliancePlayer as player,
truthAllianceInventory as inventory,
} from './fixture-truth-alliance';
for (const answer of ['yes', 'no'] as const) void test(`controlled prior foreign shipment impossibility settles before a still-owned Alliance ${answer}`, () => {
const f = fixture(), bound = bind(f, answer);
const controlled = structuredClone(bound.game);
// Only this prior debt is controlled: it represents the already-lost funded
// route after a foreign event. Nexus, cards, invitation and answer are earned.
controlled.shipmentPromises = [{ turn: controlled.turn, player: f.target, asker: f.asker,
claim: { territory: 'carthag', minimum: 20 }, answer: true }];
const game = act(controlled, f.partner, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(game.shipmentPromises![0].released, true);
const promise = game.alliancePromises![0];
assert.ok(!promise.fulfilled && !promise.released);
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, answer === 'yes' ? 'alliance' : 'ready');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truth cards preserve an owed native bribe before accepting its named Nexus partner', () => {
const f = fixture();
let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.partner, minimum: 3,
} });
assert.ok(viewGame(game, f.target).truthBribeAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, f.partner);
assert.ok(viewGame(game, f.target).truthAllianceAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment);
const before = player(game, f.partner).spice;
game = act(game, f.target, payment.action);
assert.equal(player(game, f.target).spice, 7);
assert.equal(player(game, f.partner).spice, before);
assert.equal(player(game, f.partner).bribes, 3);
assert.ok(game.bribePromises![0].fulfilled);
const acceptance = viewGame(game, f.target).alliancePromiseGuide; assert.ok(acceptance);
game = act(game, f.target, acceptance.action);
assert.equal(player(game, f.target).ally, f.partner);
assert.ok(!game.alliancePromises![0].fulfilled);
game = close(game);
assert.ok(game.alliancePromises![0].fulfilled);
assert.equal(player(game, f.partner).bribes, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
+166
View File
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actTruthAlliance as act, answerTruthAlliance as answer, askTruthAlliance as ask, bindTruthAlliance as bind,
closeTruthAllianceNexus as close, createTruthAllianceFixture as fixture, declareTruthAlliance as declare,
finishTruthAlliance as finish, truthAllianceInventory as inventory, truthAllianceNexus as nexus, truthAlliancePlayer as player,
} from './fixture-truth-alliance';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
const promise = (game: Game, target: string) => game.alliancePromises!.find(promise => promise.player === target)!;
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current Nexus Yes accepts real consent and fulfills only at actual alliance closure`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.game.turn, 2); assert.ok(nexus(f.game)); assert.equal(f.originalCards.length, 33);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.offer!.steps[0].actor, f.partner); assert.equal(f.game.allianceOffers[f.partner], f.target);
assert.equal(player(f.game, f.target).ally, null);
const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
assert.ok(!promise(bound.game, f.target).fulfilled); rejected(bound.game, f.target, { type: 'ready' });
const guide = viewGame(bound.game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner);
let game = act(bound.game, f.target, guide.action);
assert.equal(player(game, f.target).ally, f.partner); assert.equal(player(game, f.partner).ally, f.target);
assert.ok(!promise(game, f.target).fulfilled, 'Actual acceptance is still provisional until this Nexus closes');
game = close(game);
assert.equal(game.nexus, false); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).ally, f.partner);
assert.equal(viewGame(game, f.target).alliancePromiseNexus, null);
game = finish(game); assert.equal(game.phase, 2); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native No for an existing reciprocal alliance breaks it before owned readiness and waits for closure', () => {
const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game;
assert.equal(player(game, f.target).ally, f.partner); rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null);
game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).ally, null); assert.equal(player(game, f.partner).ally, null);
assert.ok(!promise(game, f.target).fulfilled);
game = close(game); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native final No permits an interim newly formed alliance but requires its own break before Ready', () => {
const f = fixture({ consent: 'none' }); let game = bind(f, 'no').game;
game = act(game, f.partner, { type: 'alliance', target: f.target });
game = act(game, f.target, { type: 'alliance', target: f.partner });
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).ally, null); game = close(game);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native absent foreign consent offers No or unknown without inventing an alliance and preserves the saved card', () => {
const f = fixture({ consent: 'none' }), declared = declare(f);
let game = ask(declared.game, f.asker, f.target, f.partner);
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['no', 'unknown']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, 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.equal(player(game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 2);
assert.equal(game.alliancePromises?.length ?? 0, 0);
game = act(game, f.partner, { type: 'alliance', target: f.target });
game = bind(f, 'yes', f.partner, game).game; game = close(game);
assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native binding reopens the respondent own readiness and cannot finish with unmatched membership', () => {
const f = fixture(); let game = act(f.game, f.target, { type: 'ready' });
assert.ok(game.ready.includes(f.target)); game = bind(f, 'yes', f.partner, game).game;
assert.ok(!game.ready.includes(f.target)); rejected(game, f.target, { type: 'ready' });
game = close(game); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two physical Truth copies constrain the same final partner without settling at proposal or acceptance', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.partner); game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, f.partner);
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
assert.equal(game.alliancePromises!.length, 2); assert.ok(game.alliancePromises!.every(promise => !promise.fulfilled));
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
assert.ok(game.alliancePromises!.every(promise => !promise.fulfilled)); game = close(game);
assert.ok(game.alliancePromises!.every(promise => promise.fulfilled));
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two actual foreign offers cannot answer Yes to two different final partners in one Nexus', () => {
const f = fixture(); let game = act(f.game, f.asker, { type: 'alliance', target: f.target });
assert.equal(game.allianceOffers[f.partner], f.target); assert.equal(game.allianceOffers[f.asker], f.target);
game = declare(f, game, { copies: 2 }).game;
game = ask(game, f.asker, f.target, f.partner); game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, f.asker);
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
game = close(game); assert.equal(player(game, f.target).ally, f.partner);
assert.ok(game.alliancePromises!.every(promise => promise.fulfilled)); assert.deepEqual(inventory(game), f.originalCards);
});
for (const consent of ['offered', 'allied'] as const) void test(`native opposing ${consent === 'offered' ? 'offer withdrawal' : 'alliance break'} releases a Yes with no owned acceptance left`, () => {
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(game.allianceOffers[f.partner], undefined);
assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled);
game = close(game); assert.equal(player(game, f.target).ally, null);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native respondent cannot erase the only final Yes witness by its own actual alliance break', () => {
const f = fixture({ consent: 'allied' }), game = bind(f).game;
rejected(game, f.target, { type: 'alliance', target: null });
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game, f.target).released);
const closed = close(game); assert.equal(promise(closed, f.target).fulfilled, true);
});
void test('native current Nexus question ownership and exact guide event reject foreign and stale callbacks', () => {
const f = fixture(), declared = declare(f);
rejected(declared.game, f.opponent, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, partner: f.target } });
let game = ask(declared.game, f.asker, f.target, f.partner);
assert.ok(viewGame(game, f.target).truthAllianceAnswers!.includes('yes'));
assert.equal(viewGame(game, f.asker).truthAllianceAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.ok(guide.action.alliancePromiseEvent);
assert.ok(viewGame(game, f.target).alliancePromises.every(promise => !Object.hasOwn(promise, 'source')));
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-owned-event' });
game = act(game, f.partner, { type: 'alliance', target: null });
assert.equal(promise(game, f.target).released, true);
game = act(game, f.partner, { type: 'alliance', target: f.target });
rejected(game, f.target, guide.action);
game = act(game, f.target, { type: 'alliance', target: f.partner });
assert.equal(player(game, f.target).ally, f.partner); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies answer and accept a real current foreign Nexus offer legally', () => {
const f = fixture(), declared = declare(f), asked = ask(declared.game, f.asker, f.target, f.partner);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(action, difficulty);
assert.ok(view.truthAllianceAnswers!.some(answer => answer === action.answer));
const answered = act(structuredClone(asked), f.target, action);
assert.equal(answered.discard.filter(card => card.id === f.truth).length, action.answer === 'unknown' ? 0 : 1);
assert.deepEqual(inventory(answered), f.originalCards);
}
const bound = bind(f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'alliance' && action.target === f.partner); assert.ok(action, difficulty);
const accepted = act(structuredClone(bound), f.target, action);
assert.equal(player(accepted, f.target).ally, f.partner); assert.ok(!promise(accepted, f.target).fulfilled);
assert.deepEqual(inventory(close(accepted)), f.originalCards);
}
});