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.
174 lines
9.3 KiB
TypeScript
174 lines
9.3 KiB
TypeScript
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);
|
||
}
|
||
});
|