Compose native Stone and Reinforcements without a physical-token bonus
This commit is contained in:
1 parent
be0644c0e9
commit
842fd72761
15 files changed
+280
-65
No files matched your search
@@ -78,6 +78,8 @@ export function stoneHarassCustody(game: Game): void {
|
||||
...(player.faceDancers ?? []).map(card => card.leader)]),
|
||||
].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
|
||||
for (const player of game.players) {
|
||||
// Both native fixture rosters use four-card hands.
|
||||
assert.ok(player.hand.length <= 4, 'Conserved card staging must also respect native hand capacity.');
|
||||
assert.equal(player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
|
||||
@@ -312,6 +314,13 @@ export function stoneHarassOccupyFixture(
|
||||
ecazForces: options.ecazForces ?? 5, allyForces: options.allyForces ?? 4,
|
||||
...(options.opponentForces === undefined ? {} : { opponentForces: options.opponentForces }),
|
||||
...(options.opponentElite === undefined ? {} : { opponentElite: options.opponentElite }) });
|
||||
// Isolate selected battle cards after genuine setup/auctions, before the
|
||||
// lead choice reserves any plan. Return real unselected hands to the deck;
|
||||
// adding Stone, Harass and a paired reserve card must not create a fifth card.
|
||||
for (const player of occupy.game.players) {
|
||||
occupy.game.deck.push(...player.hand);
|
||||
player.hand = [];
|
||||
}
|
||||
const owner = lead === 'ecaz' ? occupy.ecaz : occupy.ally;
|
||||
const harassOwner = options.harass === false ? null : owner;
|
||||
const stoneOwner = options.stone === 'harass' ? owner : occupy.opponent;
|
||||
|
||||
@@ -121,7 +121,7 @@ void test('the losing card cannot enter a Moritani Nexus retention window', () =
|
||||
assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]);
|
||||
});
|
||||
|
||||
void test('selected card requires own funded typed reserves and rejects malformed cards, pairs and unsupported combinations', () => {
|
||||
void test('selected card requires own funded typed reserves and rejects malformed or duplicate commitments', () => {
|
||||
const missing = fixture();
|
||||
delete missing.attacker.reinforcementsReserves;
|
||||
assert.throws(() => quoteBattleResolution(missing), BattleResolutionQuoteError);
|
||||
@@ -139,7 +139,17 @@ void test('selected card requires own funded typed reserves and rejects malforme
|
||||
const duplicate = fixture();
|
||||
duplicate.attacker.plan.defense = reinforcement.id;
|
||||
assert.throws(() => quoteBattleResolution(duplicate), BattleResolutionQuoteError);
|
||||
const stone = fixture();
|
||||
putCard(stone, 'defender', 'weapon', 'stoneBurner');
|
||||
assert.throws(() => quoteBattleResolution(stone), BattleResolutionQuoteError);
|
||||
});
|
||||
|
||||
void test('Stone ignores the Reinforcements score bonus but still charges its original typed reserve cost', () => {
|
||||
const g = fixture();
|
||||
putCard(g, 'defender', 'weapon', 'stoneBurner');
|
||||
g.defender.stoneMode = 'ignore';
|
||||
g.defender.plan.dial = 1;
|
||||
g.defender.plan.support = 1;
|
||||
const q = quoteBattleResolution(g);
|
||||
assert.equal(q.winner, 'd', 'three undialed attacker counters lose to four; +2 is not a physical token bonus');
|
||||
assert.deepEqual(q.stone, {winner: 'defender', attacker: [3], defender: [4]});
|
||||
assert.deepEqual(q.reinforcements, [{player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2}]);
|
||||
assert.deepEqual(q.discarded, [{player: 'a', card: REINFORCEMENTS_CARD}]);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {viewGame, type Game} from '../game/engine';
|
||||
import {holdOccupyCardsTraitor} from './fixture-ecaz-occupy-cards';
|
||||
import {
|
||||
STONE_CARD, MIRROR_CARD, stoneHarassOccupyFixture, stoneHarassPlayer,
|
||||
takeStoneHarassCard, sealStoneHarassPlans, finishStoneHarassBattle, traitorIdentities,
|
||||
} from './fixture-stone-harass';
|
||||
|
||||
const reinforcement = 'ecaz-reinforcements';
|
||||
function prepare({lead = 'ecaz', holder = 'lead', mirror = false, opponentForces = 6,
|
||||
variableDial = 1}: {lead?: 'ecaz' | 'ally'; holder?: 'lead' | 'opponent'; mirror?: boolean;
|
||||
opponentForces?: number; variableDial?: number} = {}) {
|
||||
const source = stoneHarassOccupyFixture({lead, harass: false,
|
||||
stone: mirror ? 'opponent' : 'harass', mirror, variableDial,
|
||||
support: variableDial, ecazForces: 5, allyForces: 4, opponentForces});
|
||||
const owner = holder === 'lead' ? source.lead : source.opponent;
|
||||
takeStoneHarassCard(source.game, owner, reinforcement);
|
||||
const plan = source.plans.find(step => step.actor === owner)!.action;
|
||||
if (plan.weapon) plan.defense = reinforcement;
|
||||
else plan.weapon = reinforcement;
|
||||
return {source, owner};
|
||||
}
|
||||
const count = (game: Game, id: string) => game.discard.filter(card => card.id === id).length;
|
||||
const has = (game: Game, owner: string, id: string) => stoneHarassPlayer(game, owner).hand.some(card => card.id === id);
|
||||
|
||||
void test('same-plan Stone and Reinforcements pay only the actual fixed or variable card user without adding physical strength', () => {
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const winning of [false, true]) {
|
||||
const {source, owner} = prepare({lead, opponentForces: winning ? 4 : 6});
|
||||
const before = structuredClone(source.game);
|
||||
const done = finishStoneHarassBattle(sealStoneHarassPlans(source), {mode: 'ignore'});
|
||||
assert.equal(done.lastBattleContext?.winner, winning ? source.lead : source.opponent,
|
||||
'the five undialed coalition counters beat four but lose to six; Reinforcements adds no tokens');
|
||||
const delta = (id: string, field: 'reserves' | 'tanks') =>
|
||||
stoneHarassPlayer(done, id)[field] - stoneHarassPlayer(before, id)[field];
|
||||
assert.equal(delta(owner, 'reserves'), -3);
|
||||
assert.equal(delta(owner === source.ecaz ? source.ally : source.ecaz, 'reserves'), 0);
|
||||
assert.equal(delta(source.ecaz, 'tanks'), winning ? lead === 'ecaz' ? 6 : 3 : lead === 'ecaz' ? 8 : 5);
|
||||
assert.equal(delta(source.ally, 'tanks'), winning ? lead === 'ally' ? 4 : 1 : lead === 'ally' ? 7 : 4);
|
||||
assert.equal(stoneHarassPlayer(done, source.ecaz).forces[source.location] ?? 0, winning ? 2 : 0);
|
||||
assert.equal(stoneHarassPlayer(done, source.ally).forces[source.location] ?? 0, winning ? 3 : 0);
|
||||
assert.equal(count(done, reinforcement), 1);
|
||||
assert.equal(has(done, source.lead, STONE_CARD), winning);
|
||||
assert.equal(count(done, STONE_CARD), winning ? 0 : 1);
|
||||
assert.equal(done.battle, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('opposing Reinforcements cannot turn a five-token army into seven and still pays its original elite reserve cost', () => {
|
||||
for (const lead of ['ecaz', 'ally'] as const) {
|
||||
const {source, owner} = prepare({lead, holder: 'opponent', variableDial: 0, opponentForces: 5});
|
||||
const player = stoneHarassPlayer(source.game, owner);
|
||||
player.reserves = 3; player.tanks = 12;
|
||||
player.elites!.reserves = 1; player.elites!.tanks = 4;
|
||||
const cost = viewGame(source.game, owner).battle!.reinforcements!;
|
||||
assert.equal(cost.normal, 2); assert.equal(cost.elite, 1);
|
||||
const done = finishStoneHarassBattle(sealStoneHarassPlans(source), {mode: 'ignore'});
|
||||
assert.equal(done.lastBattleContext?.winner, source.lead, 'six physical undialed counters beat five');
|
||||
assert.equal(stoneHarassPlayer(done, owner).reserves, 0);
|
||||
assert.equal(stoneHarassPlayer(done, owner).tanks, 20);
|
||||
assert.equal(stoneHarassPlayer(done, owner).elites!.tanks, 5);
|
||||
assert.equal(count(done, reinforcement), 1);
|
||||
assert.equal(has(done, source.lead, STONE_CARD), true);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the independent copied Stone mode preserves Mirror custody and the reserve fee', () => {
|
||||
for (const lead of ['ecaz', 'ally'] as const) {
|
||||
const {source, owner} = prepare({lead, mirror: true, variableDial: 0, opponentForces: 5});
|
||||
const beforeReserves = stoneHarassPlayer(source.game, owner).reserves;
|
||||
const done = finishStoneHarassBattle(sealStoneHarassPlans(source),
|
||||
{modes: {[source.lead]: 'kill', [source.opponent]: 'ignore'}});
|
||||
assert.equal(done.lastBattleContext?.winner, source.lead);
|
||||
assert.equal(stoneHarassPlayer(done, owner).reserves, beforeReserves - 3);
|
||||
assert.equal(has(done, source.lead, MIRROR_CARD), true);
|
||||
assert.equal(count(done, MIRROR_CARD), 0);
|
||||
assert.equal(count(done, STONE_CARD), 1);
|
||||
assert.equal(count(done, reinforcement), 1);
|
||||
for (const actor of [source.lead, source.opponent])
|
||||
assert.equal(stoneHarassPlayer(done, actor).leaders.find(leader => leader.id === source.leaders[actor])!.dead, true);
|
||||
}
|
||||
});
|
||||
|
||||
void test('opposing successful Traitor overrides Stone kill but does not waive the Reinforcements reserve cost', () => {
|
||||
for (const lead of ['ecaz', 'ally'] as const) {
|
||||
const {source, owner} = prepare({lead, opponentForces: 4});
|
||||
const physical = traitorIdentities(source.game);
|
||||
const leader = stoneHarassPlayer(source.game, source.lead).leaders.find(leader =>
|
||||
!leader.dead && !leader.usedAt && physical.has(leader.id))!;
|
||||
assert.ok(leader);
|
||||
source.plans.find(step => step.actor === source.lead)!.action.leader = leader.id;
|
||||
holdOccupyCardsTraitor(source.game, source.opponent, leader.id);
|
||||
const before = stoneHarassPlayer(source.game, owner).reserves;
|
||||
const done = finishStoneHarassBattle(sealStoneHarassPlans(source), {mode: 'kill', traitors: [source.opponent]});
|
||||
assert.equal(done.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext?.winner, source.opponent);
|
||||
assert.equal(stoneHarassPlayer(done, owner).reserves, before - 3);
|
||||
assert.equal(stoneHarassPlayer(done, source.opponent).leaders.find(candidate =>
|
||||
candidate.id === source.leaders[source.opponent])!.dead, false, 'the defeated Stone attack cannot kill the winning caller’s leader');
|
||||
assert.equal(count(done, reinforcement), 1);
|
||||
assert.equal(count(done, STONE_CARD), 1);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user