Files
Dune/tests/kull-richese-revival-commitment.test.ts
T
Quintine 4dfdd500e0
Verify and publish checkpoint container / container (push) Successful in 10m31s
Support Revival answers with Richese Semuta
2026-10-29 01:12:37 +11:00

144 lines
8.1 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { act, finishRevival, inventory, player } from './fixture-kull-revival-semuta';
import { createSemutaRevivalBenefitsForcesFixture } from './fixture-semuta-revival-benefits-forces';
/** The native source purchases Semuta, ships four forces and loses them to
* Storm before this real Revival opportunity. As in the existing Kull
* Semuta fixture, only original physical cards are reassigned among zones;
* no phase, wallet, force, promise, response or pending revival is installed. */
let nativeSource: ReturnType<typeof createSemutaRevivalBenefitsForcesFixture> | undefined;
function fixture(answer: 'yes' | 'no' = 'yes') {
const source = nativeSource ??= createSemutaRevivalBenefitsForcesFixture({kind:'choamRevival', advanced:false});
let game = structuredClone(source.beforeRevival);
assert.ok(game.kullPreview && game.semutaPreview);
const originalCards = inventory(game);
const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []),
...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)];
const move = (id:string, recipient:string) => {
const zone = zones.find(cards=>cards.some(card=>card.id === id)); assert.ok(zone, id);
const [card] = zone.splice(zone.findIndex(row=>row.id === id),1);
const seat = player(game,recipient); assert.ok(seat.hand.length < handLimit(seat));
seat.hand.push(card);
};
move(SEMUTA_DRUG_ID, 'richese');
move('ix-kull-wahad', 'choam');
const truth = zones.flat().find(card=>card.effect === 'truthtrance'); assert.ok(truth);
move(truth.id, 'atreides');
const counter = zones.flat().find(card=>card.effect === 'karama' && card.id !== source.cost &&
!card.id.startsWith('richese-')); assert.ok(counter);
move(counter.id, 'tleilaxu');
assert.deepEqual(inventory(game),originalCards);
assert.equal(player(game,'choam').tanks,4);
game = act(game,'atreides',{type:'card',card:truth.id});
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(seat=>!game.truthtrance!.passed.includes(seat.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
game = act(game,'atreides',{type:'truthAsk',question:{kind:'revival',target:'choam',
turn:game.turn,counter:'total',minimum:3}});
assert.ok(viewGame(game,'choam').truthRevivalAnswers?.includes(answer));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'choam'); view.players.find(seat=>seat.id === 'choam')!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,difficulty);
assert.equal(action.type,'truthAnswer',difficulty);
assert.ok(view.truthRevivalAnswers?.includes(action.answer as 'yes'|'no'),difficulty);
assert.doesNotThrow(()=>act(game,'choam',action),difficulty);
}
game = act(game,'choam',{type:'truthAnswer',answer});
const promise = game.revivalPromises!.find(row=>row.player === 'choam'); assert.ok(promise);
// Pass the physical Truthtrance's Semuta reaction, retaining Semuta for the
// subsequent Kull or counter discard.
if (game.pendingTreacheryDiscard) game = semuta(game,truth.id,false);
assert.ok(game.revivalPromises?.some(row=>row.event === promise.event && !row.released));
return {game,source,originalCards,event:promise.event,kull:'ix-kull-wahad',
karama:source.cost,counter:counter.id};
}
function semuta(game:Game, card:string, claim:boolean):Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.reaction?.stage,'offer');
assert.equal(pending.batch.entries[0].card.id,card);
const event = pending.batch.event;
if (claim) {
assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true);
game = act(game,'richese',{type:'semutaCommit',event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game,'richese',{type:'semutaSelect',event,card});
} else {
for (let count=0;game.pendingTreacheryDiscard?.batch.event === event && count<game.players.length;count++) {
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
const next = game.players.find(seat=>!reaction.passed.includes(seat.id)); assert.ok(next);
game = act(game,next.id,{type:'semutaPass',event});
}
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
return game;
}
function pending(f:ReturnType<typeof fixture>) {
let game = f.game;
const guide = viewGame(game,'choam').revivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'revive'); assert.equal(guide.action.amount,3);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'choam'); view.players.find(seat=>seat.id === 'choam')!.bot = difficulty;
const action = botActions(view).find(row=>row.revivalPromiseEvent === guide.action.revivalPromiseEvent);
assert.ok(action,difficulty); assert.doesNotThrow(()=>act(game,'choam',action),difficulty);
}
game = act(game,'choam',guide.action);
for (let count=0;count<20 && game.response?.kind !== 'choamRevival';count++) {
if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival')
game = act(game,game.decision.player,{type:'decision',decline:true});
else throw Error('The original revival did not reach its native benefit response.');
}
assert.equal(game.response?.kind,'choamRevival'); assert.ok(game.pendingRevival);
game = act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
assert.equal(game.pendingKull?.stage,'offer');
const reaction = viewGame(game,'choam').kullReaction; assert.ok(reaction);
game = act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
assert.equal(game.pendingKull?.stage,'counter');
return game;
}
for (const claim of [false,true]) void test(`Revival Yes survives Kull success and Semuta ${claim?'claim':'pass'}`,()=>{
const f = fixture(); let game = pending(f);
const promise = structuredClone(game.revivalPromises!.find(row=>row.event === f.event)); assert.ok(promise);
for (let count=0;count<game.players.length && !game.pendingTreacheryDiscard;count++) {
assert.ok(game.response); const next = game.players.find(seat=>!game.response!.passed.includes(seat.id)); assert.ok(next);
game = act(game,next.id,{type:'passResponse'});
}
assert.deepEqual(game.revivalPromises!.find(row=>row.event === f.event),promise);
game = semuta(game,f.kull,claim);
game = finishRevival(game);
assert.equal(game.revivalPromises!.find(row=>row.event === f.event)?.fulfilled,true);
assert.equal(player(game,'choam').tanks,1);
assert.equal(player(game,'richese').hand.some(card=>card.id === f.kull),claim);
assert.deepEqual(inventory(game),f.originalCards);
});
for (const claim of [false,true]) void test(`Revival Yes survives Kull counter and Semuta ${claim?'claim':'pass'}`,()=>{
const f = fixture(); let game = pending(f);
assert.ok(viewGame(game,'tleilaxu').responseControls?.cancelCards.includes(f.counter));
game = act(game,'tleilaxu',{type:'card',mode:'cancel',card:f.counter});
assert.ok(!game.revivalPromises!.find(row=>row.event === f.event)?.released);
game = semuta(game,f.counter,claim);
if (game.pendingTreacheryDiscard) game = semuta(game,f.karama,false);
game = finishRevival(game);
assert.equal(game.revivalPromises!.find(row=>row.event === f.event)?.fulfilled,true);
assert.equal(player(game,'choam').tanks,1);
assert.equal(player(game,'richese').hand.some(card=>card.id === f.counter),claim);
assert.deepEqual(inventory(game),f.originalCards);
});
void test('Revival No retains its cap in the Kull and Richese profile',()=>{
const f = fixture('no');
const before = structuredClone(f.game);
assert.throws(()=>act(f.game,'choam',{type:'revive',amount:3,elite:0}));
assert.deepEqual(f.game,before);
assert.ok(!f.game.revivalPromises!.find(row=>row.event === f.event)?.released);
});