Expand Semuta and Richese Homeworld shipments
Verify and publish checkpoint container / container (push) Successful in 10m7s
Verify and publish checkpoint container / container (push) Successful in 10m7s
This commit is contained in:
1 parent
1ba942138d
commit
4d769af8c7
15 files changed
+626
-39
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import {
|
||||
actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
|
||||
basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
|
||||
type BasicSemutaAmbassadorStep,
|
||||
} from './fixture-basic-semuta-ambassador';
|
||||
|
||||
export const actIndependentOrdinary = actBasicSemutaAmbassador;
|
||||
export const independentOrdinaryInventory = basicSemutaAmbassadorInventory;
|
||||
export const independentOrdinaryPlayer = basicSemutaAmbassadorPlayer;
|
||||
export const independentOrdinaryClean = basicSemutaAmbassadorClean;
|
||||
|
||||
function next(game: Game): BasicSemutaAmbassadorStep | null {
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const reaction = game.pendingTreacheryDiscard.reaction;
|
||||
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: {type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event} };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
|
||||
/** Continue the original phase using native player actions, never a staged jump. */
|
||||
export function advanceIndependentOrdinary(game: Game, until: (state: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
const step = next(game); assert.ok(step, `Native continuation required at ${game.turn}/${game.phase}`);
|
||||
game = actIndependentOrdinary(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('The original Semuta phase did not reach its requested boundary.');
|
||||
}
|
||||
|
||||
/** Genuine public setup and auctions: only unopened deal and Spice order are
|
||||
* selected; Guild buys Semuta and Atreides buys the relevant physical card. */
|
||||
export function createIndependentOrdinaryFixture(effect: 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics') {
|
||||
let game = createGame('SEMUTARECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
|
||||
for (const faction of ['emperor', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const owner = 'atreides', holder = 'guild', actions: BasicSemutaAmbassadorStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actIndependentOrdinary(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
throw Error('The original Semuta source did not reach its requested boundary.');
|
||||
};
|
||||
act(owner, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
act(owner, { type: 'start' });
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true);
|
||||
assert.ok(game.ecazTreachery && !game.techTokens && !game.leaderSkills && !game.nexusCards && !game.homeworlds);
|
||||
const originalInventory = independentOrdinaryInventory(game);
|
||||
const starting = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
|
||||
.slice(0, game.players.length).map(card => card.id);
|
||||
assert.equal(starting.length, game.players.length);
|
||||
const card = game.deck.find(item => item.effect === effect); assert.ok(card);
|
||||
const selectedDeal = [...starting, card.id];
|
||||
for (let position = 0; position < selectedDeal.length; position++) {
|
||||
const from = game.deck.findIndex(card => card.id === selectedDeal[position]); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
}
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = next(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
}
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
act('richese', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
|
||||
method: 'onceAround', direction: 'counterclockwise' });
|
||||
reach(state => independentOrdinaryClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
for (let count = 0; !independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
|
||||
act(lot.active, { type: 'richeseBid', event: lot.event, amount: lot.active === holder ? 1 : null, allyPayment: 0 });
|
||||
reach(independentOrdinaryClean);
|
||||
}
|
||||
assert.ok(independentOrdinaryPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
reach(state => state.phase === 3 && independentOrdinaryClean(state) && state.auction?.active === owner);
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, card.id);
|
||||
act(owner, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
reach(state => independentOrdinaryClean(state) && independentOrdinaryPlayer(state, owner).hand.some(held => held.id === card.id));
|
||||
reach(state => state.phase === 5 && state.active === owner && independentOrdinaryClean(state));
|
||||
if (effect === 'hajr') return { game, owner, holder, card: card.id, originalInventory, actions,
|
||||
play: {type: 'card', card: card.id} as Action };
|
||||
const sector = effect === 'atomics' ? [8, 9].find(value => value !== game.storm)! : game.storm % 18 + 1;
|
||||
const destination = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') &&
|
||||
place.sectors.includes(sector) && game.players.every(player =>
|
||||
!Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(destination, 'The next Storm path has an empty legal sand sector');
|
||||
const casualtyLocation = `${destination.id}:${sector}`;
|
||||
act(owner, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
|
||||
if (effect === 'weather' || effect === 'atomics') {
|
||||
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && independentOrdinaryClean(state));
|
||||
} else if (effect === 'harvester') {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow &&
|
||||
!state.spiceWindow.harvested && independentOrdinaryClean(state));
|
||||
} else {
|
||||
reach(state => state.turn === 2 && state.phase === 4 && independentOrdinaryClean(state));
|
||||
assert.equal(independentOrdinaryPlayer(game, owner).forces[casualtyLocation], undefined);
|
||||
assert.equal(independentOrdinaryPlayer(game, owner).tanks, 3);
|
||||
}
|
||||
assert.deepEqual(independentOrdinaryInventory(game), originalInventory);
|
||||
return { game, owner, holder, card: card.id, originalInventory, actions, casualtyLocation,
|
||||
play: (effect === 'weather' ? {type: 'card', card: card.id, amount: 1}
|
||||
: effect === 'ghola' ? {type: 'card', card: card.id, amount: 3, elite: 0}
|
||||
: {type: 'card', card: card.id}) as Action };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import ts from 'typescript';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
import { createRicheseNoField } from '../game/richese-no-field';
|
||||
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import * as combat from '../game/combat';
|
||||
import * as options from '../game/homeworld-shipment-options';
|
||||
import { HomeworldNoFieldShipment } from '../components/homeworld-shipment';
|
||||
|
||||
function fixture(): GameView {
|
||||
const context = {advanced: false, players: [
|
||||
{id: 'r', faction: 'richese' as const, reserves: 12, eliteReserves: 0, ally: null as string | null},
|
||||
{id: 'a', faction: 'atreides' as const, reserves: 15, eliteReserves: 0, ally: null as string | null},
|
||||
{id: 'g', faction: 'guild' as const, reserves: 20, eliteReserves: 0, ally: null as string | null},
|
||||
]};
|
||||
const custody: HomeworldCustody = {salusa: null, visitors: {}};
|
||||
const populations = homeworldPopulations(context, custody);
|
||||
return {me: 'r', active: 'r', advanced: false, status: 'playing', phase: 5,
|
||||
homeworldShipment: {event: 'homeworld-event', blocked: null}, aid: {available: 0, pledged: 0},
|
||||
players: context.players.map(p => ({...p, name: p.faction, spice: p.id === 'r' ? 5 : undefined,
|
||||
shipped: false, elites: {reserves: 0}})),
|
||||
homeworlds: {worlds: homeworldForceGroups(context, custody).map(world => ({...world,
|
||||
...populations.find(population => population.location === world.id)!}))},
|
||||
richeseNoField: {owner: 'r', event: 'marker-event', private: createRicheseNoField(['zero', 'three', 'five']),
|
||||
canShip: true, shipBlock: null},
|
||||
} as unknown as GameView;
|
||||
}
|
||||
type Element = ReactElement<{children?: ReactNode; [key: string]: unknown}>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{children?: ReactNode; [key: string]: unknown}>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
|
||||
void test('fresh marker controls expose native reserve quantity and submit one mixed shipment', () => {
|
||||
const view = fixture(), sent: Action[] = [], states: unknown[] = [];
|
||||
const markup = renderToStaticMarkup(createElement(HomeworldNoFieldShipment, {game: view, act() {}, busy: false}));
|
||||
assert.match(markup, /your reserves/); assert.match(markup, /12.*available/);
|
||||
let cursor = 0;
|
||||
const exports: {HomeworldNoFieldShipment?: typeof HomeworldNoFieldShipment} = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/homeworld-shipment.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX},
|
||||
}).outputText, {exports, require(name: string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
if (name === 'react') return {useState(initial: unknown) {
|
||||
const index = cursor++; if (!(index in states)) states[index] = initial;
|
||||
return [states[index], (next: unknown) => { states[index] = next; }];
|
||||
}};
|
||||
if (name === '@/game/combat') return combat;
|
||||
if (name === '@/game/homeworld-shipment-options') return options;
|
||||
if (name === './ui/button') return {Button: 'button'};
|
||||
if (name === './ui/input') return {Input: 'input'};
|
||||
throw Error(`Unexpected import ${name}`);
|
||||
}});
|
||||
assert.ok(exports.HomeworldNoFieldShipment);
|
||||
const render = () => { cursor = 0; return elements(exports.HomeworldNoFieldShipment!({game: view, busy: false,
|
||||
act: action => sent.push(action)})); };
|
||||
for (const [label, value] of [['No-Field destination Homeworld', 'homeworld:atreides'],
|
||||
['Private No-Field token', 'zero']] as const) {
|
||||
const select = render().find(element => element.type === 'select' && element.props['aria-label'] === label);
|
||||
assert.ok(select);
|
||||
(select.props.onChange as (event: unknown) => void)({target: {value}});
|
||||
}
|
||||
const input = render().find(element => element.type === 'input'); assert.ok(input);
|
||||
assert.equal(input.props.max, 12);
|
||||
(input.props.onChange as (event: unknown) => void)({target: {value: '2'}});
|
||||
const button = render().find(element => element.type === 'button'); assert.ok(button);
|
||||
assert.equal(button.props.disabled, false);
|
||||
(button.props.onClick as () => void)();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), {type: 'homeworldShip', event: 'homeworld-event',
|
||||
destination: 'homeworld:atreides', sources: {'homeworld:richese': {normal: 2, elite: 0}},
|
||||
noField: 'zero', noFieldEvent: 'marker-event', allyPayment: 0});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldShipmentActions, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
|
||||
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
|
||||
|
||||
const owner = 'richese', opponent = 'atreides', guild = 'guild';
|
||||
const origin = 'homeworld:richese', destination = 'homeworld:atreides';
|
||||
function seat(game: Game, id: string) {
|
||||
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
|
||||
}
|
||||
const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Follow the actual native phase decisions and allow the real Advanced Guild
|
||||
* interception. No saved phase, played card, reserve or marker is staged. */
|
||||
function advance(game: Game, until: (state: Game) => boolean, trace: string[] = []): Game {
|
||||
let state = game;
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(state)) return state;
|
||||
assert.notEqual(state.status, 'finished');
|
||||
if (state.response) trace.push(`response:${state.response.kind}`);
|
||||
if (state.decision?.kind === 'homeworldShipmentGuild') {
|
||||
const decision = state.decision; trace.push(`decision:${decision.kind}`);
|
||||
state = applyAction(state, decision.player, {type: 'decision', event: decision.event, allow: true});
|
||||
continue;
|
||||
}
|
||||
const next = nextIndependentCardModulesStep(state);
|
||||
assert.ok(next, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}.`);
|
||||
state = applyAction(state, next.actor, next.action);
|
||||
}
|
||||
throw Error('The real mixed Homeworld shipment did not reach its boundary.');
|
||||
}
|
||||
|
||||
/** Original public Homeworld lobby, selecting just one undealt Karama position
|
||||
* so Guild can actually exercise the marker response and Advanced stop. */
|
||||
function setup(advanced: boolean): Game {
|
||||
let game = createGame('RICHESENATIVEFRESHMIX', newPlayer(owner, owner, 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(opponent, opponent, 'atreides'));
|
||||
joinGame(game, newPlayer(guild, guild, 'guild'));
|
||||
game = applyAction(game, owner, {type: 'homeworlds', enabled: true});
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type: 'ready'});
|
||||
game = applyAction(game, owner, advanced ? {type: 'start', advancedPreview: true} : {type: 'start'});
|
||||
const card = game.deck.findIndex(candidate => candidate.effect === 'karama');
|
||||
assert.ok(card >= 0 && game.deck.length >= 3);
|
||||
[game.deck[2], game.deck[card]] = [game.deck[card], game.deck[2]];
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
assert.ok(seat(game, guild).hand.some(card => card.effect === 'karama'));
|
||||
return advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
!!viewGame(state, owner).homeworldShipment && !!viewGame(state, owner).richeseNoField?.canShip);
|
||||
}
|
||||
|
||||
function inventory(game: Game) {
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
|
||||
Object.values(player.forces).reduce((sum, value) => sum + value, 0) + homes
|
||||
.filter(home => home.native !== player.id)
|
||||
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
|
||||
}
|
||||
function declaration(game: Game) {
|
||||
const own = viewGame(game, owner), info = own.richeseNoField!;
|
||||
const token = info.private!.tokens.find(candidate => candidate.value === 3); assert.ok(token);
|
||||
const selected = richeseHomeworldMarkerChoice(own, destination, token.id, 0, undefined, {normal: 2, elite: 0});
|
||||
assert.equal(selected.amount, 3); assert.equal(selected.cost, 3); assert.ok(selected.action);
|
||||
return {action: selected.action, token: token.id};
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} fresh marker and reserve companions use one paid shipment`, () => {
|
||||
let game = setup(advanced);
|
||||
const before = structuredClone(game), originalInventory = inventory(game), {action, token} = declaration(game);
|
||||
assert.ok(seat(before, owner).spice >= 3);
|
||||
for (const stale of [{...action, noFieldEvent: 'stale-marker'}, {...action, event: 'stale-shipment'}]) {
|
||||
assert.throws(() => applyAction(game, owner, stale)); assert.deepEqual(game, before);
|
||||
}
|
||||
game = applyAction(game, owner, action);
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
||||
const trace: string[] = [];
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
||||
seat(state, owner).shipped && !state.pendingHomeworldShipment, trace);
|
||||
assert.ok(trace.includes('response:richeseNoField'));
|
||||
assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 3);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves - 2);
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors[destination]?.[owner], {normal: 2, elite: 0});
|
||||
assert.deepEqual(seat(game, owner).noField!.deployed, {tokenId: token, controller: owner, location: {homeworld: destination}});
|
||||
assert.equal(seat(game, owner).noField!.lastShipped, token);
|
||||
assert.deepEqual(inventory(game), originalInventory);
|
||||
const other = viewGame(game, opponent).richeseNoField!;
|
||||
assert.equal(other.private, null); assert.equal(JSON.stringify(other.public).includes(token), false);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancellation preserves the fresh mixed source and unused shipment`, () => {
|
||||
let game = setup(advanced);
|
||||
const before = structuredClone(game), {action} = declaration(game);
|
||||
const karama = seat(game, guild).hand.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
game = applyAction(game, owner, action);
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
game = applyAction(game, guild, {type: 'card', mode: 'cancel', card: karama.id});
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && !state.pendingHomeworldShipment);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
|
||||
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
||||
assert.equal(seat(game, owner).shipped, false);
|
||||
assert.deepEqual(seat(game, owner).noField, seat(before, owner).noField);
|
||||
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
|
||||
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
|
||||
assert.deepEqual(inventory(game), inventory(before));
|
||||
const option = viewGame(game, owner).homeworldShipment!;
|
||||
game = applyAction(game, owner, {type: 'homeworldShip', event: option.event, destination,
|
||||
sources: {[origin]: {normal: 1, elite: 0}}, allyPayment: 0});
|
||||
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && seat(state, owner).shipped);
|
||||
assert.equal(game.homeworlds!.custody!.visitors[destination][owner].normal, 1);
|
||||
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 1);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all minimal bot profiles offer native legal marker candidates', () => {
|
||||
const game = setup(false), view = viewGame(game, owner);
|
||||
for (const difficulty of [0, 1, 2, 3]) {
|
||||
const action = homeworldShipmentActions(view, difficulty).find(candidate => candidate.noField !== undefined &&
|
||||
(candidate.sources as Record<string, {normal: number; elite: number}> | undefined)?.['homeworld:richese']?.normal === 1);
|
||||
assert.ok(action, `Profile ${difficulty} must offer a marker with legal reserve companions when eligible.`);
|
||||
assert.doesNotThrow(() => applyAction(game, owner, action));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
|
||||
import type { HomeworldShipmentContext } from '../game/homeworld-shipment';
|
||||
import { createRicheseNoField } from '../game/richese-no-field';
|
||||
import { quoteRicheseHomeworldMarkerShipment as quote,
|
||||
type RicheseHomeworldMarkerShipmentIntent } from '../game/richese-homeworld-marker-shipment';
|
||||
|
||||
function fixture() {
|
||||
const context: HomeworldShipmentContext = {advanced: false, players: [
|
||||
{id: 'r', faction: 'richese', reserves: 12, eliteReserves: 0, ally: null},
|
||||
{id: 'a', faction: 'atreides', reserves: 17, eliteReserves: 0, ally: null},
|
||||
{id: 'g', faction: 'guild', reserves: 20, eliteReserves: 0, ally: null},
|
||||
]};
|
||||
const custody: HomeworldCustody = {salusa: null, visitors: {
|
||||
'homeworld:atreides': {r: {normal: 2, elite: 0}},
|
||||
}};
|
||||
const noField = createRicheseNoField(['zero', 'three', 'five']);
|
||||
const intent: RicheseHomeworldMarkerShipmentIntent = {player: 'r', destination: 'homeworld:atreides',
|
||||
tokenId: 'zero', forces: {normal: 3, elite: 0}};
|
||||
return {context, custody, noField, intent};
|
||||
}
|
||||
|
||||
void test('fresh marker and paid physical companions form one detached shipment in both rulesets', () => {
|
||||
for (const advanced of [false, true]) for (const tokenId of ['zero', 'three', 'five']) {
|
||||
const f = fixture(); f.context.advanced = advanced; f.intent.tokenId = tokenId;
|
||||
const before = structuredClone(f);
|
||||
const result = quote(f.context, f.custody, f.intent, f.noField);
|
||||
assert.equal(result.amount, 4); assert.equal(result.cost, 4); assert.equal(result.elite, 0);
|
||||
assert.deepEqual(result.noField.deployed, {tokenId, controller: 'r', location: {homeworld: 'homeworld:atreides'}});
|
||||
assert.equal(result.players?.find(player => player.id === 'r')?.reserves, 9);
|
||||
assert.deepEqual(result.state?.visitors['homeworld:atreides'].r, {normal: 5, elite: 0});
|
||||
assert.deepEqual(result.sources?.[0], {homeworld: 'homeworld:richese', player: 'r',
|
||||
before: {normal: 12, elite: 0}, after: {normal: 9, elite: 0}});
|
||||
assert.deepEqual(f, before);
|
||||
result.state!.visitors['homeworld:atreides'].r.normal = 0;
|
||||
assert.deepEqual(f, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole mixed invoice rounds once and zero companions keep the legacy quote', () => {
|
||||
const f = fixture();
|
||||
const legacy = {...f.intent}; delete legacy.forces;
|
||||
assert.deepEqual(quote(f.context, f.custody, {...legacy, forces: {normal: 0, elite: 0}}, f.noField),
|
||||
quote(f.context, f.custody, legacy, f.noField));
|
||||
f.context.purchasedGuildRate = true;
|
||||
for (const count of [1, 2, 3, 11, 12]) {
|
||||
f.intent.forces = {normal: count, elite: 0};
|
||||
const result = quote(f.context, f.custody, f.intent, f.noField);
|
||||
assert.equal(result.amount, count + 1); assert.equal(result.cost, Math.ceil((count + 1) / 2));
|
||||
}
|
||||
});
|
||||
|
||||
void test('invalid companion quantities, unavailable reserves and extra sources reject without mutation', () => {
|
||||
for (const forces of [
|
||||
{normal: 13, elite: 0}, {normal: 1, elite: 1}, {normal: -1, elite: 0},
|
||||
{normal: 0.5, elite: 0}, {normal: 1}, {normal: 1, elite: 0, extra: 0}, null,
|
||||
]) {
|
||||
const f = fixture();
|
||||
f.intent = {...f.intent, forces} as unknown as RicheseHomeworldMarkerShipmentIntent;
|
||||
const before = structuredClone(f);
|
||||
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
|
||||
assert.deepEqual(f, before);
|
||||
}
|
||||
const f = fixture(); f.intent = {...f.intent, source: 'homeworld:guild'} as RicheseHomeworldMarkerShipmentIntent;
|
||||
const before = structuredClone(f);
|
||||
assert.throws(() => quote(f.context, f.custody, f.intent, f.noField), HomeworldCustodyError);
|
||||
assert.deepEqual(f, before);
|
||||
});
|
||||
|
||||
void test('owned controls quote fresh reserve companions, exact source, affordability and rate', () => {
|
||||
const f = fixture(), populations = homeworldPopulations(f.context, f.custody);
|
||||
const view = {
|
||||
me: 'r', active: 'r', advanced: false, status: 'playing', phase: 5,
|
||||
homeworldShipment: {event: 'shipment', blocked: null}, aid: {available: 0, pledged: 0},
|
||||
players: f.context.players.map(player => ({...player, name: player.faction, spice: player.id === 'r' ? 6 : undefined,
|
||||
shipped: false, elites: {reserves: 0}})),
|
||||
homeworlds: {worlds: homeworldForceGroups(f.context, f.custody).map(world => ({...world,
|
||||
...populations.find(population => population.location === world.id)!}))},
|
||||
richeseNoField: {owner: 'r', event: 'marker', private: f.noField, canShip: true},
|
||||
} as unknown as GameView;
|
||||
const before = structuredClone(view);
|
||||
const chosen = richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined, {normal: 3, elite: 0});
|
||||
assert.equal(chosen.amount, 4); assert.equal(chosen.cost, 4);
|
||||
assert.deepEqual(chosen.action, {type: 'homeworldShip', event: 'shipment', destination: 'homeworld:atreides',
|
||||
sources: {'homeworld:richese': {normal: 3, elite: 0}}, noField: 'three', noFieldEvent: 'marker', allyPayment: 0});
|
||||
assert.deepEqual(view, before);
|
||||
const discount = {...view, karamaShipping: {player: 'r'}} as GameView;
|
||||
assert.equal(richeseHomeworldMarkerChoice(discount, 'homeworld:atreides', 'three', 0, undefined,
|
||||
{normal: 2, elite: 0}).cost, 2);
|
||||
view.players[0].spice = 2;
|
||||
assert.equal(richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined,
|
||||
{normal: 3, elite: 0}).action, null);
|
||||
assert.equal(richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'three', 0, undefined,
|
||||
{normal: 13, elite: 0}).action, null);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Game, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { stormSectorAfter } from '../game/disaster-rules';
|
||||
import { actIndependentOrdinary as act, createIndependentOrdinaryFixture as fixture,
|
||||
advanceIndependentOrdinary as advance, independentOrdinaryInventory as inventory,
|
||||
independentOrdinaryPlayer as player } from './fixture-semuta-independent-ordinary';
|
||||
|
||||
function react(game: Game, holder: string, event: string, claim: boolean) {
|
||||
if (claim) return act(game, holder, {type: 'semutaCommit', event});
|
||||
for (const seat of game.players) game = act(game, seat.id, {type: 'semutaPass', event});
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const effect of ['hajr', 'ghola', 'weather', 'harvester', 'atomics'] as const) for (const claim of [false, true]) {
|
||||
void test(`Basic independent cards: ${effect} offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => {
|
||||
const f = fixture(effect), before = structuredClone(f.game);
|
||||
const oldPlayer = player(f.game, f.owner);
|
||||
const pending = act(f.game, f.owner, f.play);
|
||||
assert.deepEqual(f.game, before);
|
||||
const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
|
||||
assert.equal(frame.reaction?.stage, 'offer');
|
||||
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
|
||||
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]);
|
||||
assert.ok(viewGame(pending, f.holder).semutaReaction?.canCommit);
|
||||
if (effect === 'hajr') assert.ok(pending.hajr.includes(f.owner));
|
||||
if (effect === 'ghola') {
|
||||
assert.equal(player(pending, f.owner).reserves, oldPlayer.reserves + 3);
|
||||
assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks - 3);
|
||||
}
|
||||
if (effect === 'weather') assert.equal(pending.stormPending, 1);
|
||||
if (effect === 'atomics') {
|
||||
assert.equal(pending.shieldWallDestroyed, true);
|
||||
assert.equal(player(pending, f.owner).forces[f.casualtyLocation!], undefined);
|
||||
assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks + 3);
|
||||
}
|
||||
if (effect === 'harvester') {
|
||||
assert.equal(pending.spiceWindow!.amount, f.game.spiceWindow!.amount * 2);
|
||||
assert.equal(pending.spiceWindow!.harvested, true);
|
||||
}
|
||||
const committed = {phase: pending.phase, turn: pending.turn, storm: pending.storm,
|
||||
stormPending: pending.stormPending, hajr: pending.hajr, spice: pending.spice,
|
||||
spiceWindow: pending.spiceWindow, shieldWallDestroyed: pending.shieldWallDestroyed,
|
||||
forces: player(pending, f.owner).forces,
|
||||
reserves: player(pending, f.owner).reserves, tanks: player(pending, f.owner).tanks};
|
||||
const done = react(pending, f.holder, frame.batch.event, claim);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual({phase: done.phase, turn: done.turn, storm: done.storm,
|
||||
stormPending: done.stormPending, hajr: done.hajr, spice: done.spice,
|
||||
spiceWindow: done.spiceWindow, shieldWallDestroyed: done.shieldWallDestroyed,
|
||||
forces: player(done, f.owner).forces,
|
||||
reserves: player(done, f.owner).reserves, tanks: player(done, f.owner).tanks}, committed);
|
||||
assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), claim);
|
||||
assert.equal(done.discard.some(card => card.id === f.card), !claim);
|
||||
assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
assert.throws(() => act(done, f.holder, {type: 'semutaCommit', event: frame.batch.event}));
|
||||
if (effect === 'hajr') {
|
||||
const next = act(done, f.owner, {type: 'endMovement'});
|
||||
assert.ok(!next.movementRemaining?.includes(f.owner));
|
||||
} else if (effect === 'weather' || effect === 'atomics') {
|
||||
const next = advance(done, state => state.turn === done.turn && state.phase === 1);
|
||||
assert.equal(next.storm, stormSectorAfter(done.storm, done.stormPending!));
|
||||
if (effect === 'atomics') assert.equal(next.shieldWallDestroyed, true);
|
||||
} else if (effect === 'harvester') {
|
||||
const next = advance(done, state => state.turn === done.turn && state.phase === 2);
|
||||
const blow = pending.spiceWindow!;
|
||||
const key = `${blow.territory}:${blow.sector}`;
|
||||
assert.equal(next.spice[key] ?? 0, committed.spice[key] ?? 0);
|
||||
} else {
|
||||
const next = advance(done, state => state.turn === done.turn && state.phase === 5);
|
||||
assert.equal(player(next, f.owner).reserves, committed.reserves);
|
||||
assert.equal(player(next, f.owner).tanks, committed.tanks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('all AI profiles can legally respond to an independent-card Hajr discard', () => {
|
||||
const f = fixture('hajr'), pending = act(f.game, f.owner, f.play);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = structuredClone(pending);
|
||||
player(game, f.holder).bot = difficulty;
|
||||
const response = botActions(viewGame(game, f.holder)).find(action =>
|
||||
action.type === 'semutaCommit' || action.type === 'semutaPass');
|
||||
assert.ok(response);
|
||||
assert.doesNotThrow(() => act(game, f.holder, response));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user