305 lines
21 KiB
TypeScript
305 lines
21 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
|
|
import { createTruthTurnActionFixture } from './fixture-truth-turn-action';
|
|
import {
|
|
actTruthTurnCombat as act, advanceTruthTurnCombat as advance, answerTruthTurnCombat as answer,
|
|
askTruthTurnCombat as ask, bindTruthTurnCombat as bind, createTruthTurnCombatFixture as fixture,
|
|
declareTruthTurnCombat as declare, finishTruthTurnCombat as finish, finishTruthTurnCombatBattle as cleanup,
|
|
nextTruthTurnCombatNative as nativeStep, revealTruthTurnCombat as reveal, settleTruthTurnCombat as settle,
|
|
truthTurnCombatCall as call, truthTurnCombatClean as clean, truthTurnCombatFact as fact,
|
|
truthTurnCombatGroup as group, truthTurnCombatInventory as inventory, truthTurnCombatPayment as payment,
|
|
truthTurnCombatPlan as plan, truthTurnCombatPlayer as player, truthTurnCombatPromise as promise,
|
|
truthTurnCombatTraitors as traitors, type TruthTurnCombatFixture, type TruthTurnCombatStep,
|
|
} from './fixture-truth-turn-combat';
|
|
|
|
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: TruthTurnCombatFixture) {
|
|
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
|
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
|
}
|
|
|
|
void test('native combat adapters keep the original beforePlans and unsubmitted Traitor sources without installed played state', () => {
|
|
for (const source of ['plan', 'traitor', 'noMatch', 'harkonnen'] as const) {
|
|
const f = fixture({ source }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
|
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
|
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActionCount);
|
|
assert.equal(f.sourceActionCount, source === 'plan' ? 24 : source === 'harkonnen' ? 72 : 26);
|
|
assert.equal(f.originalCards.length, 33); assert.equal(f.game.advanced, false); assert.equal(f.game.nexusCards, undefined);
|
|
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
|
|
assert.ok(f.game.battle?.event); assert.equal(f.game.battle.event, f.event); assert.equal(f.game.battle.territory, f.territory);
|
|
assert.equal(f.game.battle.revealed, source !== 'plan'); assert.deepEqual(f.game.battle.traitorCalls, {});
|
|
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
|
|
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
|
if (source === 'noMatch') {
|
|
assert.notEqual(f.game.battle.plans[f.asker].leader, f.traitorHeld);
|
|
assert.ok(player(f.game, f.target).traitors.includes(f.traitorHeld));
|
|
assert.deepEqual(viewGame(f.game, f.target).battle!.traitorCallChoices, [false]);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('native plan AND payment freezes current spice and uses the exact originally held projectile through actual reveal', () => {
|
|
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary && f.planSource);
|
|
assert.equal(player(f.planSource.beforeShipment, f.target).spice - player(f.game, f.target).spice, 1);
|
|
assert.equal(player(f.game, f.target).spice, 9);
|
|
const mixed = group('and', fact(f.spiceFact), plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
|
|
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
|
|
assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.recipient).bribes, 2);
|
|
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
|
rejected(game, f.target, f.targetPlan);
|
|
const sealed = reveal(game, f, f.projectilePlan); game = sealed.game;
|
|
assert.equal(sealed.afterTargetPlan.battle?.revealed, false);
|
|
assert.equal(sealed.afterTargetPlan.turnActionPromises![0].combat?.plan?.closed, false);
|
|
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary);
|
|
assert.equal(player(game, f.target).spice, 7, 'Original Basic zero-dial/zero-support plans add no invented spice charge');
|
|
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: true }]);
|
|
assert.equal(game.turnActionPromises![0].combat?.plan?.closed, true);
|
|
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
|
|
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
|
});
|
|
|
|
void test('native plan OR payment preserves either manual exact-weapon plan or real payment route', () => {
|
|
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary);
|
|
const mixed = group('or', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
|
|
const bound = bind(f, 'yes', mixed).game;
|
|
let armed = reveal(bound, f, f.projectilePlan).game;
|
|
assert.equal(promise(armed, f.target).fulfilled, true); assert.equal(player(armed, f.recipient).bribes, 0);
|
|
let paid = act(bound, f.target, f.payment); assert.equal(promise(paid, f.target).fulfilled, true);
|
|
paid = reveal(paid, f, f.targetPlan).game;
|
|
assert.equal(paid.battle!.plans[f.target].weapon, null);
|
|
assert.ok(player(paid, f.target).hand.some(card => card.id === f.ordinary));
|
|
armed = cleanup(armed, f); paid = cleanup(paid, f);
|
|
for (const state of [armed, paid]) { assert.equal(promise(state, f.target).fulfilled, true); physical(state, f); }
|
|
});
|
|
|
|
void test('native whole plan AND payment No retains both alternatives and rejects only completion of the exact predicate', () => {
|
|
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary);
|
|
const mixed = group('and', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
|
|
for (const first of ['payment', 'plan'] as const) {
|
|
let game = bind(f, 'no', mixed).game;
|
|
if (first === 'payment') {
|
|
game = act(game, f.target, f.payment); rejected(game, f.target, f.projectilePlan);
|
|
game = reveal(game, f, f.targetPlan).game;
|
|
assert.ok(player(game, f.target).hand.some(card => card.id === f.ordinary));
|
|
} else {
|
|
game = reveal(game, f, f.projectilePlan).game; rejected(game, f.target, f.payment);
|
|
game = act(game, f.target, { ...f.payment, amount: 1 });
|
|
rejected(game, f.target, { ...f.payment, amount: 1 });
|
|
}
|
|
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
|
|
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('native false revealed plan receipt survives original battle cleanup while its payment OR remains due until late Mentat', () => {
|
|
const f = fixture(), mixed = group('or', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
|
|
let game = bind(f, 'yes', mixed).game; game = reveal(game, f, f.targetPlan).game;
|
|
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: false }]);
|
|
assert.ok(!promise(game, f.target).fulfilled); game = cleanup(game, f);
|
|
assert.equal(game.battle, null); assert.ok(!promise(game, f.target).released);
|
|
const receipt = structuredClone(game.turnActionPromises![0].combat);
|
|
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
|
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
|
|
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(game.turnActionPromises![0].combat, receipt);
|
|
physical(game, f);
|
|
});
|
|
|
|
void test('native already submitted own plan is a fixed answer-time predicate without resubmitting or revealing a foreign plan', () => {
|
|
const f = fixture(); assert.ok(f.projectilePlan);
|
|
let game = act(f.game, f.target, f.projectilePlan); assert.equal(game.battle?.revealed, false);
|
|
const fixed = structuredClone(game.battle!.plans[f.target]);
|
|
game = bind(f, 'yes', f.mixed, game).game;
|
|
assert.equal(game.turnActionPromises![0].combat?.plan?.closed, true);
|
|
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: true }]);
|
|
assert.ok(!promise(game, f.target).fulfilled); assert.equal(game.battle!.plans[f.asker], undefined);
|
|
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
|
|
assert.deepEqual(game.battle!.plans[f.target], fixed); game = act(game, f.asker, f.ownPlan);
|
|
assert.ok(game.battle?.revealed); game = cleanup(game, f); physical(game, f);
|
|
});
|
|
|
|
void test('native matching Reveal and Decline each combine with actual ordinary payment at the original Traitor declaration', () => {
|
|
const f = fixture({ source: 'traitor' });
|
|
for (const selected of [true, false]) {
|
|
const mixed = group('and', call(f.event, f.territory, selected), payment(f.recipient));
|
|
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
|
|
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [selected]);
|
|
rejected(game, f.target, { type: 'traitorCall', call: !selected });
|
|
game = act(game, f.target, { type: 'traitorCall', call: selected });
|
|
assert.equal(game.turnActionPromises![0].combat?.traitor?.closed, true);
|
|
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, selected);
|
|
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f);
|
|
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('native whole Reveal AND payment No keeps payment or the actual opposite declaration as independent alternatives', () => {
|
|
const f = fixture({ source: 'traitor' }), mixed = group('and', call(f.event, f.territory), payment(f.recipient));
|
|
for (const first of ['payment', 'reveal'] as const) {
|
|
let game = bind(f, 'no', mixed).game;
|
|
if (first === 'payment') {
|
|
game = act(game, f.target, f.payment); rejected(game, f.target, f.reveal);
|
|
game = act(game, f.target, f.decline);
|
|
} else {
|
|
game = act(game, f.target, f.reveal); rejected(game, f.target, f.payment);
|
|
game = act(game, f.target, { ...f.payment, amount: 1 });
|
|
rejected(game, f.target, { ...f.payment, amount: 1 });
|
|
}
|
|
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
|
|
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('native genuinely unmatched opposing leader preserves a funded payment OR without inventing a Traitor identity', () => {
|
|
const f = fixture({ source: 'noMatch' }), mixed = group('or', call(f.event, f.territory), payment(f.recipient));
|
|
let game = bind(f, 'yes', mixed).game;
|
|
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]); rejected(game, f.target, f.reveal);
|
|
game = act(game, f.target, f.decline); assert.ok(!promise(game, f.target).fulfilled);
|
|
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, false);
|
|
game = cleanup(game, f); assert.ok(!promise(game, f.target).released);
|
|
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
|
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
|
});
|
|
|
|
void test('native remote allied Harkonnen Reveal fulfills at declaration before its actually allowed or prevented effect', () => {
|
|
const f = fixture({ source: 'harkonnen' }); assert.ok(f.karama);
|
|
assert.equal(player(f.game, f.target).ally, f.beneficiary);
|
|
assert.ok(![f.game.battle!.attacker, f.game.battle!.defender].includes(f.target));
|
|
for (const prevented of [false, true]) {
|
|
let game = bind(f).game; game = act(game, f.target, f.payment); game = act(game, f.target, f.reveal);
|
|
assert.equal(game.response?.kind, 'harkonnenTraitor'); assert.equal(promise(game, f.target).fulfilled, true);
|
|
assert.equal(game.turnActionPromises![0].combat?.traitor?.beneficiary, f.beneficiary);
|
|
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, true);
|
|
const receipt = structuredClone(game.turnActionPromises![0].combat);
|
|
if (prevented) game = act(game, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
|
|
else game = settle(game);
|
|
assert.equal(game.battle!.traitorCalls[f.target], !prevented);
|
|
assert.deepEqual(game.turnActionPromises![0].combat, receipt); assert.equal(promise(game, f.target).fulfilled, true);
|
|
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('native current combat leaves and exact guides enforce event territory owner phase and original raw history', () => {
|
|
for (const source of ['plan', 'traitor'] as const) {
|
|
const f = fixture({ source }), declared = declare(f);
|
|
const native = source === 'plan' ? plan(f.event, f.territory, f.weaponClaim) : call(f.event, f.territory);
|
|
const stale = source === 'plan' ? plan('stale-combat', f.territory, f.weaponClaim) : call('stale-combat', f.territory);
|
|
const wrongTerritory = source === 'plan' ? plan(f.event, 'carthag', f.weaponClaim) : call(f.event, 'carthag');
|
|
for (const invalid of [stale, wrongTerritory]) rejected(declared.game, f.asker, { type: 'truthAsk', question: {
|
|
...f.question, mixed: group('or', payment(f.recipient), invalid),
|
|
} });
|
|
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
|
|
let game = answer(ask(declared.game, f.asker, f.target, group('and', native, payment(f.recipient))), f.target, 'yes');
|
|
game = act(game, f.target, f.payment);
|
|
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
|
|
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
|
|
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
|
|
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
|
|
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
|
assert.equal(guide.action.type, source === 'plan' ? 'battlePlan' : 'traitorCall');
|
|
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
|
|
assert.equal(guide.action.turnActionPromisePhase, 6); rejected(game, f.asker, guide.action);
|
|
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-guide-event' });
|
|
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
|
|
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
|
|
rejected(game, f.target, wire);
|
|
if (source === 'plan') game = act(game, f.asker, f.ownPlan);
|
|
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f);
|
|
rejected(game, f.target, wire); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('all four minimal profiles actually answer and use native plan or Traitor guides through combined fulfillment', () => {
|
|
for (const source of ['plan', 'traitor'] as const) {
|
|
const f = fixture({ source }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game = structuredClone(asked); const actions: TruthTurnCombatStep[] = [];
|
|
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
|
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
|
|
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
|
for (let count = 0; count < 24 && !promise(game, f.target).fulfilled; count++) {
|
|
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
|
const guide = view.turnActionGuide;
|
|
if (guide) {
|
|
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
|
|
assert.ok(action, `${source}/${difficulty}`); assert.deepEqual(action, guide.action);
|
|
game = act(game, f.target, action, actions);
|
|
} else if (source === 'plan' && game.battle?.plans[f.target] && !game.battle.plans[f.asker]) {
|
|
game = act(game, f.asker, f.ownPlan, actions);
|
|
} else {
|
|
const next = nativeStep(game); assert.ok(next, `${source}/${difficulty}`);
|
|
game = act(game, next.actor, next.action, actions);
|
|
}
|
|
}
|
|
assert.equal(promise(game, f.target).fulfilled, true, `${source}/${difficulty}`);
|
|
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
|
|
assert.ok(actions.some(step => step.actor === f.target && step.action.type === (source === 'plan' ? 'battlePlan' : 'traitorCall')));
|
|
game = cleanup(game, f); physical(game, f);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('two genuine Truths keep a later native plan candidate compatible with an already paid whole No', () => {
|
|
// The inherited original H/A/E prefix really holds both Harkonnen Truths.
|
|
// Actual A endMovement, E's paid reserve shipment and native battle controls
|
|
// earn this battle. No second card, army, purse, battle or ledger is set.
|
|
const f = createTruthTurnActionFixture(), actions = structuredClone(f.sourceActions);
|
|
const originalTraitors = traitors(f.game), opponent = f.observer;
|
|
let game = act(f.game, f.target, { type: 'endMovement' }, actions);
|
|
game = advance(game, state => state.phase === 5 && state.active === opponent && clean(state), actions, true);
|
|
const beforeShipment = player(game, opponent).spice;
|
|
game = settle(act(game, opponent, { type: 'ship', territory: 'arrakeen', sector: 10,
|
|
amount: 1, elite: 0, allyPayment: 0 }, actions), actions);
|
|
assert.equal(player(game, opponent).spice, beforeShipment - 1);
|
|
game = act(game, opponent, { type: 'endMovement' }, actions);
|
|
game = advance(game, state => state.phase === 6 && !!state.active && !state.battle && clean(state), actions, true);
|
|
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen' &&
|
|
[candidate.attacker, candidate.defender].includes(f.target) && [candidate.attacker, candidate.defender].includes(opponent));
|
|
assert.ok(choice);
|
|
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
|
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
|
|
game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
|
|
state.battle.preLeader?.closed !== false, actions, true);
|
|
assert.ok(game.battle?.event); assert.equal(player(game, f.target).spice, 10);
|
|
assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths);
|
|
assert.ok(actions.length > f.sourceActionCount); assert.deepEqual(inventory(game), f.originalCards);
|
|
const event = game.battle.event, territory = game.battle.territory;
|
|
const dialClaim = { kind: 'dial', compare: 'gte', value: 1 } as const;
|
|
const mixed = group('and', plan(event, territory, dialClaim), payment(f.asker));
|
|
const open = (state: Game, card: string, question: TruthQuestion): Game => {
|
|
let next = act(state, f.asker, { type: 'card', card }, actions);
|
|
while (next.truthtrance?.stage === 'priority') {
|
|
const actor = next.players.find(candidate => !next.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
|
next = act(next, actor.id, { type: 'truthPass' }, actions);
|
|
}
|
|
return act(next, f.asker, { type: 'truthAsk', question }, actions);
|
|
};
|
|
game = open(game, f.truths[0], { kind: 'turnActions', target: f.target, turn: game.turn, mixed });
|
|
game = answer(game, f.target, 'no', actions);
|
|
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
|
|
const earlier = structuredClone(game.turnActionPromises![0]), earlierHistory = structuredClone(game.truthHistory![0]);
|
|
assert.equal(player(game, f.target).spice, 8); assert.equal(game.bribePromises?.length ?? 0, 0);
|
|
game = open(game, f.truths[1], { kind: 'battlePlan', target: f.target, territory, claim: dialClaim });
|
|
assert.deepEqual(viewGame(game, f.target).truthBattleAnswers, ['no']);
|
|
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
assert.deepEqual(game.turnActionPromises![0], earlier); assert.deepEqual(game.truthHistory![0], earlierHistory);
|
|
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
|
|
game = answer(game, f.target, 'no', actions);
|
|
for (const actor of [f.target, opponent]) {
|
|
const leader = player(game, actor).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
|
|
game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0,
|
|
weapon: null, defense: null }, actions);
|
|
}
|
|
assert.ok(game.battle?.revealed); assert.equal(game.battle.plans[f.target].dial, 0);
|
|
game = finish(game, actions);
|
|
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
|
|
assert.equal(game.truthHistory!.length, 2);
|
|
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
|
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), originalTraitors);
|
|
});
|