Files
Dune/tests/semuta-advanced-public.test.ts
T
Quintine b93c5c4cf5
Verify and publish checkpoint container / container (push) Canceled after 0s
Connect ordinary Semuta in Advanced Richese preview
2026-10-29 04:15:28 +11:00

185 lines
9.5 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 { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { bribePromiseModeSupported } from '../game/bribe-promises';
import { revivalPromiseModeSupported } from '../game/revival-promises';
import { alliancePromiseModeSupported } from '../game/alliance-promises';
import { bidPromiseModeSupported } from '../game/bid-promises';
import { createSaphoTruthOrderFixture, playSaphoTruthOrder, finishTruthOrder,
finishOriginalTruthQuestions, inventory as saphoInventory } from './fixture-sapho-truth-order';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
function inventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function take(game: Game, idOrEffect: string) {
const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)];
for (const zone of zones) {
const index = zone.findIndex(card => card.id === idOrEffect || card.effect === idOrEffect);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing physical card ${idOrEffect}.`);
}
/** Use the actual public Advanced start and setup. For the specific discard
* window, transfer the two existing physical cards and stage Movement; this
* does not represent a natural auction or certify a complete game. */
function fixture() {
let game = createGame('ADVSEMUTAPUBLIC', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
joinGame(game, newPlayer('r', 'Richese', 'richese'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players) game = act(game, player.id, {type:'ready'});
assert.throws(() => act(game, 'a', {type:'start'}), /Advanced.*preview/i);
game = act(game, 'a', {type:'start', advancedPreview:true});
assert.equal(game.status, 'setup');
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
assert.equal(game.advanced, true);
for (let count = 0; game.status === 'setup' && count < 80; count++) {
let next: Game | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(row => row.id === player.id)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) { next = act(game, player.id, action); break; }
}
assert.ok(next, 'The genuine setup has a legal next step.');
game = next;
}
assert.equal(game.status, 'playing');
const before = inventory(game);
const semuta = take(game, SEMUTA_DRUG_ID);
const hajr = take(game, 'hajr');
seat(game, 'r').hand.push(semuta);
seat(game, 'a').hand.push(hajr);
assert.deepEqual(inventory(game), before);
Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null,
decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']});
// Advanced Hajr is available after the original Force Movement, not before.
game = act(game, 'a', {type:'move', from:'arrakeen:10', territory:'imperial_basin',
sector:9, amount:1, elite:0});
assert.equal(seat(game, 'a').moved, 1);
return {game, semuta, hajr, before};
}
for (const claim of [false, true]) void test(`public Advanced Richese offers Hajr to Semuta and ${claim ? 'claim' : 'pass'} resumes Movement`, () => {
const f = fixture(), original = structuredClone(f.game);
let game = act(f.game, 'a', {type:'card', card:f.hajr.id});
assert.deepEqual(f.game, original);
assert.ok(game.hajr.includes('a'));
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
assert.equal(pending.reaction?.stage, 'offer');
const event = pending.batch.event;
const reaction = viewGame(game, 'r').semutaReaction; assert.ok(reaction?.canCommit);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'r');
view.players.find(player => player.id === 'r')!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty);
}
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:f.hajr.id});
} else {
for (const actor of ['a','r','e']) game = act(game, actor, {type:'semutaPass', event});
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.ok(game.hajr.includes('a'));
assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr.id), claim);
assert.equal(game.discard.some(card => card.id === f.hajr.id), !claim);
assert.deepEqual(inventory(game), f.before);
game = act(game, 'a', {type:'endMovement'});
assert.ok(!game.movementRemaining?.includes('a'));
});
for (const effect of ['ghola', 'harvester', 'weather'] as const) {
for (const claim of [false, true]) void test(`public Advanced Richese offers ${effect} to Semuta and ${claim ? 'claim' : 'pass'} preserves its effect`, () => {
const f = fixture();
let game = f.game;
// Keep actual card inventory conserved while replacing the staged Hajr.
game.deck.push(take(game, f.hajr.id));
const card = take(game, effect);
seat(game, 'a').hand.push(card);
assert.deepEqual(inventory(game), f.before);
if (effect === 'ghola') {
Object.assign(game, {phase:4, active:null});
const player = seat(game, 'a');
assert.ok(player.forces['arrakeen:10'] > 0);
player.forces['arrakeen:10']--;
player.tanks++;
} else if (effect === 'harvester') {
Object.assign(game, {phase:1, active:null, nexus:false});
game.spiceWindow = {territory:'hagga_basin', sector:12, amount:6, harvested:false};
game.spice['hagga_basin:12'] = 6;
} else {
Object.assign(game, {phase:0, active:null, stormPending:2, stormResolution:null,
stormCardWindowOpen:true});
}
const before = {reserves:seat(game, 'a').reserves, tanks:seat(game, 'a').tanks};
const action: Action = effect === 'ghola' ? {type:'card', card:card.id, amount:1, elite:0}
: effect === 'weather' ? {type:'card', card:card.id, amount:1}
: {type:'card', card:card.id};
game = act(game, 'a', action);
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true);
if (effect === 'ghola') {
assert.equal(seat(game, 'a').tanks, before.tanks - 1);
assert.equal(seat(game, 'a').reserves, before.reserves + 1);
} else if (effect === 'harvester') {
assert.equal(game.spice['hagga_basin:12'], 12);
assert.equal(game.spiceWindow?.amount, 12);
assert.equal(game.spiceWindow?.harvested, true);
} else assert.equal(game.stormPending, 1);
const committed = structuredClone({phase:game.phase, stormPending:game.stormPending,
spice:game.spice, spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves,
tanks:seat(game, 'a').tanks});
const event = pending.batch.event;
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:card.id});
} else for (const actor of ['a', 'r', 'e']) game = act(game, actor, {type:'semutaPass', event});
assert.equal(game.pendingTreacheryDiscard, null);
assert.deepEqual({phase:game.phase, stormPending:game.stormPending, spice:game.spice,
spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves,
tanks:seat(game, 'a').tanks}, committed);
assert.equal(seat(game, 'r').hand.some(item => item.id === card.id), claim);
assert.equal(game.discard.some(item => item.id === card.id), !claim);
assert.deepEqual(inventory(game), f.before);
});
}
void test('Advanced ordinary Semuta entry preserves Sapho in a simultaneous Truthtrance order', () => {
const f = createSaphoTruthOrderFixture({advanced:true, mode:'first'});
assert.equal(f.game.semutaAdvancedOrdinaryPreview, true);
assert.equal(f.game.semutaPreview, undefined);
assert.ok(viewGame(f.game, f.owner).saphoTruthOrderChoices.some(option =>
option.card === f.card && option.mode === 'first'));
let game = playSaphoTruthOrder(f.game, f.owner, f.card, 'first');
assert.equal(game.pendingTreacheryDiscard ?? null, null);
game = finishTruthOrder(game);
assert.equal(game.truthtrance!.queue[0].player, f.owner);
game = finishOriginalTruthQuestions(game);
assert.equal(game.truthtrance, null);
assert.deepEqual(saphoInventory(game), f.originalCards);
});
void test('Advanced ordinary Semuta entry preserves existing bribe, revival, alliance and bid question admission', () => {
const {game} = fixture();
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
assert.equal(game.semutaPreview, undefined);
for (const supported of [bribePromiseModeSupported, revivalPromiseModeSupported,
alliancePromiseModeSupported, bidPromiseModeSupported]) assert.equal(supported(game), true);
});