176 lines
8.6 KiB
TypeScript
176 lines
8.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import type { Card } from '../game/cards';
|
|
import type { NoFieldValue } from '../game/richese-no-field';
|
|
import type { TruthFact } from '../game/truthtrance';
|
|
import {
|
|
createHarassNoFieldFixture, type HarassNoFieldFixture,
|
|
} from './fixture-harass-no-field';
|
|
import {
|
|
createNoFieldFullPlanFixture, type NoFieldFullPlanFixture,
|
|
} from './fixture-no-field-full-plan';
|
|
|
|
export type TruthtranceNoFieldOptions = {
|
|
advanced?: boolean;
|
|
marker?: NoFieldValue;
|
|
reserves?: number;
|
|
boundary?: 'beforeShipment' | 'afterShipment' | 'battle' | 'fullPlanOffer';
|
|
};
|
|
export type TruthtranceNoFieldFixture = {
|
|
game: Game;
|
|
owner: string;
|
|
asker: string;
|
|
opponent: string;
|
|
observer: string;
|
|
token: { id: string; value: NoFieldValue };
|
|
truthCard: Card;
|
|
staging: string[];
|
|
harass: HarassNoFieldFixture | null;
|
|
fullPlan: NoFieldFullPlanFixture | null;
|
|
};
|
|
export function truthtranceNoFieldPlayer(game: Game, actor: string) {
|
|
const player = game.players.find(candidate => candidate.id === actor);
|
|
assert.ok(player, `Missing original No-Field seat ${actor}`);
|
|
return player;
|
|
}
|
|
|
|
/** Physical inventory at these settled native boundaries; no catalog is redealt. */
|
|
export function truthtranceNoFieldCards(game: Game) {
|
|
return [
|
|
...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
|
...game.players.flatMap(player => player.hand),
|
|
].map(card => card.id).sort();
|
|
}
|
|
|
|
/** Conserved hand staging after genuine setup, never a replacement or invented card. */
|
|
export function stageNoFieldTruthtrance(game: Game, actor: string, staging: string[]): Card {
|
|
const inventory = truthtranceNoFieldCards(game);
|
|
const player = truthtranceNoFieldPlayer(game, actor);
|
|
const held = player.hand.find(card => card.effect === 'truthtrance');
|
|
if (held) return structuredClone(held);
|
|
const source = [game.deck, ...game.players.filter(candidate => candidate.id !== actor).map(candidate => candidate.hand)]
|
|
.find(cards => cards.some(card => card.effect === 'truthtrance'));
|
|
assert.ok(source, 'Relocate an originally selected, unused physical Truthtrance');
|
|
if (player.hand.length === 4) {
|
|
const returned = player.hand.pop()!;
|
|
game.deck.push(returned);
|
|
staging.push(`After genuine setup: conserve ${returned.id} by returning it from ${actor}'s full hand to the original deck.`);
|
|
}
|
|
assert.ok(player.hand.length < 4);
|
|
const card = source.splice(source.findIndex(candidate => candidate.effect === 'truthtrance'), 1)[0];
|
|
player.hand.push(card);
|
|
staging.push(`After genuine setup: conserve original physical Truthtrance ${card.id} by relocating it into ${actor}'s hand; no hand exceeds four.`);
|
|
assert.deepEqual(truthtranceNoFieldCards(game), inventory);
|
|
return structuredClone(card);
|
|
}
|
|
|
|
/** All windows and marker custody come from the original setup/shipment/battle actions. */
|
|
export function createTruthtranceNoFieldFixture(options: TruthtranceNoFieldOptions = {}): TruthtranceNoFieldFixture {
|
|
const boundary = options.boundary ?? 'afterShipment';
|
|
const harass = boundary === 'fullPlanOffer' ? null : createHarassNoFieldFixture({
|
|
advanced: options.advanced ?? false, marker: options.marker, reserves: options.reserves,
|
|
});
|
|
if (boundary === 'fullPlanOffer') assert.notEqual(options.advanced, false, 'The native full-plan offer is Advanced');
|
|
const fullPlan = boundary === 'fullPlanOffer' ? createNoFieldFullPlanFixture({
|
|
marker: options.marker, reserves: options.reserves,
|
|
}) : null;
|
|
const native = harass ?? fullPlan!;
|
|
const source = boundary === 'beforeShipment' ? native.beforeShipment
|
|
: boundary === 'afterShipment' ? native.afterShipment : native.game;
|
|
const game = structuredClone(source);
|
|
const asker = fullPlan ? fullPlan.atreides : harass!.observer;
|
|
const staging = [...native.staging];
|
|
const truthCard = stageNoFieldTruthtrance(game, asker, staging);
|
|
const inventory = truthtranceNoFieldCards(game);
|
|
assert.equal(new Set(inventory).size, inventory.length);
|
|
assert.deepEqual(inventory, truthtranceNoFieldCards(native.afterSetup));
|
|
for (const player of game.players) {
|
|
assert.ok(player.hand.length <= 4);
|
|
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
|
|
}
|
|
return {
|
|
game, owner: native.owner, asker, opponent: native.opponent, observer: native.observer,
|
|
token: structuredClone(native.token), truthCard, staging, harass, fullPlan,
|
|
};
|
|
}
|
|
|
|
export function noFieldTruthFact(value: NoFieldValue): TruthFact {
|
|
return { kind: 'noFieldValue', value };
|
|
}
|
|
export function declareNoFieldTruthtrance(game: Game, asker: string): Game {
|
|
const card = truthtranceNoFieldPlayer(game, asker).hand.find(candidate => candidate.effect === 'truthtrance');
|
|
assert.ok(card);
|
|
return applyAction(game, asker, { type: 'card', card: card.id });
|
|
}
|
|
export function passNoFieldTruthPriority(game: Game): Game {
|
|
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
|
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id));
|
|
assert.ok(actor);
|
|
game = applyAction(game, actor.id, { type: 'truthPass' });
|
|
}
|
|
assert.equal(game.truthtrance?.stage, 'ask');
|
|
return game;
|
|
}
|
|
export function askNoFieldTruth(game: Game, asker: string, target: string, fact: TruthFact): Game {
|
|
if (!game.truthtrance) game = passNoFieldTruthPriority(declareNoFieldTruthtrance(game, asker));
|
|
assert.equal(game.truthtrance?.stage, 'ask');
|
|
return applyAction(game, asker, { type: 'truthAsk', question: { kind: 'fact', target, fact } });
|
|
}
|
|
export function answerNoFieldTruth(game: Game, expected: 'yes' | 'no'): Game {
|
|
const question = game.truthtrance?.question;
|
|
assert.equal(game.truthtrance?.stage, 'answer');
|
|
assert.ok(question?.kind === 'fact');
|
|
assert.equal(viewGame(game, question.target).truthAnswer, expected);
|
|
return applyAction(game, question.target, { type: 'truthAnswer', answer: expected });
|
|
}
|
|
export function rejectNoFieldTruthAction(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
|
|
/** Everything observed must stay put, including live opportunities and prior shipment history. */
|
|
export function noFieldTruthObservation(game: Game) {
|
|
return structuredClone({
|
|
turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
|
movementRemaining: game.movementRemaining, response: game.response, decision: game.decision,
|
|
phaseOpening: game.phaseOpening, pendingShipment: game.pendingShipment, battle: game.battle,
|
|
shipmentPromises: game.shipmentPromises, richeseAllyOpportunity: game.richeseAllyOpportunity,
|
|
richeseAllyOffer: game.richeseAllyOffer,
|
|
players: game.players.map(player => ({
|
|
id: player.id, noField: player.noField, noFieldEvent: player.noFieldEvent,
|
|
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
|
spice: player.spice, shipped: player.shipped, moved: player.moved, revived: player.revived,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/** Finish the real public answer and its original physical discard, without driving the parent. */
|
|
export function finishNoFieldTruth(game: Game, expected: 'yes' | 'no'): Game {
|
|
const before = structuredClone(game);
|
|
const window = before.truthtrance!;
|
|
assert.equal(window.queue.length, 1);
|
|
assert.ok(window.question?.kind === 'fact');
|
|
const consumed = window.queue[0];
|
|
const card = truthtranceNoFieldPlayer(before, consumed.player).hand.find(candidate => candidate.id === consumed.card);
|
|
assert.ok(card?.effect === 'truthtrance');
|
|
const done = answerNoFieldTruth(game, expected);
|
|
assert.equal(done.truthtrance, null);
|
|
assert.ok(!done.pendingTreacheryDiscard);
|
|
assert.deepEqual(noFieldTruthObservation(done), noFieldTruthObservation(before));
|
|
assert.deepEqual(done.truthHistory, [...(before.truthHistory ?? []), {
|
|
turn: before.turn, phase: before.phase, asker: consumed.player,
|
|
question: window.question, answer: expected,
|
|
}]);
|
|
assert.deepEqual(done.deck, before.deck);
|
|
assert.deepEqual(done.richeseCache, before.richeseCache);
|
|
assert.deepEqual(done.richeseRemoved, before.richeseRemoved);
|
|
assert.deepEqual(done.discard, [...before.discard, card]);
|
|
for (const player of done.players) {
|
|
const original = truthtranceNoFieldPlayer(before, player.id);
|
|
assert.deepEqual(player.hand, original.hand.filter(candidate => candidate.id !== consumed.card));
|
|
}
|
|
assert.deepEqual(truthtranceNoFieldCards(done), truthtranceNoFieldCards(before));
|
|
return done;
|
|
}
|