Support earlier classic Basic movement promises
Allow current-round classic Basic questions before Movement, with owned Charity, ordinary auction payment and Revival preparation. Reopen readiness, retain combined shipment obligations, reset stale allowances and release forced storm impossibility. Current-phase guidance and minimal bots preserve the route; held Karama now prepares its actual Guild shipment rate. Validation: affected rules/control tests, types/lint, production build and four gameplay/control/table-rendering programmes. Native early invoices, wider preparations/modules, full rules and deployed acceptance remain open. No saved-game, database, restart or deployment operation.
This commit is contained in:
1 parent
49586efd2f
commit
f995b6f4cd
18 files changed
+708
-46
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Action } from '../game/engine';
|
||||
import {
|
||||
createTruthtranceEarlyShipmentFixture, actTruthtranceEarlyShipment,
|
||||
advanceTruthtranceEarlyShipment, nextTruthtranceEarlyShipmentStep,
|
||||
truthtranceEarlyShipmentClean, truthtranceEarlyShipmentPlayer,
|
||||
type TruthtranceEarlyShipmentStep,
|
||||
} from './fixture-truthtrance-early-shipment';
|
||||
import { truthMovementInventory } from './fixture-truth-movement';
|
||||
|
||||
export type TruthMovementEarlyStep = TruthtranceEarlyShipmentStep;
|
||||
export const actTruthMovementEarly = actTruthtranceEarlyShipment;
|
||||
export const advanceTruthMovementEarly = advanceTruthtranceEarlyShipment;
|
||||
export const nextTruthMovementEarlyStep = nextTruthtranceEarlyShipmentStep;
|
||||
export const truthMovementEarlyClean = truthtranceEarlyShipmentClean;
|
||||
export const truthMovementEarlyPlayer = truthtranceEarlyShipmentPlayer;
|
||||
export const truthMovementEarlyInventory = truthMovementInventory;
|
||||
|
||||
/** Genuine original Basic setup, before the selected early movement question.
|
||||
* Phase0 uses only the real setup prefix and unopened ordinary Spice selection;
|
||||
* phase2 retains the original Storm/Spice continuation through clean Charity.
|
||||
* The caller owns the question, subsequent auction and later shipment/move. */
|
||||
export function createTruthMovementEarlyFixture(options: { phase?: 0 | 2 } = {}) {
|
||||
const base = createTruthtranceEarlyShipmentFixture(), phase = options.phase ?? 2;
|
||||
const game = structuredClone(phase === 0 ? base.afterSetup : base.game);
|
||||
const count = phase === 0 ? base.afterSetupActionCount : base.sourceActionCount;
|
||||
const actions: TruthMovementEarlyStep[] = structuredClone(base.actions.slice(0, count));
|
||||
const spiceOrder = phase === 0 ? base.spiceOrder.map(({ position, card }) => {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Select the same original unopened ordinary Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
return { position, from, card: structuredClone(selected) };
|
||||
}) : structuredClone(base.spiceOrder);
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, phase);
|
||||
assert.deepEqual(game.expansions, []); assert.ok(truthMovementEarlyClean(game));
|
||||
const owner = truthMovementEarlyPlayer(game, base.target);
|
||||
assert.equal(owner.spice, 10); assert.equal(owner.reserves, 20); assert.equal(owner.tanks, 0);
|
||||
assert.deepEqual(owner.forces, {}); assert.equal(owner.shipped, false); assert.equal(owner.moved, 0);
|
||||
assert.ok(truthMovementEarlyPlayer(game, base.asker).hand.some(card => card.id === base.card));
|
||||
assert.ok(truthMovementEarlyPlayer(game, base.counterHolder).hand.some(card => card.id === base.karama));
|
||||
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
|
||||
assert.deepEqual(truthMovementInventory(game), base.originalTreachery);
|
||||
const claim = { kind: 'movement' as const, target: base.target, territory: 'carthag', minimum: 8 };
|
||||
return {
|
||||
game, source: structuredClone(game), base, phase,
|
||||
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
|
||||
afterSetup: base.afterSetup, afterSetupActionCount: base.afterSetupActionCount,
|
||||
beforeSpiceOrder: base.beforeSpiceOrder, spiceOrder, deal: base.deal, dealOrder: base.dealOrder,
|
||||
originalCards: base.originalTreachery, originalTreachery: base.originalTreachery, originalSpice: base.originalSpice,
|
||||
asker: base.asker, target: base.target, counterHolder: base.counterHolder, guild: base.guild,
|
||||
truth: base.card, card: base.card, karama: base.karama, claim, question: claim,
|
||||
actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { storm: base.afterSetupActionCount, charity: base.sourceActionCount },
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
declarationStep: { actor: base.asker, action: { type: 'card', card: base.card } } satisfies TruthMovementEarlyStep,
|
||||
askStep: { actor: base.asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementEarlyStep,
|
||||
bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
|
||||
payment: { type: 'decision', karama: false } satisfies Action,
|
||||
purchaseCost: 2,
|
||||
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 } satisfies Action,
|
||||
shipmentCost: 8,
|
||||
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 } satisfies Action,
|
||||
endMovement: { type: 'endMovement' } satisfies Action,
|
||||
staging: [
|
||||
`Original public Basic Atreides/Emperor/Harkonnen/Guild setup selects only the original five unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Atreides genuinely holds Truthtrance, Emperor Baliset, Harkonnen Karama/Shield and Guild Snooper. The phase${phase} source uses exactly its original ${actions.length}-action prefix: ${phase === 0 ? 'the Charity factory suffix is excluded, and the same still-unopened ordinary Spice position is selected before any Storm/Spice continuation' : 'actual Storm/Spice controls reach clean Charity before any question or Bidding'}. Emperor retains its printed ten spice, twenty reserves, zero board forces and no Tanks. The suggested future normal purchase2, city shipment8 and eight-force ground movement remain caller actions; no purchase, question, answer, dealt hand, wallet, army, phase clock, queue or receipt is installed. The movement predicate concerns the later ground event, not the preceding shipment.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementEarlyFixture = ReturnType<typeof createTruthMovementEarlyFixture>;
|
||||
@@ -54,7 +54,7 @@ export function createTruthtranceEarlyShipmentFixture() {
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
|
||||
assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
|
||||
assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
|
||||
assert.ok(truthtranceEarlyShipmentPlayer(game, target).hand.some(card => card.id === filler[0]!.id));
|
||||
@@ -80,7 +80,7 @@ export function createTruthtranceEarlyShipmentFixture() {
|
||||
const declarationStep: TruthtranceEarlyShipmentStep = { actor: asker,
|
||||
action: { type: 'card', card: truth.id } };
|
||||
const askStep: TruthtranceEarlyShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, beforeSpiceOrder, spiceOrder,
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount, beforeSpiceOrder, spiceOrder,
|
||||
deal, dealOrder, originalTreachery, originalSpice, asker, target, counterHolder, guild: 'guild',
|
||||
card: truth.id, karama: karama.id, question, declarationStep, askStep,
|
||||
territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
|
||||
|
||||
@@ -129,3 +129,17 @@ void test('Basic native selected families qualify, while optional modules and un
|
||||
assert.equal(movementPromiseTargetAvailable(configured, 'target'), false, key);
|
||||
}
|
||||
});
|
||||
|
||||
void test('earlier current-round movement questions support classic Basic while native early invoices remain unfinished', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4]) {
|
||||
const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = [];
|
||||
assert.equal(movementPromiseTargetAvailable(classic, 'target'), true);
|
||||
const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
|
||||
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
|
||||
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
|
||||
}
|
||||
for (const phase of [-1, 6, 8]) {
|
||||
const g = movementBoundary(); g.phase = phase;
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
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 { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { createTruthMovementEarlyFixture, actTruthMovementEarly as act } from './fixture-truth-movement-early';
|
||||
|
||||
type Props = Parameters<typeof MovementPromises>[0];
|
||||
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
|
||||
function controls(node: unknown): Control[] {
|
||||
if (Array.isArray(node)) return node.flatMap(controls);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
|
||||
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
|
||||
}
|
||||
const markup = (props: Props) => renderToStaticMarkup(createElement(MovementPromises, props));
|
||||
|
||||
/** Controlled personalized UI projections for label/dispatch boundaries only.
|
||||
* They do not establish earned Charity, Tanks, bids, cards or early feasibility.
|
||||
* The separate original-source case below binds a real early answer. */
|
||||
function projectedView(phase: number, policy: Action[] | null = null, active: string | null = 'a'): GameView {
|
||||
const source = createGame('EARLYMOVEMENTCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 1; game.phase = phase; game.active = active;
|
||||
game.movementPromises = [{ player: 'p', asker: 'a', turn: 1,
|
||||
territory: 'carthag', minimum: 8, answer: true }];
|
||||
game.movementPromisePolicy = policy;
|
||||
return game;
|
||||
}
|
||||
function assertCurrentControl(game: GameView, action: Action, label: string) {
|
||||
const sent: Action[] = [], props = { game, busy: false, act: (value: Action) => { sent.push(value); } };
|
||||
const html = markup(props);
|
||||
assert.match(html, /Your Movement turn is still ahead/);
|
||||
assert.match(html, /Complete any current preparation below before confirming readiness/);
|
||||
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
|
||||
assert.match(html, /a shipment can prepare that move but does not fulfill it/);
|
||||
assert.doesNotMatch(html, />Ship \d|>Move \d|>Use Ornithopter and move|>Finish movement while honoring No/);
|
||||
const available = controls(MovementPromises(props));
|
||||
assert.equal(available.length, 1); assert.equal(available[0].children, label);
|
||||
assert.equal(available[0].disabled, false);
|
||||
available[0].onClick(); assert.equal(sent.length, 1); assert.equal(sent[0], action);
|
||||
const busy = controls(MovementPromises({ ...props, busy: true }));
|
||||
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
|
||||
busy[0].onClick(); assert.equal(sent.length, 1, 'A stale busy handler cannot submit again');
|
||||
assert.match(markup({ ...props, busy: true }), /Your ground-movement promises/);
|
||||
return props;
|
||||
}
|
||||
|
||||
void test('real early Charity binding exposes only current readiness and keeps its obligation readable after submission', () => {
|
||||
const f = createTruthMovementEarlyFixture();
|
||||
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
||||
for (const player of game.players)
|
||||
if (!game.truthtrance!.passed.includes(player.id)) game = act(game, player.id, { type: 'truthPass' });
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: f.claim });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
if (game.pendingTreacheryDiscard?.reaction) {
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const player of game.players) game = act(game, player.id, { type: 'semutaPass', event });
|
||||
}
|
||||
assert.equal(game.phase, 2);
|
||||
const view = viewGame(game, f.target), next = view.movementPromisePolicy?.[0];
|
||||
assert.deepEqual(next, { type: 'ready' });
|
||||
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
|
||||
const before = markup(props);
|
||||
assert.match(before, /Your Movement turn is still ahead/);
|
||||
assert.match(before, /Finish this preparation phase while preserving your promises/);
|
||||
assert.equal(controls(MovementPromises(props)).length, 1);
|
||||
assert.doesNotMatch(before, />Ship \d|>Move \d|>Finish movement while honoring No/);
|
||||
controls(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(sent.length, 0);
|
||||
controls(MovementPromises(props))[0].onClick(); assert.deepEqual(sent, [next]);
|
||||
game = act(game, f.target, sent[0]); assert.ok(game.ready.includes(f.target));
|
||||
const waiting = { ...props, game: viewGame(game, f.target) };
|
||||
assert.equal(controls(MovementPromises(waiting)).length, 0);
|
||||
const after = markup(waiting);
|
||||
assert.match(after, /Your Movement turn is still ahead/);
|
||||
assert.match(after, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
|
||||
assert.match(after, /needed forces, spice and cards available/);
|
||||
});
|
||||
|
||||
void test('projected Charity and Revival controls forward only their current preparation with busy protection', () => {
|
||||
const charity: Action = { type: 'charity' };
|
||||
assertCurrentControl(projectedView(2, [charity], null), charity, 'Claim Charity for your later movement');
|
||||
const revival: Action = { type: 'revive', amount: 3, elite: 1 };
|
||||
assertCurrentControl(projectedView(4, [revival], null), revival, 'Revive 3 forces for your later movement');
|
||||
});
|
||||
|
||||
void test('projected Charity and Revival readiness remains a current control while later movement stays deferred', () => {
|
||||
for (const phase of [2, 4]) {
|
||||
const ready: Action = { type: 'ready' };
|
||||
assertCurrentControl(projectedView(phase, [ready], null), ready,
|
||||
'Finish this preparation phase while preserving your promises');
|
||||
}
|
||||
});
|
||||
|
||||
void test('the current bid-pass control preserves its exact action without offering a future shipment', () => {
|
||||
const pass: Action = { type: 'passBid' };
|
||||
assertCurrentControl(projectedView(3, [pass], 'p'), pass, 'Pass this bid and preserve later movement');
|
||||
});
|
||||
|
||||
void test('current auction guidance distinguishes original cash and physical Karama payment decisions', () => {
|
||||
const cash: Action = { type: 'decision', karama: false }, cashView = projectedView(3, [cash], 'p');
|
||||
cashView.decision = { kind: 'auctionPayment', player: cashView.me };
|
||||
assertCurrentControl(cashView, cash, 'Pay the current auction price');
|
||||
const card = baseDeck().find(card => card.effect === 'karama'); assert.ok(card);
|
||||
const payment: Action = { type: 'decision', karama: true, card: card.id };
|
||||
const cardView = projectedView(3, [payment], 'p');
|
||||
cardView.decision = { kind: 'auctionPayment', player: cardView.me };
|
||||
cardView.players.find(player => player.id === cardView.me)!.hand = [card];
|
||||
assertCurrentControl(cardView, payment, `Use ${card.name} for the current auction payment`);
|
||||
});
|
||||
|
||||
void test('early notices persist without a current control and cannot turn future movement policy into an early action', () => {
|
||||
const future: Action[] = [
|
||||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 },
|
||||
{ type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 },
|
||||
{ type: 'endMovement' },
|
||||
{ type: 'card', card: 'future-hajr' },
|
||||
];
|
||||
for (const phase of [0, 1, 2, 3, 4]) for (const policy of [null, ...future.map(action => [action])]) {
|
||||
const props = { game: projectedView(phase, policy), busy: false,
|
||||
act() { assert.fail('An early notice cannot execute a later shipment or move'); } };
|
||||
assert.equal(controls(MovementPromises(props)).length, 0);
|
||||
const html = markup(props);
|
||||
assert.match(html, /Your Movement turn is still ahead/);
|
||||
assert.match(html, /movement controls appear on your turn/);
|
||||
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
|
||||
assert.doesNotMatch(html, /<button/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an out-of-phase preparation or foreign auction payment never becomes an owned early control', () => {
|
||||
for (const [phase, action] of [[2, { type: 'revive', amount: 1 }], [4, { type: 'charity' }],
|
||||
[3, { type: 'ready' }]] satisfies [number, Action][]) {
|
||||
const props = { game: projectedView(phase, [action]), busy: false, act() { assert.fail('Wrong preparation phase'); } };
|
||||
assert.equal(controls(MovementPromises(props)).length, 0);
|
||||
}
|
||||
const foreign = projectedView(3, [{ type: 'decision', karama: false }]);
|
||||
foreign.decision = { kind: 'auctionPayment', player: 'a' };
|
||||
assert.equal(controls(MovementPromises({ game: foreign, busy: false,
|
||||
act() { assert.fail('A foreign invoice cannot become an owned control'); } })).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthMovementEarlyFixture as fixture, actTruthMovementEarly as act,
|
||||
advanceTruthMovementEarly as advance, truthMovementEarlyClean as clean,
|
||||
truthMovementEarlyPlayer as player, truthMovementEarlyInventory as inventory } from './fixture-truth-movement-early';
|
||||
|
||||
function asked(options: { phase?: 0 | 1 | 2 | 3 | 4; minimum?: number; territory?: string; control?: (g: Game) => void } = {}) {
|
||||
const f = fixture({ phase: options.phase === 0 || options.phase === 1 ? 0 : 2 });
|
||||
let game = options.phase !== undefined && ![0, 2].includes(options.phase)
|
||||
? advance(f.game, g => g.phase === options.phase && clean(g)) : f.game;
|
||||
options.control?.(game);
|
||||
game = act(game, f.asker, f.declarationStep.action);
|
||||
game = advance(game, g => g.truthtrance?.stage === 'ask');
|
||||
assert.ok(viewGame(game, f.asker).truthMovementTargets.includes(f.target));
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { ...f.claim,
|
||||
territory: options.territory ?? f.claim.territory, minimum: options.minimum ?? 8 } });
|
||||
return { f, game };
|
||||
}
|
||||
function bound(options: Parameters<typeof asked>[0] = {}, answer = 'yes') {
|
||||
const { f, game } = asked(options);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes(answer as 'yes' | 'no'));
|
||||
return { f, game: act(game, f.target, { type: 'truthAnswer', answer }) };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function giveKarama(game: Game, count = 1) {
|
||||
for (let n = 0; n < count; n++) {
|
||||
const holder = game.players.find(p => p.id !== 'emperor' && p.hand.some(c => c.effect === 'karama'));
|
||||
const cards = holder?.hand ?? game.deck, index = cards.findIndex(c => c.effect === 'karama');
|
||||
assert.ok(index >= 0); player(game).hand.push(cards.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
function allies(game: Game) { player(game).ally = 'harkonnen'; player(game, 'harkonnen').ally = 'emperor'; }
|
||||
|
||||
void test('an original Charity movement answer retains its physical source without granting early shipment or movement', () => {
|
||||
const { f, game } = bound();
|
||||
assert.equal(game.phase, 2); assert.equal(player(game).spice, 10);
|
||||
assert.equal(player(game).reserves, 20); assert.deepEqual(player(game).forces, {});
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
|
||||
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
|
||||
});
|
||||
|
||||
void test('the actual winning invoice reserves spice for shipment and only the later ground event fulfills Yes', () => {
|
||||
const { f, game: initial } = bound();
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
const purchased = game.auction!.cards[game.auction!.index].id;
|
||||
reject(game, f.target, { type: 'bid', amount: 3 });
|
||||
game = act(game, f.target, f.bid);
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
assert.equal(player(game).spice, 8); assert.ok(player(game).hand.some(c => c.id === purchased));
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
game = advance(act(game, f.target, f.shipment), clean);
|
||||
assert.equal(player(game).spice, 0); assert.equal(player(game).forces['arrakeen:10'], 8);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
reject(game, f.target, f.endMovement);
|
||||
game = advance(act(game, f.target, f.move), clean);
|
||||
assert.equal(player(game).forces['carthag:11'], 8); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = act(game, f.target, f.endMovement); assert.notEqual(game.active, f.target);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('original Storm, Spice, Bidding and Revival questions retain the same current-round future movement', () => {
|
||||
for (const phase of [0, 1, 3, 4] as const) {
|
||||
const { f, game: initial } = bound({ phase });
|
||||
assert.equal(initial.phase, phase);
|
||||
assert.ok(!viewGame(initial, f.target).movementPromisePolicy?.some(a => ['ship', 'move', 'endMovement'].includes(a.type)));
|
||||
let game = advance(initial, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(act(game, f.target, f.shipment), clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = advance(act(game, f.target, f.move), clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
const needPreparation = (g: Game) => {
|
||||
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
|
||||
g.ready = [p.id];
|
||||
};
|
||||
void test('controlled Charity and Revival needs reopen readiness and cannot be skipped or spent on an excess revival', () => {
|
||||
const { f, game: initial } = bound({ control: needPreparation, minimum: 1 });
|
||||
let game = initial;
|
||||
assert.equal(game.ready.includes(f.target), false);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'charity' }]);
|
||||
reject(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, { type: 'charity' });
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'revive', amount: 1 }]);
|
||||
reject(game, f.target, { type: 'ready' }); reject(game, f.target, { type: 'revive', amount: 2 });
|
||||
game = act(game, f.target, { type: 'revive', amount: 1 });
|
||||
assert.equal(player(game).reserves, 1); assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
for (let count = 0; !game.movementPromises![0].fulfilled && count < 4; count++) {
|
||||
const action = viewGame(game, f.target).movementPromisePolicy![0]; assert.ok(action);
|
||||
game = advance(act(game, f.target, action), clean);
|
||||
}
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
|
||||
void test('a controlled already-Ready Revival respondent can answer and use only its current remaining preparation', () => {
|
||||
const { f, game: initial } = bound({ phase: 4, minimum: 1, control: g => {
|
||||
needPreparation(g); player(g).spice = 1;
|
||||
} });
|
||||
assert.equal(initial.ready.includes(f.target), false);
|
||||
assert.deepEqual(viewGame(initial, f.target).movementPromisePolicy, [{ type: 'revive', amount: 1 }]);
|
||||
reject(initial, f.target, { type: 'ready' });
|
||||
const game = act(initial, f.target, { type: 'revive', amount: 1 });
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
|
||||
});
|
||||
|
||||
void test('controlled physical Karamas price the actual invoice separately from later shipment', () => {
|
||||
for (const count of [1, 2]) {
|
||||
const { f, game: initial } = bound({ control: g => { player(g).spice = 4; giveKarama(g, count); } });
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
if (count === 1) { reject(game, f.target, { type: 'bid', amount: 1 }); continue; }
|
||||
game = act(game, f.target, { type: 'bid', amount: 1 });
|
||||
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
|
||||
reject(game, f.target, { type: 'decision', karama: false });
|
||||
const payment = viewGame(game, f.target).movementPromisePolicy![0];
|
||||
assert.equal(payment.karama, true);
|
||||
game = act(game, f.target, payment);
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
const price = viewGame(game, f.target).movementPromisePolicy![0];
|
||||
assert.equal(price.type, 'card'); assert.notEqual(price.card, payment.card);
|
||||
assert.equal(player(game).hand.filter(c => c.effect === 'karama').length, 1);
|
||||
game = advance(act(game, f.target, price), clean);
|
||||
assert.equal(game.karamaShipping?.player, f.target);
|
||||
game = advance(act(game, f.target, f.shipment), clean);
|
||||
assert.equal(player(game).spice, 0); assert.equal(game.karamaShipping, null);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = advance(act(game, f.target, f.move), clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled ally escrow waits for actual income or cancellation before releasing a movement shortfall', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
const { f, game: initial } = bound({ control: allies, territory: 'imperial_basin' });
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === 'harkonnen');
|
||||
game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(game, g => g.response?.kind === 'emperorIncome');
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
if (canceled) game = act(game, 'harkonnen', { type: 'card',
|
||||
card: player(game, 'harkonnen').hand.find(c => c.effect === 'karama')!.id, mode: 'cancel' });
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
assert.equal(player(game).spice, canceled ? 7 : 10);
|
||||
assert.equal(game.movementPromises![0].released, canceled ? true : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled all-No answers leave native Karama overbid payment available to every existing profile', () => {
|
||||
const { f, game: initial } = bound({ control: g => giveKarama(g) }, 'no');
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'bid', amount: 11 });
|
||||
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
|
||||
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.equal(action.karama, true);
|
||||
assert.doesNotThrow(() => applyAction(game, f.target, action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('a controlled last Storm Ready resolves forced losses instead of rejecting its owner or preserving a false route', () => {
|
||||
const { f, game: initial } = bound({ phase: 0, territory: 'arrakeen', control: g => {
|
||||
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 12; p.forces = { 'oh_gap:11': 8 };
|
||||
g.storm = 9;
|
||||
} });
|
||||
let game = initial;
|
||||
for (const [index, actor] of game.stormDialers.entries()) game = act(game, actor, { type: 'stormDial', amount: index === 0 ? 2 : 0 });
|
||||
assert.equal(game.stormPending, 2);
|
||||
for (const p of game.players) if (p.id !== f.target) game = act(game, p.id, { type: 'ready' });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
assert.equal(player(game).forces['oh_gap:11'] ?? 0, 0); assert.equal(player(game).tanks, 20);
|
||||
assert.equal(game.movementPromises![0].released, true);
|
||||
});
|
||||
|
||||
function secondQuestion(game: Game, question: Record<string, unknown>): Game {
|
||||
// Controlled acquisition of another conserved original Truthtrance, without
|
||||
// staging an answer, future card, board, balance or movement allowance.
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0]; player(game, 'atreides').hand.push(card);
|
||||
game = act(game, 'atreides', { type: 'card', card: card.id });
|
||||
game = advance(game, g => g.truthtrance?.stage === 'ask');
|
||||
return act(game, 'atreides', { type: 'truthAsk', question });
|
||||
}
|
||||
void test('controlled prior Ornithopter credit expires at the real movement boundary and cannot supply a second promised move', () => {
|
||||
const { f, game: initial } = bound({ phase: 4, minimum: 2, control: g => {
|
||||
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 18;
|
||||
p.forces = { 'arrakeen:10': 2 }; p.ornithopterExtraMoves = 1;
|
||||
} });
|
||||
let game = secondQuestion(initial, { kind: 'movement', target: f.target, territory: 'arrakeen', minimum: 2 });
|
||||
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), false);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
assert.equal(player(game).ornithopterExtraMoves, undefined);
|
||||
game = advance(act(game, f.target, { ...f.move, amount: 2 }), clean);
|
||||
game = act(game, f.target, f.endMovement);
|
||||
assert.ok(game.movementPromises!.every(promise => promise.fulfilled));
|
||||
});
|
||||
|
||||
void test('a controlled second original question keeps an early shipment Yes in the combined movement witness', () => {
|
||||
const f = fixture();
|
||||
let game = act(f.game, f.asker, f.declarationStep.action);
|
||||
game = advance(game, g => g.truthtrance?.stage === 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { ...f.claim, kind: 'shipment' } });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = secondQuestion(game, f.claim);
|
||||
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), false);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('no'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
const policy = viewGame(game, f.target).movementPromisePolicy![0]; assert.equal(policy.type, 'ship');
|
||||
assert.equal(policy.territory, 'carthag');
|
||||
game = advance(act(game, f.target, policy), clean);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
game = act(game, f.target, f.endMovement);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
Reference in new issue
Block a user