Fix shipment promise forecasts and movement openings

This commit is contained in:
Quintine committed 2026-10-27 19:57:01 +11:00
1 parent 21f95bdd5b
commit cd1f726593
9 files changed
+277 -5

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe';
void test('a Basic Kull phase ban cannot fund a later shipment with the blocked auction Karama', () => {
// The shared fixture selects the original unopened deal and Spice position;
// it uses public Basic opt-in and real setup. No wallet or force is staged.
const f = createKullBribeFixture();
let game = structuredClone(f.charity);
game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === f.actor);
assert.equal(player(game, f.actor).spice, 10);
const lot = game.auction!.cards[game.auction!.index].id;
// The holder actually attempts an ordinary free purchase. CHOAM interrupts
// with its original Kull; everyone with a counter opportunity passes.
game = act(game, f.actor, { type: 'card', mode: 'purchase', card: f.karama });
const reaction = viewGame(game, f.reactor).kullReaction;
assert.ok(reaction && reaction.plays.some(play => play.card.id === f.kull && !play.blocked));
game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
game = advance(game, clean);
assert.equal(game.phase, 3);
assert.ok(game.kullRestrictions?.some(row => row.player === f.actor && row.turn === game.turn && row.phase === 3));
assert.ok(player(game, f.actor).hand.some(card => card.id === f.karama));
assert.ok(viewGame(game, f.actor).karamaBlocked);
const blockedPurchase = structuredClone(game);
assert.throws(() => act(game, f.actor, { type: 'card', mode: 'purchase', card: f.karama }), /Kull Wahad/);
assert.deepEqual(game, blockedPurchase);
// Seven of ten spice is an actually funded bid; the real invoice leaves
// three. Eight to Carthag costs eight normally or four at the Karama rate.
// The one retained Karama cannot both evade this Bidding invoice (Kull) and
// legitimately fund that shipment after cash payment.
assert.equal(game.auction?.active, f.actor);
game = act(game, f.actor, { type: 'bid', amount: 7, allyPayment: 0 });
assert.equal(game.auction?.bidder, f.actor);
assert.equal(game.auction?.bid, 7);
assert.equal(player(game, f.actor).spice, 10);
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer);
game = act(game, passer.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.actor, territory: 'carthag', minimum: 8,
} });
const respondent = viewGame(game, f.actor);
assert.deepEqual(respondent.truthShipmentAnswers, ['no']);
assert.ok(respondent.shipmentPromisePolicy == null);
assert.equal(respondent.shipmentPreparation, null);
const beforeRejected = structuredClone(game);
assert.throws(() => act(game, f.actor, { type: 'truthAnswer', answer: 'yes' }));
assert.deepEqual(game, beforeRejected);
// The other seat's private counter remains absent from an observer's view.
assert.ok(!JSON.stringify(viewGame(game, f.asker)).includes(f.counter));
game = act(game, f.actor, { type: 'truthAnswer', answer: 'no' });
// Complete the actual auction; the cash invoice settles automatically when
// the blocked Karama leaves no legal alternative payment choice.
game = advance(game, g => player(g, f.actor).hand.some(card => card.id === lot));
assert.equal(player(game, f.actor).spice, 3);
assert.ok(player(game, f.actor).hand.some(card => card.id === lot));
assert.ok(player(game, f.actor).hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(game), f.originalCards);
});
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, advance, clean, createKullBribeFixture, player } from './fixture-kull-bribe';
function shipmentOpening(answer: 'yes' | 'no', amalHolder?: 'emperor' | 'choam') {
const f = createKullBribeFixture();
let game = structuredClone(f.charity);
// Controlled physical-card supplements: return Emperor’s original Karama
// to the unopened deck and move an original Amal to the named hand. No
// force, spice, phase or promise is staged.
let amal: string | undefined;
if (amalHolder) {
const own = player(game, f.actor);
const karamaIndex = own.hand.findIndex(card => card.id === f.karama);
assert.ok(karamaIndex >= 0);
game.deck.push(own.hand.splice(karamaIndex, 1)[0]);
const index = game.deck.findIndex(card => card.effect === 'amal');
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
player(game, amalHolder).hand.push(card);
amal = card.id;
}
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer);
game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.actor, territory: 'carthag', minimum: 8,
} });
assert.ok(viewGame(game, f.actor).truthShipmentAnswers?.includes(answer));
game = act(game, f.actor, { type: 'truthAnswer', answer });
game = advance(game, g => g.phase === 5 && g.phaseOpening?.initialize === true);
assert.equal(game.active, null);
assert.equal(game.movementRemaining, null);
return { f, game, amal };
}
function completeOpening(game: Game) {
while (game.phaseOpening) {
const passer = game.players.find(p => !game.phaseOpening!.passed.includes(p.id));
assert.ok(passer);
game = act(game, passer.id, { type: 'ready' });
}
return game;
}
void test('an early shipment No survives each real Ix Movement opening pass', () => {
const { f, game: opening } = shipmentOpening('no');
const game = completeOpening(opening);
assert.equal(player(game, f.actor).shipped, false);
assert.equal(game.phase, 5);
assert.ok(game.movementRemaining?.includes(f.actor));
assert.equal(game.shipmentPromises?.[0].answer, false);
assert.equal(game.shipmentPromises?.[0].released, undefined);
});
void test('an owner cannot play Amal to defeat a positive shipment promise at the Movement opening', () => {
const { f, game, amal } = shipmentOpening('yes', 'emperor');
assert.ok(amal);
assert.equal(player(game, f.actor).spice, 10);
const before = structuredClone(game);
assert.throws(() => act(game, f.actor, { type: 'card', card: amal }), /Truthtrance shipment/);
assert.deepEqual(game, before);
});
void test('another player’s Amal releases an impossible shipment only after the opening settles', () => {
const { f, game: opening, amal } = shipmentOpening('yes', 'choam');
assert.ok(amal);
let game = act(opening, f.reactor, { type: 'card', card: amal });
assert.equal(player(game, f.actor).spice, 5);
assert.equal(game.shipmentPromises?.[0].released, undefined);
game = completeOpening(game);
game = advance(game, clean);
assert.equal(game.shipmentPromises?.[0].released, true);
});
void test('a second-turn shipment promise survives the opening after a real previous shipment', () => {
const f = createKullBribeFixture();
let game = structuredClone(f.game);
assert.equal(game.turn, 1);
assert.equal(game.phase, 5);
assert.equal(game.active, f.actor);
game = act(game, f.actor, { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 1, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(player(game, f.actor).shipped, true);
game = act(game, f.actor, { type: 'endMovement' });
game = advance(game, g => g.turn === 2 && g.phase === 2 && clean(g));
assert.deepEqual(game.movementRemaining, []);
assert.equal(player(game, f.actor).shipped, true);
game = act(game, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(p => !game.truthtrance!.passed.includes(p.id));
assert.ok(passer);
game = act(game, passer.id, { type: 'truthPass' });
}
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.actor, territory: 'carthag', minimum: 8,
} });
assert.ok(viewGame(game, f.actor).truthShipmentAnswers?.includes('no'));
game = act(game, f.actor, { type: 'truthAnswer', answer: 'no' });
game = advance(game, g => g.turn === 2 && g.phase === 5 && g.phaseOpening?.initialize === true);
assert.deepEqual(game.movementRemaining, []);
assert.equal(player(game, f.actor).shipped, true);
game = completeOpening(game);
assert.equal(player(game, f.actor).shipped, false);
assert.ok(game.movementRemaining?.includes(f.actor));
assert.ok(game.shipmentPromises?.some(p => p.turn === 2 && p.answer === false && !p.released));
});
+2 -2
View File
@@ -66,7 +66,7 @@ function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
void test('current-turn target timing distinguishes earlier phases from unused movement turns and evaluates the actual Semuta respondent', () => {
void test('current-turn target timing distinguishes earlier phases from unused movement turns and admits native Richese', () => {
const g = fixture();
assert.equal(shipmentPromiseTurnAvailable(g, 'a', true), true);
assert.equal(shipmentPromiseTurnAvailable(g, 'p', true), true);
@@ -82,7 +82,7 @@ void test('current-turn target timing distinguishes earlier phases from unused m
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);
assert.equal(shipmentPromiseModeSupported(mode, 'r'), true);
});
void test('a waiting promise supplies no early executable action, survives the intervening turn and fulfills only actual later delivery', () => {