255 lines
9.2 KiB
TypeScript
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);
|
|
}
|
|
});
|