Allow chosen Reinforcements reserve costs
Verify and publish checkpoint container / container (push) Successful in 11m11s

This commit is contained in:
Quintine committed 2026-10-29 04:41:26 +11:00
1 parent 9dd386ff7a
commit 1cf90f1328
16 files changed
+419 -44

No files matched your search

+3 -2
View File
@@ -104,8 +104,9 @@ for (const [reserves, marker] of [[6, 3], [8, 5], [3, 0]] as const)
assert.equal(revealed.battle!.revealed, true);
assertMarkerRevealed(fixture, revealed);
const { afterVotes, game: done } = finishNoFieldReinforcements(fixture, revealed);
assert.equal(occupyRicheseSeat(afterVotes, fixture.richese).reserves, reserves - 3 - fixture.variableDial,
'The future undialed return neither funds nor changes the three-counter original reserve cost.');
assert.ok(afterVotes.battle, 'The shared final battle opportunity precedes settlement.');
assert.equal(occupyRicheseSeat(afterVotes, fixture.richese).reserves, reserves - marker,
'The No-Field has materialized; the reserve fee and return have not settled yet.');
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(done.lastBattleContext!.winner, fixture.owner);
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - 3 - fixture.variableDial);
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { NativeReserveError, type NativeReserveSelections } from '../game/homeworld-native-reserves';
import { type HomeworldCustody, type HomeworldCustodyContext } from '../game/homeworld-custody';
import {
quoteReinforcementsHomeworldCost as quote,
reinforcementsHomeworldSources as sources,
} from '../game/reinforcements-homeworld';
import { ReinforcementsError } from '../game/reinforcements';
import { combatArmy } from '../game/combat-location';
import {
assertReinforcementsHomeworldCustody,
createReinforcementsHomeworldFixture,
finishReinforcementsHomeworldBattle,
reinforcementsHomeworldPlayer as player,
reinforcementsHomeworldPool as pool,
sealReinforcementsHomeworldPlan,
} from './fixture-reinforcements-homeworld';
const kaitain = 'homeworld:emperor';
const salusa = 'homeworld:emperor:salusa';
const ix = 'homeworld:ixians';
const units = (normal: number, elite = 0) => ({ normal, elite });
function fixture(): { context: HomeworldCustodyContext; custody: HomeworldCustody } {
return {
context: { advanced: true, players: [
{ id: 'e', faction: 'emperor', reserves: 10, eliteReserves: 3 },
{ id: 'i', faction: 'ixians', reserves: 10, eliteReserves: 2 },
] },
custody: { salusa: units(3, 2), visitors: {
[kaitain]: { i: units(2) },
[ix]: { e: units(1, 1) },
} },
};
}
void test('Homeworld options and costs honor the selected typed fee and preserve normal-first defaults', () => {
const { context, custody } = fixture();
const before = structuredClone({ context, custody });
const defaults = sources(context, custody, 'e');
assert.deepEqual(defaults, sources(context, custody, 'e', 0));
assert.equal(defaults.length, 4);
const mixed = sources(context, custody, 'e', 2);
assert.equal(mixed.length, 4);
for (const allocation of mixed) {
assert.equal(allocation[kaitain].normal + allocation[salusa].normal, 1);
assert.equal(allocation[kaitain].elite + allocation[salusa].elite, 2);
const result = quote(context, custody, 'e', allocation, 2);
assert.deepEqual({ normal: result.normal, elite: result.elite, bonus: result.bonus },
{ normal: 1, elite: 2, bonus: 2 });
assert.deepEqual(result.sources, allocation);
assert.equal(result.native.players[0].reserves, 7);
assert.equal(result.native.players[0].eliteReserves, 1);
}
assert.throws(() => quote(context, custody, 'e', undefined, 2), NativeReserveError);
const defaultResult = quote(context, custody, 'e', defaults[0]);
assert.deepEqual({ normal: defaultResult.normal, elite: defaultResult.elite }, { normal: 3, elite: 0 });
assert.deepEqual({ context, custody }, before);
});
void test('single native Homeworld can automatically pay an explicit elite choice', () => {
const { context, custody } = fixture();
assert.deepEqual(sources(context, custody, 'i', 2), [{ [ix]: units(1, 2) }]);
const result = quote(context, custody, 'i', undefined, 2);
assert.deepEqual(result.sources, { [ix]: units(1, 2) });
assert.deepEqual({ normal: result.normal, elite: result.elite, bonus: result.bonus },
{ normal: 1, elite: 2, bonus: 2 });
assert.equal(result.native.players[1].eliteReserves, 0);
});
void test('selected types and explicit native allocations are both validated', () => {
const { context, custody } = fixture();
const before = structuredClone({ context, custody });
for (const choice of [-1, 4, 1.5, NaN, Infinity]) {
assert.throws(() => sources(context, custody, 'e', choice), ReinforcementsError);
assert.throws(() => quote(context, custody, 'e', undefined, choice), ReinforcementsError);
}
for (const allocation of [
{ [kaitain]: units(3) }, // valid default fee, wrong for the chosen mix
{ [ix]: units(1, 2) }, // visitor forces cannot pay the fee
{ [kaitain]: units(1, 2) }, // Kaitain has only one elite
{ [kaitain]: units(1, 1) }, // does not sum to the chosen fee
{ [kaitain]: { normal: 1, elite: 2, extra: 0 } },
]) {
assert.throws(() => quote(context, custody, 'e', allocation as unknown as NativeReserveSelections, 2), NativeReserveError);
}
assert.deepEqual({ context, custody }, before);
});
void test('Advanced Emperor seals and pays a mixed Homeworld fee from the selected Worlds', () => {
const allocation = { [kaitain]: units(1), [salusa]: units(1, 1) };
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: allocation });
const before = player(f.game, f.owner);
assert.deepEqual(pool(f.game, f.owner, kaitain), units(10));
assert.deepEqual(pool(f.game, f.owner, salusa), units(2, 1));
assert.deepEqual(combatArmy(f.game, f.owner, f.location), units(3, 4));
const action = { ...f.planActions[0].action, reinforcementsElite: 1 };
let game = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner, action });
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'Sealing does not pay the fee');
assert.equal(player(game, f.owner).elites!.reserves, before.elites!.reserves);
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)), f.planActions[1]);
const done = finishReinforcementsHomeworldBattle(game);
const after = player(done, f.owner);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(pool(done, f.owner, kaitain), units(9));
assert.deepEqual(pool(done, f.owner, salusa), units(1));
assert.deepEqual(combatArmy(done, f.owner, f.location), units(0));
assert.equal(after.reserves, before.reserves - 3);
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
assert.equal(after.tanks, before.tanks + 10, 'Three fee forces and seven defeated invaders');
assert.equal(after.elites!.tanks, before.elites!.tanks + 5, 'One fee elite and four defeated elites');
assert.equal(after.battleLosses, before.battleLosses + 10);
assertReinforcementsHomeworldCustody(f.game);
assertReinforcementsHomeworldCustody(done);
});
+4 -1
View File
@@ -70,7 +70,10 @@ void test('actual Advanced native movement exposes all three legal fee allocatio
for (const salusaNormal of [0, 1, 2]) {
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: sources(salusaNormal) });
const choices = viewGame(f.game, f.owner).battle!.reinforcements!.homeworldSources!;
assert.deepEqual(choices.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
assert.deepEqual(choices.filter(choice => Object.values(choice).every(group => group.elite === 0))
.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
assert.ok(choices.some(choice => Object.values(choice).some(group => group.elite > 0)),
'The expanded preview also offers physically available special reserves.');
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
const done = finishReinforcementsHomeworldBattle(game);
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Game, type GameView } from '../game/engine';
import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const card = 'ecaz-reinforcements';
function game(): Game {
const result = harassWithdrawGame({ advanced: true, factions: ['emperor', 'atreides', 'harkonnen'] });
takeHarassCard(result, 'a', card);
return result;
}
function markup(view: GameView): string {
return renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false }));
}
void test('owner can see every available Reinforcements reserve mix in the battle plan', () => {
const state = game(), view = viewGame(state, 'a');
assert.deepEqual(view.battle?.reinforcements?.eliteOptions, [0, 1, 2, 3]);
// Staged projection forces the physical card into its slot, avoiding a
// browser hook driver; the actual sealed-plan test below uses engine actions.
view.battle!.ownCommitments = [{ source: 'native', beneficiary: 'd', target: 'a', field: 'weapon', value: card }];
const html = markup(view);
const select = html.match(/<label[^>]*>\s*<span>Reinforcements reserve fee<\/span>[\s\S]*?<\/label>/)?.[0];
assert.ok(select);
for (const elite of [0, 1, 2, 3]) {
assert.match(select, new RegExp(`value="${elite}"`));
assert.match(select, new RegExp(`${3 - elite} normal \\+ ${elite} special`));
}
const opponent = markup(viewGame(state, 'd'));
assert.doesNotMatch(opponent, /Reinforcements reserve fee/);
});
void test('sealed typed reserve choice is visible only to its owner', () => {
const state = applyAction(game(), 'a', {
type: 'battlePlan', dial: 1, support: 1, leader: 'emperor-0', weapon: card, defense: null,
reinforcementsElite: 2,
});
const owner = viewGame(state, 'a'), opponent = viewGame(state, 'd');
assert.equal(owner.battle?.plans.a.reinforcementsElite, 2);
assert.deepEqual(opponent.battle?.plans, {});
assert.match(markup(owner), /Sealed reserve fee:\s*1 normal \+ 2 special/);
assert.doesNotMatch(markup(opponent), /Sealed reserve fee:/);
});
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const card = 'ecaz-reinforcements';
/** Genuine Advanced setup and native battle admission; the shared fixture
* stages physical board forces and card custody, preserving every counter. */
function fixture(): Game {
const game = harassWithdrawGame({ advanced: true, factions: ['emperor','atreides','harkonnen'] });
takeHarassCard(game, 'a', card);
harassCustody(game);
return game;
}
function plan(elite?: number, useCard = true): Action {
return { type: 'battlePlan', dial: 1, support: 1, leader: 'emperor-0',
weapon: useCard ? card : null, defense: null,
...(elite === undefined ? {} : { reinforcementsElite: elite }),
} as Action;
}
function second(game: Game): Game {
return applyAction(game, 'd', { type:'battlePlan', dial:1, support:1,
leader:'atreides-0', weapon:null, defense:null });
}
function finish(game: Game): Game {
let next = applyAction(game, 'a', {type:'traitorCall',call:false});
next = applyAction(next, 'd', {type:'traitorCall',call:false});
if (next.decision?.kind === 'moritaniAssassinate')
next = applyAction(next, next.decision.player,
{type:'decision', event:next.decision.event, decline:true});
harassCustody(next);
return next;
}
for (const selected of [0,1,2,3] as const) {
void test(`Reinforcements commits the selected ${selected} elite reserve cost`, () => {
const start = fixture(), initial = structuredClone(start);
const owner = start.players.find(row => row.id === 'a')!;
assert.ok(owner.reserves - owner.elites!.reserves >= 3);
assert.ok(owner.elites!.reserves >= 3);
const one = applyAction(start, 'a', plan(selected));
assert.deepEqual(start, initial);
assert.equal(one.players[0].reserves, owner.reserves, 'Sealing does not spend the cost');
assert.equal(one.players[0].elites!.reserves, owner.elites!.reserves);
assert.deepEqual(viewGame(one, 'd').battle?.plans, {}, 'A sealed plan remains private');
const resolved = finish(second(one));
const after = resolved.players.find(row => row.id === 'a')!;
assert.equal(after.reserves, owner.reserves - 3);
assert.equal(after.elites!.reserves, owner.elites!.reserves - selected);
assert.equal(after.elites!.tanks, owner.elites!.tanks + selected);
assert.equal(resolved.discard.filter(row => row.id === card).length, 1);
assert.equal(resolved.log.filter(row => row.text.includes('used Reinforcements: three reserve forces')).length, 1);
});
}
void test('legacy omitted selection preserves normal-first reserve costs', () => {
const initial = fixture();
const owner = initial.players[0];
const done = finish(second(applyAction(initial, 'a', plan())));
assert.equal(done.players[0].reserves, owner.reserves - 3);
assert.equal(done.players[0].elites!.reserves, owner.elites!.reserves);
});
void test('invalid or irrelevant elite-cost selections reject without mutating the game', () => {
const game = fixture();
for (const action of [plan(-1), plan(4), plan(1.5), plan(Number.NaN), plan(1, false)]) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'a', action));
assert.deepEqual(game, before);
}
const restricted = fixture();
restricted.players[0].elites!.reserves = 2;
restricted.players[0].elites!.tanks = 3;
restricted.players[0].reserves = 3;
restricted.players[0].tanks = 12;
harassCustody(restricted);
for (const invalid of [0,3]) {
const before = structuredClone(restricted);
assert.throws(() => applyAction(restricted, 'a', plan(invalid)));
assert.deepEqual(restricted, before);
}
assert.doesNotThrow(() => applyAction(restricted, 'a', plan(2)));
});
void test('all bot profiles retain a legal battle plan at an eligible typed-cost opportunity', () => {
const game = fixture();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'a');
view.players.find(row => row.id === 'a')!.bot = difficulty;
const actions = botActions(view);
const battle = actions.find(action => action.type === 'battlePlan');
assert.ok(battle, `${difficulty} must have a legal battle plan`);
assert.doesNotThrow(() => applyAction(game, 'a', battle));
}
});
+16 -1
View File
@@ -4,7 +4,7 @@ import { baseDeck, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { BattleResolutionQuoteError, quoteBattleResolution, type BattleResolutionInput } from '../game/battle-resolution-quote';
import { isReinforcements, quoteReinforcements, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements';
import { isReinforcements, quoteReinforcements, reinforcementsEliteOptions, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements';
const physicalCards = [...baseDeck(), ...richeseCards(), ...ecazTreacheryCards()];
const reinforcement = ecazTreacheryCards().find(c => c.id === REINFORCEMENTS_CARD)!;
@@ -42,6 +42,21 @@ function putCard(g: BattleResolutionInput, side: 'attacker' | 'defender', slot:
g[side].plan[slot] = card.id;
}
void test('Reinforcements permits each available typed mix and retains the legacy default', () => {
assert.deepEqual(reinforcementsEliteOptions(4, 4), [0, 1, 2, 3]);
assert.deepEqual(reinforcementsEliteOptions(2, 2), [1, 2]);
assert.deepEqual(reinforcementsEliteOptions(0, 4), [3]);
assert.deepEqual(reinforcementsEliteOptions(1, 1), []);
for (const elite of [0, 1, 2, 3])
assert.deepEqual(quoteReinforcements(4, 4, elite), { normal: 3 - elite, elite, bonus: 2 });
assert.deepEqual(quoteReinforcements(2, 2), { normal: 2, elite: 1, bonus: 2 });
assert.deepEqual(quoteReinforcements(2, 2, 2), { normal: 1, elite: 2, bonus: 2 });
for (const elite of [-1, 0, 3, 4, 1.5, NaN, Infinity])
assert.throws(() => quoteReinforcements(2, 2, elite), ReinforcementsError);
assert.throws(() => quoteReinforcements(1, 1, 1), ReinforcementsError);
assert.throws(() => reinforcementsEliteOptions(-1, 3), ReinforcementsError);
});
void test('canonical identity and reserve cost boundaries preserve the physical counter types', () => {
assert.equal(isReinforcements(reinforcement), true);
assert.equal(isReinforcements({ ...reinforcement, name: 'Reinforcement' }), false);