Connect independent-card battle and arrival reactions
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
4d769af8c7
commit
6719245f40
10 files changed
+464
-9
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { validateAmbassadors, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { fremenAmbassadorMovement } from '../game/fremen-ambassador';
|
||||
import { guildAmbassadorShipments } from '../game/guild-ambassador';
|
||||
import { finishEcazOccupySetup } from './fixture-ecaz-occupy';
|
||||
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
|
||||
|
||||
const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
const quiet = homeworldEcazClean;
|
||||
|
||||
/** Native public setup: Ecaz and Moritani with a selected Ix deck and
|
||||
* Tleilaxu seat. Only the unopened Spice positions are chosen. */
|
||||
function fixture(first: 'ambassador' | 'terror', effect: AmbassadorEffect = 'ecaz', entrant = 'guild') {
|
||||
let game = createGame('MIXEDARRIVAL', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
|
||||
for (const faction of ['moritani', 'guild', 'tleilaxu'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
|
||||
if (first === 'ambassador') {
|
||||
act('ecaz', {type: 'seatPosition', position: 5});
|
||||
act('moritani', {type: 'seatPosition', position: 1});
|
||||
act('ecaz', {type: 'seatPosition', position: 2});
|
||||
}
|
||||
act('ecaz', {type: 'moduleOption', module: 'ecazTreachery', enabled: true});
|
||||
for (const player of game.players) act(player.id, {type: 'ready'});
|
||||
act('ecaz', {type: 'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.ecazTreachery, true);
|
||||
assert.deepEqual(game.expansions, ['ecaz', 'ix']);
|
||||
game = finishEcazOccupySetup(game);
|
||||
const afterSetup = structuredClone(game);
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const at = game.spiceDeck.findIndex((card, position) => position >= index && 'territory' in card && !card.discovery);
|
||||
assert.ok(at >= index);
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
const step = () => {
|
||||
if (game.phase === 5 && game.active && quiet(game)) act(game.active, {type: 'endMovement'});
|
||||
else {
|
||||
const next = nextHomeworldEcazStep(game);
|
||||
assert.ok(next, `Native continuation ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
};
|
||||
const reach = (predicate: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 900 && !predicate(game); count++) step();
|
||||
assert.ok(predicate(game), 'The native turn must reach its original decision.');
|
||||
};
|
||||
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
const ambassadors = game.ecazAmbassadors!;
|
||||
const chosen = ambassadors.tokens.find(token => token.effect === effect); assert.ok(chosen);
|
||||
// Child cases control only the five-token packet, preserving the original
|
||||
// physical inventory; both actual board placements are still native actions.
|
||||
if (chosen.zone === 'pool') {
|
||||
const replaced = ambassadors.tokens.find(token => token.zone === 'supply' && token.effect !== 'ecaz');
|
||||
assert.ok(replaced);
|
||||
const index = ambassadors.cohort.indexOf(replaced.id); assert.ok(index >= 0);
|
||||
ambassadors.cohort[index] = chosen.id; replaced.zone = 'pool'; chosen.zone = 'supply';
|
||||
validateAmbassadors(ambassadors);
|
||||
}
|
||||
assert.equal(chosen.zone, 'supply');
|
||||
const ambassador = chosen.id;
|
||||
act('ecaz', {type: 'decision', token: ambassador, territory: 'arrakeen'});
|
||||
reach(state => !state.response && state.ecazAmbassadors!.tokens.find(token => token.id === ambassador)!.zone === 'placed');
|
||||
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'moritaniPlacement');
|
||||
const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available')!.id;
|
||||
act('moritani', {type: 'decision', token: terror, territory: 'arrakeen'});
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && quiet(state));
|
||||
const before = structuredClone(game);
|
||||
const action: Action = {type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0};
|
||||
act(entrant, action);
|
||||
reach(state => !!state.pendingArrivalOverlap);
|
||||
assert.equal(game.pendingArrivalOverlap!.first, first);
|
||||
return {game, before, action, ambassador, terror, afterSetup, entrant};
|
||||
}
|
||||
|
||||
for (const first of ['ambassador', 'terror'] as const)
|
||||
void test(`mixed Basic independent cards preserve ${first}-first native arrival and its one payment`, () => {
|
||||
const f = fixture(first), seen: string[] = [];
|
||||
let game = f.game;
|
||||
assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1);
|
||||
for (let count = 0; count < 50 && (game.pendingArrivalOverlap || game.pendingAmbassador ||
|
||||
game.pendingTerrorEntry || !quiet(game)); count++) {
|
||||
let actor: string, action: Action;
|
||||
if (game.response) {
|
||||
const next = nextHomeworldEcazStep(game); assert.ok(next);
|
||||
actor = next.actor; action = next.action;
|
||||
} else if (game.decision?.kind === 'ecazAmbassador') {
|
||||
seen.push('ambassador'); actor = 'ecaz';
|
||||
action = {type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, choice: 'duke', beneficiary: 'ecaz'};
|
||||
} else if (game.decision?.kind === 'moritaniTerror') {
|
||||
const entry = viewGame(game, 'moritani').terrorEntry!; assert.ok('stage' in entry);
|
||||
seen.push(`terror:${entry.stage}`); actor = 'moritani';
|
||||
action = entry.stage === 'offer' ? {type: 'decision', reveal: true} : {type: 'decision', choice: 'spice'};
|
||||
} else throw Error(`Unexpected native overlap continuation: ${game.decision?.kind}`);
|
||||
game = applyAction(game, actor, action);
|
||||
}
|
||||
assert.deepEqual(seen, first === 'ambassador' ? ['ambassador', 'terror:offer', 'terror:robbery'] :
|
||||
['terror:offer', 'terror:robbery', 'ambassador']);
|
||||
const robbery = Math.ceil((seat(f.before, 'guild').spice - 1) / 2);
|
||||
assert.equal(seat(game, 'guild').spice, seat(f.before, 'guild').spice - 1 - robbery);
|
||||
assert.equal(seat(game, 'moritani').spice, seat(f.before, 'moritani').spice + robbery);
|
||||
assert.equal(seat(game, 'guild').reserves, seat(f.before, 'guild').reserves - 1);
|
||||
assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1);
|
||||
assert.equal(game.dukeVidal!.controller, 'ecaz');
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)!.status, 'removed');
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.ambassador)!.zone, 'supply');
|
||||
assert.equal(game.active, 'guild');
|
||||
assert.throws(() => applyAction(game, 'guild', f.action));
|
||||
game = applyAction(game, 'guild', {type: 'endMovement'});
|
||||
assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1);
|
||||
});
|
||||
|
||||
void test('all four AI profiles can legally respond to a mixed independent-card arrival', () => {
|
||||
const f = fixture('terror'), actor = f.game.decision!.player;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action);
|
||||
assert.doesNotThrow(() => applyAction(f.game, actor, action));
|
||||
}
|
||||
});
|
||||
|
||||
for (const effect of ['fremen', 'guild'] as const)
|
||||
void test(`mixed Basic independent cards resume a real ${effect} Ambassador child before original Terror`, () => {
|
||||
// Native Guild entrant does not trigger the Guild Ambassador; Tleilaxu
|
||||
// supplies a separate genuine paid entry for that physical token.
|
||||
const entrant = effect === 'guild' ? 'tleilaxu' : 'guild';
|
||||
const f = fixture('ambassador', effect, entrant);
|
||||
let game = f.game;
|
||||
const entry = game.pendingAmbassador; assert.ok(entry);
|
||||
const before = {spice: seat(game, 'ecaz').spice, reserves: seat(game, 'ecaz').reserves};
|
||||
game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, trigger: true, beneficiary: 'ecaz'});
|
||||
if (effect === 'fremen') {
|
||||
assert.equal(game.pendingAmbassador?.stage, 'move');
|
||||
const options = fremenAmbassadorMovement(game, 'ecaz').sources;
|
||||
const source = options.find(row => row.territory !== 'arrakeen' && row.sectors.some(s => s.forces > 0) &&
|
||||
row.destinations.some(d => d.territory === 'arrakeen' && d.sector === 10));
|
||||
assert.ok(source, 'An original Ecaz force can reach the triggering stronghold.');
|
||||
const sector = source.sectors.find(row => row.forces > 0)!;
|
||||
game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event,
|
||||
forces: {[sector.key]: 1}, territory: 'arrakeen', sector: 10});
|
||||
} else {
|
||||
assert.equal(game.pendingAmbassador?.stage, 'ship');
|
||||
assert.ok(guildAmbassadorShipments(game, 'ecaz').destinations.some(d => d.territory === 'arrakeen' && d.sector === 10));
|
||||
game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, amount: 1, elite: 0,
|
||||
territory: 'arrakeen', sector: 10});
|
||||
assert.equal(seat(game, 'ecaz').reserves, before.reserves - 1);
|
||||
}
|
||||
assert.equal(seat(game, 'ecaz').spice, before.spice);
|
||||
assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz');
|
||||
assert.equal(game.pendingTerrorEntry?.cause, 'ambassador');
|
||||
assert.equal(game.pendingTerrorEntry?.token, f.terror);
|
||||
game = applyAction(game, 'moritani', {type: 'decision', decline: true});
|
||||
assert.equal(game.pendingTerrorEntry?.entrant, entrant);
|
||||
game = applyAction(game, 'moritani', {type: 'decision', decline: true});
|
||||
assert.equal(game.pendingArrivalOverlap, null);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(game.pendingTerrorEntry, null);
|
||||
assert.equal(seat(game, entrant).forces['arrakeen:10'], 1);
|
||||
assert.equal(seat(game, 'ecaz').forces['arrakeen:10'], 1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean,
|
||||
basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
export { act, clean, inventory, player };
|
||||
|
||||
/** Original public setup, Richese cache sale, paid shipments and battle. Only
|
||||
* unopened starting-deck and Spice-card order are selected for the witness. */
|
||||
export function createIndependentSemutaBattleFixture(kind: 'loser-reinforcements' | 'loser-harass' | 'winner-mandatory' | 'winner-selected') {
|
||||
let game = createGame('SEMUTAINDEPENDENTBATTLE', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
|
||||
for (const faction of ['emperor', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const loser = 'atreides', winner = 'emperor', holder = 'guild', location = 'red_chasm:7';
|
||||
const perform = (actor: string, action: Action) => { game = act(game, actor, action); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1800 && !until(game); count++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const reaction = game.pendingTreacheryDiscard.reaction;
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id));
|
||||
assert.ok(actor);
|
||||
perform(actor.id, {type:'semutaPass', event:game.pendingTreacheryDiscard.batch.event});
|
||||
} else {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `Native step required at ${game.phase}`);
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.ok(until(game), 'The real game must reach the requested boundary.');
|
||||
};
|
||||
perform(loser, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const seat of game.players) perform(seat.id, {type:'ready'});
|
||||
perform(loser, {type:'start'});
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
const originalInventory = inventory(game);
|
||||
const cardId = kind === 'loser-harass' ? 'ecaz-harass-withdraw' : 'ecaz-reinforcements';
|
||||
const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield);
|
||||
const reserved = new Set([cardId, shield.id]);
|
||||
const filler = game.deck.filter(card => !reserved.has(card.id)).slice(0, 2); assert.equal(filler.length, 2);
|
||||
const starting = kind === 'winner-mandatory'
|
||||
? [filler[0].id, cardId, filler[1].id, shield.id]
|
||||
: [cardId, shield.id, filler[0].id, filler[1].id];
|
||||
for (let index = 0; index < starting.length; index++) {
|
||||
const from = game.deck.findIndex(card => card.id === starting[index]); assert.ok(from >= index);
|
||||
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
|
||||
}
|
||||
reach(state => state.status === 'playing');
|
||||
assert.ok(player(game, kind === 'winner-mandatory' ? winner : loser).hand.some(card => card.id === cardId));
|
||||
if (kind !== 'winner-mandatory') assert.ok(player(game, winner).hand.some(card => card.id === shield.id));
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const from = game.spiceDeck.findIndex((card, pos) => pos >= index && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= index); game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]);
|
||||
}
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
perform('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID,
|
||||
method:'onceAround', direction:'counterclockwise'});
|
||||
reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
for (let count = 0; !player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot?.active && !lot.outcome);
|
||||
perform(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0});
|
||||
reach(clean);
|
||||
}
|
||||
assert.ok(player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
const shipped = new Set<string>();
|
||||
while (shipped.size < 2) {
|
||||
reach(state => state.phase === 5 && !!state.active && clean(state));
|
||||
const actor = game.active!;
|
||||
if ([loser, winner].includes(actor) && !shipped.has(actor)) {
|
||||
perform(actor, {type:'ship', ...splitLocation(location), amount:actor === winner ? 4 : 1, elite:0, allyPayment:0});
|
||||
reach(clean); shipped.add(actor);
|
||||
}
|
||||
perform(actor, {type:'endMovement'});
|
||||
}
|
||||
reach(state => state.phase === 6 && !state.battle && clean(state));
|
||||
const actor = game.active!;
|
||||
const choice = viewGame(game, actor).battleChoices.find(row => row.territory === 'red_chasm' &&
|
||||
[row.attacker, row.defender].includes(loser) && [row.attacker, row.defender].includes(winner));
|
||||
assert.ok(choice);
|
||||
perform(choice.chooser, {type:'chooseBattle', territory:choice.territory,
|
||||
target:choice.attacker === choice.chooser ? choice.defender : choice.attacker});
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && clean(state));
|
||||
const strongest = player(game, winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => b.strength-a.strength)[0];
|
||||
const weakest = player(game, loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => a.strength-b.strength)[0];
|
||||
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength + 2);
|
||||
perform(winner, {type:'battlePlan', dial:1, support:0, leader:strongest.id, weapon:null,
|
||||
defense:kind === 'winner-mandatory' ? cardId : shield.id});
|
||||
perform(loser, {type:'battlePlan', dial:0, support:0, leader:weakest.id,
|
||||
weapon:kind === 'winner-mandatory' ? null : cardId, defense:null});
|
||||
let final: {actor:string;action:Action} | undefined;
|
||||
for (let count = 0; count < 100; count++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step);
|
||||
if (step.action.type === 'traitorCall') {
|
||||
const voters = viewGame(game, game.host).battle!.traitorVoters;
|
||||
if (voters.every(id => id === step.actor || game.battle!.traitorCalls[id] !== undefined)) { final = step; break; }
|
||||
}
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
assert.ok(final);
|
||||
return {game, loser, winner, holder, location, card:cardId, shield:shield.id, originalInventory,
|
||||
finalActor:final.actor, finalAction:final.action, kind};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Game, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { act, clean, createIndependentSemutaBattleFixture as fixture, inventory, player } from './fixture-semuta-independent-battle';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
function react(game: Game, holder: string, claim: boolean, selected: string) {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
const event = pending.batch.event;
|
||||
if (!claim) {
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
||||
return game;
|
||||
}
|
||||
game = act(game, holder, {type:'semutaCommit', event});
|
||||
return game.pendingTreacheryDiscard?.reaction?.stage === 'select'
|
||||
? act(game, holder, {type:'semutaSelect', event, card:selected}) : game;
|
||||
}
|
||||
|
||||
function advance(game: Game, until: (state: Game) => boolean): Game {
|
||||
for (let count = 0; count < 600 && !until(game); count++) {
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
game = react(game, '', false, '');
|
||||
continue;
|
||||
}
|
||||
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game)); return game;
|
||||
}
|
||||
|
||||
for (const kind of ['loser-reinforcements', 'loser-harass', 'winner-mandatory', 'winner-selected'] as const)
|
||||
for (const claim of [false, true]) void test(`Basic independent battle ${kind}: ${claim ? 'claim' : 'pass'} without replay`, () => {
|
||||
const f = fixture(kind);
|
||||
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
||||
if (kind === 'winner-selected') {
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
||||
game = react(game, f.holder, false, f.card);
|
||||
game = advance(game, state => state.decision?.kind === 'battleCards' && state.decision.player === f.winner);
|
||||
assert.ok(player(game, f.winner).hand.some(card => card.id === f.shield));
|
||||
game = act(game, f.winner, {type:'decision', discard:[f.shield]});
|
||||
}
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.batch.cause, kind === 'winner-selected' ? 'battle:winner' :
|
||||
kind === 'winner-mandatory' ? 'battle:winnerMandatory' : 'battle:mandatory');
|
||||
const card = kind === 'winner-selected' ? f.shield : f.card;
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
||||
const before = structuredClone(game);
|
||||
const ownTanks = player(before, f.winner).tanks;
|
||||
const ownForces = player(before, f.winner).forces[f.location] ?? 0;
|
||||
const casualties = pending.continuation.kind === 'battleResolved' ? pending.continuation.casualties : undefined;
|
||||
const immediateLoss = casualties?.options.length === 1
|
||||
? casualties.options[0].normal + casualties.options[0].elite : 0;
|
||||
game = react(game, f.holder, claim, card);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game, f.loser).tanks, player(before, f.loser).tanks);
|
||||
assert.equal(player(game, f.winner).tanks, ownTanks + immediateLoss);
|
||||
assert.equal(player(game, f.winner).forces[f.location] ?? 0,
|
||||
ownForces - immediateLoss);
|
||||
assert.equal(game.lastBattleContext?.event, before.lastBattleContext?.event);
|
||||
for (const seat of game.players) assert.equal(seat.spice, player(before, seat.id).spice);
|
||||
assert.equal(player(game, f.holder).hand.some(held => held.id === card), claim);
|
||||
assert.equal(game.discard.some(discarded => discarded.id === card), !claim);
|
||||
assert.equal(game.discard.some(discarded => discarded.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.throws(() => act(game, f.holder, {type:'semutaCommit', event:pending.batch.event}));
|
||||
game = advance(game, state => state.phase >= 7 && clean(state));
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(player(game, f.winner).tanks,
|
||||
player(f.game, f.winner).tanks + 1 + (kind === 'winner-mandatory' ? 3 : 0));
|
||||
if (kind === 'winner-mandatory')
|
||||
assert.equal(player(game, f.winner).reserves, player(f.game, f.winner).reserves - 3);
|
||||
assert.equal(player(game, f.winner).forces[f.location], 3);
|
||||
});
|
||||
|
||||
void test('each AI profile may legally answer the native independent battle discard', () => {
|
||||
const f = fixture('loser-reinforcements'), pending = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = structuredClone(pending);
|
||||
player(game, f.holder).bot = difficulty;
|
||||
const action = botActions(viewGame(game, f.holder)).find(candidate =>
|
||||
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
|
||||
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('an earlier pending battle discard resumes automatically without creating an offer', () => {
|
||||
const f = fixture('loser-reinforcements');
|
||||
const produced = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
||||
assert.equal(produced.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
||||
assert.ok(produced.pendingTreacheryDiscard?.reaction);
|
||||
// Model a saved automatic continuation created before this battle source was
|
||||
// admitted to the independent-card Semuta preview; all other facts are real.
|
||||
const saved = structuredClone(produced);
|
||||
delete saved.pendingTreacheryDiscard!.reaction;
|
||||
assert.equal(viewGame(saved, f.holder).automaticContinuationPending, true);
|
||||
assert.equal(viewGame(saved, f.holder).semutaReaction, null);
|
||||
const resumed = act(saved, f.holder, {type:'advanceBots'});
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(viewGame(resumed, f.holder).semutaReaction, null);
|
||||
assert.equal(resumed.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(player(resumed, f.loser).tanks, player(saved, f.loser).tanks);
|
||||
const continuation = saved.pendingTreacheryDiscard!.continuation;
|
||||
const casualties = continuation.kind === 'battleResolved' ? continuation.casualties : undefined;
|
||||
const immediateLoss = casualties?.options.length === 1
|
||||
? casualties.options[0].normal + casualties.options[0].elite : 0;
|
||||
assert.equal(player(resumed, f.winner).tanks, player(saved, f.winner).tanks + immediateLoss);
|
||||
assert.equal(resumed.lastBattleContext?.event, saved.lastBattleContext?.event);
|
||||
assert.deepEqual(inventory(resumed), f.originalInventory);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { actIndependentOrdinary as act, createIndependentOrdinaryFixture as fixture,
|
||||
independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player } from './fixture-semuta-independent-ordinary';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
void test('an older pending ordinary discard continues without creating a new Semuta offer', () => {
|
||||
const f = fixture('hajr');
|
||||
const played = act(f.game, f.owner, f.play);
|
||||
assert.ok(played.pendingTreacheryDiscard?.reaction);
|
||||
const saved = structuredClone(played);
|
||||
delete saved.pendingTreacheryDiscard!.reaction;
|
||||
assert.equal(viewGame(saved, f.owner).automaticContinuationPending, true);
|
||||
const resumed = applyAction(saved, f.owner, {type: 'advanceBots'});
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.ok(resumed.hajr.includes(f.owner));
|
||||
assert.ok(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(resumed.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(resumed), f.originalInventory);
|
||||
});
|
||||
Reference in new issue
Block a user