Support Basic allied No-Field transit
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
50c6201d33
commit
8083598cef
9 files changed
+282
-14
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import {
|
||||
actNoFieldAlliedSeparation as act, advanceNoFieldAlliedSeparation as advance,
|
||||
createNoFieldAlliedSeparationFixture, noFieldAlliedSeparationClean as clean,
|
||||
noFieldAlliedSeparationInventory as inventory, noFieldAlliedSeparationPlayer as player,
|
||||
nextNoFieldAlliedSeparation,
|
||||
type NoFieldAlliedSeparationStep,
|
||||
} from './fixture-no-field-allied-separation';
|
||||
|
||||
export { act, advance, clean, inventory, player };
|
||||
|
||||
/** Start with the existing genuine Basic setup and first Storm. Replace only
|
||||
* the source's prospective marker shipment with a paid two-force shipment;
|
||||
* Emperor's paid two forces are already across the actual Storm. The next
|
||||
* real worm opens Nexus and both factions consent before Richese's next turn.
|
||||
* Only unopened Spice-card order is selected; no played force, purse,
|
||||
* alliance, phase, marker or receipt is installed. */
|
||||
export function createNoFieldAlliedTransitFixture(value: NoFieldValue = 0) {
|
||||
const base = createNoFieldAlliedSeparationFixture({advanced: false, value});
|
||||
let game = structuredClone(base.markerShipment.before);
|
||||
const actor = base.actor, ally = base.ally, other = base.other;
|
||||
const actions: NoFieldAlliedSeparationStep[] = [];
|
||||
const perform = (id: string, action: Action) => { game = act(game, id, action, actions); };
|
||||
const reach = (predicate: (state: Game) => boolean) => {
|
||||
for (let count = 0; count < 1800 && !predicate(game); count++) {
|
||||
const own = player(game, actor);
|
||||
// The two-force first shipment costs four, so native turn-two Charity
|
||||
// must actually bring Richese from one spice to two for the marker.
|
||||
if (game.turn === 2 && game.phase === 2 && clean(game) && own.spice < 2 && own.charityTurn !== game.turn) {
|
||||
perform(actor, {type: 'charity'}); continue;
|
||||
}
|
||||
const step = nextNoFieldAlliedSeparation(game); assert.ok(step);
|
||||
perform(step.actor, step.action);
|
||||
}
|
||||
assert.ok(predicate(game), 'The real transit source must reach its requested boundary.');
|
||||
};
|
||||
assert.equal(game.active, actor); assert.equal(game.turn, 1); assert.equal(game.storm, 10);
|
||||
assert.equal(player(game, ally).forces['imperial_basin:11'], 2);
|
||||
assert.equal(player(game, actor).noField!.deployed, null);
|
||||
const beforePhysical = structuredClone(game);
|
||||
perform(actor, {type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0});
|
||||
reach(clean);
|
||||
assert.equal(player(game, actor).forces['imperial_basin:9'], 2);
|
||||
assert.equal(player(beforePhysical, actor).spice - player(game, actor).spice, 4);
|
||||
perform(actor, {type: 'endMovement'});
|
||||
reach(state => state.turn === 2 && state.phase === 0 && clean(state));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && (position === 0
|
||||
? '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, count]) =>
|
||||
count > 0 && key.startsWith(`${card.territory}:`)))));
|
||||
assert.ok(from >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(from, 1)[0]);
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
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});
|
||||
assert.equal(player(game, actor).ally, ally); assert.equal(player(game, ally).ally, actor);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && clean(state));
|
||||
const own = player(game, actor), marker = own.noField?.tokens.find(item => item.value === value);
|
||||
assert.ok(marker && own.noFieldEvent);
|
||||
assert.equal(own.forces['imperial_basin:9'], 2);
|
||||
assert.equal(player(game, ally).forces['imperial_basin:11'], 2);
|
||||
assert.equal(own.allySinceTurn, game.turn);
|
||||
assert.ok(own.spice >= 2);
|
||||
const ship: Action = {type: 'ship', noField: marker.id, event: own.noFieldEvent,
|
||||
territory: 'imperial_basin', sector: 9, allyPayment: 0};
|
||||
return {game, actor, ally, other, value, token: marker.id, ship, actions,
|
||||
baseline: 2, original: inventory(game), base};
|
||||
}
|
||||
|
||||
/** Resolve the original No-Field response and any native arrival controls. */
|
||||
export function finishAlliedMarkerShipment(game: Game): Game {
|
||||
return advance(game, state => state.phase === 5 && clean(state) && !state.pendingShipment);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { act, advance, clean, createNoFieldAlliedTransitFixture as fixture,
|
||||
finishAlliedMarkerShipment, inventory, player } from './fixture-no-field-allied-transit';
|
||||
|
||||
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, 'Declaring the real shipment does not mutate the input.');
|
||||
// The native action can resolve the no-response case immediately when no
|
||||
// player can intervene, or retain its real Richese response for passes.
|
||||
if (game.response) assert.equal(game.response.kind, 'richeseNoField');
|
||||
game = finishAlliedMarkerShipment(game);
|
||||
assert.equal(player(before, f.actor).spice - player(game, f.actor).spice, 2);
|
||||
assert.equal(player(game, f.actor).reserves, player(before, f.actor).reserves);
|
||||
assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(game, f.actor).noField!.deployed!.tokenId, f.token);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
const warning = viewGame(game, f.actor).basicAlliedShipment;
|
||||
assert.ok(warning); assert.equal(warning.territory, 'Imperial Basin');
|
||||
assert.equal(warning.normal, 0); 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);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
void test(`Basic allied marker ${value} may depart on its own original movement`, () => {
|
||||
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: {}, 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.ally).forces['imperial_basin:11'], 2);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test(`Basic allied marker ${value} left at turn end reveals and loses only its new forces`, () => {
|
||||
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), materialized = 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 - materialized); assert.equal(own.tanks, tanks + materialized);
|
||||
assert.equal(own.forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(after, f.ally).forces['imperial_basin:11'], 2);
|
||||
assert.equal(viewGame(after, f.actor).basicAlliedShipment, null);
|
||||
assert.deepEqual(inventory(after), f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('voluntary revelation preserves the same visitor obligation without revealing twice', () => {
|
||||
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 + 3);
|
||||
assert.equal(viewGame(game, f.actor).basicAlliedShipment?.normal, 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, 3);
|
||||
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 2);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('each minimal AI profile can complete an allied marker visit legally', () => {
|
||||
const f = fixture(3), source = ship(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(source);
|
||||
for (let count = 0; game.phase === 5 && game.active === f.actor && count < 60; count++) {
|
||||
if (!clean(game)) { game = advance(game, clean); continue; }
|
||||
const view = viewGame(game, f.actor);
|
||||
view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const next = botActions(view)[0]; assert.ok(next);
|
||||
game = act(game, f.actor, next);
|
||||
}
|
||||
assert.notEqual(game.active, f.actor, `${difficulty} must finish the visiting turn.`);
|
||||
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 2);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Karama cancels the marker visit before payment, custody or visitor receipt', () => {
|
||||
const f = fixture(3), game = structuredClone(f.game), opponent = player(game, f.other);
|
||||
// Controlled conserved card transfer: select one original undealt Karama
|
||||
// into an opponent's free hand slot to exercise the native response.
|
||||
const from = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(from >= 0); assert.ok(opponent.hand.length < 4);
|
||||
const [karama] = game.deck.splice(from, 1); opponent.hand.push(karama);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
const before = structuredClone(game);
|
||||
let attempted = act(game, f.actor, f.ship);
|
||||
assert.equal(attempted.response?.kind, 'richeseNoField');
|
||||
attempted = act(attempted, f.other, {type: 'card', mode: 'cancel', card: karama.id});
|
||||
attempted = advance(attempted, state => clean(state) && !state.pendingShipment);
|
||||
assert.equal(player(attempted, f.actor).spice, player(before, f.actor).spice);
|
||||
assert.equal(player(attempted, f.actor).reserves, player(before, f.actor).reserves);
|
||||
assert.deepEqual(player(attempted, f.actor).noField, player(before, f.actor).noField);
|
||||
assert.equal(player(attempted, f.actor).shipped, false);
|
||||
assert.equal(player(attempted, f.actor).forces['imperial_basin:9'], f.baseline);
|
||||
assert.equal(player(attempted, f.ally).forces['imperial_basin:11'], 2);
|
||||
assert.ok(attempted.basicAlliedShipment == null);
|
||||
assert.equal(attempted.discard.filter(card => card.id === karama.id).length, 1);
|
||||
assert.deepEqual(inventory(attempted), f.original);
|
||||
});
|
||||
|
||||
void test('allied transit does not bypass storm or the stronghold occupancy limit', () => {
|
||||
const f = fixture(3), storm = TERRITORIES.find(place => place.type !== 'polar' && place.sectors.includes(f.game.storm));
|
||||
assert.ok(storm);
|
||||
const original = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.actor, {...f.ship, territory: storm.id, sector: f.game.storm}), /storm/i);
|
||||
assert.deepEqual(f.game, original);
|
||||
|
||||
const blocked = structuredClone(f.game), ally = player(blocked, f.ally);
|
||||
// Controlled force relocation retains the two physical ally counters; the
|
||||
// native Atreides garrison supplies the second existing stronghold occupant.
|
||||
assert.ok(player(blocked, f.other).forces['arrakeen:10'] > 0);
|
||||
assert.equal(ally.forces['imperial_basin:11'], 2);
|
||||
delete ally.forces['imperial_basin:11']; ally.forces['arrakeen:10'] = 2;
|
||||
assert.notEqual(blocked.storm, 10);
|
||||
const before = structuredClone(blocked);
|
||||
assert.throws(() => applyAction(blocked, f.actor, {...f.ship, territory: 'arrakeen', sector: 10}), /stronghold.*three/i);
|
||||
assert.deepEqual(blocked, before);
|
||||
assert.deepEqual(inventory(blocked), f.original);
|
||||
});
|
||||
Reference in new issue
Block a user