Allow chosen Reinforcements reserve costs
Verify and publish checkpoint container / container (push) Successful in 11m11s
Verify and publish checkpoint container / container (push) Successful in 11m11s
This commit is contained in:
1 parent
9dd386ff7a
commit
1cf90f1328
16 files changed
+419
-44
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user