Connect mixed No-Field armies to native Occupy battles

This commit is contained in:
Quintine committed 2026-10-16 09:12:57 +11:00
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);
});
+267
View File
@@ -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);
});
-12
View File
@@ -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 });
+158
View File
@@ -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.');
}
+1 -2
View File
@@ -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,