Support whole and mixed final Nexus alliance questions

This commit is contained in:
Quintine committed 2026-10-26 17:26:05 +11:00
1 parent 0d979e4f8b
commit 05330ca7c9
26 files changed
+2408 -73

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES,
AllianceExpressionPromiseError, allianceExpressionOutcomes, allianceExpressionPromisesMatch,
allianceExpressionText, evaluateAllianceExpression, findAllianceExpressionPlans,
liveAllianceExpressionPromises, parseAllianceExpression, parseAllianceExpressionClaim,
validateAllianceExpressionPromises, type AllianceAction, type AllianceExpression,
type AllianceExpressionContext, type AllianceExpressionPromise,
} from '../game/alliance-expression-promises';
import { findAlliancePromisePlan, type AllianceOpportunity, type AlliancePromise } from '../game/alliance-promises';
import { quoteNexusAlliance } from '../game/nexus-alliance';
const seats = ['respondent', 'partner-a', 'partner-b', 'partner-c', 'asker'];
const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' });
const leaf = (partner: string): AllianceExpression => ({ kind: 'partner', partner });
const group = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression => ({ kind, terms });
const either = () => group('or', leaf('partner-a'), leaf('partner-b'));
function promise(event: string, claim = either(), extra: Partial<AllianceExpressionPromise> = {}): AllianceExpressionPromise {
return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', claim, answer: true, ...extra };
}
function scalar(event: string, partner: string, answer: boolean): AlliancePromise {
return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', partner, answer };
}
function context(): AllianceExpressionContext {
return { players: seats.map(id => ({ id, ally: null })), offers: {} };
}
function action(state: AllianceExpressionContext, actor: string, target: string | null): AllianceExpressionContext {
const quoted = quoteNexusAlliance({ ...state, actor, target });
return { players: state.players.map(seat => ({ ...seat, ally: quoted.allies[seat.id] })), offers: quoted.offers };
}
function invitations(): AllianceExpressionContext {
return action(action(context(), 'partner-a', 'respondent'), 'partner-b', 'respondent');
}
function execute(state: AllianceExpressionContext, plan: AllianceAction[]): AllianceExpressionContext {
return plan.reduce((current, step) => action(current, 'respondent', step.target), state);
}
const finalAlly = (state: AllianceExpressionContext) => state.players.find(seat => seat.id === 'respondent')!.ally;
function freeze(value: unknown): void {
if (!value || typeof value !== 'object') return;
for (const child of Object.values(value as Record<string, unknown>)) freeze(child);
Object.freeze(value);
}
void test('alliance expression claims retain full nesting and exact public partner text without private metadata', () => {
const claim = { turn: 2, claim: group('and', either(), leaf('partner-a')) };
const parsed = parseAllianceExpressionClaim(claim);
assert.deepEqual(parsed, claim); assert.notEqual(parsed.claim, claim.claim);
assert.equal(allianceExpressionText(parsed.claim, id => id === 'partner-a' ? 'Atreides' : 'Emperor'),
'((be allied with Atreides when this Nexus ends OR be allied with Emperor when this Nexus ends) AND be allied with Atreides when this Nexus ends)');
for (const turn of [0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '2', null])
assert.throws(() => parseAllianceExpressionClaim({ ...claim, turn }), AllianceExpressionPromiseError);
for (const extra of [{ source: 'private-source' }, { event: 'private-event' }, { answer: true }])
assert.throws(() => parseAllianceExpressionClaim({ ...claim, ...extra }), AllianceExpressionPromiseError);
});
void test('full alliance parsing enforces all branches, two-child groups, sixteen leaves and four grouping levels', () => {
assert.equal(ALLIANCE_EXPRESSION_MAX_LEAVES, 16); assert.equal(ALLIANCE_EXPRESSION_MAX_DEPTH, 4);
const nested = (depth: number): AllianceExpression => depth === 0 ? leaf('partner-a') :
group('and', nested(depth - 1), leaf('partner-b'));
assert.deepEqual(parseAllianceExpression(nested(4)), nested(4));
assert.deepEqual(parseAllianceExpression(group('or', ...Array.from({ length: 16 }, () => leaf('partner-a')))),
group('or', ...Array.from({ length: 16 }, () => leaf('partner-a'))));
const invalid: unknown[] = [null, [], {}, { kind: 'partner', partner: '' }, { kind: 'partner', partner: ' partner-a' },
{ kind: 'partner', partner: 'partner-a ' }, { kind: 'partner', partner: null },
{ kind: 'partner', partner: 'partner-a', source: 'private' }, { kind: 'and', terms: [] },
{ kind: 'or', terms: [leaf('partner-a')] }, { kind: 'not', terms: [leaf('partner-a'), leaf('partner-b')] },
{ kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: 2 }] },
group('or', ...Array.from({ length: 17 }, () => leaf('partner-a'))), nested(5),
group('and', ...Array.from({ length: 9 }, () => either()))];
for (const value of invalid) assert.throws(() => parseAllianceExpression(value), AllianceExpressionPromiseError);
const hiddenInvalid = { kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: '' }] } as AllianceExpression;
assert.throws(() => evaluateAllianceExpression(hiddenInvalid, 'partner-a'), AllianceExpressionPromiseError);
});
void test('positive final truth uses one native partner and a whole No negates the whole nested question', () => {
const both = group('and', leaf('partner-a'), leaf('partner-b'));
for (const ally of [null, 'partner-a', 'partner-b', 'partner-c']) {
assert.equal(evaluateAllianceExpression(both, ally), false);
assert.equal(allianceExpressionPromisesMatch([promise('no-both', both, { answer: false })], 'respondent', opportunity(), ally), true);
assert.equal(evaluateAllianceExpression(either(), ally), ally === 'partner-a' || ally === 'partner-b');
}
const nested = group('and', either(), group('or', leaf('partner-b'), leaf('partner-c')));
assert.equal(evaluateAllianceExpression(nested, 'partner-a'), false);
assert.equal(evaluateAllianceExpression(nested, 'partner-b'), true);
assert.equal(evaluateAllianceExpression(group('and', leaf('partner-a'), leaf('partner-a')), 'partner-a'), true);
});
void test('truth-compatible outcomes keep current membership first and intersect scalar and whole answers', () => {
const state = action(invitations(), 'respondent', 'partner-b');
const records = [promise('either')];
assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state), ['partner-b', 'partner-a']);
assert.deepEqual(allianceExpressionOutcomes([scalar('no-b', 'partner-b', false)], records, 'respondent', opportunity(), state), ['partner-a']);
assert.deepEqual(allianceExpressionOutcomes([], [promise('whole-no', either(), { answer: false })],
'respondent', opportunity(), context()), [null, 'partner-c', 'asker']);
assert.deepEqual(allianceExpressionOutcomes([], records, 'absent', opportunity(), state), []);
});
void test('two actual incoming invitations produce both canonical owned OR routes and leave manual alternatives available', () => {
const state = invitations(); const records = [promise('either')];
const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state);
assert.deepEqual(plans, [[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]);
for (const plan of plans) {
const completed = execute(state, plan);
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(completed)), true);
const selected = finalAlly(completed)!;
assert.equal(completed.players.find(seat => seat.id === selected)!.ally, 'respondent');
}
const manual = action(state, 'respondent', 'partner-b');
assert.equal(finalAlly(manual), 'partner-b');
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(manual)), true);
assert.equal(records[0].fulfilled, undefined, 'No branch is stored or fulfilled before actual Nexus close');
});
void test('missing, own, withdrawn or occupied-partner offers cannot invent foreign consent', () => {
const records = [promise('either')];
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), context()), []);
const ownOffer = action(context(), 'respondent', 'partner-a');
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), ownOffer), []);
const oneWithdrawn = action(invitations(), 'partner-a', null);
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), oneWithdrawn),
[[{ type: 'alliance', target: 'partner-b' }]]);
const bothWithdrawn = action(oneWithdrawn, 'partner-b', null);
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), bothWithdrawn), []);
const occupied = action(action(action(context(), 'partner-a', 'respondent'), 'partner-c', 'partner-a'), 'partner-a', 'partner-c');
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), occupied), []);
});
void test('whole OR No can require exactly None while allowing an interim pair until the owned break', () => {
const anyPartner = group('or', ...seats.filter(id => id !== 'respondent').map(leaf));
const records = [promise('no-one', anyPartner, { answer: false })];
const paired = action(invitations(), 'respondent', 'partner-a');
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(paired)), false);
assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), paired), [null]);
const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), paired);
assert.deepEqual(plans, [[{ type: 'alliance', target: null }]]);
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(paired, plans[0]))), true);
assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined);
});
void test('whole AND No retains a matching held pair and all other currently owned final routes', () => {
const state = action(invitations(), 'respondent', 'partner-a');
const records = [promise('not-both', group('and', leaf('partner-a'), leaf('partner-b')), { answer: false })];
const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state);
assert.deepEqual(plans, [[], [{ type: 'alliance', target: null }],
[{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-b' }]]);
assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state),
['partner-a', null, 'partner-b', 'partner-c', 'asker']);
for (const plan of plans) assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(state, plan))), true);
});
void test('joint scalar and whole-expression contradictions have no outcome or invented branch', () => {
const state = invitations();
const cases = [
{ scalars: [scalar('yes-a', 'partner-a', true)], expressions: [promise('no-either', either(), { answer: false })] },
{ scalars: [scalar('no-a', 'partner-a', false)], expressions: [promise('yes-a-twice', group('and', leaf('partner-a'), leaf('partner-a')))] },
{ scalars: [], expressions: [promise('yes-both', group('and', leaf('partner-a'), leaf('partner-b')))] },
{ scalars: [], expressions: [promise('yes-either'), promise('no-either', either(), { answer: false })] },
];
for (const selected of cases) {
assert.deepEqual(allianceExpressionOutcomes(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []);
assert.deepEqual(findAllianceExpressionPlans(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []);
}
});
void test('no live expression preserves the original scalar planner exactly, including no-op and absent-owner behavior', () => {
const old = [promise('old', either(), { source: 'older-nexus' }), promise('released', either(), { released: true })];
for (const state of [context(), invitations(), action(invitations(), 'respondent', 'partner-a')])
for (const records of [[], [scalar('yes-a', 'partner-a', true)], [scalar('no-a', 'partner-a', false)]]) {
const expected = findAlliancePromisePlan(records, 'respondent', opportunity(), state);
assert.deepEqual(findAllianceExpressionPlans(records, old, 'respondent', opportunity(), state), expected === null ? [] : [expected]);
}
assert.deepEqual(findAllianceExpressionPlans([], [], 'absent', opportunity(), context()), []);
});
void test('original turn, Nexus source, owner and terminal state isolate the live whole-expression records', () => {
const live = promise('live');
const records = [live, promise('old-turn', leaf('partner-c'), { turn: 1 }),
promise('other-source', leaf('partner-c'), { source: 'other-worm-same-turn' }),
promise('other-owner', leaf('partner-c'), { player: 'partner-a' }),
promise('fulfilled', leaf('partner-c'), { fulfilled: true }), promise('released', leaf('partner-c'), { released: true })];
assert.deepEqual(liveAllianceExpressionPromises(records, 'respondent', opportunity()), [live]);
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-c'), false);
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), invitations()),
[[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]);
});
void test('expression records validate source, unique event, every personal partner and terminal flags without mutating inputs', () => {
const records = [promise('live'), promise('done', leaf('partner-a'), { fulfilled: true }),
promise('released-no', leaf('asker'), { answer: false, released: true })];
const state = invitations(), scalars = [scalar('no-c', 'partner-c', false)];
const before = structuredClone({ records, state, scalars });
freeze(records); freeze(state); freeze(scalars);
assert.doesNotThrow(() => validateAllianceExpressionPromises(records, seats));
assert.doesNotThrow(() => validateAllianceExpressionPromises(JSON.parse(JSON.stringify(records)) as AllianceExpressionPromise[], seats));
parseAllianceExpressionClaim({ turn: 2, claim: records[0].claim });
allianceExpressionText(records[0].claim); evaluateAllianceExpression(records[0].claim, 'partner-a');
allianceExpressionOutcomes(scalars, records, 'respondent', opportunity(), state);
findAllianceExpressionPlans(scalars, records, 'respondent', opportunity(), state);
assert.deepEqual({ records, state, scalars }, before);
const base = promise('event');
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { event: ' event' }, { source: '' }, { source: ' source' },
{ player: 'missing' }, { asker: 'missing' }, { asker: 'respondent' }, { answer: 'yes' },
{ turn: 0 }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true },
{ claim: group('or', leaf('partner-a'), leaf('missing')) },
{ claim: group('or', leaf('partner-a'), leaf('respondent')) }].map(extra => [{ ...base, ...extra }])];
for (const value of invalid)
assert.throws(() => validateAllianceExpressionPromises(value as AllianceExpressionPromise[], seats), AllianceExpressionPromiseError);
assert.deepEqual({ records, state, scalars }, before);
});
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { AllianceExpression } from '../game/alliance-expression-promises';
import type { AllianceActionClaim } from '../game/mixed-alliance-question';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthAlliance, advanceTruthAlliance, answerTruthAlliance, closeTruthAllianceNexus,
createTruthAllianceFixture, declareTruthAlliance, finishTruthAlliance, nextTruthAlliance,
settleTruthAlliance, truthAllianceClean, truthAllianceInventory, truthAllianceNexus,
truthAlliancePlayer, type TruthAllianceStep,
} from './fixture-truth-alliance';
export type TruthAllianceExpressionStep = TruthAllianceStep;
export type TruthMixedAllianceExpression = MixedActionExpression<AllianceActionClaim>;
export const actTruthAllianceExpression = actTruthAlliance;
export const advanceTruthAllianceExpression = advanceTruthAlliance;
export const answerTruthAllianceExpression = answerTruthAlliance;
export const closeTruthAllianceExpressionNexus = closeTruthAllianceNexus;
export const finishTruthAllianceExpression = finishTruthAlliance;
export const nextTruthAllianceExpression = nextTruthAlliance;
export const settleTruthAllianceExpression = settleTruthAlliance;
export const truthAllianceExpressionClean = truthAllianceClean;
export const truthAllianceExpressionInventory = truthAllianceInventory;
export const truthAllianceExpressionNexus = truthAllianceNexus;
export const truthAllianceExpressionPlayer = truthAlliancePlayer;
export const actTruthMixedAlliance = actTruthAlliance;
export const advanceTruthMixedAlliance = advanceTruthAlliance;
export const answerTruthMixedAlliance = answerTruthAlliance;
export const closeTruthMixedAllianceNexus = closeTruthAllianceNexus;
export const finishTruthMixedAlliance = finishTruthAlliance;
export const nextTruthMixedAlliance = nextTruthAlliance;
export const settleTruthMixedAlliance = settleTruthAlliance;
export const truthMixedAllianceClean = truthAllianceClean;
export const truthMixedAllianceInventory = truthAllianceInventory;
export const truthMixedAllianceNexus = truthAllianceNexus;
export const truthMixedAlliancePlayer = truthAlliancePlayer;
export const truthAllianceExpressionPartner = (partner: string): AllianceExpression => ({ kind: 'partner', partner });
export const truthAllianceExpressionGroup = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression =>
({ kind, terms });
export const truthMixedAllianceFact = (fact: TruthFact): TruthMixedAllianceExpression => ({ kind: 'fact', fact });
export const truthMixedAlliancePartner = (partner: string): TruthMixedAllianceExpression =>
({ kind: 'action', action: { partner } });
export const truthMixedAllianceGroup = (kind: 'and' | 'or', ...terms: TruthMixedAllianceExpression[]): TruthMixedAllianceExpression =>
({ kind, terms });
type SourceOptions = Parameters<typeof createTruthAllianceFixture>[0] & { secondOffer?: boolean };
/** Thin original H/A/E base33 adapter. The optional second invitation is an
* actual Harkonnen action after the original Worm and native Atreides offer.
* No played hand, purse, offer, alliance, promise or answer-time fact is set. */
export function createTruthAllianceExpressionFixture(options: SourceOptions & { claim?: AllianceExpression } = {}) {
const { claim: suppliedClaim, secondOffer = false, ...sourceOptions } = options;
const base = createTruthAllianceFixture(sourceOptions);
const actions = structuredClone(base.sourceActions);
let game = base.game;
let secondInvitation: { before: Game; steps: TruthAllianceStep[]; after: Game } | null = null;
if (secondOffer) {
const before = structuredClone(game), from = actions.length;
game = actTruthAlliance(game, base.asker, { type: 'alliance', target: base.target }, actions);
assert.equal(game.allianceOffers[base.asker], base.target);
secondInvitation = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
}
const claim = structuredClone(suppliedClaim ?? truthAllianceExpressionGroup('or',
truthAllianceExpressionPartner(base.asker), truthAllianceExpressionPartner(base.opponent)));
const question = { kind: 'allianceExpression' as const, target: base.target, turn: game.turn, claim };
assert.deepEqual(truthAllianceInventory(game), base.originalCards);
return { ...base, game, sourceGame: structuredClone(game), allianceSource: base, claim, question,
questionAction: { type: 'truthAsk', question } satisfies Action,
secondOffer, secondInvitation, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
sourceCounts: { inherited: base.sourceActionCount, additionalInvitations: Number(secondOffer) },
sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw final-partner expression and, if requested, adds one real Harkonnen invitation. It installs no aggregate or compiled branch.` };
}
export type TruthAllianceExpressionFixture = ReturnType<typeof createTruthAllianceExpressionFixture>;
export function declareTruthAllianceExpression(f: TruthAllianceExpressionFixture, source = f.game,
options: { copies?: 1 | 2 } = {}) {
return declareTruthAlliance(f.allianceSource, source, options);
}
export function askTruthAllianceExpression(game: Game, asker: string, target: string, claim: AllianceExpression,
actions: TruthAllianceStep[] = []): Game {
return actTruthAlliance(game, asker, { type: 'truthAsk', question: {
kind: 'allianceExpression', target, turn: game.turn, claim: structuredClone(claim),
} }, actions);
}
export function bindTruthAllianceExpression(f: TruthAllianceExpressionFixture, answer: 'yes' | 'no' = 'yes',
claim = f.claim, source = f.game) {
const declared = declareTruthAllianceExpression(f, source), actions = [...declared.actions];
const asked = askTruthAllianceExpression(declared.game, f.asker, f.target, claim, actions);
const game = answerTruthAlliance(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
export function truthAllianceExpressionPromise(game: Game,
target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).allianceExpressionPromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original whole final-partner question and aggregate');
return row;
}
export function createTruthMixedAllianceFixture(options: SourceOptions & { mixed?: TruthMixedAllianceExpression } = {}) {
const { mixed: suppliedMixed, ...sourceOptions } = options;
const base = createTruthAllianceExpressionFixture(sourceOptions);
const shield = base.setup.deck.find(card => card.kind === 'shield');
const hajr = base.setup.deck.find(card => card.effect === 'hajr');
assert.ok(shield && hajr, 'Original unopened base33 supplies printed current-fact identities');
const shieldFact: TruthFact = { kind: 'hand', name: shield.name };
const hajrFact: TruthFact = { kind: 'hand', name: hajr.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
const mixed = structuredClone(suppliedMixed ?? truthMixedAllianceGroup('and', truthMixedAllianceFact(spiceFact),
truthMixedAllianceGroup('or', truthMixedAlliancePartner(base.asker), truthMixedAlliancePartner(base.opponent))));
const question = { kind: 'mixedAlliance' as const, target: base.target, turn: base.game.turn, mixed };
return { ...base, expressionSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceDisclosure: `${base.sourceDisclosure} Current facts and final actual partnership share one raw AND/OR question. Facts and residual clauses are never staged.` };
}
export type TruthMixedAllianceFixture = ReturnType<typeof createTruthMixedAllianceFixture>;
export function declareTruthMixedAlliance(f: TruthMixedAllianceFixture, source = f.game,
options: { copies?: 1 | 2 } = {}) {
return declareTruthAllianceExpression(f.expressionSource, source, options);
}
export function askTruthMixedAlliance(game: Game, asker: string, target: string, mixed: TruthMixedAllianceExpression,
actions: TruthAllianceStep[] = []): Game {
return actTruthAlliance(game, asker, { type: 'truthAsk', question: {
kind: 'mixedAlliance', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function bindTruthMixedAlliance(f: TruthMixedAllianceFixture, answer: 'yes' | 'no' = 'yes',
mixed = f.mixed, source = f.game) {
const declared = declareTruthMixedAlliance(f, source), actions = [...declared.actions];
const asked = askTruthMixedAlliance(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthAlliance(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
export function truthMixedAlliancePromise(game: Game,
target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).mixedAlliancePromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original mixed question and whole-answer aggregate');
return row;
}
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { AlliancePromiseError } from '../game/alliance-promises';
import { parseAllianceActionClaim, type AllianceActionClaim, type MixedAllianceClaim } from '../game/mixed-alliance-question';
import { compileMixedActionExpression, parseMixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
void test('mixed alliance action leaves retain one exact partner without adding turn or private Nexus bindings', () => {
const input = Object.freeze({ partner: 'exact-partner-id' });
const parsed = parseAllianceActionClaim(input);
assert.deepEqual(parsed, input); assert.notEqual(parsed, input);
assert.deepEqual(parseAllianceActionClaim({ partner: 'asker-can-be-the-partner' }), { partner: 'asker-can-be-the-partner' });
});
void test('mixed alliance action leaves reject metadata, frozen values, groups and missing or inexact identities', () => {
const invalid: unknown[] = [null, undefined, [], 'partner-a', {}, { partner: '' }, { partner: ' ' },
{ partner: ' partner-a' }, { partner: 'partner-a ' }, { partner: 2 }, { partner: null },
{ partner: 'partner-a', turn: 1 }, { partner: 'partner-a', source: 'private' },
{ partner: 'partner-a', event: 'private' }, { partner: 'partner-a', answer: true },
{ partner: 'partner-a', fulfilled: false }, { kind: 'partner', partner: 'partner-a' },
{ kind: 'or', terms: [{ partner: 'partner-a' }, { partner: 'partner-b' }] }];
for (const value of invalid) assert.throws(() => parseAllianceActionClaim(value), AlliancePromiseError);
});
void test('shared mixed parsing rejects a malformed late alliance leaf before a decisive fact can hide it', () => {
const fact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
const parseFact = (): TruthFact => fact;
const input = { kind: 'or', terms: [{ kind: 'fact', fact },
{ kind: 'action', action: { partner: 'partner-a' } }, { kind: 'action', action: { partner: 'partner-b', turn: 1 } }] };
assert.throws(() => parseMixedActionExpression(input, parseFact, parseAllianceActionClaim), AlliancePromiseError);
const valid = { kind: 'and', terms: [{ kind: 'fact', fact }, { kind: 'or', terms: [
{ kind: 'action', action: { partner: 'partner-a' } }, { kind: 'action', action: { partner: 'partner-b' } }] }] };
const parsed = parseMixedActionExpression(valid, parseFact, parseAllianceActionClaim);
assert.deepEqual(parsed, valid);
assert.deepEqual(compileMixedActionExpression(parsed, () => 'yes', true), valid.terms[1]);
});
void test('frozen current facts reduce only the residual final-partner expression and preserve raw mixed text', () => {
const question: MixedAllianceClaim = { turn: 2, mixed: { kind: 'or', terms: [
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } },
{ kind: 'and', terms: [{ kind: 'action', action: { partner: 'partner-a' } },
{ kind: 'action', action: { partner: 'partner-b' } }] },
] } };
const before = structuredClone(question);
assert.deepEqual(compileMixedActionExpression<AllianceActionClaim>(question.mixed, () => 'yes', true),
{ kind: 'constant', value: true });
assert.deepEqual(compileMixedActionExpression(question.mixed, () => 'no', false),
{ kind: 'and', terms: [{ kind: 'action', action: { partner: 'partner-a' } },
{ kind: 'action', action: { partner: 'partner-b' } }] });
assert.deepEqual(question, before);
});
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AllianceExpressionFields, allianceExpressionInputError } from '../components/alliance-expression-fields';
import { AllianceExpressionPromises } from '../components/alliance-expression-promises';
import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { parseAllianceExpression, type AllianceExpression } from '../game/alliance-expression-promises';
import { createTechTokens } from '../game/tech-tokens';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const partners = [{ id: 'r', name: 'Partner' }, { id: 'a', name: 'Asker' }];
const partner = (id = 'r'): AllianceExpression => ({ kind: 'partner', partner: id });
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 find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-alliance-expression-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-alliance-expression-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function editor(initial: AllianceExpression, disabled = false, choices = partners) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => AllianceExpressionFields({ value, partners: choices, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(AllianceExpressionFields, { value, partners: choices, disabled, onChange() {} })),
};
}
function nested(depth: number): AllianceExpression {
let value = partner();
for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, partner('a')] };
return 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) => renderToStaticMarkup(createElement(AllianceExpressionPromises, { game, busy: false, send() {} }));
/** Controlled own projections prove UI behavior. Native consent, answer
* feasibility and the actual Nexus closing event have separate engine proof. */
function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView {
const source = createGame('TRUTHALLIANCEEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
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 = []; game.mixedAlliancePromises = [];
game.allianceExpressionPromises = [{ event: 'controlled-pure-alliance', player: 'p', asker: 'a', turn: 2,
claim: kind === 'break' ? partner() : { kind: 'or', terms: [partner(), partner('a')] }, 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-pure-alliance' } };
return game;
}
void test('all sixteen final partner clauses remain controlled with complete AND/OR branches and singleton collapse', () => {
const e = editor(partner()); click(control(e.tree(), 'or-1'));
assert.deepEqual(e.value, { kind: 'or', terms: [partner(), partner('')] });
assert.ok(allianceExpressionInputError(e.value, partners)); select(e.tree(), '1.2', 'partner', 'a');
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a');
}
assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16);
assert.deepEqual(parseAllianceExpression(e.value), e.value); assert.match(e.html(), /16 \/ 16 conditions/);
assert.equal((e.html().match(/Named alliance partner/g) ?? []).length, 16);
const ids = [...e.html().matchAll(/ id="([^"]+)"/g)].map(match => match[1]); assert.equal(new Set(ids).size, ids.length);
const before = structuredClone(e.value), changes = e.changes;
for (const key of ['add-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.equal(e.changes, changes); assert.deepEqual(e.value, before);
const grouped = editor({ kind: 'and', terms: [partner(), partner('a')] });
click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4);
click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], partner('a'));
click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, partner('a'));
});
void test('four grouping levels and strict envelopes coexist with repairable unnamed or removed partner drafts', () => {
const deep = editor(nested(4)); assert.equal(allianceExpressionInputError(deep.value, partners), null);
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 0); select(deep.tree(), '1.1.1.1.1', 'partner', 'a'); assert.equal(deep.changes, 1);
const draft = editor(partner('removed')); assert.match(draft.html(), /role="alert"/);
click(control(draft.tree(), 'or-1')); assert.ok(draft.value.kind === 'or'); assert.deepEqual(draft.value.terms[0], partner('removed'));
select(draft.tree(), '1.1', 'partner', 'a'); select(draft.tree(), '1.2', 'partner', 'r');
assert.equal(allianceExpressionInputError(draft.value, partners), null);
for (const invalid of [nested(5), { kind: 'and', terms: [partner()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => partner()) },
{ kind: 'partner', partner: '' }, { kind: 'partner', partner: ' r ' }, { kind: 'partner', partner: 'r', source: 'hidden' },
{ kind: 'or', terms: [partner(), partner('a')], binding: true }, { kind: 'alliance', partner: 'r' }])
assert.ok(allianceExpressionInputError(invalid, partners));
assert.ok(allianceExpressionInputError(partner(), []));
});
void test('disabled callbacks and unlisted partner selections preserve the complete controlled expression', () => {
const frozen = editor({ kind: 'and', terms: [partner(), partner('a')] }, true), before = structuredClone(frozen.value);
for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'partner', 'r');
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
const e = editor(partner());
for (const id of ['unknown', '', ' r ', 'p']) select(e.tree(), '1', 'partner', id);
assert.equal(e.changes, 0); select(e.tree(), '1', 'partner', 'a'); assert.deepEqual(e.value, partner('a'));
const empty = editor(partner(), false, []); select(empty.tree(), '1', 'partner', 'r'); assert.equal(empty.changes, 0);
});
void test('own panel retains raw alternatives, whole No and passive settled history without exposing private sources', () => {
const game = projected(); game.alliancePromiseGuide = null;
game.allianceExpressionPromises.push(
{ ...game.allianceExpressionPromises[0], event: 'no-event', answer: false, claim: { kind: 'and', terms: [partner(), partner('a')] } },
{ ...game.allianceExpressionPromises[0], event: 'done-event', fulfilled: true },
{ ...game.allianceExpressionPromises[0], event: 'released-event', released: true },
{ ...game.allianceExpressionPromises[0], event: 'foreign-event', player: 'a', claim: partner('foreign-partner') },
);
for (const promise of game.allianceExpressionPromises) Object.defineProperty(promise, 'source', {
get() { throw Error('No private Nexus source'); }, configurable: true,
});
const html = markup(game);
assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, / OR /); assert.match(html, / AND /);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
assert.match(html, /Yes and No settle only at the actual Nexus close/);
assert.match(html, /Forming an alliance does not fulfill the answer early/); assert.match(html, /Offers alone are not alliances/);
assert.match(html, /Fulfilled at the Nexus close/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /href="\/rules#truthtrance-alliance-expression"/);
assert.doesNotMatch(html, /foreign-partner|controlled-pure-alliance|foreign-event|data-testid="alliance-promise-guide"/);
assert.equal(AllianceExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('exact canonical guides accept either OR alternative, break as preparation and require visible scalar and pure outcomes', () => {
for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'ready'] as const) {
const game = projected(kind, advanced), sent: Action[] = [];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action);
assert.notEqual(game.active, game.me);
}
const alternative = projected(); alternative.allianceOffers = { a: 'p' }; alternative.alliancePromiseGuide!.action.target = 'a';
const sent: Action[] = []; press(guide({ game: alternative, busy: false, send(action) { sent.push(action); } }));
assert.equal(sent[0], alternative.alliancePromiseGuide!.action);
for (const game of [projected(), projected('ready')]) {
game.allianceExpressionPromises[0].claim = partner('a');
assert.equal(guide({ game, busy: false, send() { assert.fail('Visible pure outcome must match'); } }).props.disabled, true);
}
const scalar = projected(); scalar.alliancePromises.push({ event: 'earlier-scalar', player: 'p', asker: 'a', turn: 2,
partner: 'r', answer: false });
assert.equal(guide({ game: scalar, busy: false, send() {} }).props.disabled, true);
const preparation = projected('ready'); preparation.alliancePromiseGuide!.action = { type: 'alliance', target: null,
alliancePromiseEvent: 'controlled-pure-alliance' };
assert.equal(guide({ game: preparation, busy: false, send() {} }).props.disabled, false);
const noReady = projected('ready'); noReady.allianceExpressionPromises[0].answer = false;
assert.equal(guide({ game: noReady, busy: false, send() {} }).props.disabled, true);
});
void test('stale pure callbacks retain exact event, action, public consent context, room and Nexus guards', () => {
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.alliancePromiseNexus = null; },
game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; },
game => { game.allianceOffers.a = 'p'; }, game => { game.players.find(player => player.id === 'a')!.ally = 'r'; },
game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; },
game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; },
game => { game.allianceExpressionPromises[0].event = 'expired'; }, game => { game.allianceExpressionPromises[0].player = 'a'; },
game => { game.allianceExpressionPromises[0].turn = 1; }, game => { game.allianceExpressionPromises[0].fulfilled = true; },
game => { game.allianceExpressionPromises[0].released = true; }, game => { game.allianceExpressionPromises = []; },
game => { game.players = game.players.filter(player => player.id !== 'r'); },
game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
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[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const target of ['p', 'missing']) {
const game = projected(); game.alliancePromiseGuide!.action.target = target;
assert.equal(guide({ game, busy: false, send() {} }).props.disabled, true);
}
for (const event of ['', 'unrelated']) {
const game = projected(); game.alliancePromiseGuide!.action.alliancePromiseEvent = event;
const button = guide({ game, busy: false, send() { assert.fail('Unknown event'); } }); assert.equal(button.props.disabled, true); press(button);
}
const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide'); } };
const oldBusy = guide(busy); busy.busy = true; press(oldBusy);
const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced projection'); } };
const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced);
});
void test('one live scalar or pure panel owns the shared guide while private card, spice and Nexus sources are untouched', () => {
const game = projected(), sent: Action[] = [];
const both = () => renderToStaticMarkup(createElement('div', null,
createElement(AlliancePromises, { game, busy: false, send() {} }),
createElement(AllianceExpressionPromises, { game, busy: false, send() {} })));
const one = () => assert.equal((both().match(/data-testid="alliance-promise-guide"/g) ?? []).length, 1);
game.alliancePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true });
one(); assert.match(markup(game), /data-testid="alliance-promise-guide"/);
game.alliancePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, partner: 'r', answer: true });
one(); assert.doesNotMatch(markup(game), /data-testid="alliance-promise-guide"/);
game.alliancePromises[1].fulfilled = true; one();
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}`); }, configurable: true });
for (const promise of game.allianceExpressionPromises) Object.defineProperty(promise, 'source', { get() { throw Error('No Nexus source'); } });
assert.match(markup(game), /Accept Partner’s alliance invitation/);
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.equal(sent[0], game.alliancePromiseGuide!.action);
game.allianceExpressionPromises[0].fulfilled = true; assert.doesNotMatch(both(), /data-testid="alliance-promise-guide"/);
});
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import {
actTruthAllianceExpression as act, answerTruthAllianceExpression as answer,
askTruthAllianceExpression as ask, askTruthMixedAlliance as askMixed,
closeTruthAllianceExpressionNexus as close, createTruthAllianceExpressionFixture as fixture,
createTruthMixedAllianceFixture as mixedFixture, declareTruthAllianceExpression as declare,
declareTruthMixedAlliance as declareMixed, truthAllianceExpressionInventory as inventory,
truthAllianceExpressionPlayer as player, truthAllianceExpressionPromise as promise,
truthMixedAlliancePromise as mixedPromise,
} from './fixture-truth-alliance-expression';
void test('two original Truths preserve prior scalar alliance No while a later whole OR Yes takes the other real partner', () => {
const f = fixture({ secondOffer: true }); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner,
} });
game = answer(game, f.target, 'no'); game = ask(game, f.asker, f.target, f.claim);
assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['yes', 'no']);
game = answer(game, f.target, 'yes');
assert.equal(viewGame(game, f.target).alliancePromises.length, 1);
assert.equal(viewGame(game, f.target).allianceExpressionPromises.length, 1);
assert.notEqual(viewGame(game, f.target).alliancePromises[0].event, promise(game).event);
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.asker);
game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, f.asker);
assert.ok(!game.alliancePromises![0].fulfilled && !promise(game).fulfilled);
game = close(game); assert.equal(game.alliancePromises![0].fulfilled, true); assert.equal(promise(game).fulfilled, true);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths share one real final partner between earlier scalar Yes and later mixed OR Yes at actual close', () => {
const f = mixedFixture({ secondOffer: true }); let game = declareMixed(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner,
} });
game = answer(game, f.target, 'yes'); game = askMixed(game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes']); game = answer(game, f.target, 'yes');
assert.equal(viewGame(game, f.target).alliancePromises.length, 1);
assert.deepEqual(viewGame(game, f.target).allianceExpressionPromises, []);
assert.notEqual(viewGame(game, f.target).alliancePromises[0].event, mixedPromise(game).event);
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner);
game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, f.partner);
assert.ok(!game.alliancePromises![0].fulfilled); assert.equal(mixedPromise(game).fulfilled, false);
game = close(game); assert.equal(game.alliancePromises![0].fulfilled, true); assert.equal(mixedPromise(game).fulfilled, true);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths pay the earlier native bribe before a later mixed OR accepts its manual alternative without thawing the spice fact', () => {
const f = mixedFixture({ secondOffer: true }); let game = declareMixed(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 = askMixed(game, f.asker, f.target, f.mixed);
assert.ok(viewGame(game, f.target).truthMixedAllianceAnswers?.includes('yes')); game = answer(game, f.target, 'yes');
assert.equal(player(game, f.target).spice, 10); assert.equal(mixedPromise(game).fulfilled, false);
const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment);
const recipientSpice = 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, recipientSpice); assert.equal(player(game, f.partner).bribes, 3);
assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(mixedPromise(game).fulfilled, false);
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
const alternative = guide.action.target === f.asker ? f.partner : f.asker;
game = act(game, f.target, { type: 'alliance', target: alternative }); assert.equal(player(game, f.target).ally, alternative);
assert.equal(mixedPromise(game).fulfilled, false); game = close(game); assert.equal(mixedPromise(game).fulfilled, true);
assert.equal(game.bribePromises![0].fulfilled, true); assert.equal(player(game, f.partner).bribes, 3);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths preserve an earlier whole Yes against a later contradictory scalar alliance No', () => {
const f = fixture({ secondOffer: true }); let game = declare(f, f.game, { copies: 2 }).game;
game = ask(game, f.asker, f.target, { kind: 'partner', partner: f.partner });
game = answer(game, f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner,
} });
assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['yes']);
const before = structuredClone(game);
assert.throws(() => answer(game, f.target, 'no')); assert.deepEqual(game, before);
game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.target, f.partner); game = act(game, f.target, guide.action);
assert.ok(!promise(game).fulfilled && !game.alliancePromises![0].fulfilled);
game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(game.alliancePromises![0].fulfilled, true);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
@@ -0,0 +1,204 @@
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 {
actTruthAllianceExpression as act, answerTruthAllianceExpression as answer,
askTruthAllianceExpression as ask, bindTruthAllianceExpression as bind,
closeTruthAllianceExpressionNexus as close, createTruthAllianceExpressionFixture as fixture,
declareTruthAllianceExpression as declare, finishTruthAllianceExpression as finish,
truthAllianceExpressionGroup as group, truthAllianceExpressionInventory as inventory,
truthAllianceExpressionNexus as nexus, truthAllianceExpressionPartner as partner,
truthAllianceExpressionPlayer as player, truthAllianceExpressionPromise as promise,
} from './fixture-truth-alliance-expression';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} whole alliance OR uses two real invitations and settles only at actual Nexus close`, () => {
const f = fixture({ advanced, secondOffer: true });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.ok(nexus(f.game));
assert.equal(f.game.turn, 2); assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceActionCount, f.sourceCounts.inherited + 1);
assert.equal(f.offer!.steps[0].actor, f.partner); assert.equal(f.secondInvitation!.steps[0].actor, f.asker);
assert.equal(f.game.allianceOffers[f.partner], f.target); assert.equal(f.game.allianceOffers[f.asker], f.target);
assert.equal(player(f.game, f.target).ally, null);
const before = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, before);
assert.deepEqual(promise(game).claim, f.claim); assert.equal(promise(game).answer, true);
assert.ok(!promise(game).fulfilled); rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
assert.ok(guide.action.target === f.asker || guide.action.target === f.partner);
assert.equal(guide.action.alliancePromiseEvent, promise(game).event);
game = act(game, f.target, guide.action); assert.ok(!promise(game).fulfilled);
const chosen = player(game, f.target).ally; assert.ok(chosen === f.asker || chosen === f.partner);
game = close(game); assert.equal(game.nexus, false); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).ally, chosen); assert.equal(promise(game).released ?? false, false);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native whole OR accepts the other real invitation manually without pinning its guide route', () => {
const f = fixture({ secondOffer: true }); let game = bind(f).game;
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
const alternative = guide.action.target === f.asker ? f.partner : f.asker;
assert.equal(game.allianceOffers[alternative], f.target);
game = act(game, f.target, { type: 'alliance', target: alternative });
assert.equal(player(game, f.target).ally, alternative); assert.ok(!promise(game).fulfilled);
game = close(game); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native distinct-partner whole AND has only No and may retain a real single partnership until close', () => {
const f = fixture({ secondOffer: true }), claim = group('and', partner(f.asker), partner(f.partner));
const asked = ask(declare(f).game, f.asker, f.target, claim);
assert.deepEqual(viewGame(asked, f.target).truthAllianceExpressionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'no');
assert.ok(!promise(game).fulfilled); game = act(game, f.target, { type: 'alliance', target: f.partner });
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game).fulfilled);
game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, f.partner);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole OR No breaks an existing named pair and then readies without treating one false leaf as the whole answer', () => {
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.ok(!promise(game).fulfilled); const ready = viewGame(game, f.target).alliancePromiseGuide;
assert.ok(ready); assert.equal(ready.action.type, 'ready'); game = close(game);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, null);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole OR No permits temporary real membership but does not settle until its own break and actual close', () => {
const f = fixture(); let game = bind(f, 'no').game;
game = act(game, f.target, { type: 'alliance', target: f.partner });
assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game).fulfilled);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: null });
assert.ok(!promise(game).fulfilled); game = close(game); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole OR without foreign consent preserves unknown save and reasks the same physical Truth after a real invitation', () => {
const f = fixture({ consent: 'none' }); let game = ask(declare(f).game, f.asker, f.target, f.claim);
assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['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);
game = act(game, f.partner, { type: 'alliance', target: f.target }); game = bind(f, 'yes', f.claim, game).game;
game = close(game); assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native definite whole alliance answer reopens only the respondent existing Ready', () => {
const f = fixture(); let game = act(f.game, f.target, { type: 'ready' });
game = act(game, f.partner, { type: 'ready' }); assert.ok(game.ready.includes(f.target) && game.ready.includes(f.partner));
game = bind(f, 'yes', f.claim, game).game;
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner));
rejected(game, f.target, { type: 'ready' }); game = close(game); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole alliance question and callback retain current context owner turn event and JSON semantics', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthAllianceExpressionTargets.includes(f.target));
rejected(declared.game, f.partner, 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, claim: partner(f.target) } });
let game = ask(declared.game, f.asker, f.target, f.claim);
assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthAllianceExpressionAnswers, null);
assert.equal(viewGame(game, f.partner).truthAllianceExpressionAnswers, 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);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-whole-alliance-event' });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
rejected(game, f.target, wire); game = close(game); rejected(game, f.target, wire);
game = act(game, f.asker, { type: 'card', card: f.truths[1] });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const next = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(next);
game = act(game, next.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask'); rejected(game, f.asker, f.questionAction);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole alliance projection exposes only own original expression and aggregate status', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.partner]) {
const view = viewGame(game, actor); assert.equal(view.truthAllianceExpressionAnswers, null);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
if (actor === f.target) {
const row = promise(game); assert.equal(row.player, f.target); assert.equal(row.asker, f.asker);
assert.equal(row.turn, f.game.turn); assert.deepEqual(row.claim, f.claim); assert.equal(row.answer, true);
assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'claim', 'answer', 'fulfilled', 'released'].includes(key)));
assert.equal(view.alliancePromiseGuide?.action.alliancePromiseEvent, row.event);
} else {
assert.deepEqual(view.allianceExpressionPromises, []); assert.equal(view.alliancePromiseGuide, null);
}
}
assert.equal(game.allianceExpressionPromises!.length, 1); assert.ok(game.allianceExpressionPromises![0].source);
});
void test('native whole OR survives one opposing invitation withdrawal, releases after the last, and never reactivates on reoffer', () => {
const f = fixture({ secondOffer: true }); let game = bind(f).game;
game = act(game, f.partner, { type: 'alliance', target: null }); assert.ok(!promise(game).released);
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.asker);
game = act(game, f.asker, { type: 'alliance', target: null });
assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled);
game = act(game, f.asker, { type: 'alliance', target: f.target }); rejected(game, f.target, guide.action);
assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
game = act(game, f.target, { type: 'alliance', target: f.asker }); game = close(game);
assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole OR retains opposing actual departure rights and does not bind a later renewed consent', () => {
const f = fixture({ consent: 'allied' }); let game = bind(f).game;
rejected(game, f.target, { type: 'alliance', target: null });
game = act(game, f.partner, { type: 'alliance', target: null });
assert.equal(player(game, f.target).ally, null); assert.equal(promise(game).released, true);
game = act(game, f.partner, { type: 'alliance', target: f.target }); game = close(game);
assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled);
assert.equal(player(game, f.target).ally, null); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native nested whole OR and AND permits each complete matching final-partner outcome', () => {
const f = fixture({ secondOffer: true });
const claim = group('or', group('and', partner(f.asker), partner(f.asker)), partner(f.partner));
for (const selected of [f.asker, f.partner]) {
let game = bind(f, 'yes', claim).game; game = act(game, f.target, { type: 'alliance', target: selected });
assert.ok(!promise(game).fulfilled); game = close(game); assert.equal(player(game, f.target).ally, selected);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('all four minimal policies answer whole alliance questions and use their real acceptance and Ready controls legally', () => {
const f = fixture({ secondOffer: true }), asked = ask(declare(f).game, f.asker, f.target, f.claim);
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(action.answer === 'yes' || action.answer === 'no' || action.answer === 'unknown');
assert.ok(view.truthAllianceExpressionAnswers!.includes(action.answer));
const game = act(structuredClone(asked), f.target, action); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
}
for (const difficulty of DIFFICULTIES) {
let game = bind(f).game; const view = viewGame(game, f.target);
view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'alliance'); assert.ok(action, difficulty);
game = act(game, f.target, action); assert.ok(player(game, f.target).ally); assert.ok(!promise(game).fulfilled);
const readyView = viewGame(game, f.target); readyView.players.find(seat => seat.id === f.target)!.bot = difficulty;
const ready = botActions(readyView).find(action => action.type === 'ready'); assert.ok(ready, difficulty);
game = act(game, f.target, ready); game = close(game); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
}
});
@@ -0,0 +1,263 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MixedAllianceFields, mixedAllianceInputError } from '../components/mixed-alliance-fields';
import { MixedAlliancePromises } from '../components/mixed-alliance-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises';
import { AllianceExpressionPromises } from '../components/alliance-expression-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { AllianceActionClaim } from '../game/mixed-alliance-question';
import type { TruthFact } from '../game/truthtrance';
import { createTechTokens } from '../game/tech-tokens';
type Expression = MixedActionExpression<AllianceActionClaim>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const partner = (id = 'r'): Expression => ({ kind: 'action', action: { partner: id } });
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [partner(), partner('a')] }] });
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 find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-alliance-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-mixed-alliance-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function editFact(tree: ReactNode, path: string, next: TruthFact) {
const scope = find(tree, 'data-mixed-alliance-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields, `Current fact fields ${path}`);
(fields.props.onChange as (fact: TruthFact) => void)(next);
}
/** These personalized projections exercise controlled UI and canonical guide
* ownership, without claiming native answer-time snapshots or consent. */
function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView {
const source = createGame('TRUTHMIXEDALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
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 = []; game.allianceExpressionPromises = [];
game.mixedAlliancePromises = [{ event: 'controlled-mixed-alliance', player: 'p', asker: 'a', turn: 2,
mixed: claim(), answer: kind !== 'break', fulfilled: false, released: false }];
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-mixed-alliance' } };
return game;
}
function editor(initial: Expression, disabled = false, game = projected()) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => MixedAllianceFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MixedAllianceFields, { value, game, target: 'p', disabled, onChange() {} })),
error: () => mixedAllianceInputError(value, game, 'p'),
};
}
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) => renderToStaticMarkup(createElement(MixedAlliancePromises, { game, busy: false, send() {} }));
void test('shared mixed editor preserves sixteen clauses and offers every public other partner without predicting consent', () => {
const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'partner', 'r');
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a');
}
assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.equal(e.error(), null);
const html = e.html(), ids = [...html.matchAll(/ id="([^"]+)"/g)].map(match => match[1]);
assert.equal(new Set(ids).size, ids.length); assert.match(html, /16 \/ 16 conditions/);
assert.equal((html.match(/Named alliance partner/g) ?? []).length, 15);
const before = structuredClone(e.value), changes = e.changes;
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-or-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
const game = projected(); game.allianceOffers = {};
game.players.find(player => player.id === 'a')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'a';
const allied = editor({ kind: 'and', terms: [fact(), partner()] }, false, game);
assert.equal(allied.error(), null); assert.match(allied.html(), /<option value="r"/); assert.match(allied.html(), /<option value="a"/);
assert.doesNotMatch(allied.html(), /<option value="p"/);
select(allied.tree(), '1.2', 'partner', 'a'); assert.equal(allied.error(), null);
select(allied.tree(), '1.2', 'partner', 'unlisted'); assert.equal(allied.changes, 1);
});
void test('mixed and current-fact groups share depth four and preserve branches through meaning, timing and singleton collapse', () => {
const e = editor({ kind: 'and', terms: [fact(), partner()] });
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
click(control(e.tree(), 'nested-or-1')); select(e.tree(), '1.3.2', 'partner', 'a');
click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 });
select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1'));
editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 }); assert.equal(e.error(), null);
click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2'));
click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.3'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), partner()] });
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
select(e.tree(), '1.2', 'timing', 'action'); select(e.tree(), '1.2', 'partner', 'r'); assert.equal(e.error(), null);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, partner('r')); assert.ok(e.error());
let nested = fact();
for (let depth = 0; depth < 3; depth++) nested = { kind: 'and', terms: [nested, partner()] };
const deep = editor(nested); click(control(deep.tree(), 'fact-or-1.1.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1', 'fact-and-1.1.1.1.1', 'fact-or-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 1);
});
void test('incomplete partner and fact drafts remain editable while strict envelopes and disabled callbacks are enforced', () => {
const e = editor({ kind: 'and', terms: [fact(), partner()] });
select(e.tree(), '1.2', 'partner', ''); assert.ok(e.error()); click(control(e.tree(), 'or-1.2'));
assert.ok(e.value.kind === 'and'); assert.deepEqual(e.value.terms[1], { kind: 'or', terms: [partner(''), fact()] });
assert.match(e.html(), /role="alert"/); select(e.tree(), '1.2.1', 'partner', 'a'); assert.equal(e.error(), null);
for (const invalid of [NaN, -1, 0.5, Number.MAX_SAFE_INTEGER + 1, Infinity]) {
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: invalid }); assert.ok(e.error());
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 0 }); assert.equal(e.error(), null);
}
const game = projected();
for (const invalid of [fact(), partner(), { ...claim(), binding: true },
{ kind: 'and', terms: [fact(), { kind: 'constant', value: true }, partner()] },
{ kind: 'and', terms: [fact(), { kind: 'action', action: { partner: 'p' } }] },
{ kind: 'and', terms: [fact(), { kind: 'action', action: { partner: 'r', source: 'hidden' } }] },
{ kind: 'and', terms: [fact(), { kind: 'action', action: { partner: ' r ' } }] }])
assert.ok(mixedAllianceInputError(invalid, game, 'p'));
const frozen = editor({ kind: 'and', terms: [fact(), partner()] }, true), before = structuredClone(frozen.value);
for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.1', 'timing', 'action');
select(frozen.tree(), '1.2', 'partner', 'a'); editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('editor and shared guide use public choices without private custody, balances, sources or compiled mixed facts', () => {
const game = projected(), value: Expression = { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), partner()] };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'bribes'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const promise of game.mixedAlliancePromises) for (const key of ['source', 'binding', 'residual', 'factAnswers', 'compiled', 'frozenFact'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key}`); }, configurable: true });
const html = renderToStaticMarkup(createElement(MixedAllianceFields, { value, game, target: 'p', disabled: false, onChange() {} }));
assert.match(html, /Current facts are evaluated when the player answers/); assert.match(html, /Only the aggregate answer is shared/);
assert.match(html, /unknown current fact can still allow a definite whole answer/); assert.match(html, /Offers alone are not alliances/);
assert.equal(mixedAllianceInputError(value, game, 'p'), null);
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.equal(sent[0], game.alliancePromiseGuide!.action);
});
void test('own mixed panel retains only raw questions, aggregate outcomes and passive constant or terminal records', () => {
const game = projected(); game.alliancePromiseGuide = null;
game.mixedAlliancePromises.push(
{ ...game.mixedAlliancePromises[0], event: 'no-event', answer: false },
{ ...game.mixedAlliancePromises[0], event: 'constant-event', fulfilled: true },
{ ...game.mixedAlliancePromises[0], event: 'released-event', released: true },
{ ...game.mixedAlliancePromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'and', terms: [
fact({ kind: 'hand', name: 'Poison Blade' }), partner('foreign-partner'),
] } },
);
for (const promise of game.mixedAlliancePromises) for (const key of ['source', 'binding', 'residual', 'factAnswers', 'compiled', 'frozenFact'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key}`); }, configurable: true });
const html = markup(game);
assert.match(html, /you hold Shield/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /Individual fact answers and any reduced future branch remain private/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /A remaining alliance obligation settles only at the actual Nexus close/);
assert.match(html, /forming an alliance does not fulfill it early/); assert.match(html, /Offers alone are not alliances/);
assert.match(html, /constant whole answer can already be complete without an alliance obligation/);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
assert.match(html, /href="\/rules#truthtrance-mixed-alliance"/);
assert.doesNotMatch(html, /Poison Blade|foreign-partner|controlled-mixed-alliance|constant-event|factAnswers|data-testid="alliance-promise-guide"/);
assert.equal(MixedAlliancePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('exact mixed guide forwards owned reciprocal acceptance, preparation and Ready without inferring raw mixed residuals', () => {
for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'ready'] as const) {
const game = projected(kind, advanced), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action);
assert.notEqual(game.active, game.me);
}
// The same raw OR question can have a private constant-true or an owned
// alliance residual. The client forwards the canonical server-owned guide.
const game = projected('ready'); game.players.find(player => player.id === 'p')!.ally = null;
game.players.find(player => player.id === 'r')!.ally = null;
game.mixedAlliancePromises[0].mixed = { kind: 'or', terms: [fact({ kind: 'spice', compare: 'gte', value: 9 }), partner()] };
Object.defineProperty(game.players.find(player => player.id === 'p')!, 'spice', { get() { throw Error('No current-fact reevaluation'); } });
const sent: Action[] = []; const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action);
const noConsent = projected(); noConsent.allianceOffers = {};
assert.equal(guide({ game: noConsent, busy: false, send() {} }).props.disabled, true);
const scalar = projected(); scalar.alliancePromises.push({ event: 'earlier-scalar', player: 'p', asker: 'a', turn: 2,
partner: 'r', answer: false }); assert.equal(guide({ game: scalar, busy: false, send() {} }).props.disabled, true);
});
void test('stale mixed callbacks preserve exact owner, event, public context, Nexus, quiet-window and room guards', () => {
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.alliancePromiseNexus = null; },
game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; }, game => { game.allianceOffers.a = 'p'; },
game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; },
game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; },
game => { game.alliancePromiseGuide!.action.alliancePromiseEvent = 'other'; },
game => { game.mixedAlliancePromises[0].event = 'expired'; }, game => { game.mixedAlliancePromises[0].player = 'a'; },
game => { game.mixedAlliancePromises[0].turn = 1; }, game => { game.mixedAlliancePromises[0].fulfilled = true; },
game => { game.mixedAlliancePromises[0].released = true; }, game => { game.mixedAlliancePromises = []; },
game => { game.players.find(player => player.id === 'a')!.ally = 'r'; },
game => { game.players = game.players.filter(player => player.id !== 'r'); },
game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
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[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide'); } };
const oldBusy = guide(busy); busy.busy = true; press(oldBusy);
const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced projection'); } };
const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced);
for (const event of ['', 'unrelated']) {
const game = projected(); game.alliancePromiseGuide!.action.alliancePromiseEvent = event;
const button = guide({ game, busy: false, send() { assert.fail('Unknown event'); } }); assert.equal(button.props.disabled, true); press(button);
}
});
void test('one live scalar, pure or mixed panel owns the common guide while settled histories remain passive', () => {
const game = projected();
const all = () => renderToStaticMarkup(createElement('div', null,
createElement(AlliancePromises, { game, busy: false, send() {} }),
createElement(AllianceExpressionPromises, { game, busy: false, send() {} }),
createElement(MixedAlliancePromises, { game, busy: false, send() {} })));
const one = () => assert.equal((all().match(/data-testid="alliance-promise-guide"/g) ?? []).length, 1);
assert.match(markup(game), /data-testid="alliance-promise-guide"/); one();
game.alliancePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true });
game.allianceExpressionPromises.push({ event: 'pure-history', player: 'p', asker: 'a', turn: 1,
claim: { kind: 'partner', partner: 'r' }, answer: true, fulfilled: true });
assert.match(markup(game), /data-testid="alliance-promise-guide"/); one();
game.allianceExpressionPromises.push({ event: 'live-pure', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'partner', partner: 'r' }, answer: true });
assert.doesNotMatch(markup(game), /data-testid="alliance-promise-guide"/); one();
game.alliancePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, partner: 'r', answer: true });
assert.doesNotMatch(renderToStaticMarkup(createElement(AllianceExpressionPromises, { game, busy: false, send() {} })), /data-testid="alliance-promise-guide"/);
one(); game.alliancePromises[1].fulfilled = true; one();
game.allianceExpressionPromises[1].fulfilled = true; assert.match(markup(game), /data-testid="alliance-promise-guide"/); one();
game.mixedAlliancePromises[0].fulfilled = true; assert.doesNotMatch(all(), /data-testid="alliance-promise-guide"/);
});
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
actTruthMixedAlliance as act, answerTruthMixedAlliance as answer, askTruthMixedAlliance as ask,
bindTruthMixedAlliance as bind, closeTruthMixedAllianceNexus as close,
createTruthMixedAllianceFixture as fixture, declareTruthMixedAlliance as declare,
finishTruthMixedAlliance as finish, truthMixedAllianceFact as fact,
truthMixedAllianceGroup as group, truthMixedAllianceInventory as inventory,
truthMixedAllianceNexus as nexus, truthMixedAlliancePartner as partner,
truthMixedAlliancePlayer as player, truthMixedAlliancePromise as promise,
} from './fixture-truth-alliance-expression';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed final-alliance OR freezes genuine ten spice before a real debit and either consent path`, () => {
const f = fixture({ advanced, secondOffer: true });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.ok(nexus(f.game));
assert.deepEqual(f.staging, []); assert.equal(f.originalCards.length, 33);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.sourceCounts.inherited + 1);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
assert.equal(truthFactAnswer(player(f.game, f.target), f.shieldFact), 'yes');
const before = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, before);
assert.deepEqual(promise(game).mixed, f.mixed); assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.asker).bribes, 3);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(promise(game).fulfilled, false); rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
const selected = guide.action.target === f.asker ? f.partner : f.asker;
game = act(game, f.target, { type: 'alliance', target: selected });
assert.equal(player(game, f.target).ally, selected); assert.equal(promise(game).fulfilled, false);
game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).spice, 7); truthOnce(game, f.truth);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true current fact OR final alliance is a fulfilled constant that still reopens only its respondent Ready', () => {
const f = fixture(), mixed = group('or', fact(f.shieldFact), partner(f.partner));
let game = act(f.game, f.target, { type: 'ready' }); game = act(game, f.partner, { type: 'ready' });
game = ask(declare(f, game).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner));
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(game.allianceExpressionPromises?.length ?? 0, 0);
assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
game = act(game, f.target, { type: 'alliance', target: f.partner }); game = close(game);
assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false current fact AND final alliance has only constant No and keeps an actual later acceptance legal', () => {
const f = fixture(), mixed = group('and', fact(f.hajrFact), partner(f.partner));
assert.equal(truthFactAnswer(player(f.game, f.target), f.hajrFact), 'no');
let game = act(f.game, f.target, { type: 'ready' });
game = ask(declare(f, game).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
assert.ok(!game.ready.includes(f.target)); assert.equal(promise(game).fulfilled, true);
assert.equal(game.allianceExpressionPromises?.length ?? 0, 0);
game = act(game, f.target, { type: 'alliance', target: f.partner }); game = close(game);
assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true);
assert.equal(promise(game).released, false); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown forecast takes the conservative whole mixed alliance ${answerValue} obligation`, () => {
const f = fixture(), respondent = player(f.game, f.target), knowledge = truthKnowledgeOf(f.game, respondent);
assert.equal(knowledge.stormForecast, null); assert.equal(truthFactAnswer(respondent, f.forecastFact, knowledge), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), partner(f.partner));
let game = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, [answerValue]);
rejected(game, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
game = answer(game, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
if (answerValue === 'yes') {
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: f.partner });
} else {
game = act(game, f.target, { type: 'alliance', target: f.partner });
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: null });
}
assert.equal(promise(game).fulfilled, false); game = close(game); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).ally, answerValue === 'yes' ? f.partner : null);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed alliance whole No negates every OR route and waits for the real final membership', () => {
const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game;
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(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'alliance', target: f.target });
game = act(game, f.target, { type: 'alliance', target: f.asker }); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, { type: 'alliance', target: null }); game = close(game);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, null);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed alliance question projection and JSON guide retain owner turn event and hide frozen facts and residuals', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedAllianceTargets.includes(f.target));
rejected(declared.game, f.partner, 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,
mixed: group('and', fact(f.spiceFact), partner(f.target)) } });
let game = ask(declared.game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthMixedAllianceAnswers, null);
assert.equal(viewGame(game, f.partner).truthMixedAllianceAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
for (const actor of [f.target, f.asker, f.partner]) {
const view = viewGame(game, actor); assert.deepEqual(view.allianceExpressionPromises, []);
assert.deepEqual(view.alliancePromises, []); assert.deepEqual(view.truthHistory.at(-1)?.question, f.question);
assert.equal(view.truthHistory.at(-1)?.answer, 'yes'); assert.equal(view.truthMixedAllianceAnswers, null);
if (actor === f.target) {
const row = promise(game); assert.deepEqual(Object.keys(row).sort(),
['answer', 'asker', 'event', 'fulfilled', 'mixed', 'player', 'released', 'turn']);
assert.deepEqual(row.mixed, f.mixed); assert.equal(row.player, f.target); assert.equal(row.asker, f.asker);
assert.equal(row.turn, f.game.turn); assert.equal(row.answer, true); assert.equal(row.fulfilled, false);
assert.equal(row.released, false); assert.equal(view.alliancePromiseGuide?.action.alliancePromiseEvent, row.event);
} else {
assert.deepEqual(view.mixedAlliancePromises, []); assert.equal(view.alliancePromiseGuide, null);
}
}
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-mixed-alliance-event' });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
assert.equal(promise(game).fulfilled, false); rejected(game, f.target, wire); game = close(game);
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed OR retains one surviving consent and releases only after the last real withdrawal without reactivation', () => {
const f = fixture({ secondOffer: true }); let game = bind(f).game;
game = act(game, f.partner, { type: 'alliance', target: null }); assert.equal(promise(game).released, false);
const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.target, f.asker);
game = act(game, f.asker, { type: 'alliance', target: null }); assert.equal(promise(game).released, true);
assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'alliance', target: f.target });
rejected(game, f.target, guide.action); assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
game = close(game); assert.equal(promise(game).released, true); assert.equal(promise(game).fulfilled, false);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies answer mixed alliance and perform legal real acceptance and Ready', () => {
const f = fixture({ secondOffer: true }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.ok(answerAction.answer === 'yes' || answerAction.answer === 'no' || answerAction.answer === 'unknown');
assert.ok(view.truthMixedAllianceAnswers!.includes(answerAction.answer));
const answered = act(structuredClone(asked), f.target, answerAction); truthOnce(answered, f.truth);
let game = bind(f).game; const boundView = viewGame(game, f.target);
boundView.players.find(seat => seat.id === f.target)!.bot = difficulty;
const acceptance = botActions(boundView).find(action => action.type === 'alliance'); assert.ok(acceptance, difficulty);
game = act(game, f.target, acceptance); assert.ok(player(game, f.target).ally); assert.equal(promise(game).fulfilled, false);
const readyView = viewGame(game, f.target); readyView.players.find(seat => seat.id === f.target)!.bot = difficulty;
const ready = botActions(readyView).find(action => action.type === 'ready'); assert.ok(ready, difficulty);
game = act(game, f.target, ready); game = close(game); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
}
});