Support Basic movement promises before shipment
Allow clean active Basic ground-movement questions before or after shipment. Enumerate ordinary reserve amounts, native physical allocations, destinations and pledged payment splits through the actual declaration and shared physical commit. Detached projection stops before income/arrival effects and never counts shipment as ground fulfillment. Joint movement No and shipment Yes retain a legal next action rather than invalid End guidance. Preserve exact pending ordinary reserve and native Guild permission/rate declarations when checking the remaining route. Voluntary wrong destination, force splitting or fee loss cannot release Yes through an opponent response. Guard unsupported sources before full speculative continuation. Existing live payment, cancellation, income and entry paths remain. Persistent busy-safe controls display reinforcement and both obligations; all four existing profiles use the same legal witness without strategy refinement. Verify 19 files165/165, types/lint/build, six original-action gameplay/control/table-rendering programmes and four profiles with16 accepted actions. Genuine sources19/37/24/19 retain classic Arrakeen10 reinforced to11, native zero-board Emperor/CHOAM starts and original Guild5/15/5 reserve/cross transport. Second-card/counter cases explicitly transfer conserved originals. Bounded review repaired source guarding, funding completeness and exact pending-frame behavior. Stop an observed expensive search, reorder complete candidates by useful proximity and conservatively prune dead-end final moves; twelve new engine cases pass in3.2s. Correct a display-template lint issue. Earlier phases/waiting turns, native transport/relocation preparation, No-Field/module sources, broader interactions, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
This commit is contained in:
1 parent
b5a53da712
commit
f0b2ac82d9
18 files changed
+637
-34
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthMovement, createTruthMovementFixture, truthMovementClean,
|
||||
truthMovementInventory, truthMovementPlayer, type TruthMovementStep,
|
||||
} from './fixture-truth-movement';
|
||||
import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam';
|
||||
import { createHajrReplayFixture } from './fixture-hajr-replay';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export const actTruthMovementShipment = actTruthMovement;
|
||||
export const truthMovementShipmentClean = truthMovementClean;
|
||||
export const truthMovementShipmentInventory = truthMovementInventory;
|
||||
export const truthMovementShipmentPlayer = truthMovementPlayer;
|
||||
|
||||
/** A source fixture may continue past this snapshot for its own purpose. Keep
|
||||
* only its actual prefix before the named shipment, never count later actions
|
||||
* as part of this unopened shipment opportunity. */
|
||||
function beforeShipmentActions(actions: TruthMovementStep[], step: TruthMovementStep) {
|
||||
const index = actions.findIndex(candidate => candidate.actor === step.actor &&
|
||||
JSON.stringify(candidate.action) === JSON.stringify(step.action));
|
||||
assert.ok(index >= 0, 'The source must contain its actual named shipment');
|
||||
return structuredClone(actions.slice(0, index));
|
||||
}
|
||||
function unopened(game: Game, target: string) {
|
||||
const player = truthMovementPlayer(game, target);
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, target);
|
||||
assert.ok(truthMovementClean(game)); assert.equal(player.shipped, false);
|
||||
assert.equal(player.moved, 0); assert.equal(game.movementPromises, undefined);
|
||||
}
|
||||
function balances(game: Game) {
|
||||
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
}
|
||||
|
||||
/** Original public Basic Atreides/Emperor source before its first shipment.
|
||||
* Ten board forces cannot satisfy the eleven-force statement without a real
|
||||
* reserve arrival. No Truthtrance or suggested shipment/movement is played. */
|
||||
export function createTruthMovementShipmentFixture(options: { hajr?: boolean } = {}) {
|
||||
const source = createTruthMovementFixture(options), game = structuredClone(source.beforeShipment);
|
||||
unopened(game, source.target);
|
||||
const player = truthMovementPlayer(game, source.target);
|
||||
assert.deepEqual(player.forces, { 'arrakeen:10': 10 }); assert.equal(player.reserves, 10);
|
||||
assert.ok(player.spice >= 1);
|
||||
const actions = beforeShipmentActions(source.actions, source.shipmentStep);
|
||||
assert.deepEqual(truthMovementInventory(game), source.originalCards);
|
||||
return {
|
||||
game, source, actions, sourceActionCount: actions.length,
|
||||
target: source.target, asker: source.asker, truth: source.truth, hajr: source.hajr,
|
||||
claim: { kind: 'movement' as const, target: source.target, territory: 'carthag', minimum: 11 },
|
||||
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 11 } satisfies Action,
|
||||
shipmentCost: 1, originalCards: source.originalCards, balances: balances(game),
|
||||
staging: [
|
||||
'The original public Basic Atreides/Emperor starting-deal, ordinary Spice and native scalar selections are reused at their actual beforeShipment snapshot. The recorded action prefix ends before the original helper later ships to Polar Sink. Ten original Arrakeen forces and ten reserves remain untouched. The suggested one-spice Arrakeen shipment would create an eleven-force group for a later Carthag movement; neither action nor any question, answer or promise has been installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Genuine native CHOAM source before its paid city shipment and held Kulon.
|
||||
* The later source's actual shipment/Kulon actions are excluded from this prefix. */
|
||||
export function createTruthMovementShipmentChoamFixture() {
|
||||
const source = createTruthMovementChoamFixture(), game = structuredClone(source.beforeShipment);
|
||||
unopened(game, source.target);
|
||||
const player = truthMovementPlayer(game, source.target);
|
||||
assert.equal(Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 0);
|
||||
assert.ok(player.hand.some(card => card.id === source.kulon)); assert.equal(game.choamMovement, undefined);
|
||||
const shipment: Action = { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 };
|
||||
const actions = beforeShipmentActions(source.actions, { actor: source.target, action: shipment });
|
||||
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
|
||||
gameDistance(game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => {
|
||||
const place = splitLocation(key); return place.sector !== 0 && place.sector === game.storm;
|
||||
}) === 4);
|
||||
assert.ok(destination);
|
||||
assert.deepEqual(truthMovementInventory(game), source.originalCards);
|
||||
return {
|
||||
game, source, actions, sourceActionCount: actions.length,
|
||||
target: source.target, asker: source.asker, truth: source.truth, kulon: source.kulon,
|
||||
claim: { kind: 'movement' as const, target: source.target, territory: destination.territory, minimum: 1 },
|
||||
shipment, shipmentCost: 1, destination, originalCards: source.originalCards, balances: balances(game),
|
||||
staging: [
|
||||
'Original native public Basic CHOAM/Emperor setup, unopened Kulon/Truthtrance deal, ordinary Spice positions and native scalar lotteries only. The actual beforeShipment snapshot has zero CHOAM board forces and its original held Kulon. The source action prefix excludes its later one-spice Arrakeen shipment and allowed Kulon. No hand, wallet, force, bonus, phase clock, question or receipt is installed. The suggested city shipment, native Kulon and four-step route remain real actions for the caller.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The original native paid cache/normal purchases leave Emperor entirely in
|
||||
* reserves with the real Box/Hajr, while Guild holds its original dealt Truth. */
|
||||
export function createTruthMovementShipmentEmperorFixture() {
|
||||
const source = createHajrReplayFixture({ truthForGuild: true }), game = structuredClone(source.beforeShipment);
|
||||
const target = source.owner, asker = 'guild'; unopened(game, target);
|
||||
const truth = source.guildTruth; assert.ok(truth);
|
||||
const player = truthMovementPlayer(game, target);
|
||||
assert.equal(Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 0);
|
||||
assert.equal(player.reserves, 20);
|
||||
assert.ok(player.hand.some(card => card.id === source.box)); assert.ok(player.hand.some(card => card.id === source.hajr));
|
||||
const actions = beforeShipmentActions(source.actions, source.shipmentStep);
|
||||
const originalCards = truthMovementInventory(source.setup);
|
||||
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||
return {
|
||||
game, source, actions, sourceActionCount: actions.length, target, asker, truth,
|
||||
hajr: source.hajr, box: source.box,
|
||||
claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 },
|
||||
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
|
||||
shipmentCost: 1, originalCards, balances: balances(game),
|
||||
staging: [
|
||||
'Original public Basic Richese/Emperor/Guild setup, unopened original Guild Truthtrance deal, native Box1 and Hajr1 purchases, ordinary Spice positions and scalar lotteries only. The actual beforeShipment snapshot and action prefix stop before the helper later ships three Emperor forces to desert. Emperor retains all twenty original reserves, no board forces, the real bought Box/Hajr and its untouched remaining wallet. The proposed one-spice city shipment and subsequent movement are unplayed; no source hand, resource, force, phase or receipt is installed.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Public native Basic Guild/Emperor setup. Guild's printed five Tuek's forces
|
||||
* are retained; this source is not a zero-board replacement of native setup. */
|
||||
export function createTruthMovementShipmentGuildFixture() {
|
||||
const target = 'guild', asker = 'emperor';
|
||||
let game = createGame('TRUTHMOVEMENTSHIPMENTGUILD', newPlayer(target, 'Spacing Guild', 'guild'), false);
|
||||
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
|
||||
const actions: TruthMovementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let count = 0; !until(game) && count < 600; count++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'The native Guild source must reach its original clean turn');
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), originalCards = truthMovementInventory(game);
|
||||
assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const ordinary = game.deck.find(card => card.kind === 'projectile'), truth = game.deck.find(card => card.effect === 'truthtrance');
|
||||
assert.ok(ordinary && truth);
|
||||
const dealOrder = [ordinary.id, truth.id].map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [ordinary.id]);
|
||||
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truth.id]);
|
||||
const spiceOrder = [0, 1].map(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);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
|
||||
unopened(game, target);
|
||||
const player = truthMovementPlayer(game, target);
|
||||
assert.deepEqual(player.forces, { 'tueks_sietch:5': 5 }); assert.equal(player.reserves, 15); assert.equal(player.spice, 5);
|
||||
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||
return {
|
||||
game, lobby, setup, beforeStartingDeal, afterSetup, dealOrder, spiceOrder,
|
||||
actions, sourceActionCount: actions.length, target, asker, truth: truth.id, ordinary: ordinary.id,
|
||||
originalCards, balances: balances(game),
|
||||
claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 },
|
||||
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
shipmentCost: 1,
|
||||
discountedShipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
discountedShipmentCost: 1, discountedShipmentFullCost: 2,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
|
||||
crossShipment: { type: 'guildShip', from: 'tueks_sietch:5', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
returnShipment: { type: 'guildShip', from: 'tueks_sietch:5', territory: 'reserves', amount: 2, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
staging: [
|
||||
'Fresh public Basic Guild/Emperor create, join, ready and start retain the original33-card inventory. Only the unopened Guild projectile/Emperor Truthtrance deal, two ordinary unopened Spice positions and native scalar lotteries are selected. Actual setup and phases stop at the clean active Guild turn before any shipment, ground movement, question or answer. Guild retains its original five Tuek\'s Sietch forces, fifteen reserves and five spice; no board evacuation or installed hand, wallet, faction, clock or receipt occurs. The suggested city1 costs1 at both rounded rates and creates no discount; city2 costs1 at Guild rate versus2 at full price and can exercise the actual rate response. Native cross/return suggestions use the same original Tuek\'s group. No counter card is injected; an extra counter edge must disclose its own actual or conserved acquisition.',
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -104,9 +104,9 @@ void test('the Basic profile permits a clean active post-shipment respondent wit
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
|
||||
});
|
||||
void test('movement question profile rejects an unspent shipment, exhausted turn and active opposing response', () => {
|
||||
void test('movement question profile permits an unspent shipment but rejects an exhausted turn and active opposing response', () => {
|
||||
const unused = movementBoundary(); unused.players[0].shipped = false;
|
||||
assert.equal(movementPromiseTargetAvailable(unused, 'target'), false);
|
||||
assert.equal(movementPromiseTargetAvailable(unused, 'target'), true);
|
||||
const ended = movementBoundary(); ended.movementRemaining = ['asker'];
|
||||
assert.equal(movementPromiseTargetAvailable(ended, 'target'), false);
|
||||
const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] };
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { movementShipmentPreparations } from '../game/movement-shipment-preparation';
|
||||
import type { Game } from '../game/engine';
|
||||
|
||||
/** Structural enumeration fixture only, not a claimed natural gameplay source. */
|
||||
function source(): Game {
|
||||
return { players: [{ id: 'owner', faction: 'ixians', forces: {}, reserves: 3, shipped: false,
|
||||
elites: { reserves: 1 }, ally: 'ally' }], aid: { ally: { recipient: 'owner', amount: 3 } },
|
||||
expansions: [], advanced: false } as unknown as Game;
|
||||
}
|
||||
void test('preparation enumeration retains larger optional pledged shares and distinct physical special-force allocations', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
const choices = [...movementShipmentPreparations(game, 'owner', (state, _actor, action) =>
|
||||
action.territory === 'imperial_basin' && action.sector === 9 && action.amount === 2 && action.allyPayment === 3
|
||||
? structuredClone(state) : null)];
|
||||
assert.equal(choices.length, 2);
|
||||
assert.deepEqual(choices.map(choice => choice.actions[0].elite), [0, 1]);
|
||||
assert.deepEqual(choices.map(choice => choice.actions[0].allyPayment), [3, 3]);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
void test('spent shipment and paid Box decisions cannot start another preparation enumeration', () => {
|
||||
const game = source(); game.players[0].shipped = true;
|
||||
assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Shipment is spent'))], []);
|
||||
game.players[0].shipped = false; game.pendingNullentropy = { player: 'owner' } as Game['pendingNullentropy'];
|
||||
assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, type ReactElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MovementPromises } from '../components/movement-promises';
|
||||
import { Truthtrance } from '../components/truthtrance';
|
||||
import { ShipmentPromises } from '../components/shipment-promises';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { createTruthMovementShipmentFixture, actTruthMovementShipment as act } from './fixture-truth-movement-shipment';
|
||||
|
||||
function buttons(node: unknown): { onClick: () => void }[] {
|
||||
if (Array.isArray(node)) return node.flatMap(buttons);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
|
||||
return [...(props.onClick ? [{ onClick: props.onClick }] : []), ...buttons(props.children)];
|
||||
}
|
||||
void test('pre-shipment answers and persistent guidance submit the real reinforcement while busy controls submit nothing', () => {
|
||||
const f = createTruthMovementShipmentFixture({ hajr: false });
|
||||
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
||||
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: f.claim });
|
||||
const answer = renderToStaticMarkup(createElement(Truthtrance, { game: viewGame(game, f.target), act() {}, busy: false }));
|
||||
assert.match(answer, /Answer Yes/); assert.match(answer, /Answer No/);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const actions: Action[] = [], props = { game: viewGame(game, f.target), act(action: Action) { actions.push(action); }, busy: false };
|
||||
const html = renderToStaticMarkup(createElement(MovementPromises, props));
|
||||
assert.match(html, /Ship 1 forces to Arrakeen, sector 10, before the required movement/);
|
||||
assert.match(html, /shipment can prepare that move but does not fulfill it/);
|
||||
buttons(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
|
||||
buttons(MovementPromises(props))[0].onClick(); assert.deepEqual(actions, props.game.movementPromisePolicy);
|
||||
game = act(game, f.target, actions[0]); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
const moved = viewGame(game, f.target); assert.equal(moved.movementPromisePolicy![0].type, 'move');
|
||||
assert.match(renderToStaticMarkup(createElement(MovementPromises, { ...props, game: moved })), /Move 11 forces to Carthag/);
|
||||
});
|
||||
void test('a controlled conserved second question keeps one joint next-step control and both readable obligations', () => {
|
||||
const f = createTruthMovementShipmentFixture({ hajr: false }), source = structuredClone(f.game);
|
||||
const index = source.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const extra = source.deck.splice(index, 1)[0]; source.players.find(p => p.id === f.asker)!.hand.push(extra);
|
||||
let game = source;
|
||||
for (const [card, question, answer] of [[f.truth, { kind: 'shipment', target: f.target, territory: 'arrakeen', minimum: 1 }, 'yes'],
|
||||
[extra.id, f.claim, 'no']] as const) {
|
||||
game = act(game, f.asker, { type: 'card', card });
|
||||
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
||||
game = act(game, f.asker, { type: 'truthAsk', question });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer });
|
||||
}
|
||||
const props = { game: viewGame(game, f.target), act() {}, busy: false };
|
||||
const shipment = renderToStaticMarkup(createElement(ShipmentPromises, props));
|
||||
assert.match(shipment, /both sets of answers possible/); assert.doesNotMatch(shipment, /Suggested next step/);
|
||||
assert.match(renderToStaticMarkup(createElement(MovementPromises, props)), /Ship 1 forces to Arrakeen/);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { actTruthMovement as act } from './fixture-truth-movement';
|
||||
import { createTruthMovementShipmentFixture as classic, createTruthMovementShipmentChoamFixture as choam,
|
||||
createTruthMovementShipmentEmperorFixture as emperor, createTruthMovementShipmentGuildFixture as guild,
|
||||
truthMovementShipmentInventory as inventory } from './fixture-truth-movement-shipment';
|
||||
|
||||
function ask(game: Game, asker: string, card: string, question: Record<string, unknown>) {
|
||||
game = act(game, asker, { type: 'card', card });
|
||||
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
||||
return act(game, asker, { type: 'truthAsk', question });
|
||||
}
|
||||
void test('a genuine active pre-shipment turn can promise an eleven-force movement using actual reserve shipment', () => {
|
||||
const f = classic({ hajr: false });
|
||||
const game = ask(f.game, f.asker, f.truth, f.claim);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
});
|
||||
function pass(game: Game): Game {
|
||||
if (game.pendingTreacheryDiscard?.reaction) {
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
||||
}
|
||||
while (game.response) {
|
||||
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||
game = act(game, p.id, { type: 'passResponse' });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function bindClassic(answer = 'yes') {
|
||||
const f = classic({ hajr: false });
|
||||
let game = ask(f.game, f.asker, f.truth, f.claim);
|
||||
game = pass(act(game, f.target, { type: 'truthAnswer', answer }));
|
||||
return { f, game };
|
||||
}
|
||||
void test('real city reinforcement pays once, leaves the ground promise pending and moves the combined eleven forces', () => {
|
||||
const { f, game: bound } = bindClassic(), before = structuredClone(bound);
|
||||
const first = viewGame(bound, f.target).movementPromisePolicy![0];
|
||||
assert.equal(first.type, 'ship'); assert.equal(first.territory, 'arrakeen'); assert.equal(first.amount, 1);
|
||||
assert.deepEqual(bound, before);
|
||||
let game = pass(act(bound, f.target, first));
|
||||
const owner = game.players.find(p => p.id === f.target)!;
|
||||
assert.equal(owner.spice, f.balances[f.target] - 1); assert.equal(owner.reserves, 9);
|
||||
assert.equal(owner.forces['arrakeen:10'], 11); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
void test('voluntary shipment away from the only reinforcing origin cannot erase the accepted eleven-force route', () => {
|
||||
const { f, game } = bindClassic(), before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { ...f.shipment, territory: 'carthag', sector: 11 }));
|
||||
assert.deepEqual(game, before);
|
||||
assert.throws(() => act(game, f.target, { type: 'endMovement' }));
|
||||
});
|
||||
void test('No allows a matching-destination shipment but still forbids the subsequent qualifying ground movement', () => {
|
||||
const f = classic({ hajr: false });
|
||||
let game = ask(f.game, f.asker, f.truth, { ...f.claim, minimum: 1 });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = pass(act(game, f.target, { ...f.shipment, territory: 'carthag', sector: 11 }));
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
assert.throws(() => act(game, f.target, { ...f.move, amount: 1 }));
|
||||
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
void test('genuine zero-board native Emperor and CHOAM sources can promise movement before their actual shipment', () => {
|
||||
for (const f of [emperor(), choam()]) {
|
||||
let game = ask(f.game, f.asker, f.truth, f.claim);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.equal(viewGame(game, f.target).movementPromisePolicy![0].type, 'ship');
|
||||
game = pass(act(game, f.target, f.shipment));
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
if ('kulon' in f) game = pass(act(game, f.target, { type: 'card', mode: 'choam', card: f.kulon }));
|
||||
game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
|
||||
assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
void test('the voluntary bribe quote retains the one-spice reinforcement needed by the eleven-force route', () => {
|
||||
const { f, game } = bindClassic(), before = structuredClone(game);
|
||||
const maximum = viewGame(game, f.target).bribeOptions.targets.find(p => p.id === f.asker)!.maximum;
|
||||
assert.equal(maximum, f.balances[f.target] - 1);
|
||||
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: maximum + 1 }));
|
||||
assert.deepEqual(game, before);
|
||||
const after = act(game, f.target, { type: 'bribe', target: f.asker, amount: maximum });
|
||||
assert.equal(viewGame(after, f.target).movementPromisePolicy![0].type, 'ship');
|
||||
});
|
||||
void test('a controlled conserved second Truth answer keeps shipment Yes ahead of movement No and never suggests illegal End', () => {
|
||||
const f = classic({ hajr: false }), source = structuredClone(f.game);
|
||||
const extra = source.deck.splice(source.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0]; assert.ok(extra);
|
||||
source.players.find(p => p.id === f.asker)!.hand.push(extra);
|
||||
let game = ask(source, f.asker, f.truth, { kind: 'shipment', target: f.target, territory: 'arrakeen', minimum: 1 });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.asker, extra.id, f.claim); game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
const first = viewGame(game, f.target).movementPromisePolicy![0]; assert.equal(first.type, 'ship');
|
||||
game = pass(act(game, f.target, first));
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'endMovement' }]);
|
||||
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
function counterSource() {
|
||||
const f = guild(), source = structuredClone(f.game);
|
||||
const counter = source.deck.splice(source.deck.findIndex(card => card.effect === 'karama'), 1)[0]; assert.ok(counter);
|
||||
source.players.find(p => p.id === f.asker)!.hand.push(counter);
|
||||
return { f, source, counter };
|
||||
}
|
||||
void test('a controlled acquired counter holds the real Guild-rate declaration until its original allowed shipment', () => {
|
||||
const { f, source } = counterSource(); let game = ask(source, f.asker, f.truth, f.claim);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, f.discountedShipment);
|
||||
assert.equal(game.response?.kind, 'guildRate'); assert.equal(game.pendingShipment?.amount, 2);
|
||||
const before = structuredClone(game); viewGame(game, f.target); viewGame(game, f.asker); assert.deepEqual(game, before);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined); assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = pass(game);
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.spice, 4);
|
||||
assert.equal(game.players.find(p => p.id === f.target)!.forces['arrakeen:10'], 2);
|
||||
game = act(game, f.target, f.move); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
void test('a controlled acquired counter raises the real Guild price and releases an impossible seven-force promise', () => {
|
||||
const { f, source, counter } = counterSource();
|
||||
let game = ask(source, f.asker, f.truth, { ...f.claim, territory: 'south_mesa', minimum: 7 });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 4 });
|
||||
game = act(game, f.target, { ...f.discountedShipment, territory: 'tueks_sietch', sector: 5 });
|
||||
assert.equal(game.response?.kind, 'guildRate');
|
||||
game = act(game, f.asker, { type: 'card', card: counter.id, mode: 'cancel' });
|
||||
const own = game.players.find(p => p.id === f.target)!;
|
||||
assert.equal(own.spice, 1); assert.equal(own.reserves, 15); assert.equal(own.shipped, false);
|
||||
assert.deepEqual(own.forces, { 'tueks_sietch:5': 5 });
|
||||
assert.equal(game.movementPromises![0].released, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
void test('controlled acquired-counter Guild cross-shipment preserves the exact permission and rate frames before ground fulfillment', () => {
|
||||
const { f, source } = counterSource(); let game = ask(source, f.asker, f.truth, f.claim);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, f.crossShipment);
|
||||
assert.ok(game.pendingGuildTransport); const before = structuredClone(game);
|
||||
viewGame(game, f.target); assert.deepEqual(game, before);
|
||||
game = pass(game);
|
||||
const own = game.players.find(p => p.id === f.target)!;
|
||||
assert.equal(own.reserves, 15); assert.equal(own.spice, 4); assert.equal(own.forces['arrakeen:10'], 2);
|
||||
game = act(game, f.target, f.move); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
void test('controlled acquired-counter Guild return cannot split away the only five-force ground group', () => {
|
||||
const { f, source } = counterSource();
|
||||
let game = ask(source, f.asker, f.truth, { ...f.claim, territory: 'south_mesa', minimum: 5 });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, f.returnShipment)); assert.deepEqual(game, before);
|
||||
});
|
||||
void test('all four minimal profiles reinforce the original ten-force group and finish its promised movement', () => {
|
||||
const f = classic({ hajr: false }), asked = ask(f.game, f.asker, f.truth, f.claim);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = asked, count = 0;
|
||||
while (game.phase === 5 && game.active === f.target && count++ < 12) {
|
||||
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action); game = pass(act(game, f.target, action));
|
||||
}
|
||||
assert.ok(count < 12); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user