Combine current facts with native Revival

This commit is contained in:
Quintine committed 2026-10-26 16:53:27 +11:00
1 parent 6b2dfb84a2
commit 957d5a172f
19 files changed
+1386 -5

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
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 { RevivalExpressionLeaf } from '../game/revival-expression-promises';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthRevivalExpression, advanceTruthRevivalExpression, createTruthRevivalExpressionFixture,
declareTruthRevivalExpression, finishTruthRevivalExpression, nextTruthRevivalExpression,
settleTruthRevivalExpression, truthRevivalExpressionClean, truthRevivalExpressionInventory,
truthRevivalExpressionPlayer, type TruthRevivalExpressionStep,
} from './fixture-truth-revival-expression';
export type TruthMixedRevivalExpression = MixedActionExpression<RevivalExpressionLeaf>;
export type TruthMixedRevivalStep = TruthRevivalExpressionStep;
export const actTruthMixedRevival = actTruthRevivalExpression;
export const advanceTruthMixedRevival = advanceTruthRevivalExpression;
export const finishTruthMixedRevival = finishTruthRevivalExpression;
export const nextTruthMixedRevival = nextTruthRevivalExpression;
export const settleTruthMixedRevival = settleTruthRevivalExpression;
export const truthMixedRevivalClean = truthRevivalExpressionClean;
export const truthMixedRevivalInventory = truthRevivalExpressionInventory;
export const truthMixedRevivalPlayer = truthRevivalExpressionPlayer;
export const truthMixedRevivalFact = (fact: TruthFact): TruthMixedRevivalExpression => ({ kind: 'fact', fact });
export const truthMixedRevivalForce = (minimum = 1, counter: 'total' | 'normal' | 'elite' = 'total'):
TruthMixedRevivalExpression => ({ kind: 'action', action: { kind: 'forces', counter, minimum } });
export const truthMixedRevivalLeader = (leader: string): TruthMixedRevivalExpression =>
({ kind: 'action', action: { kind: 'leader', leader } });
export const truthMixedRevivalGroup = (kind: 'and' | 'or', ...terms: TruthMixedRevivalExpression[]):
TruthMixedRevivalExpression => ({ kind, terms });
/** Original public H/A/T Ix47: the inherited paid shipment and actual projectile
* kill reach T4, one tank counter and a cost2 personal early Hidar return. This
* adapter changes only the raw question. It installs no played state or binding. */
export function createTruthMixedRevivalFixture(options: {
advanced?: boolean; window?: 'active' | 'waiting'; ghola?: boolean; bothDead?: boolean;
mixed?: TruthMixedRevivalExpression;
} = {}) {
const { mixed: suppliedMixed, ...sourceOptions } = options;
const base = createTruthRevivalExpressionFixture(sourceOptions);
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 4 };
const falseSpiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 5 };
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
const mixed = structuredClone(suppliedMixed ?? truthMixedRevivalGroup('and', truthMixedRevivalFact(spiceFact),
truthMixedRevivalForce(), truthMixedRevivalLeader(base.leader)));
const question = { kind: 'mixedRevival' as const, target: base.target, turn: base.game.turn, mixed };
return { ...base, revivalSource: base, mixed, question, spiceFact, falseSpiceFact, forecastFact,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceCounts: { inherited: base.sourceActions.length, postStorm: base.postStormSourceActions.length,
phase3: base.phase3SourceActions.length },
sourceDisclosure: `${base.sourceDisclosure} This thin mixed adapter changes only the original raw question: current facts and future ordinary typed force/named-leader returns share one AND/OR tree. It inherits every original action and freezes no fact or residual itself.` };
}
export type TruthMixedRevivalFixture = ReturnType<typeof createTruthMixedRevivalFixture>;
export function declareTruthMixedRevival(f: TruthMixedRevivalFixture, source = f.game,
options: { copies?: 1 | 2 } = {}) {
return declareTruthRevivalExpression(f.revivalSource, source, options);
}
export function askTruthMixedRevival(game: Game, asker: string, target: string, mixed: TruthMixedRevivalExpression,
actions: TruthMixedRevivalStep[] = []): Game {
return actTruthMixedRevival(game, asker, { type: 'truthAsk', question: {
kind: 'mixedRevival', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthMixedRevival(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthMixedRevivalStep[] = []): Game {
return actTruthMixedRevival(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthMixedRevival(f: TruthMixedRevivalFixture, answer: 'yes' | 'no' = 'yes',
mixed = f.mixed, source = f.game) {
const declared = declareTruthMixedRevival(f, source), actions = [...declared.actions];
const asked = askTruthMixedRevival(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthMixedRevival(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** Own public raw aggregate only; no private frozen clause or residual access. */
export function truthMixedRevivalPromise(game: Game,
target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).mixedRevivalPromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original mixed question and whole-answer status');
return row;
}
export function completeTruthMixedRevival(game: Game, target: string, actions: TruthMixedRevivalStep[] = []): Game {
for (let count = 0; count < 4 && !truthMixedRevivalPromise(game, target).fulfilled; count++) {
const guide = viewGame(game, target).revivalExpressionPromiseGuide;
assert.ok(guide, 'The owned mixed Yes has an exact native ordinary return guide');
assert.equal(guide.phase, game.phase);
game = settleTruthMixedRevival(actTruthMixedRevival(game, target, guide.action, actions), actions);
}
assert.equal(truthMixedRevivalPromise(game, target).fulfilled, true);
return game;
}
+325
View File
@@ -0,0 +1,325 @@
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 { MixedRevivalFields, mixedRevivalInputError } from '../components/mixed-revival-fields';
import { MixedRevivalPromises } from '../components/mixed-revival-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { RevivalExpressionPromiseGuideButton, RevivalExpressionPromises } from '../components/revival-expression-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { RevivalActionClaim } from '../game/mixed-revival-question';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import { createTechTokens } from '../game/tech-tokens';
import type { TruthFact } from '../game/truthtrance';
type Expression = MixedActionExpression<RevivalActionClaim>;
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 force = (minimum = 1, counter: 'total' | 'normal' | 'elite' = 'total'): Expression =>
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const named = (leader = 'named-leader'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [force(3), named()] }] });
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-revival-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-revival-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-mixed-revival-${path}-minimum`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, next: TruthFact) {
const scope = find(tree, 'data-mixed-revival-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);
}
/** Controlled projections prove field and exact callback contracts. Earned
* cards, actual casualties, quotes and native returns have separate proof. */
function projected(phase = 4, type = 'revive', advanced = false): GameView {
const source = createGame('TRUTHMIXEDREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = [];
game.allLeaders = [
{ id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 },
{ id: 'second-leader', name: 'Second Commander', faction: 'emperor', strength: 4 },
{ id: 'foreign-leader', name: 'Foreign Commander', faction: 'atreides', strength: 5 },
];
game.revivalExpressionPromises = [];
game.mixedRevivalPromises = [{ event: 'controlled-mixed-revival-event', player: 'p', asker: 'a', turn: 2,
mixed: claim(), answer: type !== 'ready', progress: { total: 2, normal: 1, elite: 1, leaders: ['named-leader'] },
fulfilled: false, released: false }];
game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null;
game.active = advanced ? 'p' : null;
game.auction = phase === 3 ? { bid: 4, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p',
remaining: 2, inspectionBlocked: null, card: null } : null;
game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null;
if (type === 'acceptLeaderRevival') game.revivalRequests.p = { leader: 'named-leader', price: 3 };
game.revivalExpressionPromiseGuide = { phase, action: { type, revivalExpressionPromiseEvent: 'controlled-mixed-revival-event',
...(type === 'revive' ? { amount: 2, elite: 1, freeElite: 0 } : {}),
...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}),
...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: 3 } : {}),
...(type === 'decision' ? { karama: true, card: 'controlled-original-karama' } : {}) } };
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: () => MixedRevivalFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MixedRevivalFields, { value, game, target, disabled, onChange() {} })),
error: () => mixedRevivalInputError(value, game, target),
};
}
function guide(props: Parameters<typeof RevivalExpressionPromiseGuideButton>[0]) {
const button = RevivalExpressionPromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedRevivalPromises, { game, busy: false, send() {} }));
const pureMarkup = (game: GameView) => renderToStaticMarkup(createElement(RevivalExpressionPromises, { game, busy: false, send() {} }));
void test('shared mixed editor keeps sixteen original conditions, typed force counters and unique per-leaf IDs without overflow', () => {
const e = editor(fact()); click(control(e.tree(), 'or-1'));
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
if (count % 3 === 0) select(e.tree(), `1.${count}`, 'counter', 'elite');
}
select(e.tree(), '1.2', 'kind', 'leader'); select(e.tree(), '1.2', 'leader', 'second-leader');
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[1], named('second-leader'));
assert.deepEqual(full.terms[2], force(3, 'elite')); assert.equal(e.error(), null);
const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
assert.match(html, /16 \/ 16 conditions/); assert.equal((html.match(/Revival condition type/g) ?? []).length, 15);
assert.equal(new Set(ids).size, ids.length);
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 and embedded fact groups retain every branch through edits, collapse and the shared depth-four envelope', () => {
const e = editor({ kind: 'and', terms: [fact(), force(3)] });
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);
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.match(e.html(), /Current-fact group 1\.1/); 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.4')); click(control(e.tree(), 'remove-1.3'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), force(3)] });
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, force()); 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, force()] };
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(mixedRevivalInputError({ kind: 'and', terms: [nested, force()] }, projected(), 'p'));
});
void test('numeric and named drafts stay repairable while exact action leaves reject groups or metadata and busy callbacks preserve the tree', () => {
for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) {
const e = editor({ kind: 'and', terms: [fact(), force()] });
minimum(e.tree(), '1.2', invalid); assert.ok(e.error()); click(control(e.tree(), 'or-1.2'));
const raw = e.value; assert.ok(raw.kind === 'and');
assert.deepEqual(raw.terms[1], { kind: 'or', terms: [force(invalid), fact()] });
assert.match(e.html(), /role="alert"/);
minimum(e.tree(), '1.2.1', 2); assert.equal(e.error(), null);
select(e.tree(), '1.2.1', 'kind', 'leader'); select(e.tree(), '1.2.1', 'leader', ''); assert.ok(e.error());
select(e.tree(), '1.2.1', 'leader', 'named-leader'); assert.equal(e.error(), null);
}
for (const action of [{ kind: 'and', terms: [{ kind: 'forces', counter: 'total', minimum: 1 }] },
{ kind: 'forces', counter: 'total', minimum: 1, turn: 2 }, { kind: 'leader', leader: 'named-leader', fulfilled: true },
{ kind: 'forces', counter: 'strength', minimum: 1 }, { kind: 'leader', leader: 'foreign-leader' }])
assert.ok(mixedRevivalInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p'));
const frozen = editor({ kind: 'and', terms: [fact(), force()] }, 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));
minimum(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'kind', 'leader');
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('leader choices use public respondent faction and Advanced Atreides identity while current facts inspect no secret eligibility', () => {
for (const advanced of [false, true]) {
const game = projected(4, 'revive', advanced);
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const key of ['revival', 'deck', 'spiceDeck', 'discard']) Object.defineProperty(game, key, {
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
});
const own = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), named()] }, false, game);
assert.equal(own.error(), null);
const ownHTML = own.html(); assert.match(ownHTML, /Named Commander/); assert.match(ownHTML, /Second Commander/);
assert.doesNotMatch(ownHTML, /Foreign Commander|Kwisatz Haderach/);
assert.match(ownHTML, /Current facts are evaluated when the player answers/);
assert.match(ownHTML, /unknown current fact can still allow a definite whole answer/);
assert.match(ownHTML, /conservative future commitment/); assert.match(ownHTML, /If no such value is available, the answer is I don’t know/);
const other = editor({ kind: 'and', terms: [fact(), named('foreign-leader')] }, false, game, 'a');
assert.equal(other.error(), null); assert.match(other.html(), /Foreign Commander/); assert.doesNotMatch(other.html(), /Named Commander|Second Commander/);
if (advanced) assert.match(other.html(), /Kwisatz Haderach/); else assert.doesNotMatch(other.html(), /Kwisatz Haderach/);
assert.equal(mixedRevivalInputError({ kind: 'and', terms: [fact(), named('kwisatz')] }, game, 'a') === null, advanced);
editFact(own.tree(), '1.1', { kind: 'spice', compare: 'gte', value: NaN }); assert.ok(own.error());
editFact(own.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(own.error(), null);
}
});
void test('own aggregate-only panel preserves the original question, whole answer and typed cumulative ordinary progress without frozen branches', () => {
const game = projected(); game.revivalExpressionPromiseGuide = null;
game.mixedRevivalPromises.push(
{ ...game.mixedRevivalPromises[0], event: 'no-event', answer: false },
{ ...game.mixedRevivalPromises[0], event: 'fulfilled-event', fulfilled: true },
{ ...game.mixedRevivalPromises[0], event: 'released-event', released: true },
{ ...game.mixedRevivalPromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), named('foreign-leader')] } },
);
for (const promise of game.mixedRevivalPromises) for (const key of ['binding', 'residual', 'factAnswers', 'frozenFacts', 'compiled'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
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, /Named Commander/);
assert.match(html, /Future ordinary progress: 2 physical forces \(1 normal, 1 elite\)/); assert.match(html, /returned leaders: Named Commander/);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
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, /cumulative across smaller future ordinary returns/);
assert.match(html, /Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count/);
assert.match(html, /Original costs, revival cycles, limits and turn order still apply/);
assert.doesNotMatch(html, /Poison Blade|Foreign Commander|controlled-mixed-revival-event|fulfilled-event|factAnswers/);
assert.equal(MixedRevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('shared Basic and ordered Advanced guides forward the exact mixed return packet and accept only the current leader price', () => {
for (const advanced of [false, true]) for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready']) {
const game = projected(4, type, advanced), sent: Action[] = [];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); assert.equal(sent[0], game.revivalExpressionPromiseGuide!.action);
assert.match(markup(game), /data-testid="revival-expression-promise-guide"/);
if (type === 'revive') assert.deepEqual(sent[0], { type: 'revive', amount: 2, elite: 1, freeElite: 0,
revivalExpressionPromiseEvent: 'controlled-mixed-revival-event' });
if (type === 'acceptLeaderRevival') {
game.revivalRequests.p.price = 4; press(button); assert.equal(sent.length, 1);
game.revivalRequests.p.price = 3; game.revivalRequests.p.declined = true; press(button); assert.equal(sent.length, 1);
}
if (advanced) { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; press(button); assert.equal(sent.length, 1); }
}
});
void test('early mixed preparations retain Charity, ordinary auction payment and owned native silent-zero or pass packets', () => {
for (const [phase, type] of [[2, 'charity'], [2, 'ready'], [3, 'passBid'], [3, 'decision']] as const) {
const game = projected(phase, type), 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.revivalExpressionPromiseGuide!.action);
if (type === 'decision') {
assert.equal(sent[0].card, 'controlled-original-karama'); assert.equal(sent[0].karama, true);
assert.doesNotMatch(markup(game), /controlled-original-karama/); game.decision!.player = 'a';
} else if (phase === 2) game.ready.push('p'); else game.auction!.active = 'a';
press(button); assert.equal(sent.length, 1);
}
for (const method of ['silent', 'onceAround'] as const) {
const game = projected(3, 'richeseBid'), sent: Action[] = [];
const native = createRicheseAuction({ event: 'controlled-mixed-revival-auction', cardId: 'controlled-card', owner: 'a',
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
game.revivalExpressionPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0, revivalExpressionPromiseEvent: 'controlled-mixed-revival-event' } };
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.revivalExpressionPromiseGuide.action);
game.richeseAuction.event = 'later-auction'; press(button); assert.equal(sent.length, 1);
game.richeseAuction.event = native.event;
if (method === 'silent') game.richeseAuction.submitted.push('p'); else game.richeseAuction.active = 'a';
press(button); assert.equal(sent.length, 1);
}
});
void test('stale mixed guide callbacks preserve exact event, owner, turn, phase, action counters, order and quiet-window guards without private inspection', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
game => { game.ready = ['p']; }, game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; },
game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; }, game => { game.revivalOrder = null; },
game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; },
game => { game.revivalExpressionPromiseGuide = { phase: 4, action: { ...game.revivalExpressionPromiseGuide!.action } }; },
game => { game.revivalExpressionPromiseGuide = null; }, game => { game.revivalExpressionPromiseGuide!.phase = 3; },
game => { game.revivalExpressionPromiseGuide!.action.amount = 3; }, game => { game.revivalExpressionPromiseGuide!.action.elite = 0; },
game => { game.revivalExpressionPromiseGuide!.action.freeElite = 1; },
game => { game.mixedRevivalPromises[0].event = 'expired'; }, game => { game.mixedRevivalPromises[0].player = 'a'; },
game => { game.mixedRevivalPromises[0].turn = 1; }, game => { game.mixedRevivalPromises[0].fulfilled = true; },
game => { game.mixedRevivalPromises[0].released = true; }, game => { game.mixedRevivalPromises = []; },
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(4, 'revive', true), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, send() { assert.fail('A busy guide cannot send'); } };
const button = guide(busy); busy.busy = true; press(button);
const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot send'); } };
const original = guide(replaced); replaced.game = { ...replaced.game }; press(original);
for (const [phase, type] of [[0, 'ready'], [1, 'ready'], [2, 'revive'], [3, 'revive'], [4, 'charity']] as const) {
const game = projected(phase, type), sent: Action[] = [];
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
}
for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'decision']) {
const game = projected(type === 'decision' ? 3 : 4, type), sent: Action[] = [];
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['revival', 'deck', 'spiceDeck', 'discard']) Object.defineProperty(game, key, {
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
});
if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No hidden auction lot inspection'); } });
assert.match(markup(game), /Your mixed Revival promises/);
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]);
}
});
void test('mixed panels wait for the actual Revival actor and share one guide with live pure records while historical panels stay passive', () => {
for (const phase of [0, 1, 2, 3]) {
const game = projected(phase); game.revivalExpressionPromiseGuide = null;
assert.match(markup(game), /Your Revival opportunity is still ahead/); assert.doesNotMatch(markup(game), /<button/);
}
const game = projected(4, 'revive', true); game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a';
game.revivalExpressionPromiseGuide = null; assert.match(markup(game), /while the current actor finishes/);
game.revivalOrder!.remaining = ['p', 'a']; game.active = 'p'; game.revivalExpressionPromiseGuide = projected().revivalExpressionPromiseGuide;
assert.match(markup(game), /data-testid="revival-expression-promise-guide"/);
game.revivalExpressionPromises.push({ event: 'controlled-pure-event', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'forces', counter: 'total', minimum: 3 }, answer: true, progress: { total: 0, normal: 0, elite: 0, leaders: [] } });
assert.doesNotMatch(markup(game), /data-testid="revival-expression-promise-guide"/);
assert.match(pureMarkup(game), /data-testid="revival-expression-promise-guide"/);
game.revivalExpressionPromises[0].fulfilled = true;
assert.match(markup(game), /data-testid="revival-expression-promise-guide"/);
assert.doesNotMatch(pureMarkup(game), /data-testid="revival-expression-promise-guide"/);
game.revivalExpressionPromises[0].fulfilled = false; game.revivalExpressionPromises[0].turn = 1;
assert.match(markup(game), /data-testid="revival-expression-promise-guide"/);
assert.doesNotMatch(pureMarkup(game), /data-testid="revival-expression-promise-guide"/);
game.mixedRevivalPromises[0].fulfilled = true;
assert.doesNotMatch(markup(game), /data-testid="revival-expression-promise-guide"|Your Revival opportunity is still ahead/);
game.mixedRevivalPromises[0].fulfilled = false; game.mixedRevivalPromises[0].turn = 1;
assert.doesNotMatch(markup(game), /data-testid="revival-expression-promise-guide"|Your Revival opportunity is still ahead/);
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthMixedRevival as act, answerTruthMixedRevival as answer, askTruthMixedRevival as ask,
completeTruthMixedRevival as complete, createTruthMixedRevivalFixture as fixture,
declareTruthMixedRevival as declare, finishTruthMixedRevival as finish,
settleTruthMixedRevival as settle, truthMixedRevivalFact as fact,
truthMixedRevivalForce as force, truthMixedRevivalGroup as group,
truthMixedRevivalInventory as inventory, truthMixedRevivalLeader as leader,
truthMixedRevivalPlayer as player, truthMixedRevivalPromise as promise,
} from './fixture-truth-mixed-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthsOnce(game: Game, truths: string[]) {
for (const truth of truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
void test('two original Truths retain scalar force No while mixed OR Yes follows the real alternate leader', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1,
} });
game = answer(game, f.target, 'no');
game = ask(game, f.asker, f.target, group('and', fact(f.spiceFact), group('or', force(), leader(f.leader))));
assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['yes', 'no']);
game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'reviveLeader'); game = settle(act(game, f.target, guide.action));
assert.equal(promise(game).fulfilled, true); assert.equal(game.revivalPromises![0].progress, 0);
assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).spice, 2);
game = finish(game); assert.equal(game.revivalPromises![0].fulfilled, true);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths retain scalar leader No while mixed OR Yes returns only the real free force', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'leaderRevival', target: f.target, turn: game.turn, leader: f.leader,
} });
game = answer(game, f.target, 'no');
game = answer(ask(game, f.asker, f.target,
group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)))), f.target, 'yes');
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'revive'); game = settle(act(game, f.target, guide.action));
assert.equal(promise(game).fulfilled, true); assert.deepEqual(promise(game).progress.leaders, []);
assert.equal(player(game, f.target).spice, 5); rejected(game, f.target, f.revivalAction);
game = finish(game); assert.equal(game.leaderRevivalPromises![0].fulfilled, true);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths share one native leader invoice between an earlier named Yes and mixed AND Yes', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'leaderRevival', target: f.target, turn: game.turn, leader: f.leader,
} });
game = answer(game, f.target, 'yes'); game = answer(ask(game, f.asker, f.target, f.mixed), f.target, 'yes');
game = complete(game, f.target);
assert.equal(game.leaderRevivalPromises![0].fulfilled, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).leaderRevived, true);
assert.equal(player(game, f.target).freeForcesRevived, 1);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.deaths, 1);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths preserve an earlier mixed AND Yes against a contradictory later scalar force No', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = answer(ask(game, f.asker, f.target, f.mixed), f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1,
} });
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = answer(game, f.target, 'yes'); game = complete(game, f.target);
assert.equal(game.revivalPromises![0].fulfilled, true); assert.equal(game.revivalPromises![0].progress, 1);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 3);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('earlier original Bribe Yes reserves T4 before a later mixed ordinary AND answer', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4,
} });
game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.opponent).bribes, 4); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(game.bribePromises![0].fulfilled, true);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('earlier mixed T4 fact and AND Yes retain their budget through a real bribe3 free-force bank1 and leader2', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = answer(ask(game, f.asker, f.target, f.mixed), f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4,
} });
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); game = answer(game, f.target, 'no');
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 });
assert.equal(player(game, f.target).spice, 1);
let guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'revive');
game = settle(act(game, f.target, guide.action)); assert.equal(player(game, f.target).spice, 2);
assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'reviveLeader');
game = settle(act(game, f.target, guide.action)); assert.equal(player(game, f.target).spice, 0);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.opponent).bribes, 3);
game = finish(game); assert.ok(!game.bribePromises![0].fulfilled, 'The actual turn-wide bribe No outlasts Revival');
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('earlier original shipment funding excludes a later mixed AND beyond the original shared purse', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 4 },
} });
game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
assert.equal(game.shipmentPromises![0].answer, true); assert.ok(!game.shipmentPromises![0].released);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
void test('earlier mixed AND reserves one real return invoice before a later original shipment answer', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = answer(ask(game, f.asker, f.target, f.mixed), f.target, 'yes');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 4 },
} });
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']); game = answer(game, f.target, 'no');
game = complete(game, f.target); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 3); assert.ok(!game.shipmentPromises![0].fulfilled);
truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
});
+315
View File
@@ -0,0 +1,315 @@
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 {
actTruthMixedRevival as act, advanceTruthMixedRevival as advance,
answerTruthMixedRevival as answer, askTruthMixedRevival as ask,
bindTruthMixedRevival as bind, completeTruthMixedRevival as complete,
createTruthMixedRevivalFixture as fixture, declareTruthMixedRevival as declare,
finishTruthMixedRevival as finish, settleTruthMixedRevival as settle,
truthMixedRevivalClean as clean, truthMixedRevivalFact as fact,
truthMixedRevivalForce as force, truthMixedRevivalGroup as group,
truthMixedRevivalInventory as inventory, truthMixedRevivalLeader as leader,
truthMixedRevivalPlayer as player, truthMixedRevivalPromise as promise,
} from './fixture-truth-mixed-revival';
const progress0 = { total: 0, normal: 0, elite: 0, leaders: [] };
const forceAction: Action = { type: 'revive', amount: 1, elite: 0 };
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 Revival freezes original T4 before real force income and named leader debit`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.expansions, ['ix']); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, advanced ? 108 : 102); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceCounts.inherited, f.sourceActionCount);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
assert.equal(f.revivalSource.question.kind, 'revivalExpression'); assert.equal(f.question.kind, 'mixedRevival');
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1);
assert.equal(player(f.afterBattle, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
assert.equal(player(f.game, f.target).leaders.filter(disc => !disc.dead).length, 4);
assert.equal(player(f.game, f.target).revivalCycle, 0); assert.equal(f.quote.early, true); assert.equal(f.quote.cost, 2);
assert.equal(player(f.game, f.target).tanks, 1); assert.equal(player(f.game, f.target).spice, 4);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
assert.deepEqual(promise(bound.game).mixed, f.mixed); assert.deepEqual(promise(bound.game).progress, progress0);
rejected(bound.game, f.target, { type: 'ready' });
let game = act(bound.game, f.target, forceAction);
if (game.pendingRevival) assert.deepEqual(promise(game).progress, progress0);
game = settle(game);
assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [] });
assert.equal(promise(game).fulfilled, false); assert.equal(player(game, f.target).spice, 5);
assert.equal(player(game, f.target).freeForcesRevived, 1); assert.equal(player(game, f.target).tanks, 0);
rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.phase, 4); assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game).event);
game = settle(act(game, f.target, guide.action));
assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).spice, 3); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(player(game, f.target).leaderRevived, true);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.deaths, 1);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
if (advanced) assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true current fact OR return answers Yes immediately with no native Revival binding', () => {
const f = fixture(), mixed = group('or', fact(f.spiceFact), leader(f.leader));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, ['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.revivalExpressionPromises?.length ?? 0, 0);
assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
assert.deepEqual(promise(game).mixed, mixed); assert.deepEqual(promise(game).progress, progress0);
game = finish(game); assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).revived, 0);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false current fact AND return answers No immediately while the real return remains legal', () => {
const f = fixture(), mixed = group('and', fact(f.falseSpiceFact), force());
assert.equal(truthFactAnswer(player(f.game, f.target), f.falseSpiceFact), 'no');
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, ['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.revivalExpressionPromises?.length ?? 0, 0);
game = settle(act(game, f.target, forceAction)); assert.equal(player(game, f.target).spice, 5);
assert.equal(truthFactAnswer(player(game, f.target), f.falseSpiceFact), 'yes');
assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native false current fact OR return Yes requires the actual ordinary return', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.falseSpiceFact), leader(f.leader))).game;
assert.equal(promise(game).fulfilled, false); assert.deepEqual(promise(game).progress, progress0);
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.revivalAction));
assert.equal(promise(game).fulfilled, true); assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
assert.equal(player(game, f.target).spice, 2); truthOnce(game, f.truth);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true current fact AND return No forbids that exact ordinary event until the real deadline', () => {
const f = fixture(); let game = bind(f, 'no', group('and', fact(f.spiceFact), force())).game;
rejected(game, f.target, forceAction); assert.equal(promise(game).fulfilled, false);
game = settle(act(game, f.target, f.revivalAction)); assert.equal(player(game, f.target).spice, 2);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, forceAction); assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).tanks, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast uses the conservative whole ${answerValue} ordinary-return constraint`, () => {
const f = fixture(), p = player(f.game, f.target);
assert.equal(p.faction, 'tleilaxu'); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), leader(f.leader));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, [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);
if (answerValue === 'yes') {
game = settle(act(game, f.target, f.revivalAction)); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).progress.leaders, [f.leader]);
} else {
rejected(game, f.target, f.revivalAction); game = settle(act(game, f.target, forceAction));
assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
}
game = finish(game); assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed OR preserves the manual force or leader alternative instead of pinning its guide', () => {
const f = fixture(), mixed = group('or', group('and', fact(f.spiceFact), force()), leader(f.leader));
let game = bind(f, 'yes', mixed).game;
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.ok(guide.action.type === 'revive' || guide.action.type === 'reviveLeader');
const manual = guide.action.type === 'revive' ? f.revivalAction : forceAction;
assert.notEqual(manual.type, guide.action.type); game = settle(act(game, f.target, manual));
assert.equal(promise(game).fulfilled, true);
assert.equal(promise(game).progress.total, manual.type === 'revive' ? 1 : 0);
assert.deepEqual(promise(game).progress.leaders, manual.type === 'reviveLeader' ? [f.leader] : []);
rejected(game, f.target, guide.action); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native mixed partial normal returns retain a larger force OR until the real alternate leader return', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), group('or', force(2, 'normal'), leader(f.leader)));
let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, forceAction));
assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'reviveLeader'); game = settle(act(game, f.target, guide.action));
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).progress.total, 1);
assert.deepEqual(promise(game).progress.leaders, [f.leader]); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
for (const first of ['forces', 'leader'] as const) void test(`native mixed whole AND No permits ${first} first and rejects only whole completion`, () => {
const f = fixture(); let game = bind(f, 'no').game;
game = settle(act(game, f.target, first === 'forces' ? forceAction : f.revivalAction));
assert.equal(promise(game).fulfilled, false); assert.equal(promise(game).progress.total, first === 'forces' ? 1 : 0);
assert.deepEqual(promise(game).progress.leaders, first === 'leader' ? [f.leader] : []);
rejected(game, f.target, first === 'forces' ? f.revivalAction : forceAction);
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed whole OR No forbids both qualifying ordinary returns through the native deadline', () => {
const f = fixture(), mixed = group('or', fact(f.falseSpiceFact), force(), leader(f.leader));
let game = bind(f, 'no', mixed).game;
rejected(game, f.target, forceAction); rejected(game, f.target, f.revivalAction);
assert.deepEqual(promise(game).progress, progress0); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior ordinary returns start outside a newly answered mixed interval', () => {
const f = fixture(); let game = settle(act(f.game, f.target, forceAction));
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).spice, 5);
const mixed = group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)));
game = bind(f, 'yes', mixed, game).game;
assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, false);
game = settle(act(game, f.target, f.revivalAction));
assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native original Ghola stays outside the mixed ordinary-return progress and No restriction', () => {
const f = fixture({ ghola: true }); assert.ok(f.ghola);
let game = bind(f, 'no', group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)))).game;
game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }));
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(player(game, f.target).leaderRevived, false); assert.equal(player(game, f.target).spice, 5);
assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, forceAction); game = finish(game); assert.equal(promise(game).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed projection exposes only owned raw question aggregate and filters its pure residual', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.opponent]) {
const view = viewGame(game, actor); assert.ok(!('mixedRevivalQuestions' in view));
assert.deepEqual(view.revivalExpressionPromises, []); assert.equal(view.truthMixedRevivalAnswers, 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.mixedRevivalPromises, []); assert.equal(view.revivalExpressionPromiseGuide, null);
} else {
const row = promise(game);
assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'event', 'fulfilled', 'mixed', 'player', 'progress', 'released', 'turn']);
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(row.progress, progress0);
assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, game.turn);
assert.equal(typeof row.answer, 'boolean'); assert.equal(typeof row.fulfilled, 'boolean'); assert.equal(typeof row.released, 'boolean');
assert.ok(view.revivalExpressionPromiseGuide?.action.revivalExpressionPromiseEvent);
assert.ok(!JSON.stringify(row).includes('"claim"'));
}
}
assert.equal(game.revivalExpressionPromises?.length, 1);
assert.equal(game.revivalExpressionPromises![0].event, promise(game).event);
});
void test('native mixed question and guide preserve exact owner turn event and JSON packets', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedRevivalTargets.includes(f.target));
rejected(declared.game, f.opponent, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question,
mixed: group('and', fact(f.spiceFact), leader('atreides-0')) } });
let game = ask(declared.game, f.asker, f.target, f.mixed);
assert.equal(viewGame(game, f.asker).truthMixedRevivalAnswers, null);
assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['yes', 'no']);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4);
assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game).event);
rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, revivalExpressionPromiseEvent: 'stale-mixed-revival-event' });
rejected(game, f.target, { ...guide.action, amount: 20, elite: 0 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
rejected(game, f.target, wire); game = complete(game, f.target);
rejected(game, f.target, wire); rejected(finish(game), f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed post-Storm Yes crosses real Spice Charity Bidding and original ordered Revival', () => {
const f = fixture({ advanced: true, window: 'waiting' });
assert.equal(f.postStorm.turn, 2); assert.equal(f.postStorm.phase, 1);
assert.equal(f.sourceCounts.postStorm, 83); assert.deepEqual(f.postStorm, f.storm.after);
assert.equal(f.postStormSourceActions.length, f.postStormActionCount);
assert.deepEqual(f.sourceActions.slice(f.postStormActionCount, f.phase3ActionCount), f.bidding.steps);
let game = bind(f, 'yes', f.mixed, f.postStorm).game;
assert.deepEqual(promise(game).progress, progress0); assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
const actions: { actor: string; action: Action }[] = [];
game = advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === f.target, actions);
assert.ok(actions.some(step => step.action.type === 'passBid')); assert.ok(actions.some(step => step.action.type === 'ready'));
assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
assert.deepEqual(promise(game).progress, progress0); assert.equal(player(game, f.target).spice, 4);
game = complete(game, f.target); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native waiting mixed Yes exposes the common guide only at its original owned opportunity', () => {
const f = fixture({ advanced: true, window: 'waiting' }); assert.notEqual(f.game.revivalOrder!.remaining[0], f.target);
let game = bind(f).game; assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
rejected(game, f.target, forceAction); rejected(game, f.target, f.revivalAction);
game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
game = complete(game, f.target); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
});
for (const permitted of [false, true]) void test(`native mixed personally priced early leader ${permitted ? 'Yes pays the real quote' : 'No rejects the real acceptance'}`, () => {
const f = fixture({ advanced: true, window: 'waiting', bothDead: true });
assert.equal(player(f.afterBattle, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
assert.equal(player(f.game, f.opponent).leaders.filter(disc => !disc.dead).length, 4);
assert.equal(f.game.revivalOrder!.remaining[0], f.opponent);
let game = act(f.game, f.opponent, { type: 'requestLeaderRevival', leader: f.opponentLeader });
game = act(game, f.target, { type: 'quoteLeaderRevival', target: f.opponent, amount: 3 });
const before = player(game, f.opponent).spice, payee = player(game, f.target).spice;
game = declare(f, game).game;
game = ask(game, f.asker, f.opponent, group('and', fact({ kind: 'spice', compare: 'gte', value: 3 }), leader(f.opponentLeader)));
game = answer(game, f.opponent, permitted ? 'yes' : 'no');
if (permitted) {
const guide = viewGame(game, f.opponent).revivalExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'acceptLeaderRevival'); assert.equal(guide.action.leaderRevivalPrice, 3);
assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game, f.opponent).event);
rejected(game, f.opponent, { ...guide.action, leaderRevivalPrice: 2 });
game = settle(act(game, f.opponent, guide.action));
assert.equal(player(game, f.opponent).spice, before - 3); assert.equal(player(game, f.target).spice, payee + 3);
assert.equal(player(game, f.opponent).leaderRevived, true); assert.equal(promise(game, f.opponent).fulfilled, true);
assert.deepEqual(promise(game, f.opponent).progress.leaders, [f.opponentLeader]);
} else {
rejected(game, f.opponent, { type: 'acceptLeaderRevival', leaderRevivalPrice: 3 });
assert.equal(player(game, f.opponent).spice, before);
assert.equal(player(game, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
}
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies actually answer mixed Yes and perform exact native ordinary return guides', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked);
const answering = viewGame(game, f.target); answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty); assert.ok(answering.truthMixedRevivalAnswers?.includes('yes'));
game = act(game, f.target, answerAction);
for (let count = 0; count < 4 && !promise(game).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.revivalExpressionPromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.revivalExpressionPromiseEvent === guide.action.revivalExpressionPromiseEvent);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action); game = settle(act(game, f.target, action));
}
assert.equal(promise(game).fulfilled, true, difficulty); assert.equal(player(game, f.target).spice, 3);
assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});