Connect printed flight and poison plays to Truthtrance
This commit is contained in:
1 parent
b02839ce2a
commit
6a0fba860d
17 files changed
+963
-41
No files matched your search
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { residualPoisonCandidates } from '../game/residual-poison';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { actResidualPoisonCapture } from './fixture-residual-poison-capture';
|
||||
import { ornithopterHajrMove } from './fixture-ornithopter-hajr';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { truthTurnThumperSpice, truthTurnThumperTraitors } from './fixture-truth-turn-thumper';
|
||||
|
||||
export type TruthFlightPoisonEffect = 'ornithopter' | 'residualPoison';
|
||||
export type TruthFlightPoisonLeaf = { kind: 'cardPlay'; effect: TruthFlightPoisonEffect } |
|
||||
{ kind: 'payment'; recipient: string; minimum: number };
|
||||
export type TruthFlightPoisonExpression = MixedActionExpression<TruthFlightPoisonLeaf>;
|
||||
export type TruthFlightPoisonStep = { actor: string; action: Action };
|
||||
export const actTruthFlightPoison = actResidualPoisonCapture;
|
||||
export const truthFlightPoisonPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(candidate => candidate.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const truthFlightPoisonClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale;
|
||||
export const truthFlightPoisonSpice = truthTurnThumperSpice;
|
||||
export const truthFlightPoisonTraitors = truthTurnThumperTraitors;
|
||||
/** An active physical flight is the card's real table slot until disposal. */
|
||||
export function truthFlightPoisonInventory(game: Game) {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(player => player.hand), ...(game.ornithopter ? [game.ornithopter.card] : []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Every original Treachery Card has one actual physical slot'); return ids;
|
||||
}
|
||||
export const truthFlightPoisonUse = (effect: TruthFlightPoisonEffect): TruthFlightPoisonExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
export const truthFlightPoisonFact = (fact: TruthFact): TruthFlightPoisonExpression => ({ kind: 'fact', fact });
|
||||
export const truthFlightPoisonPayment = (recipient: string, minimum = 2): TruthFlightPoisonExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthFlightPoisonGroup = (kind: 'and' | 'or', ...terms: TruthFlightPoisonExpression[]): TruthFlightPoisonExpression => ({ kind, terms });
|
||||
|
||||
export function nextTruthFlightPoisonNative(game: Game): TruthFlightPoisonStep | null {
|
||||
if (game.truthtrance || game.pendingNullentropy) return null;
|
||||
if (game.pendingTreacheryDiscard) return nextRicheseStrongholdsNativeStep(game);
|
||||
if (truthFlightPoisonClean(game) && game.battle?.preparation?.kind === 'prescience')
|
||||
return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthFlightPoison(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthFlightPoisonStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original flight/Poison source ended before its requested boundary');
|
||||
const step = nextTruthFlightPoisonNative(game); assert.ok(step, 'Supply the actual Truth, private selection or native Battle Plan');
|
||||
game = actTruthFlightPoison(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original flight/Poison source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthFlightPoison(game: Game, actions: TruthFlightPoisonStep[] = []): Game {
|
||||
return advanceTruthFlightPoison(game, truthFlightPoisonClean, actions);
|
||||
}
|
||||
export function finishTruthFlightPoison(game: Game, actions: TruthFlightPoisonStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthFlightPoison(game, state => state.turn === turn + 1, actions);
|
||||
}
|
||||
|
||||
/** Fresh bare R/A/E/G original setup in its actual requested mode. Native
|
||||
* cache1 acquisition, funded physical shipment and real battle preparation
|
||||
* supply every current role; no Nexus/profile is removed from a played game. */
|
||||
export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean } = {}) {
|
||||
const effect = options.effect ?? 'ornithopter', advanced = options.advanced ?? false;
|
||||
const target = effect === 'ornithopter' ? 'emperor' : 'atreides', asker = 'richese', recipient = asker;
|
||||
const canonical = richeseCards().find(card => card.effect === effect); assert.ok(canonical); const card = canonical.id;
|
||||
let game = createGame('TRUTHFLIGHTPOISON', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: TruthFlightPoisonStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthFlightPoison(game, actor, action, actions); };
|
||||
const sourceStep = (state: Game) => {
|
||||
if (!state.response && !state.phaseOpening && !state.pendingTreacheryDiscard) {
|
||||
if (state.decision?.kind === 'richeseCache' && state.richeseCache?.some(held => held.id === card))
|
||||
return { actor: state.decision.player, action: { type: 'decision', event: state.richeseBidding!.event,
|
||||
card, method: 'onceAround', direction: 'counterclockwise' } satisfies Action };
|
||||
const lot = state.richeseAuction;
|
||||
if (truthFlightPoisonClean(state) && lot?.cardId === card && !lot.outcome && lot.active === target && lot.bid === 0)
|
||||
return { actor: target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action };
|
||||
}
|
||||
return nextTruthFlightPoisonNative(state);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return;
|
||||
const step = sourceStep(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
throw Error('The actual cache acquisition/source boundary must be reached finitely');
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalCards = truthFlightPoisonInventory(game), originalSpice = truthFlightPoisonSpice(game), originalTraitors = truthFlightPoisonTraitors(game);
|
||||
const startingTruthSelection = ['richese', 'guild'].map(actor => {
|
||||
const position = game.players.findIndex(player => player.id === actor);
|
||||
const from = game.deck.findIndex((held, index) => index >= position && held.effect === 'truthtrance'); assert.ok(from >= position);
|
||||
const truth = game.deck[from].id; [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { actor, position, from, card: truth };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
||||
const at = game.spiceDeck.findIndex(held => 'territory' in held && !held.discovery); assert.ok(at >= 0);
|
||||
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
reach(state => state.decision?.kind === 'richeseCache'); const beforePurchase = structuredClone(game), purchaseAt = actions.length;
|
||||
reach(state => truthFlightPoisonPlayer(state, target).hand.some(held => held.id === card) && truthFlightPoisonClean(state));
|
||||
const acquisition = { before: beforePurchase, steps: structuredClone(actions.slice(purchaseAt)), after: structuredClone(game) };
|
||||
assert.equal(truthFlightPoisonPlayer(beforePurchase, target).spice - truthFlightPoisonPlayer(game, target).spice, 1);
|
||||
const shipper = effect === 'ornithopter' ? target : asker, place = effect === 'ornithopter' ? 'imperial_basin' : 'arrakeen';
|
||||
const sector = effect === 'ornithopter' ? 9 : 10, amount = effect === 'ornithopter' ? 2 : 1;
|
||||
reach(state => state.phase === 5 && state.active === shipper && truthFlightPoisonClean(state));
|
||||
const beforeShipment = structuredClone(game), shipmentAt = actions.length;
|
||||
const shipmentStep: TruthFlightPoisonStep = { actor: shipper, action: { type: 'ship', territory: place, sector, amount, elite: 0, allyPayment: 0 } };
|
||||
act(shipper, shipmentStep.action); reach(truthFlightPoisonClean);
|
||||
const shipment = { before: beforeShipment, step: shipmentStep, steps: structuredClone(actions.slice(shipmentAt)), after: structuredClone(game) };
|
||||
assert.equal(truthFlightPoisonPlayer(beforeShipment, shipper).spice - truthFlightPoisonPlayer(game, shipper).spice, effect === 'ornithopter' ? 4 : 1);
|
||||
let choice: TruthFlightPoisonStep | null = null;
|
||||
if (effect === 'residualPoison') {
|
||||
reach(state => state.phase === 6 && truthFlightPoisonClean(state));
|
||||
const actual = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === 'arrakeen'); assert.ok(actual);
|
||||
choice = { actor: actual.chooser, action: { type: 'chooseBattle', territory: 'arrakeen', target: actual.chooser === target ? asker : target } };
|
||||
act(choice.actor, choice.action);
|
||||
reach(state => !!state.battle?.preLeader && !state.battle.preLeader.closed && !state.battle.preparation && truthFlightPoisonClean(state));
|
||||
assert.deepEqual(game.battle!.plans, {}); assert.ok(residualPoisonCandidates(truthFlightPoisonPlayer(game, asker).leaders, 'arrakeen').length > 1);
|
||||
const quote = viewGame(game, target).residualPoison; assert.ok(quote && !quote.blocked && quote.target === asker);
|
||||
}
|
||||
assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced);
|
||||
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
|
||||
assert.deepEqual(truthFlightPoisonInventory(game), originalCards); assert.deepEqual(truthFlightPoisonSpice(game), originalSpice);
|
||||
assert.deepEqual(truthFlightPoisonTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||||
const truths = startingTruthSelection.map(row => row.card), truthHolders = startingTruthSelection.map(row => row.actor);
|
||||
for (const [index, truth] of truths.entries()) assert.ok(truthFlightPoisonPlayer(game, truthHolders[index]).hand.some(held => held.id === truth));
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthFlightPoisonPlayer(game, target).spice };
|
||||
const mixed = truthFlightPoisonGroup('and', truthFlightPoisonUse(effect), truthFlightPoisonPayment(recipient));
|
||||
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
|
||||
return { game, source: structuredClone(game), effect, advanced, target, asker, recipient, card, truth: truths[0], truths, truthHolders,
|
||||
initial, lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, spiceOrder: structuredClone(front), acquisition, shipment, choice,
|
||||
origin: effect === 'ornithopter' ? 'imperial_basin:9' : null, poisonTarget: effect === 'residualPoison' ? asker : null,
|
||||
event: game.battle?.event ?? null, spiceFact, mixed, question, originalCards, originalSpice, originalTraitors,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { selected: actions.length, nativeCacheBids: actions.filter(step => step.actor === target && step.action.type === 'richeseBid' && step.action.amount === 1).length,
|
||||
acquisition: acquisition.steps.length, shipment: shipment.steps.length },
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
end: { type: 'endMovement' } satisfies Action,
|
||||
sourceDisclosure: 'Genuine public bare Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original Basic or Advanced preview mode. ' +
|
||||
'Only two original unopened Richese/Guild Truth deals, original unopened first-turn land Spice positions and scalar lotteries are selected. ' +
|
||||
'The holder actually buys its canonical cache card for1. ' + (effect === 'ornithopter'
|
||||
? 'Emperor actually pays4 to ship two ordinary counters to Imperial Basin, retaining5 and its unused ground movement. '
|
||||
: 'Richese actually pays1 to ship one original counter into original Atreides10 in Arrakeen; actual chooseBattle and native preparation create the preleader window while Atreides retains9. ') +
|
||||
'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed. Source counts derive from actual arrays.',
|
||||
};
|
||||
}
|
||||
export type TruthFlightPoisonFixture = ReturnType<typeof createTruthFlightPoisonFixture>;
|
||||
export function declareTruthFlightPoison(f: TruthFlightPoisonFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthFlightPoisonStep[] = []) {
|
||||
const asker = f.truthHolders[copy]; let game = actTruthFlightPoison(source, asker, { type: 'card', card: f.truths[copy] }, actions);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthFlightPoison(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
|
||||
}
|
||||
export function askTruthFlightPoison(game: Game, asker: string, question: TruthQuestion, actions: TruthFlightPoisonStep[] = []): Game {
|
||||
return actTruthFlightPoison(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
|
||||
}
|
||||
export function answerTruthFlightPoison(game: Game, target: string, answer: 'yes' | 'no', actions: TruthFlightPoisonStep[] = []): Game {
|
||||
return actTruthFlightPoison(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthFlightPoison(f: TruthFlightPoisonFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthFlightPoisonStep[] = []) {
|
||||
const declared = declareTruthFlightPoison(f, source, copy, actions), asked = askTruthFlightPoison(declared.game, declared.asker, question, actions);
|
||||
return { game: answerTruthFlightPoison(asked, f.target, answer, actions), declared, asked, actions };
|
||||
}
|
||||
export function truthFlightPoisonPromise(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 truthFlightPoisonMove(game: Game, f: TruthFlightPoisonFixture, mode?: OrnithopterMode, from = f.origin!): Action {
|
||||
assert.equal(f.effect, 'ornithopter');
|
||||
return ornithopterHajrMove(game, from, { ...(mode ? { mode } : {}), range: mode === 'range3' ? 3 : 1, elite: 0 });
|
||||
}
|
||||
export function truthFlightPoisonAction(game: Game, f: TruthFlightPoisonFixture): Action {
|
||||
assert.equal(f.effect, 'residualPoison'); const quote = viewGame(game, f.target).residualPoison;
|
||||
assert.ok(quote && !quote.blocked && quote.event && quote.target);
|
||||
return { type: 'card', card: quote.card.id, target: quote.target, event: quote.event };
|
||||
}
|
||||
export function closeTruthFlightPoisonPreparation(game: Game, actions: TruthFlightPoisonStep[] = []): Game {
|
||||
assert.ok(game.battle?.preLeader);
|
||||
for (const actor of [game.battle.attacker, game.battle.defender]) {
|
||||
if (!game.battle!.preLeader!.ready.includes(actor) && !game.battle!.preLeader!.closed)
|
||||
game = actTruthFlightPoison(game, actor, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event }, actions);
|
||||
}
|
||||
assert.equal(game.battle!.preLeader!.closed, true); return game;
|
||||
}
|
||||
export function finishTruthFlightPoisonBattle(f: TruthFlightPoisonFixture, source: Game, actions: TruthFlightPoisonStep[] = []) {
|
||||
assert.equal(f.effect, 'residualPoison'); let game = settleTruthFlightPoison(source, actions);
|
||||
game = closeTruthFlightPoisonPreparation(game, actions);
|
||||
const plans: TruthFlightPoisonStep[] = [];
|
||||
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
||||
if (game.battle!.plans[actor]) continue;
|
||||
const candidates = residualPoisonCandidates(truthFlightPoisonPlayer(game, actor).leaders, game.battle!.territory)
|
||||
.sort((a, b) => actor === f.target ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(candidates[0]); const step: TruthFlightPoisonStep = { actor, action: { type: 'battlePlan', leader: candidates[0].id, dial: 0, support: 0 } };
|
||||
game = actTruthFlightPoison(game, actor, step.action, actions); plans.push(step);
|
||||
}
|
||||
game = advanceTruthFlightPoison(game, state => !state.battle && truthFlightPoisonClean(state), actions);
|
||||
return { game, plans, actions };
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from '../game/printed-card-play';
|
||||
|
||||
void test('Box accepted activation counts extend the shared roles without changing earlier Hajr or Ghola baselines', () => {
|
||||
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug']);
|
||||
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug',
|
||||
'ornithopter', 'residualPoison']);
|
||||
assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0);
|
||||
const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
|
||||
const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox');
|
||||
@@ -76,6 +77,25 @@ void test('Semuta counts actual physical resolutions separately from other roles
|
||||
assert.equal(printedCardPlayCount(full, 'richese', 4, 'semutaDrug'), Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
void test('Ornithopter placement and Residual Poison use retain separate exact-role counts without group or victim metadata', () => {
|
||||
const original = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
|
||||
let counts = original;
|
||||
for (const effect of ['ornithopter', 'residualPoison'] as const) {
|
||||
assert.equal(printedCardPlayCount(counts, 'richese', 3, effect), 0);
|
||||
counts = recordPrintedCardPlay(counts, 'richese', 3, effect);
|
||||
assert.equal(printedCardPlayCount(counts, 'richese', 3, effect), 1);
|
||||
assert.equal(printedCardPlayCount(counts, 'other', 3, effect), 0);
|
||||
assert.equal(printedCardPlayCount(counts, 'richese', 4, effect), 0);
|
||||
for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) {
|
||||
const invalid = { turn: 3, plays: [{ player: 'richese', effect, count: 1, ...extra }] } as PrintedCardPlayCounts;
|
||||
assert.throws(() => printedCardPlayCount(invalid, 'richese', 3, effect), PrintedCardPlayError);
|
||||
}
|
||||
const full: PrintedCardPlayCounts = { turn: 3, plays: [{ player: 'richese', effect, count: Number.MAX_SAFE_INTEGER }] };
|
||||
assert.throws(() => recordPrintedCardPlay(full, 'richese', 3, effect), PrintedCardPlayError);
|
||||
}
|
||||
assert.deepEqual(original, { turn: 3, plays: [{ player: 'richese', effect: 'ghola', count: 1 }] });
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { gameDistance, location, splitLocation } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { printedCardPlayCount } from '../game/printed-card-play';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthFlightPoison as act, advanceTruthFlightPoison as advance, answerTruthFlightPoison as answer,
|
||||
askTruthFlightPoison as ask, bindTruthFlightPoison as bind, closeTruthFlightPoisonPreparation as closePreparation,
|
||||
createTruthFlightPoisonFixture as fixture, declareTruthFlightPoison as declare,
|
||||
finishTruthFlightPoison as finish, finishTruthFlightPoisonBattle as finishBattle,
|
||||
nextTruthFlightPoisonNative as nativeStep, settleTruthFlightPoison as settle,
|
||||
truthFlightPoisonAction as poison, truthFlightPoisonClean as clean,
|
||||
truthFlightPoisonFact as fact, truthFlightPoisonGroup as group, truthFlightPoisonInventory as inventory,
|
||||
truthFlightPoisonMove as move, truthFlightPoisonPayment as payment, truthFlightPoisonPlayer as player,
|
||||
truthFlightPoisonPromise as promise, truthFlightPoisonSpice as spice,
|
||||
truthFlightPoisonTraitors as traitors, truthFlightPoisonUse as use,
|
||||
type TruthFlightPoisonExpression, type TruthFlightPoisonFixture, type TruthFlightPoisonStep,
|
||||
} from './fixture-truth-flight-poison';
|
||||
|
||||
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: TruthFlightPoisonFixture) {
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
|
||||
assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
}
|
||||
const raw = (f: TruthFlightPoisonFixture, mixed: TruthFlightPoisonExpression): TruthQuestion =>
|
||||
({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed });
|
||||
const stage = (game: Game, f: TruthFlightPoisonFixture, index = 0) => game.turnActionPromises![index].cardPlays?.[f.effect];
|
||||
const count = (game: Game, f: TruthFlightPoisonFixture) => printedCardPlayCount(game.printedCardPlays, f.target, game.turn, f.effect);
|
||||
const pay = (game: Game, f: TruthFlightPoisonFixture, amount: number, actions: TruthFlightPoisonStep[] = []) =>
|
||||
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
|
||||
const resources = (game: Game) => game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice,
|
||||
forces: structuredClone(candidate.forces), reserves: candidate.reserves, tanks: candidate.tanks,
|
||||
elites: structuredClone(candidate.elites), moved: candidate.moved, shipped: candidate.shipped }));
|
||||
|
||||
void test('flight and Poison sources earn native cache1 cards, real original forces and unopened Truths in both modes', () => {
|
||||
for (const effect of ['ornithopter', 'residualPoison'] as const) for (const advanced of [false, true]) {
|
||||
const f = fixture({ effect, advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.beforeStartingDeal.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
|
||||
assert.equal(f.sourceCounts.nativeCacheBids, 1); assert.equal(f.sourceCounts.acquisition, f.acquisition.steps.length);
|
||||
assert.equal(f.sourceCounts.shipment, f.shipment.steps.length); assert.equal(!!f.game.semutaPreview, !advanced);
|
||||
assert.ok(!f.game.nexusCards && !f.game.homeworlds && !f.game.leaderSkills && !f.game.techTokens && !f.game.strongholdCards);
|
||||
assert.equal(player(f.acquisition.before, f.target).spice - player(f.acquisition.after, f.target).spice, 1);
|
||||
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
|
||||
assert.equal(count(f.game, f), 0); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
|
||||
if (effect === 'ornithopter') {
|
||||
assert.equal(player(f.game, f.target).spice, 5); assert.equal(player(f.game, f.target).forces['imperial_basin:9'], 2);
|
||||
assert.equal(player(f.game, f.target).elites?.forces['imperial_basin:9'] ?? 0, 0); assert.equal(player(f.game, f.target).moved, 0);
|
||||
} else {
|
||||
assert.equal(player(f.game, f.target).spice, 9); assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 1);
|
||||
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 10); assert.equal(f.game.battle!.preLeader!.closed, false);
|
||||
assert.deepEqual(f.game.battle!.plans, {}); assert.ok(viewGame(f.game, f.target).residualPoison && !viewGame(f.game, f.target).residualPoison!.blocked);
|
||||
}
|
||||
physical(f.game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic flight AND frozen fact and all five paid counts the first declaration before disposal with no required second group', () => {
|
||||
const f = fixture(), mixed = group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 5));
|
||||
let game = settle(bind(f, 'yes', raw(f, mixed)).game); game = pay(game, f, 5);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
game = settle(act(game, f.target, move(game, f, 'twoGroups')));
|
||||
assert.equal(count(game, f), 1); assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(game.ornithopter?.completed, 1); assert.equal(game.ornithopter?.card.id, f.card);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 0); physical(game, f);
|
||||
game = settle(act(game, f.target, f.end));
|
||||
assert.equal(game.ornithopter, null); assert.equal(player(game, f.target).moved, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(count(game, f), 1);
|
||||
assert.equal(stage(game, f)?.played, true); assert.deepEqual(promise(game).mixed, mixed); physical(game, f);
|
||||
});
|
||||
|
||||
void test('range-three and both distinct two-group routes are actual native flights and count only one printed use', () => {
|
||||
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
const f = fixture({ advanced }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
|
||||
const first = move(game, f, mode), destination = location(String(first.territory), Number(first.sector));
|
||||
assert.equal(gameDistance(game, f.origin!, destination, key => splitLocation(key).sector === game.storm), mode === 'range3' ? 3 : 1);
|
||||
game = settle(act(game, f.target, first)); assert.equal(count(game, f), 1); assert.equal(promise(game).fulfilled, true);
|
||||
if (mode === 'twoGroups') {
|
||||
assert.equal(game.ornithopter?.completed, 1);
|
||||
const second = move(game, f); assert.equal(second.ornithopterEvent, game.ornithopter!.event); assert.equal(second.movementCard, undefined);
|
||||
assert.equal(second.from, f.origin); game = settle(act(game, f.target, second));
|
||||
assert.equal(player(game, f.target).moved, 2);
|
||||
} else assert.equal(player(game, f.target).moved, 1);
|
||||
assert.equal(game.ornithopter, null); assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(count(game, f), 1); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a positive flight answer prevents own ordinary-move or early-end evasion while OR can end and pay later', () => {
|
||||
const f = fixture({ advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
|
||||
rejected(game, f.target, f.end); rejected(game, f.target, move(game, f)); assert.equal(count(game, f), 0);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'move');
|
||||
assert.equal(guide.action.movementCard, f.card); game = settle(act(game, f.target, guide.action)); assert.equal(promise(game).fulfilled, true);
|
||||
let alternate = settle(bind(f, 'yes', raw(f, group('or', use(f.effect), payment(f.recipient)))).game);
|
||||
alternate = settle(act(alternate, f.target, f.end)); assert.equal(stage(alternate, f)?.played, false); assert.equal(stage(alternate, f)?.closed, true);
|
||||
alternate = advance(alternate, state => state.phase === 8 && clean(state)); alternate = pay(alternate, f, 2);
|
||||
assert.equal(promise(alternate).fulfilled, true); assert.ok(player(alternate, f.target).hand.some(card => card.id === f.card));
|
||||
physical(game, f); physical(alternate, f);
|
||||
});
|
||||
|
||||
void test('an active flight gives no new-interval credit and its second group or later disposal does not count again', () => {
|
||||
const f = fixture({ advanced: true }); let game = settle(act(f.game, f.target, move(f.game, f, 'twoGroups')));
|
||||
assert.equal(count(game, f), 1); assert.equal(game.ornithopter?.completed, 1);
|
||||
const event = game.ornithopter!.event, declared = declare(f, game), asked = ask(declared.game, declared.asker, raw(f, use(f.effect)));
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); assert.equal(game.ornithopter!.event, event);
|
||||
game = settle(act(game, f.target, move(game, f))); assert.equal(count(game, f), 1); assert.equal(stage(game, f)?.played, false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); game = settle(act(game, f.target, f.end));
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('flight whole No AND permits one branch and No OR keeps its payment cap after own movement closes', () => {
|
||||
const f = fixture({ advanced: true }), packet = move(f.game, f, 'range3');
|
||||
let played = settle(bind(f, 'no').game); played = settle(act(played, f.target, packet)); played = pay(played, f, 1);
|
||||
rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.equal(count(played, f), 1);
|
||||
let paid = settle(bind(f, 'no').game); paid = pay(paid, f, 2); rejected(paid, f.target, packet); assert.equal(count(paid, f), 0);
|
||||
let neither = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game);
|
||||
rejected(neither, f.target, packet); neither = pay(neither, f, 1); neither = settle(act(neither, f.target, f.end));
|
||||
assert.equal(stage(neither, f)?.closed, true); neither = advance(neither, state => state.phase === 8 && clean(state));
|
||||
rejected(neither, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.ok(!promise(neither).released);
|
||||
neither = finish(neither); assert.equal(promise(neither).fulfilled, true);
|
||||
physical(played, f); physical(paid, f); physical(neither, f);
|
||||
});
|
||||
|
||||
void test('Residual AND frozen fact and all nine paid kills one actual native leader for no fee or bounty before disposal continuation', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ effect: 'residualPoison', advanced }), mixed = group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 9));
|
||||
let game = settle(bind(f, 'yes', raw(f, mixed)).game); const before = resources(game), leaders = structuredClone(player(game, f.asker).leaders);
|
||||
const packet = poison(game, f); assert.equal('leader' in packet, false); game = act(game, f.target, packet);
|
||||
const victims = player(game, f.asker).leaders.filter(leader => leader.dead && !leaders.find(original => original.id === leader.id)!.dead);
|
||||
assert.equal(victims.length, 1); assert.equal(victims[0].deaths, leaders.find(original => original.id === victims[0].id)!.deaths + 1);
|
||||
assert.deepEqual(resources(game), before); assert.equal(count(game, f), 1); assert.equal(stage(game, f)?.played, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(game.battle!.plans, {});
|
||||
if (!advanced) assert.ok(game.pendingTreacheryDiscard);
|
||||
game = settle(game); game = pay(game, f, 9); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
|
||||
const completed = finishBattle(f, game); game = completed.game;
|
||||
assert.equal(stage(game, f)?.played, true); assert.equal(count(game, f), 1); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(player(game, f.asker).leaders.find(leader => leader.id === victims[0].id)!.dead, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Residual completion quotes neither sample a random victim nor create hypothetical Tanks or bounty funding', t => {
|
||||
const f = fixture({ effect: 'residualPoison' }), declared = declare(f), before = structuredClone(declared.game);
|
||||
let rng = 0; const mock = t.mock.method(crypto, 'getRandomValues', () => { rng++; throw Error('A future-role quote must not choose a victim'); });
|
||||
const funded = applyAction(declared.game, declared.asker, { type: 'truthAsk', question: raw(f, group('and', use(f.effect), payment(f.recipient, 9))) });
|
||||
assert.deepEqual(viewGame(funded, f.target).truthTurnActionAnswers, ['yes', 'no']);
|
||||
const unfunded = applyAction(declared.game, declared.asker, { type: 'truthAsk', question: raw(f, group('and', use(f.effect), payment(f.recipient, 10))) });
|
||||
assert.deepEqual(viewGame(unfunded, f.target).truthTurnActionAnswers, ['no']); assert.equal(rng, 0); mock.mock.restore();
|
||||
assert.deepEqual(declared.game, before); assert.deepEqual(funded.players, before.players); assert.deepEqual(unfunded.players, before.players);
|
||||
assert.equal(player(funded, f.asker).leaders.filter(leader => leader.dead).length, 0); assert.equal(count(funded, f), 0); physical(funded, f);
|
||||
});
|
||||
|
||||
void test('Residual Yes rejects own first leader commitment while a real foreign first plan releases the unfulfilled known-window condition', () => {
|
||||
const f = fixture({ effect: 'residualPoison', advanced: true });
|
||||
let bound = settle(bind(f, 'yes', raw(f, group('and', use(f.effect), payment(f.recipient)))).game); bound = pay(bound, f, 2);
|
||||
bound = closePreparation(bound); const packet = poison(bound, f);
|
||||
const ownLeader = player(bound, f.target).leaders.find(leader => !leader.dead && !leader.usedAt)!;
|
||||
rejected(bound, f.target, { type: 'battlePlan', dial: 0, support: 0, leader: ownLeader.id });
|
||||
assert.equal(stage(bound, f)?.played, false); assert.ok(!promise(bound).released);
|
||||
const enemyLeader = player(bound, f.asker).leaders.find(leader => !leader.dead && !leader.usedAt)!;
|
||||
const foreign = act(bound, f.asker, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id });
|
||||
assert.equal(promise(foreign).released, true); assert.equal(stage(foreign, f)?.played, false);
|
||||
assert.equal(foreign.turnActionPromises![0].payments[0].amount, 2); assert.ok(player(foreign, f.target).hand.some(card => card.id === f.card));
|
||||
rejected(foreign, f.target, packet); assert.equal(player(foreign, f.asker).leaders.filter(leader => leader.dead).length, 0); physical(foreign, f);
|
||||
});
|
||||
|
||||
void test('Residual whole OR supports actual manual printed use or payment followed by native battle cleanup with the card unused', () => {
|
||||
const f = fixture({ effect: 'residualPoison', advanced: true }), question = raw(f, group('or', use(f.effect), payment(f.recipient)));
|
||||
const bound = settle(bind(f, 'yes', question).game);
|
||||
const played = settle(act(bound, f.target, poison(bound, f))); assert.equal(promise(played).fulfilled, true);
|
||||
assert.equal(player(played, f.target).spice, 9); assert.equal(player(played, f.recipient).bribes, 0);
|
||||
let paid = pay(bound, f, 2); paid = finishBattle(f, paid).game;
|
||||
assert.equal(promise(paid).fulfilled, true); assert.equal(stage(paid, f)?.played, false);
|
||||
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card)); assert.equal(player(paid, f.asker).leaders.filter(leader => leader.dead).length, 0);
|
||||
physical(played, f); physical(paid, f);
|
||||
});
|
||||
|
||||
void test('Residual whole No caps payments and blocks the forbidden random effect before any victim is sampled', t => {
|
||||
const f = fixture({ effect: 'residualPoison', advanced: true }), originalPacket = poison(f.game, f);
|
||||
let played = settle(bind(f, 'no').game); played = settle(act(played, f.target, originalPacket)); played = pay(played, f, 1);
|
||||
rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.equal(count(played, f), 1);
|
||||
let paid = settle(bind(f, 'no').game); paid = pay(paid, f, 2);
|
||||
let neither = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game);
|
||||
for (const game of [paid, neither]) {
|
||||
const before = structuredClone(game); let rng = 0;
|
||||
const mock = t.mock.method(crypto, 'getRandomValues', () => { rng++; throw Error('No must reject before random victim selection'); });
|
||||
assert.throws(() => applyAction(game, f.target, originalPacket)); assert.equal(rng, 0); mock.mock.restore(); assert.deepEqual(game, before);
|
||||
assert.equal(count(game, f), 0); assert.equal(player(game, f.asker).leaders.filter(leader => leader.dead).length, 0);
|
||||
}
|
||||
neither = pay(neither, f, 1); neither = finishBattle(f, neither).game;
|
||||
neither = advance(neither, state => state.phase === 8 && clean(state)); rejected(neither, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
neither = finish(neither); assert.equal(promise(neither).fulfilled, true); assert.equal(stage(neither, f)?.played, false);
|
||||
physical(played, f); physical(paid, f); physical(neither, f);
|
||||
});
|
||||
|
||||
void test('earlier Residual use is not a second original Truth future-play receipt and cannot create a new victim on disposal', () => {
|
||||
const f = fixture({ effect: 'residualPoison', advanced: true }); let game = settle(act(f.game, f.target, poison(f.game, f)));
|
||||
assert.equal(count(game, f), 1); const dead = player(game, f.asker).leaders.filter(leader => leader.dead).map(leader => leader.id);
|
||||
const declared = declare(f, game), asked = ask(declared.game, declared.asker, raw(f, use(f.effect)));
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); assert.equal(count(game, f), 1);
|
||||
assert.deepEqual(player(game, f.asker).leaders.filter(leader => leader.dead).map(leader => leader.id), dead); physical(game, f);
|
||||
});
|
||||
|
||||
void test('exact flight and Poison guides bind native packets through JSON and reject stale promise, source or forged victim fields', () => {
|
||||
for (const effect of ['ornithopter', 'residualPoison'] as const) {
|
||||
const f = fixture({ effect, advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.event, promise(game).event);
|
||||
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, game.phase);
|
||||
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-flight-poison-answer' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 8 });
|
||||
if (effect === 'ornithopter') {
|
||||
assert.equal(guide.action.type, 'move'); assert.equal(guide.action.movementCard, f.card);
|
||||
assert.ok(guide.action.ornithopter === 'range3' || guide.action.ornithopter === 'twoGroups');
|
||||
rejected(game, f.target, { ...guide.action, ornithopterEvent: 'already-started-stale-flight' });
|
||||
} else {
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card); assert.equal(guide.action.target, f.asker);
|
||||
assert.equal(guide.action.event, f.event); assert.equal('leader' in guide.action, false);
|
||||
rejected(game, f.target, { ...guide.action, event: 'stale-poison-battle' });
|
||||
rejected(game, f.target, { ...guide.action, leader: player(game, f.asker).leaders[0].id });
|
||||
}
|
||||
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, true); assert.equal(count(game, f), 1);
|
||||
rejected(game, f.target, packet); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('four minimal profiles consume exact guides for a human-bound Yes in both roles and modes without requiring strategic answer policy', () => {
|
||||
for (const effect of ['ornithopter', 'residualPoison'] as const) for (const advanced of [false, true]) {
|
||||
const f = fixture({ effect, advanced }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const answering = viewGame(asked, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const proposed = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(proposed);
|
||||
assert.ok(answering.truthTurnActionAnswers?.includes(proposed.answer as 'yes' | 'no' | 'unknown'));
|
||||
let game = answer(structuredClone(asked), f.target, 'yes'); const actions: TruthFlightPoisonStep[] = [];
|
||||
for (let step = 0; step < 100 && (!promise(game).fulfilled || !clean(game)); step++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
if (view.turnActionGuide) {
|
||||
const guide = view.turnActionGuide, packet = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
|
||||
assert.ok(packet); assert.deepEqual(packet, guide.action); game = act(game, f.target, packet, actions);
|
||||
} else {
|
||||
const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); assert.equal(player(game, f.recipient).bribes, 2);
|
||||
if (effect === 'ornithopter' && game.ornithopter) game = settle(act(game, f.target, f.end, actions), actions);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(count(game, f), 1);
|
||||
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice - 2); physical(game, f);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,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' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' | 'semutaDrug' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' | 'semutaDrug' | 'ornithopter' | 'residualPoison' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
|
||||
...(cunning ? ['cunning', cunning] : ['original'])]);
|
||||
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
|
||||
@@ -181,6 +181,27 @@ function semutaPlay(stage: 'offer' | 'select' = 'offer', next?: Action): GameVie
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('semutaDrug'), payment()] };
|
||||
return game;
|
||||
}
|
||||
function flightPlay(mode: 'range3' | 'twoGroups' = 'range3', continuation = false): GameView {
|
||||
const card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card);
|
||||
const game = projected(5, { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 }, eliteForces: { 'arrakeen:10': 1 },
|
||||
territory: 'carthag', sector: 11, fighters: false,
|
||||
...(continuation ? { ornithopterEvent: 'controlled-flight' } : { movementCard: card.id, ornithopter: mode }) });
|
||||
const me = game.players.find(player => player.id === 'p')!; me.hand = continuation ? [] : [card];
|
||||
me.forces = { 'arrakeen:10': 4, 'arrakeen:11': 1 }; me.moved = continuation ? 1 : 0;
|
||||
game.ornithopter = { card, blocked: null, modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }],
|
||||
active: continuation ? { event: 'controlled-flight', mode, completed: 1, remaining: 1,
|
||||
cohort: { forces: { 'arrakeen:10': 4, 'arrakeen:11': 1 }, elites: { 'arrakeen:10': 1 } } } : null };
|
||||
game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('ornithopter'), physical('move', 4, 'carthag'), payment()] };
|
||||
return game;
|
||||
}
|
||||
function residualPlay(): GameView {
|
||||
const card = richeseCards().find(card => card.effect === 'residualPoison'); assert.ok(card);
|
||||
const game = combat(false, { type: 'card', card: card.id, event: 'controlled-native-battle', target: 'a' });
|
||||
game.players.find(player => player.id === 'p')!.hand = [card];
|
||||
game.residualPoison = { card, event: 'controlled-native-battle', target: 'a', blocked: null };
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('residualPoison'), payment()] };
|
||||
return game;
|
||||
}
|
||||
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
|
||||
const game = projected(1, next);
|
||||
game.nexus = true; game.spiceWindow = null; game.active = 'a';
|
||||
@@ -1296,17 +1317,20 @@ void test('turn-action printed-role editor uses public names without holdings or
|
||||
Object.defineProperty(game, 'distrans', { get() { throw Error('No private Distrans authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'saphoOptions', { get() { throw Error('No private Sapho authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'semutaReaction', { get() { throw Error('No private Semuta authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'ornithopter', { get() { throw Error('No private flight authoring inspection'); }, configurable: true });
|
||||
Object.defineProperty(game, 'residualPoison', { get() { throw Error('No private poison 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', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug']);
|
||||
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison']);
|
||||
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'));
|
||||
select(e.tree(), '1', 'effect', 'nullentropyBox'); assert.deepEqual(e.value, printedCard('nullentropyBox')); assert.equal(e.error(), null);
|
||||
for (const effect of ['distrans', 'juiceOfSapho', 'semutaDrug'] as const) {
|
||||
for (const effect of ['distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison'] as const) {
|
||||
select(e.tree(), '1', 'effect', effect); assert.deepEqual(e.value, printedCard(effect)); assert.equal(e.error(), null);
|
||||
for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' }])
|
||||
for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' },
|
||||
{ territory: 'carthag' }, { ornithopter: 'range3' }, { victim: 'private-leader' }])
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect, ...extra } }, game, 'p'));
|
||||
}
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
|
||||
@@ -1324,17 +1348,20 @@ void test('turn-action printed-role editor uses public names without holdings or
|
||||
assert.match(html, /Play Juice of Sapho in its printed role after this answer this turn/);
|
||||
assert.match(html, /Complete a printed Semuta Drug transfer and disposal after this answer this turn/);
|
||||
assert.match(html, /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
|
||||
assert.match(html, /Declare a physical Ornithopter flight after this answer this turn/);
|
||||
assert.match(html, /Complete a printed Residual Poison play after this answer this turn/);
|
||||
assert.match(html, /Later groups and disposal do not count again/);
|
||||
assert.match(html, /not a particular recipient, transferred card or order outcome/);
|
||||
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('nullentropyBox')] }, false, game);
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay');
|
||||
select(full.tree(), `1.${count}`, 'effect', count % 3 === 0 ? 'semutaDrug' : count % 2 ? 'distrans' : 'juiceOfSapho');
|
||||
select(full.tree(), `1.${count}`, 'effect', count % 2 ? 'ornithopter' : 'residualPoison');
|
||||
}
|
||||
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('semutaDrug')] }, false, game);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), printedCard('residualPoison')] }, 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');
|
||||
@@ -1673,9 +1700,106 @@ void test('Semuta guides reject extra fields, offer-stage selection and fake car
|
||||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('A pending fresh discard owns its native controls'); } }).props.disabled, true);
|
||||
});
|
||||
|
||||
void test('Ornithopter guides preserve exact native initial modes and played-zone continuation without requiring a new role atom', () => {
|
||||
for (const mode of ['range3', 'twoGroups'] as const) {
|
||||
const game = flightPlay(mode), sent: Action[] = [];
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private flight receipt inspection'); } });
|
||||
const 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.equal(sent[0].movementCard, 'richese-ornithopter'); assert.equal(sent[0].ornithopter, mode);
|
||||
assert.equal(Object.hasOwn(sent[0], 'ornithopterEvent'), false);
|
||||
assert.deepEqual(sent[0].eliteForces, { 'arrakeen:10': 1 }); assert.equal(sent[0].fighters, false);
|
||||
assert.match(markup(game), /Declare Ornithopter/);
|
||||
}
|
||||
const game = flightPlay('twoGroups', true), sent: Action[] = [];
|
||||
game.turnActionPromises[0].mixed = claim();
|
||||
assert.equal(game.players.find(player => player.id === 'p')!.hand!.length, 0);
|
||||
const 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.equal(sent[0].ornithopterEvent, 'controlled-flight'); assert.equal(Object.hasOwn(sent[0], 'movementCard'), false);
|
||||
assert.equal(Object.hasOwn(sent[0], 'ornithopter'), false);
|
||||
const preparation = flightPlay(); preparation.turnActionPromises[0].mixed = claim();
|
||||
assert.equal(guide({ game: preparation, busy: false, act() {} }).props.disabled, false);
|
||||
});
|
||||
|
||||
void test('Ornithopter quotes and active events cannot be bypassed by generic move packets or stale callbacks', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.ornithopter = null; },
|
||||
(game: GameView) => { game.ornithopter = { ...game.ornithopter! }; },
|
||||
(game: GameView) => { game.ornithopter!.modes[0].blocked = 'A binding No prevents this printed use.'; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.forces['arrakeen:10'] = 1; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.moved = 1; },
|
||||
(game: GameView) => { game.active = 'a'; },
|
||||
(game: GameView) => { game.turnActionPromises[0].mixed = payment(); },
|
||||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
]) {
|
||||
const game = flightPlay(), sent: Action[] = [], 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 extra of [{ movementCard: 'unheld-flight-card' }, { ornithopter: 'unsupported' }, { ornithopterEvent: 'old-flight' },
|
||||
{ target: 'a' }, { leader: 'private-leader' }, { mode: 'range3' }]) {
|
||||
const game = flightPlay(); Object.assign(game.turnActionGuide!.action, extra);
|
||||
assert.equal(guide({ game, busy: false, act() { assert.fail('A new flight must use the current native declaration'); } }).props.disabled, true);
|
||||
}
|
||||
for (const extra of [{ ornithopterEvent: 'old-flight' }, { movementCard: 'richese-ornithopter' }, { ornithopter: 'twoGroups' }]) {
|
||||
const game = flightPlay('twoGroups', true); Object.assign(game.turnActionGuide!.action, extra);
|
||||
assert.equal(guide({ game, busy: false, act() { assert.fail('A continuation cannot declare or recount its played card'); } }).props.disabled, true);
|
||||
}
|
||||
const missingEvent = flightPlay('twoGroups', true); delete missingEvent.turnActionGuide!.action.ornithopterEvent;
|
||||
assert.equal(guide({ game: missingEvent, busy: false, act() { assert.fail('An active flight requires its current event'); } }).props.disabled, true);
|
||||
const current = flightPlay('twoGroups', true), sent: Action[] = [], saved = guide({ game: current, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(saved.props.disabled, false); current.ornithopter!.active!.event = 'next-flight'; press(saved); assert.deepEqual(sent, []);
|
||||
const direct = flightPlay(); direct.turnActionGuide!.action = { type: 'card', card: 'richese-ornithopter',
|
||||
turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
|
||||
assert.equal(guide({ game: direct, busy: false, act() { assert.fail('Ornithopter requires a physical native flight declaration'); } }).props.disabled, true);
|
||||
const no = flightPlay(); no.turnActionPromises[0].answer = false; no.ornithopter!.blocked = 'A binding No prevents this printed use.';
|
||||
assert.equal(guide({ game: no, busy: false, act() { assert.fail('The generic route cannot bypass the native No quote'); } }).props.disabled, true);
|
||||
});
|
||||
|
||||
void test('Residual Poison guides use the exact canonical card and actual current opponent without inspecting leaders', () => {
|
||||
const game = residualPlay(), sent: Action[] = [];
|
||||
for (const player of game.players) {
|
||||
Object.defineProperty(player, 'leaders', { get() { throw Error('The native quote owns the random victim pool'); }, configurable: true });
|
||||
if (player.id !== 'p') Object.defineProperty(player, 'hand', { get() { throw Error('No foreign custody inspection'); }, configurable: true });
|
||||
}
|
||||
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private poison receipt inspection'); } });
|
||||
const 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.deepEqual(sent[0], { type: 'card', card: 'richese-residual-poison', event: 'controlled-native-battle', target: 'a',
|
||||
turnActionPromiseEvent: event, turnActionPromisePhase: 6 });
|
||||
assert.match(markup(game), /Play Residual Poison against Asker/); assert.match(markup(game), /the native leader death is random/);
|
||||
});
|
||||
|
||||
void test('Residual Poison rejects blocked No quotes, stale battles, false targets and victim selectors without generic fallback', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.residualPoison = null; },
|
||||
(game: GameView) => { game.residualPoison = { ...game.residualPoison! }; },
|
||||
(game: GameView) => { game.residualPoison!.blocked = 'A binding No prevents this printed use.'; },
|
||||
(game: GameView) => { game.residualPoison!.event = 'next-battle'; },
|
||||
(game: GameView) => { game.battle!.revealed = true; },
|
||||
(game: GameView) => { game.battle!.event = 'next-battle'; },
|
||||
(game: GameView) => { game.battle!.defender = 'r'; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||
(game: GameView) => { game.turnActionPromises[0].mixed = planClaim(); },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
]) {
|
||||
const game = residualPlay(), sent: Action[] = [], 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 extra of [{ event: 'old-battle' }, { target: 'p' }, { target: 'r' }, { leader: 'selected-victim' },
|
||||
{ victim: 'selected-victim' }, { amount: 1 }, { mode: 'poison' }, { random: 0 }]) {
|
||||
const game = residualPlay(); Object.assign(game.turnActionGuide!.action, extra);
|
||||
const button = guide({ game, busy: false, act() { assert.fail('Only the actual printed Residual Poison packet is allowed'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const no = residualPlay(); no.turnActionPromises[0].answer = false; no.residualPoison!.blocked = 'A binding No prevents this printed use.';
|
||||
assert.equal(guide({ game: no, busy: false, act() { assert.fail('Generic preparation cannot bypass the native No quote'); } }).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'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho'), printedCard('semutaDrug')] }] };
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho'), printedCard('semutaDrug'), printedCard('ornithopter'), printedCard('residualPoison')] }] };
|
||||
game.turnActionPromises[0].answer = false;
|
||||
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
|
||||
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
@@ -1689,6 +1813,10 @@ void test('printed-card history retains only raw aggregate conditions and their
|
||||
assert.match(html, /The deadline is the actual turn end, including for a whole No answer/);
|
||||
assert.match(html, /Semuta Drug counts only when its native transfer and disposal actually complete after the answer/);
|
||||
assert.match(html, /A committed private choice cannot be canceled/);
|
||||
assert.match(html, /Ornithopter counts its actual physical flight declaration after the answer/);
|
||||
assert.match(html, /Later groups and disposal do not count again/);
|
||||
assert.match(html, /Residual Poison counts the actual printed play, random leader death and disposal after the answer/);
|
||||
assert.match(html, /Its whole No deadline is the original turn end/);
|
||||
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
|
||||
});
|
||||
|
||||
@@ -1720,8 +1848,6 @@ void test('turn-action guide forwards exact shipment, movement, preparation and
|
||||
projected().turnActionGuide!.action,
|
||||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 },
|
||||
{ type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 },
|
||||
{ type: 'move', territory: 'carthag', sector: 11, forces: { 'arrakeen:10': 4 }, eliteForces: {},
|
||||
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' },
|
||||
{ type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' },
|
||||
{ type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' },
|
||||
];
|
||||
|
||||
@@ -94,6 +94,26 @@ void test('Semuta future-use questions require completed physical exchange witho
|
||||
}
|
||||
});
|
||||
|
||||
void test('Ornithopter and Residual Poison questions name actual native play and the proper deadline without chosen details', () => {
|
||||
const game = fixture();
|
||||
for (const effect of ['ornithopter', 'residualPoison'] as const) {
|
||||
const action = { kind: 'cardPlay' as const, effect };
|
||||
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
|
||||
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
|
||||
{ kind: 'action', action }] } };
|
||||
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
|
||||
const text = truthQuestionText(question, id => id, id => id);
|
||||
assert.match(text, effect === 'ornithopter' ? /play Ornithopter into a native flight after this answer before your Shipment and Movement turn ends/ :
|
||||
/play Residual Poison in its printed role after this answer this turn/);
|
||||
assert.doesNotMatch(text, /twoGroups|private-leader|killed|Semuta|Ghola/);
|
||||
for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) {
|
||||
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
|
||||
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
|
||||
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
|
||||
g.players.push(
|
||||
|
||||
@@ -1693,7 +1693,7 @@ void test('optional Box stage preserves legacy bytes and strict raw folded metad
|
||||
|
||||
void test('each optional printed stage subset preserves legacy and Box bytes while rejecting unknown choice metadata', () => {
|
||||
const base = cards(card('ghola'), false);
|
||||
const optional = ['nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'] as const;
|
||||
const optional = PRINTED_CARD_EFFECTS.filter(effect => effect !== 'hajr' && effect !== 'ghola');
|
||||
for (let mask = 0; mask < (1 << optional.length); mask++) {
|
||||
const cardPlays = { ...base.cardPlays! };
|
||||
optional.forEach((effect, index) => { if (mask & (1 << index)) cardPlays[effect] = null; });
|
||||
@@ -1807,3 +1807,59 @@ void test('Semuta No remains binding through other deadlines and physical use or
|
||||
assert.deepEqual(used[0].payments, []);
|
||||
validateTurnActionPromises([expired], seats);
|
||||
});
|
||||
|
||||
void test('Movement end closes only the owner Ornithopter interval while Residual Poison retains its actual turn deadline', () => {
|
||||
const no = cards(group('or', card('ornithopter'), card('residualPoison')), false);
|
||||
assert.equal(closeTurnActionTransport([no], 'other', 1)[0], no);
|
||||
assert.equal(closeTurnActionTransport([no], 'respondent', 2)[0], no);
|
||||
assert.throws(() => recordTurnActionCardPlay([no], 'respondent', 1, 'ornithopter'), TurnActionError);
|
||||
const moved = closeTurnActionTransport([no], 'respondent', 1);
|
||||
assert.deepEqual(moved[0].cardPlays!.ornithopter, { played: false, closed: true });
|
||||
assert.deepEqual(moved[0].cardPlays!.residualPoison, { played: false, closed: false });
|
||||
assert.equal(moved[0].fulfilled, false);
|
||||
assert.equal(recordTurnActionCardPlay(moved, 'respondent', 1, 'ornithopter')[0], moved[0]);
|
||||
assert.throws(() => recordTurnActionCardPlay(moved, 'respondent', 1, 'residualPoison'), TurnActionError);
|
||||
const paidClose = closeTurnActionPayments(moved, 'respondent', 1);
|
||||
assert.equal(paidClose[0].fulfilled, false);
|
||||
const expired = finishTurnActionTurn(paidClose, 'respondent', 1)[0];
|
||||
assert.equal(expired.fulfilled, true);
|
||||
assert.deepEqual(expired.cardPlays!.residualPoison, { played: false, closed: true });
|
||||
const orniOnly = cards(card('ornithopter'), false, 'turn-action:2');
|
||||
const closed = closeTurnActionTransport([orniOnly], 'respondent', 1)[0];
|
||||
assert.equal(closed.fulfilled, true);
|
||||
assert.equal(recordTurnActionCardPlay([closed], 'respondent', 1, 'ornithopter')[0], closed);
|
||||
const old = cards(card('ghola'), false, 'turn-action:3');
|
||||
assert.equal(Object.hasOwn(closeTurnActionTransport([old], 'respondent', 1)[0].cardPlays!, 'ornithopter'), false);
|
||||
const explicitNull = { ...old, cardPlays: { ...old.cardPlays!, ornithopter: null } };
|
||||
assert.equal(closeTurnActionTransport([explicitNull], 'respondent', 1)[0].cardPlays!.ornithopter, null);
|
||||
validateTurnActionPromises([expired, closed], seats);
|
||||
});
|
||||
|
||||
void test('Ornithopter and Residual Poison whole receipts preserve dynamic alternatives and reject selected modes or victims', () => {
|
||||
const joint = cards(group('and', card('ornithopter'), card('residualPoison'), pay()));
|
||||
const alternative = cards(group('or', card('ornithopter'), card('residualPoison')), true, 'turn-action:2');
|
||||
for (const effect of ['ornithopter', 'residualPoison'] as const) {
|
||||
for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) {
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect, ...extra }), TurnActionError);
|
||||
const invalid = { ...joint, cardPlays: { ...joint.cardPlays!, [effect]: { played: false, closed: false, ...extra } } };
|
||||
assert.throws(() => turnActionValue(invalid), TurnActionError);
|
||||
}
|
||||
}
|
||||
const flight = recordTurnActionCardPlay([joint, alternative], 'respondent', 1, 'ornithopter');
|
||||
assert.equal(flight[1].fulfilled, true);
|
||||
assert.deepEqual(branches(flight[0]), [[{ kind: 'cardPlay', effect: 'residualPoison', answer: true }, signedPay()]]);
|
||||
assert.equal(recordTurnActionCardPlay(flight, 'respondent', 1, 'ornithopter')[0], flight[0]);
|
||||
const moved = closeTurnActionTransport(flight, 'respondent', 1);
|
||||
const poisoned = recordTurnActionCardPlay(moved, 'respondent', 1, 'residualPoison');
|
||||
assert.deepEqual(branches(poisoned[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(poisoned, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const no = cards(group('and', card('ornithopter'), card('residualPoison')), false, 'turn-action:3');
|
||||
const one = recordTurnActionCardPlay([no], 'respondent', 1, 'ornithopter');
|
||||
const before = structuredClone(one);
|
||||
assert.throws(() => recordTurnActionCardPlay(one, 'respondent', 1, 'residualPoison'), TurnActionError);
|
||||
assert.deepEqual(one, before);
|
||||
const fresh = cards(card('ornithopter'), true, 'turn-action:4');
|
||||
assert.equal(fresh.cardPlays!.ornithopter!.played, false);
|
||||
assert.equal(recordTurnActionCardPlay([fresh], 'respondent', 1, 'ornithopter')[0].fulfilled, true);
|
||||
validateTurnActionPromises(poisoned, seats);
|
||||
});
|
||||
Reference in new issue
Block a user