Extend conditional battle questions across previews
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
0e52c25355
commit
50703664cf
13 files changed
+336
-24
No files matched your search
@@ -128,7 +128,7 @@ void test('real pre-dial Storm and active Spice Blow hide targets and reject the
|
||||
for (const [f, game] of [[source, storm], [spiceSource, spice]] as const) {
|
||||
assert.deepEqual(viewGame(game, f.asker).truthFutureBattleLeaderTargets, []);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => ask({ ...f, game }), /quiet Basic/);
|
||||
assert.throws(() => ask({ ...f, game }), /supported quiet/);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthMovementAdvancedFixture, actTruthMovementAdvanced as act,
|
||||
truthMovementAdvancedClean as clean } from './fixture-truth-movement-advanced';
|
||||
import { createTruthMovementIxianFixture } from './fixture-truth-movement-ixian';
|
||||
import { advanceFutureShipment as advance } from './fixture-truthtrance-future-shipment';
|
||||
import { futureBattleLeaderRecords as records } from './fixture-truth-future-battle-leader';
|
||||
import { futureBattleLeaderWindow } from '../game/future-battle-leader';
|
||||
|
||||
type Fixture = ReturnType<typeof createTruthMovementAdvancedFixture>;
|
||||
|
||||
/** The fixture earns the physical Truthtrance and Atreides' Arrakeen army via
|
||||
* native Advanced setup and phase actions. Nothing installs a battle or answer. */
|
||||
function ask(f: {game: Game; target: string; asker: string; truth: string}): { game: Game; leader: string; other: string } {
|
||||
let game = f.game;
|
||||
const target = game.players.find(player => player.id === f.target)!;
|
||||
const leader = target.leaders[0].id, other = target.leaders[1].id;
|
||||
assert.ok(viewGame(game, f.asker).truthFutureBattleLeaderTargets.includes(f.target));
|
||||
game = act(game, f.asker, { type: 'card', card: f.truth });
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id));
|
||||
assert.ok(player);
|
||||
game = act(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'futureBattleLeader', target: f.target, turn: game.turn, leader,
|
||||
} } as unknown as Action);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
return { game, leader, other };
|
||||
}
|
||||
|
||||
function battle(game: Game, f: Fixture): Game {
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.asker && clean(state));
|
||||
game = act(game, f.asker, { type: 'ship', amount: 1, elite: 0,
|
||||
territory: 'arrakeen', sector: 10, allyPayment: 0 });
|
||||
game = advance(game, clean);
|
||||
game = act(game, f.asker, { type: 'endMovement' });
|
||||
game = advance(game, state => state.phase === 6 && clean(state) && !!state.active && !state.battle);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === 'arrakeen');
|
||||
assert.ok(choice);
|
||||
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
return advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false);
|
||||
}
|
||||
|
||||
for (const answer of ['yes', 'no'] as const) {
|
||||
void test(`Advanced classic ${answer} binds and settles in a real paid-support battle`, () => {
|
||||
const f = createTruthMovementAdvancedFixture({ hajr: false });
|
||||
const asked = ask(f);
|
||||
assert.ok(viewGame(asked.game, f.target).truthFutureBattleLeaderAnswers?.includes(answer));
|
||||
let game = act(asked.game, f.target, { type: 'truthAnswer', answer });
|
||||
assert.equal(records(game)[0].status, 'waiting');
|
||||
game = battle(game, f);
|
||||
assert.equal(records(game)[0].status, 'bound');
|
||||
assert.equal(records(game)[0].battle, game.battle!.event);
|
||||
const plan = (leader: string): Action => ({ type: 'battlePlan', leader, dial: 1,
|
||||
support: 1, allyPayment: 0, weapon: null, defense: null });
|
||||
const rejected = plan(answer === 'yes' ? asked.other : asked.leader);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, rejected));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, f.target, plan(answer === 'yes' ? asked.leader : asked.other));
|
||||
const emperor = game.players.find(player => player.id === f.asker)!;
|
||||
game = act(game, f.asker, { type: 'battlePlan', leader: emperor.leaders[0].id,
|
||||
dial: 0, support: 0, allyPayment: 0, weapon: null, defense: null });
|
||||
assert.ok(game.battle?.revealed);
|
||||
assert.equal(records(game)[0].status, 'settled');
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced conditional question expires without requiring a battle', () => {
|
||||
const f = createTruthMovementAdvancedFixture({ hajr: false });
|
||||
const asked = ask(f);
|
||||
let game = act(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(records(game)[0].status, 'waiting');
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
game = advance(game, state => state.turn > f.game.turn || state.phase > 6 || state.status === 'finished');
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(records(game)[0].status, 'expired');
|
||||
});
|
||||
|
||||
void test('all bot profiles provide an available Advanced conditional answer', () => {
|
||||
const f = createTruthMovementAdvancedFixture({ hajr: false });
|
||||
const asked = ask(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = structuredClone(asked.game);
|
||||
game.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const reply = botActions(viewGame(game, f.target)).find(action => action.type === 'truthAnswer');
|
||||
assert.ok(reply, difficulty);
|
||||
const answered = act(game, f.target, reply);
|
||||
assert.equal(records(answered)[0].status, 'waiting');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Ixians bind and settle their own leader in a real Cyborg battle', () => {
|
||||
const f = createTruthMovementIxianFixture();
|
||||
const asked = ask(f);
|
||||
let game = act(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(records(game)[0].status, 'waiting');
|
||||
game = act(game, f.target, f.matchingMove);
|
||||
game = advance(game, clean);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.asker && clean(state));
|
||||
game = act(game, f.asker, { type: 'ship', amount: 1, elite: 0,
|
||||
territory: f.destination.territory, sector: f.destination.sector, allyPayment: 0 });
|
||||
game = advance(game, clean);
|
||||
game = act(game, f.asker, { type: 'endMovement' });
|
||||
game = advance(game, state => state.phase === 6 && clean(state) && !!state.active && !state.battle);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === f.destination.territory);
|
||||
assert.ok(choice);
|
||||
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false);
|
||||
assert.equal(records(game)[0].status, 'bound');
|
||||
assert.equal(records(game)[0].battle, game.battle!.event);
|
||||
const common = { dial: 0, support: 0, allyPayment: 0, weapon: null, defense: null };
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'battlePlan', leader: asked.other, ...common }));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, f.target, { type: 'battlePlan', leader: asked.leader, ...common });
|
||||
game = act(game, f.asker, { type: 'battlePlan',
|
||||
leader: game.players.find(player => player.id === f.asker)!.leaders[0].id, ...common });
|
||||
assert.ok(game.battle?.revealed);
|
||||
assert.equal(records(game)[0].status, 'settled');
|
||||
});
|
||||
|
||||
void test('Advanced optional-module and Ecaz previews remain outside the shared question window', () => {
|
||||
const f = createTruthMovementAdvancedFixture({ hajr: false });
|
||||
assert.ok(futureBattleLeaderWindow(f.game));
|
||||
assert.equal(futureBattleLeaderWindow({ ...f.game, homeworlds: { custody: null } }), false);
|
||||
assert.equal(futureBattleLeaderWindow({ ...f.game, ecazOccupyPreview: true }), false);
|
||||
});
|
||||
@@ -32,7 +32,7 @@ void test('future battle question lists only the target faction’s public nativ
|
||||
const props = { game, target: 'target', leader: native.id, available: true, busy: false,
|
||||
onLeaderChange: (_leader: string) => {}, onAsk: (_target: string, _leader: string) => {} };
|
||||
const html = renderToStaticMarkup(createElement(TruthFutureBattleLeader, props));
|
||||
assert.match(html, /If you fight a battle later this turn/);
|
||||
assert.match(html, /If you submit a battle plan later this turn/);
|
||||
assert.match(html, /first such battle/);
|
||||
assert.match(html, /there is no opportunity/);
|
||||
assert.ok(html.includes(native.name));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, location as locationKey } from '../game/board';
|
||||
import { advanceToNextStorm } from './fixture-advanced-source';
|
||||
import { basicEcazOccupyFixture, basicEcazOccupyBattleAction } from './fixture-basic-ecaz-occupy';
|
||||
import { nextBasicEcazNativeStep } from './fixture-basic-ecaz-native';
|
||||
import { settleEcazOccupyArrival, stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { futureBattleLeaderRecords as records } from './fixture-truth-future-battle-leader';
|
||||
|
||||
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
function next(game: Game): Game {
|
||||
const step = nextBasicEcazNativeStep(game);
|
||||
assert.ok(step, `The original continuation needs an action in phase ${game.phase}.`);
|
||||
return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
function advance(game: Game, until: (game: Game) => boolean): Game {
|
||||
for (let i = 0; i < 1600 && !until(game); i++) game = next(game);
|
||||
assert.ok(until(game), 'Native continuation did not reach the requested boundary.');
|
||||
return game;
|
||||
}
|
||||
function arrangeBlow(game: Game, worm: boolean, position: number) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed
|
||||
: 'territory' in card && card.territory !== 'the_great_flat'));
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
|
||||
/** The fixture starts with a genuine Basic deal. As in the original Occupy
|
||||
* fixture, conserved board counters are returned, unopened Spice Cards are
|
||||
* reordered, and a rival army, the shipping budgets, two Truthtrance cards and
|
||||
* Karama are staged. Alliance, answers, allied shipments, battle and plan
|
||||
* choices are real native actions. */
|
||||
function source() {
|
||||
const original = basicEcazOccupyFixture({ chooseBattle: false });
|
||||
let game = structuredClone(original.afterSetup);
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
}
|
||||
// This fixture deliberately supplies a test-bank budget for the actual
|
||||
// later paid shipments, as the selected battle needs four forces per ally.
|
||||
for (const id of [original.ecaz, original.ally])
|
||||
game.players.find(p => p.id === id)!.spice = 30;
|
||||
// Stage before either actual auction can move an unopened face to discard.
|
||||
const first = stageEcazOccupyCard(game, original.opponent, card => card.effect === 'truthtrance');
|
||||
const second = stageEcazOccupyCard(game, original.opponent, card => card.effect === 'truthtrance' && card.id !== first);
|
||||
stageEcazOccupyCard(game, original.opponent, card => card.effect === 'karama');
|
||||
arrangeBlow(game, false, 0);
|
||||
arrangeBlow(game, false, 1);
|
||||
game = advance(game, g => g.phase === 5 && clean(g));
|
||||
while (game.phase === 5) game = clean(game) ? applyAction(game, game.active!, { type: 'endMovement' }) : next(game);
|
||||
game = advanceToNextStorm(game);
|
||||
arrangeBlow(game, true, 0);
|
||||
arrangeBlow(game, false, 1);
|
||||
arrangeBlow(game, false, 2);
|
||||
game = advance(game, g => g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
|
||||
game = applyAction(game, original.ecaz, { type: 'alliance', target: original.ally });
|
||||
game = applyAction(game, original.ally, { type: 'alliance', target: original.ecaz });
|
||||
game = advance(game, g => g.phase === 5 && clean(g));
|
||||
const sector = TERRITORIES.find(t => t.id === original.territory)!.sectors.find(s => s !== game.storm)!;
|
||||
const location = locationKey(original.territory, sector);
|
||||
const rival = game.players.find(p => p.id === original.opponent)!;
|
||||
assert.ok(rival.reserves >= 8);
|
||||
rival.reserves -= 8;
|
||||
rival.forces[location] = 8;
|
||||
const named: Record<string, string> = {};
|
||||
for (const [target, card] of [[original.ecaz, first], [original.ally, second]] as const) {
|
||||
const player = game.players.find(p => p.id === target)!;
|
||||
const leader = player.leaders.find(l => !l.dead && !l.usedAt)!;
|
||||
assert.ok(leader);
|
||||
named[target] = leader.id;
|
||||
assert.ok(viewGame(game, original.opponent).truthFutureBattleLeaderTargets.includes(target));
|
||||
game = applyAction(game, original.opponent, { type: 'card', card });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
|
||||
assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = applyAction(game, original.opponent, { type: 'truthAsk', question: {
|
||||
kind: 'futureBattleLeader', target, turn: game.turn, leader: leader.id,
|
||||
} } as Action);
|
||||
game = applyAction(game, target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(records(game).find(row => row.player === target)?.status, 'waiting');
|
||||
}
|
||||
while (game.phase === 5) {
|
||||
if (!clean(game)) { game = next(game); continue; }
|
||||
const actor = game.active!;
|
||||
if (actor === original.ecaz || actor === original.ally) {
|
||||
game = settleEcazOccupyArrival(applyAction(game, actor, { type: 'ship',
|
||||
territory: original.territory, sector, amount: 4 }));
|
||||
}
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
game = advance(game, g => g.phase === 6 && clean(g) && !g.battle && !!g.active);
|
||||
return { ...original, game, named, location };
|
||||
}
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true])
|
||||
void test(`Basic Occupy ${lead} lead ${canceled ? 'canceled' : 'allowed'} binds only its actual plan owner`, () => {
|
||||
const f = source();
|
||||
let game = f.game;
|
||||
const battle = basicEcazOccupyBattleAction(f);
|
||||
game = applyAction(game, battle.actor, battle.action);
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
assert.ok(records(game).every(row => row.status === 'waiting' && !row.battle));
|
||||
const selected = lead === 'ecaz' ? f.ecaz : f.ally;
|
||||
const nonleader = selected === f.ecaz ? f.ally : f.ecaz;
|
||||
game = applyAction(game, f.ecaz, { type: 'decision', event: game.decision!.event, lead: selected });
|
||||
assert.equal(game.response?.kind, 'ecazOccupy');
|
||||
assert.ok(records(game).every(row => row.status === 'waiting' && !row.battle));
|
||||
if (canceled) {
|
||||
const card = game.players.find(p => p.id === f.opponent)!.hand.find(c => c.effect === 'karama');
|
||||
assert.ok(card);
|
||||
game = applyAction(game, f.opponent, { type: 'card', mode: 'cancel', card: card.id });
|
||||
}
|
||||
game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation &&
|
||||
(!g.battle.preLeader || g.battle.preLeader.closed));
|
||||
assert.equal(game.battle!.ecazOccupy!.canceled, canceled);
|
||||
assert.equal(records(game).find(row => row.player === selected)?.status, 'bound');
|
||||
assert.equal(records(game).find(row => row.player === selected)?.battle, game.battle!.event);
|
||||
assert.equal(records(game).find(row => row.player === nonleader)?.status, 'waiting');
|
||||
const profile = viewGame(game, selected).battle!.ecazOccupy!.profile!;
|
||||
const wrong = game.players.find(p => p.id === selected)!.leaders.find(l => l.id !== f.named[selected] && !l.dead && !l.usedAt)!;
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, selected, { type: 'battlePlan', dial: profile.fixedEcazDial,
|
||||
support: 0, leader: wrong.id }));
|
||||
assert.deepEqual(game, before);
|
||||
game = applyAction(game, selected, { type: 'battlePlan', dial: profile.fixedEcazDial,
|
||||
support: 0, leader: f.named[selected] });
|
||||
const opponentLeader = game.players.find(p => p.id === f.opponent)!.leaders.find(l => !l.dead && !l.usedAt)!;
|
||||
game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id });
|
||||
assert.equal(records(game).find(row => row.player === selected)?.status, 'settled');
|
||||
game = advance(game, g => !g.battle && (g.phase > 6 || g.turn > f.game.turn));
|
||||
assert.equal(records(game).find(row => row.player === nonleader)?.status, 'expired');
|
||||
});
|
||||
@@ -88,11 +88,12 @@ void test('Basic Richese answer survives physical Truthtrance discard and Semuta
|
||||
assert.deepEqual(truthtranceRichesePromisesInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Ecaz participation guards the future-battle question in the view and parser', () => {
|
||||
void test('Advanced Ecaz participation guards the future-battle question in the view and parser', () => {
|
||||
const f = createTruthMovementChoamFixture();
|
||||
// Controlled guard probe: this extra seat is staged on a genuine quiet
|
||||
// CHOAM source; it does not claim to be an Ecaz setup or played journey.
|
||||
let game = structuredClone(f.beforeShipment);
|
||||
game.advanced = true;
|
||||
game.expansions.push('ecaz');
|
||||
game.players.push(newPlayer('ecaz-guard', 'Ecaz', 'ecaz'));
|
||||
assert.deepEqual(viewGame(game, f.asker).truthFutureBattleLeaderTargets, []);
|
||||
|
||||
Reference in new issue
Block a user