Implement mixed Planetologist advisor gather continuation
This commit is contained in:
1 parent
bc37cd38fe
commit
c265f0638e
16 files changed
+512
-171
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameTerritories, location } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { botGroundMoveAllowed } from '../game/bot-mobility';
|
||||
import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills';
|
||||
import { nextHarassHomeworldStep, harassHomeworldClean } from './fixture-harass-homeworld';
|
||||
|
||||
export type AdvisorGatherStep = { actor: string; action: Action };
|
||||
export const advisorGatherPlayer = (game: Game, actor = 'beneGesserit') => {
|
||||
const player = game.players.find(player => player.id === actor);
|
||||
assert.ok(player); return player;
|
||||
};
|
||||
|
||||
/** Original normal lobby and native actions only. The lottery selects an actual
|
||||
* offered Planetologist; no counters, posture, money, phases or usage is staged.
|
||||
* The only conserved card input is one explicitly labelled original Karama. */
|
||||
export function createPlanetologistAdvisorGatherFixture(options: { fresh?: boolean } = {}) {
|
||||
const setup = createSelectedDeckSkillsSetup({ advanced: true,
|
||||
factions: ['beneGesserit', 'emperor', 'atreides'], expansions: [], skill: 'planetologist' });
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const actions: AdvisorGatherStep[] = [...setup.actions], staging = [...setup.staging];
|
||||
const owner = 'beneGesserit', canceler = 'emperor', opponent = 'atreides';
|
||||
const targetTurn = options.fresh ? 1 : 2;
|
||||
const until = () => game.turn === targetTurn && game.phase === 5 && game.active === owner &&
|
||||
harassHomeworldClean(game) && (!options.fresh || advisorGatherPlayer(game).shipped);
|
||||
for (let count = 0; count < 600 && !until(); count++) {
|
||||
assert.equal(game.status, 'playing');
|
||||
let step: AdvisorGatherStep | null = null;
|
||||
const decision = !game.response && !game.phaseOpening ? game.decision : null;
|
||||
if (decision?.kind === 'advisor') {
|
||||
const accompany = game.turn === 1 && decision.shipment === canceler;
|
||||
step = { actor: decision.player, action: accompany
|
||||
? { type: 'decision', accept: true, accompany: true, sector: 9, amount: 1 }
|
||||
: { type: 'decision', accept: false } };
|
||||
} else if (decision?.kind === 'advisorBattle') {
|
||||
step = { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
} else if (game.turn === 1 && game.phase === 5 && harassHomeworldClean(game)) {
|
||||
const actor = game.active!, player = advisorGatherPlayer(game, actor);
|
||||
if (actor === owner && !player.shipped)
|
||||
step = { actor, action: { type: 'ship', territory: 'carthag', sector: 11,
|
||||
amount: 1, elite: 0, allyPayment: 0 } };
|
||||
else if (actor === canceler && !player.shipped)
|
||||
step = { actor, action: { type: 'ship', territory: 'imperial_basin', sector: 9,
|
||||
amount: 1, elite: 0, allyPayment: 0 } };
|
||||
else if (actor === opponent && !player.moved) {
|
||||
const view = viewGame(game, actor), own = view.players.find(player => player.id === actor)!;
|
||||
let destination: { territory: string; sector: number } | null = null;
|
||||
for (const territory of gameTerritories(game)) {
|
||||
if (territory.type !== 'rock' || game.players.some(player => presenceAt(player, territory.id))) continue;
|
||||
const sector = territory.sectors.find(sector =>
|
||||
botGroundMoveAllowed(view, own, 'arrakeen:10', location(territory.id, sector), 0));
|
||||
if (sector !== undefined) { destination = { territory: territory.id, sector }; break; }
|
||||
}
|
||||
assert.ok(destination, 'The original Atreides army keeps one defender and moves nine to empty rock');
|
||||
step = { actor, action: { type: 'move', from: 'arrakeen:10', amount: 9, ...destination } };
|
||||
}
|
||||
}
|
||||
step ??= nextHarassHomeworldStep(game);
|
||||
assert.ok(step, `The native gather chronology needs a legal action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
if (game.turn === 1 && step.action.type === 'stormDial')
|
||||
step.action.amount = options.fresh && step.actor === owner ? 2 : 0;
|
||||
actions.push(structuredClone(step));
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.ok(until(), 'Reach the original BG movement turn with both physical source groups');
|
||||
const me = advisorGatherPlayer(game);
|
||||
assert.equal(me.forces['carthag:11'], 1);
|
||||
assert.equal(me.forces['imperial_basin:9'], 1);
|
||||
assert.equal(me.advisors?.imperial_basin.lockedTurn, 1);
|
||||
assert.equal(advisorGatherPlayer(game, opponent).forces['arrakeen:10'], 1);
|
||||
let karama = advisorGatherPlayer(game, canceler).hand.find(card => card.effect === 'karama');
|
||||
if (!karama) {
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0, 'A conserved original Karama must remain in the ordinary deck');
|
||||
assert.ok(advisorGatherPlayer(game, canceler).hand.length < 4);
|
||||
karama = game.deck.splice(index, 1)[0];
|
||||
advisorGatherPlayer(game, canceler).hand.push(karama);
|
||||
staging.push(`Conserved ${karama.id}: original deck → Emperor hand; not a purchase or new card`);
|
||||
}
|
||||
const forces = { 'carthag:11': 1, 'imperial_basin:9': 1 };
|
||||
const action: Action = { type: 'move', planetologist: 'gather', fighters: true,
|
||||
forces, territory: 'arrakeen', sector: 10 };
|
||||
return { initial: setup.initial, afterSetup: setup.afterSetup, game, owner, canceler, opponent,
|
||||
forces, action, karama: karama.id, actions, staging };
|
||||
}
|
||||
|
||||
/** The normal native response, including automatic passes, not a forced flip. */
|
||||
export function allowPlanetologistAdvisorGather(game: Game): Game {
|
||||
let state = structuredClone(game);
|
||||
for (let count = 0; state.response && count < 12; count++) {
|
||||
assert.equal(state.response.kind, 'advisorFlip');
|
||||
const actor = state.players.find(player => !state.response!.passed.includes(player.id));
|
||||
assert.ok(actor);
|
||||
state = applyAction(state, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(state.response, null);
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, RuleError, viewGame } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { planetologistMoveDraft } from '../components/planetologist-movement';
|
||||
import {
|
||||
advisorGatherPlayer, allowPlanetologistAdvisorGather, createPlanetologistAdvisorGatherFixture,
|
||||
} from './fixture-planetologist-advisor-gather';
|
||||
|
||||
void test('a real mixed gather declares one native flip before either source moves, then arrives as fighters for one movement', () => {
|
||||
const f = createPlanetologistAdvisorGatherFixture();
|
||||
// Advisor first: simultaneous physical custody cannot depend on source order.
|
||||
const draft = planetologistMoveDraft(viewGame(f.game, f.owner), 'gather', 'arrakeen', 10,
|
||||
{ 'imperial_basin:9': 1, 'carthag:11': 1 }, {});
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.ok(draft.action);
|
||||
const before = advisorGatherPlayer(f.game), rival = advisorGatherPlayer(f.game, f.opponent);
|
||||
const pending = applyAction(f.game, f.owner, draft.action);
|
||||
const paused = advisorGatherPlayer(pending);
|
||||
assert.equal(pending.response?.kind, 'advisorFlip');
|
||||
assert.deepEqual(paused.forces, before.forces);
|
||||
assert.deepEqual(paused.advisors, before.advisors);
|
||||
assert.equal(paused.moved, before.moved);
|
||||
assert.equal(paused.shipped, before.shipped);
|
||||
assert.equal(paused.spice, before.spice);
|
||||
const done = allowPlanetologistAdvisorGather(pending), after = advisorGatherPlayer(done);
|
||||
assert.equal(after.forces['arrakeen:10'], 2);
|
||||
assert.equal(after.forces['carthag:11'] ?? 0, 0);
|
||||
assert.equal(after.forces['imperial_basin:9'] ?? 0, 0);
|
||||
assert.equal(isAdvisor(after, 'arrakeen'), false);
|
||||
assert.equal(after.moved, before.moved + 1);
|
||||
assert.equal(after.shipped, true);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.deepEqual(advisorGatherPlayer(done, f.opponent).forces, rival.forces);
|
||||
assert.equal(advisorGatherPlayer(done, f.canceler).hand.filter(card => card.id === f.karama).length, 1,
|
||||
'Allowing the native flip does not spend the opponent’s physical counter.');
|
||||
});
|
||||
|
||||
void test('one original Karama cancels the entire uncommitted mixed gather and a legal fighter-only replacement retains the movement', () => {
|
||||
const f = createPlanetologistAdvisorGatherFixture();
|
||||
const before = advisorGatherPlayer(f.game);
|
||||
const pending = applyAction(f.game, f.owner, f.action);
|
||||
const canceled = applyAction(pending, f.canceler, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
const stopped = advisorGatherPlayer(canceled);
|
||||
assert.deepEqual(stopped.forces, before.forces);
|
||||
assert.deepEqual(stopped.advisors, before.advisors);
|
||||
assert.equal(stopped.moved, before.moved);
|
||||
assert.equal(stopped.shipped, before.shipped);
|
||||
assert.equal(stopped.spice, before.spice);
|
||||
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.ok(canceled.players.every(player => player.hand.every(card => card.id !== f.karama)));
|
||||
const snapshot = structuredClone(canceled);
|
||||
assert.throws(() => applyAction(canceled, f.owner, f.action), RuleError);
|
||||
assert.deepEqual(canceled, snapshot, 'An immediate repeated flip cannot bypass the spent Karama.');
|
||||
const retry = planetologistMoveDraft(viewGame(canceled, f.owner), 'gather', 'arrakeen', 10, f.forces, {});
|
||||
assert.equal(retry.action, null);
|
||||
const replacement = applyAction(canceled, f.owner, {
|
||||
type: 'move', from: 'carthag:11', amount: 1, territory: 'arrakeen', sector: 10,
|
||||
});
|
||||
const after = advisorGatherPlayer(replacement);
|
||||
assert.equal(after.forces['arrakeen:10'], 1);
|
||||
assert.equal(after.forces['imperial_basin:9'], 1);
|
||||
assert.equal(isAdvisor(after, 'imperial_basin'), true);
|
||||
assert.equal(isAdvisor(after, 'arrakeen'), false);
|
||||
assert.equal(after.moved, before.moved + 1);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(replacement.discard.filter(card => card.id === f.karama).length, 1);
|
||||
});
|
||||
|
||||
void test('a genuine fresh accompanying advisor cannot use mixed gather to become a fighter during its arrival turn', () => {
|
||||
const f = createPlanetologistAdvisorGatherFixture({ fresh: true });
|
||||
const draft = planetologistMoveDraft(viewGame(f.game, f.owner), 'gather', 'arrakeen', 10, f.forces, {});
|
||||
assert.equal(draft.action, null);
|
||||
const snapshot = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.owner, f.action), RuleError);
|
||||
assert.deepEqual(f.game, snapshot);
|
||||
assert.equal(advisorGatherPlayer(f.game).forces['imperial_basin:9'], 1);
|
||||
assert.equal(isAdvisor(advisorGatherPlayer(f.game), 'imperial_basin'), true);
|
||||
assert.equal(advisorGatherPlayer(f.game).moved, 0);
|
||||
assert.equal(advisorGatherPlayer(f.game, f.canceler).hand.filter(card => card.id === f.karama).length, 1);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
@@ -9,10 +7,7 @@ import {
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
PlanetologistMovement,
|
||||
planetologistMoveDraft,
|
||||
} from '../components/planetologist-movement';
|
||||
import { planetologistMoveDraft } from '../components/planetologist-movement';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { location } from '../game/board';
|
||||
|
||||
@@ -74,34 +69,8 @@ function fixture(): Game {
|
||||
return g;
|
||||
}
|
||||
|
||||
function markup(g: Game, viewer = 'p', busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(PlanetologistMovement, {
|
||||
game: viewGame(g, viewer),
|
||||
act() {},
|
||||
busy,
|
||||
destination,
|
||||
sector: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('the owner sees both modes, physical source controls and the shared board destination', () => {
|
||||
const g = fixture();
|
||||
const html = markup(g);
|
||||
assert.match(html, /Planetologist movement/);
|
||||
assert.match(html, /One territory farther, maximum three/);
|
||||
assert.match(html, /Gather from two territories/);
|
||||
assert.match(html, /Destination: Polar Sink, sector 0/);
|
||||
assert.match(html, /False Wall West, sector 16 · 2 forces/);
|
||||
assert.match(html, /Elite from this group · 1 available/);
|
||||
assert.match(html, /Select forces from one territory/);
|
||||
assert.match(html, /disabled=""/);
|
||||
assert.equal(markup(g, 'o'), '');
|
||||
assert.ok((markup(g, 'p', true).match(/disabled=""/g) ?? []).length >= 2);
|
||||
});
|
||||
|
||||
void test('drafts send only the public mode and exact physical counts, and both settle through the engine', () => {
|
||||
void test('range and gather controls settle the selected physical normal and elite counters through the engine', () => {
|
||||
const g = fixture();
|
||||
const view = viewGame(g, 'p');
|
||||
const range = planetologistMoveDraft(
|
||||
@@ -113,15 +82,6 @@ void test('drafts send only the public mode and exact physical counts, and both
|
||||
{ [rangeSource]: 1 },
|
||||
);
|
||||
assert.equal(range.blocked, null);
|
||||
assert.deepEqual(range.action, {
|
||||
type: 'move',
|
||||
planetologist: 'range',
|
||||
forces: { [rangeSource]: 2 },
|
||||
eliteForces: { [rangeSource]: 1 },
|
||||
territory: destination,
|
||||
sector: 0,
|
||||
});
|
||||
assert.equal(Object.hasOwn(range.action!, 'leader'), false);
|
||||
const ranged = applyAction(structuredClone(g), 'p', range.action!);
|
||||
assert.equal(ranged.players[0].forces[`${destination}:0`], 2);
|
||||
assert.equal(ranged.players[0].elites!.forces[`${destination}:0`], 1);
|
||||
@@ -135,55 +95,8 @@ void test('drafts send only the public mode and exact physical counts, and both
|
||||
{ [gatherSources[0]]: 1, [gatherSources[1]]: 2 },
|
||||
);
|
||||
assert.equal(gather.blocked, null);
|
||||
assert.deepEqual(gather.action, {
|
||||
type: 'move',
|
||||
planetologist: 'gather',
|
||||
forces: { [gatherSources[0]]: 2, [gatherSources[1]]: 3 },
|
||||
eliteForces: { [gatherSources[0]]: 1, [gatherSources[1]]: 2 },
|
||||
territory: destination,
|
||||
sector: 0,
|
||||
});
|
||||
assert.equal(Object.hasOwn(gather.action!, 'leader'), false);
|
||||
const gathered = applyAction(structuredClone(g), 'p', gather.action!);
|
||||
assert.equal(gathered.players[0].forces[`${destination}:0`], 5);
|
||||
assert.equal(gathered.players[0].elites!.forces[`${destination}:0`], 3);
|
||||
});
|
||||
|
||||
void test('the client rejects wrong origin counts and unavailable elite custody before sending', () => {
|
||||
const view = viewGame(fixture(), 'p');
|
||||
assert.match(
|
||||
planetologistMoveDraft(
|
||||
view,
|
||||
'gather',
|
||||
destination,
|
||||
0,
|
||||
{ [gatherSources[0]]: 1 },
|
||||
{ [gatherSources[0]]: 0 },
|
||||
).blocked!,
|
||||
/exactly two different territories/,
|
||||
);
|
||||
assert.match(
|
||||
planetologistMoveDraft(
|
||||
view,
|
||||
'range',
|
||||
destination,
|
||||
0,
|
||||
{ [rangeSource]: 2 },
|
||||
{ [rangeSource]: 0 },
|
||||
).blocked!,
|
||||
/normal and elite mix/,
|
||||
);
|
||||
const expansion = fixture();
|
||||
expansion.players[1].faction = 'ixians';
|
||||
assert.match(
|
||||
planetologistMoveDraft(
|
||||
viewGame(expansion, 'p'),
|
||||
'range',
|
||||
destination,
|
||||
0,
|
||||
{ [rangeSource]: 2 },
|
||||
{ [rangeSource]: 1 },
|
||||
).blocked!,
|
||||
/expansion factions awaits/,
|
||||
);
|
||||
});
|
||||
@@ -380,12 +380,6 @@ void test('Bene Gesserit gather resolves determined mixed-source stances and pre
|
||||
assert.equal(next.players[0].forces[second], 1);
|
||||
custody(next);
|
||||
}
|
||||
const mixed = fixture('beneGesserit', true);
|
||||
const { a, first, to } = gather(mixed);
|
||||
mixed.players[0].advisors = { [splitLocation(first).territory]: {} };
|
||||
mixed.players[1].forces = { [first]: 1, [to]: 1 };
|
||||
mixed.players[1].reserves = 18;
|
||||
unchanged(mixed, a, /combined flip response/);
|
||||
const uniform = fixture('beneGesserit', true);
|
||||
const draft = gather(uniform);
|
||||
uniform.players[0].advisors = {
|
||||
|
||||
Reference in new issue
Block a user