Support whole and mixed final Nexus alliance questions
This commit is contained in:
1 parent
0d979e4f8b
commit
05330ca7c9
26 files changed
+2408
-73
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user