Support Truthtrance shipment promises with Advanced Guild
Admit base Advanced Guild through the existing active unused-shipment predicate. Preserve native interception, typed transport permission, rate cancellation and actual payment/arrival. Opposing stops evaluate the complete promise against absent shipment; self-interception cannot voluntarily evade a required shipment. Share the authoritative self-stop reason with controls, and hide executable completion guidance during pending native windows. No other profile opens or strategic AI tuning is added. Validation and exact frozen-source evidence are in the canonical Truthtrance shipment contract. Public classic Advanced starts earn real physical Truthtrance/Karamas via the unopened original starting deal, native Guild TAKE/WAIT, typed Emperor delivery or opposing stop, and Guild half/full-price routes. Four minimal profiles use legal native continuations. Separate controlled probes cover No/mixed claims, self-stop, unaffordable repricing/owned alternatives and Fremen route denial. Update existing scope expectations; no new recovery campaign. Broader timings/routes/modules, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
1 parent
377e9c5160
commit
2939ffa900
16 files changed
+678
-53
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type TruthtranceGuildShipmentStep = { actor: string; action: Action };
|
||||
export const truthtranceGuildShipmentPlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const truthtranceGuildShipmentClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actTruthtranceGuildShipment(game: Game, actor: string, action: Action,
|
||||
actions: TruthtranceGuildShipmentStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Only original native continuations. Questions, answers and shipment
|
||||
* declarations remain explicit; ordinary responses allow their declared effect. */
|
||||
export function nextTruthtranceGuildShipmentStep(game: Game): TruthtranceGuildShipmentStep | 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) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'guildTiming') return { actor: decision.player,
|
||||
action: { type: 'decision', take: false } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthtranceGuildShipment(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthtranceGuildShipmentStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Truthtrance/Guild programme ended before its next control');
|
||||
const step = nextTruthtranceGuildShipmentStep(game); assert.ok(step, 'Choose the original structured question and answer explicitly');
|
||||
game = actTruthtranceGuildShipment(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Truthtrance/Guild programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** The actual public Advanced classic start deals the selected unopened
|
||||
* Truthtrance to Atreides and distinct original Karamas to Emperor/Guild.
|
||||
* Native TAKE/WAIT controls create the current unused reserve shipment.
|
||||
* No postdeal card, resource, army, phase, timing flag or promise is installed. */
|
||||
export function createTruthtranceGuildShipmentFixture(options: { target?: 'emperor' | 'guild' } = {}) {
|
||||
const target = options.target ?? 'emperor';
|
||||
let game = createGame('TRUTHGUILDSHIPMENT', newPlayer('atreides', 'Atreides', 'atreides'), true);
|
||||
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: TruthtranceGuildShipmentStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthtranceGuildShipment(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceGuildShipment(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start', advancedPreview: true });
|
||||
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 karmas = game.deck.filter(card => card.effect === 'karama');
|
||||
assert.ok(truth && karmas.length === 2);
|
||||
const deal = [truth.id, karmas[0].id, karmas[1].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(truthtranceGuildShipmentPlayer(game, 'atreides').hand.some(card => card.id === truth.id));
|
||||
assert.ok(truthtranceGuildShipmentPlayer(game, 'emperor').hand.some(card => card.id === karmas[0].id));
|
||||
assert.ok(truthtranceGuildShipmentPlayer(game, 'guild').hand.some(card => card.id === karmas[1].id));
|
||||
for (let count = 0; count < 2; 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);
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
||||
const timing: { before: Game; step: TruthtranceGuildShipmentStep; declared: Game; after: Game }[] = [];
|
||||
const chooseTiming = (take: boolean) => {
|
||||
assert.equal(game.decision?.kind, 'guildTiming');
|
||||
const before = structuredClone(game), step: TruthtranceGuildShipmentStep = { actor: 'guild', action: { type: 'decision', take } };
|
||||
act(step.actor, step.action);
|
||||
const declared = structuredClone(game);
|
||||
reach(truthtranceGuildShipmentClean);
|
||||
timing.push({ before, step, declared, after: structuredClone(game) });
|
||||
};
|
||||
chooseTiming(target === 'guild');
|
||||
let completedPrefix: { before: Game; step: TruthtranceGuildShipmentStep; after: Game } | null = null;
|
||||
if (target === 'emperor') {
|
||||
assert.equal(game.active, 'atreides');
|
||||
const before = structuredClone(game), step: TruthtranceGuildShipmentStep = { actor: 'atreides', action: { type: 'endMovement' } };
|
||||
act(step.actor, step.action);
|
||||
completedPrefix = { before, step, after: structuredClone(game) };
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
||||
chooseTiming(false);
|
||||
}
|
||||
assert.equal(game.active, target);
|
||||
assert.equal(truthtranceGuildShipmentPlayer(game, target).shipped, false);
|
||||
assert.equal(truthtranceGuildShipmentPlayer(game, target).moved, 0);
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, timing, completedPrefix,
|
||||
asker: 'atreides', target, guild: 'guild', rateCanceler: 'emperor', card: truth.id,
|
||||
guildKarama: karmas[1].id, rateKarama: karmas[0].id, location: 'carthag:11',
|
||||
sourceActionCount: actions.length, actions,
|
||||
staging: ['Only the three unopened original starting-deal positions, original land Spice positions and scalar lotteries are selected. Public Advanced classic setup actually deals the physical Truthtrance and distinct Karamas; native Guild TAKE/WAIT and an optional explicit completed Atreides turn create the unused shipment. No expansions/modules or postdeal hands, resources, forces, clocks, timing flags or promise receipts are installed.'] };
|
||||
}
|
||||
export type TruthtranceGuildShipmentFixture = ReturnType<typeof createTruthtranceGuildShipmentFixture>;
|
||||
|
||||
export function askTruthtranceGuildShipment(fixture: TruthtranceGuildShipmentFixture) {
|
||||
const actions: TruthtranceGuildShipmentStep[] = [], before = structuredClone(fixture.game);
|
||||
const declaration: TruthtranceGuildShipmentStep = { actor: fixture.asker,
|
||||
action: { type: 'card', card: fixture.card } };
|
||||
let game = actTruthtranceGuildShipment(before, declaration.actor, declaration.action, actions);
|
||||
const opened = structuredClone(game);
|
||||
game = advanceTruthtranceGuildShipment(game, game => game.truthtrance?.stage === 'ask', actions);
|
||||
const beforeQuestion = structuredClone(game), question: TruthtranceGuildShipmentStep = { actor: fixture.asker, action: {
|
||||
type: 'truthAsk', question: { kind: 'shipment', target: fixture.target, territory: 'carthag', minimum: 3 },
|
||||
} };
|
||||
game = actTruthtranceGuildShipment(game, question.actor, question.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
return { game, before, declaration, opened, beforeQuestion, question, actions };
|
||||
}
|
||||
export function bindTruthtranceGuildShipment(fixture: TruthtranceGuildShipmentFixture) {
|
||||
const asked = askTruthtranceGuildShipment(fixture), actions = [...asked.actions];
|
||||
const answer: TruthtranceGuildShipmentStep = { actor: fixture.target, action: { type: 'truthAnswer', answer: 'yes' } };
|
||||
const game = actTruthtranceGuildShipment(asked.game, answer.actor, answer.action, actions);
|
||||
assert.ok(truthtranceGuildShipmentClean(game));
|
||||
return { ...asked, game, asked: asked.game, answer, actions };
|
||||
}
|
||||
export function declareTruthtranceGuildShipment(fixture: TruthtranceGuildShipmentFixture, state: Game) {
|
||||
const before = structuredClone(state), step: TruthtranceGuildShipmentStep = { actor: fixture.target, action: {
|
||||
type: 'ship', territory: 'carthag', sector: 11, amount: 3,
|
||||
elite: fixture.target === 'emperor' ? 1 : 0, allyPayment: 0,
|
||||
} }, actions: TruthtranceGuildShipmentStep[] = [];
|
||||
const game = actTruthtranceGuildShipment(before, step.actor, step.action, actions);
|
||||
assert.equal(game.decision?.kind, 'guildShipment');
|
||||
return { before, step, game, actions };
|
||||
}
|
||||
|
||||
/** Resolve real permission, the printed Guild stop or an original opposing
|
||||
* Karama rate cancellation before native payment/arrival. Explicitly ending
|
||||
* the respondent's actual turn proves the settled promise no longer blocks it. */
|
||||
export function completeTruthtranceGuildShipment(fixture: TruthtranceGuildShipmentFixture,
|
||||
mode: 'allow' | 'stop' | 'cancelRate' = 'allow') {
|
||||
const binding = bindTruthtranceGuildShipment(fixture), declaration = declareTruthtranceGuildShipment(fixture, binding.game);
|
||||
const actions = [...binding.actions, ...declaration.actions];
|
||||
let game = declaration.game;
|
||||
assert.ok(mode !== 'stop' || fixture.target !== fixture.guild, 'The bounded stop scene targets the opposing Emperor');
|
||||
assert.ok(mode !== 'cancelRate' || fixture.target === fixture.guild, 'The bounded repricing scene targets the native Guild');
|
||||
const choice: TruthtranceGuildShipmentStep = { actor: fixture.guild, action: mode === 'stop'
|
||||
? { type: 'card', mode: 'special', card: fixture.guildKarama }
|
||||
: { type: 'decision', allow: true } };
|
||||
game = actTruthtranceGuildShipment(game, choice.actor, choice.action, actions);
|
||||
const afterPermission = structuredClone(game);
|
||||
let rate: { before: Game; step: TruthtranceGuildShipmentStep; after: Game } | null = null;
|
||||
if (mode === 'cancelRate') {
|
||||
assert.equal(game.response?.kind, 'guildRate');
|
||||
const before = structuredClone(game), step: TruthtranceGuildShipmentStep = { actor: fixture.rateCanceler,
|
||||
action: { type: 'card', mode: 'cancel', card: fixture.rateKarama } };
|
||||
game = actTruthtranceGuildShipment(game, step.actor, step.action, actions);
|
||||
rate = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
game = advanceTruthtranceGuildShipment(game,
|
||||
game => truthtranceGuildShipmentClean(game) && !game.pendingShipment, actions);
|
||||
const settled = structuredClone(game), end: TruthtranceGuildShipmentStep = { actor: fixture.target, action: { type: 'endMovement' } };
|
||||
game = actTruthtranceGuildShipment(game, end.actor, end.action, actions);
|
||||
return { game, binding, declaration, choice, afterPermission, rate, settled, end, mode, actions };
|
||||
}
|
||||
@@ -543,10 +543,6 @@ void test('corrupt persisted promise records and pending shipment questions reje
|
||||
(g) => {
|
||||
g.phase = 6;
|
||||
},
|
||||
(g) => {
|
||||
g.advanced = true;
|
||||
g.players[2].faction = 'guild';
|
||||
},
|
||||
(g) => {
|
||||
g.expansions = ['ix'];
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ void test('Advanced shipment scope excludes separately enabled optional modules
|
||||
...base,
|
||||
players: [...base.players, { faction: 'guild' }],
|
||||
}),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
for (const option of [
|
||||
'techTokens',
|
||||
@@ -528,7 +528,6 @@ void test('malformed questions and unsupported public scopes reject without disc
|
||||
},
|
||||
});
|
||||
for (const change of [
|
||||
'advanced',
|
||||
'expansion',
|
||||
'phase',
|
||||
'spent',
|
||||
@@ -536,10 +535,6 @@ void test('malformed questions and unsupported public scopes reject without disc
|
||||
'territory',
|
||||
]) {
|
||||
const bad = structuredClone(g);
|
||||
if (change === 'advanced') {
|
||||
bad.advanced = true;
|
||||
bad.players[2].faction = 'guild';
|
||||
}
|
||||
if (change === 'expansion') bad.expansions = ['ix'];
|
||||
if (change === 'phase') bad.phase = 4;
|
||||
if (change === 'spent') bad.players[1].shipped = true;
|
||||
@@ -607,10 +602,6 @@ void test('saved malformed promise bindings reject before action or normalizatio
|
||||
g.shipmentPromises![0].fulfilled = true;
|
||||
g.shipmentPromises![0].released = true;
|
||||
},
|
||||
(g) => {
|
||||
g.advanced = true;
|
||||
g.players[2].faction = 'guild';
|
||||
},
|
||||
(g) => {
|
||||
g.phase = 4;
|
||||
},
|
||||
|
||||
@@ -568,29 +568,27 @@ void test('unrelated opponent cards and wealth cannot change Advanced answer or
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Guild and selected expansions reject unsupported questions immutably before binding or discard', () => {
|
||||
for (const scope of ['guild', 'expansion'] as const) {
|
||||
const g = fixture('emperor', scope === 'guild' ? ['guild'] : []);
|
||||
if (scope === 'expansion') g.expansions = ['ix'];
|
||||
const pending = openQuestion(g);
|
||||
reject(pending, 'a', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'shipment',
|
||||
target: 'p',
|
||||
territory: 'carthag',
|
||||
minimum: 6,
|
||||
},
|
||||
});
|
||||
assert.equal(pending.shipmentPromises?.length ?? 0, 0);
|
||||
assert.equal(
|
||||
pending.discard.filter((c) => c.effect === 'truthtrance').length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
player(pending, 'a').hand.filter((c) => c.effect === 'truthtrance')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
void test('selected expansions still reject unsupported questions immutably before binding or discard', () => {
|
||||
const g = fixture('emperor');
|
||||
g.expansions = ['ix'];
|
||||
const pending = openQuestion(g);
|
||||
reject(pending, 'a', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'shipment',
|
||||
target: 'p',
|
||||
territory: 'carthag',
|
||||
minimum: 6,
|
||||
},
|
||||
});
|
||||
assert.equal(pending.shipmentPromises?.length ?? 0, 0);
|
||||
assert.equal(
|
||||
pending.discard.filter((c) => c.effect === 'truthtrance').length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
player(pending, 'a').hand.filter((c) => c.effect === 'truthtrance')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actTruthtranceGuildShipment, advanceTruthtranceGuildShipment, askTruthtranceGuildShipment,
|
||||
completeTruthtranceGuildShipment, createTruthtranceGuildShipmentFixture,
|
||||
truthtranceGuildShipmentClean, truthtranceGuildShipmentPlayer as player,
|
||||
type TruthtranceGuildShipmentFixture,
|
||||
} from './fixture-truthtrance-guild-shipment';
|
||||
|
||||
function source(f: TruthtranceGuildShipmentFixture) {
|
||||
assert.equal(f.game.advanced, true);
|
||||
assert.deepEqual(f.game.expansions, []);
|
||||
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.homeworlds && !f.game.nexusCards);
|
||||
assert.ok(!f.game.leaderSkills && !f.game.discoveryEnabled);
|
||||
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start' && step.action.advancedPreview === true));
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 3).map(card => card.id), [f.card, f.rateKarama, f.guildKarama]);
|
||||
assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
|
||||
assert.ok(player(f.afterSetup, f.rateCanceler).hand.some(card => card.id === f.rateKarama));
|
||||
assert.ok(player(f.afterSetup, f.guild).hand.some(card => card.id === f.guildKarama));
|
||||
assert.notEqual(f.guildKarama, f.rateKarama);
|
||||
assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.game.active, f.target);
|
||||
assert.equal(player(f.game, f.target).shipped, false);
|
||||
assert.equal(player(f.game, f.target).moved, 0);
|
||||
assert.equal(f.game.guildTimingLocked, false);
|
||||
assert.equal(viewGame(f.game, f.asker).truthShipmentSupported, true);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function bound(f: TruthtranceGuildShipmentFixture, result: ReturnType<typeof completeTruthtranceGuildShipment>) {
|
||||
assert.ok(viewGame(result.binding.asked, f.target).truthShipmentAnswers!.includes('yes'));
|
||||
assert.equal(result.binding.game.truthtrance, null);
|
||||
assert.equal(result.binding.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(result.binding.game.shipmentPromises, [{
|
||||
turn: f.game.turn, player: f.target, asker: f.asker,
|
||||
territory: 'carthag', minimum: 3, answer: true,
|
||||
}]);
|
||||
assert.equal(result.declaration.game.decision?.kind, 'guildShipment');
|
||||
assert.equal(result.declaration.game.pendingShipment!.elite, f.target === 'emperor' ? 1 : 0);
|
||||
assert.deepEqual(result.declaration.game.players.map(player => ({ spice: player.spice, reserves: player.reserves,
|
||||
forces: player.forces, elites: player.elites })), result.binding.game.players.map(player => ({ spice: player.spice,
|
||||
reserves: player.reserves, forces: player.forces, elites: player.elites })));
|
||||
assert.equal(result.declaration.game.shipmentPromises![0].fulfilled, undefined);
|
||||
assert.equal(result.declaration.game.shipmentPromises![0].released, undefined);
|
||||
assert.equal(viewGame(result.declaration.game, f.target).shipmentCompletion, null,
|
||||
'The permission decision has no concurrently executable completion guide');
|
||||
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(player(result.settled, f.target).shipped, true);
|
||||
assert.equal(player(result.settled, f.target).moved, 0);
|
||||
assert.equal(result.settled.pendingShipment, null);
|
||||
assert.equal(result.game.movementRemaining!.includes(f.target), false);
|
||||
assert.equal(result.actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
|
||||
assert.equal(result.actions.filter(step => step.actor === f.target && step.action.type === 'endMovement').length, 1);
|
||||
}
|
||||
|
||||
void test('genuine Advanced Guild WAIT and allowance deliver the promised typed reserve shipment with one native payment and fulfillment', () => {
|
||||
const f = createTruthtranceGuildShipmentFixture(); source(f);
|
||||
assert.ok(f.completedPrefix);
|
||||
assert.equal(f.timing.length, 2);
|
||||
assert.ok(f.timing.every(choice => choice.step.action.take === false));
|
||||
const result = completeTruthtranceGuildShipment(f, 'allow'); bound(f, result);
|
||||
assert.equal(player(result.binding.game, f.target).spice - player(result.settled, f.target).spice, 3);
|
||||
assert.equal(player(result.settled, f.guild).spice - player(result.binding.game, f.guild).spice, 3);
|
||||
assert.equal(player(result.binding.game, f.target).reserves - player(result.settled, f.target).reserves, 3);
|
||||
assert.equal(player(result.binding.game, f.target).elites!.reserves - player(result.settled, f.target).elites!.reserves, 1);
|
||||
assert.equal(player(result.settled, f.target).forces[f.location], 3);
|
||||
assert.equal(player(result.settled, f.target).elites!.forces[f.location], 1);
|
||||
assert.equal(result.settled.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(result.settled.shipmentPromises![0].released, undefined);
|
||||
assert.equal(player(result.settled, f.guild).specialKaramaUsed, undefined);
|
||||
assert.equal(result.settled.discard.some(card => card.id === f.guildKarama), false);
|
||||
});
|
||||
|
||||
void test('genuine Guild special Karama stop consumes the promised opportunity without payment or arrival and publicly releases the impossible positive', () => {
|
||||
const f = createTruthtranceGuildShipmentFixture(); source(f);
|
||||
const result = completeTruthtranceGuildShipment(f, 'stop'); bound(f, result);
|
||||
assert.deepEqual(result.settled.players.map(player => ({ spice: player.spice, reserves: player.reserves,
|
||||
forces: player.forces, elites: player.elites })), result.binding.game.players.map(player => ({ spice: player.spice,
|
||||
reserves: player.reserves, forces: player.forces, elites: player.elites })));
|
||||
assert.equal(player(result.settled, f.target).forces[f.location], undefined);
|
||||
assert.equal(player(result.settled, f.guild).specialKaramaUsed, true);
|
||||
assert.equal(result.settled.discard.filter(card => card.id === f.guildKarama).length, 1);
|
||||
assert.equal(result.settled.shipmentPromises![0].released, true);
|
||||
assert.equal(result.settled.shipmentPromises![0].fulfilled, undefined);
|
||||
assert.equal(viewGame(result.settled, f.asker).shipmentPromises[0].released, true);
|
||||
assert.ok(result.settled.log.slice(result.binding.game.log.length)
|
||||
.some(entry => /Truthtrance.*no longer binding|Truthtrance.*released/i.test(entry.text)));
|
||||
assert.throws(() => actTruthtranceGuildShipment(result.settled, f.target, result.declaration.step.action));
|
||||
assert.throws(() => actTruthtranceGuildShipment(result.settled, f.guild, result.choice.action));
|
||||
});
|
||||
|
||||
void test('genuine Guild TAKE reserve promise survives an actual opposing Karama rate cancellation and pays the funded full tariff once', () => {
|
||||
const f = createTruthtranceGuildShipmentFixture({ target: 'guild' }); source(f);
|
||||
assert.equal(f.timing.length, 1);
|
||||
assert.equal(f.timing[0].step.action.take, true);
|
||||
assert.equal(f.timing[0].declared.response?.kind, 'guildTiming');
|
||||
assert.ok(f.actions.some(step => step.actor === f.rateCanceler && step.action.type === 'passResponse'));
|
||||
assert.equal(f.game.guildTimingGranted, true);
|
||||
const halfRate = completeTruthtranceGuildShipment(f, 'allow'); bound(f, halfRate);
|
||||
assert.equal(player(halfRate.binding.game, f.target).spice - player(halfRate.settled, f.target).spice, 2);
|
||||
assert.equal(player(halfRate.settled, f.target).forces[f.location], 3);
|
||||
assert.equal(halfRate.settled.shipmentPromises![0].fulfilled, true);
|
||||
assert.ok(player(halfRate.settled, f.rateCanceler).hand.some(card => card.id === f.rateKarama));
|
||||
const result = completeTruthtranceGuildShipment(f, 'cancelRate'); bound(f, result);
|
||||
assert.ok(result.rate);
|
||||
assert.equal(result.rate.before.response?.kind, 'guildRate');
|
||||
assert.equal(result.rate.before.pendingShipment!.cost, 2, 'The actual original declaration earns the native half price');
|
||||
assert.equal(viewGame(result.rate.before, f.target).shipmentCompletion, null);
|
||||
assert.ok(viewGame(result.rate.before, f.rateCanceler).responseControls!.cancelCards.includes(f.rateKarama));
|
||||
assert.equal(player(result.binding.game, f.target).spice - player(result.settled, f.target).spice, 3);
|
||||
assert.equal(player(result.binding.game, f.target).reserves - player(result.settled, f.target).reserves, 3);
|
||||
assert.equal(player(result.settled, f.target).forces[f.location], 3);
|
||||
assert.equal(result.settled.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(result.settled.shipmentPromises![0].released, undefined);
|
||||
assert.equal(result.settled.discard.filter(card => card.id === f.rateKarama).length, 1);
|
||||
assert.equal(player(result.settled, f.guild).specialKaramaUsed, undefined);
|
||||
assert.ok(player(result.settled, f.guild).hand.some(card => card.id === f.guildKarama));
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer the real shipment question and use authoritative legal completion through native Guild permission', () => {
|
||||
const f = createTruthtranceGuildShipmentFixture(), asked = askTruthtranceGuildShipment(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const targetView = viewGame(asked.game, f.target);
|
||||
targetView.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(targetView).find(action => action.type === 'truthAnswer'); assert.ok(answer);
|
||||
assert.ok(answer.answer === 'yes' || answer.answer === 'no' || answer.answer === 'unknown');
|
||||
assert.ok(targetView.truthShipmentAnswers!.includes(answer.answer));
|
||||
assert.equal(answer.answer, 'yes', 'The original funded stronghold predicate remains an ordinary positive policy choice');
|
||||
let game = actTruthtranceGuildShipment(structuredClone(asked.game), f.target, answer);
|
||||
const promised = viewGame(game, f.target);
|
||||
promised.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const shipment = botActions(promised).find(action => action.type === 'ship'); assert.ok(shipment);
|
||||
assert.equal(shipment.territory, 'carthag');
|
||||
assert.ok(Number(shipment.amount) >= 3);
|
||||
game = actTruthtranceGuildShipment(game, f.target, shipment);
|
||||
assert.equal(game.decision?.kind, 'guildShipment');
|
||||
const guildView = viewGame(game, f.guild);
|
||||
guildView.players.find(player => player.id === f.guild)!.bot = difficulty;
|
||||
const permission = botActions(guildView).find(action => action.type === 'decision' && action.allow === true ||
|
||||
action.type === 'card' && action.mode === 'special' && action.card === f.guildKarama); assert.ok(permission);
|
||||
game = actTruthtranceGuildShipment(game, f.guild, permission);
|
||||
game = advanceTruthtranceGuildShipment(game,
|
||||
game => truthtranceGuildShipmentClean(game) && !game.pendingShipment);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(game, f.target).shipped, true);
|
||||
assert.ok(game.shipmentPromises![0].fulfilled || game.shipmentPromises![0].released);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, newPlayer, prepareSpecialKaramaIntent, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { territory } from '../game/board';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
// Controlled response probes, distinct from the original-action fixture.
|
||||
function fixture(target: 'guild' | 'emperor' | 'fremen' = 'emperor', spice = 12, ownKarama = false) {
|
||||
const g = createGame('GUILDTRUTHPROBE', newPlayer('a', 'Asker', 'atreides'), true);
|
||||
g.players.push(newPlayer('p', 'Respondent', target));
|
||||
g.players.push(newPlayer('o', 'Other', target === 'guild' ? 'emperor' : 'guild'));
|
||||
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18,
|
||||
active: 'p', order: ['p', 'a', 'o'], movementRemaining: ['p', 'a', 'o'], deck: baseDeck() });
|
||||
for (const p of g.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; }
|
||||
player(g, 'p').spice = spice;
|
||||
const hold = (id: 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, id).hand.push(card); return card.id;
|
||||
};
|
||||
const truth = hold('a', 'truthtrance');
|
||||
const guild = target === 'guild' ? 'p' : 'o';
|
||||
const cancel = hold('a', 'karama');
|
||||
const special = hold(ownKarama ? 'p' : guild === 'p' ? 'o' : guild, 'karama');
|
||||
return { game: g, truth, cancel, special, guild };
|
||||
}
|
||||
function bind(f: ReturnType<typeof fixture>, answer = 'yes', minimum = 6, mixed = false) {
|
||||
let g = applyAction(f.game, 'a', { type: 'card', card: f.truth });
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const actor = g.players.find(p => !g.truthtrance!.passed.includes(p.id))!;
|
||||
g = applyAction(g, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
const leaf = { kind: 'shipment', territory: 'carthag', minimum };
|
||||
const question = mixed ? { kind: 'mixedShipment', target: 'p', mixed: { kind: 'or', terms: [
|
||||
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, leaf,
|
||||
] } } : { ...leaf, target: 'p' };
|
||||
g = applyAction(g, 'a', { type: 'truthAsk', question });
|
||||
assert.ok(viewGame(g, 'p').truthShipmentAnswers?.includes(answer as 'yes' | 'no'));
|
||||
return applyAction(g, 'p', { type: 'truthAnswer', answer });
|
||||
}
|
||||
function ship(g: Game, amount = 6) {
|
||||
return applyAction(g, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount, allyPayment: 0 });
|
||||
}
|
||||
function allowInterception(g: Game, guild: string) {
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
assert.equal(viewGame(g, 'p').shipmentCompletion, null, 'No executable guide during interception');
|
||||
return applyAction(g, guild, { type: 'decision', allow: true });
|
||||
}
|
||||
function pass(g: Game) {
|
||||
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 reject(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
for (const [answer, amount, mixed] of [['yes', 6, false], ['no', 1, false], ['yes', 1, true]] as const)
|
||||
void test(`opposing Guild stop settles ${mixed ? 'mixed true OR shipment' : answer} from the actual absent shipment`, () => {
|
||||
const f = fixture(), bound = bind(f, answer, 6, mixed), pending = ship(bound, amount);
|
||||
assert.equal(pending.shipmentPromises![0].fulfilled, undefined);
|
||||
assert.equal(pending.shipmentPromises![0].released, undefined);
|
||||
const done = applyAction(pending, f.guild, { type: 'card', mode: 'special', card: f.special });
|
||||
const released = answer === 'yes' && !mixed;
|
||||
assert.equal(done.shipmentPromises![0].released === true, released);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled === true, !released);
|
||||
assert.equal(player(done, 'p').shipped, true);
|
||||
assert.deepEqual(done.players.map(p => [p.spice, p.reserves, p.forces]), bound.players.map(p => [p.spice, p.reserves, p.forces]));
|
||||
assert.equal(player(done, f.guild).specialKaramaUsed, true);
|
||||
assert.equal(done.discard.filter(c => c.id === f.special).length, 1);
|
||||
assert.equal(done.discard.filter(c => c.id === f.truth).length, 1);
|
||||
assert.equal(done.log.some(row => /shipment answer is no longer binding/.test(row.text)), released);
|
||||
reject(done, f.guild, { type: 'card', mode: 'special', card: f.special });
|
||||
});
|
||||
|
||||
void test('Guild cannot evade its own positive answer by self-interception, but may stop a nonmatching No shipment', () => {
|
||||
const f = fixture('guild', 12, true), pending = ship(bind(f));
|
||||
const action = { type: 'card', mode: 'special', card: f.special };
|
||||
assert.match(viewGame(pending, 'p').guildShipmentStopPromiseBlock!, /Honor your Truthtrance/);
|
||||
assert.throws(() => prepareSpecialKaramaIntent(pending, 'p', action), /Honor your Truthtrance/);
|
||||
reject(pending, 'p', action);
|
||||
assert.equal(player(pending, 'p').specialKaramaUsed, undefined);
|
||||
const negative = ship(bind(f, 'no'), 1);
|
||||
assert.equal(viewGame(negative, 'p').guildShipmentStopPromiseBlock, null);
|
||||
const done = applyAction(negative, 'p', action);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(done.shipmentPromises![0].released, undefined);
|
||||
});
|
||||
|
||||
for (const [spice, ownKarama] of [[6, false], [3, false], [3, true]] as const)
|
||||
void test(`Guild rate cancellation with ${spice} spice and ${ownKarama ? 'owned' : 'no owned'} alternative preserves the real completion`, () => {
|
||||
const f = fixture('guild', spice, ownKarama), bound = bind(f), declared = ship(bound);
|
||||
const rate = allowInterception(declared, f.guild);
|
||||
assert.equal(rate.response?.kind, 'guildRate');
|
||||
assert.equal(viewGame(rate, 'p').shipmentCompletion, null);
|
||||
const done = applyAction(rate, 'a', { type: 'card', mode: 'cancel', card: f.cancel });
|
||||
if (spice === 6) {
|
||||
assert.equal(player(done, 'p').shipped, true);
|
||||
assert.equal(player(done, 'p').spice, 0);
|
||||
assert.equal(player(done, 'p').forces['carthag:11'], 6);
|
||||
assert.equal(done.shipmentPromises![0].fulfilled, true);
|
||||
} else {
|
||||
assert.equal(player(done, 'p').shipped, false);
|
||||
assert.equal(player(done, 'p').spice, spice);
|
||||
assert.equal(player(done, 'p').reserves, 20);
|
||||
assert.equal(done.shipmentPromises![0].released === true, !ownKarama);
|
||||
if (ownKarama) {
|
||||
const preparation = viewGame(done, 'p').shipmentCompletion!.actions[0];
|
||||
assert.equal(preparation.card, f.special); assert.equal(preparation.mode, 'shipment');
|
||||
const prepared = pass(applyAction(done, 'p', preparation));
|
||||
const delivered = pass(allowInterception(ship(prepared), f.guild));
|
||||
assert.equal(delivered.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(delivered, 'p').forces['carthag:11'], 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const destination of ['carthag', 'the_great_flat'] as const) void test(
|
||||
`denied Fremen southern transport ${destination === 'carthag' ? 'releases an unreachable destination' : 'retains ordinary free reinforcement'}`, () => {
|
||||
const f = fixture('fremen', 12);
|
||||
player(f.game, 'p').ally = f.guild; player(f.game, f.guild).ally = 'p';
|
||||
// Reuse the bound grammar while selecting an independent destination.
|
||||
let g = applyAction(f.game, 'a', { type: 'card', card: f.truth });
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const actor = g.players.find(p => !g.truthtrance!.passed.includes(p.id))!;
|
||||
g = applyAction(g, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
g = applyAction(g, 'a', { type: 'truthAsk', question: { kind: 'shipment', target: 'p', territory: destination, minimum: 6 } });
|
||||
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
const sector = territory(destination).sectors[0];
|
||||
g = applyAction(g, 'p', { type: 'guildShip', from: 'reserves', territory: destination, sector, amount: 6 });
|
||||
assert.equal(g.response?.kind, 'guildTransportPermission');
|
||||
assert.equal(viewGame(g, 'p').shipmentCompletion, null);
|
||||
g = applyAction(g, 'a', { type: 'card', mode: 'cancel', card: f.cancel });
|
||||
assert.equal(player(g, 'p').shipped, false);
|
||||
assert.equal(player(g, 'p').reserves, 20);
|
||||
assert.equal(g.shipmentPromises![0].released === true, destination === 'carthag');
|
||||
if (destination === 'the_great_flat') {
|
||||
const action = viewGame(g, 'p').shipmentCompletion!.actions[0];
|
||||
assert.equal(action.type, 'ship'); assert.equal(action.territory, destination);
|
||||
const delivered = applyAction(g, 'p', action);
|
||||
assert.equal(delivered.shipmentPromises![0].fulfilled, true);
|
||||
assert.equal(player(delivered, 'p').spice, 12);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user