Compose native Richese No-Field and Stone with Advanced Occupy

This commit is contained in:
Quintine committed 2026-10-03 08:48:26 +10:00
1 parent 03c77604de
commit 113667da55
23 files changed
+985 -246

No files matched your search

+1 -73
View File
@@ -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,
})), '');
}
});
-80
View File
@@ -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);
});
+224
View File
@@ -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);
});
+232
View File
@@ -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);
});
+205
View File
@@ -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.');
}
+1 -6
View File
@@ -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);