Bind Truthtrance shipments before respondent turns
Admit supported active or originally queued unused shipments during the current Shipment & Movement phase. Answering preserves the real active player and queue; private future reachability uses owned resources, joint whole promises and the actual respondent profile. Hide executable guidance until that owner is active. Keep native Guild WAIT/TAKE, frozen mixed facts, interception, costs and actual delivery. Do not release a waiting promise while another declaration is unresolved or count its approved ally contribution as freely recoverable. Before response declaration, reject Emperor gifts or optional BG accompaniment that remove the last legal completion; affordable options remain legal. Minimal AI declines optional accompaniment while bound rather than attempting to spend required reserves. No strategy tuning or new profile/module/earlier-phase opening. Validation and exact frozen-source evidence are recorded in the canonical Truthtrance shipment contract. Three real public Basic/Advanced programmes use original unopened starting deals, paid intervening turns and a later Emperor or native out-of-order Guild fulfillment. Four minimal policies answer while waiting and ship only on their actual turn. Controlled funding, temporary price, gift and advisor probes remain separate. Existing stale scope fixtures and two type declarations were repaired during feedback. Earlier phases, broader modules/actions, full Truthtrance/modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
1 parent
2939ffa900
commit
ae2d0371ce
16 files changed
+745
-56
No files matched your search
@@ -0,0 +1,181 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type FutureShipmentStep = { actor: string; action: Action };
|
||||
export type FutureShipmentTransition = { before: Game; step: FutureShipmentStep; after: Game };
|
||||
export const futureShipmentPlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const futureShipmentClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actFutureShipment(game: Game, actor: string, action: Action, actions: FutureShipmentStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Real original decisions only. The owned programme supplies its questions,
|
||||
* answers, shipments and each deliberately selected Guild timing choice. */
|
||||
export function nextFutureShipmentStep(game: Game): FutureShipmentStep | null {
|
||||
if (game.truthtrance) {
|
||||
if (game.truthtrance.stage !== 'priority') return null;
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
return { actor: actor.id, action: { type: 'truthPass' } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceFutureShipment(game: Game, until: (state: Game) => boolean, actions: FutureShipmentStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original future-shipment programme ended before its next control');
|
||||
const step = nextFutureShipmentStep(game); assert.ok(step, 'Choose the actual structured question and answer before advancing');
|
||||
game = actFutureShipment(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original future-shipment programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Original public Basic/Advanced classic setup deals selected unopened cards,
|
||||
* including the physical Truthtrance. Advanced starts with an actual Guild
|
||||
* WAIT, leaving the distinct respondent's original turn still ahead. No dealt
|
||||
* custody, armies, resources, phase, queue, timing flags or promises are staged. */
|
||||
export function createFutureShipmentFixture(options: {
|
||||
advanced?: boolean; target?: 'emperor' | 'guild'; mixed?: boolean;
|
||||
} = {}) {
|
||||
const target = options.target ?? 'emperor', advanced = options.advanced ?? target === 'guild';
|
||||
assert.ok(target !== 'guild' || advanced, 'The bounded future Guild programme uses its native Advanced timing');
|
||||
const mixed = options.mixed ?? target === 'guild';
|
||||
let game = createGame('TRUTHFUTURESHIPMENT', newPlayer('atreides', 'Atreides', 'atreides'), advanced);
|
||||
for (const faction of ['emperor', 'harkonnen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: FutureShipmentStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actFutureShipment(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceFutureShipment(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance');
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const filler = [game.deck.find(card => card.name === 'Baliset'),
|
||||
game.deck.find(card => card.kind === 'shield'), game.deck.find(card => card.kind === 'snooper')];
|
||||
assert.ok(truth && karama && filler.every(Boolean));
|
||||
const deal = [truth.id, filler[0]!.id, karama.id, filler[1]!.id, filler[2]!.id];
|
||||
for (const [position, id] of deal.entries()) {
|
||||
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]];
|
||||
}
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
|
||||
assert.ok(futureShipmentPlayer(game, 'atreides').hand.some(card => card.id === truth.id));
|
||||
assert.ok(futureShipmentPlayer(game, 'harkonnen').hand.some(card => card.id === karama.id));
|
||||
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0);
|
||||
front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
let initialWait: FutureShipmentTransition | null = null;
|
||||
if (advanced) {
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
||||
const before = structuredClone(game), step: FutureShipmentStep = { actor: 'guild', action: { type: 'decision', take: false } };
|
||||
act(step.actor, step.action); reach(futureShipmentClean);
|
||||
initialWait = { before, step, after: structuredClone(game) };
|
||||
} else reach(state => state.phase === 5 && state.active === 'atreides' && futureShipmentClean(state));
|
||||
assert.equal(game.active, 'atreides');
|
||||
assert.notEqual(game.active, target);
|
||||
assert.ok(game.movementRemaining!.includes(target));
|
||||
assert.equal(futureShipmentPlayer(game, target).shipped, false);
|
||||
const question: TruthQuestion = mixed ? { kind: 'mixedShipment', target, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'shipment', territory: 'sietch_tabr', minimum: 3 },
|
||||
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 3 } },
|
||||
] } } : { kind: 'shipment', target, territory: 'sietch_tabr', minimum: 3 };
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, initialWait, question,
|
||||
advanced, mixed, target, asker: 'atreides', guild: 'guild', counterHolder: 'harkonnen',
|
||||
card: truth.id, karama: karama.id, location: 'sietch_tabr:14', deal,
|
||||
sourceActionCount: actions.length, actions,
|
||||
staging: ['Only five unopened original starting-deal positions, unopened land Spice positions and scalar lotteries are selected. Public classic Basic/Advanced setup actually deals Truthtrance to Atreides and a distinct Karama to Harkonnen for genuine native response passes. Advanced begins with the real Guild WAIT. No postdeal holdings, resources, armies, clocks, movement queue, timing flags or promise receipts are assigned.'] };
|
||||
}
|
||||
export type FutureShipmentFixture = ReturnType<typeof createFutureShipmentFixture>;
|
||||
export function askFutureShipment(fixture: FutureShipmentFixture) {
|
||||
const actions: FutureShipmentStep[] = [], before = structuredClone(fixture.game);
|
||||
const declaration: FutureShipmentStep = { actor: fixture.asker, action: { type: 'card', card: fixture.card } };
|
||||
let game = actFutureShipment(before, declaration.actor, declaration.action, actions);
|
||||
const opened = structuredClone(game);
|
||||
game = advanceFutureShipment(game, game => game.truthtrance?.stage === 'ask', actions);
|
||||
const beforeQuestion = structuredClone(game), question: FutureShipmentStep = { actor: fixture.asker,
|
||||
action: { type: 'truthAsk', question: fixture.question } };
|
||||
game = actFutureShipment(game, question.actor, question.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
return { game, before, declaration, opened, beforeQuestion, question, actions };
|
||||
}
|
||||
export function bindFutureShipment(fixture: FutureShipmentFixture) {
|
||||
const asked = askFutureShipment(fixture), actions = [...asked.actions];
|
||||
const answer: FutureShipmentStep = { actor: fixture.target, action: { type: 'truthAnswer', answer: 'yes' } };
|
||||
const game = actFutureShipment(asked.game, answer.actor, answer.action, actions);
|
||||
assert.equal(game.active, fixture.asker);
|
||||
assert.ok(futureShipmentClean(game));
|
||||
return { ...asked, asked: asked.game, game, answer, actions };
|
||||
}
|
||||
|
||||
/** Keep the original promise throughout paid intervening turns. For a Guild
|
||||
* respondent, WAIT before Emperor is an actual new choice while still bound;
|
||||
* TAKE before Harkonnen then uses the original out-of-order timing response. */
|
||||
export function startFutureShipmentTarget(fixture: FutureShipmentFixture, state: Game) {
|
||||
let game = structuredClone(state);
|
||||
const actions: FutureShipmentStep[] = [], prefixes: {
|
||||
before: Game; shipment: FutureShipmentStep; declared: Game; afterShipment: Game;
|
||||
end: FutureShipmentStep; afterEnd: Game;
|
||||
}[] = [], timing: { before: Game; step: FutureShipmentStep; declared: Game; after: Game }[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actFutureShipment(game, actor, action, actions); };
|
||||
const settle = () => { game = advanceFutureShipment(game,
|
||||
game => futureShipmentClean(game) && !game.pendingShipment, actions); };
|
||||
const completePrefix = (actor: string, territory: string, sector: number) => {
|
||||
assert.equal(game.active, actor);
|
||||
const before = structuredClone(game), shipment: FutureShipmentStep = { actor,
|
||||
action: { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 } };
|
||||
act(shipment.actor, shipment.action);
|
||||
const declared = structuredClone(game); settle();
|
||||
const afterShipment = structuredClone(game), end: FutureShipmentStep = { actor, action: { type: 'endMovement' } };
|
||||
act(end.actor, end.action);
|
||||
prefixes.push({ before, shipment, declared, afterShipment, end, afterEnd: structuredClone(game) });
|
||||
};
|
||||
const chooseTiming = (take: boolean) => {
|
||||
assert.equal(game.decision?.kind, 'guildTiming');
|
||||
const before = structuredClone(game), step: FutureShipmentStep = { actor: fixture.guild,
|
||||
action: { type: 'decision', take } };
|
||||
act(step.actor, step.action);
|
||||
const declared = structuredClone(game); settle();
|
||||
timing.push({ before, step, declared, after: structuredClone(game) });
|
||||
};
|
||||
completePrefix(fixture.asker, 'imperial_basin', 9);
|
||||
if (fixture.advanced) chooseTiming(false);
|
||||
if (fixture.target === fixture.guild) {
|
||||
assert.equal(game.active, 'emperor');
|
||||
completePrefix('emperor', 'polar_sink', 0);
|
||||
chooseTiming(true);
|
||||
}
|
||||
assert.equal(game.active, fixture.target);
|
||||
assert.equal(futureShipmentPlayer(game, fixture.target).shipped, false);
|
||||
return { game, prefixes, timing, actions };
|
||||
}
|
||||
/** Execute only at the target's actual native turn, then explicitly finish it.
|
||||
* Delivery alone settles the promise; earlier payments and timing never do. */
|
||||
export function completeFutureShipment(fixture: FutureShipmentFixture) {
|
||||
const binding = bindFutureShipment(fixture), started = startFutureShipmentTarget(fixture, binding.game);
|
||||
const actions = [...binding.actions, ...started.actions];
|
||||
let game = started.game;
|
||||
const beforeShipment = structuredClone(game), shipment: FutureShipmentStep = { actor: fixture.target, action: {
|
||||
type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 3,
|
||||
elite: fixture.advanced && fixture.target === 'emperor' ? 1 : 0, allyPayment: 0,
|
||||
} };
|
||||
game = actFutureShipment(game, shipment.actor, shipment.action, actions);
|
||||
const declared = structuredClone(game);
|
||||
game = advanceFutureShipment(game, game => futureShipmentClean(game) && !game.pendingShipment, actions);
|
||||
const settled = structuredClone(game), end: FutureShipmentStep = { actor: fixture.target, action: { type: 'endMovement' } };
|
||||
game = actFutureShipment(game, end.actor, end.action, actions);
|
||||
return { game, binding, started, beforeShipment, shipment, declared, settled, end, actions };
|
||||
}
|
||||
@@ -539,6 +539,7 @@ void test('corrupt persisted promise records and pending shipment questions reje
|
||||
},
|
||||
(g) => {
|
||||
g.active = f.seats[2].playerId;
|
||||
g.movementRemaining = g.movementRemaining!.filter(id => id !== f.seats[1].playerId);
|
||||
},
|
||||
(g) => {
|
||||
g.phase = 6;
|
||||
@@ -580,6 +581,7 @@ void test('corrupt persisted promise records and pending shipment questions reje
|
||||
Object.assign(g.truthtrance!.question!, {
|
||||
target: f.seats[2].playerId,
|
||||
});
|
||||
g.movementRemaining = g.movementRemaining!.filter(id => id !== f.seats[2].playerId);
|
||||
},
|
||||
(g) => {
|
||||
g.truthtrance!.queue[0].player = 'missing-holder';
|
||||
|
||||
@@ -538,7 +538,7 @@ void test('malformed questions and unsupported public scopes reject without disc
|
||||
if (change === 'expansion') bad.expansions = ['ix'];
|
||||
if (change === 'phase') bad.phase = 4;
|
||||
if (change === 'spent') bad.players[1].shipped = true;
|
||||
if (change === 'inactive') bad.active = 'a';
|
||||
if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; }
|
||||
reject(bad, 'a', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
@@ -607,6 +607,7 @@ void test('saved malformed promise bindings reject before action or normalizatio
|
||||
},
|
||||
(g) => {
|
||||
g.active = 'a';
|
||||
g.movementRemaining = ['a', 'o'];
|
||||
},
|
||||
(g) => {
|
||||
g.players[1].shipped = true;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actFutureShipment, advanceFutureShipment, askFutureShipment, completeFutureShipment,
|
||||
createFutureShipmentFixture, futureShipmentClean, futureShipmentPlayer as player,
|
||||
startFutureShipmentTarget, type FutureShipmentFixture,
|
||||
} from './fixture-truthtrance-future-shipment';
|
||||
|
||||
const promise = (game: Game) => { assert.equal(game.shipmentPromises!.length, 1); return game.shipmentPromises![0]; };
|
||||
function live(game: Game, target: string) {
|
||||
const held = promise(game);
|
||||
assert.equal(held.player, target);
|
||||
assert.equal(held.answer, true);
|
||||
assert.equal(held.fulfilled, undefined);
|
||||
assert.equal(held.released, undefined);
|
||||
}
|
||||
function source(f: FutureShipmentFixture) {
|
||||
assert.equal(f.game.advanced, f.advanced);
|
||||
assert.deepEqual(f.game.expansions, []);
|
||||
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.homeworlds && !f.game.nexusCards &&
|
||||
!f.game.leaderSkills && !f.game.discoveryEnabled);
|
||||
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start'));
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 5).map(card => card.id), f.deal);
|
||||
assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
|
||||
assert.ok(player(f.afterSetup, f.counterHolder).hand.some(card => card.id === f.karama && card.effect === 'karama'));
|
||||
assert.equal(player(f.afterSetup, f.counterHolder).hand.length, 2);
|
||||
assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.game.active, f.asker);
|
||||
assert.notEqual(f.game.active, f.target);
|
||||
assert.ok(f.game.movementRemaining!.includes(f.target));
|
||||
assert.equal(player(f.game, f.target).shipped, false);
|
||||
assert.ok(viewGame(f.game, f.asker).truthShipmentTargets.includes(f.target));
|
||||
if (f.advanced) {
|
||||
assert.ok(f.initialWait);
|
||||
assert.equal(f.initialWait.step.action.take, false);
|
||||
assert.equal(f.initialWait.before.decision?.kind, 'guildTiming');
|
||||
}
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function completed(f: FutureShipmentFixture, result: ReturnType<typeof completeFutureShipment>) {
|
||||
assert.ok(viewGame(result.binding.asked, f.target).truthShipmentAnswers!.includes('yes'));
|
||||
live(result.binding.game, f.target);
|
||||
assert.equal(result.binding.game.active, f.asker);
|
||||
assert.equal(result.binding.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(viewGame(result.binding.game, f.target).shipmentCompletion, null);
|
||||
assert.deepEqual(result.binding.game.movementRemaining, f.game.movementRemaining);
|
||||
assert.deepEqual(result.binding.game.order, f.game.order);
|
||||
for (const prefix of result.started.prefixes) {
|
||||
live(prefix.before, f.target); live(prefix.declared, f.target); live(prefix.afterShipment, f.target); live(prefix.afterEnd, f.target);
|
||||
assert.equal(player(prefix.before, prefix.shipment.actor).spice - player(prefix.afterShipment, prefix.shipment.actor).spice, 2);
|
||||
assert.equal(player(prefix.before, prefix.shipment.actor).reserves - player(prefix.afterShipment, prefix.shipment.actor).reserves, 1);
|
||||
assert.equal(prefix.afterEnd.movementRemaining!.includes(prefix.shipment.actor), false);
|
||||
assert.equal(viewGame(prefix.declared, f.target).shipmentCompletion, null);
|
||||
}
|
||||
for (const timing of result.started.timing) {
|
||||
live(timing.before, f.target); live(timing.declared, f.target); live(timing.after, f.target);
|
||||
assert.deepEqual(timing.before.order, timing.after.order);
|
||||
}
|
||||
live(result.beforeShipment, f.target);
|
||||
assert.equal(result.beforeShipment.active, f.target);
|
||||
assert.ok(viewGame(result.beforeShipment, f.target).shipmentCompletion);
|
||||
assert.throws(() => actFutureShipment(result.beforeShipment, f.target, { type: 'endMovement' }), /Truthtrance/i);
|
||||
assert.throws(() => actFutureShipment(result.beforeShipment, f.target,
|
||||
{ ...result.shipment.action, amount: 2, elite: 0 }), /Truthtrance/i);
|
||||
const cost = f.target === f.guild ? 2 : 3;
|
||||
assert.equal(player(result.beforeShipment, f.target).spice - player(result.settled, f.target).spice, cost);
|
||||
assert.equal(player(result.beforeShipment, f.target).reserves - player(result.settled, f.target).reserves, 3);
|
||||
assert.equal(player(result.settled, f.target).forces[f.location], 3);
|
||||
assert.equal(player(result.settled, f.target).shipped, true);
|
||||
assert.equal(promise(result.settled).fulfilled, true);
|
||||
assert.equal(promise(result.settled).released, undefined);
|
||||
assert.equal(result.settled.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(result.actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
|
||||
assert.equal(result.game.movementRemaining!.includes(f.target), false);
|
||||
assert.deepEqual(result.game.order, f.game.order);
|
||||
assert.throws(() => actFutureShipment(result.settled, f.target, result.shipment.action));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} future Emperor promise survives another player's paid turn then fulfills its actual shipment once`, () => {
|
||||
const f = createFutureShipmentFixture({ advanced }); source(f);
|
||||
const result = completeFutureShipment(f); completed(f, result);
|
||||
assert.equal(result.started.prefixes.length, 1);
|
||||
assert.equal(result.started.prefixes[0].shipment.actor, f.asker);
|
||||
assert.equal(player(result.settled, f.asker).forces['imperial_basin:9'], 1);
|
||||
assert.equal(player(result.settled, f.guild).spice - player(result.beforeShipment, f.guild).spice, 3);
|
||||
assert.throws(() => actFutureShipment(result.binding.game, f.target, result.shipment.action));
|
||||
assert.throws(() => actFutureShipment(result.binding.game, f.target, { type: 'endMovement' }));
|
||||
if (advanced) {
|
||||
assert.equal(player(result.settled, f.target).elites!.forces[f.location], 1);
|
||||
assert.equal(player(result.beforeShipment, f.target).elites!.reserves - player(result.settled, f.target).elites!.reserves, 1);
|
||||
assert.equal(result.declared.decision?.kind, 'guildShipment');
|
||||
live(result.declared, f.target);
|
||||
assert.equal(viewGame(result.declared, f.target).shipmentCompletion, null);
|
||||
assert.deepEqual(result.started.timing.map(choice => choice.step.action.take), [false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('genuine future Guild mixed promise remains binding through WAIT and earlier paid turns before native TAKE and half-rate delivery', () => {
|
||||
const f = createFutureShipmentFixture({ target: 'guild', mixed: true }); source(f);
|
||||
const result = completeFutureShipment(f); completed(f, result);
|
||||
assert.equal(result.binding.asked.truthtrance!.question!.kind, 'mixedShipment');
|
||||
assert.ok(promise(result.binding.game).mixed);
|
||||
assert.deepEqual(result.started.prefixes.map(prefix => prefix.shipment.actor), ['atreides', 'emperor']);
|
||||
assert.deepEqual(result.started.timing.map(choice => choice.step.action.take), [false, true]);
|
||||
assert.equal(result.started.timing[0].after.active, 'emperor');
|
||||
assert.equal(result.started.timing[1].before.decision?.kind, 'guildTiming');
|
||||
if (result.started.timing[1].before.decision?.kind === 'guildTiming')
|
||||
assert.equal(result.started.timing[1].before.decision.next, 'harkonnen');
|
||||
assert.equal(result.started.timing[1].declared.response?.kind, 'guildTiming');
|
||||
assert.equal(result.started.timing[1].after.active, f.guild);
|
||||
assert.equal(result.started.timing[1].after.guildTimingGranted, true);
|
||||
assert.ok(result.started.actions.some(step => step.actor === f.counterHolder && step.action.type === 'passResponse'));
|
||||
assert.equal(player(result.beforeShipment, f.guild).spice - player(result.binding.game, f.guild).spice, 4);
|
||||
assert.equal(player(result.settled, 'emperor').forces['polar_sink:0'], 1);
|
||||
assert.equal(result.beforeShipment.movementRemaining!.includes('atreides'), false);
|
||||
assert.equal(result.beforeShipment.movementRemaining!.includes('emperor'), false);
|
||||
assert.ok(result.game.movementRemaining!.includes('harkonnen'));
|
||||
assert.equal(player(result.game, 'harkonnen').shipped, false);
|
||||
assert.equal(player(result.game, 'harkonnen').moved, 0);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer the actual future question without an early witness and ship legally only after the intervening native turn', () => {
|
||||
const f = createFutureShipmentFixture({ advanced: true }), asked = askFutureShipment(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const answerView = viewGame(asked.game, f.target);
|
||||
answerView.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(answerView).find(action => action.type === 'truthAnswer'); assert.ok(answer);
|
||||
assert.ok(answer.answer === 'yes');
|
||||
assert.ok(answerView.truthShipmentAnswers!.includes(answer.answer));
|
||||
const bound = actFutureShipment(structuredClone(asked.game), f.target, answer);
|
||||
live(bound, f.target);
|
||||
const waiting = viewGame(bound, f.target);
|
||||
waiting.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
assert.equal(waiting.shipmentCompletion, null);
|
||||
assert.equal(botActions(waiting).some(action => ['ship', 'guildShip', 'move', 'endMovement'].includes(action.type)), false);
|
||||
const started = startFutureShipmentTarget(f, bound), active = viewGame(started.game, f.target);
|
||||
active.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const shipment = botActions(active).find(action => action.type === 'ship'); assert.ok(shipment);
|
||||
assert.equal(shipment.territory, 'sietch_tabr');
|
||||
assert.ok(Number(shipment.amount) >= 3);
|
||||
let game = actFutureShipment(started.game, f.target, shipment);
|
||||
game = advanceFutureShipment(game, game => futureShipmentClean(game) && !game.pendingShipment);
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(promise(game).released, undefined);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { shipmentPromiseModeSupported, shipmentPromiseTurnAvailable } from '../game/shipment-promises';
|
||||
import { ShipmentPromises } from '../components/shipment-promises';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
// Controlled timing/funding probes; the separate fixture earns its source.
|
||||
function fixture(spice = 6, advanced = false) {
|
||||
const g = createGame('FUTURESHIPPROBE', newPlayer('a', 'Current', 'atreides'), advanced);
|
||||
g.players.push(newPlayer('p', 'Waiting Emperor', 'emperor'), newPlayer('o', 'Other', advanced ? 'guild' : 'harkonnen'));
|
||||
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'a',
|
||||
order: ['a', 'p', 'o'], movementRemaining: ['a', 'p', 'o'], deck: baseDeck() });
|
||||
for (const p of g.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; }
|
||||
player(g, 'p').spice = spice;
|
||||
return g;
|
||||
}
|
||||
function hold(g: Game, actor: string, effect: string) {
|
||||
const index = g.deck.findIndex(c => c.effect === effect); assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0]; player(g, actor).hand.push(card); return card.id;
|
||||
}
|
||||
function bind(state: Game, minimum = 6) {
|
||||
const g = structuredClone(state), card = hold(g, 'a', 'truthtrance');
|
||||
let game = applyAction(g, 'a', { type: 'card', card });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!;
|
||||
game = applyAction(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.ok(viewGame(game, 'a').truthShipmentTargets.includes('p'));
|
||||
game = applyAction(game, 'a', { type: 'truthAsk', question: { kind: 'shipment', target: 'p', territory: 'carthag', minimum } });
|
||||
assert.ok(viewGame(game, 'p').truthShipmentAnswers!.includes('yes'));
|
||||
return applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
function pass(state: Game) {
|
||||
let g = state;
|
||||
for (let n = 0; g.response && n < 30; n++) {
|
||||
const actor = g.players.find(p => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null); return g;
|
||||
}
|
||||
function startTarget(state: Game) {
|
||||
let g = applyAction(state, 'a', { type: 'endMovement' });
|
||||
if (g.decision?.kind === 'guildTiming')
|
||||
g = pass(applyAction(g, g.decision.player, { type: 'decision', take: false }));
|
||||
assert.equal(g.active, 'p'); return g;
|
||||
}
|
||||
function complete(state: Game) {
|
||||
let g = state;
|
||||
for (let n = 0; !player(g, 'p').shipped && n < 20; n++) {
|
||||
if (g.response) g = pass(g);
|
||||
else if (g.decision?.kind === 'guildShipment')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', allow: true });
|
||||
else {
|
||||
const action = viewGame(g, 'p').shipmentCompletion?.actions[0]; assert.ok(action);
|
||||
g = applyAction(g, 'p', action);
|
||||
}
|
||||
}
|
||||
g = pass(g); assert.equal(player(g, 'p').shipped, true); return g;
|
||||
}
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('same-phase target eligibility requires an unused active or original remaining turn and evaluates the actual Semuta respondent', () => {
|
||||
const g = fixture();
|
||||
assert.equal(shipmentPromiseTurnAvailable(g, 'a', true), true);
|
||||
assert.equal(shipmentPromiseTurnAvailable(g, 'p', true), true);
|
||||
assert.equal(shipmentPromiseTurnAvailable(g, 'p', false), false);
|
||||
assert.equal(shipmentPromiseTurnAvailable({ ...g, movementRemaining: ['a'] }, 'p', true), false);
|
||||
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 4 }, 'p', true), false);
|
||||
const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true,
|
||||
players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] };
|
||||
assert.equal(shipmentPromiseModeSupported(mode, 'p'), true);
|
||||
assert.equal(shipmentPromiseModeSupported(mode, 'r'), false);
|
||||
});
|
||||
|
||||
void test('a waiting promise supplies no early executable action, survives the intervening turn and fulfills only actual later delivery', () => {
|
||||
const bound = bind(fixture());
|
||||
assert.equal(bound.active, 'a'); assert.equal(viewGame(bound, 'p').shipmentCompletion, null);
|
||||
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: viewGame(bound, 'p'), busy: false, act() { assert.fail('Render cannot act'); } }));
|
||||
assert.match(html, /Your shipment turn is still ahead/); assert.doesNotMatch(html, /Suggested next step/);
|
||||
reject(bound, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount: 6 });
|
||||
reject(bound, 'p', { type: 'endMovement' });
|
||||
const active = startTarget(bound); assert.equal(active.shipmentPromises![0].released, undefined);
|
||||
assert.ok(viewGame(active, 'p').shipmentCompletion);
|
||||
const done = complete(active);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(done, 'p').forces['carthag:11'], 6); assert.equal(player(done, 'p').spice, 0);
|
||||
});
|
||||
|
||||
void test('a foreign temporary price does not hide the waiting respondent’s owned Karama completion', () => {
|
||||
const g = fixture(3), own = hold(g, 'p', 'karama'), foreign = hold(g, 'a', 'karama');
|
||||
const bound = bind(g), priced = pass(applyAction(bound, 'a', { type: 'card', card: foreign, mode: 'shipment', target: 'a' }));
|
||||
assert.equal(priced.karamaShipping?.player, 'a');
|
||||
assert.equal(priced.shipmentPromises![0].released, undefined);
|
||||
assert.equal(viewGame(priced, 'p').shipmentCompletion, null);
|
||||
const active = startTarget(priced);
|
||||
assert.equal(viewGame(active, 'p').shipmentCompletion?.actions[0].card, own);
|
||||
const done = complete(active);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(done, 'p').spice, 0); assert.equal(player(done, 'p').forces['carthag:11'], 6);
|
||||
assert.equal(done.discard.filter(c => c.id === own).length, 1);
|
||||
});
|
||||
|
||||
void test('a waiting Emperor cannot declare a gift that removes the last completion, while an affordable gift remains legal', () => {
|
||||
for (const spice of [6, 7]) {
|
||||
const g = fixture(spice); player(g, 'p').ally = 'a'; player(g, 'a').ally = 'p';
|
||||
const bound = bind(g), gift = { type: 'emperorGift', amount: 1 };
|
||||
if (spice === 6) reject(bound, 'p', gift);
|
||||
else {
|
||||
const gifted = pass(applyAction(bound, 'p', gift));
|
||||
assert.equal(player(gifted, 'p').spice, 6); assert.equal(gifted.shipmentPromises![0].released, undefined);
|
||||
assert.equal(complete(startTarget(gifted)).shipmentPromises![0].fulfilled, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a waiting Bene Gesserit can accompany only while retaining a legal promised reserve shipment', () => {
|
||||
for (const reserves of [1, 2]) {
|
||||
const g = fixture(6);
|
||||
player(g, 'p').faction = 'beneGesserit';
|
||||
player(g, 'p').name = 'Waiting Bene Gesserit';
|
||||
player(g, 'p').reserves = reserves;
|
||||
player(g, 'p').forces = { 'polar_sink:0': 20 - reserves };
|
||||
const bound = bind(g, 1);
|
||||
const shipped = pass(applyAction(bound, 'a', {
|
||||
type: 'ship', territory: 'carthag', sector: 11, amount: 1,
|
||||
}));
|
||||
assert.equal(shipped.decision?.kind, 'advisor');
|
||||
assert.equal(shipped.shipmentPromises![0].released, undefined);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(shipped, 'p');
|
||||
view.players.find(p => p.id === 'p')!.bot = difficulty;
|
||||
const choices = botActions(view);
|
||||
assert.deepEqual(choices, [{ type: 'decision', accept: false }]);
|
||||
assert.equal(applyAction(shipped, 'p', choices[0]).decision, null);
|
||||
}
|
||||
if (reserves === 1) reject(shipped, 'p', { type: 'decision', accept: true });
|
||||
const choice = pass(applyAction(shipped, 'p', { type: 'decision', accept: reserves === 2 }));
|
||||
assert.equal(player(choice, 'p').reserves, 1);
|
||||
const done = complete(startTarget(choice));
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(done, 'p').forces['carthag:11'], 1);
|
||||
}
|
||||
});
|
||||
|
||||
for (const stop of [false, true]) void test(
|
||||
`a foreign approved shipment locks outgoing funding until ${stop ? 'stop refunds it for the waiting completion' : 'delivery spends it and releases the impossible answer'}`, () => {
|
||||
const g = fixture(6, true); player(g, 'p').ally = 'a'; player(g, 'a').ally = 'p';
|
||||
const karama = hold(g, 'o', 'karama');
|
||||
const pledged = applyAction(g, 'p', { type: 'pledgeAid', amount: 2 });
|
||||
const bound = bind(pledged), pending = applyAction(bound, 'a', {
|
||||
type: 'ship', territory: 'carthag', sector: 11, amount: 2, allyPayment: 2,
|
||||
});
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.shipmentPromises![0].released, undefined);
|
||||
reject(pending, 'p', { type: 'pledgeAid', amount: 0 });
|
||||
const settled = pass(applyAction(pending, 'o', stop
|
||||
? { type: 'card', mode: 'special', card: karama }
|
||||
: { type: 'decision', allow: true }));
|
||||
assert.equal(settled.shipmentPromises![0].released === true, !stop);
|
||||
assert.equal(settled.aid.p.amount, stop ? 2 : 0);
|
||||
if (stop) {
|
||||
const done = complete(startTarget(settled));
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(done, 'p').forces['carthag:11'], 6);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user