Support native early Basic movement promises
Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation. Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation.
This commit is contained in:
1 parent
f995b6f4cd
commit
c311ed4251
16 files changed
+787
-54
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { earlyAuctionFunding, type EarlyAuctionFunding } from '../game/early-auction-funding';
|
||||
import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction';
|
||||
|
||||
const input = (): EarlyAuctionFunding => ({ phase: 3, owner: 'buyer', ally: 'ally',
|
||||
paid: false, normal: null, pledge: 8, funding: {} });
|
||||
function lot(method: 'onceAround' | 'silent') {
|
||||
return createRicheseAuction({ event: 'native-lot', cardId: 'held-cache-card',
|
||||
source: 'cache', owner: 'richese', method, eligible: ['buyer', 'ally', 'richese'],
|
||||
order: ['buyer', 'ally', 'richese'], tieOrder: ['buyer', 'ally', 'richese'] });
|
||||
}
|
||||
void test('an unpaid ordinary invoice keeps its own cash and donated escrow distinct', () => {
|
||||
assert.deepEqual(earlyAuctionFunding({ ...input(), normal: { bidder: 'buyer', bid: 7, allyPayment: 2 } }),
|
||||
{ normalCash: 5, nativeCash: 0, refund: 8 });
|
||||
assert.deepEqual(earlyAuctionFunding({ ...input(), normal: { bidder: 'ally', bid: 7, allyPayment: 3 } }),
|
||||
{ normalCash: 0, nativeCash: 0, refund: 5 });
|
||||
});
|
||||
void test('native Once Around owns only the leading exact split and releases an outbid offer', () => {
|
||||
const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' },
|
||||
ally: { amount: 8, allyPayment: 3, donor: 'buyer' } };
|
||||
let native = submitRicheseBid(lot('onceAround'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20);
|
||||
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 5, refund: 8 });
|
||||
native = submitRicheseBid(native, { event: 'native-lot', actor: 'ally', amount: 8 }, 20);
|
||||
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 0, refund: 5 });
|
||||
});
|
||||
void test('own Silent funding is exact while a submitted ally locks the whole pledge regardless of secret split', () => {
|
||||
let native = submitRicheseBid(lot('silent'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20);
|
||||
const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' },
|
||||
ally: { amount: 8, allyPayment: 1, donor: 'buyer' } };
|
||||
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 5, refund: 8 });
|
||||
native = submitRicheseBid(native, { event: 'native-lot', actor: 'ally', amount: 8 }, 20);
|
||||
for (const allyPayment of [0, 1, 5, 8]) {
|
||||
const quote = earlyAuctionFunding({ ...input(), native, funding: { ...funding,
|
||||
ally: { ...funding.ally, allyPayment } } });
|
||||
assert.deepEqual(quote, { normalCash: 0, nativeCash: 5, refund: 0 });
|
||||
}
|
||||
});
|
||||
void test('a revealed native winner reserves its actual donor leg and a losing offer spends nothing', () => {
|
||||
let native = lot('silent');
|
||||
for (const [actor, amount] of [['buyer', 7], ['ally', 8], ['richese', 0]] as const)
|
||||
native = submitRicheseBid(native, { event: native.event, actor, amount }, 20);
|
||||
const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' },
|
||||
ally: { amount: 8, allyPayment: 3, donor: 'buyer' } };
|
||||
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 0, refund: 5 });
|
||||
});
|
||||
void test('an already-paid sale and a later phase do not charge an old invoice twice', () => {
|
||||
const native = submitRicheseBid(lot('onceAround'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20);
|
||||
const state = { ...input(), native, funding: { buyer: { amount: 7, allyPayment: 0, donor: null } },
|
||||
normal: { bidder: 'buyer', bid: 4, allyPayment: 0 } };
|
||||
assert.deepEqual(earlyAuctionFunding({ ...state, paid: true }), { normalCash: 0, nativeCash: 0, refund: 8 });
|
||||
assert.deepEqual(earlyAuctionFunding({ ...state, phase: 2 }), { normalCash: 0, nativeCash: 0, refund: 8 });
|
||||
});
|
||||
void test('a malformed donated invoice cannot create a negative refund', () => {
|
||||
assert.throws(() => earlyAuctionFunding({ ...input(), pledge: 2,
|
||||
normal: { bidder: 'ally', bid: 4, allyPayment: 4 } }), RangeError);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
|
||||
type TruthMovementStep,
|
||||
} from './fixture-truth-movement';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type TruthMovementNativeEarlyStep = TruthMovementStep;
|
||||
export type TruthMovementNativeEarlyRoster = 'richese-choam' | 'ecaz-moritani' | 'ixians-tleilaxu';
|
||||
export type TruthMovementNativeEarlyAuction = {
|
||||
position?: 'first' | 'last';
|
||||
method?: 'onceAround' | 'silent';
|
||||
direction?: 'clockwise' | 'counterclockwise';
|
||||
card?: string;
|
||||
keepUnbid?: boolean;
|
||||
};
|
||||
export const actTruthMovementNativeEarly = actTruthMovement;
|
||||
export const truthMovementNativeEarlyClean = truthMovementClean;
|
||||
export const truthMovementNativeEarlyPlayer = truthMovementPlayer;
|
||||
export const truthMovementNativeEarlyInventory = truthMovementInventory;
|
||||
|
||||
/** Consume live native controls only. Callers own Truthtrance, paid bids and
|
||||
* Emperor's shipment/movement; cache defaults are actual zero/pass actions. */
|
||||
export function nextTruthMovementNativeEarlyStep(game: Game,
|
||||
auction: TruthMovementNativeEarlyAuction = {}): TruthMovementNativeEarlyStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision) {
|
||||
const actor = decision.player, event = game.richeseBidding?.event;
|
||||
if (decision.kind === 'richeseBlackMarket')
|
||||
return { actor, action: { type: 'decision', event, decline: true } };
|
||||
if (decision.kind === 'richeseDeclaration')
|
||||
return { actor, action: { type: 'decision', event, position: auction.position ?? 'first' } };
|
||||
if (decision.kind === 'richeseCacheTerms') return { actor, action: {
|
||||
type: 'decision', event, method: auction.method ?? 'onceAround',
|
||||
direction: auction.direction ?? 'clockwise',
|
||||
} };
|
||||
if (decision.kind === 'richeseCache') {
|
||||
const card = game.richeseCache?.find(card => auction.card ? card.id === auction.card : true);
|
||||
assert.ok(card, 'Choose an original card still held in the native Richese cache');
|
||||
return { actor, action: { type: 'decision', event, card: card.id,
|
||||
...(actor === game.richeseBidding?.owner ? {
|
||||
method: auction.method ?? 'onceAround', direction: auction.direction ?? 'clockwise',
|
||||
} : {}),
|
||||
} };
|
||||
}
|
||||
if (decision.kind === 'richeseUnbid')
|
||||
return { actor, action: { type: 'decision', event, keep: auction.keepUnbid ?? false } };
|
||||
if (decision.kind === 'ecazPlacement' || decision.kind === 'choamMovement')
|
||||
return { actor, action: { type: 'decision', decline: true } };
|
||||
if (decision.kind === 'moritaniSetup')
|
||||
return { actor, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
|
||||
if (decision.kind === 'ixSetup') {
|
||||
const card = game.ixSetupCards?.find(card => card.kind === 'shield');
|
||||
assert.ok(card, 'Ixians must actually choose the original Shield from their starting-card inspection');
|
||||
return { actor, action: { type: 'decision', card: card.id } };
|
||||
}
|
||||
}
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz');
|
||||
if (!decision && game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
}
|
||||
if (truthMovementNativeEarlyClean(game)) {
|
||||
if (game.richeseAuction && !game.richeseAuction.outcome) {
|
||||
const lot = game.richeseAuction;
|
||||
const actor = lot.method === 'silent'
|
||||
? lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)) : lot.active;
|
||||
assert.ok(actor, 'An unfinished native cache lot needs its next eligible bidder');
|
||||
return { actor, action: { type: 'richeseBid',
|
||||
event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
if (game.status === 'playing' && game.phase === 5 && game.active === 'emperor') return null;
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
|
||||
export function advanceTruthMovementNativeEarly(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMovementNativeEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original native source ended before its requested control');
|
||||
const step = nextTruthMovementNativeEarlyStep(game, auction);
|
||||
assert.ok(step, 'Supply the real Truthtrance, bid or Emperor movement choice before continuing');
|
||||
game = actTruthMovementNativeEarly(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original native early movement source did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Fresh public Basic expansion setup, stopped at clean Charity before any
|
||||
* question or Bidding. Select only original unopened deal/Spice positions and
|
||||
* scalar lotteries; original setup controls supply every physical counter. */
|
||||
export function createTruthMovementNativeEarlyFixture(options: {
|
||||
roster?: TruthMovementNativeEarlyRoster;
|
||||
} = {}) {
|
||||
const roster = options.roster ?? 'richese-choam', asker = 'atreides', target = 'emperor';
|
||||
const native = roster === 'richese-choam' ? ['richese', 'choam'] as const
|
||||
: roster === 'ecaz-moritani' ? ['ecaz', 'moritani'] as const : ['ixians', 'tleilaxu'] as const;
|
||||
const expansions = roster === 'richese-choam' ? ['choam'] : roster === 'ecaz-moritani' ? ['ecaz'] : ['ix'];
|
||||
let game = createGame(`TRUTHMOVEMENTEARLY${roster.toUpperCase()}`,
|
||||
newPlayer(asker, 'Atreides', 'atreides'), false, expansions);
|
||||
for (const faction of [target, ...native] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: TruthMovementNativeEarlyStep[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
game = actTruthMovementNativeEarly(game, actor, action, actions);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceTruthMovementNativeEarly(game, until, actions);
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game), lobbyActionCount = actions.length;
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game), setupActionCount = actions.length;
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.deepEqual(game.expansions, 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 === 0), 'Choose only an unopened original deal');
|
||||
const originalMainDeck = game.deck.map(card => card.id), originalCache = (game.richeseCache ?? []).map(card => card.id);
|
||||
const originalCards = truthMovementNativeEarlyInventory(game);
|
||||
if (roster === 'richese-choam') {
|
||||
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
|
||||
}
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance');
|
||||
const baliset = game.deck.find(card => card.name === 'Baliset');
|
||||
const shield = game.deck.find(card => card.kind === 'shield');
|
||||
const snooper = game.deck.find(card => card.kind === 'snooper');
|
||||
assert.ok(truth && baliset && shield && snooper);
|
||||
const deal = [truth.id, baliset.id, shield.id, snooper.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), afterSetupActionCount = actions.length;
|
||||
for (const [index, player] of game.players.entries())
|
||||
assert.deepEqual(player.hand.map(card => card.id), [deal[index]]);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const beforeSpiceOrder = structuredClone(game);
|
||||
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
|
||||
const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land);
|
||||
const spiceOrder = [{ position: 0, from, card: structuredClone(land) }];
|
||||
reach(state => state.turn === 1 && state.phase === 2 && truthMovementNativeEarlyClean(state));
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.deepEqual(game.ready, []); assert.equal(game.auction ?? null, null);
|
||||
assert.equal(game.richeseBidding ?? null, null); assert.equal(game.richeseAuction ?? null, null);
|
||||
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
|
||||
const emperor = truthMovementNativeEarlyPlayer(game, target);
|
||||
assert.equal(emperor.spice, 10); assert.equal(emperor.reserves, 20); assert.equal(emperor.tanks, 0);
|
||||
assert.deepEqual(emperor.forces, {}); assert.equal(emperor.shipped, false); assert.equal(emperor.moved, 0);
|
||||
assert.ok(truthMovementNativeEarlyPlayer(game, asker).hand.some(card => card.id === truth.id));
|
||||
assert.deepEqual(truthMovementNativeEarlyInventory(game), originalCards);
|
||||
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
const claim = { kind: 'movement' as const, target, territory: 'carthag', minimum: 8 };
|
||||
return {
|
||||
game, source: structuredClone(game), roster, expansions, asker, target, native,
|
||||
richese: roster === 'richese-choam' ? 'richese' : null,
|
||||
choam: roster === 'richese-choam' ? 'choam' : null,
|
||||
ecaz: roster === 'ecaz-moritani' ? 'ecaz' : null,
|
||||
moritani: roster === 'ecaz-moritani' ? 'moritani' : null,
|
||||
ixians: roster === 'ixians-tleilaxu' ? 'ixians' : null,
|
||||
tleilaxu: roster === 'ixians-tleilaxu' ? 'tleilaxu' : null,
|
||||
lobby, lobbyActionCount, setup, setupActionCount, beforeStartingDeal, afterSetup, afterSetupActionCount,
|
||||
beforeSpiceOrder, spiceOrder, deal, dealOrder, startingDealSelection: dealOrder,
|
||||
originalMainDeck, originalCache, originalCards, originalTreachery: originalCards, originalSpice,
|
||||
actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { lobby: lobbyActionCount, setup: setupActionCount, storm: afterSetupActionCount, charity: actions.length },
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
card: truth.id, truth: truth.id, claim, question: claim,
|
||||
declarationStep: { actor: asker, action: { type: 'card', card: truth.id } } satisfies TruthMovementNativeEarlyStep,
|
||||
askStep: { actor: asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementNativeEarlyStep,
|
||||
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 bare Basic Atreides/Emperor/${native.join('/')} setup uses four original unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Actual setup deals Truthtrance to Atreides and Baliset to Emperor, and actual Storm/Spice controls reach clean turn-1 Charity in exactly ${actions.length} recorded actions. Emperor retains its printed ten spice, twenty reserves, zero board forces and unused movement. ${roster === 'richese-choam' ? 'The original 35-card main deck and ten-card cache are conserved; native first/last Once Around/Silent auctions remain future controls.' : roster === 'ecaz-moritani' ? 'Ecaz actually places six in Imperial Basin and Moritani actually places six in empty Oh Gap; neither spends Emperor resources.' : 'Ixians actually inspect the four original starting cards and choose the original Shield; the remaining Truthtrance/Baliset/Snooper are natively shuffled and dealt to Atreides/Emperor/Tleilaxu. The native mobile-stronghold placement retains its original six-counter garrison, and Tleilaxu receives its actual original Face Dancers.'} The constructor stops before any Truthtrance declaration, answer, auction declaration, bid or payment. Future purchase2, city shipment8, ground movement8 and ending movement remain caller actions; no dealt hand, wallet, force, clock, answer, promise or completed receipt is installed.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementNativeEarlyFixture = ReturnType<typeof createTruthMovementNativeEarlyFixture>;
|
||||
@@ -130,12 +130,12 @@ void test('Basic native selected families qualify, while optional modules and un
|
||||
}
|
||||
});
|
||||
|
||||
void test('earlier current-round movement questions support classic Basic while native early invoices remain unfinished', () => {
|
||||
void test('earlier current-round movement question profiles include declared Basic native families', () => {
|
||||
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);
|
||||
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
|
||||
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
|
||||
}
|
||||
for (const phase of [-1, 6, 8]) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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 { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createTruthMovementNativeEarlyFixture, actTruthMovementNativeEarly as act,
|
||||
advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean,
|
||||
type TruthMovementNativeEarlyFixture,
|
||||
} from './fixture-truth-movement-native-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));
|
||||
|
||||
/** Real source controls bind the answer and decline the original discard
|
||||
* reaction. No promise, native auction, submitted bid or policy is installed. */
|
||||
function bind(f: TruthMovementNativeEarlyFixture): Game {
|
||||
let game = act(f.game, f.asker, f.declarationStep.action);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
||||
game = act(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
game = act(game, f.asker, f.askStep.action);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers?.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
assert.ok(reaction?.stage === 'offer');
|
||||
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
|
||||
game = act(game, player.id, { type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.ok(clean(game), 'Finish the original Truthtrance and Semuta controls before the native auction');
|
||||
assert.equal(game.phase, 2);
|
||||
return game;
|
||||
}
|
||||
|
||||
function nativeControl(method: 'onceAround' | 'silent') {
|
||||
const f = createTruthMovementNativeEarlyFixture();
|
||||
const game = advance(bind(f), state => state.phase === 3 && clean(state) && !!state.richeseAuction &&
|
||||
!state.richeseAuction.outcome && (method === 'silent'
|
||||
? !Object.hasOwn(state.richeseAuction.sealed, f.target)
|
||||
: state.richeseAuction.active === f.target), [], { method, card: 'richese-nullentropy-box' });
|
||||
const lot = game.richeseAuction!;
|
||||
assert.equal(lot.method, method); assert.equal(lot.source, 'cache');
|
||||
const expected: Action = { type: 'richeseBid', event: lot.event,
|
||||
amount: method === 'silent' ? 0 : null, allyPayment: 0 };
|
||||
const view = viewGame(game, f.target);
|
||||
assert.deepEqual(view.movementPromisePolicy, [expected]);
|
||||
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
|
||||
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.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 button = controls(MovementPromises(props));
|
||||
assert.equal(button.length, 1); assert.equal(button[0].disabled, false);
|
||||
assert.equal(button[0].children, method === 'silent'
|
||||
? 'Submit zero in this Silent auction and preserve later movement'
|
||||
: 'Pass this Richese bid and preserve later movement');
|
||||
const busy = controls(MovementPromises({ ...props, busy: true }));
|
||||
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
|
||||
busy[0].onClick(); assert.deepEqual(sent, []);
|
||||
assert.match(markup({ ...props, busy: true }), /Your Movement turn is still ahead/);
|
||||
button[0].onClick(); assert.deepEqual(sent, [expected]);
|
||||
assert.equal(sent[0], view.movementPromisePolicy![0], 'Forward the actual event-bound private policy action');
|
||||
const next = act(game, f.target, sent[0]);
|
||||
assert.equal(next.movementPromises![0].released, undefined);
|
||||
assert.equal(next.movementPromises![0].fulfilled, undefined);
|
||||
if (method === 'silent') assert.equal(next.richeseAuction!.sealed[f.target], 0);
|
||||
else assert.ok(next.richeseAuction!.passed.includes(f.target));
|
||||
const waiting = { ...props, game: viewGame(next, f.target) };
|
||||
assert.deepEqual(waiting.game.movementPromisePolicy, []);
|
||||
assert.equal(controls(MovementPromises(waiting)).length, 0);
|
||||
const after = markup(waiting);
|
||||
assert.match(after, /Your ground-movement promises/);
|
||||
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.doesNotMatch(after, /<button/);
|
||||
}
|
||||
|
||||
void test('real Once Around movement guidance submits only the current native pass and retains its notice', () => {
|
||||
nativeControl('onceAround');
|
||||
});
|
||||
void test('real Silent movement guidance submits zero with the original native event and retains its notice', () => {
|
||||
nativeControl('silent');
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
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 { createTruthMovementNativeEarlyFixture as fixture, actTruthMovementNativeEarly as act,
|
||||
advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean,
|
||||
truthMovementNativeEarlyPlayer as player, truthMovementNativeEarlyInventory as inventory,
|
||||
type TruthMovementNativeEarlyFixture as Fixture } from './fixture-truth-movement-native-early';
|
||||
|
||||
const owner = (game: Game) => player(game, 'emperor');
|
||||
|
||||
function ask(f: Fixture, source = f.game, question: Record<string, unknown> = f.claim) {
|
||||
let game = act(source, f.asker, f.declarationStep.action);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const owner = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(owner);
|
||||
game = act(game, owner.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.ok(viewGame(game, f.asker).truthMovementTargets.includes(f.target));
|
||||
return act(game, f.asker, { type: 'truthAsk', question });
|
||||
}
|
||||
function bound(f = fixture(), source = f.game, question: Record<string, unknown> = f.claim) {
|
||||
const game = ask(f, source, question);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
||||
return advance(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('fresh Basic E1, E2 and E3 Charity sources can bind later physical movement without granting an early move', () => {
|
||||
for (const roster of ['richese-choam', 'ecaz-moritani', 'ixians-tleilaxu'] as const) {
|
||||
const f = fixture({ roster }), game = bound(f);
|
||||
assert.equal(game.phase, 2); assert.equal(owner(game).spice, 10);
|
||||
assert.deepEqual(owner(game).forces, {}); assert.equal(owner(game).reserves, 20);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
|
||||
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a question after a native standing or sealed bid prices its own split before offering Yes', () => {
|
||||
for (const method of ['onceAround', 'silent'] as const) for (const minimum of [8, 9]) {
|
||||
const f = fixture(), auction = { method };
|
||||
let game = advance(f.game, g => g.phase === 3 && clean(g) && !!g.richeseAuction &&
|
||||
(method === 'silent' ? !Object.hasOwn(g.richeseAuction.sealed, f.target) : g.richeseAuction.active === f.target), [], auction);
|
||||
game = act(game, f.target, { type: 'richeseBid', event: game.richeseAuction!.event, amount: 2, allyPayment: 0 });
|
||||
game = ask(f, game, { ...f.claim, minimum });
|
||||
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), minimum === 8);
|
||||
assert.equal(owner(game).spice, 10, 'A quote does not pay its current invoice');
|
||||
assert.equal(game.richeseFunding?.[f.target].amount, 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled Silent ally funding keeps the full pledge locked and resolves only the actual winning donor cost', () => {
|
||||
for (const loses of [false, true]) for (const allyPayment of [1, 3]) {
|
||||
const f = fixture(), source = structuredClone(f.game), auction = { method: 'silent' as const };
|
||||
owner(source).ally = 'choam'; player(source, 'choam').ally = f.target;
|
||||
let game = advance(bound(f, source), g => g.phase === 3 && clean(g) && !!g.richeseAuction && !g.richeseAuction.outcome, [], auction);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
|
||||
const event = game.richeseAuction!.event;
|
||||
game = act(game, 'choam', { type: 'richeseBid', event, amount: 3, allyPayment });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
reject(game, f.target, { type: 'pledgeAid', amount: 7 });
|
||||
const zero = { type: 'richeseBid', event, amount: 0, allyPayment: 0 };
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [zero]);
|
||||
game = act(game, f.target, zero);
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = act(game, 'richese', { type: 'richeseBid', event, amount: loses ? 4 : 0, allyPayment: 0 });
|
||||
game = advance(game, g => g.phase === 4 && clean(g), [], auction);
|
||||
assert.equal(owner(game).spice, loses ? 14 : 10 - allyPayment);
|
||||
assert.equal(game.movementPromises![0].released, !loses && allyPayment === 3 ? true : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled preopening non-CHOAM Charity forecasts wait for the real allowed or canceled payer receipt', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
const f = fixture(), source = structuredClone(f.afterSetup), p = owner(source);
|
||||
// Explicit prior losses/wallet and conserved original Karama acquisition.
|
||||
// Only the original unopened ordinary Spice card is selected; no CHOAM
|
||||
// result, receipt or future income is installed by this controlled source.
|
||||
p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
|
||||
const index = source.spiceDeck.findIndex(card => JSON.stringify(card) === JSON.stringify(f.spiceOrder[0].card));
|
||||
assert.ok(index >= 0); source.spiceDeck.unshift(source.spiceDeck.splice(index, 1)[0]);
|
||||
const from = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(from >= 0);
|
||||
const karama = source.deck.splice(from, 1)[0]; player(source, 'richese').hand.push(karama);
|
||||
let game = bound(f, source, { ...f.claim, minimum: 1 });
|
||||
assert.equal(owner(game).spice, 0); assert.equal(game.choamCharity, undefined);
|
||||
game = advance(game, g => g.response?.kind === 'choamCharity');
|
||||
if (canceled) game = act(game, 'richese', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
game = advance(game, g => g.phase === 2 && clean(g));
|
||||
assert.equal(game.choamCharity?.canceled, canceled);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'charity' }]);
|
||||
const before = player(game, 'choam').spice;
|
||||
game = act(game, f.target, { type: 'charity' });
|
||||
assert.equal(owner(game).spice, 2); assert.equal(player(game, 'choam').spice, before - (canceled ? 0 : 2));
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
game = act(game, f.target, { type: 'revive', amount: 1 });
|
||||
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);
|
||||
assert.equal(game.movementPromises![0].released, undefined); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual first/last Once Around and Silent cache invoices preserve only the affordable later eight-force route', () => {
|
||||
for (const position of ['first', 'last'] as const) for (const method of ['onceAround', 'silent'] as const) {
|
||||
const f = fixture(), auction = { position, method, card: 'richese-nullentropy-box' };
|
||||
let game = advance(bound(f), g => g.phase === 3 && clean(g) && !!g.richeseAuction &&
|
||||
!g.richeseAuction.outcome && (method === 'silent' ? !Object.hasOwn(g.richeseAuction.sealed, f.target)
|
||||
: g.richeseAuction.active === f.target), [], auction);
|
||||
const event = game.richeseAuction!.event;
|
||||
reject(game, f.target, { type: 'richeseBid', event, amount: 3, allyPayment: 0 });
|
||||
game = act(game, f.target, { type: 'richeseBid', event, amount: 2, allyPayment: 0 });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(game, g => g.phase === 4 && clean(g), [], auction);
|
||||
assert.equal(owner(game).spice, 8); assert.ok(owner(game).hand.some(card => card.id === auction.card));
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target, [], auction);
|
||||
game = advance(act(game, f.target, f.shipment), clean, [], auction);
|
||||
assert.equal(owner(game).spice, 0); assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = advance(act(game, f.target, f.move), clean, [], auction);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = act(game, f.target, f.endMovement);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native current-phase minimal policies pass an owned Once Around bid or submit Silent zero with the real event', () => {
|
||||
for (const method of ['onceAround', 'silent'] as const) {
|
||||
const f = fixture(), auction = { method };
|
||||
const game = advance(bound(f), g => g.phase === 3 && clean(g) && !!g.richeseAuction &&
|
||||
(method === 'silent' ? !Object.hasOwn(g.richeseAuction.sealed, f.target) : g.richeseAuction.active === f.target), [], auction);
|
||||
const expected = { type: 'richeseBid', event: game.richeseAuction!.event,
|
||||
amount: method === 'silent' ? 0 : null, allyPayment: 0 };
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [expected]);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [expected]);
|
||||
const next = act(game, f.target, expected);
|
||||
assert.equal(next.movementPromises![0].released, undefined);
|
||||
assert.deepEqual(viewGame(next, f.target).movementPromisePolicy, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('original E1 and E3 early answers survive their native controls and fulfill only on the actual ground event', () => {
|
||||
for (const roster of ['ixians-tleilaxu', 'ecaz-moritani'] as const) {
|
||||
const f = fixture({ roster });
|
||||
let game = advance(bound(f), 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);
|
||||
game = act(game, f.target, f.endMovement); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled CHOAM denial retains the real pending revival and releases only an unaffordable later route', () => {
|
||||
for (const spice of [1, 3]) {
|
||||
const f = fixture(), source = advance(f.game, g => g.phase === 4 && clean(g));
|
||||
const p = owner(source); p.spice = spice; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
|
||||
const index = source.deck.findIndex(card => card.name === 'La La La'); assert.ok(index >= 0);
|
||||
const card = source.deck.splice(index, 1)[0]; player(source, 'choam').hand.push(card);
|
||||
let game = bound(f, source, { ...f.claim, minimum: 1 });
|
||||
game = act(game, f.target, { type: 'revive', amount: 1 });
|
||||
assert.equal(game.decision?.kind, 'choamFreeRevival');
|
||||
assert.equal(owner(game).reserves, 0); assert.equal(owner(game).spice, spice);
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = act(game, 'choam', { type: 'card', card: card.id, mode: 'choam' });
|
||||
game = advance(game, clean);
|
||||
assert.equal(owner(game).reserves, 0); assert.equal(owner(game).spice, spice);
|
||||
assert.equal(game.movementPromises![0].released, spice === 1 ? true : undefined);
|
||||
if (spice === 3) {
|
||||
game = advance(act(game, f.target, { type: 'revive', amount: 1 }), clean);
|
||||
assert.equal(owner(game).spice, 1); assert.equal(owner(game).reserves, 1);
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
for (let count = 0; !game.movementPromises![0].fulfilled && count < 4; count++)
|
||||
game = advance(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]), clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user