Add current concealed No-Field Truthtrance facts
This commit is contained in:
1 parent
24c38ebccd
commit
5509abf1e5
13 files changed
+797
-12
No files matched your search
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import {
|
||||
applyNoFieldFullPlanStep, finishNoFieldFullPlanBattle,
|
||||
} from './fixture-no-field-full-plan';
|
||||
import { noFieldFullPlanOccupyFixture } from './fixture-no-field-full-plan-occupy';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import {
|
||||
askNoFieldTruth, createTruthtranceNoFieldFixture, declareNoFieldTruthtrance,
|
||||
finishNoFieldTruth, noFieldTruthFact, noFieldTruthObservation, passNoFieldTruthPriority,
|
||||
rejectNoFieldTruthAction, stageNoFieldTruthtrance, truthtranceNoFieldCards,
|
||||
truthtranceNoFieldPlayer,
|
||||
} from './fixture-truthtrance-no-field';
|
||||
|
||||
const VALUES: NoFieldValue[] = [0, 3, 5];
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const marker of VALUES) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native Truthtrance reads deployed printed ${marker} without touching its token or forces`, () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ advanced, marker });
|
||||
const { game, owner, asker, token } = fixture;
|
||||
const original = truthtranceNoFieldPlayer(game, owner);
|
||||
assert.equal(original.noField!.deployed!.tokenId, token.id);
|
||||
for (const value of VALUES) {
|
||||
const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(value));
|
||||
const expected = marker === value ? 'yes' : 'no';
|
||||
assert.deepEqual(noFieldTruthObservation(waiting), noFieldTruthObservation(game));
|
||||
for (const actor of game.players.map(player => player.id)) {
|
||||
const view = viewGame(waiting, actor);
|
||||
assert.equal(view.truthAnswer, actor === owner ? expected : null);
|
||||
if (actor !== owner) {
|
||||
assert.equal(view.richeseNoField!.private, null);
|
||||
assert.deepEqual(view.richeseNoField!.public, viewGame(game, actor).richeseNoField!.public);
|
||||
assert.equal(view.players.find(player => player.id === owner)!.hand, undefined);
|
||||
}
|
||||
}
|
||||
const done = finishNoFieldTruth(waiting, expected);
|
||||
assert.equal(done.discard.filter(card => card.id === fixture.truthCard.id).length, 1);
|
||||
for (const actor of done.players.map(player => player.id)) {
|
||||
assert.deepEqual(viewGame(done, actor).truthHistory.at(-1), done.truthHistory!.at(-1));
|
||||
assert.equal(viewGame(done, actor).truthAnswer, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const reserves of [2, 0]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} printed five remains five with ${reserves} physical reserves`, () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ advanced, marker: 5, reserves });
|
||||
const { game, asker, owner } = fixture;
|
||||
assert.equal(truthtranceNoFieldPlayer(game, owner).reserves, reserves);
|
||||
for (const value of [5, 0] as const) {
|
||||
const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(value));
|
||||
finishNoFieldTruth(waiting, value === 5 ? 'yes' : 'no');
|
||||
}
|
||||
const observed = finishNoFieldTruth(askNoFieldTruth(game, asker, owner, noFieldTruthFact(5)), 'yes');
|
||||
const history = structuredClone(observed.truthHistory);
|
||||
const before = truthtranceNoFieldPlayer(observed, owner);
|
||||
const exposed = applyAction(observed, owner, {
|
||||
type: 'revealNoField', event: before.noFieldEvent, token: fixture.token.id,
|
||||
});
|
||||
const after = truthtranceNoFieldPlayer(exposed, owner);
|
||||
assert.equal(after.noField!.deployed, null);
|
||||
assert.equal(after.noField!.lastShipped, fixture.token.id);
|
||||
assert.deepEqual(after.noField!.tokens, before.noField!.tokens);
|
||||
assert.notEqual(after.noFieldEvent, before.noFieldEvent);
|
||||
assert.equal(after.reserves, 0);
|
||||
assert.equal(after.forces[fixture.harass!.location] ?? 0, reserves);
|
||||
assert.deepEqual(exposed.truthHistory, history);
|
||||
assert.equal(viewGame(exposed, asker).richeseNoField!.public.lastUsed, 5);
|
||||
assert.deepEqual(truthtranceNoFieldCards(exposed), truthtranceNoFieldCards(observed));
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} absent, never-shipped and already-revealed markers answer No, not zero or Unknown`, () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ advanced, marker: 3, boundary: 'beforeShipment' });
|
||||
const { game, asker, owner, opponent } = fixture;
|
||||
assert.equal(truthtranceNoFieldPlayer(game, owner).noField!.deployed, null);
|
||||
assert.equal(truthtranceNoFieldPlayer(game, owner).noField!.lastShipped, null);
|
||||
assert.equal(truthtranceNoFieldPlayer(game, opponent).noField, undefined);
|
||||
for (const target of [owner, opponent])
|
||||
for (const value of VALUES)
|
||||
finishNoFieldTruth(askNoFieldTruth(game, asker, target, noFieldTruthFact(value)), 'no');
|
||||
|
||||
// The original planned shipment remains executable, with its original token/event.
|
||||
const done = finishNoFieldTruth(askNoFieldTruth(game, asker, owner, noFieldTruthFact(3)), 'no');
|
||||
const shipment = fixture.harass!.shipmentStep;
|
||||
let shipped = applyAction(done, shipment.actor, shipment.action);
|
||||
// Advanced still owns its original Guild interception before physical deployment.
|
||||
for (let count = 0; (shipped.response || shipped.phaseOpening || shipped.decision) && count < 40; count++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(shipped);
|
||||
assert.ok(step);
|
||||
shipped = applyAction(shipped, step.actor, step.action);
|
||||
}
|
||||
assert.equal(shipped.decision, null);
|
||||
assert.equal(truthtranceNoFieldPlayer(shipped, owner).noField!.deployed!.tokenId, fixture.token.id);
|
||||
assert.equal(shipped.response, null);
|
||||
const concealed = truthtranceNoFieldPlayer(shipped, owner);
|
||||
const revealed = applyAction(shipped, owner, {
|
||||
type: 'revealNoField', event: concealed.noFieldEvent, token: fixture.token.id,
|
||||
});
|
||||
assert.equal(truthtranceNoFieldPlayer(revealed, owner).noField!.deployed, null);
|
||||
assert.equal(truthtranceNoFieldPlayer(revealed, owner).noField!.lastShipped, fixture.token.id);
|
||||
assert.equal(viewGame(revealed, asker).richeseNoField!.public.lastUsed, 3);
|
||||
stageNoFieldTruthtrance(revealed, asker, fixture.staging);
|
||||
for (const value of VALUES)
|
||||
finishNoFieldTruth(askNoFieldTruth(revealed, asker, owner, noFieldTruthFact(value)), 'no');
|
||||
});
|
||||
}
|
||||
|
||||
void test('a nonowner respondent cannot borrow the concealed token from their Richese ally', () => {
|
||||
const fixture = noFieldFullPlanOccupyFixture({ lead: 'ecaz', marker: 5, richeseReserves: 2 });
|
||||
const { game, richese: owner, ecaz: ally, atreides: asker } = fixture;
|
||||
assert.equal(truthtranceNoFieldPlayer(game, owner).ally, ally);
|
||||
assert.equal(truthtranceNoFieldPlayer(game, ally).ally, owner);
|
||||
stageNoFieldTruthtrance(game, asker, fixture.staging);
|
||||
assert.equal(truthtranceNoFieldPlayer(game, ally).noField, undefined);
|
||||
for (const value of [0, 5] as const) {
|
||||
const waiting = askNoFieldTruth(game, asker, ally, noFieldTruthFact(value));
|
||||
assert.equal(viewGame(waiting, ally).truthAnswer, 'no');
|
||||
finishNoFieldTruth(waiting, 'no');
|
||||
}
|
||||
finishNoFieldTruth(askNoFieldTruth(game, asker, owner, noFieldTruthFact(5)), 'yes');
|
||||
});
|
||||
|
||||
void test('dishonest and Unknown answers retain the original current fact and parent opportunity until the truthful public answer', () => {
|
||||
for (const boundary of ['beforeShipment', 'afterShipment'] as const) {
|
||||
const fixture = createTruthtranceNoFieldFixture({ marker: 0, boundary });
|
||||
const { game, owner, asker } = fixture;
|
||||
const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(0));
|
||||
const expected = boundary === 'beforeShipment' ? 'no' : 'yes';
|
||||
rejectNoFieldTruthAction(waiting, owner, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' });
|
||||
rejectNoFieldTruthAction(waiting, owner, { type: 'truthAnswer', answer: 'unknown' });
|
||||
rejectNoFieldTruthAction(waiting, owner, { type: 'truthSave' });
|
||||
const native = fixture.harass!;
|
||||
rejectNoFieldTruthAction(waiting, native.shipmentStep.actor, native.shipmentStep.action);
|
||||
assert.equal(waiting.truthtrance!.stage, 'answer');
|
||||
assert.equal(waiting.truthHistory?.length ?? 0, 0);
|
||||
assert.ok(truthtranceNoFieldPlayer(waiting, asker).hand.some(card => card.id === fixture.truthCard.id));
|
||||
const done = finishNoFieldTruth(waiting, expected);
|
||||
if (boundary === 'beforeShipment') {
|
||||
const shipped = applyAction(done, native.shipmentStep.actor, native.shipmentStep.action);
|
||||
assert.equal(truthtranceNoFieldPlayer(shipped, owner).noField!.deployed!.tokenId, fixture.token.id);
|
||||
} else {
|
||||
const player = truthtranceNoFieldPlayer(done, owner);
|
||||
const revealed = applyAction(done, owner, { type: 'revealNoField', event: player.noFieldEvent, token: fixture.token.id });
|
||||
assert.equal(truthtranceNoFieldPlayer(revealed, owner).noField!.deployed, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('invalid denomination and extra private selection fields cannot replace the live asking opportunity', () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ marker: 3 });
|
||||
const waiting = passNoFieldTruthPriority(declareNoFieldTruthtrance(fixture.game, fixture.asker));
|
||||
for (const fact of [
|
||||
{ kind: 'noFieldValue', value: '3' },
|
||||
{ kind: 'noFieldValue', value: 1 },
|
||||
{ kind: 'noFieldValue', value: 3, token: fixture.token.id },
|
||||
]) {
|
||||
rejectNoFieldTruthAction(waiting, fixture.asker, {
|
||||
type: 'truthAsk', question: { kind: 'fact', target: fixture.owner, fact },
|
||||
});
|
||||
}
|
||||
finishNoFieldTruth(askNoFieldTruth(waiting, fixture.asker, fixture.owner, noFieldTruthFact(3)), 'yes');
|
||||
});
|
||||
|
||||
void test('AND/OR publish only the aggregate answer, never matching leaves or physical No-Field identity', () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ advanced: true, marker: 5, reserves: 0 });
|
||||
const { game, owner, asker } = fixture;
|
||||
const rows: { fact: TruthFact; answer: 'yes' | 'no' }[] = [
|
||||
{ fact: { kind: 'or', terms: [noFieldTruthFact(0), noFieldTruthFact(3)] }, answer: 'no' },
|
||||
{ fact: { kind: 'or', terms: [noFieldTruthFact(3), noFieldTruthFact(5)] }, answer: 'yes' },
|
||||
{ fact: { kind: 'and', terms: [noFieldTruthFact(5), { kind: 'spice', compare: 'gte', value: 0 }] }, answer: 'yes' },
|
||||
{ fact: { kind: 'and', terms: [noFieldTruthFact(5), noFieldTruthFact(0)] }, answer: 'no' },
|
||||
];
|
||||
for (const { fact, answer } of rows) {
|
||||
const waiting = askNoFieldTruth(game, asker, owner, fact);
|
||||
assert.equal(viewGame(waiting, owner).truthAnswer, answer);
|
||||
for (const other of game.players.filter(player => player.id !== owner)) {
|
||||
const view = viewGame(waiting, other.id);
|
||||
assert.equal(view.truthAnswer, null);
|
||||
assert.deepEqual(view.truthHistory, []);
|
||||
assert.equal(view.richeseNoField!.private, null);
|
||||
assert.deepEqual(view.richeseNoField!.public, viewGame(game, other.id).richeseNoField!.public);
|
||||
assert.equal(view.players.find(player => player.id === owner)!.hand, undefined);
|
||||
}
|
||||
const done = finishNoFieldTruth(waiting, answer);
|
||||
const receipt = done.truthHistory!.at(-1)!;
|
||||
assert.deepEqual(receipt.question, { kind: 'fact', target: owner, fact });
|
||||
assert.equal(JSON.stringify(receipt).includes(fixture.token.id), false);
|
||||
for (const actor of done.players.map(player => player.id)) {
|
||||
assert.deepEqual(viewGame(done, actor).truthHistory, [receipt]);
|
||||
if (actor !== owner) {
|
||||
assert.equal(viewGame(done, actor).richeseNoField!.private, null);
|
||||
assert.deepEqual(viewGame(done, actor).richeseNoField!.public, viewGame(game, actor).richeseNoField!.public);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a native full-plan offer survives the fact interrupt and later genuine battle changes custody without rewriting the answer', () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ advanced: true, marker: 5, reserves: 2, boundary: 'fullPlanOffer' });
|
||||
const native = fixture.fullPlan!;
|
||||
const { game, owner, asker } = fixture;
|
||||
assert.equal(game.decision?.kind, 'fullPlanOffer');
|
||||
assert.equal(truthtranceNoFieldPlayer(game, asker).hand.length, 4);
|
||||
const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(5));
|
||||
rejectNoFieldTruthAction(waiting, native.declineStep.actor, native.declineStep.action);
|
||||
const answered = finishNoFieldTruth(waiting, 'yes');
|
||||
assert.deepEqual(answered.decision, game.decision);
|
||||
assert.deepEqual(answered.battle, game.battle);
|
||||
const history = structuredClone(answered.truthHistory);
|
||||
let continued = applyNoFieldFullPlanStep(answered, native.declineStep);
|
||||
assert.equal(continued.decision, null);
|
||||
for (const step of native.planActions) continued = applyNoFieldFullPlanStep(continued, step);
|
||||
assert.equal(continued.battle!.revealed, true);
|
||||
const exposed = truthtranceNoFieldPlayer(continued, owner);
|
||||
const original = truthtranceNoFieldPlayer(game, owner);
|
||||
assert.equal(exposed.noField!.deployed, null);
|
||||
assert.equal(exposed.noField!.lastShipped, fixture.token.id);
|
||||
assert.deepEqual(exposed.noField!.tokens, original.noField!.tokens);
|
||||
assert.notEqual(exposed.noFieldEvent, original.noFieldEvent);
|
||||
assert.equal(exposed.reserves, 0);
|
||||
assert.equal(exposed.forces[native.location], 2);
|
||||
assert.deepEqual(continued.truthHistory, history);
|
||||
const done = finishNoFieldFullPlanBattle(continued);
|
||||
assert.equal(done.battle, null);
|
||||
assert.deepEqual(done.truthHistory, history);
|
||||
assert.equal(truthtranceNoFieldPlayer(done, owner).noField!.deployed, null);
|
||||
assert.deepEqual(truthtranceNoFieldCards(done), truthtranceNoFieldCards(game));
|
||||
assert.equal(done.discard.filter(card => card.id === fixture.truthCard.id).length, 1);
|
||||
});
|
||||
|
||||
void test('all four bot profiles pass real priority and reply legally using only their private aggregate truthAnswer', () => {
|
||||
const fixture = createTruthtranceNoFieldFixture({ advanced: true, marker: 5, reserves: 0 });
|
||||
const { game, owner, asker, opponent } = fixture;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let current = declareNoFieldTruthtrance(game, asker);
|
||||
const priorityView = viewGame(current, owner);
|
||||
priorityView.players.find(player => player.id === owner)!.bot = difficulty;
|
||||
const priority = botActions(priorityView).find(action => action.type === 'truthPass');
|
||||
assert.ok(priority);
|
||||
current = applyAction(current, owner, priority);
|
||||
current = passNoFieldTruthPriority(current);
|
||||
for (const value of [5, 0] as const) {
|
||||
const pending = askNoFieldTruth(current, asker, owner, noFieldTruthFact(value));
|
||||
const view = viewGame(pending, owner);
|
||||
view.players.find(player => player.id === owner)!.bot = difficulty;
|
||||
const replies = botActions(view);
|
||||
assert.equal(view.truthAnswer, value === 5 ? 'yes' : 'no');
|
||||
const reply = replies.find(action => action.type === 'truthAnswer');
|
||||
assert.ok(reply);
|
||||
const otherView = viewGame(pending, opponent);
|
||||
otherView.players.find(player => player.id === opponent)!.bot = difficulty;
|
||||
assert.ok(botActions(otherView).every(action => action.type !== 'truthAnswer'));
|
||||
const replied = applyAction(pending, owner, reply);
|
||||
assert.equal(replied.truthHistory?.at(-1)?.answer, value === 5 ? 'yes' : 'no');
|
||||
assert.equal(replied.truthtrance, null);
|
||||
assert.deepEqual(noFieldTruthObservation(replied), noFieldTruthObservation(game));
|
||||
assert.deepEqual(truthtranceNoFieldCards(replied), truthtranceNoFieldCards(game));
|
||||
assert.equal(replied.discard.filter(card => card.id === fixture.truthCard.id).length, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createRicheseNoField,
|
||||
deployRicheseNoField,
|
||||
projectRicheseNoField,
|
||||
revealRicheseNoField,
|
||||
type NoFieldValue,
|
||||
type RicheseNoField,
|
||||
} from '../game/richese-no-field';
|
||||
import {
|
||||
noFieldValueFactMatches,
|
||||
parseNoFieldValueFact,
|
||||
type NoFieldValueFact,
|
||||
} from '../game/truthtrance-no-field-value';
|
||||
|
||||
function fact(value: NoFieldValue): NoFieldValueFact {
|
||||
return { kind: 'noFieldValue', value };
|
||||
}
|
||||
|
||||
function inventory() {
|
||||
return createRicheseNoField(['token-zero', 'token-three', 'token-five']);
|
||||
}
|
||||
|
||||
function deployed(value: NoFieldValue) {
|
||||
const state = inventory();
|
||||
return deployRicheseNoField(state, {
|
||||
tokenId: state.tokens.find((token) => token.value === value)!.id,
|
||||
controller: 'richese-player',
|
||||
location: { territory: 'arrakeen', sector: 10 },
|
||||
});
|
||||
}
|
||||
|
||||
void test('only a deployed physical token answers Yes for its exact printed denomination without altering custody', () => {
|
||||
for (const printedValue of [0, 3, 5] as const) {
|
||||
const state = deployed(printedValue);
|
||||
const before = structuredClone(state);
|
||||
assert.equal(
|
||||
noFieldValueFactMatches(
|
||||
state,
|
||||
parseNoFieldValueFact({ kind: 'noFieldValue', value: printedValue }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
noFieldValueFactMatches(state, fact(printedValue === 5 ? 3 : 5)),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('no inventory or undeployed inventory is No, not a concealed zero', () => {
|
||||
const state = inventory();
|
||||
assert.equal(noFieldValueFactMatches(undefined, fact(0)), false);
|
||||
assert.equal(noFieldValueFactMatches(undefined, fact(5)), false);
|
||||
assert.equal(noFieldValueFactMatches(state, fact(0)), false);
|
||||
assert.equal(noFieldValueFactMatches(state, fact(3)), false);
|
||||
assert.equal(noFieldValueFactMatches(deployed(0), fact(0)), true);
|
||||
});
|
||||
|
||||
void test('printed five is independent of capped or exhausted reserves and revelation ends the fact despite last-use history', () => {
|
||||
const state = deployed(5);
|
||||
const before = structuredClone(state);
|
||||
for (const reserves of [2, 0]) {
|
||||
assert.equal(noFieldValueFactMatches(state, fact(5)), true);
|
||||
const revealed = revealRicheseNoField(state, {
|
||||
tokenId: state.deployed!.tokenId,
|
||||
reserves,
|
||||
cause: 'voluntary',
|
||||
});
|
||||
assert.equal(revealed.forces, reserves);
|
||||
assert.equal(revealed.value, 5);
|
||||
assert.equal(projectRicheseNoField(revealed.state, false).lastUsed, 5);
|
||||
assert.equal(noFieldValueFactMatches(revealed.state, fact(5)), false);
|
||||
assert.equal(noFieldValueFactMatches(revealed.state, fact(0)), false);
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('observation rejects malformed physical custody instead of treating it as absence', () => {
|
||||
const state = deployed(3);
|
||||
const malformed: RicheseNoField[] = [
|
||||
{ ...state, tokens: [] },
|
||||
{
|
||||
...state,
|
||||
tokens: state.tokens.map((token) => ({ ...token, value: 3 as const })),
|
||||
},
|
||||
{
|
||||
...state,
|
||||
deployed: { ...state.deployed!, tokenId: 'not-a-physical-token' },
|
||||
},
|
||||
{ ...state, lastShipped: 'token-five' },
|
||||
{
|
||||
...state,
|
||||
deployed: {
|
||||
...state.deployed!,
|
||||
location: { territory: 'arrakeen', sector: -1 },
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const invalid of malformed)
|
||||
assert.throws(() => noFieldValueFactMatches(invalid, fact(3)));
|
||||
assert.throws(() =>
|
||||
noFieldValueFactMatches({ ...inventory(), tokens: [] }, fact(0)),
|
||||
);
|
||||
});
|
||||
|
||||
void test('question input requires exactly the fact kind and a numeric printed denomination', () => {
|
||||
for (const invalid of [
|
||||
null,
|
||||
[],
|
||||
'noFieldValue',
|
||||
{ kind: 'noFieldValue' },
|
||||
{ value: 3 },
|
||||
{ kind: 'forceCount', value: 3 },
|
||||
{ kind: 'noFieldValue', value: 3, tokenId: 'token-three' },
|
||||
{ kind: 'noFieldValue', value: 3, reserves: 0 },
|
||||
])
|
||||
assert.throws(() => parseNoFieldValueFact(invalid));
|
||||
for (const value of ['0', '3', '5', true, false, null, undefined, NaN, Infinity, -1, 1, 3.5])
|
||||
assert.throws(() => parseNoFieldValueFact({ kind: 'noFieldValue', value }));
|
||||
});
|
||||
Reference in new issue
Block a user