Support paid No-Field companions in Basic allied transit
Verify and publish checkpoint container / container (push) Successful in 10m34s
Verify and publish checkpoint container / container (push) Successful in 10m34s
This commit is contained in:
1 parent
8083598cef
commit
40d567a053
7 files changed
+270
-9
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { actNoFieldAlliedSeparation as act, noFieldAlliedSeparationClean as clean,
|
||||
noFieldAlliedSeparationPlayer as player, nextNoFieldAlliedSeparation } from './fixture-no-field-allied-separation';
|
||||
import { nextRicheseHomeworldShipmentNativeStep, richeseHomeworldShipmentInventory as inventory,
|
||||
richeseHomeworldShipmentPopulation } from './fixture-richese-homeworld-shipment';
|
||||
|
||||
export { act, clean, inventory, player };
|
||||
|
||||
/** Original Basic + Homeworlds setup: one real paid counter per side in the
|
||||
* storm-separated Basin, then an actual worm, consensual alliance and an aid
|
||||
* pledge. Only unopened Treachery and Spice order is selected. */
|
||||
export function createNoFieldAlliedCompanionsFixture(value: NoFieldValue) {
|
||||
const actor = 'richese', ally = 'emperor', other = 'atreides';
|
||||
let game = createGame('NOFIELDALLIEDCOMPANIONS', newPlayer(actor, 'Richese', 'richese'), false, ['choam']);
|
||||
joinGame(game, newPlayer(ally, 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer(other, 'Atreides', 'atreides'));
|
||||
const actions: {actor: string; action: Action}[] = [];
|
||||
const perform = (id: string, action: Action) => { game = act(game, id, action, actions); };
|
||||
const reach = (predicate: (state: Game) => boolean) => {
|
||||
for (let i = 0; i < 1800 && !predicate(game); i++) {
|
||||
if (game.turn === 2 && game.phase === 2 && clean(game) && player(game, actor).spice < 2 &&
|
||||
player(game, actor).charityTurn !== game.turn) {
|
||||
perform(actor, {type: 'charity'}); continue;
|
||||
}
|
||||
const step = nextNoFieldAlliedSeparation(game) ?? nextRicheseHomeworldShipmentNativeStep(game);
|
||||
assert.ok(step, `Missing native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
assert.ok(predicate(game), 'Reach the requested native boundary');
|
||||
};
|
||||
for (const [index, seat] of game.players.entries()) perform(seat.id, {type: 'seatPosition', position: index + 4});
|
||||
perform(ally, {type: 'seatPosition', position: 1}); perform(actor, {type: 'seatPosition', position: 2});
|
||||
perform(other, {type: 'seatPosition', position: 3});
|
||||
perform(game.host, {type: 'homeworlds', enabled: true});
|
||||
for (const seat of game.players) perform(seat.id, {type: 'ready'});
|
||||
perform(game.host, {type: 'start'});
|
||||
const ordinary = game.deck.find(card => card.kind === 'projectile');
|
||||
const shield = game.deck.find(card => card.kind === 'shield');
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
assert.ok(ordinary && shield && karama);
|
||||
for (const [index, id] of [ordinary.id, shield.id, karama.id].entries()) {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= index);
|
||||
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
|
||||
}
|
||||
reach(state => state.status === 'playing');
|
||||
assert.ok(player(game, other).hand.some(card => card.id === karama.id));
|
||||
const original = inventory(game);
|
||||
const safe = (position: number, worm: boolean) => {
|
||||
const from = game.spiceDeck.findIndex((card, i) => i >= position && (worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed
|
||||
: 'territory' in card && !card.discovery && card.territory !== 'imperial_basin' &&
|
||||
game.players.every(seat => !Object.entries(seat.forces).some(([key, n]) => n > 0 && key.startsWith(`${card.territory}:`)))));
|
||||
assert.ok(from >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(from, 1)[0]);
|
||||
};
|
||||
safe(0, false);
|
||||
for (const [index, dialer] of game.stormDialers.entries()) perform(dialer, {type: 'stormDial', amount: index ? 5 : 4});
|
||||
reach(state => state.turn === 1 && state.phase === 5 && clean(state));
|
||||
assert.equal(game.storm, 10);
|
||||
while (game.phase === 5) {
|
||||
const current = game.active;
|
||||
if (clean(game) && current === ally && !player(game, ally).shipped) {
|
||||
perform(ally, {type: 'ship', territory: 'imperial_basin', sector: 11, amount: 1, elite: 0, allyPayment: 0,
|
||||
homeworldSources: {'homeworld:emperor': {normal: 1, elite: 0}}});
|
||||
reach(clean); continue;
|
||||
}
|
||||
if (clean(game) && current === actor && !player(game, actor).shipped) {
|
||||
perform(actor, {type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0,
|
||||
homeworldSources: {'homeworld:richese': {normal: 1, elite: 0}}});
|
||||
reach(clean); perform(actor, {type: 'endMovement'}); break;
|
||||
}
|
||||
const step = nextNoFieldAlliedSeparation(game) ?? nextRicheseHomeworldShipmentNativeStep(game); assert.ok(step);
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 0 && clean(state));
|
||||
safe(0, true); safe(1, false);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && clean(state));
|
||||
perform(actor, {type: 'alliance', target: ally}); perform(ally, {type: 'alliance', target: actor});
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && clean(state));
|
||||
assert.equal(player(game, actor).forces['imperial_basin:9'], 1);
|
||||
assert.equal(player(game, ally).forces['imperial_basin:11'], 1);
|
||||
assert.equal(richeseHomeworldShipmentPopulation(game).side, 'high');
|
||||
assert.equal(player(game, actor).ally, ally); assert.equal(player(game, ally).ally, actor);
|
||||
const own = player(game, actor), token = own.noField!.tokens.find(token => token.value === value);
|
||||
assert.ok(token && own.noFieldEvent);
|
||||
const needed = Math.max(0, 4 - own.spice); assert.ok(player(game, ally).spice >= needed);
|
||||
if (needed) perform(ally, {type: 'pledgeAid', amount: needed});
|
||||
const ship: Action = {type: 'ship', noField: token.id, event: player(game, actor).noFieldEvent!,
|
||||
noFieldReserves: 1, territory: 'imperial_basin', sector: 9, allyPayment: needed};
|
||||
return {game, actor, ally, other, token: token.id, karama: karama.id, value, ship,
|
||||
allyPayment: needed, baseline: 1, companions: 1, original, actions};
|
||||
}
|
||||
export function advanceAlliedCompanions(game: Game, until: (state: Game) => boolean): Game {
|
||||
for (let i = 0; i < 1800; i++) {
|
||||
if (until(game)) return game;
|
||||
const step = nextNoFieldAlliedSeparation(game) ?? nextRicheseHomeworldShipmentNativeStep(game); assert.ok(step);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Native allied companion controls did not reach the requested boundary');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { act, advanceAlliedCompanions as advance, clean, createNoFieldAlliedCompanionsFixture as fixture,
|
||||
inventory, player } from './fixture-no-field-allied-companions';
|
||||
|
||||
function ship(f: ReturnType<typeof fixture>): Game {
|
||||
const before = structuredClone(f.game);
|
||||
let game = act(f.game, f.actor, f.ship);
|
||||
assert.deepEqual(f.game, before);
|
||||
if (game.response) assert.equal(game.response.kind, 'richeseNoField');
|
||||
game = advance(game, state => clean(state) && !state.pendingShipment);
|
||||
const own = player(game, f.actor);
|
||||
assert.equal(player(before, f.actor).spice - own.spice, 4 - f.allyPayment);
|
||||
assert.equal(own.reserves, player(before, f.actor).reserves - f.companions);
|
||||
assert.equal(own.forces['imperial_basin:9'], f.baseline + f.companions);
|
||||
assert.equal(own.noField!.deployed!.tokenId, f.token);
|
||||
const warning = viewGame(game, f.actor).basicAlliedShipment;
|
||||
assert.ok(warning); assert.equal(warning.normal, f.companions);
|
||||
assert.equal(warning.elite, 0); assert.equal(warning.noField, true);
|
||||
assert.equal(viewGame(game, f.ally).basicAlliedShipment, null);
|
||||
assert.equal(viewGame(game, f.ally).richeseNoField?.private, null);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
void test(`Basic allied paid companion and marker ${value} may leave together`, () => {
|
||||
const f = fixture(value); let game = ship(f);
|
||||
const event = player(game, f.actor).noFieldEvent; assert.ok(event);
|
||||
game = act(game, f.actor, {type: 'move', noField: f.token, event,
|
||||
forces: {'imperial_basin:9': f.companions}, territory: 'polar_sink', sector: 0});
|
||||
game = advance(game, clean);
|
||||
assert.deepEqual(player(game, f.actor).noField!.deployed!.location, {territory: 'polar_sink', sector: 0});
|
||||
assert.equal(viewGame(game, f.actor).basicAlliedShipment, null);
|
||||
game = act(game, f.actor, {type: 'endMovement'});
|
||||
assert.ok(player(game, f.actor).noField!.deployed);
|
||||
assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(game, f.actor).forces['polar_sink:0'], f.companions);
|
||||
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test(`Basic allied paid companion and marker ${value} remaining at turn end are lost`, () => {
|
||||
const f = fixture(value), game = ship(f), before = player(game, f.actor);
|
||||
const reserves = before.reserves, tanks = before.tanks, event = before.noFieldEvent;
|
||||
const after = act(game, f.actor, {type: 'endMovement'});
|
||||
const own = player(after, f.actor), revealed = Math.min(value, reserves);
|
||||
assert.equal(own.noField!.deployed, null); assert.notEqual(own.noFieldEvent, event);
|
||||
assert.equal(own.noField!.lastShipped, f.token);
|
||||
assert.equal(own.reserves, reserves - revealed);
|
||||
assert.equal(own.tanks, tanks + revealed + f.companions);
|
||||
assert.equal(own.forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(after, f.ally).forces['imperial_basin:11'], 1);
|
||||
assert.equal(viewGame(after, f.actor).basicAlliedShipment, null);
|
||||
assert.deepEqual(inventory(after), f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('voluntary reveal adds actual marker forces to the paid companion obligation', () => {
|
||||
const f = fixture(3); let game = ship(f);
|
||||
const event = player(game, f.actor).noFieldEvent; assert.ok(event);
|
||||
game = act(game, f.actor, {type: 'revealNoField', event, token: f.token});
|
||||
const revealedEvent = player(game, f.actor).noFieldEvent;
|
||||
assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline + f.companions + 3);
|
||||
assert.equal(viewGame(game, f.actor).basicAlliedShipment?.normal, f.companions + 3);
|
||||
game = act(game, f.actor, {type: 'endMovement'});
|
||||
assert.equal(player(game, f.actor).noFieldEvent, revealedEvent);
|
||||
assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(game, f.actor).tanks, f.companions + 3);
|
||||
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('opponent Karama cancels the combined invoice before payment or custody', () => {
|
||||
const f = fixture(3), before = structuredClone(f.game);
|
||||
let game = act(f.game, f.actor, f.ship);
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
game = act(game, f.other, {type: 'card', mode: 'cancel', card: f.karama});
|
||||
game = advance(game, state => clean(state) && !state.pendingShipment);
|
||||
assert.equal(player(game, f.actor).spice, player(before, f.actor).spice);
|
||||
assert.equal(player(game, f.actor).reserves, player(before, f.actor).reserves);
|
||||
assert.deepEqual(player(game, f.actor).noField, player(before, f.actor).noField);
|
||||
assert.equal(player(game, f.actor).shipped, false);
|
||||
assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
|
||||
assert.deepEqual(game.aid, before.aid);
|
||||
assert.ok(game.basicAlliedShipment == null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('each AI profile legally moves the marker and paid companion together without visitor losses', () => {
|
||||
const f = fixture(3), source = ship(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(source);
|
||||
const tanks = player(game, f.actor).tanks;
|
||||
const view = viewGame(game, f.actor);
|
||||
view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const departure = botActions(view)[0]; assert.ok(departure, `${difficulty} must choose a legal departure`);
|
||||
assert.equal(departure.type, 'move');
|
||||
assert.equal(departure.noField, f.token);
|
||||
assert.equal((departure.forces as Record<string, number>)['imperial_basin:9'], f.companions);
|
||||
game = act(game, f.actor, departure);
|
||||
game = advance(game, clean);
|
||||
assert.notEqual(departure.territory, 'imperial_basin');
|
||||
assert.deepEqual(player(game, f.actor).noField!.deployed!.location,
|
||||
{territory: departure.territory, sector: departure.sector});
|
||||
assert.equal(viewGame(game, f.actor).basicAlliedShipment, null);
|
||||
for (let count = 0; game.phase === 5 && game.active === f.actor && count < 60; count++) {
|
||||
if (!clean(game)) { game = advance(game, clean); continue; }
|
||||
const nextView = viewGame(game, f.actor);
|
||||
nextView.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const next = botActions(nextView)[0]; assert.ok(next);
|
||||
game = act(game, f.actor, next);
|
||||
}
|
||||
assert.notEqual(game.active, f.actor, `${difficulty} must finish its visiting turn`);
|
||||
assert.equal(player(game, f.actor).tanks, tanks);
|
||||
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user