Enforce Basic Truthtrance ground-movement promises
Add a structured future single-movement predicate and persistent authoring, feasible-answer and promise controls for bare native Basic active post-shipment turns. Yes requires one matching physical movement while possible; No survives every later Hajr/Ornithopter opportunity until actual turn closure. Matching physical commitment precedes disposal and arrival effects. Preserve compatible promises after opposing loss and reject voluntary destruction of the last owned completion. Reuse the actual movement declaration and physical commitment in a focused owned-action search rather than a strategic shortlist. Retain typed groups, original two-group cohorts, held Hajr, both Ornithopter modes, city access and current Kulon bonus. Deterministic preparation on private clones retains physical cost semantics without inventing response/discard events in reachability. Existing native/optional/Advanced movement retains its actual controls. Verify 17-file218/218, types/lint/build, seven original-action gameplay/control/table-rendering programmes and all four existing legal profiles. Original public classic, paid cache Ornithopter and native allowed-Kulon sources use only unopened positions and scalar lotteries; extra-card/force-loss edges are explicitly controlled. Repair nearby stale fixture continuations to acknowledge Semuta/battle outcomes and put pre-movement Hajr in Basic. Full modes, wider preparation/timing/module coverage, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
This commit is contained in:
1 parent
b4a556d5d3
commit
edbcbe28de
23 files changed
+1471
-218
No files matched your search
@@ -107,9 +107,11 @@ function originalPacket(effect: BasicSemutaAmbassadorEffect, copied: boolean) {
|
||||
* grant BEFORE any discard, payout, replacement draw or Semuta commitment. */
|
||||
export function createBasicSemutaAmbassadorFixture(options: {
|
||||
effect: BasicSemutaAmbassadorEffect; copied?: boolean; arrival?: 'shipment' | 'movement';
|
||||
cacheCard?: 'semuta' | 'ornithopter'; ecazTruth?: boolean;
|
||||
}) {
|
||||
const effect = options.effect, copied = options.copied ?? false, packet = originalPacket(effect, copied);
|
||||
const arrivalKind = options.arrival ?? 'shipment';
|
||||
const cacheCardId = options.cacheCard === 'ornithopter' ? 'richese-ornithopter' : SEMUTA_DRUG_ID;
|
||||
let game = createGame('BASICSEMUTAAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
for (const faction of ['choam', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
@@ -131,7 +133,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache?.length, 10);
|
||||
assert.ok(game.richeseCache!.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(game.richeseCache!.some(card => card.id === cacheCardId));
|
||||
const originalMainDeck = game.deck.map(card => card.id).sort();
|
||||
const originalCache = game.richeseCache!.map(card => card.id).sort();
|
||||
const originalTreachery = basicSemutaAmbassadorInventory(game);
|
||||
@@ -139,6 +141,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
// Ordinary cards avoid optional card-use controls during the source path.
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 5);
|
||||
assert.equal(ordinary.length, 5);
|
||||
if (options.ecazTruth) ordinary[0] = game.deck.find(card => card.effect === 'truthtrance')!;
|
||||
const deal = ordinary.map(card => card.id);
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
@@ -177,13 +180,13 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
const beforeCacheOffer = structuredClone(game), acquisitionStart = actions.length;
|
||||
const cacheOffer: BasicSemutaAmbassadorStep = { actor: 'richese', action: { type: 'decision',
|
||||
event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
event: game.richeseBidding!.event, card: cacheCardId, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
act(cacheOffer.actor, cacheOffer.action);
|
||||
reach(state => basicSemutaAmbassadorClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
const cacheAuction = structuredClone(game);
|
||||
const cacheBidSteps: BasicSemutaAmbassadorStep[] = [];
|
||||
let semutaBid: BasicSemutaAmbassadorTransition | null = null;
|
||||
for (let count = 0; !basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
|
||||
for (let count = 0; !basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === cacheCardId) && count < 8; count++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && !lot.outcome && lot.active);
|
||||
const bid: BasicSemutaAmbassadorStep = { actor: lot.active, action: { type: 'richeseBid', event: lot.event,
|
||||
amount: lot.active === holder ? 1 : null, allyPayment: 0 } };
|
||||
@@ -191,7 +194,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
const result = transition(bid, state => basicSemutaAmbassadorClean(state));
|
||||
if (bid.actor === holder) semutaBid = result;
|
||||
}
|
||||
assert.ok(semutaBid); assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaBid); assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === cacheCardId));
|
||||
assert.equal(game.richeseCache!.length, 9);
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game, holder).spice,
|
||||
basicSemutaAmbassadorPlayer(beforeCacheOffer, holder).spice - 1);
|
||||
@@ -239,7 +242,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
assert.equal(game.pendingAmbassador!.beneficiary, beneficiary); assert.equal(game.pendingAmbassador!.entrant, entrant);
|
||||
assert.equal(game.decision?.player, beneficiary); assert.equal(game.active, entrant);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === cacheCardId));
|
||||
assert.deepEqual(basicSemutaAmbassadorInventory(game), originalTreachery);
|
||||
const currentGrant = structuredClone(game), eligibleChosenIds = basicSemutaAmbassadorPlayer(game, beneficiary).hand
|
||||
.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
|
||||
@@ -251,11 +254,11 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
cacheAuction, cacheBidSteps, semutaBid, acquisition, normalPurchase, placement, beforeArrival, shipment, arrival, arrivalKind, offer,
|
||||
triggerStep, beforeCopy, copyStep, currentGrant, ownerView: viewGame(game, owner), holderView: viewGame(game, holder),
|
||||
entrantView: viewGame(game, entrant), effect, copied, advanced: false as const,
|
||||
owner, beneficiary, holder, entrant, token: token.id, semuta: SEMUTA_DRUG_ID, event, territory, sector,
|
||||
location: `${territory}:${sector}`, eligibleChosenIds, chosenCards: eligibleChosenIds, discardStep,
|
||||
owner, beneficiary, holder, entrant, token: token.id, semuta: cacheCardId, event, territory, sector,
|
||||
location: `${territory}:${sector}`, cacheCard: cacheCardId, eligibleChosenIds, chosenCards: eligibleChosenIds, discardStep,
|
||||
preReplacementDeckTop, balances: basicSemutaAmbassadorBalances(game),
|
||||
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as BasicSemutaAmbassadorStep,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: ['Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot.'] };
|
||||
staging: [`Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot. Optional source selection buys ${cacheCardId} instead and deals original Truthtrance to Ecaz=${!!options.ecazTruth}.`] };
|
||||
}
|
||||
export type BasicSemutaAmbassadorFixture = ReturnType<typeof createBasicSemutaAmbassadorFixture>;
|
||||
@@ -76,7 +76,18 @@ const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `
|
||||
* a human must seal a plan; the helper never reads or invents that private plan. */
|
||||
export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||
if (game.status === 'finished') return null;
|
||||
if (game.pendingTreacheryDiscard) return { actor: game.host, action: { type: 'advanceBots' } };
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage === 'offer') {
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
}
|
||||
if (reaction?.stage === 'select') {
|
||||
const action = botActions(viewGame(game, reaction.player))[0]; assert.ok(action);
|
||||
return { actor: reaction.player, action };
|
||||
}
|
||||
return { actor: game.host, action: { type: 'advanceBots' } };
|
||||
}
|
||||
if (game.phaseOpening) {
|
||||
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))?.id;
|
||||
assert.ok(actor);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { actTruthMovement as apply, truthMovementClean as clean, truthMovementInventory as inventory,
|
||||
truthMovementPlayer as player, type TruthMovementStep } from './fixture-truth-movement';
|
||||
|
||||
/** Genuine public native Basic CHOAM, actual dealt Kulon/Truthtrance, actual
|
||||
* paid Arrakeen shipment and native allowed Kulon. No movement state is staged. */
|
||||
export function createTruthMovementChoamFixture() {
|
||||
let game = createGame('TRUTHMOVEMENTCHOAM', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam']);
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
const actions: TruthMovementStep[] = [];
|
||||
const act = (actor: string, action: TruthMovementStep['action']) => { game = apply(game, actor, action, actions); };
|
||||
const reach = (until: (game: Game) => boolean) => {
|
||||
for (let count = 0; !until(game) && count < 500; count++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); act(next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game));
|
||||
};
|
||||
for (const p of game.players) act(p.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), originalCards = inventory(game);
|
||||
assert.ok(game.players.every(p => !p.hand.length));
|
||||
const kulon = game.deck.find(card => card.name === 'Kulon')!, truth = game.deck.find(card => card.effect === 'truthtrance')!;
|
||||
assert.ok(kulon && truth);
|
||||
const dealOrder = [kulon, truth].map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
reach(state => state.status === 'playing');
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === kulon.id));
|
||||
assert.ok(player(game, 'emperor').hand.some(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 => clean(state) && state.phase === 5 && state.active === 'choam');
|
||||
const beforeShipment = structuredClone(game);
|
||||
act('choam', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }); reach(clean);
|
||||
assert.equal(player(game, 'choam').spice, player(beforeShipment, 'choam').spice - 1);
|
||||
const beforeKulon = structuredClone(game); act('choam', { type: 'card', mode: 'choam', card: kulon.id });
|
||||
assert.ok(game.response?.kind === 'choamWorthless' || game.choamMovement?.bonus === 1); reach(clean);
|
||||
assert.equal(game.choamMovement?.bonus, 1); assert.equal(player(game, 'choam').forces['arrakeen:10'], 1);
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
return { game, lobby, setup, beforeShipment, beforeKulon, actions, sourceActionCount: actions.length,
|
||||
originalCards, dealOrder, spiceOrder, target: 'choam', asker: 'emperor', truth: truth.id, kulon: kulon.id,
|
||||
staging: ['Original unopened two-card deal and two ordinary Spice positions plus native scalar lotteries only. Public Basic CHOAM/Emperor setup, actual one-spice Arrakeen shipment and actual allowed/discarded native Kulon. No hand, wallet, force, clock, allowance, bonus or promise is installed.'] };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type TruthMovementStep = { actor: string; action: Action };
|
||||
export type TruthMovementTransition = { before: Game; step: TruthMovementStep; after: Game };
|
||||
export const truthMovementPlayer = (game: Game, actor = 'atreides') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const truthMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function truthMovementInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original physical card has one current location');
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Select original scalar lotteries only. UUID/byte entropy remains native. */
|
||||
function withOriginalScalarLottery<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actTruthMovement(game: Game, actor: string, action: Action,
|
||||
actions: TruthMovementStep[] = []): Game {
|
||||
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
|
||||
/** Native windows only. The caller owns Truthtrance and the shipped target's
|
||||
* actual movement choices; this helper cannot silently finish those promises. */
|
||||
export function nextTruthMovementNativeStep(game: Game): TruthMovementStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (game.phase === 5 && game.active === 'atreides' &&
|
||||
truthMovementPlayer(game).shipped && truthMovementClean(game)) return null;
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthMovement(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMovementStep[] = []): Game {
|
||||
for (let count = 0; count < 800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Basic programme ended before its next control');
|
||||
const next = nextTruthMovementNativeStep(game);
|
||||
assert.ok(next, `Supply the original Truthtrance or movement choice: ${JSON.stringify({
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
||||
truth: game.truthtrance?.stage, decision: game.decision,
|
||||
})}`);
|
||||
game = actTruthMovement(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Basic Truthtrance movement source did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Genuine public Basic source with no installed hand, wallet, force, clock or
|
||||
* pending receipt. Choose only unopened starting-deal/Spice positions and native
|
||||
* scalar lotteries. The target's optional Hajr is its actually dealt card. */
|
||||
export function createTruthMovementFixture(options: { hajr?: boolean } = {}) {
|
||||
const asker = 'emperor', target = 'atreides', withHajr = options.hajr ?? true;
|
||||
let game = createGame('TRUTHMOVEMENTSOURCE', newPlayer(target, 'Atreides', 'atreides'), 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) => { game = advanceTruthMovement(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.deepEqual(game.expansions, []); assert.equal(game.deck.length, 33);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalCards = truthMovementInventory(game);
|
||||
const targetCard = game.deck.find(card => withHajr ? card.effect === 'hajr' : card.kind === 'projectile');
|
||||
const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
|
||||
assert.ok(targetCard && truthtrance); assert.notEqual(targetCard.id, truthtrance.id);
|
||||
const deal = [targetCard.id, truthtrance.id];
|
||||
const dealOrder = deal.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), [targetCard.id]);
|
||||
assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
|
||||
assert.deepEqual(truthMovementPlayer(game, target).forces, { 'arrakeen:10': 10 });
|
||||
assert.equal(truthMovementPlayer(game, target).reserves, 10);
|
||||
const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
|
||||
forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
|
||||
}]));
|
||||
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);
|
||||
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: 0, allyPayment: 0,
|
||||
} };
|
||||
act(shipmentStep.actor, shipmentStep.action);
|
||||
reach(truthMovementClean);
|
||||
const shipment: TruthMovementTransition = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) };
|
||||
const targetNow = truthMovementPlayer(game, target);
|
||||
assert.equal(game.active, target); assert.equal(game.phase, 5); assert.equal(game.turn, 1);
|
||||
assert.equal(targetNow.shipped, true); assert.equal(targetNow.moved, 0);
|
||||
assert.deepEqual(targetNow.forces, { 'arrakeen:10': 10, 'polar_sink:0': 1 });
|
||||
assert.equal(targetNow.reserves, 9);
|
||||
assert.equal(targetNow.spice, truthMovementPlayer(beforeShipment, target).spice - 2);
|
||||
assert.deepEqual(truthMovementInventory(game), originalCards);
|
||||
const source = structuredClone(game);
|
||||
const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
|
||||
return {
|
||||
game, source, lobby, setup, beforeStartingDeal, afterSetup, beforeShipment, shipment, shipmentStep,
|
||||
asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
|
||||
hajr: withHajr ? targetCard.id : null, targetCard: targetCard.id,
|
||||
deal, dealOrder, spiceOrder, originalCards, originalSpice, startingPositions,
|
||||
truthStep, actions, sourceActionCount: actions.length,
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
staging: [
|
||||
'Only two unopened original starting-deal positions, two unopened ordinary land Spice positions and native scalar lotteries are selected. Public Basic Atreides/Emperor setup retains the original 33-card deck. Emperor receives its original dealt Truthtrance; Atreides receives original dealt Hajr (or an ordinary projectile when explicitly requested). Real readiness, setup, Storm/Spice/Charity/Bidding/Revival precede a real two-spice one-force reserve shipment to Polar Sink. The original ten Arrakeen forces, native balances, real bought/dealt cards and all allowances remain. No hand, wallet, force, clock, pending decision, movement promise or receipt is installed. The source stops before any Truthtrance declaration or actual ground movement.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementFixture = ReturnType<typeof createTruthMovementFixture>;
|
||||
@@ -150,8 +150,8 @@ void test('no-card and owner-only Karama tables apply Fremen movement automatica
|
||||
}
|
||||
});
|
||||
|
||||
void test('Karama preserves forces and movement allowance, while only the current Fremen group move loses range two', () => {
|
||||
const initial = fixture(true);
|
||||
void test('Basic pre-movement Hajr preserves forces and allowance when Karama reduces only the current Fremen group range', () => {
|
||||
const initial = fixture();
|
||||
initial.players[1].hand = [karamas[0]];
|
||||
initial.players[0].hand = [baseDeck().find((c) => c.effect === 'hajr')!];
|
||||
const hajr = applyAction(initial, 'f', {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MovementClaimError, parseMovementClaim, validMovementClaim, movementClaimText,
|
||||
movementMatches, liveMovementPromises, movementEventAllowed,
|
||||
settleMovementEvent, finishMovementPromises, movementPromiseTargetAvailable,
|
||||
type MovementPromise,
|
||||
} from '../game/movement-promises';
|
||||
import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
|
||||
const claim = { territory: 'carthag', minimum: 6 };
|
||||
const promise = (overrides: Partial<MovementPromise> = {}): MovementPromise => ({
|
||||
...claim, turn: 2, player: 'target', asker: 'asker', answer: true, ...overrides,
|
||||
});
|
||||
|
||||
void test('movement claims validate printed destinations and safe physical minima while extracting only claim fields', () => {
|
||||
assert.deepEqual(parseMovementClaim({ kind: 'movement', target: 'target', ...claim }), claim);
|
||||
assert.equal(validMovementClaim(promise()), true);
|
||||
assert.equal(movementClaimText(claim), 'make a ground movement of at least 6 physical forces to Carthag');
|
||||
for (const minimum of [0, -1, 1.5, 21, Infinity, NaN, '6', null, undefined])
|
||||
assert.throws(() => parseMovementClaim({ ...claim, minimum }), MovementClaimError);
|
||||
for (const territory of ['', 'homeworld:caladan', 'mobile_stronghold', 'missing', null])
|
||||
assert.equal(validMovementClaim({ ...claim, territory }), false);
|
||||
for (const value of [null, [], 'carthag', 6])
|
||||
assert.throws(() => parseMovementClaim(value), MovementClaimError);
|
||||
assert.deepEqual(parseMovementClaim({ territory: 'polar_sink', minimum: 1 }), { territory: 'polar_sink', minimum: 1 });
|
||||
assert.equal(validMovementClaim({ ...claim, minimum: 20 }), true);
|
||||
});
|
||||
|
||||
void test('a movement promise needs one matching event rather than an aggregate of smaller arrivals', () => {
|
||||
let ledger = [promise()];
|
||||
const original = structuredClone(ledger);
|
||||
for (const amount of [2, 4, 5]) {
|
||||
ledger = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount });
|
||||
assert.equal(ledger[0].fulfilled, undefined);
|
||||
}
|
||||
assert.deepEqual(ledger, original);
|
||||
assert.equal(movementMatches(claim, null), false);
|
||||
assert.equal(movementMatches(claim, { territory: 'arrakeen', amount: 20 }), false);
|
||||
assert.equal(movementMatches(claim, { territory: 'carthag', amount: Infinity }), false);
|
||||
assert.equal(movementMatches(claim, { territory: 'carthag', amount: 6 }), true);
|
||||
const fulfilled = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount: 7 });
|
||||
assert.equal(fulfilled[0].fulfilled, true);
|
||||
assert.deepEqual(ledger, original);
|
||||
});
|
||||
|
||||
void test('No survives a nonmatching first move and still rejects a later Hajr or Ornithopter matching event', () => {
|
||||
const original = [promise({ answer: false })];
|
||||
const afterFirst = settleMovementEvent(original, 'target', 2, { territory: 'arrakeen', amount: 8 });
|
||||
assert.equal(afterFirst[0].fulfilled, undefined);
|
||||
assert.equal(movementEventAllowed(afterFirst, { territory: 'carthag', amount: 6 }), false);
|
||||
assert.throws(() => settleMovementEvent(afterFirst, 'target', 2, { territory: 'carthag', amount: 6 }),
|
||||
/accepted No/);
|
||||
assert.deepEqual(afterFirst, original);
|
||||
assert.equal(movementEventAllowed(afterFirst, { territory: 'carthag', amount: 5 }), true);
|
||||
const finished = finishMovementPromises(afterFirst, 'target', 2);
|
||||
assert.equal(finished[0].fulfilled, true); assert.equal(original[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('a fulfilled Yes remains satisfied through later moves and actual turn completion', () => {
|
||||
const original = [promise()];
|
||||
assert.throws(() => finishMovementPromises(original, 'target', 2), /before ending movement/);
|
||||
const fulfilled = settleMovementEvent(original, 'target', 2, { territory: 'carthag', amount: 6 });
|
||||
const afterLater = settleMovementEvent(fulfilled, 'target', 2, { territory: 'arrakeen', amount: 1 });
|
||||
assert.equal(afterLater[0].fulfilled, true);
|
||||
assert.deepEqual(finishMovementPromises(afterLater, 'target', 2), afterLater);
|
||||
assert.equal(original[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('settlement and closing affect only live current-player promises and preserve their metadata', () => {
|
||||
const ledger = [
|
||||
{ ...promise(), note: { source: 'original' } },
|
||||
{ ...promise({ player: 'other', answer: false }), note: { source: 'other' } },
|
||||
{ ...promise({ turn: 1, answer: false }), note: { source: 'old' } },
|
||||
{ ...promise({ released: true }), note: { source: 'released' } },
|
||||
{ ...promise({ fulfilled: true }), note: { source: 'fulfilled' } },
|
||||
];
|
||||
const before = structuredClone(ledger);
|
||||
assert.deepEqual(liveMovementPromises(ledger, 'target', 2), [ledger[0]]);
|
||||
const result = settleMovementEvent(ledger, 'target', 2, { territory: 'carthag', amount: 6 });
|
||||
assert.equal(result[0].fulfilled, true); assert.deepEqual(result[0].note, ledger[0].note);
|
||||
assert.deepEqual(result.slice(1), ledger.slice(1)); assert.deepEqual(ledger, before);
|
||||
assert.deepEqual(finishMovementPromises(result, 'target', 2), result);
|
||||
assert.deepEqual(settleMovementEvent(ledger, 'target', 3, { territory: 'carthag', amount: 6 }), ledger);
|
||||
assert.equal(movementEventAllowed([promise({ answer: false, fulfilled: true }), promise({ answer: false, released: true })],
|
||||
{ territory: 'carthag', amount: 6 }), true);
|
||||
});
|
||||
|
||||
function movementBoundary(): Game {
|
||||
const g = createGame('MOVEMENTPROMISEBOUNDARY', newPlayer('target', 'Target', 'atreides'), false);
|
||||
joinGame(g, newPlayer('asker', 'Asker', 'emperor'));
|
||||
g.status = 'playing'; g.phase = 5; g.turn = 2; g.active = 'target';
|
||||
g.movementRemaining = ['target', 'asker']; g.players[0].shipped = true;
|
||||
return g;
|
||||
}
|
||||
void test('the Basic profile permits a clean active post-shipment respondent with Truthtrance or ordinary flight open', () => {
|
||||
const g = movementBoundary(); assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.truthtrance = { stage: 'ask', queue: [], passed: [], question: null };
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.ornithopter = { event: 'flight', player: 'target', turn: 2, mode: 'twoGroups', startingMove: 0, completed: 0,
|
||||
card: { id: 'richese-ornithopter', name: 'Ornithopter', kind: 'special', effect: 'ornithopter' } };
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
|
||||
g.players[0].shipped = false; g.players[0].moved = 1;
|
||||
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', () => {
|
||||
const unused = movementBoundary(); unused.players[0].shipped = false;
|
||||
assert.equal(movementPromiseTargetAvailable(unused, 'target'), false);
|
||||
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: [] };
|
||||
assert.equal(movementPromiseTargetAvailable(response, 'target'), false);
|
||||
const advanced = movementBoundary(); advanced.advanced = true;
|
||||
assert.equal(movementPromiseTargetAvailable(advanced, 'target'), false);
|
||||
});
|
||||
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'];
|
||||
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
|
||||
const missingFamily = structuredClone(native); missingFamily.expansions = [];
|
||||
assert.equal(movementPromiseTargetAvailable(missingFamily, 'target'), false);
|
||||
for (const key of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
|
||||
const configured = { ...native, [key]: true } as unknown as Game;
|
||||
assert.equal(movementPromiseTargetAvailable(configured, 'target'), false, key);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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 { MovementClaimFields } from '../components/movement-claim-fields';
|
||||
import { Truthtrance } from '../components/truthtrance';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { createTruthMovementFixture, actTruthMovement as act } from './fixture-truth-movement';
|
||||
|
||||
function question() {
|
||||
const f = createTruthMovementFixture(); let game = act(f.game, f.asker, f.truthStep.action);
|
||||
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: { kind: 'movement', target: f.target, territory: 'carthag', minimum: 6 } });
|
||||
return { f, game };
|
||||
}
|
||||
function clickables(node: unknown): { children?: unknown; disabled?: boolean; onClick: () => void }[] {
|
||||
if (Array.isArray(node)) return node.flatMap(clickables);
|
||||
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 }] : []), ...clickables(props.children)];
|
||||
}
|
||||
void test('the actual movement question shows truthful feasible answers and its continuing No semantics', () => {
|
||||
const { f, game } = question(), view = viewGame(game, f.target);
|
||||
const html = renderToStaticMarkup(createElement(Truthtrance, { game: view, act() {}, busy: false }));
|
||||
assert.match(html, /before your Shipment and Movement turn ends/);
|
||||
assert.match(html, /No forbids every matching move through later Hajr or Ornithopter/);
|
||||
assert.match(html, />Answer Yes<\/button>/); assert.match(html, />Answer No<\/button>/);
|
||||
assert.doesNotMatch(html, />I don’t know yet<\/button>/);
|
||||
assert.equal(viewGame(game, f.asker).truthMovementAnswers, null);
|
||||
});
|
||||
void test('persistent promise controls submit the actual private witness and respect busy state', () => {
|
||||
const { f, game: asked } = question();
|
||||
const game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const view = viewGame(game, f.target), actions: Action[] = [];
|
||||
const props = { game: view, act: (action: Action) => { actions.push(action); }, busy: false };
|
||||
const html = renderToStaticMarkup(createElement(MovementPromises, props));
|
||||
assert.match(html, /Your ground-movement promises/); assert.match(html, /Move 6 forces to Carthag/);
|
||||
clickables(MovementPromises(props))[0].onClick();
|
||||
assert.deepEqual(actions, view.movementPromisePolicy);
|
||||
const next = act(game, f.target, actions[0]); assert.equal(next.movementPromises![0].fulfilled, true);
|
||||
clickables(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(actions.length, 1);
|
||||
assert.equal(MovementPromises({ ...props, game: viewGame(game, f.asker) }), null);
|
||||
assert.equal(viewGame(game, f.asker).movementPromisePolicy, null);
|
||||
});
|
||||
void test('movement authoring fields describe ground forces and reject invalid physical minima', () => {
|
||||
const html = renderToStaticMarkup(createElement(MovementClaimFields, {
|
||||
id: 'movement', value: { territory: 'carthag', minimum: 0 }, onChange() {},
|
||||
}));
|
||||
assert.match(html, /Ground-movement destination/); assert.match(html, /Minimum physical forces in one ground move/);
|
||||
assert.match(html, /aria-invalid="true"/); assert.doesNotMatch(html, /from reserves|shipment conditions/);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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 { createTruthMovementChoamFixture } from './fixture-truth-movement-choam';
|
||||
import { createBasicSemutaAmbassadorFixture } from './fixture-basic-semuta-ambassador';
|
||||
import { viewGame, normalizeAutomaticGame, type Game } from '../game/engine';
|
||||
import { createTruthMovementFixture, actTruthMovement as act, advanceTruthMovement as advance,
|
||||
truthMovementPlayer as player, truthMovementInventory as inventory, truthMovementClean as clean } from './fixture-truth-movement';
|
||||
|
||||
function ask(game: Game, asker: string, card: string, target: string, minimum = 6, territory = 'carthag') {
|
||||
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: { kind: 'movement', target, territory, minimum } });
|
||||
}
|
||||
void test('an original public Basic movement Yes binds a real six-force move before turn end', () => {
|
||||
const f = createTruthMovementFixture(), source = structuredClone(f.game);
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.movementPromises?.length, 1);
|
||||
assert.throws(() => act(game, f.target, { type: 'endMovement' }));
|
||||
const guide = viewGame(game, f.target).movementPromisePolicy;
|
||||
assert.ok(guide?.length);
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = advance(game, clean);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.ok(!game.movementRemaining?.includes(f.target));
|
||||
assert.equal(player(game).forces['carthag:11'], 6);
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(f.game, source);
|
||||
});
|
||||
|
||||
void test('a nonmatching first move keeps Yes live until an owned Hajr completion', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 4, territory: 'imperial_basin', sector: 9 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
const next = viewGame(game, f.target).movementPromisePolicy?.[0];
|
||||
assert.deepEqual(next, { type: 'card', card: f.hajr });
|
||||
game = act(game, f.target, next!); game = advance(game, clean);
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('No survives a nonmatching first move and forbids a later matching Hajr movement', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 4, territory: 'imperial_basin', sector: 9 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = act(game, f.target, { type: 'card', card: f.hajr! }); game = advance(game, clean);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 }));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a voluntary nonmatching last movement cannot evade Yes when no Hajr is held', () => {
|
||||
const f = createTruthMovementFixture({ hajr: false });
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 10, territory: 'imperial_basin', sector: 9 }));
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('controlled second physical Truthtrance binds compatible Yes/No thresholds without aggregating groups', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
// Controlled acquisition of the other original card; the first question,
|
||||
// original army, wallets and all movement allowances remain genuine.
|
||||
const second = game.deck.splice(game.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0];
|
||||
assert.ok(second); player(game, f.asker).hand.push(second);
|
||||
game = ask(game, f.asker, second.id, f.target, 7);
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.equal(game.movementPromises![1].fulfilled, undefined);
|
||||
game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.ok(game.movementPromises!.every(promise => promise.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a controlled impossible opposing outcome releases Yes without replaying or altering the original card cost', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
// Explicit outcome staging, not a claim of an unstaged Atomics/Terror source.
|
||||
const harmed = structuredClone(game), owner = player(harmed);
|
||||
owner.tanks += owner.forces['arrakeen:10']; delete owner.forces['arrakeen:10'];
|
||||
game = normalizeAutomaticGame(harmed);
|
||||
assert.equal(game.movementPromises![0].released, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four existing profiles answer and fulfill the actual Basic movement commitment legally', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
const question = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = question, actions = 0;
|
||||
while (game.active === f.target && game.phase === 5 && actions++ < 20) {
|
||||
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
if (game.response || game.phaseOpening || game.decision) { game = advance(game, clean); continue; }
|
||||
const action = botActions(view)[0]; assert.ok(action); game = act(game, f.target, action);
|
||||
}
|
||||
assert.ok(actions < 20); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
function passSemuta(game: Game): Game {
|
||||
if (!game.pendingTreacheryDiscard?.reaction) return game;
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
function ornithopterSource() {
|
||||
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam', cacheCard: 'ornithopter', ecazTruth: true });
|
||||
const game = act(f.game, f.beneficiary, { type: 'decision', event: f.event, cards: [] });
|
||||
const truth = player(game, f.owner).hand.find(card => card.effect === 'truthtrance')!;
|
||||
assert.ok(truth); return { ...f, game, asker: f.owner, target: f.holder, truth: truth.id };
|
||||
}
|
||||
for (const answer of ['yes', 'no'] as const) void test(`actual bought two-group Ornithopter retains ${answer} after its first nonmatching group`, () => {
|
||||
const f = ornithopterSource();
|
||||
let game = ask(f.game, f.asker, f.truth, f.target, 1, 'habbanya_ridge_flat');
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
|
||||
game = passSemuta(game);
|
||||
game = act(game, f.target, { type: 'move', from: 'tueks_sietch:5', amount: 1,
|
||||
territory: 'south_mesa', sector: 5, movementCard: f.cacheCard, ornithopter: 'twoGroups' });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
const event = game.ornithopter!.event;
|
||||
const second = { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
||||
territory: 'habbanya_ridge_flat', sector: 17, ornithopterEvent: event };
|
||||
if (answer === 'yes') {
|
||||
game = act(game, f.target, second);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true, 'Physical commitment precedes final card disposal');
|
||||
// Native Ecaz retains its existing final-move Semuta boundary; the
|
||||
// physical card still retires once before the ordinary entry resumes.
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
} else {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, f.target, second)); assert.deepEqual(game, before);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined, 'No remains live until the original ending continuation');
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
game = passSemuta(game);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === f.cacheCard).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('a real bought range-three Ornithopter supplies the authoritative Yes completion', () => {
|
||||
const f = ornithopterSource();
|
||||
let game = ask(f.game, f.asker, f.truth, f.target, 1, 'the_greater_flat');
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
|
||||
game = passSemuta(game);
|
||||
const next = viewGame(game, f.target).movementPromisePolicy?.[0];
|
||||
assert.equal(next?.movementCard, f.cacheCard); assert.equal(next?.ornithopter, 'range3');
|
||||
game = act(game, f.target, next!);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = passSemuta(game); game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.deepEqual(inventory(game), f.originalTreachery);
|
||||
});
|
||||
|
||||
void test('a controlled opposing loss releases only the impossible Yes and keeps an independent smaller promise', () => {
|
||||
const f = createTruthMovementFixture();
|
||||
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const second = game.deck.splice(game.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0];
|
||||
assert.ok(second); player(game, f.asker).hand.push(second);
|
||||
game = ask(game, f.asker, second.id, f.target, 1, 'imperial_basin');
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const harmed = structuredClone(game), owner = player(harmed);
|
||||
owner.forces['arrakeen:10'] -= 9; owner.tanks += 9;
|
||||
game = normalizeAutomaticGame(harmed);
|
||||
assert.equal(game.movementPromises![0].released, true);
|
||||
assert.equal(game.movementPromises![1].released, undefined);
|
||||
assert.ok(viewGame(game, f.target).movementPromisePolicy?.length);
|
||||
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
||||
assert.equal(game.movementPromises![1].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('actual native Kulon and city access preserve a four-territory CHOAM movement promise', () => {
|
||||
const f = createTruthMovementChoamFixture();
|
||||
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
|
||||
gameDistance(f.game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => {
|
||||
const location = splitLocation(key); return location.sector !== 0 && location.sector === f.game.storm;
|
||||
}) === 4);
|
||||
assert.ok(destination);
|
||||
let game = ask(f.game, f.asker, f.truth, f.target, 1, destination.territory);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const next = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(next);
|
||||
assert.equal(next.movementCard, undefined);
|
||||
game = act(game, f.target, next);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -593,6 +593,11 @@ for (const stage of ['response', 'payment'] as const)
|
||||
});
|
||||
initial = applyAction(initial, 'p', { type: 'traitorCall', call: false });
|
||||
initial = applyAction(initial, 'q', { type: 'traitorCall', call: false });
|
||||
while (initial.battle?.outcomeWindow) {
|
||||
const window = initial.battle.outcomeWindow;
|
||||
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); assert.ok(actor);
|
||||
initial = applyAction(initial, actor, { type: 'battleOutcomeReady', event: window.event });
|
||||
}
|
||||
assert.equal(initial.decision?.kind, 'choamAudit');
|
||||
initial = applyAction(initial, 'q', {
|
||||
type: 'decision',
|
||||
|
||||
Reference in new issue
Block a user