Connect Semuta to Moritani initial battle discards
This commit is contained in:
1 parent
d0e4c177e4
commit
0406fc1869
10 files changed
+283
-15
No files matched your search
@@ -32,6 +32,9 @@ function battle(holdCard: boolean, advanced = false, playedCard = true,
|
||||
cards.hands[winnerId] = 'choam';
|
||||
}
|
||||
Object.assign(game, { active: winnerId, order: ids, phase: 6, storm: 18 });
|
||||
// This deliberately staged battle skips the preceding Charity phase, so its
|
||||
// Advanced-only ordering receipt cannot remain active in the battle fixture.
|
||||
delete game.charityOrder;
|
||||
for (const player of game.players) {
|
||||
player.forces = player.id === observerId ? {} : { 'arrakeen:10': 10 };
|
||||
player.reserves = player.id === observerId ? 20 : 10;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
semutaMoritaniRetentionFixture,
|
||||
resolveSemutaMoritaniBattle,
|
||||
finishSemutaMoritaniWinner,
|
||||
chooseSemutaMoritaniRetention,
|
||||
passSemutaMoritaniDiscard,
|
||||
semutaMoritaniPlayer as player,
|
||||
semutaMoritaniInventory as inventory,
|
||||
semutaMoritaniResources as resources,
|
||||
semutaMoritaniReload as reload,
|
||||
} from './fixture-semuta-moritani-retention';
|
||||
|
||||
function rejectWithoutMutation(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Semuta ${claim ? 'claims' : 'passes'} a losing allied Hero before Moritani retention`, () => {
|
||||
const fixture = semutaMoritaniRetentionFixture();
|
||||
// Controlled physical-card substitution: retain the same genuine Basic
|
||||
// Richese/Moritani roster and battle; move one existing Hero to the loser.
|
||||
const heroIndex = fixture.game.deck.findIndex(card => card.kind === 'hero');
|
||||
assert.notEqual(heroIndex, -1, 'A physical Cheap Hero must be available.');
|
||||
const [hero] = fixture.game.deck.splice(heroIndex, 1);
|
||||
player(fixture.game, 'l').hand.push(hero);
|
||||
fixture.loserLeader = hero.id;
|
||||
fixture.loserStrength = 0;
|
||||
assert.deepEqual(inventory(fixture.game), fixture.cards);
|
||||
|
||||
const pending = resolveSemutaMoritaniBattle(fixture);
|
||||
const original = structuredClone(resources(pending));
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const receipt = pending.pendingTreacheryDiscard!;
|
||||
assert.equal(receipt.batch.cause, 'battle:mandatory');
|
||||
assert.equal(receipt.continuation.kind, 'battleResolved');
|
||||
assert.deepEqual(receipt.batch.entries.map(entry => [entry.discardedBy, entry.card.id]), [['l', hero.id]]);
|
||||
assert.equal(pending.discard.filter(card => card.id === hero.id).length, 1);
|
||||
assert.equal(pending.moritaniRetention?.stage, 'choose');
|
||||
assert.deepEqual(pending.moritaniRetention!.played, [fixture.weapon, fixture.defense]);
|
||||
assert.deepEqual(pending.moritaniRetention!.eligible, [fixture.weapon, fixture.defense]);
|
||||
assert.equal(player(pending, 'l').hand.some(card => card.id === hero.id), false);
|
||||
const event = receipt.batch.event;
|
||||
for (const id of ['r', 'm', 'w', 'l'])
|
||||
assert.deepEqual(viewGame(reload(pending), id).semutaReaction?.candidates, []);
|
||||
rejectWithoutMutation(pending, 'l', { type: 'decision', keep: fixture.weapon });
|
||||
rejectWithoutMutation(pending, 'r', { type: 'semutaCommit', event: 'stale-event' });
|
||||
|
||||
let resumed = claim
|
||||
? applyAction(reload(pending), 'r', { type: 'semutaCommit', event })
|
||||
: passSemutaMoritaniDiscard(reload(pending));
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(resumed.decision?.kind, 'battleCards');
|
||||
assert.deepEqual(resources(resumed), original);
|
||||
assert.equal(player(resumed, 'r').hand.some(card => card.id === hero.id), claim);
|
||||
assert.equal(resumed.discard.some(card => card.id === hero.id), !claim);
|
||||
assert.equal(resumed.discard.filter(card => card.id === fixture.semuta).length, Number(claim));
|
||||
assert.deepEqual(resumed.moritaniRetention, pending.moritaniRetention);
|
||||
|
||||
resumed = finishSemutaMoritaniWinner(resumed);
|
||||
assert.equal(resumed.decision?.kind, 'moritaniRetention');
|
||||
rejectWithoutMutation(resumed, 'l', { type: 'decision', keep: hero.id });
|
||||
const later = chooseSemutaMoritaniRetention(resumed, fixture.weapon);
|
||||
assert.equal(later.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(later.pendingTreacheryDiscard!.batch.cause, 'battle:moritani');
|
||||
assert.deepEqual(later.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [fixture.defense]);
|
||||
const final = passSemutaMoritaniDiscard(later);
|
||||
assert.equal(final.moritaniRetention, null);
|
||||
assert.equal(final.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(final, 'l').hand.some(card => card.id === fixture.weapon), true);
|
||||
assert.equal(final.discard.some(card => card.id === fixture.defense), true);
|
||||
assert.deepEqual(resources(final), original);
|
||||
assert.deepEqual(inventory(final), fixture.cards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { quoteBattleResolution, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
|
||||
const cards = () => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
||||
|
||||
function alliedBattle(): BattleResolutionInput {
|
||||
const side = (id: string, faction: 'emperor' | 'guild', dial: number): ResolutionCombatant => {
|
||||
const leader = leaders(faction)[0];
|
||||
return { id, faction, spice: 10, hand: [], leader,
|
||||
plan: { dial, support: 0, leader: leader.id, weapon: null, defense: null },
|
||||
forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false } };
|
||||
};
|
||||
const attacker = side('winner', 'emperor', 4);
|
||||
const defender = side('loser', 'guild', 0);
|
||||
defender.ally = 'moritani';
|
||||
return { advanced: false, turn: 2, territory: 'arrakeen', attacker, defender,
|
||||
voters: [
|
||||
{ id: 'winner', beneficiary: 'winner', called: false, traitors: [] },
|
||||
{ id: 'loser', beneficiary: 'loser', called: false, traitors: [] },
|
||||
],
|
||||
participants: [
|
||||
{ id: 'winner', faction: 'emperor' },
|
||||
{ id: 'loser', faction: 'guild', ally: 'moritani' },
|
||||
{ id: 'moritani', faction: 'moritani', ally: 'loser' },
|
||||
],
|
||||
physicalCards: cards(), pendingAuditorPresent: false, pendingRetentionPresent: false };
|
||||
}
|
||||
|
||||
function play(g: BattleResolutionInput, slot: 'leader' | 'weapon' | 'defense', kind: Card['kind']) {
|
||||
const card = cards().find(candidate => candidate.kind === kind &&
|
||||
!g.defender.hand.some(existing => existing.id === candidate.id));
|
||||
assert.ok(card, `Missing physical ${kind} card`);
|
||||
g.defender.hand = [...g.defender.hand, card];
|
||||
g.defender.plan[slot] = card.id;
|
||||
if (slot === 'leader') delete g.defender.leader;
|
||||
return card.id;
|
||||
}
|
||||
|
||||
function callLosingLeader(g: BattleResolutionInput) {
|
||||
const voter = g.voters.find(candidate => candidate.id === 'winner')!;
|
||||
voter.called = true;
|
||||
voter.traitors = [g.defender.plan.leader!];
|
||||
}
|
||||
|
||||
void test('early native Moritani cleanup places the losing Hero in the first discard while preserving its other card', () => {
|
||||
const g = alliedBattle();
|
||||
const hero = play(g, 'leader', 'hero'), weapon = play(g, 'weapon', 'projectile');
|
||||
g.nativeMoritaniEarlyMandatoryDiscard = true;
|
||||
const before = structuredClone(g);
|
||||
const q = quoteBattleResolution(g);
|
||||
assert.equal(q.winner, 'winner');
|
||||
assert.deepEqual(q.retention?.played, [weapon]);
|
||||
assert.deepEqual(q.retention?.eligible, [weapon]);
|
||||
assert.deepEqual(q.discarded, [{ player: 'loser', card: hero }]);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('legacy/default Moritani cleanup retains its original whole played set until the later choice', () => {
|
||||
const g = alliedBattle();
|
||||
const hero = play(g, 'leader', 'hero'), weapon = play(g, 'weapon', 'projectile');
|
||||
for (const option of [undefined, false]) {
|
||||
g.nativeMoritaniEarlyMandatoryDiscard = option;
|
||||
const before = structuredClone(g), q = quoteBattleResolution(g);
|
||||
assert.equal(q.winner, 'winner');
|
||||
assert.deepEqual(new Set(q.retention?.played), new Set([hero, weapon]));
|
||||
assert.deepEqual(q.retention?.eligible, [weapon]);
|
||||
assert.equal(q.discarded.some(entry => entry.player === 'loser'), false);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
for (const used of [false, true]) for (const traitor of [false, true]) {
|
||||
void test(`early Moritani cleanup ${used ? 'used' : 'unused'} Poison Tooth with ${traitor ? 'a traitor call' : 'normal battle'}`, () => {
|
||||
const g = alliedBattle();
|
||||
const tooth = play(g, 'weapon', 'poisonTooth'), defense = play(g, 'defense', 'shield');
|
||||
g.defender.poisonTooth = used;
|
||||
g.nativeMoritaniEarlyMandatoryDiscard = true;
|
||||
if (traitor) callLosingLeader(g);
|
||||
const before = structuredClone(g), q = quoteBattleResolution(g);
|
||||
assert.equal(q.winner, 'winner');
|
||||
const mandatory = used && !traitor;
|
||||
assert.equal(q.retention!.played.includes(tooth), !mandatory);
|
||||
assert.equal(q.retention!.eligible.includes(tooth), !mandatory);
|
||||
assert.equal(q.retention!.played.includes(defense), true);
|
||||
assert.equal(q.retention!.eligible.includes(defense), true);
|
||||
assert.deepEqual(q.discarded.filter(entry => entry.player === 'loser'),
|
||||
mandatory ? [{ player: 'loser', card: tooth }] : []);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
}
|
||||
|
||||
for (const traitor of [false, true]) {
|
||||
void test(`early Moritani cleanup Artillery ${traitor ? 'prevented by a traitor call' : 'used in battle'}`, () => {
|
||||
const g = alliedBattle();
|
||||
const artillery = play(g, 'weapon', 'artillery'), defense = play(g, 'defense', 'shield');
|
||||
g.nativeMoritaniEarlyMandatoryDiscard = true;
|
||||
if (traitor) callLosingLeader(g);
|
||||
const before = structuredClone(g), q = quoteBattleResolution(g);
|
||||
assert.equal(q.winner, 'winner');
|
||||
assert.equal(q.retention!.played.includes(artillery), traitor);
|
||||
assert.equal(q.retention!.eligible.includes(artillery), traitor);
|
||||
assert.equal(q.retention!.played.includes(defense), true);
|
||||
assert.equal(q.retention!.eligible.includes(defense), true);
|
||||
assert.deepEqual(q.discarded.filter(entry => entry.player === 'loser'),
|
||||
traitor ? [] : [{ player: 'loser', card: artillery }]);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user