Connect Truthtrance promises to printed Hajr and Ghola plays
This commit is contained in:
1 parent
f548039cd8
commit
c93b693137
17 files changed
+1554
-104
No files matched your search
@@ -0,0 +1,186 @@
|
||||
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 { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { createTruthTravelFixture } from './fixture-truth-travel';
|
||||
import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement';
|
||||
import { createTruthMovementGholaFixture } from './fixture-truth-movement-ghola';
|
||||
import { createTruthRevivalFixture } from './fixture-truth-revival';
|
||||
import { createTruthLeaderRevivalFixture } from './fixture-truth-leader-revival';
|
||||
import { location } from '../game/board';
|
||||
import {
|
||||
actHajrReplay, advanceHajrReplay, createHajrReplayFixture, hajrReplayClean, hajrReplayMove,
|
||||
} from './fixture-hajr-replay';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { actTruthMovement, truthMovementInventory, truthMovementPlayer, type TruthMovementStep } from './fixture-truth-movement';
|
||||
import type { TruthTurnCombatLeaf } from './fixture-truth-turn-combat';
|
||||
|
||||
export type TruthTurnCardPlayLeaf = TruthTurnCombatLeaf | { kind: 'cardPlay'; effect: 'hajr' | 'ghola' };
|
||||
export type TruthTurnCardPlayExpression = MixedActionExpression<TruthTurnCardPlayLeaf>;
|
||||
export type TruthTurnCardPlayStep = TruthMovementStep;
|
||||
export const actTruthTurnCardPlay = actTruthMovement;
|
||||
export const truthTurnCardPlayInventory = truthMovementInventory;
|
||||
export const truthTurnCardPlayPlayer = truthMovementPlayer;
|
||||
export const truthTurnCardPlayClean = (game: Game) => !game.truthtrance && !game.response && !game.decision &&
|
||||
!game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
|
||||
export const truthTurnCardPlayFact = (fact: TruthFact): TruthTurnCardPlayExpression => ({ kind: 'fact', fact });
|
||||
export const truthTurnCardPlayUse = (effect: 'hajr' | 'ghola'): TruthTurnCardPlayExpression =>
|
||||
({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
export const truthTurnCardPlayPayment = (recipient: string, minimum = 2): TruthTurnCardPlayExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthTurnCardPlayShip = (territory = 'polar_sink', minimum = 1): TruthTurnCardPlayExpression =>
|
||||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||||
export const truthTurnCardPlayForce = (minimum = 2, counter: 'total' | 'normal' | 'elite' = 'total'):
|
||||
TruthTurnCardPlayExpression => ({ kind: 'action', action: { kind: 'forces', minimum, counter } });
|
||||
export const truthTurnCardPlayGroup = (kind: 'and' | 'or', ...terms: TruthTurnCardPlayExpression[]): TruthTurnCardPlayExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
/** The supported Advanced original avoids Basic Richese's Semuta preview.
|
||||
* Stop after move1 then its actual Hajr, preserving one native movement for
|
||||
* after the future paid Box recovery. The Box itself supplies no Hajr timing. */
|
||||
function createSupportedHajrReplaySource() {
|
||||
const base = createHajrReplayFixture({ advanced: true, truthForGuild: true });
|
||||
const actions = structuredClone(base.actions), inherited = actions.length;
|
||||
let game = structuredClone(base.game);
|
||||
const firstMove = hajrReplayMove(game, base.source);
|
||||
game = actHajrReplay(game, base.owner, firstMove, actions);
|
||||
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||
game = actHajrReplay(game, base.owner, { type: 'card', card: base.hajr }, actions);
|
||||
game = advanceHajrReplay(game, hajrReplayClean, actions);
|
||||
const truth = base.guildTruth; assert.ok(truth);
|
||||
const originalCards = truthMovementInventory(base.setup);
|
||||
assert.equal(game.advanced, true); assert.ok(!game.semutaPreview);
|
||||
assert.equal(truthMovementPlayer(game, base.owner).moved, 1);
|
||||
assert.equal(truthMovementPlayer(game, base.owner).hajrPlays, 1);
|
||||
assert.equal(truthMovementPlayer(game, base.owner).spice, 2);
|
||||
assert.ok(truthMovementPlayer(game, base.owner).hand.some(card => card.id === base.box));
|
||||
assert.equal(game.discard.filter(card => card.id === base.hajr).length, 1);
|
||||
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||
return { ...base, game, actions, asker: 'guild', target: base.owner, truth, originalCards,
|
||||
firstMove, secondMoveFrom: location(String(firstMove.territory), Number(firstMove.sector)),
|
||||
sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length },
|
||||
sourceDisclosure: `${base.staging.join(' ')} This is the original Advanced preview with Guild's unopened Truthtrance deal. ` +
|
||||
'A real first typed movement is followed immediately by the first actual Hajr, which is now physically discarded. ' +
|
||||
'The source stops at moved1/max2 with the owned Box and its exact two-spice fee. After a future answer, Box recovery must precede ' +
|
||||
'the second real movement that earns new Advanced timing for replay. No Basic Semuta composition or installed timing window is claimed.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Every option selects an existing genuine original source or its exact
|
||||
* earlier action prefix. Native deals, casualties, purchases and already used
|
||||
* Hajr all remain physical chronology; no played state is installed. */
|
||||
export function createTruthTurnCardPlayFixture(options: {
|
||||
source?: 'hajr' | 'gholaForces' | 'gholaRevival' | 'gholaLeader' | 'hajrReplay';
|
||||
advanced?: boolean; early?: boolean; mixed?: TruthTurnCardPlayExpression;
|
||||
} = {}) {
|
||||
const sourceKind = options.source ?? 'hajr', requestedAdvanced = options.advanced ?? false;
|
||||
assert.ok(!requestedAdvanced || sourceKind !== 'gholaForces',
|
||||
'Use the genuine typed Revival source for Advanced Ghola');
|
||||
const basicHajr = sourceKind === 'hajr' && !requestedAdvanced ? createTruthTravelFixture({ window: options.early ? 'charity' : 'active' }) : null;
|
||||
const advancedHajr = sourceKind === 'hajr' && requestedAdvanced ? createTruthMixedMovementFixture({ advanced: true, window: 'active' }) : null;
|
||||
const gholaForces = sourceKind === 'gholaForces' ? createTruthMovementGholaFixture() : null;
|
||||
const gholaRevival = sourceKind === 'gholaRevival' ? createTruthRevivalFixture({ targetGhola: true, advanced: requestedAdvanced }) : null;
|
||||
const gholaLeader = sourceKind === 'gholaLeader' ? createTruthLeaderRevivalFixture({ ghola: true, advanced: requestedAdvanced }) : null;
|
||||
const hajrReplay = sourceKind === 'hajrReplay' ? createSupportedHajrReplaySource() : null;
|
||||
const original = basicHajr ?? advancedHajr ?? gholaForces ?? gholaRevival ?? gholaLeader ?? hajrReplay!;
|
||||
const game = structuredClone(original.game), actions = structuredClone(original.actions), advanced = game.advanced;
|
||||
const asker = original.asker, target = original.target, recipient = asker;
|
||||
const effect: 'hajr' | 'ghola' = sourceKind === 'hajr' || sourceKind === 'hajrReplay' ? 'hajr' : 'ghola';
|
||||
const hajr = basicHajr?.hajr ?? advancedHajr?.hajr ?? hajrReplay?.hajr ?? null;
|
||||
const ghola = gholaForces?.ghola ?? gholaRevival?.ghola ?? gholaLeader?.ghola ?? null;
|
||||
const card = effect === 'hajr' ? hajr : ghola; assert.ok(card);
|
||||
const truths = structuredClone(gholaRevival?.truths ?? gholaLeader?.truths ?? [original.truth]);
|
||||
const mixed = structuredClone(options.mixed ?? (hajrReplay ? truthTurnCardPlayUse(effect)
|
||||
: truthTurnCardPlayGroup('and', truthTurnCardPlayUse(effect), truthTurnCardPlayPayment(recipient))));
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMovementPlayer(game, target).spice };
|
||||
const firstMove: Action = basicHajr?.firstMove ?? advancedHajr?.firstMove ??
|
||||
{ type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 };
|
||||
const cardAction: Action = effect === 'hajr' ? { type: 'card', card } : gholaLeader
|
||||
? { type: 'card', card, leader: gholaLeader.leader }
|
||||
: { type: 'card', card, amount: 1, elite: advanced ? 1 : 0 };
|
||||
const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ');
|
||||
assert.ok(truthTurnCardPlayClean(game)); assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||||
assert.ok(truths.every(truth => truthMovementPlayer(game, asker).hand.some(held => held.id === truth)));
|
||||
assert.deepEqual(truthMovementInventory(game), original.originalCards);
|
||||
return {
|
||||
game, source: structuredClone(game), sourceKind, effect, advanced, early: options.early ?? false,
|
||||
basicHajr, advancedHajr, gholaForces, gholaRevival, gholaLeader, hajrReplay,
|
||||
asker, target, recipient, truth: truths[0], truths, card, hajr, ghola, mixed, question, spiceFact,
|
||||
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
|
||||
originalCards: original.originalCards, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { selected: actions.length, inherited: original.actions.length },
|
||||
cardAction, firstMove,
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
end: { type: 'endMovement' } satisfies Action,
|
||||
ordinaryForces: { type: 'revive', amount: 2, elite: 0 } satisfies Action,
|
||||
leader: gholaLeader?.leader ?? null, box: hajrReplay?.box ?? null,
|
||||
sourceDisclosure: `${sourceDisclosure} This adapter changes only the raw turn-action question. Future accepted printed-role play ` +
|
||||
'must occur after the actual answer; earlier use, recovery, an ordinary return or another role of the same card is not a new Hajr/Ghola play. ' +
|
||||
'All selected source counts derive from recorded arrays. No played hand, purse, army, casualty, clock, card, answer or receipt is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTurnCardPlayFixture = ReturnType<typeof createTruthTurnCardPlayFixture>;
|
||||
|
||||
export function declareTruthTurnCardPlay(f: TruthTurnCardPlayFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthTurnCardPlayStep[] = []) {
|
||||
const before = structuredClone(source), from = actions.length;
|
||||
let game = actTruthTurnCardPlay(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||
game = actTruthTurnCardPlay(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, before, actions, actionCount: actions.length - from };
|
||||
}
|
||||
export function askTruthTurnCardPlay(game: Game, asker: string, target: string, mixed: TruthTurnCardPlayExpression,
|
||||
actions: TruthTurnCardPlayStep[] = []): Game {
|
||||
return actTruthTurnCardPlay(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTurnCardPlay(game: Game, target: string, answer: 'yes' | 'no',
|
||||
actions: TruthTurnCardPlayStep[] = []): Game {
|
||||
return actTruthTurnCardPlay(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTurnCardPlay(f: TruthTurnCardPlayFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnCardPlayStep[] = []) {
|
||||
const from = actions.length, declared = declareTruthTurnCardPlay(f, source, copy, actions);
|
||||
const asked = askTruthTurnCardPlay(declared.game, f.asker, f.target, mixed, actions);
|
||||
const game = answerTruthTurnCardPlay(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length - from };
|
||||
}
|
||||
export function truthTurnCardPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
|
||||
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
|
||||
}
|
||||
export function nextTruthTurnCardPlayNative(game: Game): TruthTurnCardPlayStep | null {
|
||||
if (game.truthtrance || game.pendingNullentropy) return null;
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function nextTruthTurnCardPlay(game: Game): TruthTurnCardPlayStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).turnActionGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextTruthTurnCardPlayNative(game);
|
||||
}
|
||||
export function advanceTruthTurnCardPlay(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTurnCardPlayStep[] = [], nativeOnly = false): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original card-play source ended before its requested native boundary');
|
||||
const next = nativeOnly ? nextTruthTurnCardPlayNative(game) : nextTruthTurnCardPlay(game);
|
||||
assert.ok(next, 'Supply the original Truthtrance, paid private selection or unsealed native battle');
|
||||
game = actTruthTurnCardPlay(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The genuine card-play source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTurnCardPlay(game: Game, actions: TruthTurnCardPlayStep[] = []): Game {
|
||||
return advanceTruthTurnCardPlay(game, truthTurnCardPlayClean, actions);
|
||||
}
|
||||
export function finishTruthTurnCardPlay(game: Game, actions: TruthTurnCardPlayStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthTurnCardPlay(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnCardPlayClean(state), actions);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PrintedCardPlayError,
|
||||
printedCardPlayCount,
|
||||
recordPrintedCardPlay,
|
||||
type PrintedCardPlayCounts,
|
||||
} from '../game/printed-card-play';
|
||||
|
||||
void test('missing legacy counts and other turn/player/effect baselines read as zero', () => {
|
||||
assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0);
|
||||
const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr');
|
||||
assert.equal(printedCardPlayCount(counts, 'atreides', 1, 'hajr'), 1);
|
||||
assert.equal(printedCardPlayCount(counts, 'atreides', 2, 'hajr'), 0);
|
||||
assert.equal(printedCardPlayCount(counts, 'emperor', 1, 'hajr'), 0);
|
||||
assert.equal(printedCardPlayCount(counts, 'atreides', 1, 'ghola'), 0);
|
||||
});
|
||||
|
||||
void test('accepted printed-role plays count once per call and genuine replay increments without physical IDs', () => {
|
||||
const first = recordPrintedCardPlay(undefined, 'atreides', 3, 'hajr');
|
||||
const second = recordPrintedCardPlay(first, 'atreides', 3, 'hajr');
|
||||
const ghola = recordPrintedCardPlay(second, 'atreides', 3, 'ghola');
|
||||
const other = recordPrintedCardPlay(ghola, 'emperor', 3, 'hajr');
|
||||
assert.deepEqual(other, { turn: 3, plays: [
|
||||
{ player: 'atreides', effect: 'hajr', count: 2 }, { player: 'atreides', effect: 'ghola', count: 1 },
|
||||
{ player: 'emperor', effect: 'hajr', count: 1 },
|
||||
] });
|
||||
assert.deepEqual(first, { turn: 3, plays: [{ player: 'atreides', effect: 'hajr', count: 1 }] });
|
||||
assert.notEqual(second, first);
|
||||
assert.notEqual(second.plays, first.plays);
|
||||
assert.notEqual(second.plays[0], first.plays[0]);
|
||||
});
|
||||
|
||||
void test('a newer turn resets the list while a past-turn write rejects without changing the baseline', () => {
|
||||
const old = recordPrintedCardPlay(undefined, 'atreides', 3, 'hajr');
|
||||
const next = recordPrintedCardPlay(old, 'emperor', 4, 'ghola');
|
||||
assert.deepEqual(next, { turn: 4, plays: [{ player: 'emperor', effect: 'ghola', count: 1 }] });
|
||||
assert.equal(printedCardPlayCount(next, 'atreides', 3, 'hajr'), 0);
|
||||
const before = structuredClone(next);
|
||||
assert.throws(() => recordPrintedCardPlay(next, 'atreides', 3, 'hajr'), PrintedCardPlayError);
|
||||
assert.deepEqual(next, before);
|
||||
});
|
||||
|
||||
void test('safe integer counts reject overflow immutably', () => {
|
||||
const full: PrintedCardPlayCounts = { turn: 1, plays: [{ player: 'atreides', effect: 'hajr', count: Number.MAX_SAFE_INTEGER }] };
|
||||
const before = structuredClone(full);
|
||||
assert.equal(printedCardPlayCount(full, 'atreides', 1, 'hajr'), Number.MAX_SAFE_INTEGER);
|
||||
assert.throws(() => recordPrintedCardPlay(full, 'atreides', 1, 'hajr'), PrintedCardPlayError);
|
||||
assert.deepEqual(full, before);
|
||||
assert.equal(recordPrintedCardPlay(full, 'atreides', 1, 'ghola').plays[1].count, 1);
|
||||
});
|
||||
|
||||
void test('strict stored counts reject malformed state even when another turn would reset or miss it', () => {
|
||||
const play = { player: 'atreides', effect: 'hajr', count: 1 };
|
||||
const invalid: unknown[] = [null, [], {}, { turn: 0, plays: [] }, { turn: 1, plays: null },
|
||||
{ turn: 1, plays: [], source: 'card-id' }, { turn: 1, plays: [play, play] },
|
||||
...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(count => ({ turn: 1, plays: [{ ...play, count }] })),
|
||||
{ turn: 1, plays: [{ ...play, player: ' atreides' }] }, { turn: 1, plays: [{ ...play, effect: 'karama' }] },
|
||||
{ turn: 1, plays: [{ ...play, card: 'hajr' }] }, { turn: 1, plays: [{ ...play, printed: true }] }];
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => printedCardPlayCount(value as PrintedCardPlayCounts, 'atreides', 2, 'hajr'), PrintedCardPlayError);
|
||||
assert.throws(() => recordPrintedCardPlay(value as PrintedCardPlayCounts, 'atreides', 2, 'hajr'), PrintedCardPlayError);
|
||||
}
|
||||
assert.doesNotThrow(() => printedCardPlayCount({ turn: 1, plays: [] }, 'atreides', 1, 'hajr'));
|
||||
});
|
||||
|
||||
void test('request metadata remains exact, safe and uncoerced for both lookup and recording', () => {
|
||||
for (const [player, turn, effect] of [['', 1, 'hajr'], ['atreides ', 1, 'ghola'], ['atreides', 0, 'hajr'],
|
||||
['atreides', 1.5, 'hajr'], ['atreides', Number.MAX_SAFE_INTEGER + 1, 'hajr'], ['atreides', '1', 'hajr'],
|
||||
['atreides', 1, 'Ghola'], ['atreides', 1, 'karama']]) {
|
||||
assert.throws(() => printedCardPlayCount(undefined, player as string, turn as number, effect as never), PrintedCardPlayError);
|
||||
assert.throws(() => recordPrintedCardPlay(undefined, player as string, turn as number, effect as never), PrintedCardPlayError);
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { TurnActionFields, turnActionInputError } from '../components/turn-actio
|
||||
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
|
||||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||||
import { BattleClaimFields } from '../components/battle-promises';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
|
||||
@@ -28,6 +29,7 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
|
||||
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
|
||||
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
|
||||
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
|
||||
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||||
const traitorClaim = (call = true): Expression =>
|
||||
@@ -73,6 +75,17 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
|
||||
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
|
||||
game.truthTurnActionAnswers = null;
|
||||
if (next.type === 'card' && typeof next.card === 'string') {
|
||||
const effect = next.card.includes('hajr') ? 'hajr' : next.card.includes('ghola') ? 'ghola' : 'karama';
|
||||
const face = baseDeck().find(card => card.effect === effect); assert.ok(face);
|
||||
const me = game.players.find(player => player.id === 'p')!;
|
||||
me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr';
|
||||
if (effect === 'ghola') {
|
||||
me.tanks = 5; me.elites = { reserves: 4, tanks: 1, forces: {}, revived: 0 };
|
||||
game.ghola = { cards: [next.card], leaders: [{ id: 'controlled-dead-leader', name: 'Returned Commander', strength: 3 }],
|
||||
kwisatz: false, maxForces: 5, eliteRemaining: 1, eliteBlock: null, available: true, reason: null };
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function auction(next: Action = { type: 'passBid' }): GameView {
|
||||
@@ -479,6 +492,117 @@ void test('turn-action combat callbacks retain current battle ownership and raw
|
||||
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
|
||||
});
|
||||
|
||||
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true });
|
||||
Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
|
||||
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
|
||||
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
|
||||
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
|
||||
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola']);
|
||||
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
|
||||
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p'));
|
||||
const html = e.html(); assert.match(html, /Play Hajr in its printed role after this answer this turn/);
|
||||
assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/);
|
||||
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
|
||||
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
|
||||
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('ghola')] }, false, game);
|
||||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay'); }
|
||||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||||
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), printedCard('ghola')] }, false, game);
|
||||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
const frozen = editor(printedCard(), true, game); select(frozen.tree(), '1', 'effect', 'ghola');
|
||||
select(frozen.tree(), '1', 'action', 'payment'); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, printedCard());
|
||||
});
|
||||
|
||||
void test('printed Ghola guides forward exact native force, leader and KH choices out of turn and reject unavailable or forged choices', () => {
|
||||
for (let phase = 0; phase <= 8; phase++) {
|
||||
const game = projected(phase, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }); game.active = 'a'; game.advanced = true;
|
||||
game.turnActionPromises[0].mixed = printedCard('ghola');
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Ghola stage inspection'); } });
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
assert.match(markup(game), /Play Tleilaxu Ghola to return 3 physical forces/);
|
||||
}
|
||||
for (const leader of ['controlled-dead-leader', 'kwisatz']) {
|
||||
const game = projected(7, { type: 'card', card: 'controlled-ghola', leader }); game.active = 'a';
|
||||
if (leader === 'kwisatz') { game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides'; game.ghola.kwisatz = true; }
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
assert.match(markup(game), leader === 'kwisatz' ? /return Kwisatz Haderach/ : /return Returned Commander/);
|
||||
if (leader === 'kwisatz') game.ghola.kwisatz = false; else game.ghola.leaders = [];
|
||||
press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.ghola = { ...game.ghola }; }, (game: GameView) => { game.ghola.available = false; },
|
||||
(game: GameView) => { game.ghola.cards = []; }, (game: GameView) => { game.ghola.maxForces = 0; },
|
||||
(game: GameView) => { game.ghola.eliteRemaining = 0; }, (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||
(game: GameView) => { game.phase = 7; }, (game: GameView) => { game.truthtrance = answering(2).truthtrance; },
|
||||
]) {
|
||||
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false);
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
for (const malformed of [
|
||||
{ card: 'missing-ghola' }, { amount: 0 }, { amount: 6 }, { amount: 1.5 }, { amount: 3, elite: 2 }, { amount: 3, elite: -1 },
|
||||
{ amount: 5, elite: 0 }, { leader: 'foreign-living-leader' }, { mode: 'shipment' },
|
||||
]) {
|
||||
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 });
|
||||
Object.assign(game.turnActionGuide!.action, malformed);
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('printed Hajr guides require the actual held card and current own immediate Movement opportunity', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = projected(5, { type: 'card', card: 'controlled-hajr' }); game.advanced = advanced;
|
||||
game.turnActionPromises[0].mixed = printedCard();
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Hajr stage inspection'); } });
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
assert.match(markup(game), /Play Hajr in its printed role while honoring your whole answer/);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrAvailable = false; },
|
||||
(game: GameView) => { const player = game.players.find(player => player.id === 'p')!; player.moved = (player.moved ?? 0) + 1; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrPlays = 1; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||
(game: GameView) => { game.active = 'a'; }, (game: GameView) => { game.phase = 4; },
|
||||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
]) {
|
||||
const game = projected(5, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false);
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
for (const phase of [0, 2, 4, 6, 8]) {
|
||||
const game = projected(phase, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const missing = projected(5, { type: 'card', card: 'controlled-hajr' }); missing.turnActionGuide!.action.card = 'missing-hajr';
|
||||
assert.equal(guide({ game: missing, busy: false, act() { assert.fail('A missing Hajr cannot dispatch'); } }).props.disabled, true);
|
||||
});
|
||||
|
||||
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
|
||||
const game = projected(8); game.turnActionGuide = null;
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola')] }] };
|
||||
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const html = markup(game);
|
||||
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered Yes/);
|
||||
assert.match(html, /Hajr counts even if its extra movement is unused/);
|
||||
assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/);
|
||||
assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/);
|
||||
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
|
||||
});
|
||||
|
||||
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
|
||||
const game = projected(); game.turnActionGuide = null;
|
||||
game.turnActionPromises.push(
|
||||
@@ -534,7 +658,7 @@ void test('turn-action ordinary-payment guide retains native out-of-turn timing
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
|
||||
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
|
||||
if (phase === 8) assert.match(markup(game), /Late recipients collect next turn/);
|
||||
if (phase === 8) assert.match(markup(game), /Late payment recipients collect next turn/);
|
||||
}
|
||||
const game = projected(8, { type: 'ready' }); game.active = null;
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
@@ -570,7 +694,7 @@ void test('turn-action current early native opportunities retain exact preparati
|
||||
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
|
||||
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
|
||||
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
|
||||
revival({ type: 'card', card: 'controlled-ghola' }), richese('silent'), richese('onceAround'),
|
||||
revival({ type: 'card', card: 'controlled-ghola', amount: 1, elite: 0 }), richese('silent'), richese('onceAround'),
|
||||
];
|
||||
for (const game of cases) {
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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 {
|
||||
actTruthTurnCardPlay as act, advanceTruthTurnCardPlay as advance, answerTruthTurnCardPlay as answer,
|
||||
askTruthTurnCardPlay as ask, bindTruthTurnCardPlay as bind, createTruthTurnCardPlayFixture as fixture,
|
||||
declareTruthTurnCardPlay as declare, finishTruthTurnCardPlay as finish, nextTruthTurnCardPlayNative as nativeStep,
|
||||
settleTruthTurnCardPlay as settle, truthTurnCardPlayClean as clean, truthTurnCardPlayFact as fact,
|
||||
truthTurnCardPlayForce as force, truthTurnCardPlayGroup as group, truthTurnCardPlayInventory as inventory,
|
||||
truthTurnCardPlayPayment as payment, truthTurnCardPlayPlayer as player, truthTurnCardPlayPromise as promise,
|
||||
truthTurnCardPlayShip as ship, truthTurnCardPlayUse as use,
|
||||
type TruthTurnCardPlayFixture, type TruthTurnCardPlayStep,
|
||||
} from './fixture-truth-turn-card-play';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function physical(game: Game, f: TruthTurnCardPlayFixture) {
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
const quotas = (game: Game, target: string) => {
|
||||
const owner = player(game, target);
|
||||
return [owner.revived, owner.freeForcesRevived ?? 0, owner.leaderRevived];
|
||||
};
|
||||
|
||||
void test('native printed-card adapters retain original deals, actual casualties and counted action prefixes', () => {
|
||||
for (const options of [
|
||||
{ source: 'hajr', early: true }, { source: 'hajr' }, { source: 'hajr', advanced: true },
|
||||
{ source: 'gholaForces' }, { source: 'gholaRevival' }, { source: 'gholaLeader' }, { source: 'hajrReplay' },
|
||||
] as const) {
|
||||
const f = fixture(options); assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(f.sourceCounts.selected, f.sourceActionCount); assert.equal(f.sourceCounts.inherited, f.sourceActionCount);
|
||||
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(inventory(f.game), f.originalCards);
|
||||
if (f.sourceKind === 'hajr') {
|
||||
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
|
||||
assert.equal(f.sourceActionCount, f.early ? 13 : f.advanced ? 23 : 19);
|
||||
} else if (f.sourceKind === 'hajrReplay') {
|
||||
assert.equal(f.advanced, true); assert.equal(f.game.advanced, true); assert.ok(!f.game.semutaPreview);
|
||||
assert.equal(player(f.game, f.target).hajrPlays, 1); assert.equal(player(f.game, f.target).moved, 1);
|
||||
assert.equal(player(f.game, f.target).spice, 2); assert.equal(f.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(f.hajrReplay!.sourceCounts.inherited + f.hajrReplay!.sourceCounts.nativeContinuation, f.sourceActionCount);
|
||||
} else {
|
||||
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
|
||||
assert.equal(f.game.turn, 2); assert.ok(player(f.game, f.target).tanks > 0);
|
||||
if (f.sourceKind === 'gholaLeader') assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Basic Hajr AND payment records the actual printed play without requiring use of its extra movement', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.spiceFact), use('hajr'), payment(f.recipient));
|
||||
const initial = structuredClone(f.game); let game = bind(f, 'yes', mixed).game;
|
||||
assert.deepEqual(f.game, initial); rejected(game, f.target, f.end);
|
||||
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 8);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||
game = settle(act(game, f.target, f.cardAction));
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, true);
|
||||
assert.equal(player(game, f.target).hajrPlays, 1); assert.equal(player(game, f.target).moved, 0);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
const receipt = structuredClone(game.turnActionPromises![0].cardPlays);
|
||||
game = settle(act(game, f.target, f.end));
|
||||
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(game.turnActionPromises![0].cardPlays, receipt);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native Advanced Hajr guide first earns its real completed-move timing and leaves the extra movement optional', () => {
|
||||
const f = fixture({ advanced: true }); let game = bind(f).game;
|
||||
game = act(game, f.target, f.payment); rejected(game, f.target, f.cardAction);
|
||||
assert.equal(player(game, f.target).moved, 0); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||
const preparation = viewGame(game, f.target).turnActionGuide; assert.ok(preparation);
|
||||
assert.equal(preparation.action.type, 'move');
|
||||
game = settle(act(game, f.target, f.firstMove)); assert.equal(player(game, f.target).moved, 1);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
|
||||
game = settle(act(game, f.target, guide.action)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = settle(act(game, f.target, f.end)); assert.equal(player(game, f.target).moved, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native whole Hajr AND payment No preserves either component and rejects only actual whole completion atomically', () => {
|
||||
const f = fixture();
|
||||
for (const first of ['payment', 'card'] as const) {
|
||||
let game = bind(f, 'no').game;
|
||||
if (first === 'payment') {
|
||||
game = act(game, f.target, f.payment); rejected(game, f.target, f.cardAction);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||
} else {
|
||||
game = settle(act(game, f.target, f.cardAction)); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { ...f.payment, amount: 1 });
|
||||
rejected(game, f.target, { ...f.payment, amount: 1 });
|
||||
}
|
||||
game = settle(act(game, f.target, f.end)); assert.ok(!promise(game, f.target).released);
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Hajr OR payment retains direct play and own-Movement-close then late-payment routes', () => {
|
||||
const f = fixture(), mixed = group('or', use('hajr'), payment(f.recipient)), bound = bind(f, 'yes', mixed).game;
|
||||
const played = settle(act(bound, f.target, f.cardAction));
|
||||
assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
|
||||
let paid = settle(act(bound, f.target, f.end));
|
||||
assert.equal(paid.turnActionPromises![0].cardPlays?.hajr?.closed, true);
|
||||
assert.equal(paid.turnActionPromises![0].cardPlays?.hajr?.played, false); assert.ok(!promise(paid, f.target).fulfilled);
|
||||
paid = advance(paid, state => state.phase === 8 && clean(state), [], true);
|
||||
rejected(paid, f.target, { type: 'ready' }); paid = act(paid, f.target, f.payment);
|
||||
assert.equal(promise(paid, f.target).fulfilled, true);
|
||||
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card));
|
||||
for (const state of [played, paid]) physical(state, f);
|
||||
});
|
||||
|
||||
void test('native held Ghola force play AND payment returns the actual Storm casualty before a separately paid shipment', () => {
|
||||
const f = fixture({ source: 'gholaForces' }), mixed = group('and', use('ghola'), payment(f.recipient), ship());
|
||||
let game = bind(f, 'yes', mixed).game; const ordinary = quotas(game, f.target);
|
||||
assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).reserves, 9);
|
||||
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.cardAction));
|
||||
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.equal(player(game, f.target).reserves, 10); assert.deepEqual(quotas(game, f.target), ordinary);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true); assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
|
||||
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native Ghola OR payment preserves either actual late Mentat play or payment after own Movement has ended', () => {
|
||||
const f = fixture({ source: 'gholaForces' }), mixed = group('or', use('ghola'), payment(f.recipient));
|
||||
let late = bind(f, 'yes', mixed).game; late = settle(act(late, f.target, f.end));
|
||||
assert.equal(late.turnActionPromises![0].cardPlays?.ghola?.closed, false);
|
||||
late = advance(late, state => state.phase === 8 && clean(state), [], true);
|
||||
rejected(late, f.target, { type: 'ready' });
|
||||
for (const action of [f.cardAction, f.payment]) {
|
||||
const game = settle(act(late, f.target, action)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, action.type === 'card');
|
||||
assert.equal(player(game, f.target).tanks, action.type === 'card' ? 0 : 1); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled known Double payment expiry retains the actual late Ghola alternative', () => {
|
||||
const f = fixture({ source: 'gholaForces' });
|
||||
// Preserve the actual played source, held card and casualty. Only the public
|
||||
// token is controlled; this does not assert an original CHOAM source journey.
|
||||
const controlled = structuredClone(f.game);
|
||||
controlled.inflation = { side: 'cancel', placedTurn: 0, updatedTurn: 0, flipped: false };
|
||||
for (const mode of ['or', 'and'] as const) {
|
||||
const declared = declare(f, controlled).game;
|
||||
const asked = ask(declared, f.asker, f.target, group(mode, use('ghola'), payment(f.recipient)));
|
||||
let game = answer(asked, f.target, 'yes');
|
||||
game = settle(act(game, f.target, f.end));
|
||||
game = advance(game, state => state.phase === 7 && clean(state), [], true);
|
||||
assert.equal(game.inflation?.side, 'cancel');
|
||||
if (mode === 'and') {
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, f.payment);
|
||||
}
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.closed, false);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
assert.equal(game.inflation?.side, 'double');
|
||||
rejected(game, f.target, f.payment);
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
game = settle(act(game, f.target, f.cardAction));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native whole Ghola OR payment No forbids printed use and cumulative payment through actual turn end', () => {
|
||||
const f = fixture({ source: 'gholaForces' }), mixed = group('or', use('ghola'), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.cardAction); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { ...f.payment, amount: 1 }); game = settle(act(game, f.target, f.end));
|
||||
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
rejected(game, f.target, f.cardAction); rejected(game, f.target, { ...f.payment, amount: 1 });
|
||||
assert.ok(!promise(game, f.target).fulfilled); game = finish(game);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, false);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native held Ghola and ordinary returns share the original Tanks and purse without double reservation or event substitution', () => {
|
||||
const f = fixture({ source: 'gholaRevival' }); assert.equal(player(f.game, f.target).tanks, 3);
|
||||
const overbooked = group('and', use('ghola'), force(3));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, overbooked);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const mixed = group('and', use('ghola'), force(2), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, f.ordinaryForces));
|
||||
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).tanks, 1);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, false);
|
||||
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.ok(!promise(game, f.target).fulfilled); const ordinary = quotas(game, f.target);
|
||||
game = settle(act(game, f.target, f.cardAction));
|
||||
assert.equal(player(game, f.target).reserves, 20); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.deepEqual(quotas(game, f.target), ordinary);
|
||||
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
// A separate original branch actually uses all three ordinary returns before
|
||||
// asking: an ordinary return never becomes an earlier printed Ghola use.
|
||||
const returned = settle(act(f.game, f.target, { type: 'revive', amount: 3, elite: 0 }));
|
||||
assert.equal(player(returned, f.target).tanks, 0); assert.equal(player(returned, f.target).spice, 0);
|
||||
const unavailable = ask(declare(f, returned).game, f.asker, f.target, use('ghola'));
|
||||
assert.deepEqual(viewGame(unavailable, f.target).truthTurnActionAnswers, ['no']);
|
||||
assert.ok(player(unavailable, f.target).hand.some(card => card.id === f.card));
|
||||
});
|
||||
|
||||
void test('native personally dead Hidar returns by the actual held Ghola alongside payment without spending ordinary leader revival', () => {
|
||||
const f = fixture({ source: 'gholaLeader' }); assert.ok(f.leader);
|
||||
let game = bind(f).game; const ordinary = quotas(game, f.target), dead = structuredClone(player(game, f.target).leaders.find(disc => disc.id === f.leader)!);
|
||||
assert.equal(dead.dead, true); assert.equal(dead.deaths, 1); game = act(game, f.target, f.payment);
|
||||
game = settle(act(game, f.target, f.cardAction));
|
||||
const returned = player(game, f.target).leaders.find(disc => disc.id === f.leader)!;
|
||||
assert.equal(returned.dead, false); assert.equal(returned.deaths, 1); assert.equal(returned.usedAt, undefined);
|
||||
// The original four spice pay two, then native Ghola's allowed Tleilaxu
|
||||
// income response pays the printed one spice; it is not ordinary Revival.
|
||||
assert.equal(player(game, f.target).spice, 3); assert.deepEqual(quotas(game, f.target), ordinary);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
physical(game, f);
|
||||
});
|
||||
|
||||
void test('native Advanced printed Ghola returns an original Storm-killed Sardaukar while retaining typed ordinary quotas', () => {
|
||||
const f = fixture({ source: 'gholaRevival', advanced: true });
|
||||
let game = bind(f).game; const ordinary = quotas(game, f.target), reserveElite = player(game, f.target).elites!.reserves;
|
||||
assert.equal(player(game, f.target).elites!.tanks, 1);
|
||||
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.cardAction));
|
||||
assert.equal(player(game, f.target).elites!.tanks, 0); assert.equal(player(game, f.target).elites!.reserves, reserveElite + 1);
|
||||
assert.equal(player(game, f.target).tanks, 2); assert.deepEqual(quotas(game, f.target), ordinary);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native earlier Hajr use does not satisfy a new interval but an owned paid Box can recover and replay the same physical card', () => {
|
||||
const f = fixture({ source: 'hajrReplay' }); assert.ok(f.box);
|
||||
let game = bind(f).game; assert.equal(player(game, f.target).hajrPlays, 1);
|
||||
assert.equal(player(game, f.target).moved, 1); assert.equal(game.advanced, true); assert.ok(!game.semutaPreview);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); assert.ok(!promise(game, f.target).fulfilled);
|
||||
const actions: TruthTurnCardPlayStep[] = [];
|
||||
for (let count = 0; count < 6 && !promise(game, f.target).fulfilled; count++) {
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
if (count === 0) {
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box);
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'old-box-guide' });
|
||||
rejected(game, f.target, { ...guide.action, selected: f.card });
|
||||
}
|
||||
const originalPacket = structuredClone(guide.action);
|
||||
game = settle(act(game, f.target, guide.action, actions), actions);
|
||||
assert.deepEqual(guide.action, originalPacket);
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(player(game, f.target).moved, 2); assert.equal(player(game, f.target).hajrPlays, 2);
|
||||
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.box));
|
||||
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === f.card));
|
||||
const recoveredAt = actions.findIndex(step => step.action.type === 'decision' && step.action.card === f.card);
|
||||
const moveAt = actions.findIndex(step => step.action.type === 'move');
|
||||
const replayAt = actions.findIndex(step => step.action.type === 'card' && step.action.card === f.card);
|
||||
assert.ok(recoveredAt >= 0 && moveAt > recoveredAt && replayAt > moveAt,
|
||||
'The actual second move must earn timing after paid recovery and before replay');
|
||||
assert.equal(actions.filter(step => step.action.type === 'move').length, 1);
|
||||
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.card));
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native earlier printed Hajr without a held recovery route leaves only No for another future play', () => {
|
||||
const f = fixture(); const played = settle(act(f.game, f.target, f.cardAction));
|
||||
assert.equal(player(played, f.target).hajrPlays, 1);
|
||||
const asked = ask(declare(f, played).game, f.asker, f.target, use('hajr'));
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
let game = answer(asked, f.target, 'no'); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
|
||||
game = settle(act(game, f.target, f.end)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); physical(game, f);
|
||||
});
|
||||
|
||||
void test('native printed-card guides retain exact event phase card and Ghola typed packets with only raw own history projected', () => {
|
||||
for (const source of ['hajr', 'gholaForces'] as const) {
|
||||
const f = fixture({ source }); let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
|
||||
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
|
||||
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
|
||||
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
|
||||
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, game.phase);
|
||||
if (source === 'gholaForces') { assert.equal(guide.action.amount, 1); assert.equal(guide.action.elite, 0); }
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-card-play-event' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 });
|
||||
rejected(game, f.target, { ...guide.action, card: f.truth });
|
||||
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); rejected(game, f.target, wire); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles actually complete early Basic Hajr and native Ghola force or leader guides', () => {
|
||||
for (const options of [{ source: 'hajr', early: true }, { source: 'gholaForces' }, { source: 'gholaLeader' }] as const) {
|
||||
const f = fixture(options), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTurnCardPlayStep[] = [];
|
||||
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
|
||||
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
||||
for (let count = 0; count < 100 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = view.turnActionGuide;
|
||||
if (guide) {
|
||||
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
|
||||
assert.ok(action, `${f.sourceKind}/${difficulty}`); assert.deepEqual(action, guide.action);
|
||||
game = settle(act(game, f.target, action, actions), actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next, `${f.sourceKind}/${difficulty}`);
|
||||
game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, `${f.sourceKind}/${difficulty}`);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
|
||||
assert.equal(game.turnActionPromises![0].cardPlays?.[f.effect]?.played, true); physical(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
TurnActionError,
|
||||
closeTurnActionBidding,
|
||||
closeTurnActionCombat,
|
||||
closeTurnActionPayments,
|
||||
closeTurnActionRevival,
|
||||
closeTurnActionTransport,
|
||||
finishTurnActionTurn,
|
||||
liveTurnActionPromises,
|
||||
parseTurnActionLeaf,
|
||||
recordTurnActionBid,
|
||||
recordTurnActionCardPlay,
|
||||
recordTurnActionPlan,
|
||||
recordTurnActionTraitor,
|
||||
recordTurnActionMovement,
|
||||
@@ -48,6 +50,7 @@ const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gt
|
||||
({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } });
|
||||
const call = (call = true, battle = 'battle:1'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
|
||||
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
||||
@@ -80,6 +83,11 @@ function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct
|
||||
leader: 'other-0', closed: false, call: null } } };
|
||||
}
|
||||
const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value });
|
||||
function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
|
||||
return { ...promise(mixed, answer, id), cardPlays: {
|
||||
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
|
||||
} };
|
||||
}
|
||||
|
||||
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
|
||||
for (const kind of ['ship', 'move'] as const) {
|
||||
@@ -872,3 +880,191 @@ void test('native off-board combat locations retain exact event and location bin
|
||||
for (const kind of ['ship', 'move'])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind, territory, minimum: 1 }), TurnActionError);
|
||||
});
|
||||
|
||||
void test('printed card-play leaves contain only the printed effect and every raw clause retains its own stage', () => {
|
||||
for (const effect of ['hajr', 'ghola'] as const) {
|
||||
const original = { kind: 'cardPlay', effect };
|
||||
assert.deepEqual(parseTurnActionLeaf(original), original);
|
||||
assert.notEqual(parseTurnActionLeaf(original), original);
|
||||
for (const invalid of [{ ...original, card: 'physical-card' }, { ...original, played: true },
|
||||
{ ...original, minimum: 1 }, { ...original, printed: true }, { ...original, answer: true }])
|
||||
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(card(effect))), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(group('or', fact(), card(effect)))), TurnActionError,
|
||||
'Compiler folding never removes the original raw interval requirement');
|
||||
}
|
||||
for (const effect of ['', 'Hajr', 'karama', 'thumper', null])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
|
||||
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
|
||||
});
|
||||
|
||||
void test('whole printed-card AND and OR retain dynamic alternatives and settle only actual future printed-role plays', () => {
|
||||
const hajr = { kind: 'cardPlay', effect: 'hajr', answer: true };
|
||||
const ghola = { kind: 'cardPlay', effect: 'ghola', answer: true };
|
||||
const and = cards(group('and', card(), card('ghola'), pay()));
|
||||
const or = cards(group('or', card(), card('ghola')), true, 'turn-action:2');
|
||||
assert.deepEqual(branches(and), [[hajr, ghola, signedPay()]]);
|
||||
assert.deepEqual(branches(or), [[hajr], [ghola]]);
|
||||
const played = recordTurnActionCardPlay([and, or], 'respondent', 1, 'hajr');
|
||||
assert.equal(played[0].fulfilled, false);
|
||||
assert.equal(played[1].fulfilled, true);
|
||||
assert.deepEqual(branches(played[0]), [[ghola, signedPay()]]);
|
||||
const gholaPlayed = recordTurnActionCardPlay(played, 'respondent', 1, 'ghola');
|
||||
assert.equal(gholaPlayed[0].fulfilled, false);
|
||||
assert.deepEqual(branches(gholaPlayed[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(gholaPlayed, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(gholaPlayed[1], played[1]);
|
||||
assert.equal(and.cardPlays!.hajr!.played, false);
|
||||
assert.equal(recordTurnActionCardPlay([or], 'respondent', 1, 'ghola')[0].fulfilled, true);
|
||||
});
|
||||
|
||||
void test('whole card-play No applies De Morgan and rejects the actual accepted play completing its aggregate', () => {
|
||||
const andNo = cards(group('and', card(), card('ghola')), false);
|
||||
assert.deepEqual(branches(andNo), [[{ kind: 'cardPlay', effect: 'hajr', answer: false }],
|
||||
[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||
const one = recordTurnActionCardPlay([andNo], 'respondent', 1, 'hajr');
|
||||
assert.deepEqual(branches(one[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||
const before = structuredClone(one);
|
||||
assert.throws(() => recordTurnActionCardPlay(one, 'respondent', 1, 'ghola'), TurnActionError);
|
||||
assert.deepEqual(one, before);
|
||||
const orNo = cards(group('or', card(), card('ghola')), false);
|
||||
assert.deepEqual(branches(orNo), [[{ kind: 'cardPlay', effect: 'hajr', answer: false },
|
||||
{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||
for (const effect of ['hajr', 'ghola'] as const)
|
||||
assert.throws(() => recordTurnActionCardPlay([orNo], 'respondent', 1, effect), TurnActionError);
|
||||
assert.deepEqual(branches(cards(group('and', card(), card()))), [[{ kind: 'cardPlay', effect: 'hajr', answer: true }]]);
|
||||
});
|
||||
|
||||
void test('earlier card receipts do not satisfy a later answer and genuine replay updates only matching live intervals', () => {
|
||||
const mixed = group('and', card(), pay());
|
||||
const old = cards(mixed);
|
||||
const played = recordTurnActionCardPlay([old], 'respondent', 1, 'hajr')[0];
|
||||
const fresh = cards(mixed, true, 'turn-action:2');
|
||||
const other = { ...cards(mixed, true, 'turn-action:3'), player: 'other' };
|
||||
const laterTurn = { ...cards(mixed, true, 'turn-action:4'), turn: 2 };
|
||||
assert.equal(turnActionValue(fresh), false);
|
||||
assert.equal(fresh.cardPlays!.hajr!.played, false);
|
||||
const replay = recordTurnActionCardPlay([played, fresh, other, laterTurn], 'respondent', 1, 'hajr');
|
||||
assert.equal(replay[0], played, 'One boolean receipt never counts or duplicates a later replay');
|
||||
assert.equal(replay[1].cardPlays!.hajr!.played, true);
|
||||
assert.equal(replay[2], other);
|
||||
assert.equal(replay[3], laterTurn);
|
||||
assert.equal(old.cardPlays!.hajr!.played, false);
|
||||
assert.equal(fresh.cardPlays!.hajr!.played, false);
|
||||
});
|
||||
|
||||
void test('actual transport closure closes Hajr while Ghola and ordinary payment alternatives retain their own deadline', () => {
|
||||
const original = cards(group('or', card(), card('ghola'), pay()));
|
||||
const closed = closeTurnActionTransport([original], 'respondent', 1);
|
||||
assert.deepEqual(closed[0].cardPlays, { hajr: { played: false, closed: true }, ghola: { played: false, closed: false } });
|
||||
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }], [signedPay()]]);
|
||||
assert.equal(recordTurnActionCardPlay(closed, 'respondent', 1, 'hajr')[0], closed[0]);
|
||||
assert.equal(recordTurnActionCardPlay(closed, 'respondent', 1, 'ghola')[0].fulfilled, true);
|
||||
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const no = cards(group('and', card(), card('ghola')), false);
|
||||
const noClosed = closeTurnActionTransport([no], 'respondent', 1);
|
||||
assert.equal(noClosed[0].fulfilled, false, 'Whole No waits for its remaining relevant native family');
|
||||
const gholaPlayed = recordTurnActionCardPlay(noClosed, 'respondent', 1, 'ghola');
|
||||
assert.equal(gholaPlayed[0].fulfilled, false);
|
||||
assert.equal(finishTurnActionTurn(gholaPlayed, 'respondent', 1)[0].fulfilled, true);
|
||||
assert.equal(closeTurnActionTransport([cards(card(), false)], 'respondent', 1)[0].fulfilled, true);
|
||||
validateTurnActionPromises(closed, seats);
|
||||
});
|
||||
|
||||
void test('full-turn closure settles absence, releases unmet Yes and leaves terminal card receipts immutable', () => {
|
||||
const no = cards(card('ghola'), false);
|
||||
const yes = cards(card('ghola'), true, 'turn-action:2');
|
||||
const fulfilled = recordTurnActionCardPlay([cards(card(), true, 'turn-action:3')], 'respondent', 1, 'hajr')[0];
|
||||
const finished = finishTurnActionTurn([no, yes, fulfilled], 'respondent', 1);
|
||||
assert.equal(finished[0].fulfilled, true);
|
||||
assert.equal(finished[1].released, true);
|
||||
assert.equal(finished[2], fulfilled);
|
||||
assert.deepEqual(finished[0].cardPlays, { hajr: { played: false, closed: true }, ghola: { played: false, closed: true } });
|
||||
for (const transform of [
|
||||
(rows: TurnActionPromise[]) => recordTurnActionCardPlay(rows, 'respondent', 1, 'hajr'),
|
||||
(rows: TurnActionPromise[]) => recordTurnActionCardPlay(rows, 'respondent', 1, 'ghola'),
|
||||
(rows: TurnActionPromise[]) => closeTurnActionTransport(rows, 'respondent', 1),
|
||||
(rows: TurnActionPromise[]) => finishTurnActionTurn(rows, 'respondent', 1),
|
||||
]) assert.ok(transform(finished).every((row, index) => row === finished[index]));
|
||||
validateTurnActionPromises(finished, seats);
|
||||
});
|
||||
|
||||
void test('strict printed-card stages preserve old rows and reject metadata, missing effects and foreign compiled clauses', () => {
|
||||
const original = cards(group('and', card(), card('ghola')));
|
||||
for (const cardPlays of [undefined, null, {}, { hajr: null, ghola: null },
|
||||
{ hajr: { played: false, closed: false } },
|
||||
{ ...original.cardPlays, card: 'physical-card' },
|
||||
{ ...original.cardPlays, hajr: { played: 1, closed: false } },
|
||||
{ ...original.cardPlays, ghola: { played: false, closed: 'yes' } },
|
||||
{ ...original.cardPlays, hajr: { played: false, closed: false, count: 1 } }])
|
||||
assert.throws(() => turnActionValue({ ...original, cardPlays } as TurnActionPromise), TurnActionError);
|
||||
const old = promise(pay());
|
||||
assert.equal('cardPlays' in recordTurnActionCardPlay([old], 'respondent', 1, 'hajr')[0], false);
|
||||
assert.equal('cardPlays' in closeTurnActionTransport([old], 'respondent', 1)[0], false);
|
||||
assert.equal('cardPlays' in finishTurnActionTurn([old], 'respondent', 1)[0], false);
|
||||
const optional = { ...prepared(group('and', bid(), forces())), cardPlays: { hajr: null, ghola: null } };
|
||||
validateTurnActionPromises([optional], seats);
|
||||
const foreign = { ...cards(card()), compiled: { kind: 'action', action: { kind: 'cardPlay', effect: 'ghola' } } };
|
||||
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
|
||||
assert.throws(() => recordTurnActionCardPlay([original], 'respondent', 1, 'karama' as never), TurnActionError);
|
||||
});
|
||||
|
||||
void test('card-play events reject all affected No violations atomically and share the outer logical budget', () => {
|
||||
const yes = cards(group('and', card(), pay()));
|
||||
const no = cards(card(), false, 'turn-action:2');
|
||||
const before = structuredClone([yes, no]);
|
||||
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, 'hajr'), TurnActionError);
|
||||
assert.deepEqual([yes, no], before);
|
||||
assert.doesNotThrow(() => turnActionValue(cards(group('or', ...Array.from({ length: 16 }, () => card())))));
|
||||
assert.throws(() => turnActionValue(cards(group('or', ...Array.from({ length: 17 }, () => card())))), TurnActionError);
|
||||
});
|
||||
|
||||
void test('payment expiry preserves a later Ghola OR alternative while an AND still requires its missing payment', () => {
|
||||
const or = cards(group('or', card('ghola'), pay()));
|
||||
const and = cards(group('and', card('ghola'), pay()), true, 'turn-action:2');
|
||||
const closed = closeTurnActionPayments([or, and], 'respondent', 1);
|
||||
for (const row of closed) {
|
||||
assert.equal(row.paymentClosed, true);
|
||||
assert.equal(row.transportClosed, false);
|
||||
assert.deepEqual(row.shipment, { pending: true, event: null });
|
||||
assert.deepEqual(row.cardPlays, or.cardPlays);
|
||||
assert.equal(row.cardPlays!.ghola!.closed, false);
|
||||
assert.equal(row.fulfilled, false);
|
||||
assert.equal(row.released, false);
|
||||
}
|
||||
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }]]);
|
||||
assert.deepEqual(branches(closed[1]), []);
|
||||
assert.ok(recordTurnActionPayment(closed, 'respondent', 1, payment()).every((row, index) => row === closed[index]));
|
||||
const laterGhola = recordTurnActionCardPlay(closed, 'respondent', 1, 'ghola');
|
||||
assert.equal(laterGhola[0].fulfilled, true);
|
||||
assert.equal(laterGhola[1].fulfilled, false);
|
||||
assert.equal(finishTurnActionTurn(laterGhola, 'respondent', 1)[1].released, true);
|
||||
const expiredTooEarly = finishTurnActionTurn([or], 'respondent', 1)[0];
|
||||
assert.equal(expiredTooEarly.released, true);
|
||||
assert.equal(expiredTooEarly.cardPlays!.ghola!.closed, true);
|
||||
assert.equal(closeTurnActionPayments(laterGhola, 'respondent', 1)[0], laterGhola[0], 'Terminal rows remain identical');
|
||||
assert.equal(closeTurnActionPayments(closed, 'respondent', 1)[1], closed[1], 'Repeated payment closure retains the same row');
|
||||
assert.equal(or.paymentClosed, false);
|
||||
validateTurnActionPromises(closed, seats);
|
||||
});
|
||||
|
||||
void test('payment-only closure keeps whole No binding through later Ghola and validates its untouched native stage', () => {
|
||||
const orNo = cards(group('or', card('ghola'), pay()), false);
|
||||
const andNo = cards(group('and', card('ghola'), pay()), false, 'turn-action:2');
|
||||
const closed = closeTurnActionPayments([orNo, andNo], 'respondent', 1);
|
||||
assert.equal(closed[0].fulfilled, false);
|
||||
assert.equal(closed[1].fulfilled, false);
|
||||
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
|
||||
assert.throws(() => recordTurnActionCardPlay([closed[0]], 'respondent', 1, 'ghola'), TurnActionError);
|
||||
const played = recordTurnActionCardPlay([closed[1]], 'respondent', 1, 'ghola');
|
||||
assert.equal(played[0].fulfilled, false);
|
||||
assert.equal(finishTurnActionTurn(played, 'respondent', 1)[0].fulfilled, true);
|
||||
const paymentOnlyNo = closeTurnActionPayments([promise(pay(), false)], 'respondent', 1)[0];
|
||||
assert.equal(paymentOnlyNo.fulfilled, true);
|
||||
const other = { ...orNo, player: 'other' }, nextTurn = { ...orNo, turn: 2 };
|
||||
assert.equal(closeTurnActionPayments([other], 'respondent', 1)[0], other);
|
||||
assert.equal(closeTurnActionPayments([nextTurn], 'respondent', 1)[0], nextTurn);
|
||||
const malformed = { ...orNo, cardPlays: { hajr: null, ghola: { played: false, closed: 'yes' } } };
|
||||
assert.throws(() => closeTurnActionPayments([malformed as unknown as TurnActionPromise], 'respondent', 1), TurnActionError);
|
||||
validateTurnActionPromises(closed, seats);
|
||||
});
|
||||
Reference in new issue
Block a user