242 lines
12 KiB
TypeScript
242 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { deployRicheseNoField } from '../game/richese-no-field';
|
|
import type { TruthQuestion } from '../game/truthtrance';
|
|
import {
|
|
createTruthtranceRichesePromisesFixture as fixture,
|
|
actTruthtranceRichesePromises as act,
|
|
askTruthtranceRichesePromises as ask,
|
|
advanceTruthtranceRichesePromises as advance,
|
|
truthtranceRichesePromisesClean as clean,
|
|
truthtranceRichesePromisesPlayer as player,
|
|
truthtranceRichesePromisesInventory as inventory,
|
|
} from './fixture-truthtrance-richese-promises';
|
|
|
|
const markerQuestion = {
|
|
kind: 'shipment', target: 'richese',
|
|
claim: {kind: 'noField', territory: 'carthag', sector: 11},
|
|
} as unknown as TruthQuestion;
|
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
const seat = (game: Game, id: string) => {
|
|
const found = game.players.find(row => row.id === id); assert.ok(found); return found;
|
|
};
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
function start(answer: 'yes' | 'no') {
|
|
const f = fixture();
|
|
const question = ask(structuredClone(f.game), f, markerQuestion);
|
|
assert.ok(viewGame(question, f.asker).truthShipmentTargets.includes(f.target));
|
|
assert.deepEqual(viewGame(question, f.target).truthShipmentAnswers, ['yes', 'no']);
|
|
let game = act(question, f.target, {type:'truthAnswer', answer});
|
|
game = advance(game, clean);
|
|
assert.equal(game.shipmentPromises?.length, 1);
|
|
assert.equal(game.shipmentPromises![0].answer, answer === 'yes');
|
|
assert.equal(game.shipmentPromises![0].fulfilled, undefined);
|
|
assert.equal(game.shipmentPromises![0].released, undefined);
|
|
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
|
|
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
|
return {f, game};
|
|
}
|
|
function markerAction(game: Game, value = 0, territory = 'carthag', sector = 11): Action {
|
|
const richese = player(game);
|
|
const token = richese.noField!.tokens.find(item => item.value === value); assert.ok(token);
|
|
assert.ok(richese.noFieldEvent);
|
|
return {type:'ship', noField:token.id, event:richese.noFieldEvent, territory, sector, allyPayment:0};
|
|
}
|
|
function finishResponse(game: Game): Game {
|
|
return advance(game, clean);
|
|
}
|
|
|
|
void test('Basic early Yes requires the requested owned No-Field, including its zero token', () => {
|
|
const {f, game: before} = start('yes');
|
|
const choice = markerAction(before), target = player(before), marker = String(choice.noField);
|
|
const ownView = viewGame(before, f.target), otherView = viewGame(before, f.asker);
|
|
assert.ok(ownView.shipmentCompletion?.actions.some(action => action.type === 'ship' && action.noField));
|
|
assert.equal(otherView.shipmentCompletion, null);
|
|
assert.equal(otherView.richeseNoField?.private, null);
|
|
assert.equal(JSON.stringify(otherView).includes(marker), false);
|
|
reject(before, f.target, {type:'endMovement'});
|
|
reject(before, f.target, {type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0});
|
|
reject(before, f.target, markerAction(before, 0, 'arrakeen', 10));
|
|
const declared = act(reload(before), f.target, choice);
|
|
const done = finishResponse(declared);
|
|
assert.equal(player(done).noField?.deployed?.tokenId, marker);
|
|
assert.deepEqual(player(done).noField?.deployed?.location, {territory:'carthag', sector:11});
|
|
assert.equal(player(done).reserves, target.reserves);
|
|
assert.equal(player(done).spice, target.spice - 1);
|
|
assert.equal(player(done).shipped, true);
|
|
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
|
assert.equal(done.shipmentPromises![0].released, undefined);
|
|
assert.deepEqual(inventory(done), f.originalInventory);
|
|
assert.equal(viewGame(done, f.asker).richeseNoField?.private, null);
|
|
reject(done, f.target, choice);
|
|
});
|
|
|
|
void test('Basic No forbids the selected marker shipment but permits a physical shipment', () => {
|
|
const {f, game: before} = start('no');
|
|
reject(before, f.target, markerAction(before));
|
|
const done = finishResponse(act(before, f.target, {
|
|
type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0,
|
|
}));
|
|
assert.equal(player(done).forces['carthag:11'], 1);
|
|
assert.equal(player(done).noField?.deployed, null);
|
|
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
|
assert.deepEqual(inventory(done), f.originalInventory);
|
|
});
|
|
|
|
void test('Basic No permits a marker in a different destination', () => {
|
|
const {f, game: before} = start('no');
|
|
const action = markerAction(before, 3, 'arrakeen', 10);
|
|
const done = finishResponse(act(before, f.target, action));
|
|
assert.deepEqual(player(done).noField?.deployed?.location, {territory:'arrakeen', sector:10});
|
|
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
|
assert.deepEqual(inventory(done), f.originalInventory);
|
|
});
|
|
|
|
void test('an opposing Karama prevents the marker and releases an impossible Yes without spending shipment', () => {
|
|
const {f, game: original} = start('yes');
|
|
const game = structuredClone(original), opponent = seat(game, f.observer);
|
|
const from = game.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(from >= 0 && opponent.hand.length);
|
|
// Move an existing physical Karama into the observer's hand via a conserved
|
|
// card swap; the declaration and the resulting counter are actual actions.
|
|
[opponent.hand[0], game.deck[from]] = [game.deck[from], opponent.hand[0]];
|
|
const karama = opponent.hand[0].id, before = structuredClone(game);
|
|
const declared = act(game, f.target, markerAction(game));
|
|
assert.equal(declared.response?.kind, 'richeseNoField');
|
|
assert.equal(declared.shipmentPromises![0].fulfilled, undefined);
|
|
let stopped = act(declared, f.observer, {type:'card', mode:'cancel', card:karama});
|
|
stopped = finishResponse(stopped);
|
|
assert.equal(player(stopped).noField?.deployed, null);
|
|
assert.equal(player(stopped).noFieldBlockedTurn, stopped.turn);
|
|
assert.equal(player(stopped).shipped, false);
|
|
assert.equal(player(stopped).spice, player(before).spice);
|
|
assert.equal(stopped.shipmentPromises![0].released, true);
|
|
assert.equal(stopped.shipmentPromises![0].fulfilled, undefined);
|
|
reject(stopped, f.target, markerAction(stopped));
|
|
const normal = finishResponse(act(stopped, f.target, {
|
|
type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0,
|
|
}));
|
|
assert.equal(player(normal).forces['carthag:11'], 1);
|
|
assert.deepEqual(inventory(normal), f.originalInventory);
|
|
});
|
|
|
|
void test('all existing bot profiles use the same private feasible answers and legal marker completion', () => {
|
|
const f = fixture(), asked = ask(structuredClone(f.game), f, markerQuestion);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const answering = viewGame(asked, f.target);
|
|
answering.players.find(row => row.id === f.target)!.bot = difficulty;
|
|
const answer = botActions(answering).find(action => action.type === 'truthAnswer');
|
|
assert.ok(answer && answering.truthShipmentAnswers?.includes(answer.answer as 'yes' | 'no' | 'unknown'));
|
|
assert.doesNotThrow(() => act(reload(asked), f.target, answer));
|
|
const {game} = start('yes');
|
|
const turn = viewGame(game, f.target);
|
|
turn.players.find(row => row.id === f.target)!.bot = difficulty;
|
|
const action = botActions(turn)[0]; assert.ok(action, difficulty);
|
|
assert.doesNotThrow(() => act(reload(game), f.target, action));
|
|
}
|
|
});
|
|
|
|
void test('a marker-only declaration does not satisfy an existing physical-force promise', () => {
|
|
const f = fixture();
|
|
let game = ask(structuredClone(f.game), f);
|
|
game = act(game, f.target, {type:'truthAnswer', answer:'yes'});
|
|
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
|
reject(game, f.target, markerAction(game));
|
|
const done = finishResponse(act(game, f.target, f.shipmentAction));
|
|
assert.equal(player(done).forces['carthag:11'], 5);
|
|
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
|
});
|
|
|
|
void test('Basic cannot promise a single shipment is both a marker and physical-only forces', () => {
|
|
const f = fixture();
|
|
const question = {
|
|
kind:'shipment', target:f.target, claim:{op:'and', terms:[
|
|
{kind:'noField', territory:'carthag'}, {territory:'carthag', minimum:1},
|
|
]},
|
|
} as unknown as TruthQuestion;
|
|
const asked = ask(structuredClone(f.game), f, question);
|
|
assert.deepEqual(viewGame(asked, f.target).truthShipmentAnswers, ['no']);
|
|
reject(asked, f.target, {type:'truthAnswer', answer:'yes'});
|
|
const done = act(asked, f.target, {type:'truthAnswer', answer:'no'});
|
|
assert.equal(done.shipmentPromises![0].answer, false);
|
|
});
|
|
|
|
void test('the marker question cannot target a faction that does not own a No-Field', () => {
|
|
const f = fixture();
|
|
let game = act(structuredClone(f.game), f.asker, {type:'card', card:f.truthtrance});
|
|
game = advance(game, state => state.truthtrance?.stage === 'ask');
|
|
const question = {kind:'shipment', target:f.observer, claim:{kind:'noField', territory:'carthag'}};
|
|
reject(game, f.asker, {type:'truthAsk', question});
|
|
});
|
|
|
|
void test('a deployed marker can be revealed before the promised different No-Field is shipped', () => {
|
|
const f = fixture(), staged = structuredClone(f.game), richese = player(staged);
|
|
const previous = richese.noField!.tokens.find(item => item.value === 3); assert.ok(previous);
|
|
// Model an earlier turn's original marker with the same physical token and
|
|
// history; staging does not create forces or change reserves.
|
|
richese.noField = deployRicheseNoField(richese.noField!, {
|
|
tokenId: previous.id, controller: richese.id,
|
|
location: {territory:'polar_sink', sector:0},
|
|
});
|
|
let game = ask(staged, f, markerQuestion);
|
|
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
|
|
game = act(game, f.target, {type:'truthAnswer', answer:'yes'});
|
|
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
|
const first = viewGame(game, f.target).shipmentCompletion?.actions[0];
|
|
assert.equal(first?.type, 'revealNoField');
|
|
assert.equal(first?.token, previous.id);
|
|
game = act(game, f.target, first!);
|
|
assert.equal(player(game).noField?.deployed, null);
|
|
assert.equal(player(game).noField?.lastShipped, previous.id);
|
|
assert.equal(player(game).forces['polar_sink:0'], 3);
|
|
const next = viewGame(game, f.target).shipmentCompletion?.actions[0];
|
|
assert.equal(next?.type, 'ship');
|
|
assert.ok(next?.noField && next.noField !== previous.id);
|
|
game = finishResponse(act(game, f.target, next!));
|
|
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
|
assert.equal(player(game).noField?.deployed?.tokenId, next!.noField);
|
|
assert.deepEqual(player(game).noField?.deployed?.location, {territory:'carthag', sector:11});
|
|
});
|
|
|
|
function givePhysicalKarama(game: Game, id: string): string {
|
|
const target = seat(game, id), held = target.hand.find(card => card.effect === 'karama');
|
|
if (held) return held.id;
|
|
assert.ok(target.hand.length);
|
|
const inDeck = game.deck.findIndex(card => card.effect === 'karama');
|
|
if (inDeck >= 0) {
|
|
[target.hand[0], game.deck[inDeck]] = [game.deck[inDeck], target.hand[0]];
|
|
return target.hand[0].id;
|
|
}
|
|
for (const other of game.players) {
|
|
if (other.id === id) continue;
|
|
const index = other.hand.findIndex(card => card.effect === 'karama');
|
|
if (index >= 0) {
|
|
[target.hand[0], other.hand[index]] = [other.hand[index], target.hand[0]];
|
|
return target.hand[0].id;
|
|
}
|
|
}
|
|
throw Error('No original physical Karama was available for the fixture');
|
|
}
|
|
|
|
void test('Richese cannot cancel its own promised No-Field while another responder holds Karama', () => {
|
|
const game = start('yes').game, karama = givePhysicalKarama(game, 'richese');
|
|
const opposing = givePhysicalKarama(game, 'emperor');
|
|
assert.notEqual(karama, opposing);
|
|
assert.ok(player(game).hand.some(card => card.id === karama));
|
|
const declared = act(game, 'richese', markerAction(game));
|
|
assert.equal(declared.response?.kind, 'richeseNoField');
|
|
reject(declared, 'richese', {type:'card', mode:'cancel', card:karama});
|
|
assert.equal(declared.pendingKull ?? null, null);
|
|
assert.equal(declared.pendingTreacheryDiscard ?? null, null);
|
|
assert.ok(player(declared).hand.some(card => card.id === karama));
|
|
assert.equal(declared.shipmentPromises![0].released, undefined);
|
|
assert.equal(declared.shipmentPromises![0].fulfilled, undefined);
|
|
});
|