Compose native Stone and Mirror with phase modules

This commit is contained in:
Quintine committed 2026-10-16 15:36:20 +11:00
1 parent 4a67e8c7be
commit ccdcfe2c4d
14 files changed
+359 -29

No files matched your search

+8 -3
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
type Action, type Game,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
@@ -15,6 +15,8 @@ export type IndependentCardModulesOptions = {
expansions?: Game['expansions'];
tech?: boolean;
strongholds?: boolean;
/** False keeps the original native deck for revealed-card comparisons. */
independent?: boolean;
};
/** Select original modules and independent cards before the undealt native setup.
@@ -40,7 +42,10 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { game = initializeEcazTreacheryGameForAudit(game); }
try {
game = options.independent !== false ? initializeEcazTreacheryGameForAudit(game) :
options.strongholds ? initializeStrongholdFactionsGameForAudit(game) : initializeFactionExpansionsGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import { createIndependentCardModulesFixture, type IndependentCardModulesStep } from './fixture-independent-card-modules';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export const NATIVE_MODULE_STONE = 'richese-stone-burner';
export const NATIVE_MODULE_MIRROR = 'richese-mirror-weapon';
export const NATIVE_MODULE_TOOTH = 'ix-poison-tooth';
export const NATIVE_MODULE_REINFORCEMENTS = 'ecaz-reinforcements';
export type NativeStoneModulesStep = IndependentCardModulesStep;
export type NativeStoneModulesOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
kind?: 'original' | 'mirror';
independent?: boolean;
};
export const nativeStoneModulesClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export function nativeStoneModulesPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export function advanceNativeStoneModules(game: Game, until: (state: Game) => boolean,
steps: NativeStoneModulesStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Original native programme ended before its requested boundary');
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, 'Supply the real unsealed battle plans');
// Real opening dials put Storm at7; every later native distance leaves
// Ix before Richese in battle order. Habbanya can then decide their tie.
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 3;
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('Original native Stone module programme did not reach its requested boundary');
}
function positionPhysicalCard(game: Game, actor: string, id: string, staging: string[]) {
const player = nativeStoneModulesPlayer(game, actor);
if (player.hand.some(card => card.id === id)) return;
const zones: { label: string; cards: Card[] }[] = [
{ label: 'remaining original deck', cards: game.deck },
{ label: 'original discard', cards: game.discard },
{ label: 'separate original Richese cache', cards: game.richeseCache ?? [] },
...game.players.filter(p => p.id !== actor).map(p => ({ label: `${p.id}'s hand`, cards: p.hand })),
];
const source = zones.find(zone => zone.cards.some(card => card.id === id));
assert.ok(source, `Missing original physical ${id}`);
assert.ok(player.hand.length < 4, 'The native physical hand must retain its printed limit');
player.hand.push(source.cards.splice(source.cards.findIndex(card => card.id === id), 1)[0]);
staging.push(`Conserved physical ${id}: ${source.label} → ${actor}'s hand; not a new purchase or face.`);
}
/** Original selected setup/cache acquisition → paid concealed shipment → earned
* END-Mentat custody → next-turn paid typed attack → actual native battle.
* Only explicitly recorded physical cards move outside the real action trail. */
export function createNativeStoneModulesFixture(options: NativeStoneModulesOptions = {}) {
const advanced = options.advanced ?? true;
const strongholds = options.strongholds ?? advanced;
const kind = options.kind ?? 'original';
const source = createIndependentCardModulesFixture({ advanced, tech: options.tech ?? true,
strongholds, independent: options.independent, factions: ['ixians', 'richese', 'atreides'], expansions: ['ix', 'choam'] });
let game = source.game;
const initial = source.initial, afterSetup = structuredClone(game);
const steps = source.steps, staging: string[] = [];
const owner = 'richese', opponent = 'ixians';
const territory = 'habbanya_ridge_sietch', sector = 17;
const act = (step: NativeStoneModulesStep): Game => {
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action); return game;
};
const advance = (until: (state: Game) => boolean) => {
game = advanceNativeStoneModules(game, until, steps);
};
advance(g => g.decision?.kind === 'richeseCache');
const acquisitionBefore = structuredClone(game);
const acquisitionStart = steps.length;
advance(g => nativeStoneModulesPlayer(g, owner).hand.some(c => c.id === NATIVE_MODULE_STONE));
const acquisition = { before: acquisitionBefore, after: structuredClone(game), steps: steps.slice(acquisitionStart) };
advance(g => g.turn === 1 && g.phase === 5 && g.active === owner && nativeStoneModulesClean(g));
const token = nativeStoneModulesPlayer(game, owner).noField!.tokens.find(t => t.value === 5)!;
const beforeShipment = structuredClone(game);
const shipmentStep: NativeStoneModulesStep = { actor: owner, action: { type: 'ship', territory, sector,
noField: token.id, event: nativeStoneModulesPlayer(game, owner).noFieldEvent } };
act(shipmentStep);
advance(g => g.turn === 1 && g.phase === 8 && nativeStoneModulesClean(g));
let beforeMentat: Game | undefined, mentatStep: NativeStoneModulesStep | undefined;
for (let count = 0; game.turn === 1 && count < 100; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
const before = structuredClone(game), after = act(next);
if (after.turn === 2) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep);
if (strongholds) assert.equal(game.strongholdCards!.owners[territory], owner);
const afterMentat = structuredClone(game);
advance(g => g.turn === 2 && g.phase === 5 && g.active === opponent && nativeStoneModulesClean(g));
const beforeAttack = structuredClone(game);
const attackStep: NativeStoneModulesStep = { actor: opponent,
action: { type: 'ship', territory, sector, amount: 3, elite: 1 } };
act(attackStep);
advance(g => g.phase === 6 && nativeStoneModulesClean(g));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
assert.ok(choice);
const battleStep: NativeStoneModulesStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
advance(g => !!g.battle && !g.battle.preparation && (!g.battle.preLeader || g.battle.preLeader.closed) && nativeStoneModulesClean(g));
if (game.ecazTreachery) positionPhysicalCard(game, owner, NATIVE_MODULE_REINFORCEMENTS, staging);
if (kind === 'mirror') {
positionPhysicalCard(game, opponent, NATIVE_MODULE_STONE, staging);
positionPhysicalCard(game, owner, NATIVE_MODULE_MIRROR, staging);
} else positionPhysicalCard(game, opponent, NATIVE_MODULE_TOOTH, staging);
const strongest = nativeStoneModulesPlayer(game, owner).leaders.filter(l => !l.dead)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const weakest = nativeStoneModulesPlayer(game, opponent).leaders.filter(l => !l.dead)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(strongest && weakest);
const dial = advanced ? 2 : 1;
const plans: NativeStoneModulesStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial, support: advanced ? dial : 0,
leader: strongest.id, weapon: kind === 'mirror' ? NATIVE_MODULE_MIRROR : NATIVE_MODULE_STONE,
defense: game.ecazTreachery ? NATIVE_MODULE_REINFORCEMENTS : null } },
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: weakest.id,
weapon: kind === 'mirror' ? NATIVE_MODULE_STONE : NATIVE_MODULE_TOOTH } },
];
return { initial, afterSetup, acquisition, beforeShipment, shipmentStep, beforeMentat, mentatStep,
afterMentat, beforeAttack, attackStep, beforeBattle, battleStep, game, owner, opponent,
territory, location: `${territory}:${sector}`, kind, advanced, plans, steps, staging };
}
-13
View File
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction, viewGame} from '../game/engine';
import {mirrorWeaponModeBlock} from '../game/mirror-weapon-mode';
import {STONE_CARD, MIRROR_CARD, stoneHarassOccupyFixture, takeStoneHarassCard,
sealStoneHarassPlans, finishStoneHarassBattle} from './fixture-stone-harass';
import {IX_TOOTH, ixRevealedWeaponFixture, revealIxWeapons,
@@ -92,18 +91,6 @@ void test('Ix public precommit still rejects winner-changing cyborg allocations
assert.equal(messages[0], messages[1]);
});
void test('new Ix admission leaves optional-module and Basic Ecaz limits closed while retaining the existing non-Ix band', () => {
const source = ixRevealedWeaponFixture().game;
assert.equal(mirrorWeaponModeBlock(source), null);
for (const moduleName of ['homeworlds', 'nexusCards', 'leaderSkills', 'discoveries', 'techTokens', 'strongholdCards'] as const)
assert.notEqual(mirrorWeaponModeBlock({...source, [moduleName]: {}}), null);
assert.notEqual(mirrorWeaponModeBlock({...source, sandtrout: true}), null);
assert.notEqual(mirrorWeaponModeBlock({...source, expansions: ['ix']}), null);
assert.notEqual(mirrorWeaponModeBlock({...source, expansions: ['ix', 'choam', 'ecaz'], advanced: false}), null);
assert.equal(mirrorWeaponModeBlock({expansions: ['choam'], advanced: true,
players: [{faction: 'emperor'}, {faction: 'richese'}, {faction: 'guild'}], techTokens: {}, strongholdCards: {}}), null);
});
void test('native Ixian or Tleilaxu Occupy keeps fixed remainder, copied Stone queue and actual card-user reserve cost separate', () => {
for (const allyFaction of ['ixians', 'tleilaxu'] as const) for (const lead of ['ecaz', 'ally'] as const) {
const source = stoneHarassOccupyFixture({allyFaction, lead, harass: false, stone: 'opponent', mirror: true,
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
advanceNativeStoneModules, createNativeStoneModulesFixture, nativeStoneModulesClean,
nativeStoneModulesPlayer, NATIVE_MODULE_MIRROR, NATIVE_MODULE_REINFORCEMENTS,
NATIVE_MODULE_STONE, NATIVE_MODULE_TOOTH,
} from './fixture-native-stone-modules';
type Fixture = ReturnType<typeof createNativeStoneModulesFixture>;
function reveal(fixture: Fixture): Game {
let game = fixture.game;
for (const step of fixture.plans) game = applyAction(game, step.actor, step.action);
return game;
}
function finish(game: Game): Game {
for (const actor of viewGame(game, game.host).battle!.traitorVoters)
game = applyAction(game, actor, { type: 'traitorCall', call: false });
return advanceNativeStoneModules(game, state => !state.battle && nativeStoneModulesClean(state));
}
function assertPhysicalOutcome(fixture: Fixture, game: Game) {
assert.equal(game.lastBattleContext?.winner, fixture.owner);
const owner = nativeStoneModulesPlayer(game, fixture.owner);
assert.equal(owner.reserves, 12, 'Five native counters materialize before the original three-reserve fee');
assert.equal(owner.tanks, fixture.advanced ? 5 : 4, 'Only the real dialed counters and original fee reach Tanks');
assert.equal(owner.forces[fixture.location], fixture.advanced ? 3 : 4);
const opponent = nativeStoneModulesPlayer(game, fixture.opponent);
assert.equal(opponent.tanks, 3);
assert.equal(opponent.elites!.tanks, 1, 'The real opposing cyborg retains its typed loss');
assert.equal(opponent.forces['hidden_mobile_stronghold:0'], 6);
assert.equal(opponent.elites!.forces['hidden_mobile_stronghold:0'], 3);
for (const step of fixture.plans) {
const leader = nativeStoneModulesPlayer(game, step.actor).leaders.find(l => l.id === step.action.leader)!;
assert.equal(leader.dead, true, 'The original kill choice kills each actual selected disc once');
}
assert.equal(game.discard.filter(card => card.id === NATIVE_MODULE_REINFORCEMENTS).length, 1);
const lostTokens = Object.entries(fixture.game.techTokens!).filter(([, token]) => token.owner === fixture.opponent);
assert.equal(lostTokens.length, 1);
assert.equal(game.techTokens![lostTokens[0][0] as keyof NonNullable<Game['techTokens']>].owner, fixture.owner);
const faces = (state: Game) => [...state.deck, ...state.discard, ...(state.richeseCache ?? []),
...(state.richeseRemoved ?? []), ...state.players.flatMap(p => p.hand)].map(card => card.id).sort();
assert.deepEqual(faces(game), faces(fixture.afterSetup));
}
void test('original native Stone chooses before Tooth with genuine Tech and earned Advanced custody, keeping physical costs and typed losses', () => {
for (const advanced of [false, true]) {
const fixture = createNativeStoneModulesFixture({ advanced });
let game = reveal(fixture);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, fixture.owner);
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision!.event, mode: 'kill' });
assert.equal(game.decision?.kind, 'poisonTooth');
assert.equal(game.decision?.physicalId, NATIVE_MODULE_TOOTH);
game = applyAction(game, fixture.opponent, { type: 'decision', event: game.decision!.event, activate: false });
game = finish(game);
assertPhysicalOutcome(fixture, game);
assert.ok(nativeStoneModulesPlayer(game, fixture.owner).hand.some(c => c.id === NATIVE_MODULE_STONE));
}
});
void test('physical native Mirror chooses copied Stone first, retains independent original mode and winning physical custody', () => {
for (const advanced of [false, true]) {
const fixture = createNativeStoneModulesFixture({ advanced, kind: 'mirror' });
let game = reveal(fixture);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, fixture.owner);
assert.equal(game.decision?.physicalId, NATIVE_MODULE_MIRROR);
const copiedChoice = { type: 'decision' as const, event: game.decision!.event, mode: 'ignore' as const };
game = applyAction(game, fixture.owner, copiedChoice);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, fixture.opponent);
assert.equal(game.decision?.physicalId, NATIVE_MODULE_STONE);
const before = structuredClone(game);
assert.throws(() => applyAction(game, fixture.owner, copiedChoice));
assert.deepEqual(game, before, 'Replaying the copy cannot consume the original owner’s choice');
game = applyAction(game, fixture.opponent, { type: 'decision', event: game.decision!.event, mode: 'kill' });
game = finish(game);
assertPhysicalOutcome(fixture, game);
assert.ok(nativeStoneModulesPlayer(game, fixture.owner).hand.some(c => c.id === NATIVE_MODULE_MIRROR));
assert.equal(game.discard.filter(c => c.id === NATIVE_MODULE_MIRROR).length, 0);
assert.equal(game.discard.filter(c => c.id === NATIVE_MODULE_STONE).length, 1);
}
});
void test('genuinely earned Habbanya decides the native physical tie, while the same Tech-only battle loses despite Reinforcements', () => {
for (const strongholds of [false, true]) {
const fixture = createNativeStoneModulesFixture({ strongholds });
assert.equal(fixture.game.battle!.attacker, fixture.opponent);
let game = reveal(fixture);
assert.equal(game.decision?.kind, 'stoneBurner');
game = applyAction(game, fixture.owner, { type: 'decision', event: game.decision!.event, mode: 'ignore' });
assert.equal(game.decision?.kind, 'poisonTooth');
game = applyAction(game, fixture.opponent, { type: 'decision', event: game.decision!.event, activate: false });
game = finish(game);
assert.equal(game.lastBattleContext?.winner, strongholds ? fixture.owner : fixture.opponent,
'Three undialed native counters tie three; the score-only +2 cannot decide this comparison');
const owner = nativeStoneModulesPlayer(game, fixture.owner);
assert.equal(owner.reserves, 12, 'The original reserve fee applies to either physical result');
assert.equal(owner.tanks, strongholds ? 5 : 8);
if (strongholds) assert.equal(fixture.afterMentat.strongholdCards!.owners.habbanya_ridge_sietch, fixture.owner);
}
});