Files
Dune/tests/table-turn.test.ts
T

255 lines
9.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck } from '../game/cards';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
function battle(advanced = false) {
let g = createGame(
'TURNUITEST',
newPlayer('f', 'Fremen support QA', 'fremen'),
advanced,
);
g.players.push(
newPlayer('b', 'Sisterhood QA', 'beneGesserit'),
newPlayer('a', 'Atreides QA', 'atreides'),
newPlayer('g', 'Guild QA', 'guild'),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase: 6,
storm: 18,
active: 'f',
order: ['f', 'b', 'a', 'g'],
deck: baseDeck(),
discard: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const p of g.players) {
p.hand = [];
p.spice = 10;
p.traitors = [];
p.forces = ['f', 'b'].includes(p.id) ? { 'red_chasm:7': 3 } : {};
p.reserves = ['f', 'b'].includes(p.id) ? 17 : 20;
}
g.players[0].ally = 'a';
g.players[2].ally = 'f';
const index = g.deck.findIndex((c) => c.effect === 'karama');
const karama = g.deck.splice(index, 1)[0];
g.players[3].hand.push(karama);
g = applyAction(g, 'f', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'b',
});
g = allow(g);
return { g, karama };
}
function allow(state: Game) {
let g = state;
for (let i = 0; g.response && i < 20; i++) {
const owner = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(owner);
g = applyAction(g, owner.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
function owners(g: Game) {
return g.players.map((p) => tableActionOwner(viewGame(g, p.id)));
}
void test('the banner and highlighted seat select the real defending Voice owner, not the battle attacker', () => {
const { g } = battle();
assert.equal(g.active, 'f');
assert.deepEqual(g.battle!.preparation, {
kind: 'voice',
owner: 'b',
beneficiary: 'b',
});
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
const own = viewGame(g, 'b');
assert.equal(
own.players.find((p) => p.id === tableActionOwner(own))!.name,
'Sisterhood QA',
);
});
void test('actual Voice and Prescience response windows are neutral, then ownership follows the provider and responding opponent', () => {
let { g } = battle();
g = applyAction(g, 'b', { type: 'voice', kind: 'poison', must: false });
assert.equal(g.battle!.preparation?.owner, 'a');
assert.deepEqual(owners(g), [null, null, null, null]);
g = allow(g);
assert.deepEqual(owners(g), ['a', 'a', 'a', 'a']);
g = applyAction(g, 'a', { type: 'prescience', field: 'leader' });
assert.equal(g.battle!.preparation?.owner, 'b');
assert.deepEqual(owners(g), [null, null, null, null]);
g = allow(g);
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
g = applyAction(g, 'b', {
type: 'prescienceAnswer',
value: g.players[1].leaders[0].id,
});
assert.equal(g.battle!.preparation, undefined);
assert.deepEqual(owners(g), [null, null, null, null]);
});
void test('canceling the actual Voice keeps the next Prescience owner visible across JSON reload', () => {
const f = battle();
let g = applyAction(f.g, 'b', { type: 'voice', kind: 'poison', must: false });
g = applyAction(g, 'g', { type: 'card', card: f.karama.id, mode: 'cancel' });
g = JSON.parse(JSON.stringify(g)) as Game;
assert.equal(g.battle!.voice, undefined);
assert.equal(g.battle!.preparation?.kind, 'prescience');
assert.deepEqual(owners(g), ['a', 'a', 'a', 'a']);
});
void test('the actual special full-plan decision takes ownership before the stale phase-active seat', () => {
let { g } = battle(true);
g = applyAction(g, 'b', { type: 'declineBattlePower' });
g = applyAction(g, 'a', { type: 'declineBattlePower' });
assert.equal(g.active, 'f');
assert.deepEqual(g.decision, { kind: 'fullPlanOffer', player: 'a' });
assert.deepEqual(owners(g), ['a', 'a', 'a', 'a']);
});
void test('higher-priority interruptions suppress an underlying single-seat owner and explicit decisions precede preparation', () => {
const { g } = battle(),
view = viewGame(g, 'f');
for (const patch of [
{ automaticContinuationPending: true },
{ phaseOpening: { initialize: false, passed: [] } },
{
truthtrance: { stage: 'priority', queue: [], passed: [], question: null },
},
{ response: { kind: 'voice', owner: 'b', passed: [] } },
])
assert.equal(tableActionOwner({ ...view, ...patch } as typeof view), null);
assert.equal(
tableActionOwner({
...view,
decision: { kind: 'fullPlanOffer', player: 'a' },
}),
'a',
);
for (const status of ['lobby', 'setup', 'finished'] as const)
assert.equal(tableActionOwner({ ...view, status }), null);
assert.equal(
tableActionOwner({
...view,
active: 'absent',
battle: null,
decision: null,
}),
null,
);
});
void test('owner selection is identical across private views and does not depend on hidden hands or traitors', () => {
const { g } = battle();
const expected = owners(g);
const changed = structuredClone(g),
card = changed.deck.pop()!;
changed.players[0].hand.push(card);
changed.players[0].traitors = [changed.players[3].leaders[0].id];
assert.deepEqual(owners(changed), expected);
for (const p of changed.players) {
const v = viewGame(changed, p.id);
if (p.id !== 'f') assert.equal(v.players[0].hand, undefined);
assert.equal(tableActionOwner(v), 'b');
}
});
void test('real simultaneous plans and traitor votes have no sole owner until exactly one participant remains', () => {
let { g } = battle();
g = applyAction(g, 'b', { type: 'declineBattlePower' });
g = applyAction(g, 'a', { type: 'declineBattlePower' });
assert.deepEqual(owners(g), [null, null, null, null]);
g = applyAction(g, 'f', {
type: 'battlePlan',
leader: g.players[0].leaders[0].id,
dial: 0,
support: 0,
});
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
g = applyAction(g, 'b', {
type: 'battlePlan',
leader: g.players[1].leaders[0].id,
dial: 0,
support: 0,
});
assert.equal(g.battle!.revealed, true);
assert.deepEqual(owners(g), [null, null, null, null]);
g = applyAction(g, 'f', { type: 'traitorCall', call: false });
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
});
void test('full-plan prescience names its publicly selected target before either ordinary plan can be sealed', () => {
const f = battle(true);
let g = f.g;
// Keep this scenario's card physically unique while placing it in the
// eligible provider's hand before its actual special-power declaration.
g.players[3].hand = [];
g.players[2].hand.push(f.karama);
g = applyAction(g, 'b', { type: 'declineBattlePower' });
g = applyAction(g, 'a', { type: 'declineBattlePower' });
g = applyAction(g, 'a', {
type: 'card',
card: f.karama.id,
mode: 'special',
target: 'b',
});
assert.deepEqual(g.battle!.fullPlan, { owner: 'a', target: 'b' });
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
});
void test('real Kull offer gives CHOAM public decision ownership before the suspended actor', () => {
const initial = choamKullGame();
const choam = initial.players[0].id, actor = initial.players[1].id;
const offered = applyAction(initial, actor, kullShipmentAttempt(initial, actor));
assert.ok(offered.pendingKull);
assert.deepEqual(owners(offered), offered.players.map(() => choam));
const saved = JSON.parse(JSON.stringify(offered)) as Game;
assert.deepEqual(owners(saved), owners(offered));
const counter = applyAction(saved, choam, {
type: 'kullDecision', event: saved.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad',
});
assert.equal(counter.response?.kind, 'choamWorthless');
assert.deepEqual(owners(counter), counter.players.map(() => null));
});
void test('neutral Richese Betrayal acknowledgement preempts an underlying buyer decision in every seat view', () => {
for (const kind of ['purchase', 'sale'] as const) {
const fixture = createRicheseBetrayalFixture(kind);
for (const player of fixture.game.players) {
const view = viewGame(fixture.game, player.id);
assert.ok(view.richeseBetrayalReaction);
assert.equal(tableActionOwner({
...view, active: fixture.buyer,
decision: { kind: 'auctionPayment', player: fixture.buyer },
}), null);
}
}
});
void test('neutral Guild Betrayal acknowledgement has no sole turn owner for own or another payer', () => {
for (const payer of ['holder', 'other'] as const) {
const fixture = createGuildBetrayalFixture({ payer });
for (const player of fixture.game.players) {
const view = viewGame(fixture.game, player.id);
assert.ok(view.guildBetrayalReaction);
assert.equal(tableActionOwner(view), null);
}
const settled = applyAction(fixture.game, fixture.holder, {
type: 'guildBetrayalUse', event: fixture.event,
});
for (const player of settled.players)
assert.equal(tableActionOwner(viewGame(settled, player.id)), fixture.shipper);
}
});