Combine current facts with native Traitor decisions
This commit is contained in:
1 parent
812f77e348
commit
13398aa76e
22 files changed
+1512
-17
No files matched your search
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TraitorActionClaim } from '../game/mixed-traitor-question';
|
||||
import type { TruthAnswer, TruthFact } from '../game/truthtrance';
|
||||
import {
|
||||
createTruthTraitorDecisionFixture, actTruthTraitorDecision,
|
||||
nextTruthTraitorDecision, truthTraitorDecisionClean, truthTraitorDecisionInventory,
|
||||
truthTraitorDecisionPlayer, truthTraitorDecisionTraitors,
|
||||
type TruthTraitorDecisionFixture, type TruthTraitorDecisionStep,
|
||||
} from './fixture-truth-traitor-decision';
|
||||
import {
|
||||
createTruthTraitorHarkonnenFixture, nextTruthTraitorHarkonnen,
|
||||
type TruthTraitorHarkonnenFixture,
|
||||
} from './fixture-truth-traitor-harkonnen';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type TruthMixedTraitorExpression = MixedActionExpression<TraitorActionClaim>;
|
||||
export type TruthMixedTraitorStep = TruthTraitorDecisionStep;
|
||||
export const actTruthMixedTraitor = actTruthTraitorDecision;
|
||||
export const truthMixedTraitorClean = truthTraitorDecisionClean;
|
||||
export const truthMixedTraitorInventory = truthTraitorDecisionInventory;
|
||||
export const truthMixedTraitorPlayer = truthTraitorDecisionPlayer;
|
||||
export const truthMixedTraitorTraitors = truthTraitorDecisionTraitors;
|
||||
export const truthMixedTraitorFact = (fact: TruthFact): TruthMixedTraitorExpression => ({ kind: 'fact', fact });
|
||||
export const truthMixedTraitorCall = (call: boolean): TruthMixedTraitorExpression => ({ kind: 'action', action: { call } });
|
||||
export const truthMixedTraitorAction = (action: TraitorActionClaim): TruthMixedTraitorExpression => ({ kind: 'action', action });
|
||||
export const truthMixedTraitorGroup = (kind: 'and' | 'or', ...terms: TruthMixedTraitorExpression[]): TruthMixedTraitorExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
function adaptTruthMixedTraitor<T extends TruthTraitorDecisionFixture | TruthTraitorHarkonnenFixture>(
|
||||
base: T, source: Game, prefix: TruthMixedTraitorStep[], mixed?: TruthMixedTraitorExpression,
|
||||
) {
|
||||
const game = structuredClone(source), actions = structuredClone(prefix);
|
||||
assert.ok(game.battle?.revealed && game.battle.event);
|
||||
assert.equal(game.battle.traitorCalls[base.target], undefined);
|
||||
const event = game.battle.event;
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMixedTraitorPlayer(game, base.target).spice };
|
||||
const absentFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMixedTraitorPlayer(game, base.target).spice + 1 };
|
||||
const heldFact: TruthFact = { kind: 'traitor', leader: base.traitorHeld };
|
||||
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
|
||||
const expression = structuredClone(mixed ?? truthMixedTraitorGroup('and', truthMixedTraitorFact(spiceFact), truthMixedTraitorCall(true)));
|
||||
const question = { kind: 'mixedTraitorCall' as const, target: base.target, battle: event,
|
||||
territory: base.territory, mixed: expression };
|
||||
const questionAction = { type: 'truthAsk', question } satisfies Action;
|
||||
return { ...base, base, game, source: structuredClone(game), event, battle: event,
|
||||
mixed: expression, question, questionAction, askStep: { actor: base.asker, action: questionAction },
|
||||
spiceFact, trueFact: spiceFact, absentFact, falseFact: absentFact, heldFact, forecastFact,
|
||||
sourceActions: actions, actions, steps: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited: base.sourceActionCount, selected: actions.length },
|
||||
sourceDisclosure: `Original public Basic base33 source with ${game.players.length} seats, ${actions.length} actual actions, at its earned revealed/unsubmitted native Traitor decision. The inherited source selects only unopened original cards and scalar lotteries. This adapter changes only the raw mixed question; declarations, answers, votes, payments, Nexus returns and cleanup remain actual caller actions.`,
|
||||
staging: [...base.staging,
|
||||
'Thin mixed Traitor adapter: retain the exact original revealed battle, matching physical identity and unsubmitted native voter. No played hand, purse, clock, alliance, offer, vote, promise, fact result or residual is installed. The source owns one actual Truthtrance; no second copy or Ghola is supplied.'],
|
||||
};
|
||||
}
|
||||
|
||||
/** Exact original two-seat Basic source; all original 26 actions are inherited. */
|
||||
export function createTruthMixedTraitorFixture(options: { mixed?: TruthMixedTraitorExpression } = {}) {
|
||||
const base = createTruthTraitorDecisionFixture();
|
||||
return adaptTruthMixedTraitor(base, base.game, base.actions, options.mixed);
|
||||
}
|
||||
export type TruthMixedTraitorFixture = ReturnType<typeof createTruthMixedTraitorFixture>;
|
||||
|
||||
/** Original three-seat source: earned remote ally, pending Cunning return, or
|
||||
* the exact prefix immediately before the original Cunning draw. */
|
||||
export function createTruthMixedTraitorHarkonnenFixture(options: {
|
||||
kind?: 'ally' | 'cunning'; window?: 'pendingReturn' | 'beforeCunning'; mixed?: TruthMixedTraitorExpression;
|
||||
} = {}) {
|
||||
const base = createTruthTraitorHarkonnenFixture({ kind: options.kind });
|
||||
const beforeCunning = options.kind === 'cunning' && options.window === 'beforeCunning';
|
||||
return adaptTruthMixedTraitor(base, beforeCunning ? base.beforeCunning : base.game,
|
||||
beforeCunning ? base.actions.slice(0, base.beforeCunningActionCount) : base.actions, options.mixed);
|
||||
}
|
||||
export type TruthMixedTraitorHarkonnenFixture = ReturnType<typeof createTruthMixedTraitorHarkonnenFixture>;
|
||||
type MixedFixture = TruthMixedTraitorFixture | TruthMixedTraitorHarkonnenFixture;
|
||||
|
||||
/** Priority may advance, but the caller retains Truth, votes and returns. */
|
||||
export function nextTruthMixedTraitor(game: Game): TruthMixedTraitorStep | null {
|
||||
return game.nexusCards ? nextTruthTraitorHarkonnen(game) : nextTruthTraitorDecision(game);
|
||||
}
|
||||
export const nextTruthMixedTraitorNativeStep = nextTruthMixedTraitor;
|
||||
export function advanceTruthMixedTraitor(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMixedTraitorStep[] = []): Game {
|
||||
for (let count = 0; count < 800; count++) {
|
||||
if (until(game)) return game;
|
||||
const step = nextTruthMixedTraitor(game);
|
||||
assert.ok(step, 'Choose the original Truthtrance, native Traitor vote or pending Nexus return');
|
||||
game = actTruthMixedTraitor(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original mixed Traitor source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthMixedTraitor(game: Game, actions: TruthMixedTraitorStep[] = []): Game {
|
||||
return advanceTruthMixedTraitor(game, truthMixedTraitorClean, actions);
|
||||
}
|
||||
export function declareTruthMixedTraitor(f: MixedFixture, source = f.game) {
|
||||
const before = structuredClone(source), actions: TruthMixedTraitorStep[] = [];
|
||||
let game = actTruthMixedTraitor(before, f.asker, { type: 'card', card: f.truth }, actions);
|
||||
const opened = structuredClone(game);
|
||||
for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id))
|
||||
game = actTruthMixedTraitor(game, player.id, { type: 'truthPass' }, actions);
|
||||
return { game, before, opened, beforeQuestion: structuredClone(game), actions };
|
||||
}
|
||||
export const createTruthMixedTraitorAskSource = declareTruthMixedTraitor;
|
||||
export function askTruthMixedTraitor(game: Game, asker: string, target: string, mixed: TruthMixedTraitorExpression,
|
||||
actions: TruthMixedTraitorStep[] = []): Game {
|
||||
assert.ok(game.battle?.event);
|
||||
return actTruthMixedTraitor(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'mixedTraitorCall', target, battle: game.battle.event,
|
||||
territory: game.battle.territory, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthMixedTraitor(game: Game, target: string, answer: TruthAnswer,
|
||||
actions: TruthMixedTraitorStep[] = []): Game {
|
||||
assert.ok(viewGame(game, target).truthMixedTraitorAnswers?.includes(answer));
|
||||
return actTruthMixedTraitor(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthMixedTraitor(f: MixedFixture, answer: 'yes' | 'no' = 'yes',
|
||||
mixed = f.mixed, source = f.game) {
|
||||
const declared = declareTruthMixedTraitor(f, source), actions = declared.actions;
|
||||
const asked = askTruthMixedTraitor(declared.game, f.asker, f.target, mixed, actions);
|
||||
const game = answerTruthMixedTraitor(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
export function voteTruthMixedTraitor(game: Game, actor: string, call: boolean,
|
||||
actions: TruthMixedTraitorStep[] = []): Game {
|
||||
return actTruthMixedTraitor(game, actor, { type: 'traitorCall', call }, actions);
|
||||
}
|
||||
export const nativeVoteTruthMixedTraitor = voteTruthMixedTraitor;
|
||||
|
||||
/** Finish the actual remaining votes, responses and cleanup. Bound target votes
|
||||
* must already have been submitted explicitly by the caller. */
|
||||
export function finishTruthMixedTraitor(game: Game, f: Pick<MixedFixture, 'event' | 'target'>,
|
||||
actions: TruthMixedTraitorStep[] = []): Game {
|
||||
for (let count = 0; count < 100; count++) {
|
||||
if (!game.battle || game.battle.event !== f.event) return game;
|
||||
assert.notEqual(game.battle.traitorCalls[f.target], undefined, 'Declare the bound native vote before cleanup');
|
||||
assert.ok(!game.truthtrance && !game.nexusTraitorPending, 'Finish the original Truthtrance or Nexus return');
|
||||
const step = nextStrongholdFactionsNativeStep(game);
|
||||
assert.ok(step, 'Finish the original revealed battle control');
|
||||
game = actTruthMixedTraitor(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original mixed Traitor battle cleanup did not finish');
|
||||
}
|
||||
export function truthMixedTraitorPromise(game: Game,
|
||||
target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
|
||||
const row = viewGame(game, target).mixedTraitorPromises.find(row => row.player === target);
|
||||
assert.ok(row, 'The respondent retains its original raw mixed question and terminal status');
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
compileMixedActionExpression, mixedActionExpressionSize, MixedActionQuestionError,
|
||||
parseMixedActionExpression, type CompiledMixedAction, type MixedActionExpression,
|
||||
} from '../game/mixed-action-question';
|
||||
import {
|
||||
MIXED_TRAITOR_MAX_DEPTH, MIXED_TRAITOR_MAX_LEAVES, mixedTraitorRequirement,
|
||||
parseTraitorActionClaim, type MixedTraitorClaim, type TraitorActionClaim,
|
||||
} from '../game/mixed-traitor-question';
|
||||
import {
|
||||
bindTraitorDecisionPromise, TraitorDecisionError, traitorDecisionChoices,
|
||||
type TraitorDecisionContext,
|
||||
} from '../game/traitor-decision-promises';
|
||||
import type { TruthAnswer, TruthFact } from '../game/truthtrance';
|
||||
|
||||
type Expression = MixedActionExpression<TraitorActionClaim>;
|
||||
type Compiled = CompiledMixedAction<TraitorActionClaim>;
|
||||
const action = (call: boolean): Expression & Compiled => ({ kind: 'action', action: { call } });
|
||||
const fact = (value = 5): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const compiledGroup = (kind: 'and' | 'or', ...terms: Compiled[]): Compiled => ({ kind, terms });
|
||||
const constant = (value: boolean): Compiled => ({ kind: 'constant', value });
|
||||
const parse = (input: unknown): Expression => parseMixedActionExpression(input,
|
||||
value => structuredClone(value) as TruthFact, parseTraitorActionClaim);
|
||||
const context = (canReveal: boolean): TraitorDecisionContext => ({
|
||||
turn: 4, battle: 'battle-event-7', player: 'harkonnen', beneficiary: 'atreides',
|
||||
target: 'emperor', leader: 'aramsham', canReveal,
|
||||
});
|
||||
|
||||
void test('strict Traitor leaves copy only the original native Reveal or Decline and metadata stays on the question', () => {
|
||||
for (const call of [false, true]) {
|
||||
const original = { call };
|
||||
const parsed = parseTraitorActionClaim(original);
|
||||
assert.deepEqual(parsed, original);
|
||||
assert.notEqual(parsed, original);
|
||||
}
|
||||
const claim: MixedTraitorClaim = {
|
||||
battle: 'battle-event-7', territory: 'arrakeen', mixed: group('and', fact(), action(true)),
|
||||
};
|
||||
assert.deepEqual(parse(claim.mixed), claim.mixed);
|
||||
assert.deepEqual(Object.keys(parseTraitorActionClaim({ call: false })), ['call']);
|
||||
});
|
||||
|
||||
void test('strict Traitor leaves reject source, frozen values, wrappers, groups and malformed booleans even after decisive facts', () => {
|
||||
const invalid: unknown[] = [null, [], {}, true, { call: undefined }, { call: null },
|
||||
{ call: 0 }, { call: 'false' }, { call: Object(false) },
|
||||
{ call: true, battle: 'battle-event-7' }, { call: false, territory: 'arrakeen' },
|
||||
{ call: true, target: 'emperor' }, { call: true, turn: 4 }, { call: true, leader: 'aramsham' },
|
||||
{ call: true, event: 'private-link' }, { call: false, answer: true },
|
||||
{ call: true, fulfilled: true }, { call: true, released: false }, { call: true, canReveal: true },
|
||||
{ kind: 'constant', value: true }, { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } },
|
||||
{ kind: 'action', action: { call: true } }, { kind: 'and', terms: [{ call: true }, { call: false }] },
|
||||
{ kind: 'or', terms: [] }];
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => parseTraitorActionClaim(value), TraitorDecisionError);
|
||||
for (const kind of ['and', 'or'] as const) assert.throws(() => parse(group(kind, fact(),
|
||||
{ kind: 'action', action: value } as Expression)), TraitorDecisionError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('both whole answers retain exact truth tables through every nested AND or OR combination and constants', () => {
|
||||
const cases: { node: Compiled; decline: boolean; reveal: boolean }[] = [
|
||||
{ node: constant(false), decline: false, reveal: false },
|
||||
{ node: constant(true), decline: true, reveal: true },
|
||||
{ node: action(false), decline: true, reveal: false },
|
||||
{ node: action(true), decline: false, reveal: true },
|
||||
];
|
||||
for (const outer of ['and', 'or'] as const) for (const inner of ['and', 'or'] as const) {
|
||||
for (const first of [false, true]) for (const second of [false, true]) for (const third of [false, true]) {
|
||||
const evaluate = (choice: boolean) => {
|
||||
const left = inner === 'and' ? choice === first && choice === second : choice === first || choice === second;
|
||||
return outer === 'and' ? left && choice === third : left || choice === third;
|
||||
};
|
||||
cases.push({ node: compiledGroup(outer, compiledGroup(inner, action(first), action(second)), action(third)),
|
||||
decline: evaluate(false), reveal: evaluate(true) });
|
||||
}
|
||||
}
|
||||
for (const { node, decline, reveal } of cases) for (const answer of [false, true]) {
|
||||
const expected = decline !== answer && reveal !== answer ? { possible: false }
|
||||
: { possible: true, call: decline === answer && reveal === answer ? null : reveal === answer };
|
||||
assert.deepEqual(mixedTraitorRequirement(node, answer), expected, JSON.stringify({ node, answer }));
|
||||
}
|
||||
assert.throws(() => mixedTraitorRequirement(action(true), 'yes' as unknown as boolean), TraitorDecisionError);
|
||||
});
|
||||
|
||||
void test('opposite-call correlations use both logical choices while a forced Decline remains binding after native eligibility changes', () => {
|
||||
const tautology = compiledGroup('or', action(true), action(false));
|
||||
const contradiction = compiledGroup('and', action(true), action(false));
|
||||
assert.deepEqual(mixedTraitorRequirement(tautology, true), { possible: true, call: null });
|
||||
assert.deepEqual(mixedTraitorRequirement(tautology, false), { possible: false });
|
||||
assert.deepEqual(mixedTraitorRequirement(contradiction, false), { possible: true, call: null });
|
||||
assert.deepEqual(mixedTraitorRequirement(contradiction, true), { possible: false });
|
||||
assert.deepEqual(traitorDecisionChoices(context(false), []), [false]);
|
||||
const required = mixedTraitorRequirement(compiledGroup('and', constant(true), action(false)), true);
|
||||
assert.deepEqual(required, { possible: true, call: false });
|
||||
assert(required.possible && required.call !== null);
|
||||
const promises = bindTraitorDecisionPromise(context(false), [], 'beneGesserit', required.call);
|
||||
assert.deepEqual(traitorDecisionChoices(context(false), promises), [false]);
|
||||
assert.deepEqual(traitorDecisionChoices(context(true), promises), [false], 'A later matching card cannot erase the original Decline');
|
||||
assert.deepEqual(mixedTraitorRequirement(action(true), true), { possible: true, call: true },
|
||||
'Current native eligibility remains the caller’s separate intersection');
|
||||
});
|
||||
|
||||
void test('compilation snapshots every current fact before exact native reduction without mutating the public or compiled trees', () => {
|
||||
const mixed = group('and', group('or', fact(5), action(false)), group('or', fact(9), action(true)));
|
||||
const before = structuredClone(mixed), reads: number[] = [];
|
||||
const residual = compileMixedActionExpression(mixed, current => {
|
||||
assert(current.kind === 'spice');
|
||||
reads.push(current.value);
|
||||
return current.value === 5 ? 'yes' : 'no';
|
||||
}, true);
|
||||
assert.deepEqual(reads, [5, 9]);
|
||||
assert.deepEqual(residual, action(true));
|
||||
const frozen = structuredClone(residual);
|
||||
assert.deepEqual(mixedTraitorRequirement(residual, true), { possible: true, call: true });
|
||||
assert.deepEqual(mixedTraitorRequirement(residual, false), { possible: true, call: false });
|
||||
assert.deepEqual(mixed, before);
|
||||
assert.deepEqual(residual, frozen);
|
||||
const decisiveReads: number[] = [];
|
||||
const decisive = compileMixedActionExpression(group('or', fact(5), group('and', fact(9), action(false))), current => {
|
||||
assert(current.kind === 'spice'); decisiveReads.push(current.value); return 'yes';
|
||||
}, true);
|
||||
assert.deepEqual(decisiveReads, [5, 9]);
|
||||
assert.deepEqual(mixedTraitorRequirement(decisive, true), { possible: true, call: null });
|
||||
});
|
||||
|
||||
void test('unknown facts keep the conservative false-for-Yes or true-for-No snapshot without guessing their correlations', () => {
|
||||
for (const answer of [false, true]) for (const call of [false, true]) for (const kind of ['and', 'or'] as const) {
|
||||
const raw = group(kind, fact(), action(call));
|
||||
const before = structuredClone(raw);
|
||||
const snapshots: TruthAnswer[] = [];
|
||||
const compiled = compileMixedActionExpression(raw, () => { snapshots.push('unknown'); return 'unknown'; }, answer);
|
||||
const permitsAction = answer ? kind === 'or' : kind === 'and';
|
||||
assert.deepEqual(mixedTraitorRequirement(compiled, answer), permitsAction
|
||||
? { possible: true, call: answer ? call : !call } : { possible: false });
|
||||
assert.deepEqual(snapshots, ['unknown']);
|
||||
assert.deepEqual(raw, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('mixed Traitor questions retain the generic sixteen-leaf and depth-four budgets including embedded fact groups', () => {
|
||||
assert.equal(MIXED_TRAITOR_MAX_LEAVES, 16);
|
||||
assert.equal(MIXED_TRAITOR_MAX_DEPTH, 4);
|
||||
const atLeaves = group('and', fact(), ...Array.from({ length: 15 }, () => action(true)));
|
||||
assert.deepEqual(parse(atLeaves), atLeaves);
|
||||
assert.equal(mixedActionExpressionSize(atLeaves).leaves, 16);
|
||||
assert.throws(() => parse(group('and', atLeaves, action(false))), MixedActionQuestionError);
|
||||
const embedded: Expression = { kind: 'fact', fact: { kind: 'or', terms:
|
||||
Array.from({ length: 15 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
|
||||
} };
|
||||
assert.deepEqual(parse(group('or', embedded, action(false))), group('or', embedded, action(false)));
|
||||
assert.throws(() => parse(group('and', embedded, action(false), action(true))), MixedActionQuestionError);
|
||||
let atDepth = group('and', fact(), action(true));
|
||||
for (let depth = 1; depth < 4; depth++) atDepth = group('or', action(false), atDepth);
|
||||
assert.equal(mixedActionExpressionSize(atDepth).depth, 4);
|
||||
assert.deepEqual(parse(atDepth), atDepth);
|
||||
assert.throws(() => parse(group('and', action(true), atDepth)), MixedActionQuestionError);
|
||||
});
|
||||
@@ -134,12 +134,15 @@ void test('promise validation accepts only the bounded public source and typed f
|
||||
assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: false, released: false })), true);
|
||||
assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: true })), true);
|
||||
assert.equal(validateTraitorDecisionPromise(promise({ released: true })), true);
|
||||
assert.equal(validateTraitorDecisionPromise(promise({ event: 'mixed-traitor-event' })), true);
|
||||
for (const invalid of [
|
||||
null, [], 'promise', 4, {}, { ...promise(), turn: 0 }, { ...promise(), turn: 1.5 },
|
||||
{ ...promise(), turn: Number.MAX_SAFE_INTEGER + 1 }, { ...promise(), battle: '' },
|
||||
{ ...promise(), player: ' ' }, { ...promise(), beneficiary: null }, { ...promise(), target: 4 },
|
||||
{ ...promise(), leader: '' }, { ...promise(), asker: '' }, { ...promise(), answer: 'yes' },
|
||||
{ ...promise(), fulfilled: 1 }, { ...promise(), released: 'false' },
|
||||
{ ...promise(), event: '' }, { ...promise(), event: ' ' }, { ...promise(), event: null },
|
||||
{ ...promise(), event: 7 }, { ...promise(), event: undefined },
|
||||
{ ...promise(), canReveal: true }, { ...promise(), submitted: false },
|
||||
{ ...promise(), card: 'private-card' }, { ...promise(), hand: [] },
|
||||
]) {
|
||||
@@ -152,6 +155,42 @@ void test('promise validation accepts only the bounded public source and typed f
|
||||
}
|
||||
});
|
||||
|
||||
void test('an optional mixed event links only its new native promise and survives cloning, fulfillment and release', () => {
|
||||
const ctx = context();
|
||||
const historical = promise({ battle: 'older-battle', event: 'older-mixed-event' });
|
||||
const ledger = [historical];
|
||||
const before = structuredClone({ ctx, ledger });
|
||||
for (const answer of [false, true]) {
|
||||
const accepted = bindTraitorDecisionPromise(ctx, ledger, 'fremen', answer, 'mixed-traitor-event');
|
||||
assert.deepEqual(accepted, [historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event' })]);
|
||||
assert.notEqual(accepted[0], historical);
|
||||
assert.deepEqual(settleTraitorDecisionPromises(ctx, accepted, answer), [
|
||||
historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event', fulfilled: true }),
|
||||
]);
|
||||
assert.deepEqual(reconcileTraitorDecisionPromises(context({ canReveal: false }), accepted), [
|
||||
historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event', ...(answer ? { released: true } : {}) }),
|
||||
]);
|
||||
assert.deepEqual(accepted, [historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event' })]);
|
||||
}
|
||||
assert.deepEqual({ ctx, ledger }, before);
|
||||
});
|
||||
|
||||
void test('omitted or undefined event arguments retain the original pure shape and invalid event links reject without mutation', () => {
|
||||
const ctx = context(), ledger = [promise()];
|
||||
const before = structuredClone({ ctx, ledger });
|
||||
for (const bound of [
|
||||
bindTraitorDecisionPromise(ctx, ledger, 'fremen', true),
|
||||
bindTraitorDecisionPromise(ctx, ledger, 'fremen', true, undefined),
|
||||
]) {
|
||||
assert.deepEqual(bound, [promise(), promise({ asker: 'fremen' })]);
|
||||
assert.equal(Object.hasOwn(bound[1], 'event'), false);
|
||||
}
|
||||
for (const event of ['', ' ', null, 7, false, {}]) {
|
||||
assert.throws(() => bindTraitorDecisionPromise(ctx, ledger, 'fremen', true, event as string), TraitorDecisionError);
|
||||
assert.deepEqual({ ctx, ledger }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('operational guards reject malformed contexts or ledger values with the named error and unchanged input', () => {
|
||||
for (const malformed of [
|
||||
null, [], { ...context(), turn: 0 }, { ...context(), battle: '' },
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MixedTraitorFields, mixedTraitorInputError } from '../components/mixed-traitor-fields';
|
||||
import { MixedTraitorPromises } from '../components/mixed-traitor-promises';
|
||||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||||
import { TraitorDecisionAnswers, TraitorDecisionPromises } from '../components/traitor-decision-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { mixedActionExpressionSize, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TraitorActionClaim } from '../game/mixed-traitor-question';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import { createTruthTraitorDecisionFixture } from './fixture-truth-traitor-decision';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
type Expression = MixedActionExpression<TraitorActionClaim>;
|
||||
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 declaration = (call = true): Expression => ({ kind: 'action', action: { call } });
|
||||
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [declaration(), declaration(false)] }] });
|
||||
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-traitor-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-traitor-${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-traitor-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);
|
||||
}
|
||||
function textContent(node: ReactNode): string {
|
||||
if (Array.isArray(node)) return node.map(textContent).join('');
|
||||
if (isValidElement<ElementProps>(node)) return textContent(node.props.children);
|
||||
return typeof node === 'string' || typeof node === 'number' ? String(node) : '';
|
||||
}
|
||||
function answer(tree: ReactNode, label: string): Element {
|
||||
const found = elements(tree).find(element => typeof element.props.onClick === 'function' && textContent(element) === `Answer ${label}`);
|
||||
assert.ok(found, `Answer ${label}`); return found;
|
||||
}
|
||||
|
||||
/** Controlled views prove original-tree editing, aggregate display and saved
|
||||
* callback guards. The native source suite owns actual Truthtrance lifecycle. */
|
||||
function projected(): GameView {
|
||||
const source = createGame('TRUTHMIXEDTRAITORCONTROLS', newPlayer('p', 'Respondent', 'emperor'), false);
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 2; game.phase = 6;
|
||||
game.truthMixedTraitorTargets = ['p', 'a'];
|
||||
game.truthMixedTraitorAnswers = ['yes', 'no', 'unknown'];
|
||||
game.mixedTraitorPromises = [{ event: 'controlled-mixed-traitor-question', player: 'p', asker: 'a', turn: 2,
|
||||
battle: 'controlled-native-battle', territory: 'arrakeen', mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||
game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed: true,
|
||||
submitted: ['p', 'a'], plans: {}, traitorSubmitted: [], traitorVoters: ['p', 'a'], traitorCallChoices: [false, true], ownTraitorPromises: [],
|
||||
} as unknown as NonNullable<GameView['battle']>;
|
||||
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [], question: {
|
||||
kind: 'mixedTraitorCall', target: 'p', battle: 'controlled-native-battle', territory: 'arrakeen', mixed: claim(),
|
||||
} };
|
||||
return game;
|
||||
}
|
||||
function editor(initial: Expression, disabled = false, game = projected(), target = 'p') {
|
||||
let value = initial, changes = 0;
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => MixedTraitorFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(MixedTraitorFields, { value, game, target, disabled, onChange() {} })),
|
||||
error: () => mixedTraitorInputError(value, game, target),
|
||||
};
|
||||
}
|
||||
const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedTraitorPromises, { game }));
|
||||
const answerMarkup = (game: GameView) => renderToStaticMarkup(createElement(TraitorDecisionAnswers, { game, busy: false, act() {} }));
|
||||
|
||||
void test('mixed Traitor editor preserves sixteen original leaves without plan node or eight-term limits', () => {
|
||||
const e = editor(fact()); click(control(e.tree(), 'or-1'));
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'call', count % 2 ? 'decline' : 'reveal');
|
||||
}
|
||||
const full = e.value; assert.ok(full.kind === 'or'); assert.equal(full.terms.length, 16);
|
||||
assert.deepEqual(full.terms[0], fact()); assert.deepEqual(full.terms[2], declaration(false));
|
||||
assert.deepEqual(full.terms[15], declaration()); assert.equal(e.error(), null);
|
||||
assert.deepEqual(mixedActionExpressionSize(full), { leaves: 16, nodes: 17, depth: 1, maxTerms: 16 });
|
||||
const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.match(html, /16 \/ 16 conditions/); assert.match(html, /Reveal traitor/); assert.match(html, /Decline traitor/);
|
||||
assert.equal((html.match(/>Traitor declaration<\/label>/g) ?? []).length, 15);
|
||||
assert.equal(new Set(ids).size, ids.length); assert.doesNotMatch(html, /total clauses|8 conditions per group/);
|
||||
assert.ok(!elements(e.tree()).some(element => typeof element.props.id === 'string' && /-(battle|territory|leader|beneficiary|target)$/.test(element.props.id)));
|
||||
const before = structuredClone(full), changes = e.changes;
|
||||
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-and-1.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
|
||||
});
|
||||
|
||||
void test('mixed Traitor edits preserve every fact and declaration branch through collapse and depth-four grouping', () => {
|
||||
const e = editor({ kind: 'and', terms: [fact(), declaration(false)] });
|
||||
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 });
|
||||
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
|
||||
click(control(e.tree(), 'nested-or-1')); click(control(e.tree(), 'nested-and-1'));
|
||||
assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, 4); assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Current-fact group 1\.1/);
|
||||
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.4')); click(control(e.tree(), 'remove-1.3'));
|
||||
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), declaration(false)] });
|
||||
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
|
||||
select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1.2', 'call', 'decline'); click(control(e.tree(), 'remove-1.1'));
|
||||
assert.deepEqual(e.value, declaration(false)); assert.ok(e.error());
|
||||
click(control(e.tree(), 'and-1')); assert.equal(e.error(), null);
|
||||
let nested = fact();
|
||||
for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, declaration()] };
|
||||
const deep = editor(nested); 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.1', 'fact-and-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);
|
||||
assert.ok(mixedTraitorInputError({ kind: 'and', terms: [nested, declaration()] }, projected(), 'p'));
|
||||
});
|
||||
|
||||
void test('mixed Traitor fact drafts stay repairable, strict declaration leaves reject metadata, and disabled callbacks cannot edit', () => {
|
||||
for (const invalid of [NaN, -1, 0.5, Infinity]) {
|
||||
const e = editor({ kind: 'and', terms: [fact(), declaration()] });
|
||||
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: invalid }); assert.ok(e.error());
|
||||
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2.1', 'call', 'decline');
|
||||
assert.ok(e.value.kind === 'and'); assert.deepEqual(e.value.terms[1], { kind: 'or', terms: [declaration(false), fact()] });
|
||||
assert.match(e.html(), /role="alert"/); editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(e.error(), null);
|
||||
}
|
||||
for (const action of [{ call: 'yes' }, { call: 1 }, {}, { call: true, battle: 'other' }, { call: false, territory: 'carthag' },
|
||||
{ call: true, leader: 'private' }, { call: false, canReveal: false }, { call: true, answer: true },
|
||||
{ kind: 'or', terms: [{ call: true }, { call: false }] }])
|
||||
assert.ok(mixedTraitorInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p'));
|
||||
const frozen = editor({ kind: 'and', terms: [fact(), declaration()] }, 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.2', 'call', 'decline'); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'timing', 'fact');
|
||||
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
|
||||
});
|
||||
|
||||
void test('mixed Traitor authoring uses only public vocabulary for combatants and remote native Harkonnen voters', () => {
|
||||
const game = projected(); game.players[0].faction = 'harkonnen'; game.players[0].ally = 'a';
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
for (const key of ['plans', 'traitorCallChoices', 'canReveal', 'cards', 'ownTraitorPromises']) Object.defineProperty(game.battle!, key, {
|
||||
get() { throw Error(`No native ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
for (const key of ['deck', 'discard', 'truthMixedTraitorAnswers', 'truthTraitorAnswers']) Object.defineProperty(game, key, {
|
||||
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
const e = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), declaration(false)] }, false, game);
|
||||
assert.equal(e.error(), null); const html = e.html();
|
||||
assert.match(html, /native Harkonnen declaration for an ally/); assert.match(html, /Current facts are snapshotted at the answer/);
|
||||
assert.match(html, /conservative declaration commitment/); assert.match(html, /constant whole answer needs no declaration commitment/);
|
||||
assert.match(html, /original declaration fulfills a remaining commitment before a later Karama or betrayal response/);
|
||||
select(e.tree(), '1.2', 'call', 'reveal'); assert.equal(e.error(), null);
|
||||
});
|
||||
|
||||
void test('own mixed Traitor history retains original whole answers and constant, bound and terminal statuses after battle cleanup', () => {
|
||||
const game = projected();
|
||||
game.mixedTraitorPromises.push(
|
||||
{ ...game.mixedTraitorPromises[0], event: 'constant-whole-answer', answer: false, fulfilled: true },
|
||||
{ ...game.mixedTraitorPromises[0], event: 'declared-whole-answer', fulfilled: true },
|
||||
{ ...game.mixedTraitorPromises[0], event: 'released-whole-answer', released: true },
|
||||
{ ...game.mixedTraitorPromises[0], event: 'foreign-question', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), declaration(false)] } },
|
||||
);
|
||||
for (const promise of game.mixedTraitorPromises) for (const key of ['binding', 'requiredCall', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'canReveal', 'leader', 'beneficiary', 'progress'])
|
||||
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
game.battle = null; game.truthtrance = null;
|
||||
const html = markup(game);
|
||||
assert.match(html, /Your mixed Traitor promises/); assert.match(html, /Turn 2/); assert.match(html, /Arrakeen/);
|
||||
assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, /you hold Shield/);
|
||||
assert.match(html, / OR /); assert.match(html, / AND /);
|
||||
assert.match(html, /This whole answer is binding/); assert.match(html, /Fulfilled: this whole commitment is complete/);
|
||||
assert.match(html, /Released: this whole commitment is no longer possible/);
|
||||
assert.match(html, /constant whole answer is complete immediately/i); assert.match(html, /No negates the whole expression, not each condition separately/);
|
||||
assert.match(html, /later Karama or betrayal response can still prevent its effect/);
|
||||
assert.doesNotMatch(html, /Poison Blade|controlled-mixed-traitor-question|declared-whole-answer|<button/);
|
||||
assert.equal(MixedTraitorPromises({ game: { ...game, me: 'missing' } }), null);
|
||||
});
|
||||
|
||||
void test('aggregate Traitor answer controls forward exactly the offered parent answer and preserve scalar wording', () => {
|
||||
for (const [value, label] of [['yes', 'Yes'], ['no', 'No'], ['unknown', 'I don’t know']] as const) {
|
||||
const game = projected(), sent: Action[] = [];
|
||||
game.truthMixedTraitorAnswers = [value];
|
||||
const props = { game, busy: false, act(action: Action) { sent.push(action); } };
|
||||
const tree = TraitorDecisionAnswers(props), button = answer(tree, label);
|
||||
assert.equal(button.props.disabled, false); click(button);
|
||||
assert.deepEqual(sent, [{ type: 'truthAnswer', answer: value }]);
|
||||
const html = answerMarkup(game);
|
||||
assert.match(html, /Answer the whole mixed claim/); assert.match(html, /Current facts are snapshotted at the answer/);
|
||||
assert.match(html, /No makes the whole expression false, not each condition separately/);
|
||||
assert.match(html, /later Karama or betrayal response can still prevent its effect/);
|
||||
}
|
||||
const game = projected();
|
||||
game.truthtrance!.question = { kind: 'traitorCall', target: 'p', battle: game.battle!.event!, territory: 'arrakeen' };
|
||||
game.truthTraitorAnswers = ['no', 'yes'];
|
||||
const html = answerMarkup(game);
|
||||
assert.match(html, /Yes binds Reveal for this original Traitor decision; No binds decline/);
|
||||
assert.doesNotMatch(html, /Answer the whole mixed claim|I don’t know/);
|
||||
assert.equal(TraitorDecisionAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null);
|
||||
game.battle!.event = 'later-battle';
|
||||
assert.equal(TraitorDecisionAnswers({ game, busy: false, act() {} }), null);
|
||||
});
|
||||
|
||||
void test('saved mixed Traitor answer callbacks reject busy, foreign, changed question and stale current battle sources', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.turn++; }, game => { game.battle = null; },
|
||||
game => { game.battle = { ...game.battle! }; }, game => { game.battle!.event = 'later-battle'; },
|
||||
game => { game.battle!.territory = 'carthag'; }, game => { game.battle!.revealed = false; },
|
||||
game => { game.battle!.traitorVoters = ['a']; }, game => { game.battle!.traitorSubmitted.push('p'); },
|
||||
game => { game.truthtrance = null; }, game => { game.truthtrance = { ...game.truthtrance! }; },
|
||||
game => { game.truthtrance!.stage = 'ask'; }, game => { game.truthtrance!.question = { ...game.truthtrance!.question! }; },
|
||||
game => { game.truthtrance!.queue[0] = { player: 'a', card: 'later-truth-card' }; },
|
||||
game => { game.truthtrance!.queue[0].player = 'p'; },
|
||||
game => { game.truthtrance!.question!.target = 'a'; },
|
||||
game => { const question = game.truthtrance!.question!; if (question.kind === 'mixedTraitorCall') question.battle = 'later-battle'; },
|
||||
game => { const question = game.truthtrance!.question!; if (question.kind === 'mixedTraitorCall') question.mixed = fact(); },
|
||||
game => { game.truthMixedTraitorAnswers = ['yes']; }, game => { game.truthMixedTraitorAnswers!.splice(0, 1); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } };
|
||||
const button = answer(TraitorDecisionAnswers(props), 'Yes'); change(game); click(button); assert.deepEqual(sent, [], change.toString());
|
||||
}
|
||||
for (const change of [
|
||||
(props: Parameters<typeof TraitorDecisionAnswers>[0]) => { props.busy = true; },
|
||||
(props: Parameters<typeof TraitorDecisionAnswers>[0]) => { props.game = projected(); },
|
||||
(props: Parameters<typeof TraitorDecisionAnswers>[0]) => { props.act = () => assert.fail('Do not use a replaced parent callback'); },
|
||||
]) {
|
||||
const sent: Action[] = [], props = { game: projected(), busy: false, act(action: Action) { sent.push(action); } };
|
||||
const button = answer(TraitorDecisionAnswers(props), 'Yes'); change(props); click(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const busy = { game: projected(), busy: true, act() { assert.fail('A busy answer cannot dispatch'); } };
|
||||
const button = answer(TraitorDecisionAnswers(busy), 'Yes'); assert.equal(button.props.disabled, true); click(button);
|
||||
});
|
||||
|
||||
void test('ordinary native Traitor buttons follow the server choice array while mixed status adds no declaration button or scalar notice', () => {
|
||||
const f = createTruthTraitorDecisionFixture(), game = viewGame(f.game, f.target);
|
||||
game.mixedTraitorPromises = [{ event: 'controlled-native-mixed-question', player: f.target, asker: f.asker, turn: game.turn,
|
||||
battle: game.battle!.event!, territory: game.battle!.territory, mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||
game.battle!.ownTraitorPromises = [];
|
||||
const render = () => renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
|
||||
const disabled = (html: string, label: string) => {
|
||||
const buttons = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)];
|
||||
const button = buttons.find(match => match[2].replace(/<[^>]*>/g, '').trim() === label); assert.ok(button, label);
|
||||
return /\bdisabled(?:[\s=>]|$)/.test(button[1]);
|
||||
};
|
||||
for (const choice of [true, false]) {
|
||||
game.battle!.traitorCallChoices = [choice];
|
||||
const html = render();
|
||||
assert.equal(disabled(html, 'Reveal traitor'), !choice);
|
||||
assert.equal(disabled(html, 'No traitor — resolve'), choice);
|
||||
if (!choice) {
|
||||
assert.match(html, /Your accepted Truthtrance answer requires declining this Traitor declaration/);
|
||||
assert.doesNotMatch(html, /You do not hold the opposing leader as a traitor/);
|
||||
}
|
||||
assert.equal(TraitorDecisionPromises({ game }), null);
|
||||
assert.doesNotMatch(markup(game), /<button/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nexusTraitorBotActions, nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
|
||||
import { truthFactAnswer } from '../game/truthtrance';
|
||||
import { truthTraitorHarkonnenNexusInventory } from './fixture-truth-traitor-harkonnen';
|
||||
import {
|
||||
actTruthMixedTraitor as act, bindTruthMixedTraitor as bind,
|
||||
createTruthMixedTraitorHarkonnenFixture as fixture, finishTruthMixedTraitor as finish,
|
||||
settleTruthMixedTraitor as settle, truthMixedTraitorCall as call,
|
||||
truthMixedTraitorFact as fact, truthMixedTraitorGroup as group,
|
||||
truthMixedTraitorInventory as inventory, truthMixedTraitorPlayer as player,
|
||||
truthMixedTraitorPromise as promise, truthMixedTraitorTraitors as traitors, voteTruthMixedTraitor as vote,
|
||||
} from './fixture-truth-mixed-traitor';
|
||||
|
||||
function rejected(game: Game, actor: string, candidate: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, candidate)); assert.deepEqual(game, before);
|
||||
}
|
||||
function sourceInventory(game: Game, f: ReturnType<typeof fixture>) {
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
assert.deepEqual(truthTraitorHarkonnenNexusInventory(game), f.originalNexusInventory);
|
||||
}
|
||||
|
||||
void test('native remote allied Harkonnen mixed Reveal fulfills before its actual allowed or prevented effect', () => {
|
||||
const f = fixture(); assert.equal(f.sourceActionCount, 72); assert.equal(f.sourceActionCount, f.base.sourceActionCount);
|
||||
assert.deepEqual(f.game, f.base.game); assert.equal(f.game.nexusCards, undefined);
|
||||
assert.equal(player(f.game, f.target).ally, f.beneficiary);
|
||||
assert.ok(![f.game.battle!.attacker, f.game.battle!.defender].includes(f.target));
|
||||
assert.ok(viewGame(f.game, f.target).battle!.traitorVoters.includes(f.target));
|
||||
for (const canceled of [false, true]) {
|
||||
let game = bind(f).game; const native = game.battle!.traitorDecisionPromises![0];
|
||||
assert.equal(native.beneficiary, f.beneficiary); assert.equal(native.player, f.target);
|
||||
assert.equal(native.event, promise(game).event); assert.equal(promise(game).fulfilled, false);
|
||||
for (const actor of game.players.map(seat => seat.id)) {
|
||||
const view = viewGame(game, actor); assert.deepEqual(view.battle!.ownTraitorPromises, []);
|
||||
assert.equal(view.mixedTraitorPromises.length, actor === f.target ? 1 : 0);
|
||||
}
|
||||
rejected(game, f.target, { type: 'traitorCall', call: false });
|
||||
game = vote(game, f.target, true); assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||||
assert.equal(game.battle!.traitorDecisionPromises![0].fulfilled, true);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
if (canceled) game = act(game, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
else game = settle(game);
|
||||
assert.equal(game.battle!.traitorCalls[f.target], !canceled);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(game.battle!.traitorDecisionPromises![0].fulfilled, true);
|
||||
game = finish(game, f); assert.equal(game.battle, null); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(promise(game).released, false); assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
sourceInventory(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native mixed Reveal over the original pending Cunning return protects the matching identity and resumes the exact parent', () => {
|
||||
const f = fixture({ kind: 'cunning' }); assert.equal(f.sourceActionCount, 77);
|
||||
assert.equal(f.sourceActionCount, f.base.sourceActionCount); assert.deepEqual(f.game, f.base.game);
|
||||
assert.ok(f.returnHeld && f.returnDrawn); const parent = structuredClone(f.game.nexusTraitorParent);
|
||||
const pendingEvent = f.game.nexusTraitorPending; assert.ok(pendingEvent && parent);
|
||||
const before = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, before);
|
||||
assert.equal(game.nexusTraitorPending, pendingEvent); assert.deepEqual(game.nexusTraitorParent, parent);
|
||||
assert.equal(promise(game).fulfilled, false); assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [true]);
|
||||
const view = viewGame(game, f.target), pending = view.nexusTraitors!.pending!;
|
||||
assert.equal(pending.event, pendingEvent); assert.ok(pending.choices.find(choice => choice.id === f.traitorHeld)?.blocked);
|
||||
assert.equal(nexusTraitorReturnAction(view, pending.event, [f.traitorHeld]), null);
|
||||
rejected(game, f.target, f.returnHeld);
|
||||
const safe = nexusTraitorBotActions(view)[0]; assert.ok(safe); assert.notDeepEqual(safe, f.returnHeld);
|
||||
game = act(game, f.target, safe); assert.equal(game.nexusTraitorPending, null); assert.equal(game.nexusTraitorParent, null);
|
||||
assert.equal(game.battle!.event, f.event); assert.equal(game.battle!.revealed, true);
|
||||
assert.deepEqual(game.battle!.plans, f.game.battle!.plans); assert.deepEqual(game.battle!.traitorCalls, {});
|
||||
assert.ok(player(game, f.target).traitors.includes(f.traitorHeld)); assert.equal(promise(game).fulfilled, false);
|
||||
game = vote(game, f.target, true); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(game.battle!.traitorDeclarations![f.target].identity, f.traitorHeld);
|
||||
game = finish(game, f); assert.equal(promise(game).fulfilled, true); sourceInventory(game, f);
|
||||
});
|
||||
|
||||
void test('native mixed decline permits the matching Cunning return with frozen facts and remains binding through an actual new draw', () => {
|
||||
const f = fixture({ kind: 'cunning' }), mixed = group('and', fact(f.heldFact), call(true));
|
||||
assert.ok(f.returnHeld); let game = bind(f, 'no', mixed).game;
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.heldFact), 'yes');
|
||||
assert.ok(!viewGame(game, f.target).nexusTraitors!.pending!.choices.find(choice => choice.id === f.traitorHeld)?.blocked);
|
||||
game = act(game, f.target, f.returnHeld);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.heldFact), 'no');
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]);
|
||||
assert.deepEqual(promise(game).mixed, mixed); assert.equal(promise(game).fulfilled, false); assert.equal(promise(game).released, false);
|
||||
game = vote(game, f.target, false); assert.equal(promise(game).fulfilled, true); sourceInventory(finish(game, f), f);
|
||||
|
||||
// Separate real branch starts before the original Cunning draw. No draw,
|
||||
// return, native vote or ledger is installed to create this composition.
|
||||
game = bind(f, 'no', mixed, f.beforeCunning).game;
|
||||
const offer = viewGame(game, f.target).nexusTraitors!.offer!;
|
||||
const draw = nexusTraitorDrawAction(viewGame(game, f.target), offer.event, 'cunning'); assert.ok(draw);
|
||||
game = act(game, f.target, draw); assert.ok(game.nexusTraitorPending);
|
||||
const afterDraw = viewGame(game, f.target), pending = afterDraw.nexusTraitors!.pending!;
|
||||
const drawn = pending.choices.filter(choice => choice.drawn).map(choice => choice.id); assert.equal(drawn.length, 1);
|
||||
const returned = nexusTraitorReturnAction(afterDraw, pending.event, drawn); assert.ok(returned);
|
||||
game = act(game, f.target, returned); assert.ok(player(game, f.target).traitors.includes(f.traitorHeld));
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.heldFact), 'yes');
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: true });
|
||||
game = vote(game, f.target, false); assert.equal(promise(game).fulfilled, true); sourceInventory(finish(game, f), f);
|
||||
});
|
||||
|
||||
void test('native fulfilled mixed Reveal permits a later actual Cunning return without retracting its receipt or reopening the vote', () => {
|
||||
const f = fixture({ kind: 'cunning', window: 'beforeCunning' });
|
||||
assert.equal(f.sourceActionCount, f.base.beforeCunningActionCount); assert.equal(f.sourceActionCount + 1, f.base.sourceActionCount);
|
||||
assert.equal(f.actions.at(-1)!.action.type, 'battlePlan'); assert.equal(f.game.nexusTraitorPending ?? null, null);
|
||||
let game = bind(f).game; game = vote(game, f.target, true);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(game.battle!.traitorCalls[f.target], true);
|
||||
const receipt = structuredClone(game.battle!.traitorDeclarations![f.target]); assert.equal(receipt.identity, f.traitorHeld);
|
||||
const offer = viewGame(game, f.target).nexusTraitors!.offer!; assert.ok(!offer.blocked);
|
||||
const draw = nexusTraitorDrawAction(viewGame(game, f.target), offer.event, 'cunning'); assert.ok(draw);
|
||||
game = act(game, f.target, draw); const view = viewGame(game, f.target), pending = view.nexusTraitors!.pending!;
|
||||
assert.ok(!pending.choices.find(choice => choice.id === f.traitorHeld)?.blocked);
|
||||
const returned = nexusTraitorReturnAction(view, pending.event, [f.traitorHeld]); assert.ok(returned);
|
||||
game = act(game, f.target, returned); assert.ok(!player(game, f.target).traitors.includes(f.traitorHeld));
|
||||
assert.equal(game.battle!.traitorCalls[f.target], true); assert.deepEqual(game.battle!.traitorDeclarations![f.target], receipt);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, []);
|
||||
assert.ok(!viewGame(game, f.asker).truthMixedTraitorTargets.includes(f.target));
|
||||
rejected(game, f.target, { type: 'traitorCall', call: true });
|
||||
game = finish(game, f); assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
sourceInventory(game, f);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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, type TruthAnswer } from '../game/truthtrance';
|
||||
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
|
||||
import {
|
||||
actTruthMixedTraitor as act, answerTruthMixedTraitor as answer, askTruthMixedTraitor as ask,
|
||||
bindTruthMixedTraitor as bind, createTruthMixedTraitorFixture as fixture,
|
||||
declareTruthMixedTraitor as declare, finishTruthMixedTraitor as finish,
|
||||
settleTruthMixedTraitor as settle, truthMixedTraitorCall as call,
|
||||
truthMixedTraitorClean as clean, truthMixedTraitorFact as fact, truthMixedTraitorGroup as group,
|
||||
truthMixedTraitorInventory as inventory, truthMixedTraitorPlayer as player,
|
||||
truthMixedTraitorPromise as promise, truthMixedTraitorTraitors as traitors, voteTruthMixedTraitor as vote,
|
||||
} from './fixture-truth-mixed-traitor';
|
||||
|
||||
function rejected(game: Game, actor: string, candidate: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, candidate)); assert.deepEqual(game, before);
|
||||
}
|
||||
function truthOnce(game: Game, truth: string) {
|
||||
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
}
|
||||
|
||||
void test('native mixed Traitor retains the original Basic26 source and binds the actual declaration through cleanup', () => {
|
||||
const f = fixture(); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.equal(f.setup.deck.length, 33); assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
|
||||
assert.ok(f.setup.players.every(seat => !seat.hand.length)); assert.equal(f.sourceActionCount, 26);
|
||||
assert.equal(f.sourceActionCount, f.base.sourceActionCount); assert.equal(f.actions.length, f.sourceActionCount);
|
||||
assert.deepEqual(f.game, f.base.game); assert.equal(f.game.battle!.revealed, true);
|
||||
assert.deepEqual(f.game.battle!.traitorCalls, {}); assert.deepEqual(player(f.game, f.target).traitors, [f.traitorHeld]);
|
||||
assert.equal(f.game.battle!.plans[f.asker].leader, f.traitorHeld);
|
||||
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1);
|
||||
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1);
|
||||
const source = structuredClone(f.game), bound = bind(f); let game = bound.game;
|
||||
assert.deepEqual(f.game, source); assert.ok(clean(game)); assert.equal(promise(game).fulfilled, false);
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [true]);
|
||||
assert.equal(game.battle!.traitorDecisionPromises!.length, 1);
|
||||
assert.equal(game.battle!.traitorDecisionPromises![0].event, promise(game).event);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: false });
|
||||
game = vote(game, f.target, true); assert.equal(game.battle!.traitorCalls[f.target], true);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
game = finish(game, f); assert.equal(game.battle, null); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(promise(game).released, false); truthOnce(game, f.truth);
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
});
|
||||
|
||||
void test('native mixed Traitor freezes current spice before a real bribe and retains its required Reveal', () => {
|
||||
const f = fixture(), beforeSpice = player(f.game, f.target).spice, beforeBribes = player(f.game, f.asker).bribes;
|
||||
assert.ok(beforeSpice >= 3); let game = bind(f).game;
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'yes');
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
assert.equal(player(game, f.target).spice, beforeSpice - 3); assert.equal(player(game, f.asker).bribes, beforeBribes + 3);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
assert.equal(promise(game).fulfilled, false); assert.deepEqual(promise(game).mixed, f.mixed);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: false });
|
||||
game = vote(game, f.target, true); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(promise(finish(game, f)).fulfilled, true); truthOnce(game, f.truth);
|
||||
});
|
||||
|
||||
void test('native true current fact OR Reveal is terminal Yes and permits an actual decline', () => {
|
||||
const f = fixture(), mixed = group('or', fact(f.trueFact), call(true));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthMixedTraitorAnswers, ['yes']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(game.battle!.traitorDecisionPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false, true]);
|
||||
game = vote(game, f.target, false); assert.equal(game.battle!.traitorCalls[f.target], false);
|
||||
game = finish(game, f); assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
});
|
||||
|
||||
void test('native false current fact AND Reveal is terminal No and permits the actual matching call', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.falseFact), call(true));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthMixedTraitorAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'no'); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(game.battle!.traitorDecisionPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false, true]);
|
||||
game = vote(game, f.target, true); assert.equal(game.battle!.traitorCalls[f.target], true);
|
||||
assert.equal(promise(finish(game, f)).fulfilled, true);
|
||||
});
|
||||
|
||||
void test('native false current fact OR Reveal Yes still requires the actual matching declaration', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.falseFact), call(true))).game;
|
||||
assert.equal(promise(game).fulfilled, false); assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [true]);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: false }); game = vote(game, f.target, true);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(promise(finish(game, f)).released, false);
|
||||
});
|
||||
|
||||
void test('native true current fact AND Reveal No requires the actual decline', () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
assert.equal(promise(game).answer, false); assert.equal(promise(game).fulfilled, false);
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: true }); game = vote(game, f.target, false);
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(promise(finish(game, f)).released, false);
|
||||
});
|
||||
|
||||
void test('native No to true current fact AND decline inversely requires Reveal', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.trueFact), call(false)); let game = bind(f, 'no', mixed).game;
|
||||
assert.equal(promise(game).answer, false); assert.equal(game.battle!.traitorDecisionPromises![0].answer, true);
|
||||
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [true]);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: false }); game = vote(game, f.target, true);
|
||||
assert.equal(promise(game).fulfilled, true); assert.deepEqual(promise(game).mixed, mixed); finish(game, f);
|
||||
});
|
||||
|
||||
void test('native correlated call tautology and contradiction are terminal without inventing a fixed native vote', () => {
|
||||
const f = fixture();
|
||||
for (const kind of ['or', 'and'] as const) {
|
||||
const mixed = group('and', fact(f.trueFact), group(kind, call(true), call(false)));
|
||||
const expected = kind === 'or' ? 'yes' : 'no';
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthMixedTraitorAnswers, [expected]);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' });
|
||||
const answered = answer(asked, f.target, expected); assert.equal(promise(answered).fulfilled, true);
|
||||
assert.equal(answered.battle!.traitorDecisionPromises?.length ?? 0, 0);
|
||||
for (const choice of [false, true]) {
|
||||
let game = vote(answered, f.target, choice); assert.equal(game.battle!.traitorCalls[f.target], choice);
|
||||
game = finish(game, f); assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown forecast uses conservative mixed Traitor ${answerValue} and completes at the original declaration`, () => {
|
||||
const f = fixture(), target = player(f.game, f.target), knowledge = truthKnowledgeOf(f.game, target);
|
||||
assert.equal(knowledge.stormForecast, null); assert.equal(truthFactAnswer(target, f.forecastFact, knowledge), 'unknown');
|
||||
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), call(true));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthMixedTraitorAnswers, [answerValue]);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
|
||||
let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
|
||||
rejected(game, f.target, { type: 'traitorCall', call: answerValue !== 'yes' });
|
||||
game = vote(game, f.target, answerValue === 'yes'); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(promise(finish(game, f)).released, false); truthOnce(game, f.truth);
|
||||
});
|
||||
|
||||
void test('controlled conserved original-card exchange keeps mixed answers inside native matching-card eligibility', () => {
|
||||
const f = fixture(), source = structuredClone(f.game), target = player(source, f.target);
|
||||
// Controlled physical exchange only: this is not an earned native exchange
|
||||
// action, and the altered hand is not claimed as original source provenance.
|
||||
const reserve = source.traitorReserve!.findIndex(id => id !== f.traitorHeld), held = target.traitors.indexOf(f.traitorHeld);
|
||||
assert.ok(reserve >= 0 && held >= 0);
|
||||
[target.traitors[held], source.traitorReserve![reserve]] = [source.traitorReserve![reserve], target.traitors[held]];
|
||||
assert.deepEqual(traitors(source), f.originalTraitors);
|
||||
const asked = ask(declare(f, source).game, f.asker, f.target, f.mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthMixedTraitorAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'no'); assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]);
|
||||
game = vote(game, f.target, false); assert.equal(promise(game).fulfilled, true); finish(game, f);
|
||||
});
|
||||
|
||||
void test('native mixed Traitor Ask enforces original owner event territory raw wire and revealed scope', () => {
|
||||
const f = fixture(), declared = declare(f), game = declared.game;
|
||||
assert.ok(viewGame(game, f.asker).truthMixedTraitorTargets.includes(f.target)); rejected(game, f.target, f.questionAction);
|
||||
for (const replacement of [{ battle: 'stale-battle' }, { battle: null }, { territory: 'carthag' },
|
||||
{ target: 'foreign-seat' }, { binding: true }, { forcedCall: true }, { frozen: true }])
|
||||
rejected(game, f.asker, { type: 'truthAsk', question: { ...f.question, ...replacement } });
|
||||
const wire: Action = JSON.parse(JSON.stringify(f.questionAction)), asked = act(game, f.asker, wire);
|
||||
assert.deepEqual(asked.truthtrance!.question, f.question);
|
||||
assert.deepEqual(new Set(viewGame(asked, f.target).truthMixedTraitorAnswers), new Set(['yes', 'no']));
|
||||
assert.equal(viewGame(asked, f.asker).truthMixedTraitorAnswers, null); assert.equal(viewGame(asked, f.target).truthTraitorAnswers, null);
|
||||
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'unknown' });
|
||||
const done = act(asked, f.target, JSON.parse(JSON.stringify({ type: 'truthAnswer', answer: 'yes' })) as Action);
|
||||
rejected(done, f.target, { type: 'truthAnswer', answer: 'yes' }); truthOnce(done, f.truth);
|
||||
const unrevealed = declare(f, f.beforeReveal).game;
|
||||
assert.deepEqual(viewGame(unrevealed, f.asker).truthMixedTraitorTargets, []); rejected(unrevealed, f.asker, wire);
|
||||
let ended = finish(vote(f.game, f.target, false), f); ended = declare(f, settle(ended)).game;
|
||||
assert.deepEqual(viewGame(ended, f.asker).truthMixedTraitorTargets, []); rejected(ended, f.asker, wire);
|
||||
});
|
||||
|
||||
void test('native mixed Traitor projections expose only each respondents raw aggregate and retain hidden native choice constraints', () => {
|
||||
const f = fixture(), game = bind(f).game, before = structuredClone(game);
|
||||
assert.equal(game.mixedTraitorQuestions?.length, 1);
|
||||
for (const actor of [f.asker, f.target]) {
|
||||
const view = viewGame(game, actor); assert.ok(!('mixedTraitorQuestions' in view));
|
||||
assert.deepEqual(view.battle!.ownTraitorPromises, []); assert.equal(view.truthMixedTraitorAnswers, null);
|
||||
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
|
||||
if (actor !== f.target) assert.deepEqual(view.mixedTraitorPromises, []);
|
||||
else {
|
||||
const row = promise(game);
|
||||
assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'battle', 'event', 'fulfilled', 'mixed', 'player', 'released', 'territory', 'turn']);
|
||||
assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, 1);
|
||||
assert.equal(row.battle, f.event); assert.equal(row.territory, f.territory); assert.equal(row.answer, true);
|
||||
assert.deepEqual(row.mixed, f.mixed); assert.equal(row.fulfilled, false); assert.equal(row.released, false);
|
||||
assert.deepEqual(view.battle!.traitorCallChoices, [true]);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(game, before); const completed = finish(vote(game, f.target, true), f);
|
||||
assert.equal(promise(completed).fulfilled, true); assert.equal(promise(completed).released, false);
|
||||
for (const actor of [f.asker, f.target]) assert.equal(viewGame(completed, actor).mixedTraitorPromises.length, actor === f.target ? 1 : 0);
|
||||
});
|
||||
|
||||
void test('native submitted false and true Traitor slots cannot be reopened by a mixed question', () => {
|
||||
const f = fixture();
|
||||
for (const choice of [false, true]) {
|
||||
const source = vote(f.game, f.target, choice), declared = declare(f, source).game;
|
||||
assert.equal(declared.battle!.traitorCalls[f.target], choice);
|
||||
assert.ok(!viewGame(declared, f.asker).truthMixedTraitorTargets.includes(f.target));
|
||||
rejected(declared, f.asker, f.questionAction); assert.equal(declared.battle!.traitorCalls[f.target], choice);
|
||||
assert.ok(player(declared, f.asker).hand.some(card => card.id === f.truth));
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer a native mixed Traitor question and actually declare its permitted vote', () => {
|
||||
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const answering = viewGame(game, f.target);
|
||||
answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const answerAction = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
|
||||
assert.ok(answering.truthMixedTraitorAnswers?.includes(answerAction.answer as TruthAnswer));
|
||||
game = act(game, f.target, answerAction); const declaring = viewGame(game, f.target);
|
||||
declaring.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(declaring.battle!.ownTraitorPromises, []);
|
||||
const declaration = botActions(declaring).find(candidate => candidate.type === 'traitorCall'); assert.ok(declaration, difficulty);
|
||||
assert.ok(declaring.battle!.traitorCallChoices!.includes(declaration.call as boolean));
|
||||
game = act(game, f.target, declaration); assert.equal(game.battle!.traitorCalls[f.target], answerAction.answer === 'yes');
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
|
||||
game = finish(game, f); assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user