Enable Basic Semuta and Truthtrance bribe questions
This commit is contained in:
1 parent
ac4302a8ed
commit
c8889e94e2
9 files changed
+306
-1
No files matched your search
@@ -43,6 +43,14 @@ void test('ordinary bribe scope preserves bare classic and selected native modes
|
||||
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
|
||||
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
|
||||
]) assert.equal(bribePromiseModeSupported(game), true);
|
||||
const basicRichese = { advanced: false, expansions: ['choam'], semutaPreview: true,
|
||||
players: [{ faction: 'richese' }, { faction: 'atreides' }, { faction: 'emperor' }] };
|
||||
assert.equal(bribePromiseModeSupported(basicRichese), true);
|
||||
for (const game of [
|
||||
{ ...basicRichese, advanced: true }, { ...basicRichese, kullPreview: true },
|
||||
{ ...basicRichese, homeworlds: {} }, { ...basicRichese, nexusCards: {} },
|
||||
{ ...basicRichese, players: classic.players },
|
||||
]) assert.equal(bribePromiseModeSupported(game), false);
|
||||
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
|
||||
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user