187 lines
11 KiB
TypeScript
187 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Game, type Action } from '../game/engine';
|
|
import type { TruthQuestion, TruthAnswer } from '../game/truthtrance';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { botActions } from '../game/bots';
|
|
import { createTruthSemutaPrebidFixture, declareTruthTransferOrderPlay,
|
|
actTruthTransferOrderPlay as act, truthTransferOrderPlayInventory as inventory,
|
|
truthTransferOrderPlayPlayer as player } from './fixture-truth-transfer-order-play';
|
|
import { passFreshDiscard } from './fixture-sapho-truth-order';
|
|
|
|
/** Native Basic Richese/Guild/Atreides/Emperor game: original Truths are dealt in
|
|
* setup, Guild actually spends Distrans, completes the turn, then purchases
|
|
* the original Semuta from Richese's actual cache for one spice. The fixture
|
|
* selects unopened original deal positions and native lotteries; no card,
|
|
* funds, phase, paid transaction or Semuta holder is installed. */
|
|
function source() {
|
|
const f = createTruthSemutaPrebidFixture();
|
|
assert.equal(f.game.advanced,false); assert.equal(f.game.semutaPreview,true);
|
|
assert.ok(player(f.game,f.target).hand.some(card => card.id === f.semuta));
|
|
assert.equal(f.game.truthtrance ?? null,null); assert.equal(f.game.pendingTreacheryDiscard ?? null,null);
|
|
assert.ok(player(f.game,f.target).spice >= 2);
|
|
assert.equal(player(f.game,f.target).ally,null);
|
|
assert.deepEqual(inventory(f.game),f.originalCards);
|
|
return f;
|
|
}
|
|
type Family = 'bribe' | 'bribeExpression' | 'mixedBribe';
|
|
function question(f: ReturnType<typeof source>, family: Family): TruthQuestion {
|
|
const target=f.target, turn=f.game.turn, recipient=f.asker;
|
|
const other = f.game.players.find(row => row.id !== target && row.id !== recipient)!.id;
|
|
if (family === 'bribe') return {kind:'bribe',target,turn,recipient,minimum:2};
|
|
if (family === 'bribeExpression') return {kind:'bribeExpression',target,turn,claim:{kind:'and',terms:[
|
|
{kind:'payment',recipient,minimum:1},{kind:'payment',recipient:other,minimum:1},
|
|
]}};
|
|
return {kind:'mixedBribe',target,turn,mixed:{kind:'and',terms:[
|
|
{kind:'fact',fact:{kind:'spice',compare:'gte',value:0}},
|
|
{kind:'action',action:{recipient,minimum:2}},
|
|
]}};
|
|
}
|
|
function ask(f: ReturnType<typeof source>, family: Family) {
|
|
const declared = declareTruthTransferOrderPlay(f).game;
|
|
return act(declared,f.asker,{type:'truthAsk',question:question(f,family)});
|
|
}
|
|
function answers(game: Game, target: string, family: Family): TruthAnswer[] | null {
|
|
const view = viewGame(game,target);
|
|
return family === 'bribe' ? view.truthBribeAnswers :
|
|
family === 'bribeExpression' ? view.truthBribeExpressionAnswers : view.truthMixedBribeAnswers;
|
|
}
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before=structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
|
|
}
|
|
function receipt(game: Game, family: Family, target: string) {
|
|
const row = family === 'bribe' ? game.bribePromises?.findLast(entry => entry.player === target) :
|
|
family === 'bribeExpression' ? game.bribeExpressionPromises?.findLast(entry => entry.player === target) :
|
|
game.mixedBribeQuestions?.findLast(entry => entry.player === target);
|
|
assert.ok(row); return structuredClone(row);
|
|
}
|
|
|
|
for (const family of ['bribe','bribeExpression','mixedBribe'] as const)
|
|
for (const answer of ['yes','no'] as const) for (const claim of [false,true]) {
|
|
void test(`Basic native Semuta ${claim ? 'claim' : 'pass'} preserves ${family} ${answer} and its real payment`, () => {
|
|
const f=source(), before=structuredClone(f.game), asked=ask(f,family);
|
|
assert.ok(answers(asked,f.target,family)?.includes(answer));
|
|
assert.equal(viewGame(asked,f.asker).truthBribeAnswers,null);
|
|
let game=act(asked,f.target,{type:'truthAnswer',answer});
|
|
const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
assert.equal(pending.continuation.kind,'truthtranceDiscard');
|
|
assert.equal(pending.batch.cause,'truthtrance');
|
|
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id,entry.discardedBy,entry.publicFace]),
|
|
[[f.truths[0],f.asker,true]]);
|
|
assert.equal(game.truthHistory?.at(-1)?.answer,answer);
|
|
const savedReceipt=receipt(game,family,f.target), savedHistory=structuredClone(game.truthHistory);
|
|
assert.ok(viewGame(game,f.target).semutaReaction?.canCommit);
|
|
if (claim) game=act(game,f.target,{type:'semutaCommit',event:pending.batch.event});
|
|
else game=passFreshDiscard(game);
|
|
assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance,null);
|
|
assert.deepEqual(receipt(game,family,f.target),savedReceipt);
|
|
assert.deepEqual(game.truthHistory,savedHistory);
|
|
assert.equal(player(game,f.target).hand.some(card => card.id === f.truths[0]),claim);
|
|
assert.equal(player(game,f.target).hand.some(card => card.id === f.semuta),!claim);
|
|
assert.equal(game.discard.some(card => card.id === f.truths[0]),!claim);
|
|
assert.equal(game.discard.some(card => card.id === f.semuta),claim);
|
|
assert.equal(game.auction?.index,before.auction?.index); assert.equal(game.auction?.bid,before.auction?.bid);
|
|
assert.deepEqual(inventory(game),f.originalCards);
|
|
const payerBefore=player(game,f.target).spice, recipientBefore=player(game,f.asker).bribes;
|
|
const other=game.players.find(row => row.id !== f.target && row.id !== f.asker)!.id;
|
|
if (answer === 'yes' && family === 'bribeExpression') {
|
|
game=act(game,f.target,{type:'bribe',target:f.asker,amount:1});
|
|
assert.ok(!game.bribeExpressionPromises?.at(-1)?.fulfilled);
|
|
game=act(game,f.target,{type:'bribe',target:other,amount:1});
|
|
assert.equal(game.bribeExpressionPromises?.at(-1)?.fulfilled,true);
|
|
} else if (answer === 'yes') {
|
|
game=act(game,f.target,{type:'bribe',target:f.asker,amount:2});
|
|
assert.equal((family === 'bribe' ? game.bribePromises?.at(-1) : game.bribeExpressionPromises?.at(-1))?.fulfilled,true);
|
|
} else if (family === 'bribeExpression') {
|
|
game=act(game,f.target,{type:'bribe',target:f.asker,amount:1});
|
|
reject(game,f.target,{type:'bribe',target:other,amount:1});
|
|
} else {
|
|
reject(game,f.target,{type:'bribe',target:f.asker,amount:2});
|
|
game=act(game,f.target,{type:'bribe',target:f.asker,amount:1});
|
|
}
|
|
assert.equal(player(game,f.target).spice,payerBefore - (answer === 'yes' ? 2 : 1));
|
|
assert.equal(player(game,f.asker).bribes,recipientBefore + (answer === 'yes' && family !== 'bribeExpression' ? 2 : 1));
|
|
assert.deepEqual(inventory(game),f.originalCards);
|
|
});
|
|
}
|
|
|
|
void test('Basic native Semuta bribe question has a legal bot answer and continuation for every profile', () => {
|
|
const f=source(), asked=ask(f,'bribe');
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view=viewGame(asked,f.target);
|
|
view.players.find(row => row.id === f.target)!.bot=difficulty;
|
|
const action=botActions(view)[0]; assert.ok(action,difficulty); assert.equal(action.type,'truthAnswer');
|
|
assert.ok(['yes','no'].includes(String(action.answer)));
|
|
let game=act(structuredClone(asked),f.target,action);
|
|
assert.ok(game.pendingTreacheryDiscard?.reaction); game=passFreshDiscard(game);
|
|
const result=receipt(game,'bribe',f.target);
|
|
assert.equal(result.answer,action.answer === 'yes');
|
|
if (action.answer === 'yes') {
|
|
const own=viewGame(game,f.target); own.players.find(row => row.id === f.target)!.bot=difficulty;
|
|
const guide=own.bribePromiseGuide; assert.ok(guide);
|
|
const next=botActions(own)[0]; assert.deepEqual(next,guide.action);
|
|
game=act(game,f.target,next); assert.equal(game.bribePromises?.at(-1)?.fulfilled,true);
|
|
}
|
|
assert.deepEqual(inventory(game),f.originalCards);
|
|
}
|
|
});
|
|
|
|
for (const claim of [false,true]) void test(
|
|
`Basic Semuta ${claim ? 'claim' : 'pass'} resumes a JSON-round-tripped constant mixed bribe answer`, () => {
|
|
const f=source(), declared=declareTruthTransferOrderPlay(f).game;
|
|
// The current known fact is true, so the OR is true independently of the
|
|
// future payment. No residual bribe-expression obligation is created.
|
|
const mixed = {kind:'or' as const,terms:[
|
|
{kind:'fact' as const,fact:{kind:'spice' as const,compare:'gte' as const,value:0}},
|
|
{kind:'action' as const,action:{recipient:f.asker,minimum:2}},
|
|
]};
|
|
const asked=act(declared,f.asker,{type:'truthAsk',question:{kind:'mixedBribe',
|
|
target:f.target,turn:f.game.turn,mixed}});
|
|
assert.deepEqual(viewGame(asked,f.target).truthMixedBribeAnswers,['yes']);
|
|
const pending=act(asked,f.target,{type:'truthAnswer',answer:'yes'});
|
|
const stored=receipt(pending,'mixedBribe',f.target);
|
|
assert.ok('binding' in stored); assert.equal(stored.binding,false);
|
|
assert.equal(pending.bribeExpressionPromises?.length ?? 0,0);
|
|
assert.ok(pending.pendingTreacheryDiscard?.reaction);
|
|
const history=structuredClone(pending.truthHistory), event=pending.pendingTreacheryDiscard!.batch.event;
|
|
let game=JSON.parse(JSON.stringify(pending)) as Game;
|
|
if (claim) game=act(game,f.target,{type:'semutaCommit',event});
|
|
else game=passFreshDiscard(game);
|
|
assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance,null);
|
|
assert.deepEqual(receipt(game,'mixedBribe',f.target),stored);
|
|
assert.deepEqual(game.truthHistory,history);
|
|
assert.equal(player(game,f.target).hand.some(card => card.id === f.truths[0]),claim);
|
|
assert.equal(game.discard.some(card => card.id === f.truths[0]),!claim);
|
|
assert.deepEqual(inventory(game),f.originalCards);
|
|
// A constant answer imposes no residual limit on a later legal payment.
|
|
const before=player(game,f.target).spice;
|
|
game=act(game,f.target,{type:'bribe',target:f.asker,amount:1});
|
|
assert.equal(player(game,f.target).spice,before - 1);
|
|
assert.deepEqual(inventory(game),f.originalCards);
|
|
},
|
|
);
|
|
|
|
void test('A tampered mixed bribe binding cannot resume its original Semuta discard continuation', () => {
|
|
const f=source(), declared=declareTruthTransferOrderPlay(f).game;
|
|
const asked=act(declared,f.asker,{type:'truthAsk',question:{kind:'mixedBribe',
|
|
target:f.target,turn:f.game.turn,mixed:{kind:'or',terms:[
|
|
{kind:'fact',fact:{kind:'spice',compare:'gte',value:0}},
|
|
{kind:'action',action:{recipient:f.asker,minimum:2}},
|
|
]}}});
|
|
const pending=act(asked,f.target,{type:'truthAnswer',answer:'yes'});
|
|
const event=pending.pendingTreacheryDiscard!.batch.event;
|
|
const tampered=JSON.parse(JSON.stringify(pending)) as Game;
|
|
const continuation=tampered.pendingTreacheryDiscard!.continuation;
|
|
assert.equal(continuation.kind,'truthtranceDiscard');
|
|
if (continuation.kind !== 'truthtranceDiscard') throw Error('Expected original Truth discard');
|
|
assert.ok(continuation.bribeBinding); assert.equal(continuation.bribeBinding.kind,'mixedBribe');
|
|
continuation.bribeBinding.value.answer=false;
|
|
reject(tampered,f.target,{type:'semutaCommit',event});
|
|
// Mutating the live ledger rather than the receipt must also reject.
|
|
const altered=JSON.parse(JSON.stringify(pending)) as Game;
|
|
assert.ok(altered.mixedBribeQuestions?.length);
|
|
altered.mixedBribeQuestions!.at(-1)!.answer=false;
|
|
reject(altered,f.target,{type:'semutaCommit',event});
|
|
assert.deepEqual(inventory(pending),f.originalCards);
|
|
});
|