Connect mixed No-Field armies to native Occupy battles
This commit is contained in:
1 parent
a58b8bc92c
commit
0dfdeb9578
14 files changed
+778
-43
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { ecazOccupyRicheseFixture, occupyRicheseSeat, openOccupyRichese,
|
||||
finishOccupyRichese, type EcazOccupyRicheseFixture } from './fixture-ecaz-occupy-richese';
|
||||
|
||||
type Lead = 'ecaz' | 'ally';
|
||||
const seat = occupyRicheseSeat;
|
||||
const at = (game: Game, actor: string, fixture: EcazOccupyRicheseFixture) =>
|
||||
seat(game, actor).forces[fixture.location] ?? 0;
|
||||
const income = (game: Game, actor: string, fixture: EcazOccupyRicheseFixture) =>
|
||||
at(game, actor, fixture) > 0 ? 2 : 0;
|
||||
|
||||
function conserved(game: Game): void {
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
|
||||
`${player.faction} retains twenty counters with its original physical owner.`);
|
||||
}
|
||||
}
|
||||
|
||||
function leader(game: Game, actor: string, strongest: boolean) {
|
||||
const available = seat(game, actor).leaders.filter(candidate => !candidate.dead && !candidate.usedAt)
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(available[0]);
|
||||
return available[0];
|
||||
}
|
||||
|
||||
/** Both plans use native leaders without weapons; native aftermath declines
|
||||
* Traitor calls rather than altering the original deal's card custody. */
|
||||
function settle(fixture: EcazOccupyRicheseFixture, lead: Lead, cancel: boolean, enemyFirst: boolean) {
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const originalMarker = structuredClone(seat(fixture.game, fixture.richese).noField!);
|
||||
assert.ok(originalMarker.deployed, 'The fixture shipped an actual original No-Field token.');
|
||||
assert.equal(originalMarker.lastShipped, originalMarker.deployed.tokenId);
|
||||
const karama = seat(fixture.game, fixture.opponent).hand.find(card => card.effect === 'karama');
|
||||
assert.ok(karama);
|
||||
let game = openOccupyRichese(fixture, lead, cancel);
|
||||
const before = structuredClone(game);
|
||||
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
||||
assert.ok(profile);
|
||||
assert.equal(profile.canceled, cancel);
|
||||
assert.equal(profile.payer, actor);
|
||||
assert.equal(profile.planOwner, actor);
|
||||
assert.equal(profile.forceOwner, cancel ? actor : fixture.ally);
|
||||
assert.equal(profile.fixedEcazDial, cancel ? 0 : 3,
|
||||
'The original five Ecaz counters contribute their fixed ceiling, independent of the selected lead.');
|
||||
assert.deepEqual(seat(game, fixture.richese).noField, originalMarker);
|
||||
if (cancel) {
|
||||
assert.ok(!seat(game, fixture.opponent).hand.some(card => card.id === karama.id));
|
||||
assert.ok(game.discard.some(card => card.id === karama.id),
|
||||
'Prevention spends the opposing seat\'s actual original Karama in the native response.');
|
||||
}
|
||||
|
||||
const variable = cancel && actor === fixture.richese ? profile.maxDial
|
||||
: cancel && fixture.opponent === fixture.richese && lead === 'ecaz' ? 3 : 2;
|
||||
const support = variable;
|
||||
const enemyPool = viewGame(game, fixture.opponent).battle!.ownForces!.normal;
|
||||
const enemyDial = fixture.opponent === fixture.richese ? enemyPool : 0;
|
||||
const enemySupport = enemyDial;
|
||||
const ownLeader = leader(game, actor, true);
|
||||
const enemyLeader = leader(game, fixture.opponent, false);
|
||||
const dial = profile.fixedEcazDial + variable;
|
||||
assert.ok(dial + ownLeader.strength > enemyDial + enemyLeader.strength,
|
||||
'The original native leaders make this controlled battle a normal coalition victory.');
|
||||
const ownPlan = { type: 'battlePlan' as const, dial, support, leader: ownLeader.id };
|
||||
const enemyPlan = { type: 'battlePlan' as const, dial: enemyDial, support: enemySupport, leader: enemyLeader.id };
|
||||
const first = enemyFirst ? fixture.opponent : actor;
|
||||
const second = enemyFirst ? actor : fixture.opponent;
|
||||
game = applyAction(game, first, enemyFirst ? enemyPlan : ownPlan);
|
||||
assert.equal(game.battle!.revealed, false);
|
||||
assert.equal(viewGame(game, second).battle!.plans[first], undefined,
|
||||
'The first native seal does not expose the other side\'s plan.');
|
||||
assert.deepEqual(seat(game, fixture.richese).noField, originalMarker);
|
||||
assert.equal(seat(game, fixture.richese).reserves, seat(before, fixture.richese).reserves);
|
||||
assert.equal(at(game, fixture.richese, fixture), at(before, fixture.richese, fixture),
|
||||
'No prospective counters are placed after only one seal.');
|
||||
if (fixture.opponent === fixture.richese) {
|
||||
assert.equal(viewGame(game, actor).battle!.opponentForces, null);
|
||||
assert.equal(viewGame(game, actor).richeseNoField!.private, null);
|
||||
} else if (lead === 'ally') {
|
||||
assert.equal(viewGame(game, fixture.opponent).battle!.opponentForces, null);
|
||||
assert.equal(viewGame(game, fixture.opponent).battle!.ecazOccupy!.profile, null,
|
||||
'A selected Richese plan cannot expose its mixed private maximum before native reveal.');
|
||||
}
|
||||
conserved(game);
|
||||
|
||||
game = applyAction(game, second, enemyFirst ? ownPlan : enemyPlan);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.equal(game.battle!.plans[actor].leader, ownLeader.id);
|
||||
assert.equal(game.battle!.plans[fixture.opponent].dial, enemyDial);
|
||||
assert.equal(seat(game, fixture.richese).noField!.deployed, null);
|
||||
assert.deepEqual(seat(game, fixture.richese).noField!.tokens, originalMarker.tokens);
|
||||
assert.equal(seat(game, fixture.richese).noField!.lastShipped, originalMarker.lastShipped);
|
||||
assert.equal(at(game, fixture.richese, fixture), at(before, fixture.richese, fixture) + fixture.materialized!,
|
||||
'Only the second original seal adds the reserve-capped marker to the existing ordinary army.');
|
||||
assert.equal(seat(before, fixture.richese).reserves - seat(game, fixture.richese).reserves, fixture.materialized);
|
||||
if (fixture.opponent === fixture.richese) {
|
||||
assert.equal(viewGame(game, actor).battle!.opponentForces!.normal, enemyPool,
|
||||
'The opposing mixed total becomes public at its original reveal, not before.');
|
||||
}
|
||||
const revealed = structuredClone(game);
|
||||
conserved(revealed);
|
||||
game = finishOccupyRichese(game);
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(seat(game, actor).leaders.find(candidate => candidate.id === ownLeader.id)!.usedAt, fixture.territory);
|
||||
assert.deepEqual(seat(game, fixture.richese).noField, seat(revealed, fixture.richese).noField,
|
||||
'Native settlement cannot restore the marker or reset its no-repeat shipment history.');
|
||||
assert.equal(seat(before, actor).spice - seat(game, actor).spice, support - income(game, actor, fixture),
|
||||
'Only the selected lead funds the variable support; the fixed Ecaz contribution is free.');
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
assert.equal(seat(game, nonLead).spice, seat(before, nonLead).spice + income(game, nonLead, fixture));
|
||||
assert.equal(seat(before, fixture.opponent).spice - seat(game, fixture.opponent).spice, enemySupport);
|
||||
conserved(game);
|
||||
return { game, before, profile, actor };
|
||||
}
|
||||
|
||||
const opposingPools = [
|
||||
{ marker: 0, richeseReserves: 4, mixedRicheseForces: 2 },
|
||||
{ marker: 5, richeseReserves: 2, mixedRicheseForces: 2 },
|
||||
{ marker: 3, richeseReserves: 0, mixedRicheseForces: 2 },
|
||||
{ marker: 3, richeseReserves: 4, mixedRicheseForces: 1 },
|
||||
] as const;
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const pool of opposingPools)
|
||||
void test(`Mixed opposing Richese ${pool.mixedRicheseForces}+No-Field ${pool.marker}, reserves ${pool.richeseReserves}, ${lead} lead settles natively`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'guild', opponentFaction: 'richese', ...pool });
|
||||
assert.equal(fixture.game.decision?.kind, 'ecazBattleLead', 'Actual chooseBattle admits the mixed opposing army.');
|
||||
conserved(fixture.game);
|
||||
const prepared = openOccupyRichese(fixture, lead);
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const total = pool.mixedRicheseForces + Math.min(pool.marker, pool.richeseReserves);
|
||||
assert.equal(viewGame(prepared, fixture.richese).battle!.ownForces!.normal, total);
|
||||
assert.equal(viewGame(prepared, actor).battle!.opponentForces, null,
|
||||
'Visible ordinary counters do not authorize disclosure of the hidden opposing maximum.');
|
||||
assert.equal(viewGame(prepared, actor).battle!.ecazOccupy!.profile!.maxDial, 7,
|
||||
'The ordinary Guild ally, not the opposing prospective army, supplies the variable Occupy pool.');
|
||||
const { game, before } = settle(fixture, lead, false, pool.marker !== 0);
|
||||
assert.equal(at(game, fixture.ecaz, fixture), 2);
|
||||
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
|
||||
assert.equal(at(game, fixture.ally, fixture), 2);
|
||||
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 2);
|
||||
assert.equal(at(game, fixture.richese, fixture), 0);
|
||||
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, total,
|
||||
'Normal defeat destroys both the original ordinary counters and the actual reserve-limited reveal.');
|
||||
assert.equal(seat(game, fixture.richese).reserves, pool.richeseReserves - fixture.materialized!);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Prevented Occupy against mixed opposing Richese leaves only selected ${lead} losses and payment`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'guild', opponentFaction: 'richese',
|
||||
marker: 5, richeseReserves: 2, mixedRicheseForces: 2 });
|
||||
const { game, before, profile, actor } = settle(fixture, lead, true, true);
|
||||
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : 4);
|
||||
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : 4);
|
||||
const ecazLoss = lead === 'ecaz' ? 3 : 0;
|
||||
const guildLoss = lead === 'ally' ? 2 : 0;
|
||||
assert.equal(at(game, fixture.ecaz, fixture), 5 - ecazLoss);
|
||||
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(at(game, fixture.ally, fixture), 4 - guildLoss);
|
||||
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, guildLoss);
|
||||
assert.equal(at(game, fixture.richese, fixture), 0);
|
||||
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, 4);
|
||||
assert.equal(profile.payer, actor);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const marker of [0, 5] as const)
|
||||
void test(`Prevented Occupy with mixed allied Richese No-Field ${marker} preserves selected ${lead}'s own army`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'richese', opponentFaction: 'guild',
|
||||
marker, richeseReserves: 2, mixedRicheseForces: 2 });
|
||||
const prepared = openOccupyRichese(fixture, lead, true);
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const total = 2 + Math.min(marker, 2);
|
||||
assert.equal(viewGame(prepared, actor).battle!.ownForces!.normal, lead === 'ecaz' ? 5 : total);
|
||||
if (lead === 'ecaz') {
|
||||
assert.equal(viewGame(prepared, fixture.opponent).battle!.opponentForces!.normal, 5,
|
||||
'Prevention restores Ecaz\'s own inspectable army without borrowing any Richese counters.');
|
||||
} else {
|
||||
assert.equal(viewGame(prepared, fixture.opponent).battle!.ecazOccupy!.profile, null);
|
||||
assert.equal(viewGame(prepared, fixture.opponent).battle!.opponentForces, null);
|
||||
}
|
||||
const { game, before, profile } = settle(fixture, lead, true, lead === 'ecaz');
|
||||
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : total);
|
||||
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : total);
|
||||
const ecazLoss = lead === 'ecaz' ? 2 : 0;
|
||||
const richeseLoss = lead === 'ally' ? total : 0;
|
||||
assert.equal(at(game, fixture.ecaz, fixture), 5 - ecazLoss);
|
||||
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(at(game, fixture.richese, fixture), total - richeseLoss,
|
||||
'An unselected Richese army still reveals natively but loses no counters for Ecaz\'s dial.');
|
||||
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, richeseLoss);
|
||||
assert.equal(seat(game, fixture.richese).reserves, 2 - Math.min(marker, 2));
|
||||
assert.equal(seat(game, fixture.opponent).tanks - seat(before, fixture.opponent).tanks, 8);
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
|
||||
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
||||
import { NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS } from './fixture-no-field-reinforcements';
|
||||
import {
|
||||
ecazOccupyMixedNoFieldFixture, sealEcazOccupyMixedNoField, finishEcazOccupyMixedNoField,
|
||||
type EcazOccupyMixedNoFieldFixture,
|
||||
} from './fixture-ecaz-occupy-mixed-no-field';
|
||||
|
||||
function assertCustody(fixture: EcazOccupyMixedNoFieldFixture, game: Game): void {
|
||||
const cards = (state: Game) => [...state.deck, ...state.discard,
|
||||
...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
|
||||
...state.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
assert.deepEqual(cards(game), cards(fixture.initial), 'Every original physical card retains custody.');
|
||||
const traitors = (state: Game) => [...(state.traitorReserve ?? []),
|
||||
...(state.ecazLoyalty?.card ? [state.ecazLoyalty.card] : []),
|
||||
...state.players.flatMap(player => player.traitors)].sort();
|
||||
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
|
||||
for (const player of game.players) {
|
||||
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
|
||||
assert.equal(counters.reduce((sum, count) => sum + count, 0), 20,
|
||||
`${player.faction} keeps its own twenty physical counters.`);
|
||||
assert.ok(counters.every(count => Number.isSafeInteger(count) && count >= 0));
|
||||
assert.ok(player.hand.length <= 4, 'Conserved staging must not leave a native hand over its limit.');
|
||||
}
|
||||
}
|
||||
function assertFirstSeal(fixture: EcazOccupyMixedNoFieldFixture, first: Game): void {
|
||||
const before = occupyRicheseSeat(fixture.preplan, fixture.richese);
|
||||
const sealed = occupyRicheseSeat(first, fixture.richese);
|
||||
assert.equal(first.battle!.revealed, false);
|
||||
assert.deepEqual(sealed.noField, fixture.noField);
|
||||
assert.equal(sealed.noFieldEvent, before.noFieldEvent);
|
||||
assert.equal(sealed.reserves, before.reserves);
|
||||
assert.equal(sealed.forces[fixture.location], fixture.ordinary);
|
||||
assert.equal(sealed.tanks, before.tanks);
|
||||
assert.equal(viewGame(first, fixture.opponent).battle!.plans[fixture.owner], undefined);
|
||||
assertCustody(fixture, first);
|
||||
}
|
||||
function assertReveal(fixture: EcazOccupyMixedNoFieldFixture, revealed: Game): void {
|
||||
const richese = occupyRicheseSeat(revealed, fixture.richese);
|
||||
const original = occupyRicheseSeat(fixture.preplan, fixture.richese);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
assert.equal(richese.noField!.deployed, null);
|
||||
assert.equal(richese.noField!.lastShipped, fixture.noField.deployed!.tokenId);
|
||||
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
|
||||
assert.notEqual(richese.noFieldEvent, original.noFieldEvent);
|
||||
assert.equal(richese.forces[fixture.location], fixture.ordinary + fixture.materializes,
|
||||
'Both actual seals add native marker counters to, rather than replace, ordinary counters.');
|
||||
assert.equal(richese.reserves, original.reserves - fixture.materializes);
|
||||
assert.equal(richese.tanks, original.tanks);
|
||||
assertCustody(fixture, revealed);
|
||||
}
|
||||
function assertHistory(fixture: EcazOccupyMixedNoFieldFixture, revealed: Game, done: Game): void {
|
||||
const richese = occupyRicheseSeat(done, fixture.richese);
|
||||
assert.equal(richese.noField!.deployed, null);
|
||||
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
|
||||
assert.equal(richese.noField!.lastShipped, fixture.noField.lastShipped);
|
||||
assert.equal(richese.noFieldEvent, occupyRicheseSeat(revealed, fixture.richese).noFieldEvent,
|
||||
'Withdrawal, losses and aftermath must not perform a second marker revelation.');
|
||||
for (const actor of [fixture.ecaz, fixture.richese, fixture.opponent]) {
|
||||
const history = viewGame(done, actor).players.find(player => player.id === fixture.richese)!.noField!;
|
||||
assert.equal(history.deployed, null);
|
||||
assert.equal(history.lastUsed, fixture.marker, 'The actual shipped denomination becomes native public history.');
|
||||
}
|
||||
assert.equal(done.battle, null);
|
||||
assert.equal(done.decision, null);
|
||||
assertCustody(fixture, done);
|
||||
}
|
||||
function assertUtilities(game: Game, used: readonly string[]): void {
|
||||
for (const id of [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]) {
|
||||
assert.equal(game.discard.filter(card => card.id === id).length, used.includes(id) ? 1 : 0);
|
||||
assert.equal(game.players.flatMap(player => player.hand).filter(card => card.id === id).length,
|
||||
used.includes(id) ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Three denominations, one reserve-capped positive marker, and both selected
|
||||
// plan actors. Every case retains positive ordinary Richese physical presence.
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
for (const [marker, reserves] of [[0, 0], [3, 6], [5, 2]] as const)
|
||||
void test(`Mixed Richese variable ally: marker ${marker}, reserves ${reserves}, ${lead} lead seals and resolves native counters`, () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture({ lead, marker, richeseReserves: reserves });
|
||||
const actorView = viewGame(fixture.preplan, fixture.owner).battle!;
|
||||
const profile = actorView.ecazOccupy!.profile!;
|
||||
assert.equal(profile.forceOwner, fixture.richese);
|
||||
assert.equal(profile.payer, fixture.owner);
|
||||
assert.equal(profile.fixedEcazDial, 3);
|
||||
assert.equal(profile.maxDial, 3 + fixture.ordinary + fixture.materializes);
|
||||
assert.equal(actorView.ownForces!.normal, fixture.ordinary + fixture.materializes);
|
||||
assert.equal(actorView.mixedNoField, true,
|
||||
'The selected Ecaz plan also uses its physical Richese variable owner, not the Ecaz counter pool.');
|
||||
const rivalView = viewGame(fixture.preplan, fixture.opponent).battle!;
|
||||
assert.equal(rivalView.opponentForces, null);
|
||||
assert.equal(rivalView.ecazOccupy!.profile, null,
|
||||
'Ordinary presence must not disclose the prospective concealed increment.');
|
||||
assert.equal(rivalView.mixedNoField, undefined);
|
||||
const first = applyAction(fixture.preplan, fixture.owner, fixture.plans.plain);
|
||||
assertFirstSeal(fixture, first);
|
||||
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
const richese = occupyRicheseSeat(done, fixture.richese);
|
||||
assert.equal(richese.forces[fixture.location] ?? 0, fixture.ordinary + fixture.materializes - fixture.variableDial);
|
||||
assert.equal(richese.tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, fixture.variableDial);
|
||||
assert.equal(richese.reserves, reserves - fixture.materializes);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.preplan, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.opponent).tanks - occupyRicheseSeat(fixture.preplan, fixture.opponent).tanks, 8);
|
||||
for (const actor of [fixture.ecaz, fixture.richese])
|
||||
assert.equal(occupyRicheseSeat(done, actor).spice,
|
||||
occupyRicheseSeat(fixture.preplan, actor).spice - (actor === fixture.owner ? fixture.support : 0) +
|
||||
((occupyRicheseSeat(done, actor).forces[fixture.location] ?? 0) > 0 ? 2 : 0));
|
||||
assertUtilities(done, []);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
void test('Mixed capped ally uses actual unsupported physical losses, while the original Ecaz fixed commitment stays free', () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture({ lead: 'ecaz', marker: 5, richeseReserves: 2,
|
||||
variableDial: 2, support: 0 });
|
||||
const revealed = sealEcazOccupyMixedNoField(fixture);
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, 4,
|
||||
'Two unsupported strength consumes all four real ordinary-plus-revealed counters.');
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.preplan, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).spice,
|
||||
occupyRicheseSeat(fixture.preplan, fixture.ecaz).spice + 2);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Mixed positive marker with ${lead}-held H/R returns only that card user's actual undialed army`, () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture({ lead });
|
||||
const before = fixture.preplan;
|
||||
const offer = viewGame(before, fixture.owner).battle!;
|
||||
assert.equal(offer.harassWithdraw!.blocked, null);
|
||||
assert.equal(offer.reinforcements!.blocked, null);
|
||||
assert.equal(offer.reinforcements!.normal, 3);
|
||||
if (lead === 'ally') {
|
||||
assert.equal(offer.harassWithdraw!.forces.normal, fixture.ordinary + fixture.materializes);
|
||||
assert.equal(offer.harassWithdraw!.locations[fixture.location].normal, fixture.ordinary + fixture.materializes);
|
||||
assert.equal(offer.reinforcements!.noField!.reservesAfterReveal, 3);
|
||||
} else assert.equal(offer.reinforcements!.noField, undefined);
|
||||
const first = applyAction(before, fixture.owner, fixture.plans.both);
|
||||
assertFirstSeal(fixture, first);
|
||||
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
const ecaz = occupyRicheseSeat(done, fixture.ecaz);
|
||||
const richese = occupyRicheseSeat(done, fixture.richese);
|
||||
const returned = lead === 'ecaz' ? 2 : fixture.ordinary + fixture.materializes - fixture.variableDial;
|
||||
assert.ok(returned > 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).reserves,
|
||||
occupyRicheseSeat(before, fixture.owner).reserves - (lead === 'ally' ? fixture.materializes : 0) - 3 + returned,
|
||||
'Three original reserves are spent before, and cannot be funded by, the future H return.');
|
||||
assert.equal(ecaz.tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3 + (lead === 'ecaz' ? 3 : 0),
|
||||
'Returning Ecaz undialed counters never reduces or recalculates ceil(original five / two).');
|
||||
assert.equal(richese.tanks - occupyRicheseSeat(before, fixture.richese).tanks,
|
||||
fixture.variableDial + (lead === 'ally' ? 3 : 0));
|
||||
assert.equal(ecaz.forces[fixture.location] ?? 0, lead === 'ecaz' ? 0 : 2);
|
||||
assert.equal(richese.forces[fixture.location] ?? 0, lead === 'ally' ? 0 : fixture.ordinary + fixture.materializes - fixture.variableDial);
|
||||
const other = lead === 'ecaz' ? fixture.richese : fixture.ecaz;
|
||||
assert.equal(occupyRicheseSeat(done, other).reserves,
|
||||
occupyRicheseSeat(before, other).reserves - (other === fixture.richese ? fixture.materializes : 0));
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
||||
occupyRicheseSeat(before, fixture.owner).spice - fixture.support);
|
||||
assertUtilities(done, [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
void test('Mixed Richese Reinforcements alone spends post-reveal reserves without returning undialed board counters', () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture();
|
||||
const revealed = sealEcazOccupyMixedNoField(fixture, 'reinforcements');
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.richese);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, 6);
|
||||
assertUtilities(done, [NO_FIELD_REINFORCEMENTS]);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
void test('Mixed Richese unfunded FIRST R and H/R plans reject immutably; a real H-only fallback remains legal', () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture({ marker: 5, richeseReserves: 7 });
|
||||
const before = fixture.preplan;
|
||||
const saved = structuredClone(before);
|
||||
const offer = viewGame(before, fixture.owner).battle!;
|
||||
assert.equal(offer.reinforcements!.normal, 2);
|
||||
assert.equal(offer.reinforcements!.noField!.reservesAfterReveal, 2);
|
||||
assert.ok(offer.reinforcements!.blocked);
|
||||
assert.equal(offer.harassWithdraw!.blocked, null);
|
||||
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
|
||||
assert.throws(() => applyAction(before, fixture.owner, plan), RuleError);
|
||||
assert.deepEqual(before, saved, 'No first seal, card custody, reserve debit or marker reveal survives rejection.');
|
||||
assert.equal(before.battle!.plans[fixture.owner], undefined);
|
||||
}
|
||||
const first = applyAction(before, fixture.owner, fixture.plans.harass);
|
||||
assertFirstSeal(fixture, first);
|
||||
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.richese);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 7 + fixture.ordinary - fixture.variableDial);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, fixture.variableDial);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
||||
assertUtilities(done, [NO_FIELD_HARASS]);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
void test('Ecaz-held mixed H/R cannot spend its ally reserves or its own future Harass return', () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture({ lead: 'ecaz', richeseReserves: 8, ecazReserves: 2 });
|
||||
const before = fixture.preplan;
|
||||
const saved = structuredClone(before);
|
||||
const offer = viewGame(before, fixture.ecaz).battle!.reinforcements!;
|
||||
assert.equal(offer.normal, 2);
|
||||
assert.equal(offer.noField, undefined);
|
||||
assert.ok(offer.blocked);
|
||||
assert.throws(() => applyAction(before, fixture.ecaz, fixture.plans.both), RuleError);
|
||||
assert.deepEqual(before, saved);
|
||||
const revealed = sealEcazOccupyMixedNoField(fixture, 'harass');
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, 4);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 5);
|
||||
assertUtilities(done, [NO_FIELD_HARASS]);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Real opposing Traitor cancels ${lead}-held mixed Harass, but preserves native reveal and the original R cost`, () => {
|
||||
const fixture = ecazOccupyMixedNoFieldFixture({ lead, opposingTraitor: true });
|
||||
const before = fixture.preplan;
|
||||
const leader = fixture.plans.both.leader;
|
||||
assert.ok(typeof leader === 'string');
|
||||
assert.ok(occupyRicheseSeat(before, fixture.opponent).traitors.includes(leader));
|
||||
const revealed = sealEcazOccupyMixedNoField(fixture, 'both');
|
||||
assertReveal(fixture, revealed);
|
||||
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
||||
assert.equal(done.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
|
||||
assert.equal(occupyRicheseSeat(done, fixture.owner).leaders.find(face => face.id === leader)!.dead, true);
|
||||
for (const actor of [fixture.ecaz, fixture.richese]) {
|
||||
const original = occupyRicheseSeat(before, actor);
|
||||
const player = occupyRicheseSeat(done, actor);
|
||||
const materialized = actor === fixture.richese ? fixture.materializes : 0;
|
||||
const reserveCost = actor === fixture.owner ? 3 : 0;
|
||||
const destroyed = actor === fixture.ecaz ? 5 : fixture.ordinary + materialized;
|
||||
assert.equal(player.forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(player.reserves, original.reserves - materialized - reserveCost,
|
||||
'An opposing real Traitor prevents every promised H return, including ordinary counters.');
|
||||
assert.equal(player.tanks - original.tanks, destroyed + reserveCost);
|
||||
}
|
||||
assertUtilities(done, [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]);
|
||||
assertHistory(fixture, revealed, done);
|
||||
});
|
||||
@@ -146,18 +146,6 @@ void test('Original concealed Richese presence permits a zero prospective pool,
|
||||
assert.deepEqual(input, saved);
|
||||
});
|
||||
|
||||
for (const role of ['ally', 'opponent'] as const) void test(`Combined Occupy ordinary-plus-marker ${role} battle is rejected immutably`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
|
||||
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker: 5,
|
||||
richeseReserves: 2, mixedRicheseForces: 1 });
|
||||
const game = fixture.beforeBattle;
|
||||
const saved = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory)!;
|
||||
assert.throws(() => applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }),
|
||||
{ message: 'Combined Occupy No-Field battle dialing awaits a ruling. Ordinary mixed battles use the visible provisional pool.' });
|
||||
assert.deepEqual(game, saved);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides cannot inspect concealed partner's ${lead} plan dial or whole plan`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { NoFieldValue, RicheseNoField } from '../game/richese-no-field';
|
||||
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
|
||||
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { holdOccupyCardsTraitor } from './fixture-ecaz-occupy-cards';
|
||||
import {
|
||||
createNoFieldReinforcementsSetup, NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS,
|
||||
} from './fixture-no-field-reinforcements';
|
||||
import {
|
||||
ecazOccupyRicheseFixture, openOccupyRichese, finishOccupyRichese, nextOccupyRichese,
|
||||
occupyRicheseSeat, type EcazOccupyRicheseFixture,
|
||||
} from './fixture-ecaz-occupy-richese';
|
||||
|
||||
export type EcazOccupyMixedNoFieldOptions = {
|
||||
/** Original fresh Advanced setup; the independent cards must already be selected. */
|
||||
initial?: Game;
|
||||
lead?: 'ecaz' | 'ally';
|
||||
marker?: NoFieldValue;
|
||||
ordinary?: number;
|
||||
richeseReserves?: number;
|
||||
ecazReserves?: number;
|
||||
variableDial?: number;
|
||||
support?: number;
|
||||
opposingTraitor?: boolean;
|
||||
};
|
||||
export type EcazOccupyMixedNoFieldPlan = 'plain' | 'harass' | 'reinforcements' | 'both';
|
||||
export type EcazOccupyMixedNoFieldFixture = {
|
||||
source: EcazOccupyRicheseFixture;
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
/** Admitted coalition, before choosing the lead. */
|
||||
beforeLead: Game;
|
||||
/** Native preparation complete, before either sealed plan; game is the same control. */
|
||||
preplan: Game;
|
||||
game: Game;
|
||||
owner: string;
|
||||
ecaz: string;
|
||||
ally: string;
|
||||
richese: string;
|
||||
opponent: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
ordinary: number;
|
||||
marker: NoFieldValue;
|
||||
materializes: number;
|
||||
noField: RicheseNoField;
|
||||
fixedEcazDial: number;
|
||||
variableDial: number;
|
||||
support: number;
|
||||
opposingTraitor: boolean;
|
||||
plans: Record<EcazOccupyMixedNoFieldPlan | 'opponent', Action>;
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
/** Native Advanced variable ALLY with ordinary counters plus its own concealed
|
||||
* marker. The shared source performs setup, Nexus, conserved board staging,
|
||||
* actual No-Field shipment and battle admission. No phase/window is replaced. */
|
||||
export function ecazOccupyMixedNoFieldFixture(
|
||||
options: EcazOccupyMixedNoFieldOptions = {},
|
||||
): EcazOccupyMixedNoFieldFixture {
|
||||
const marker = options.marker ?? 3;
|
||||
const ordinary = options.ordinary ?? 2;
|
||||
assert.ok(Number.isSafeInteger(ordinary) && ordinary > 0);
|
||||
const source = ecazOccupyRicheseFixture({
|
||||
initial: options.initial ?? createNoFieldReinforcementsSetup(),
|
||||
allyFaction: 'richese', opponentFaction: 'guild', marker,
|
||||
mixedRicheseForces: ordinary, richeseReserves: options.richeseReserves ?? 6,
|
||||
});
|
||||
assert.equal(source.initial.ecazTreachery, true,
|
||||
'Select the original independent H/R inventory before native setup, not afterward.');
|
||||
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
|
||||
// Preserve every original physical face, including auction acquisitions,
|
||||
// before staging two utility faces in a hand below the native four-card cap.
|
||||
for (const player of source.game.players) {
|
||||
source.game.deck.push(...player.hand);
|
||||
player.hand = [];
|
||||
}
|
||||
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
|
||||
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
|
||||
source.staging.push('Return all unselected original hands to the actual remaining deck; stage only the original Harass & Withdraw and Reinforcements identities in the selected lead hand.');
|
||||
if (options.ecazReserves !== undefined) {
|
||||
const player = occupyRicheseSeat(source.game, source.ecaz);
|
||||
assert.ok(Number.isSafeInteger(options.ecazReserves) && options.ecazReserves >= 0 && options.ecazReserves <= player.reserves);
|
||||
const relocated = player.reserves - options.ecazReserves;
|
||||
if (relocated) player.forces['polar_sink:0'] = (player.forces['polar_sink:0'] ?? 0) + relocated;
|
||||
player.reserves -= relocated;
|
||||
source.staging.push(`Conserve Ecaz counters: move ${relocated} unused reserves to Polar Sink, leaving ${options.ecazReserves} own reserves.`);
|
||||
}
|
||||
const beforeLead = structuredClone(source.game);
|
||||
const game = openOccupyRichese(source, options.lead ?? 'ally');
|
||||
const profile = viewGame(game, owner).battle?.ecazOccupy?.profile;
|
||||
assert.ok(profile);
|
||||
const noField = structuredClone(occupyRicheseSeat(game, source.richese).noField!);
|
||||
assert.ok(noField.deployed);
|
||||
const materializes = Math.min(marker, occupyRicheseSeat(game, source.richese).reserves);
|
||||
const physicalTraitors = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)]);
|
||||
const leader = occupyRicheseSeat(game, owner).leaders
|
||||
.filter(face => !face.dead && !face.usedAt && (!options.opposingTraitor || physicalTraitors.has(face.id)))
|
||||
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
const enemyLeader = occupyRicheseSeat(game, source.opponent).leaders
|
||||
.filter(face => !face.dead && !face.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
assert.ok(leader && enemyLeader);
|
||||
if (options.opposingTraitor) {
|
||||
holdOccupyCardsTraitor(game, source.opponent, leader.id);
|
||||
source.staging.push(`Exchange an actual original Traitor Card for ${leader.id} in the opposing hand.`);
|
||||
}
|
||||
const variableDial = options.variableDial ?? Math.min(3, ordinary + materializes);
|
||||
const support = options.support ?? variableDial;
|
||||
const plain: Action = { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
|
||||
support, leader: leader.id };
|
||||
return {
|
||||
source, initial: source.initial, afterSetup: source.afterSetup, beforeLead,
|
||||
preplan: game, game, owner, ecaz: source.ecaz, ally: source.ally, richese: source.richese,
|
||||
opponent: source.opponent, territory: source.territory, location: source.location,
|
||||
ordinary, marker, materializes, noField, fixedEcazDial: profile.fixedEcazDial,
|
||||
variableDial, support, opposingTraitor: options.opposingTraitor ?? false,
|
||||
plans: { plain, harass: { ...plain, defense: NO_FIELD_HARASS },
|
||||
reinforcements: { ...plain, weapon: NO_FIELD_REINFORCEMENTS },
|
||||
both: { ...plain, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS },
|
||||
opponent: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id } },
|
||||
staging: source.staging,
|
||||
};
|
||||
}
|
||||
|
||||
/** The second actual seal, not this helper, produces the native reveal receipt. */
|
||||
export function sealEcazOccupyMixedNoField(fixture: EcazOccupyMixedNoFieldFixture,
|
||||
plan: EcazOccupyMixedNoFieldPlan = 'plain', state = fixture.game): Game {
|
||||
const first = applyAction(state, fixture.owner, fixture.plans[plan]);
|
||||
return applyAction(first, fixture.opponent, fixture.plans.opponent);
|
||||
}
|
||||
|
||||
/** Consume real allocation/response/Traitor decisions, then native aftermath,
|
||||
* including any original CHOAM market. Never clear a live decision or window. */
|
||||
export function finishEcazOccupyMixedNoField(fixture: EcazOccupyMixedNoFieldFixture,
|
||||
state: Game): { afterVotes: Game; game: Game } {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 80; attempt++) {
|
||||
if (game.response || game.phaseOpening) {
|
||||
game = nextOccupyRichese(game);
|
||||
continue;
|
||||
}
|
||||
if (game.decision?.kind === 'harassWithdraw') {
|
||||
const decision = game.decision;
|
||||
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
|
||||
assert.ok(allocation);
|
||||
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
|
||||
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
|
||||
continue;
|
||||
}
|
||||
const battle = viewGame(game, fixture.owner).battle;
|
||||
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
||||
if (!voter) return { afterVotes: game, game: finishOccupyRichese(game) };
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: fixture.opposingTraitor && voter === fixture.opponent });
|
||||
}
|
||||
throw Error('Mixed Occupy native declaration windows did not finish.');
|
||||
}
|
||||
@@ -172,8 +172,7 @@ export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {})
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === battleTerritory);
|
||||
assert.ok(choice, 'Concealed zero remains actual native battle presence.');
|
||||
// Mixed ownership fixtures stop before admission so rejection is testable.
|
||||
if (!mixed) game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
|
||||
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
return { initial, afterSetup, beforeBattle, game, ecaz, ally, opponent, richese,
|
||||
territory: battleTerritory, location, ecazForces,
|
||||
|
||||
Reference in new issue
Block a user