Compose native Richese No-Field and Stone with Advanced Occupy
This commit is contained in:
1 parent
03c77604de
commit
113667da55
23 files changed
+985
-246
No files matched your search
@@ -1,83 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { ShipmentClaimFields, invalidShipmentClause } from '../components/shipment-claim-fields';
|
||||
import { truthQuestionText, type TruthQuestion } from '../game/truthtrance';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { ShipmentPromises } from '../components/shipment-promises';
|
||||
import { compoundShipmentGame, askCompoundShipment } from './fixture-compound-shipment';
|
||||
|
||||
const aliases = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
} });
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
const clauses = [{ territory: 'carthag', minimum: 6 }, { territory: 'arrakeen', minimum: 4 }];
|
||||
function markup(join: 'single' | 'and' | 'or', minimum = 4) {
|
||||
return renderToStaticMarkup(createElement(ShipmentClaimFields, {
|
||||
id: 'shipment-test', join, clauses: [clauses[0], { ...clauses[1], minimum }],
|
||||
onJoin() {}, onClauses() {},
|
||||
}));
|
||||
}
|
||||
void test('compound shipment fields distinguish one shipment from two and retain accessible per-clause labels', () => {
|
||||
assert.equal((markup('single').match(/<fieldset/g) ?? []).length, 1);
|
||||
for (const join of ['and', 'or'] as const) {
|
||||
const html = markup(join);
|
||||
assert.equal((html.match(/<fieldset/g) ?? []).length, 2);
|
||||
assert.match(html, /All conditions describe the same shipment/);
|
||||
assert.match(html, /A No answer requires the whole statement to be false/);
|
||||
assert.equal((html.match(/This-turn shipment destination/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/Minimum physical forces from reserves/g) ?? []).length, 2);
|
||||
const question: TruthQuestion = { kind: 'shipment', target: 'p', claim: { op: join, terms: clauses } };
|
||||
const text = truthQuestionText(question, id => id);
|
||||
assert.match(text, /Carthag/);
|
||||
assert.match(text, /Arrakeen/);
|
||||
assert.match(text, new RegExp(join.toUpperCase()));
|
||||
}
|
||||
});
|
||||
import { invalidShipmentClause } from '../components/shipment-claim-fields';
|
||||
|
||||
void test('empty, fractional and out-of-range compound counts stay invalid rather than coercing to a different promise', () => {
|
||||
for (const minimum of [NaN, 0, 2.5, 21]) {
|
||||
assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), true);
|
||||
assert.equal((markup('or', minimum).match(/aria-invalid="true"/g) ?? []).length, 1);
|
||||
}
|
||||
for (const minimum of [1, 20]) assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), false);
|
||||
});
|
||||
|
||||
void test('the real answer panel explains whole-expression No and offers answers only to the respondent', () => {
|
||||
const g = askCompoundShipment(compoundShipmentGame(true), { op: 'or', terms: clauses });
|
||||
const render = (me: string, busy = false) => renderToStaticMarkup(createElement(Truthtrance, {
|
||||
game: viewGame(g, me), act() {}, busy,
|
||||
}));
|
||||
const own = render('p');
|
||||
assert.match(own, /Answer Yes/);
|
||||
assert.match(own, /Answer No/);
|
||||
assert.match(own, /AND requires all conditions; OR requires at least one/);
|
||||
assert.match(own, /No makes the whole claim false/);
|
||||
assert.match(render('p', true), /<button[^>]*disabled=""[^>]*>Answer Yes/);
|
||||
for (const other of ['a', 'o']) {
|
||||
assert.doesNotMatch(render(other), />Answer (Yes|No)</);
|
||||
assert.match(render(other), /Waiting for Shipper to answer/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('accepted compound promises render grouped private guidance and preserve the server completion', () => {
|
||||
for (const op of ['and', 'or'] as const) for (const answer of ['yes', 'no'] as const) {
|
||||
const terms = op === 'and' ? [clauses[0], { territory: 'carthag', minimum: 4 }] : clauses;
|
||||
const asked = askCompoundShipment(compoundShipmentGame(true), { op, terms });
|
||||
const promised = applyAction(asked, 'p', { type: 'truthAnswer', answer });
|
||||
const view = viewGame(promised, 'p');
|
||||
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: view, act() {}, busy: false }));
|
||||
assert.match(html, new RegExp(`You answered ${answer === 'yes' ? 'Yes' : 'No'}`));
|
||||
assert.match(html, new RegExp(op.toUpperCase()));
|
||||
assert.match(html, /Carthag/);
|
||||
assert.doesNotMatch(html, /undefined/);
|
||||
if (answer === 'yes') assert.match(html, /Suggested next step/);
|
||||
for (const rival of ['a', 'o']) assert.equal(renderToStaticMarkup(createElement(ShipmentPromises, {
|
||||
game: viewGame(promised, rival), act() {}, busy: false,
|
||||
})), '');
|
||||
}
|
||||
});
|
||||
@@ -73,14 +73,6 @@ void test('factory instances and their mutable cards are independent of canonica
|
||||
assert.equal(second.length, 3);
|
||||
assert.equal(second[0].name, 'Recruits');
|
||||
assert.equal(ECAZ_TREACHERY_DEFINITIONS[0].card.name, 'Recruits');
|
||||
assert.ok(Object.isFrozen(ECAZ_TREACHERY_DEFINITIONS));
|
||||
for (const definition of ECAZ_TREACHERY_DEFINITIONS) {
|
||||
assert.ok(Object.isFrozen(definition));
|
||||
assert.ok(Object.isFrozen(definition.card));
|
||||
assert.ok(Object.isFrozen(definition.gameplay));
|
||||
assert.ok(Object.isFrozen(definition.battleSlots));
|
||||
assert.ok(Object.isFrozen(definition.verification.unresolved));
|
||||
}
|
||||
});
|
||||
|
||||
void test('slot occupancy never reclassifies the new special cards as weapons or defenses', () => {
|
||||
@@ -116,50 +108,6 @@ void test('presentation lookup requires the canonical physical ID, kind and effe
|
||||
assert.equal(ecazTreacheryDefinition(baseDeck()[0]), undefined);
|
||||
});
|
||||
|
||||
void test('verified inventory and source rules remain distinct from unimplemented runtime and unresolved interactions', () => {
|
||||
for (const definition of ECAZ_TREACHERY_DEFINITIONS) {
|
||||
assert.equal(definition.quantity, 1);
|
||||
assert.equal(definition.verification.inventory, 'verified');
|
||||
assert.equal(definition.verification.sourceRules, 'verified');
|
||||
assert.equal(
|
||||
definition.verification.runtime,
|
||||
definition.card.effect === 'reinforcements' ? 'not-implemented' : 'prototype',
|
||||
);
|
||||
assert.equal(definition.verification.combinedInteractions, 'incomplete');
|
||||
assert.ok(definition.verification.unresolved.length > 0);
|
||||
assert.ok(definition.summary.length > 20);
|
||||
assert.ok(definition.gameplay.length >= 4);
|
||||
assert.ok(definition.gameplay.every((paragraph) => paragraph.length > 15));
|
||||
}
|
||||
});
|
||||
|
||||
void test('every canonical Ecaz face receives its full original gameplay guide and a concise normal-card summary', () => {
|
||||
for (const card of ecazTreacheryCards()) {
|
||||
const definition = ecazTreacheryDefinition(card)!;
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.guidance, definition.summary);
|
||||
assert.deepEqual(presentation.gameplay, definition.gameplay);
|
||||
assert.equal(presentation.category, 'Special treachery');
|
||||
assert.equal(presentation.role, 'utility');
|
||||
assert.deepEqual(Object.keys(presentation).sort(), [
|
||||
'availability',
|
||||
'category',
|
||||
'gameplay',
|
||||
'guidance',
|
||||
'role',
|
||||
'topics',
|
||||
]);
|
||||
const serialized = JSON.stringify(presentation);
|
||||
for (const note of definition.verification.unresolved)
|
||||
assert.equal(serialized.includes(note), false);
|
||||
assert.equal(serialized.includes('sourceRules'), false);
|
||||
assert.equal(serialized.includes('not-implemented'), false);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits']);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /development controls in either battle-card slot/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('missing, mismatched and merely similar Ecaz identities do not receive a canonical gameplay guide', () => {
|
||||
for (const card of ecazTreacheryCards()) {
|
||||
@@ -184,31 +132,3 @@ void test('missing, mismatched and merely similar Ecaz identities do not receive
|
||||
}
|
||||
});
|
||||
|
||||
void test('base and Ix card guides retain their role, summary and reference topics without Ecaz enrichment', () => {
|
||||
for (const card of [...baseDeck(), ...ixDeck()]) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.gameplay, undefined);
|
||||
assert.ok(presentation.guidance.length > 20);
|
||||
}
|
||||
const weapon = cardPresentation(baseDeck()[0]);
|
||||
assert.equal(weapon.role, 'weapon');
|
||||
assert.equal(
|
||||
weapon.guidance,
|
||||
'Play in the weapon slot of your battle plan. A projectile weapon kills an opposing leader who has no projectile defense.',
|
||||
);
|
||||
assert.ok(weapon.topics.some((topic) => topic.id === 'battle-cards'));
|
||||
const poisonTooth = cardPresentation(
|
||||
ixDeck().find((card) => card.kind === 'poisonTooth')!,
|
||||
);
|
||||
assert.equal(poisonTooth.role, 'weapon');
|
||||
assert.ok(
|
||||
poisonTooth.topics.some((topic) => topic.id === 'card-poison-tooth'),
|
||||
);
|
||||
const hero = cardPresentation(
|
||||
baseDeck().find((card) => card.kind === 'hero')!,
|
||||
);
|
||||
assert.equal(hero.role, 'leader');
|
||||
assert.ok(hero.topics.some((topic) => topic.id === 'cheap-hero-traitor'));
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { quoteEcazOccupyBattle, quoteEcazOccupyDial, type EcazOccupyBattleInput } from '../game/ecaz-occupy-battle';
|
||||
import { ecazOccupyRicheseFixture, occupyRicheseSeat, openOccupyRichese,
|
||||
nextOccupyRichese, finishOccupyRichese, type EcazOccupyRicheseFixture } from './fixture-ecaz-occupy-richese';
|
||||
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
|
||||
function conserve(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} counters retain their original physical owner.`);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), player.faction === 'ixians' ? 7 : 5);
|
||||
}
|
||||
}
|
||||
function resolve(fixture: EcazOccupyRicheseFixture, lead: 'ecaz' | 'ally', cancel = false) {
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
let game = openOccupyRichese(fixture, lead, cancel);
|
||||
const before = structuredClone(game);
|
||||
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
||||
const variable = Math.min(2, profile.maxDial - profile.fixedEcazDial);
|
||||
const support = profile.forces.normalFixedHalf ? 0 : variable;
|
||||
const leader = occupyRicheseSeat(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemyLeader = occupyRicheseSeat(game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && enemyLeader);
|
||||
const concealed = structuredClone(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable, support, leader: leader.id });
|
||||
assert.equal(game.battle!.plans[actor].leader, leader.id, 'The selected faction commits its real native leader.');
|
||||
assert.equal(game.battle!.revealed, false);
|
||||
assert.deepEqual(occupyRicheseSeat(game, fixture.richese).noField!.deployed, concealed,
|
||||
'The first sealed plan cannot reveal its physical partner marker.');
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).reserves, occupyRicheseSeat(before, fixture.richese).reserves);
|
||||
assert.equal(viewGame(game, fixture.opponent).battle!.plans[actor], undefined);
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id });
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.equal(game.battle!.plans[actor].leader, leader.id);
|
||||
const revealed = structuredClone(game);
|
||||
if (fixture.materialized !== null) {
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).noField!.deployed, null);
|
||||
assert.equal(occupyRicheseSeat(before, fixture.richese).reserves - occupyRicheseSeat(game, fixture.richese).reserves,
|
||||
fixture.materialized, 'Both sealed plans materialize only available owner reserves, once.');
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, fixture.materialized);
|
||||
}
|
||||
game = finishOccupyRichese(game);
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(occupyRicheseSeat(game, actor).leaders.find(l => l.id === leader.id)!.usedAt, fixture.territory);
|
||||
assert.ok(game.phase >= 7, 'The mandatory coalition battle proceeds to actual collection.');
|
||||
conserve(game);
|
||||
return { game, before, revealed, actor, profile, variable, support };
|
||||
}
|
||||
|
||||
const cityIncome = (game: Game, actor: string, location: string) =>
|
||||
(occupyRicheseSeat(game, actor).forces[location] ?? 0) > 0 ? 2 : 0;
|
||||
for (const lead of ['ecaz', 'ally'] as const) void test(`Six-native Richese-present Carthag: Ecaz+Ix vs CHOAM with ${lead} lead resolves`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'ixians', opponentFaction: 'choam', omitPreview: true });
|
||||
assert.deepEqual(fixture.initial.players.map(p => p.faction), ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']);
|
||||
assert.equal(fixture.initial.status, 'setup');
|
||||
assert.deepEqual(fixture.initial.richeseCache, richeseCards(), 'The fresh initializer creates the canonical native cache.');
|
||||
assert.deepEqual([...fixture.initial.deck, ...(fixture.initial.ixSetupCards ?? []), ...fixture.initial.players.flatMap(p => p.hand)]
|
||||
.map(c => c.id).sort(), treacheryDeck(fixture.initial.expansions).map(c => c.id).sort());
|
||||
assert.ok(fixture.afterSetup.players.every(p => p.leaders.length > 0 &&
|
||||
(p.faction === 'tleilaxu' ? p.faceDancers!.length > 0 : p.traitors.length > 0)));
|
||||
assert.equal(fixture.beforeBattle.ecazOccupyPreview, undefined);
|
||||
const { game, before, actor } = resolve(fixture, lead);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).forces[fixture.location] ?? 0, 0,
|
||||
'Four unsupported original Suboids contribute two strength and all four are lost.');
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).tanks - occupyRicheseSeat(before, fixture.ally).tanks, 4);
|
||||
assert.equal(occupyRicheseSeat(game, actor).spice,
|
||||
occupyRicheseSeat(before, actor).spice + cityIncome(game, actor, fixture.location));
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks, 8);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) for (const cancel of [false, true])
|
||||
void test(`Ordinary Richese variable pool, ${lead} lead, Occupy ${cancel ? 'prevented' : 'active'} preserves native owners`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture();
|
||||
const { game, before, actor } = resolve(fixture, lead, cancel);
|
||||
const ecazLoss = cancel ? lead === 'ecaz' ? 2 : 0 : 3;
|
||||
const allyLoss = cancel && lead === 'ecaz' ? 0 : 2;
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 5 - ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).forces[fixture.location], 4 - allyLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ally).tanks - occupyRicheseSeat(before, fixture.ally).tanks, allyLoss);
|
||||
assert.equal(occupyRicheseSeat(before, actor).spice - occupyRicheseSeat(game, actor).spice,
|
||||
2 - cityIncome(game, actor, fixture.location));
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
assert.equal(occupyRicheseSeat(game, nonLead).spice,
|
||||
occupyRicheseSeat(before, nonLead).spice + cityIncome(game, nonLead, fixture.location));
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks, 8);
|
||||
});
|
||||
|
||||
for (const role of ['ally', 'opponent'] as const) for (const marker of [0, 3, 5] as const)
|
||||
for (const reserves of [0, 2, 20]) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Concealed Richese ${role} ${marker}, reserves ${reserves}, ${lead} lead resolves only after both seals`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
|
||||
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker, richeseReserves: reserves });
|
||||
conserve(fixture.game);
|
||||
const prepared = openOccupyRichese(fixture, lead);
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const rival = role === 'ally' ? fixture.opponent : actor;
|
||||
assert.equal(viewGame(prepared, rival).battle!.opponentForces, null,
|
||||
'The rival cannot receive a prospective denomination through its opponent pool.');
|
||||
if (role === 'ally') {
|
||||
assert.equal(viewGame(prepared, fixture.opponent).battle!.ecazOccupy!.profile, null,
|
||||
'Rival max dial and physical ally counters must not disclose the marker.');
|
||||
assert.equal(viewGame(prepared, actor).battle!.ownForces!.normal, Math.min(marker, reserves));
|
||||
}
|
||||
const { game, before, actor: selected, support } = resolve(fixture, lead);
|
||||
const materialized = Math.min(marker, reserves);
|
||||
const richeseLoss = role === 'opponent' ? materialized : Math.min(2, materialized);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, materialized - richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).reserves, reserves - materialized);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 2);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
||||
assert.equal(occupyRicheseSeat(before, selected).spice - occupyRicheseSeat(game, selected).spice,
|
||||
support - cityIncome(game, selected, fixture.location));
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).forces[fixture.location] ?? 0, 0);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks,
|
||||
role === 'opponent' ? materialized : 8);
|
||||
});
|
||||
|
||||
void test('Original concealed Richese presence permits a zero prospective pool, never an ordinary empty ally', () => {
|
||||
const forces = { normal: 0, elite: 0, eliteStrength: 2 as const, freeSupport: false };
|
||||
const input: EcazOccupyBattleInput = { battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
|
||||
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'richese', forces: { ...forces, normal: 5 } },
|
||||
ally: { id: 'richese', faction: 'richese', ally: 'ecaz', forces } };
|
||||
const saved = structuredClone(input);
|
||||
assert.throws(() => quoteEcazOccupyBattle(input), /storm-connected fighters/);
|
||||
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, noFieldPresence: true } });
|
||||
assert.equal(profile.maxDial, 3);
|
||||
assert.equal(profile.maxSupport, 0);
|
||||
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [{ normal: 0, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.throws(() => quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'guild', noFieldPresence: true } }),
|
||||
/Only an original concealed Richese ally/);
|
||||
assert.deepEqual(input, saved);
|
||||
});
|
||||
|
||||
for (const role of ['ally', 'opponent'] as const) void test(`Mixed 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 }), /Mixed ordinary-force and No-Field/);
|
||||
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 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
||||
const event = fixture.game.decision.event;
|
||||
let game = applyAction(fixture.game, fixture.ecaz, { type: 'decision', event, lead: actor });
|
||||
for (let attempt = 0; attempt < 100 && game.battle?.preparation?.kind !== 'prescience'; attempt++)
|
||||
game = nextOccupyRichese(game);
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
let saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
||||
assert.deepEqual(game, saved);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
|
||||
assert.equal(game.decision!.kind, 'fullPlanOffer');
|
||||
const karama = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
saved = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: karama.id, target: actor }),
|
||||
/Whole-plan special prescience against a No-Field/);
|
||||
assert.deepEqual(game, saved);
|
||||
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
});
|
||||
|
||||
for (const marker of [0, 5] as const) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Prevented Occupy with marker ${marker} retains ${lead}'s own pool and original physical reveal`, () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ marker, richeseReserves: 2 });
|
||||
const { game, before, actor, support } = resolve(fixture, lead, true);
|
||||
const materialized = Math.min(marker, 2);
|
||||
const ecazLoss = lead === 'ecaz' ? 2 : 0;
|
||||
const richeseLoss = lead === 'ally' ? materialized : 0;
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 5 - ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, ecazLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, materialized - richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(game, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, richeseLoss);
|
||||
assert.equal(occupyRicheseSeat(before, actor).spice - occupyRicheseSeat(game, actor).spice,
|
||||
support - cityIncome(game, actor, fixture.location));
|
||||
});
|
||||
|
||||
void test('Prevented Occupy makes selected Ecaz own dial and whole plan inspectable without revealing Richese', () => {
|
||||
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
||||
const counter = 'choam';
|
||||
const karama = stageEcazOccupyCard(fixture.game, counter, card => card.effect === 'karama');
|
||||
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
||||
const event = fixture.game.decision.event;
|
||||
let game = applyAction(fixture.game, fixture.ecaz, { type: 'decision', event, lead: fixture.ecaz });
|
||||
game = applyAction(game, counter, { type: 'card', mode: 'cancel', card: karama });
|
||||
for (let attempt = 0; attempt < 100 && game.battle?.preparation?.kind !== 'prescience'; attempt++)
|
||||
game = nextOccupyRichese(game);
|
||||
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
||||
const ownProfile = viewGame(game, fixture.ecaz).battle!.ecazOccupy!.profile!;
|
||||
assert.equal(ownProfile.forceOwner, fixture.ecaz);
|
||||
assert.equal(ownProfile.forces.normal, 5);
|
||||
const asked = applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' });
|
||||
assert.equal(asked.battle!.prescience!.field, 'dial');
|
||||
assert.equal(asked.battle!.preparation!.owner, fixture.ecaz);
|
||||
assert.ok(occupyRicheseSeat(asked, fixture.richese).noField!.deployed);
|
||||
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
||||
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
|
||||
assert.equal(game.decision!.kind, 'fullPlanOffer');
|
||||
const inspectionCard = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: inspectionCard.id, target: fixture.ecaz });
|
||||
assert.deepEqual(game.battle!.fullPlan, { owner: fixture.opponent, target: fixture.ecaz });
|
||||
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from '../game/combat';
|
||||
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from '../game/ecaz-occupy-battle';
|
||||
import { quoteBattleResolution, BattleResolutionQuoteError, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, type StoneBurnerContext, type StoneBurnerOccupyContext, type StoneBurnerSide } from '../game/stone-burner';
|
||||
|
||||
const ordinary = (normal: number): CombatForces => ({
|
||||
normal, elite: 0, eliteStrength: 1, freeSupport: false,
|
||||
});
|
||||
const ix: CombatForces = {
|
||||
normal: 3, elite: 2, eliteStrength: 2,
|
||||
normalFixedHalf: true, freeSupport: false,
|
||||
};
|
||||
function profile(ecazNormal = 5, lead = 'ecaz-user', canceled = false, allyForces = ix): EcazOccupyBattleProfile {
|
||||
return quoteEcazOccupyBattle({
|
||||
battleOrderActor: 'ecaz-user',
|
||||
ecaz: { id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user', forces: ordinary(ecazNormal) },
|
||||
ally: { id: 'ally-user', faction: 'ixians', ally: 'ecaz-user', forces: allyForces },
|
||||
lead, canceled,
|
||||
});
|
||||
}
|
||||
function occupyContext(p: EcazOccupyBattleProfile): StoneBurnerOccupyContext {
|
||||
return { fixedEcazDial: p.fixedEcazDial, ecazUndialed: p.canceled ? 0 : Math.floor(p.ecazForces.normal / 2) };
|
||||
}
|
||||
const opposite = (side: StoneBurnerSide): StoneBurnerSide => side === 'attacker' ? 'defender' : 'attacker';
|
||||
|
||||
void test('active Occupy compares odd/even Ecaz physical remainder plus native half-normal and double-elite counters in either slot', () => {
|
||||
for (const ecazNormal of [5, 6])
|
||||
for (const lead of ['ecaz-user', 'ally-user'])
|
||||
for (const slot of ['attacker', 'defender'] as const)
|
||||
for (const aggressor of ['attacker', 'defender'] as const) {
|
||||
const p = profile(ecazNormal, lead);
|
||||
const ownUndialed = Math.floor(ecazNormal / 2) + 3;
|
||||
const own = [p.forces, p.fixedEcazDial + 2.5, 1] as const;
|
||||
const other = [ordinary(ownUndialed + 1), 1, 1] as const;
|
||||
const result = slot === 'attacker'
|
||||
? stoneBurnerComparison(...own, ...other, aggressor, { [slot]: occupyContext(p) })
|
||||
: stoneBurnerComparison(...other, ...own, aggressor, { [slot]: occupyContext(p) });
|
||||
assert.deepEqual(result, { winner: aggressor, attacker: [ownUndialed], defender: [ownUndialed] });
|
||||
// The fixed commitment is mandatory even when the native variable pool could dial zero.
|
||||
assert.notEqual(stoneBurnerPlanBlock(p.forces, p.fixedEcazDial - 0.5, 0, other[0], slot, aggressor, { [slot]: occupyContext(p) }), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceled Occupy compares only the selected lead own native pool, never the other member undialed army', () => {
|
||||
for (const lead of ['ecaz-user', 'ally-user']) {
|
||||
const p = profile(9, lead, true, ordinary(3));
|
||||
const expected = lead === p.ecaz ? 7 : 1;
|
||||
const result = stoneBurnerComparison(p.forces, 1, 0, ordinary(4), 0, 0, 'attacker', { attacker: occupyContext(p) });
|
||||
assert.deepEqual(result, { winner: expected > 4 ? 'attacker' : 'defender', attacker: [expected], defender: [4] });
|
||||
}
|
||||
});
|
||||
|
||||
void test('active mandatory Ecaz survivors can make a typed native allocation finishable without selecting its casualties', () => {
|
||||
const emperor: CombatForces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false };
|
||||
const active = profile(5, 'ecaz-user', false, emperor);
|
||||
const canceled = profile(5, 'ally-user', true, emperor);
|
||||
assert.deepEqual(stoneBurnerComparison(active.forces, 6, 1, ordinary(2), 0, 0, 'attacker', { attacker: occupyContext(active) }), {
|
||||
winner: 'attacker', attacker: [3, 4, 5], defender: [2],
|
||||
});
|
||||
assert.equal(stoneBurnerPlanBlock(active.forces, 6, 1, ordinary(2), 'attacker', 'attacker', { attacker: occupyContext(active) }), null);
|
||||
assert.deepEqual(stoneBurnerComparison(canceled.forces, 3, 1, ordinary(2), 0, 0, 'attacker', { attacker: occupyContext(canceled) }), {
|
||||
winner: null, attacker: [1, 2, 3], defender: [2],
|
||||
});
|
||||
assert.notEqual(stoneBurnerPlanBlock(canceled.forces, 3, 1, ordinary(2), 'attacker', 'attacker', { attacker: occupyContext(canceled) }), null);
|
||||
});
|
||||
|
||||
void test('public hypothetical native pools remain authoritative under the same fixed Occupy contribution', () => {
|
||||
const context: StoneBurnerContext = { defender: { fixedEcazDial: 3, ecazUndialed: 2 } };
|
||||
const elitesOnly = { ...ix, normal: 0 };
|
||||
const concealedNormals = { ...ix, normal: 5 };
|
||||
assert.equal(stoneBurnerPlanBlock(ordinary(4), 0, 0, elitesOnly, 'attacker', 'attacker', context), null);
|
||||
assert.notEqual(stoneBurnerPlanBlock(ordinary(4), 0, 0, concealedNormals, 'attacker', 'attacker', context), null);
|
||||
assert.deepEqual(stoneBurnerComparison(ordinary(4), 0, 0, elitesOnly, 3, 0, 'attacker', context).defender, [4]);
|
||||
assert.deepEqual(stoneBurnerComparison(ordinary(4), 0, 0, concealedNormals, 3, 0, 'attacker', context).defender, [9]);
|
||||
});
|
||||
|
||||
function physicalChoices(pool: CombatForces, dial: number, support: number, p?: StoneBurnerOccupyContext): number[] {
|
||||
const offset = p?.ecazUndialed ?? 0;
|
||||
return casualtyOptions(pool, dial - (p?.fixedEcazDial ?? 0), support)
|
||||
.map(loss => offset + pool.normal + pool.elite - loss.normal - loss.elite);
|
||||
}
|
||||
function publicPlans(pool: CombatForces, p?: StoneBurnerOccupyContext): { dial: number; support: number; totals: number[] }[] {
|
||||
const plans: { dial: number; support: number; totals: number[] }[] = [];
|
||||
for (let half = 0; half <= maxCombatDial(pool) * 2; half++)
|
||||
for (let support = 0; support <= maxCombatSupport(pool); support++) {
|
||||
const dial = half / 2 + (p?.fixedEcazDial ?? 0);
|
||||
const totals = physicalChoices(pool, dial, support, p);
|
||||
if (totals.length) plans.push({ dial, support, totals });
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
function canFinish(own: number[], otherPlans: { totals: number[] }[], side: StoneBurnerSide, aggressor: StoneBurnerSide): boolean {
|
||||
return otherPlans.every(other => {
|
||||
const winners = new Set(own.flatMap(a => other.totals.map(d => a === d ? aggressor : a > d ? side : opposite(side))));
|
||||
return winners.size === 1;
|
||||
});
|
||||
}
|
||||
|
||||
void test('Occupy precommit and Voice finishability match exhaustive physical-allocation outcomes for own and opposing active/canceled profiles', () => {
|
||||
for (const canceled of [false, true])
|
||||
for (const lead of ['ecaz-user', 'ally-user'])
|
||||
for (const slot of ['attacker', 'defender'] as const)
|
||||
for (const side of ['attacker', 'defender'] as const)
|
||||
for (const aggressor of ['attacker', 'defender'] as const) {
|
||||
const p = profile(5, lead, canceled);
|
||||
const context: StoneBurnerContext = { [slot]: occupyContext(p) };
|
||||
const own = side === slot ? p.forces : ordinary(4);
|
||||
const other = side === slot ? ordinary(4) : p.forces;
|
||||
const ownProfile = context[side];
|
||||
const otherPlans = publicPlans(other, context[opposite(side)]);
|
||||
const ownPlans = publicPlans(own, ownProfile);
|
||||
for (const plan of ownPlans) {
|
||||
assert.equal(stoneBurnerPlanBlock(own, plan.dial, plan.support, other, side, aggressor, context) === null,
|
||||
canFinish(plan.totals, otherPlans, side, aggressor),
|
||||
JSON.stringify({ canceled, lead, slot, side, aggressor, plan }));
|
||||
}
|
||||
const zeroSpiceCompletion = ownPlans.some(plan => plan.support === 0 && canFinish(plan.totals, otherPlans, side, aggressor));
|
||||
assert.equal(stoneBurnerCompulsionBlock(own, other, side, aggressor, context) === null, zeroSpiceCompletion);
|
||||
}
|
||||
});
|
||||
|
||||
const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
||||
function resolution(p: EcazOccupyBattleProfile, slot: StoneBurnerSide, dial = p.fixedEcazDial + 2.5, support = 1, otherUndialed = 5): BattleResolutionInput {
|
||||
const combatant = (id: string, faction: 'ecaz' | 'ixians' | 'guild', forces: CombatForces, dial: number, support: number): ResolutionCombatant => {
|
||||
const leader = { ...leaders(faction)[0] };
|
||||
return { id, faction, spice: 10, hand: [], leader, forces,
|
||||
plan: { dial, support, leader: leader.id, weapon: null, defense: null } };
|
||||
};
|
||||
const own = combatant(p.lead, p.lead === p.ecaz ? 'ecaz' : 'ixians', p.forces, dial, support);
|
||||
own.ally = p.lead === p.ecaz ? p.ally : p.ecaz;
|
||||
const other = combatant('opponent', 'guild', ordinary(otherUndialed + 1), 1, 1);
|
||||
const cards = catalog();
|
||||
const stone = cards.find(card => card.effect === 'stoneBurner')!;
|
||||
own.hand = [stone];
|
||||
own.plan.weapon = stone.id;
|
||||
own.stoneMode = 'ignore';
|
||||
return {
|
||||
ecazOccupy: p, advanced: true, turn: 2, territory: 'carthag',
|
||||
attacker: slot === 'attacker' ? own : other,
|
||||
defender: slot === 'defender' ? own : other,
|
||||
participants: [
|
||||
{ id: p.ecaz, faction: 'ecaz', ally: p.ally },
|
||||
{ id: p.ally, faction: 'ixians', ally: p.ecaz },
|
||||
{ id: other.id, faction: other.faction },
|
||||
],
|
||||
voters: [p.ecaz, p.ally].map(id => ({ id, beneficiary: p.lead, called: false, traitors: [] }))
|
||||
.concat([{ id: other.id, beneficiary: other.id, called: false, traitors: [] }]),
|
||||
physicalCards: cards, pendingAuditorPresent: false, pendingRetentionPresent: false,
|
||||
};
|
||||
}
|
||||
|
||||
void test('normal Stone Occupy resolution honors either lead/slot/aggressor and settles original physical owners and card custody', () => {
|
||||
for (const ecazNormal of [5, 6])
|
||||
for (const lead of ['ecaz-user', 'ally-user'])
|
||||
for (const slot of ['attacker', 'defender'] as const)
|
||||
for (const aggressor of ['attacker', 'defender'] as const) {
|
||||
const p = profile(ecazNormal, lead);
|
||||
const count = Math.floor(ecazNormal / 2) + 3;
|
||||
const input = resolution(p, slot, p.fixedEcazDial + 2.5, 1, count);
|
||||
input.aggressor = input[aggressor].id;
|
||||
const before = structuredClone(input);
|
||||
const quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.result, 'normal');
|
||||
assert.equal(quote.winner, input[aggressor].id);
|
||||
assert.deepEqual(quote.stone, { winner: aggressor, attacker: [count], defender: [count] });
|
||||
assert.deepEqual(quote.leaderDeaths, { attacker: false, defender: false });
|
||||
assert.deepEqual(quote.payments.map(payment => payment.ownPayment), [1, 1]);
|
||||
if (aggressor === slot) {
|
||||
assert.deepEqual(quote.fixedLosses, [{ owner: p.ecaz, normal: Math.ceil(ecazNormal / 2), elite: 0 }]);
|
||||
assert.equal(quote.casualties?.owner, p.ally);
|
||||
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 1, paidNormal: 0, paidElite: 1 }]);
|
||||
assert.deepEqual(quote.destroyedArmies, ['opponent']);
|
||||
assert.deepEqual(quote.winnerCards, [input[slot].plan.weapon]);
|
||||
} else {
|
||||
assert.deepEqual(quote.destroyedArmies, [p.ecaz, p.ally]);
|
||||
assert.deepEqual(quote.discarded, [{ player: p.lead, card: input[slot].plan.weapon }]);
|
||||
}
|
||||
assert.deepEqual(input, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceled Stone resolution uses own pool and leaves the winning nonparticipating member untouched', () => {
|
||||
for (const lead of ['ecaz-user', 'ally-user']) {
|
||||
const p = profile(9, lead, true, ordinary(3));
|
||||
const input = resolution(p, 'attacker', 1, 0, 4);
|
||||
const quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.winner, lead === p.ecaz ? p.ecaz : 'opponent');
|
||||
assert.deepEqual(quote.stone?.attacker, [lead === p.ecaz ? 7 : 1]);
|
||||
if (lead === p.ecaz) {
|
||||
assert.equal(quote.casualties?.owner, p.ecaz);
|
||||
assert.deepEqual(quote.casualties?.options, [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
||||
assert.deepEqual(quote.fixedLosses, []);
|
||||
assert.deepEqual(quote.destroyedArmies, ['opponent']);
|
||||
} else assert.deepEqual(quote.destroyedArmies, [p.ecaz, p.ally]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('copied Stone uses the same Occupy physical comparison but retains the winning physical Mirror', () => {
|
||||
const p = profile();
|
||||
const input = resolution(p, 'defender', 5.5, 1, 5);
|
||||
const original = input.defender.hand[0];
|
||||
const mirror = catalog().find(card => card.effect === 'mirrorWeapon')!;
|
||||
input.defender.hand = [mirror];
|
||||
input.defender.plan.weapon = mirror.id;
|
||||
input.attacker.hand = [original];
|
||||
input.attacker.plan.weapon = original.id;
|
||||
input.attacker.stoneMode = 'ignore';
|
||||
input.aggressor = p.lead;
|
||||
const quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.winner, p.lead);
|
||||
assert.deepEqual(quote.stone, { winner: 'defender', attacker: [5], defender: [5] });
|
||||
assert.deepEqual(quote.winnerCards, [mirror.id]);
|
||||
assert.deepEqual(quote.discarded, [{ player: 'opponent', card: original.id }]);
|
||||
input.defender.stoneMode = 'kill';
|
||||
assert.deepEqual(quoteBattleResolution(input).leaderDeaths, { attacker: true, defender: true });
|
||||
});
|
||||
|
||||
void test('true native typed ambiguity after the Ecaz offset remains unresolved and never mutates the sealed inputs', () => {
|
||||
const emperor: CombatForces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false };
|
||||
const p = profile(5, 'ecaz-user', false, emperor);
|
||||
const input = resolution(p, 'attacker', 6, 1, 4);
|
||||
const before = structuredClone(input);
|
||||
assert.deepEqual(stoneBurnerComparison(p.forces, 6, 1, input.defender.forces, 1, 1, 'attacker', { attacker: occupyContext(p) }), {
|
||||
winner: null, attacker: [3, 4, 5], defender: [4],
|
||||
});
|
||||
assert.throws(() => quoteBattleResolution(input), BattleResolutionQuoteError);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type EcazOccupyRicheseOptions = {
|
||||
allyFaction?: 'richese' | 'ixians' | 'guild';
|
||||
opponentFaction?: 'richese' | 'choam' | 'atreides' | 'guild';
|
||||
marker?: NoFieldValue;
|
||||
richeseReserves?: number;
|
||||
mixedRicheseForces?: number;
|
||||
ecazForces?: number;
|
||||
omitPreview?: boolean;
|
||||
/** Original admitted fresh setup with its real authenticated seat identities. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type EcazOccupyRicheseFixture = {
|
||||
initial: Game; afterSetup: Game; beforeBattle: Game; game: Game;
|
||||
ecaz: string; ally: string; opponent: string; richese: string;
|
||||
territory: string; location: string; ecazForces: number; materialized: number | null;
|
||||
staging: string[];
|
||||
};
|
||||
export const occupyRicheseSeat = (game: Game, actor: string) => {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
|
||||
export function nextOccupyRichese(game: Game): Game {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'richeseAllyOpportunity')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance')
|
||||
return applyAction(game, game.decision.player, { type: 'decision', reveal: false });
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(next, 'Native continuation must not manufacture a sealed plan.');
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
function advance(game: Game, predicate: (game: Game) => boolean): Game {
|
||||
for (let attempt = 0; attempt < 1600; attempt++) {
|
||||
if (predicate(game)) return game;
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error('Native Occupy/Richese continuation did not reach its boundary.');
|
||||
}
|
||||
function orderBlow(game: Game, worm: boolean, position: number): void {
|
||||
const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed
|
||||
: 'territory' in card && card.territory !== 'carthag'));
|
||||
assert.ok(at >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
function reserveBoard(game: Game): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
player.spice = 30;
|
||||
}
|
||||
}
|
||||
function place(game: Game, owner: string, location: string, count: number): void {
|
||||
const player = occupyRicheseSeat(game, owner);
|
||||
assert.ok(Number.isSafeInteger(count) && count >= 0 && count <= player.reserves);
|
||||
player.reserves -= count;
|
||||
if (count) player.forces[location] = (player.forces[location] ?? 0) + count;
|
||||
}
|
||||
|
||||
/** Real fresh Advanced family deal and natural Nexus alliance. Explicitly
|
||||
* controlled conserved board/bank/card staging follows original setup; only
|
||||
* native shipment creates the concealed marker, and actual chooseBattle admits
|
||||
* the coalition. No phase, faction, alliance, plan or marker is assigned. */
|
||||
export function ecazOccupyRicheseFixture(options: EcazOccupyRicheseOptions = {}): EcazOccupyRicheseFixture {
|
||||
const allyFaction = options.allyFaction ?? 'richese';
|
||||
const opponentFaction = options.opponentFaction ?? 'guild';
|
||||
assert.notEqual(allyFaction, opponentFaction);
|
||||
const roster: FactionId[] = allyFaction === 'ixians' && opponentFaction === 'choam'
|
||||
? ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']
|
||||
: [...new Set<FactionId>(['ecaz', allyFaction, opponentFaction, 'richese', 'choam'])];
|
||||
const expansions: Game['expansions'] = ['ecaz', 'choam', ...(roster.includes('ixians') ? ['ix'] as const : [])];
|
||||
let game: Game;
|
||||
if (options.initial) {
|
||||
game = structuredClone(options.initial);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.advanced, true);
|
||||
assert.equal(game.ecazOccupyPreview, true);
|
||||
assert.deepEqual(game.players.map(p => p.faction), roster);
|
||||
assert.deepEqual(game.expansions, expansions);
|
||||
} else {
|
||||
game = createGame('ECAZOCCUPYRICHESE', newPlayer(roster[0], roster[0], roster[0]), true, expansions);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (let i = roster.length - 1; i >= 0; i--) {
|
||||
const position = i + 1;
|
||||
if (viewGame(game, roster[i]).playerPositions[roster[i]] !== position)
|
||||
game = applyAction(game, roster[i], { type: 'seatPosition', position });
|
||||
}
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeEcazOccupyGameForAudit(game);
|
||||
}
|
||||
const initial = structuredClone(game);
|
||||
game = finishEcazOccupySetup(game);
|
||||
const afterSetup = structuredClone(game);
|
||||
const factionSeat = (faction: FactionId) => {
|
||||
const player = game.players.find(p => p.faction === faction);
|
||||
assert.ok(player);
|
||||
return player.id;
|
||||
};
|
||||
const ecaz = factionSeat('ecaz'), ally = factionSeat(allyFaction);
|
||||
const opponent = factionSeat(opponentFaction), richese = factionSeat('richese');
|
||||
reserveBoard(game);
|
||||
const staging = ['After original faction setup: return each physical board counter to its own reserves and stage thirty-spice bank balances.'];
|
||||
orderBlow(game, false, 0); orderBlow(game, false, 1);
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 0 && clean(g));
|
||||
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
|
||||
game = advance(game, g => g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
|
||||
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
|
||||
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
|
||||
game = advance(game, g => g.phase === 5 && clean(g));
|
||||
reserveBoard(game);
|
||||
const battleTerritory = 'carthag';
|
||||
const sector = territory(battleTerritory).sectors[0];
|
||||
const location = `${battleTerritory}:${sector}`;
|
||||
const ecazForces = options.ecazForces ?? 5;
|
||||
for (const token of game.ecazAmbassadors?.tokens ?? []) {
|
||||
if (token.zone !== 'placed' || token.location !== battleTerritory) continue;
|
||||
token.zone = 'supply';
|
||||
token.location = null;
|
||||
staging.push('Return the actual Carthag Ambassador to its existing supply for this clean controlled battle position.');
|
||||
}
|
||||
place(game, ecaz, location, ecazForces);
|
||||
if (ally !== richese || options.marker === undefined) place(game, ally, location, 4);
|
||||
if (opponent !== richese || options.marker === undefined) place(game, opponent, location, 8);
|
||||
const mixed = options.mixedRicheseForces ?? 0;
|
||||
if (options.marker !== undefined) {
|
||||
assert.ok(ally === richese || opponent === richese);
|
||||
const reserves = options.richeseReserves ?? 20;
|
||||
assert.ok(Number.isSafeInteger(reserves) && reserves >= 0 && reserves + mixed <= 20);
|
||||
place(game, richese, 'polar_sink:0', occupyRicheseSeat(game, richese).reserves - reserves - mixed);
|
||||
place(game, richese, location, mixed);
|
||||
staging.push(`Conserve Richese counters: ${mixed} ordinary battle counters, ${reserves} reserves, remainder in Polar Sink; native No-Field ${options.marker} shipment remains concealed.`);
|
||||
}
|
||||
staging.push(`Conserve ${ecazForces} Ecaz counters and ordinary allied/opposing counters in Carthag; original Nexus alliance is unchanged.`);
|
||||
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
|
||||
let shipped = false;
|
||||
while (game.phase === 5) {
|
||||
if (!clean(game)) { game = nextOccupyRichese(game); continue; }
|
||||
const actor = game.active!;
|
||||
if (actor === richese && options.marker !== undefined && !shipped) {
|
||||
const player = occupyRicheseSeat(game, richese);
|
||||
const token = player.noField!.tokens.find(t => t.value === options.marker)!;
|
||||
assert.ok(token);
|
||||
const action: Action = { type: 'ship', territory: battleTerritory, sector,
|
||||
noField: token.id, event: player.noFieldEvent };
|
||||
game = applyAction(game, richese, action);
|
||||
game = advance(game, clean);
|
||||
shipped = true;
|
||||
}
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
}
|
||||
game = advance(game, clean);
|
||||
if (options.omitPreview) {
|
||||
delete game.ecazOccupyPreview;
|
||||
staging.push('Before actual battle admission: omit the audit label; keep the native original setup inventory and rule composition unchanged.');
|
||||
}
|
||||
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,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
||||
return { initial, afterSetup, beforeBattle, game, ecaz, ally, opponent, richese,
|
||||
territory: battleTerritory, location, ecazForces,
|
||||
materialized: options.marker === undefined ? null : Math.min(options.marker, options.richeseReserves ?? 20), staging };
|
||||
}
|
||||
export function openOccupyRichese(fixture: EcazOccupyRicheseFixture, lead: 'ecaz' | 'ally', cancel = false): Game {
|
||||
let game = fixture.game;
|
||||
assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
game = applyAction(game, fixture.ecaz, { type: 'decision', event: game.decision!.event,
|
||||
lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
|
||||
if (cancel) {
|
||||
assert.equal(game.response?.kind, 'ecazOccupy');
|
||||
const card = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
||||
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card: card.id });
|
||||
}
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed) && clean(game)) return game;
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error('Original Richese preleader and Occupy responses did not finish.');
|
||||
}
|
||||
export function finishOccupyRichese(state: Game): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
game = nextOccupyRichese(game);
|
||||
}
|
||||
throw Error('Native Occupy/Richese reveal aftermath did not finish.');
|
||||
}
|
||||
@@ -92,16 +92,11 @@ void test('unknown is not false: decisive AND/OR branches dominate regardless of
|
||||
void test('compilation freezes current answers once, not a callback or later fact state', () => {
|
||||
const player = { hand: [], traitors: [], traitorChoices: [], spice: 7 };
|
||||
const asked = decode(group('or', fact(), shipment()));
|
||||
let reads = 0;
|
||||
const frozen = compileMixedShipmentExpression(asked, (f) => {
|
||||
reads++;
|
||||
return truthFactAnswer(player, f);
|
||||
});
|
||||
const frozen = compileMixedShipmentExpression(asked, (f) => truthFactAnswer(player, f));
|
||||
player.spice = 0;
|
||||
assert.equal(matchesShipment({ claim: frozen }, null), true);
|
||||
assert.equal(matchesShipment({ claim: JSON.parse(JSON.stringify(frozen)) as ShipmentExpression },
|
||||
{ territory: 'arrakeen', amount: 1 }), true);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(matchesShipment({
|
||||
claim: compileMixedShipmentExpression(asked, (f) => truthFactAnswer(player, f)),
|
||||
}, null), false);
|
||||
|
||||
Reference in new issue
Block a user