Support classic Advanced movement Truthtrance promises
This commit is contained in:
1 parent
0d8481e821
commit
2ae2b984c0
15 files changed
+634
-23
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { distance, location, TERRITORIES } from '../game/board';
|
||||
import { botGroundMoveAllowed } from '../game/bot-mobility';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import {
|
||||
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
|
||||
type TruthMovementStep,
|
||||
} from './fixture-truth-movement';
|
||||
|
||||
export { actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer };
|
||||
export type TruthMovementAdvancedStep = TruthMovementStep;
|
||||
export type TruthMovementAdvancedTransition = { before: Game; steps: TruthMovementStep[]; after: Game };
|
||||
export const actTruthMovementAdvanced = actTruthMovement;
|
||||
export const truthMovementAdvancedClean = truthMovementClean;
|
||||
export const truthMovementAdvancedInventory = truthMovementInventory;
|
||||
export const truthMovementAdvancedPlayer = (game: Game, actor = game.players[0].id) => truthMovementPlayer(game, actor);
|
||||
|
||||
/** Native original setup and phase controls only. Truthtrance and the shipped
|
||||
* respondent's movement remain caller-owned; no promise is silently closed. */
|
||||
export function nextTruthMovementAdvancedNativeStep(game: Game): TruthMovementStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
const target = game.players[0].id;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard &&
|
||||
game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
truthMovementPlayer(game, target).faction === 'fremen' && truthMovementPlayer(game, target).reserves === 20)
|
||||
return { actor: target, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 },
|
||||
elitePlacements: { 'sietch_tabr:14': 2 } } };
|
||||
if (game.phase === 5 && game.active === target && truthMovementPlayer(game, target).shipped && truthMovementClean(game)) return null;
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthMovementAdvanced(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMovementStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Advanced movement source ended before its requested control');
|
||||
const next = nextTruthMovementAdvancedNativeStep(game);
|
||||
assert.ok(next, `Supply the original Truthtrance or ground movement at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
|
||||
game = actTruthMovement(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Advanced Truthtrance movement source did not reach its finite boundary');
|
||||
}
|
||||
export { advanceTruthMovementAdvanced as advanceTruthMovement };
|
||||
|
||||
/** Public rules selection and original Advanced preview start. Original Hajr
|
||||
* and Truthtrance are actually dealt; all later clocks/counters/payment arise
|
||||
* from native controls. The optional Fremen route earns a real auction Karama. */
|
||||
export function createTruthMovementAdvancedFixture(options: {
|
||||
hajr?: boolean; targetFaction?: 'atreides' | 'fremen';
|
||||
} = {}) {
|
||||
const targetFaction = options.targetFaction ?? 'atreides', target = targetFaction, asker = 'emperor';
|
||||
const withHajr = options.hajr ?? true;
|
||||
let game = createGame('TRUTHMOVEMENTADVANCED', newPlayer(target, targetFaction === 'atreides' ? 'Atreides' : 'Fremen', targetFaction), false);
|
||||
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementAdvanced(game, until, actions); };
|
||||
const transition = (run: () => void): TruthMovementAdvancedTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.advancedPreview, true);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 33);
|
||||
const targetCard = game.deck.find(card => withHajr ? card.effect === 'hajr' : card.kind === 'projectile');
|
||||
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
|
||||
const karama = targetFaction === 'fremen' ? game.deck.find(card => card.effect === 'karama') : null;
|
||||
assert.ok(targetCard && truthtrance); assert.ok(targetFaction !== 'fremen' || karama);
|
||||
const selected = [targetCard, truthtrance, ...(karama ? [karama] : [])];
|
||||
const deal = selected.slice(0, 2).map(card => card.id);
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let fremenSetup: TruthMovementAdvancedTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextTruthMovementAdvancedNativeStep(game); assert.ok(next);
|
||||
const before = next.action.type === 'fremenSetup' ? structuredClone(game) : null;
|
||||
act(next.actor, next.action);
|
||||
if (before) fremenSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
|
||||
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
|
||||
if (targetFaction === 'atreides') assert.deepEqual(truthMovementPlayer(game, target).forces, { 'arrakeen:10': 10 });
|
||||
else {
|
||||
assert.ok(fremenSetup); assert.deepEqual(truthMovementPlayer(game, target).forces, { 'sietch_tabr:14': 10 });
|
||||
assert.equal(truthMovementPlayer(game, target).elites?.forces['sietch_tabr:14'], 2);
|
||||
assert.equal(truthMovementPlayer(game, target).elites?.reserves, 1);
|
||||
}
|
||||
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
|
||||
forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
|
||||
elites: structuredClone(player.elites),
|
||||
}]));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
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) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let karamaAcquisition: TruthMovementAdvancedTransition | null = null;
|
||||
if (karama) {
|
||||
reach(state => state.phase === 3 && truthMovementClean(state) && state.auction?.active === asker &&
|
||||
state.auction.cards[state.auction.index]?.id === karama.id);
|
||||
karamaAcquisition = transition(() => {
|
||||
act(asker, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
reach(state => truthMovementClean(state) && truthMovementPlayer(state, asker).hand.some(card => card.id === karama.id));
|
||||
});
|
||||
}
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
|
||||
const beforeShipment = structuredClone(game);
|
||||
assert.equal(truthMovementPlayer(game, target).shipped, false);
|
||||
const shipmentStep: TruthMovementStep = { actor: target, action: {
|
||||
type: 'ship', amount: 1, territory: 'polar_sink', sector: 0,
|
||||
elite: targetFaction === 'fremen' ? 1 : 0, allyPayment: 0,
|
||||
} };
|
||||
const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
|
||||
const player = truthMovementPlayer(game, target), shipmentCost = truthMovementPlayer(beforeShipment, target).spice - player.spice;
|
||||
assert.equal(game.phase, 5); assert.equal(game.turn, 1); assert.equal(game.active, target);
|
||||
assert.equal(player.shipped, true); assert.equal(player.moved, 0); assert.equal(player.forces['polar_sink:0'], 1);
|
||||
assert.equal(player.reserves, 9); assert.equal(shipmentCost, targetFaction === 'fremen' ? 0 : 2);
|
||||
if (targetFaction === 'atreides') assert.equal(player.forces['arrakeen:10'], 10);
|
||||
else {
|
||||
assert.equal(player.elites?.forces['polar_sink:0'], 1); assert.equal(player.elites?.forces['sietch_tabr:14'], 2);
|
||||
assert.equal(player.elites?.reserves, 0); assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === karama!.id));
|
||||
}
|
||||
assert.ok(player.hand.some(card => card.id === targetCard.id));
|
||||
assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === truthtrance.id));
|
||||
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
|
||||
const matchingMove: Action = targetFaction === 'atreides'
|
||||
? { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 6 }
|
||||
: { type: 'move', from: 'polar_sink:0', territory: 'imperial_basin', sector: 9, amount: 1, elite: 1 };
|
||||
const questionAction: Action = { type: 'truthAsk', question: { kind: 'movement', target,
|
||||
territory: targetFaction === 'atreides' ? 'carthag' : 'imperial_basin', minimum: targetFaction === 'atreides' ? 6 : 1 } };
|
||||
let twoStepMove: Action | null = null, twoStepDestination: string | null = null;
|
||||
if (targetFaction === 'fremen') {
|
||||
const view = viewGame(game, target), own = view.players.find(candidate => candidate.id === target)!;
|
||||
const destination = TERRITORIES.flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
|
||||
.find(to => to.sector !== game.storm && distance('polar_sink:0', location(to.territory, to.sector)) === 2 &&
|
||||
botGroundMoveAllowed(view, own, 'polar_sink:0', location(to.territory, to.sector), 1));
|
||||
assert.ok(destination, 'The original Fedaykin group has a legal native two-step movement');
|
||||
twoStepDestination = destination.territory;
|
||||
twoStepMove = { type: 'move', from: 'polar_sink:0', ...destination, amount: 1, elite: 1 };
|
||||
}
|
||||
return { game, source: structuredClone(game), initial, rules, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
fremenSetup, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentCost,
|
||||
targetFaction, asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
|
||||
hajr: withHajr ? targetCard.id : null, targetCard: targetCard.id, karama: karama?.id ?? null, karamaAcquisition,
|
||||
truthStep, declaration: truthStep.action, questionAction, matchingMove, twoStepMove, twoStepDestination,
|
||||
nonmatchingMove: targetFaction === 'atreides'
|
||||
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 4 } satisfies Action : null,
|
||||
hajrAction: withHajr ? { type: 'card', card: targetCard.id } satisfies Action : null,
|
||||
typedGroup: targetFaction === 'fremen' ? { source: 'polar_sink:0', normal: 0, elite: 1 } : null,
|
||||
deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalSpice, startingPositions,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
staging: [
|
||||
'Actual public two-seat classic lobby starts Basic, uses host rules advanced:true, readies both seats and submits exact start advancedPreview:true. No played Basic state is converted and no development initializer is used.',
|
||||
'Select only original scalar lotteries, unopened starting-deal positions and two original unopened ordinary Spice positions. The target really receives Hajr (or the explicitly requested ordinary projectile) and Emperor really receives Truthtrance from the native base33 deal.',
|
||||
targetFaction === 'atreides'
|
||||
? 'Actual Atreides private Traitor setup retains its original ten Arrakeen counters and ten reserves. Native safe Storm, both Advanced Spice blows, Charity, Bidding and Revival precede an actual two-spice one-force Polar Sink shipment; the original ten Arrakeen forces and held Hajr remain.'
|
||||
: 'Actual Fremen setup places ten Tabr14 counters including two original Fedaykin, retaining one original reserve Fedaykin. Emperor actually wins the original next Karama auction lot with a one-spice invoice through native bidding/payment/income. Original Storm/forecast responses, both Spice blows, Charity and Revival precede a real free Polar Sink shipment of the last reserve Fedaykin.',
|
||||
'Stop at clean first Shipment and Movement with the target active, actual shipment used and ordinary ground movement unused. Source, beforeShipment, original paid/dealt cards and typed counters are real chronology snapshots. No hand, force, wallet, phase, allowance, movement promise, pending response or receipt is installed.',
|
||||
'Truthtrance declaration/question, Hajr play and returned original ground movements remain caller-owned. This source establishes native Advanced provenance only; it does not certify the expanded forecast, every faction/module or later-round movement semantics.',
|
||||
] };
|
||||
}
|
||||
export type TruthMovementAdvancedFixture = ReturnType<typeof createTruthMovementAdvancedFixture>;
|
||||
export function createFremenTruthMovementAdvancedFixture(options: { hajr?: boolean } = {}) {
|
||||
return createTruthMovementAdvancedFixture({ ...options, targetFaction: 'fremen' });
|
||||
}
|
||||
|
||||
/** Submit a real declaration, priority passes and one structured question. */
|
||||
export function askTruthMovementAdvanced(game: Game, asker: string, card: string,
|
||||
target: string, minimum = 6, territory = 'carthag') {
|
||||
game = actTruthMovement(game, asker, { type: 'card', card });
|
||||
for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id))
|
||||
game = actTruthMovement(game, player.id, { type: 'truthPass' });
|
||||
return actTruthMovement(game, asker,
|
||||
{ type: 'truthAsk', question: { kind: 'movement', target, territory, minimum } });
|
||||
}
|
||||
@@ -116,7 +116,7 @@ void test('movement question profile permits an unspent shipment but rejects an
|
||||
const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] };
|
||||
assert.equal(movementPromiseTargetAvailable(response, 'target'), false);
|
||||
const advanced = movementBoundary(); advanced.advanced = true;
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), false);
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
|
||||
});
|
||||
void test('Basic native selected families qualify, while optional modules and undeclared native families do not', () => {
|
||||
const native = movementBoundary(); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
|
||||
@@ -143,3 +143,17 @@ void test('earlier current-round movement question profiles include declared Bas
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('classic Advanced Movement admits active and unused waiting seats while retaining module and native-family boundaries', () => {
|
||||
const advanced = movementBoundary(); advanced.advanced = true;
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true);
|
||||
for (const field of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
|
||||
assert.equal(movementPromiseTargetAvailable({ ...advanced, [field]: true } as unknown as Game, 'target'), false, field);
|
||||
}
|
||||
const native = structuredClone(advanced); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
|
||||
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
|
||||
advanced.players[1].shipped = true;
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, type ReactElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { Truthtrance } from '../components/truthtrance';
|
||||
import { MovementPromises } from '../components/movement-promises';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced,
|
||||
actTruthMovement as act } from './fixture-truth-movement-advanced';
|
||||
|
||||
function callbacks(node: unknown): (() => void)[] {
|
||||
if (Array.isArray(node)) return node.flatMap(callbacks);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
|
||||
return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)];
|
||||
}
|
||||
|
||||
void test('genuine Advanced movement answers and persistent guidance connect to a real physical move', () => {
|
||||
const f = createTruthMovementAdvancedFixture();
|
||||
let g = askTruthMovementAdvanced(f.game, f.asker, f.truthtrance, f.target);
|
||||
const answers = renderToStaticMarkup(createElement(Truthtrance,
|
||||
{ game: viewGame(g, f.target), busy: false, act() {} }));
|
||||
assert.match(answers, />Answer Yes<\/button>/); assert.match(answers, />Answer No<\/button>/);
|
||||
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const view = viewGame(g, f.target), actions: Action[] = [];
|
||||
const props = { game: view, busy: false, act: (action: Action) => { actions.push(action); } };
|
||||
const html = renderToStaticMarkup(createElement(MovementPromises, props));
|
||||
assert.match(html, /Your ground-movement promises/); assert.match(html, /Move 6 forces to Carthag/);
|
||||
callbacks(MovementPromises(props))[0]();
|
||||
assert.deepEqual(actions, view.movementPromisePolicy);
|
||||
g = act(g, f.target, actions[0]); assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
callbacks(MovementPromises({ ...props, busy: true }))[0](); assert.equal(actions.length, 1);
|
||||
assert.equal(MovementPromises({ ...props, game: viewGame(g, f.asker) }), null);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced as askAdvancedMovement } from './fixture-truth-movement-advanced';
|
||||
import { actTruthMovement as act, advanceTruthMovement as advance,
|
||||
truthMovementClean as clean, truthMovementPlayer as player,
|
||||
truthMovementInventory as inventory } from './fixture-truth-movement';
|
||||
import { createAdvisorMatchingMoveFixture } from './fixture-advisor-matching-move';
|
||||
|
||||
void test('genuine Advanced Yes binds physical movement and preserves immediate Hajr timing', () => {
|
||||
const f = createTruthMovementAdvancedFixture();
|
||||
assert.throws(() => act(f.game, f.target, { type: 'card', card: f.hajr! }),
|
||||
'Advanced Hajr cannot prepare the initial move');
|
||||
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
|
||||
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.throws(() => act(g, f.target, { type: 'endMovement' }));
|
||||
g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
|
||||
territory: 'imperial_basin', sector: 9 });
|
||||
g = advance(g, clean);
|
||||
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
||||
const next = viewGame(g, f.target).movementPromisePolicy?.[0];
|
||||
assert.deepEqual(next, { type: 'card', card: f.hajr });
|
||||
g = advance(act(g, f.target, next!), clean);
|
||||
g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
g = act(advance(g, clean), f.target, { type: 'endMovement' });
|
||||
assert.equal(player(g, f.target).forces['carthag:11'], 6);
|
||||
assert.deepEqual(inventory(g), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced No remains binding through a genuine later Hajr movement', () => {
|
||||
const f = createTruthMovementAdvancedFixture();
|
||||
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
|
||||
g = act(g, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
g = advance(act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
|
||||
territory: 'imperial_basin', sector: 9 }), clean);
|
||||
g = advance(act(g, f.target, { type: 'card', card: f.hajr! }), clean);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6,
|
||||
territory: 'carthag', sector: 11 }));
|
||||
assert.deepEqual(g, before);
|
||||
g = act(g, f.target, { type: 'endMovement' });
|
||||
assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(g), f.originalCards);
|
||||
});
|
||||
|
||||
void test('an Advanced unused shipment prepares a larger ground group without itself fulfilling Yes', () => {
|
||||
const f = createTruthMovementAdvancedFixture();
|
||||
let g = askAdvancedMovement(f.beforeShipment, f.asker, f.truthtrance, f.target, 11);
|
||||
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
|
||||
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
|
||||
assert.equal(shipment?.type, 'ship');
|
||||
g = advance(act(g, f.target, shipment!), clean);
|
||||
assert.ok(!g.movementPromises![0].fulfilled);
|
||||
const movement = viewGame(g, f.target).movementPromisePolicy?.[0];
|
||||
assert.equal(movement?.type, 'move');
|
||||
g = act(g, f.target, movement!);
|
||||
assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
|
||||
function fremenRoute(g: Game, actor: string) {
|
||||
const owner = player(g, actor);
|
||||
const from = Object.keys(owner.forces).find(key => owner.forces[key] >= 3 && (owner.elites?.forces[key] ?? 0) >= 2);
|
||||
assert.ok(from);
|
||||
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t =>
|
||||
t.sectors.filter(sector => sector !== g.storm).map(sector => ({ territory: t.id, sector })))
|
||||
.find(to => gameDistance(g, from, `${to.territory}:${to.sector}`,
|
||||
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm) === 2);
|
||||
assert.ok(destination);
|
||||
return { type: 'move', forces: { [from]: 3 }, eliteForces: { [from]: 2 }, ...destination };
|
||||
}
|
||||
|
||||
for (const cancel of [false, true]) void test(`native Advanced typed Fremen movement ${cancel ? 'prevention releases an impossible Yes' : 'waits for allowance before fulfillment'}`, () => {
|
||||
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
|
||||
const move = fremenRoute(f.game, f.target);
|
||||
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 3, move.territory);
|
||||
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(player(g, f.target));
|
||||
g = act(g, f.target, move);
|
||||
assert.equal(g.response?.kind, 'fremenMovement');
|
||||
assert.equal(g.movementPromises![0].fulfilled, undefined);
|
||||
assert.equal(g.movementPromises![0].released, undefined);
|
||||
assert.deepEqual(player(g, f.target).forces, before.forces);
|
||||
if (cancel) {
|
||||
assert.ok(f.karama);
|
||||
g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.equal(g.movementPromises![0].released, true);
|
||||
assert.deepEqual(player(g, f.target).forces, before.forces);
|
||||
assert.equal(player(g, f.target).moved, 0);
|
||||
} else {
|
||||
g = advance(g, clean);
|
||||
assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
assert.equal(player(g, f.target).elites!.forces[`${move.territory}:${move.sector}`], 2);
|
||||
assert.equal(player(g, f.target).forces[`${move.territory}:${move.sector}`], 3);
|
||||
}
|
||||
assert.deepEqual(inventory(g), f.originalCards);
|
||||
});
|
||||
|
||||
void test('elite combat strength cannot substitute for eleven physical forces in one movement', () => {
|
||||
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
|
||||
const move = fremenRoute(f.game, f.target);
|
||||
const g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 11, move.territory);
|
||||
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
|
||||
});
|
||||
|
||||
for (const cancel of [false, true]) void test(`controlled original Truthtrance plus native BG matching ${cancel ? 'prevention retains a legal replacement' : 'allowance fulfills only at transfer'}`, () => {
|
||||
const f = createAdvisorMatchingMoveFixture();
|
||||
// Only this card acquisition is controlled; setup, original advisors/fighter,
|
||||
// paid shipment, Karama and matching declaration come from the native source.
|
||||
const source = structuredClone(f.game), at = source.deck.findIndex(c => c.effect === 'truthtrance');
|
||||
assert.ok(at >= 0); const truth = source.deck.splice(at, 1)[0];
|
||||
player(source, f.emperor).hand.push(truth);
|
||||
let g = askAdvancedMovement(source, f.emperor, truth.id, f.bg, 1, f.destination);
|
||||
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
|
||||
const forces = structuredClone(player(g, f.bg).forces);
|
||||
g = act(g, f.bg, f.action);
|
||||
assert.ok(g.response?.advisorMatchingSource);
|
||||
assert.deepEqual(player(g, f.bg).forces, forces);
|
||||
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
||||
if (cancel) {
|
||||
g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
||||
g = act(g, f.bg, f.ordinaryAction);
|
||||
} else g = advance(g, clean);
|
||||
assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
|
||||
function controlledTruth(game: Game, recipient: string) {
|
||||
const at = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(at >= 0);
|
||||
const card = game.deck.splice(at, 1)[0]; player(game, recipient).hand.push(card); return card.id;
|
||||
}
|
||||
const routeDistance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
|
||||
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
|
||||
|
||||
for (const cancel of [false, true]) void test(`native pre-first-shipment advisor preparation ${cancel ? 'prevention releases the blocked route' : 'prepares the promised city-range move'}`, () => {
|
||||
const f = createAdvisorMatchingMoveFixture();
|
||||
let g = act(f.beforeShipment, f.bg, { type: 'bribe', target: f.emperor,
|
||||
amount: player(f.beforeShipment, f.bg).spice });
|
||||
assert.equal(g.advisorPreparation?.firstShipment, null, 'A real bribe does not close preparation');
|
||||
const truth = controlledTruth(g, f.emperor);
|
||||
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
|
||||
.find(to => to.sector !== g.storm && routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) === 3);
|
||||
assert.ok(to);
|
||||
g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
|
||||
assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
|
||||
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
|
||||
const preparation = viewGame(g, f.bg).movementPromisePolicy?.[0];
|
||||
assert.deepEqual(preparation, { type: 'prepareAdvisors', territory: 'arrakeen' });
|
||||
g = act(g, f.bg, preparation!);
|
||||
assert.ok(g.response?.advisorPreparationSource);
|
||||
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
||||
if (cancel) {
|
||||
g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.equal(g.movementPromises![0].released, true);
|
||||
} else {
|
||||
g = advance(g, clean);
|
||||
const movement = viewGame(g, f.bg).movementPromisePolicy?.[0]; assert.equal(movement?.type, 'move');
|
||||
g = act(g, f.bg, movement!); assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
}
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('controlled advisor arrival flip supplies city range for the subsequent original Hajr', () => {
|
||||
const f = createAdvisorMatchingMoveFixture();
|
||||
let g = structuredClone(f.game);
|
||||
const bg = player(g, f.bg), emperor = player(g, f.emperor);
|
||||
// Controlled stance, two real reserve counters and original card custody
|
||||
// isolate this two-move search boundary; not an unstaged native source.
|
||||
bg.advisors!.imperial_basin = {};
|
||||
emperor.reserves -= 2; emperor.forces['carthag:11'] = 1; emperor.forces['imperial_basin:9'] = 1;
|
||||
const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
|
||||
const hajr = g.deck.splice(at, 1)[0]; bg.hand.push(hajr);
|
||||
const truth = controlledTruth(g, f.emperor);
|
||||
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
|
||||
.find(to => to.sector !== g.storm && routeDistance(g, 'carthag:11', `${to.territory}:${to.sector}`) === 3 &&
|
||||
routeDistance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) > 2 &&
|
||||
routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) > 1);
|
||||
assert.ok(to);
|
||||
g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
|
||||
assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
|
||||
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
|
||||
const first = viewGame(g, f.bg).movementPromisePolicy?.[0];
|
||||
assert.equal(first?.type, 'move'); assert.equal(first.territory, 'carthag'); assert.equal(first.fighters, true);
|
||||
g = act(g, f.bg, first);
|
||||
assert.equal(g.response?.kind, 'advisorFlip');
|
||||
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
||||
g = advance(g, clean);
|
||||
assert.deepEqual(viewGame(g, f.bg).movementPromisePolicy?.[0], { type: 'card', card: hajr.id });
|
||||
g = advance(act(g, f.bg, { type: 'card', card: hajr.id }), clean);
|
||||
g = act(g, f.bg, viewGame(g, f.bg).movementPromisePolicy![0]);
|
||||
assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all four existing policies answer and complete the genuine Advanced movement question legally', () => {
|
||||
const f = createTruthMovementAdvancedFixture();
|
||||
const question = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let g = question, count = 0;
|
||||
while (g.phase === 5 && g.active === f.target && count++ < 20) {
|
||||
if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
|
||||
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action);
|
||||
g = act(g, f.target, action);
|
||||
}
|
||||
assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user