Compose Ornithopter with unused Hajr movement
Replace one unused movement entitlement by the selected card mode and retain independent Hajr movement after a completed two-group flight. Preserve distinct cohorts and later legal reuse. Adopt supplied Advanced immediate-after-movement Hajr play timing with explicit provisional online/card-group interpretation; Basic timing remains. Validation: frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes; four minimal policies retain both continuations. Actual cards are acquired by original auctions and source groups use funded shipments. Multiple Hajr cards, advisors, wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
1 parent
6ccfaa1b4d
commit
72e77d7f4d
16 files changed
+522
-31
No files matched your search
@@ -153,7 +153,7 @@ void test('Weather Control stays available after dials and confirmations, but va
|
||||
'omitting selection asks about timing only',
|
||||
);
|
||||
});
|
||||
void test('Hajr requires the active movement owner and cannot grant a third move', () => {
|
||||
void test('Hajr requires the active movement owner and grants only one additional ordinary entitlement', () => {
|
||||
agrees(
|
||||
'hajr',
|
||||
(g) => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameDistance, location, splitLocation, TERRITORIES } from '../game/board';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export const HAJR_ORNITHOPTER = 'richese-ornithopter';
|
||||
export type OrnithopterHajrStep = { actor: string; action: Action };
|
||||
export type OrnithopterHajrOptions = { advanced?: boolean; interruption?: boolean; amount?: 3 | 4 };
|
||||
export const ornithopterHajrPlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const ornithopterHajrClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actOrnithopterHajr(game: Game, actor: string, action: Action,
|
||||
actions: OrnithopterHajrStep[] = []): Game {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
}
|
||||
function nextOrnithopterHajrStep(game: Game): OrnithopterHajrStep | null {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const decision = game.decision;
|
||||
if (decision.kind === 'richeseCache') return { actor: decision.player, action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: HAJR_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
|
||||
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (decision.kind === 'choamMovement') return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
}
|
||||
if (ornithopterHajrClean(game) && game.richeseAuction?.cardId === HAJR_ORNITHOPTER &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
if (ornithopterHajrClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 &&
|
||||
game.auction.cards[game.auction.index].effect === 'hajr')
|
||||
return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceOrnithopterHajr(game: Game, until: (state: Game) => boolean,
|
||||
actions: OrnithopterHajrStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Ornithopter/Hajr programme ended before its next owned control');
|
||||
const step = nextOrnithopterHajrStep(game); assert.ok(step, 'Complete the real private plan before continuing');
|
||||
game = actOrnithopterHajr(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Ornithopter/Hajr programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Original host, native cache/ordinary paid lots and actual reserve shipments.
|
||||
* An optional native CHOAM arrival interruption is funded before the pilot's
|
||||
* turn; no dealt card, board counter, wallet, clock or reaction is installed. */
|
||||
export function createOrnithopterHajrFixture(options: OrnithopterHajrOptions = {}) {
|
||||
const advanced = options.advanced ?? false;
|
||||
let game = createGame('ORNITHOPTERHAJR', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
const seats = options.interruption ? ['choam', 'emperor', 'guild'] as const : ['emperor', 'guild'] as const;
|
||||
for (const faction of seats) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: OrnithopterHajrStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actOrnithopterHajr(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceOrnithopterHajr(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
reach(state => state.phase === 2 && ornithopterHajrClean(state));
|
||||
const at = game.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0, 'The original Hajr must remain undealt');
|
||||
const hajr = game.deck[at].id; game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
const beforePurchases = structuredClone(game);
|
||||
reach(state => ornithopterHajrPlayer(state).hand.some(card => card.id === HAJR_ORNITHOPTER) &&
|
||||
ornithopterHajrPlayer(state).hand.some(card => card.id === hajr));
|
||||
const acquired = structuredClone(game), shipments: { before: Game; step: OrnithopterHajrStep; after: Game }[] = [];
|
||||
const ship = (actor: string, to: string, sector: number, amount: number, elite = 0) => {
|
||||
const before = structuredClone(game), step: OrnithopterHajrStep = { actor,
|
||||
action: { type: 'ship', territory: to, sector, amount, elite, allyPayment: 0 } };
|
||||
act(actor, step.action); reach(ornithopterHajrClean);
|
||||
shipments.push({ before, step, after: structuredClone(game) });
|
||||
};
|
||||
if (options.interruption) {
|
||||
reach(state => state.phase === 5 && state.active === 'choam' && ornithopterHajrClean(state));
|
||||
ship('choam', 'oh_gap', 9, 1);
|
||||
}
|
||||
reach(state => state.phase === 5 && state.active === 'emperor' && ornithopterHajrClean(state));
|
||||
ship('emperor', 'imperial_basin', 9, options.amount ?? 4, advanced ? 2 : 0);
|
||||
return { game, lobby, setup, afterSetup, beforePurchases, acquired, hajr, actions, shipments,
|
||||
owner: 'emperor', source: 'imperial_basin:9', interruption: options.interruption ?? false,
|
||||
staging: ['Original native setup and paid cache/ordinary auction acquire both cards. Only unopened Hajr/Spice positions and scalar lotteries are selected; original funded shipments provide every physical source.'] };
|
||||
}
|
||||
export type OrnithopterHajrFixture = ReturnType<typeof createOrnithopterHajrFixture>;
|
||||
|
||||
/** Use current physical typed custody and real storm geography, without city
|
||||
* access or any staged speed bonus. A forced destination supports the genuine
|
||||
* native CHOAM interruption; other targets are empty printed sand or rock. */
|
||||
export function ornithopterHajrMove(game: Game, from: string, options: {
|
||||
range?: number; mode?: OrnithopterMode; to?: string; elite?: number;
|
||||
} = {}): Action {
|
||||
const own = ornithopterHajrPlayer(game), range = options.range ?? 1;
|
||||
assert.ok((own.forces[from] ?? 0) > 0);
|
||||
const to = options.to ?? TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
|
||||
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
||||
.find(to => game.players.every(player => Object.entries(player.forces).every(([key, count]) =>
|
||||
count === 0 || splitLocation(key).territory !== splitLocation(to).territory)) &&
|
||||
gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) === range);
|
||||
assert.ok(to, `The original group needs an empty printed range-${range} destination`);
|
||||
const flight = viewGame(game, 'emperor').ornithopter?.active;
|
||||
return { type: 'move', from, amount: 1, elite: options.elite ?? ((own.elites?.forces[from] ?? 0) > 0 ? 1 : 0),
|
||||
...splitLocation(to), ...(options.mode ? { movementCard: HAJR_ORNITHOPTER, ornithopter: options.mode }
|
||||
: flight ? { ornithopterEvent: flight.event } : {}) };
|
||||
}
|
||||
export function playOrnithopterHajr(game: Game, fixture: OrnithopterHajrFixture,
|
||||
actions: OrnithopterHajrStep[] = []): Game {
|
||||
game = actOrnithopterHajr(game, fixture.owner, { type: 'card', card: fixture.hajr }, actions);
|
||||
return advanceOrnithopterHajr(game, ornithopterHajrClean, actions);
|
||||
}
|
||||
|
||||
/** Complete actual card groups then the independent Hajr continuation. Before
|
||||
* is Basic face timing; Advanced uses Hajr after a completed Force Movement.
|
||||
* A prior ordinary move is never reclassified as part of the later flight. */
|
||||
export function completeOrnithopterHajr(fixture: OrnithopterHajrFixture, options: {
|
||||
mode?: OrnithopterMode; timing?: 'before' | 'during' | 'after'; priorNormal?: boolean;
|
||||
} = {}) {
|
||||
const mode = options.mode ?? 'twoGroups', timing = options.timing ?? 'after';
|
||||
assert.ok(mode === 'twoGroups' || timing !== 'during', 'A one-group flight completes before another timing window');
|
||||
assert.ok(!fixture.game.advanced || timing !== 'before', 'Advanced Hajr follows an actual completed Force Movement');
|
||||
const actions: OrnithopterHajrStep[] = [], moves: { before: Game; step: OrnithopterHajrStep; after: Game }[] = [];
|
||||
let game = structuredClone(fixture.game), prior: Game | null = null, pendingArrival: Game | null = null;
|
||||
const move = (from: string, settings: Parameters<typeof ornithopterHajrMove>[2] = {}) => {
|
||||
const before = structuredClone(game), step: OrnithopterHajrStep = { actor: fixture.owner, action: ornithopterHajrMove(game, from, settings) };
|
||||
game = actOrnithopterHajr(game, step.actor, step.action, actions);
|
||||
if (game.decision?.kind === 'choamMovement') pendingArrival = structuredClone(game);
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean, actions);
|
||||
moves.push({ before, step, after: structuredClone(game) });
|
||||
return location(String(step.action.territory), Number(step.action.sector));
|
||||
};
|
||||
let firstSource = fixture.source;
|
||||
if (options.priorNormal) {
|
||||
firstSource = move(fixture.source); prior = structuredClone(game);
|
||||
game = playOrnithopterHajr(game, fixture, actions);
|
||||
} else if (timing === 'before') game = playOrnithopterHajr(game, fixture, actions);
|
||||
const beforeFlight = structuredClone(game), firstDestination = move(firstSource, {
|
||||
mode, range: mode === 'range3' ? 3 : 1,
|
||||
...(fixture.interruption ? { to: 'oh_gap:9' } : {}),
|
||||
});
|
||||
const first = structuredClone(game);
|
||||
if (!options.priorNormal && timing === 'during') game = playOrnithopterHajr(game, fixture, actions);
|
||||
if (mode === 'twoGroups') move(fixture.source);
|
||||
const flightComplete = structuredClone(game);
|
||||
if (!options.priorNormal && timing === 'after') game = playOrnithopterHajr(game, fixture, actions);
|
||||
const beforeExtra = structuredClone(game);
|
||||
if (!options.priorNormal) move(firstDestination);
|
||||
const completed = structuredClone(game);
|
||||
game = actOrnithopterHajr(game, fixture.owner, { type: 'endMovement' }, actions);
|
||||
return { game, completed, prior, beforeFlight, first, flightComplete, beforeExtra, firstDestination,
|
||||
pendingArrival, moves, actions, mode, timing };
|
||||
}
|
||||
@@ -207,6 +207,7 @@ void test('all profiles honor mode/ownership guards, existing interaction priori
|
||||
},
|
||||
(g: Game) => {
|
||||
g.hajr = ['p'];
|
||||
g.players[0].moved = 2;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.active = 'o';
|
||||
|
||||
@@ -67,11 +67,11 @@ void test('played card escrow, owner-only remaining quotas and ended movement su
|
||||
assert.equal(ended.active,'q');
|
||||
});
|
||||
void test('invalid source, blocked route, committed card and explicitly unresolved combinations are immutable',()=>{
|
||||
for(const change of ['range','storm','prior','hajr','advisor','gift','wrongMode'] as const){
|
||||
for(const change of ['range','storm','prior','advisor','gift','wrongMode'] as const){
|
||||
const g=fixture(change==='advisor'?'beneGesserit':'emperor',change==='advisor');
|
||||
let key=destination(g,origin,3);const extra:Partial<Action>={movementCard:card,ornithopter:'range3'};
|
||||
if(change==='range')key=destination(g,origin,4);if(change==='storm')g.storm=splitLocation(key).sector;
|
||||
if(change==='prior')g.players[0].moved=1;if(change==='hajr')g.hajr=['p'];
|
||||
if(change==='prior')g.players[0].moved=1;
|
||||
if(change==='advisor'){g.players[0].advisors={red_chasm:{}};g.players[1].forces={[origin]:1};}
|
||||
if(change==='wrongMode')extra.ornithopter='both';
|
||||
if(change==='gift')g.pendingRicheseGift={event:'reserved',intent:{owner:'p',recipient:'q',cardId:card},turn:g.turn,phase:g.phase,resume:{response:null,decision:null,pendingKarama:null,phaseOpening:null}} as Game['pendingRicheseGift'];
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { location } from '../game/board';
|
||||
import {
|
||||
HAJR_ORNITHOPTER, actOrnithopterHajr, advanceOrnithopterHajr, completeOrnithopterHajr,
|
||||
createOrnithopterHajrFixture, ornithopterHajrClean, ornithopterHajrMove,
|
||||
ornithopterHajrPlayer as player, playOrnithopterHajr, type OrnithopterHajrFixture,
|
||||
} from './fixture-ornithopter-hajr';
|
||||
|
||||
const ownView = (game: Game) => viewGame(game, 'emperor').players.find(player => player.id === 'emperor')!;
|
||||
function source(f: OrnithopterHajrFixture) {
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.ok(player(f.game).hand.some(card => card.id === HAJR_ORNITHOPTER));
|
||||
assert.ok(player(f.game).hand.some(card => card.id === f.hajr));
|
||||
const shipment = f.shipments.find(shipment => shipment.step.actor === f.owner)!;
|
||||
assert.equal(player(shipment.before).spice - player(shipment.after).spice, Number(shipment.step.action.amount) * 2,
|
||||
'The real source is an originally funded desert shipment, not a staged board group');
|
||||
assert.equal(player(f.game).forces[f.source], Number(shipment.step.action.amount));
|
||||
assert.equal(ownView(f.game).moved, 0);
|
||||
assert.equal(ownView(f.game).movesAllowed, 1);
|
||||
assert.equal(ownView(f.game).hajrUsed, false);
|
||||
assert.equal(ownView(f.game).hajrAvailable, !f.game.advanced);
|
||||
}
|
||||
function settled(f: OrnithopterHajrFixture, completed: Game, moves: number) {
|
||||
assert.equal(player(completed).moved, moves);
|
||||
assert.equal(ownView(completed).movesAllowed, moves);
|
||||
assert.equal(ownView(completed).hajrUsed, true);
|
||||
assert.equal(ownView(completed).hajrAvailable, false);
|
||||
assert.equal(completed.ornithopter ?? null, null);
|
||||
assert.equal(completed.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
|
||||
assert.equal(completed.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(player(completed).reserves, player(f.game).reserves);
|
||||
assert.equal(player(completed).spice, player(f.game).spice);
|
||||
assert.equal(player(completed).tanks, player(f.game).tanks);
|
||||
assert.equal(Object.values(player(completed).forces).reduce((sum, count) => sum + count, 0),
|
||||
Object.values(player(f.game).forces).reduce((sum, count) => sum + count, 0));
|
||||
assert.throws(() => actOrnithopterHajr(completed, f.owner, { type: 'card', card: f.hajr }),
|
||||
'The consumed physical Hajr cannot be replayed to add a fourth move');
|
||||
}
|
||||
|
||||
void test('genuine Basic paid Ornithopter composes with Hajr before, during or after two distinct card groups', () => {
|
||||
for (const timing of ['before', 'during', 'after'] as const) {
|
||||
const f = createOrnithopterHajrFixture();
|
||||
source(f);
|
||||
const result = completeOrnithopterHajr(f, { timing });
|
||||
assert.equal(result.moves.length, 3);
|
||||
assert.equal(player(result.first).moved, 1);
|
||||
assert.equal(result.first.ornithopter!.completed, 1);
|
||||
assert.equal(player(result.flightComplete).moved, 2);
|
||||
assert.equal(result.flightComplete.ornithopter ?? null, null);
|
||||
assert.equal(ownView(result.beforeExtra).movesAllowed, 3);
|
||||
assert.equal(result.moves[2].step.action.from, result.firstDestination,
|
||||
'The independent Hajr move may reuse the first card group after the flight ends');
|
||||
assert.equal(result.moves[2].step.action.ornithopterEvent, undefined);
|
||||
assert.notEqual(result.game.active, f.owner);
|
||||
settled(f, result.completed, 3);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine Advanced Hajr follows a completed typed card group and retains its later ordinary move', () => {
|
||||
const f = createOrnithopterHajrFixture({ advanced: true });
|
||||
source(f);
|
||||
assert.throws(() => actOrnithopterHajr(f.game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
|
||||
const result = completeOrnithopterHajr(f, { timing: 'during' });
|
||||
assert.equal(ownView(result.first).hajrAvailable, true);
|
||||
assert.equal(result.moves[0].step.action.elite, 1);
|
||||
assert.equal(result.moves[1].step.action.elite, 1);
|
||||
assert.equal(result.moves[2].step.action.elite, 1);
|
||||
assert.equal(Object.values(player(result.completed).elites!.forces).reduce((sum, count) => sum + count, 0), 2);
|
||||
settled(f, result.completed, 3);
|
||||
});
|
||||
|
||||
void test('a real ordinary move followed by Hajr grants a fresh two-group flight without retroactively counting that move', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createOrnithopterHajrFixture({ advanced });
|
||||
source(f);
|
||||
const result = completeOrnithopterHajr(f, { timing: 'during', priorNormal: true });
|
||||
assert.equal(player(result.prior!).moved, 1);
|
||||
assert.equal(result.prior!.ornithopter ?? null, null);
|
||||
assert.equal(ownView(result.beforeFlight).movesAllowed, 2);
|
||||
assert.equal(result.first.ornithopter!.startingMove, 1);
|
||||
assert.equal(result.first.ornithopter!.completed, 1);
|
||||
assert.equal(player(result.first).moved, 2);
|
||||
assert.equal(result.moves[1].step.action.from,
|
||||
location(String(result.moves[0].step.action.territory), Number(result.moves[0].step.action.sector)),
|
||||
'Units moved by the earlier ordinary entitlement may join a new flight');
|
||||
assert.equal(result.moves.length, 3);
|
||||
settled(f, result.completed, 3);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a genuine range-three card flight replaces one entitlement while same-flight repeated groups remain illegal', () => {
|
||||
const f = createOrnithopterHajrFixture();
|
||||
source(f);
|
||||
const ranged = completeOrnithopterHajr(f, { mode: 'range3', timing: 'after' });
|
||||
assert.equal(ranged.moves.length, 2);
|
||||
assert.equal(player(ranged.flightComplete).moved, 1);
|
||||
settled(f, ranged.completed, 2);
|
||||
const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' });
|
||||
let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||
const firstDestination = location(String(firstMove.territory), Number(firstMove.sector));
|
||||
const repeated = ornithopterHajrMove(game, firstDestination);
|
||||
assert.throws(() => actOrnithopterHajr(game, f.owner, repeated), /cohort|unmoved|original|force/i);
|
||||
game = playOrnithopterHajr(game, f);
|
||||
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||
assert.equal(game.ornithopter ?? null, null);
|
||||
assert.equal(ownView(game).movesAllowed, 3);
|
||||
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, firstDestination));
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||
settled(f, game, 3);
|
||||
});
|
||||
|
||||
void test('a real Advanced Baliset pause preserves Hajr timing until an owner action closes it and the next group reopens it', () => {
|
||||
const f = createOrnithopterHajrFixture({ advanced: true, interruption: true, amount: 3 });
|
||||
source(f);
|
||||
assert.equal(player(f.game, 'choam').forces['oh_gap:9'], 1);
|
||||
assert.ok(player(f.game).spice >= 1, 'The smaller real shipment leaves its original bribe funding');
|
||||
const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups', to: 'oh_gap:9' });
|
||||
let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
|
||||
assert.equal(game.decision?.kind, 'choamMovement');
|
||||
assert.equal(game.decision?.player, 'choam');
|
||||
assert.equal(player(game).moved, 0);
|
||||
assert.equal(game.ornithopter!.completed, 0);
|
||||
assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }));
|
||||
game = actOrnithopterHajr(game, 'choam', { type: 'decision', decline: true });
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||
assert.equal(player(game).moved, 1);
|
||||
assert.equal(ownView(game).hajrAvailable, true);
|
||||
game = actOrnithopterHajr(game, f.owner, { type: 'bribe', target: 'guild', amount: 1 });
|
||||
assert.equal(ownView(game).hajrAvailable, false);
|
||||
assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
|
||||
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||
assert.equal(player(game).moved, 2);
|
||||
assert.equal(ownView(game).hajrAvailable, true);
|
||||
game = playOrnithopterHajr(game, f);
|
||||
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, 'oh_gap:9'));
|
||||
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||
assert.equal(player(game).moved, 3);
|
||||
assert.equal(ownView(game).movesAllowed, 3);
|
||||
assert.equal(player(game).spice, player(f.game).spice - 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
|
||||
});
|
||||
|
||||
void test('all four minimal policies retain legal second-flight and earned Hajr movement candidates from original pending views', () => {
|
||||
const f = createOrnithopterHajrFixture();
|
||||
let first = actOrnithopterHajr(structuredClone(f.game), f.owner,
|
||||
ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' }));
|
||||
first = advanceOrnithopterHajr(first, ornithopterHajrClean);
|
||||
first = playOrnithopterHajr(first, f);
|
||||
const complete = actOrnithopterHajr(first, f.owner, ornithopterHajrMove(first, f.source));
|
||||
const ready = advanceOrnithopterHajr(complete, ornithopterHajrClean);
|
||||
assert.equal(ownView(ready).movesAllowed, 3);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
for (const game of [first, ready]) {
|
||||
const view = viewGame(game, f.owner);
|
||||
view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'move');
|
||||
assert.ok(action, `${difficulty} must keep an owned legal ${game.ornithopter ? 'second flight' : 'Hajr'} movement`);
|
||||
assert.equal(typeof action.ornithopterEvent === 'string', !!game.ornithopter);
|
||||
const moved = actOrnithopterHajr(structuredClone(game), f.owner, action);
|
||||
assert.ok(player(moved).moved === player(game).moved + 1 || moved.response || moved.decision,
|
||||
'Each real policy candidate completes its group or enters its original reaction');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -163,7 +163,7 @@ for (const advanced of [false, true]) for (const { mode, early } of paths) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => {
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld prior move needs an unused entitlement and Hajr preserves the fixed maximum three`, () => {
|
||||
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
|
||||
const before = structuredClone(f.beforeFirstMove);
|
||||
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
|
||||
@@ -174,14 +174,20 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basi
|
||||
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
|
||||
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
|
||||
...splitLocation(f.origin) }));
|
||||
const hajrGame = structuredClone(before);
|
||||
const hajrGame = structuredClone(advanced ? moved : before);
|
||||
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
|
||||
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
|
||||
const allowed = advanceSemutaHomeworldChoam(played,
|
||||
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
||||
assert.equal(player(allowed, f.owner).moved, 0);
|
||||
assert.equal(player(allowed, f.owner).moved, advanced ? 1 : 0);
|
||||
assert.ok(allowed.hajr.includes(f.owner));
|
||||
assert.throws(() => applyAction(allowed, f.owner, f.firstMove));
|
||||
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
|
||||
const declared = applyAction(allowed, f.owner, advanced
|
||||
? { ...f.firstMove, from: f.secondDestination, ...splitLocation(f.origin) }
|
||||
: f.firstMove);
|
||||
const completed = advanceSemutaHomeworldChoam(declared,
|
||||
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
||||
assert.equal(player(completed, f.owner).moved, advanced ? 2 : 1);
|
||||
assertCounters(allowed, f.setup);
|
||||
assertCounters(completed, f.setup);
|
||||
});
|
||||
Reference in new issue
Block a user