235 lines
12 KiB
TypeScript
235 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { viewGame, type Action, type Game } from '../game/engine';
|
||
import { botActions } from '../game/bots';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
import { actTruthAlliance as act, advanceTruthAlliance as advance,
|
||
createTruthAllianceFixture, truthAllianceClean as clean, truthAllianceInventory as inventory,
|
||
truthAlliancePlayer as player } from './fixture-truth-alliance';
|
||
import { declareTruthBribe } from './fixture-truth-bribe';
|
||
|
||
type PledgePromise = { player: string; ally: string; minimum: number; answer: boolean;
|
||
turn: number; phase: number; fulfilled?: boolean; released?: boolean };
|
||
const promise = (game: Game): PledgePromise => {
|
||
const rows = (game as Game & { pledgePromises?: PledgePromise[] }).pledgePromises;
|
||
assert.ok(rows?.length); return rows.at(-1)!;
|
||
};
|
||
function rejected(game: Game, actor: string, action: Action) {
|
||
const before = structuredClone(game);
|
||
assert.throws(() => act(game, actor, action));
|
||
assert.deepEqual(game, before);
|
||
}
|
||
|
||
/** A real Basic setup, storm, worm, ordinary Nexus, reciprocal consent and
|
||
* later Bidding opening. Only the source fixture's original unopened deal and
|
||
* Spice order are selected; no alliance, purse, aid or question is staged. */
|
||
function fixture(phase: 3 | 5 = 3) {
|
||
const base = createTruthAllianceFixture({ consent: 'allied' });
|
||
const game = advance(base.game, state => state.phase === phase && (phase !== 3 || !!state.auction) && clean(state));
|
||
assert.equal(player(game, base.target).ally, base.partner);
|
||
assert.equal(player(game, base.partner).ally, base.target);
|
||
assert.deepEqual(inventory(game), base.originalCards);
|
||
return { ...base, game, ally: base.partner, physical: inventory(game) };
|
||
}
|
||
type Fixture = ReturnType<typeof fixture>;
|
||
function ask(f: Fixture, source: Game, minimum: number) {
|
||
let game = declareTruthBribe(f, source).game;
|
||
const askerView = viewGame(game, f.asker);
|
||
assert.ok(askerView.truthPledgeTargets.includes(f.target));
|
||
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'pledgeAid', target: f.target,
|
||
turn: game.turn, phase: game.phase, ally: f.ally, minimum } });
|
||
return game;
|
||
}
|
||
function answer(game: Game, actor: string, response: 'yes' | 'no') {
|
||
return act(game, actor, { type: 'truthAnswer', answer: response });
|
||
}
|
||
|
||
void test('Basic Yes binds a future accepted same-value ally-funding update without treating it as a payment', () => {
|
||
const f = fixture();
|
||
let game = act(f.game, f.target, { type: 'pledgeAid', amount: 3 });
|
||
const priorSpice = player(game, f.target).spice;
|
||
const originalAid = structuredClone(game.aid[f.target]);
|
||
game = ask(f, game, 3);
|
||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
|
||
assert.equal(viewGame(game, f.asker).truthPledgeAnswers, null);
|
||
game = answer(game, f.target, 'yes');
|
||
assert.equal(promise(game).player, f.target);
|
||
assert.equal(promise(game).ally, f.ally);
|
||
assert.equal(promise(game).minimum, 3);
|
||
assert.ok(!promise(game).fulfilled, 'A pre-answer pledge does not fulfill a future update');
|
||
assert.deepEqual(game.aid[f.target], originalAid);
|
||
const guide = viewGame(game, f.target).pledgePromiseGuide;
|
||
assert.ok(guide); assert.equal(guide.action.type, 'pledgeAid'); assert.equal(guide.action.amount, 3);
|
||
for (const difficulty of DIFFICULTIES) {
|
||
const view = viewGame(game, f.target);
|
||
view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||
const action = botActions(view)[0]; assert.ok(action, difficulty);
|
||
assert.doesNotThrow(() => act(game, f.target, action));
|
||
}
|
||
game = act(game, f.target, guide.action);
|
||
assert.equal(promise(game).fulfilled, true);
|
||
assert.deepEqual(game.aid[f.target], originalAid);
|
||
assert.equal(player(game, f.target).spice, priorSpice);
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
void test('Basic No rejects a qualifying pledge without changing the real wallet or escrow', () => {
|
||
const f = fixture();
|
||
let game = ask(f, f.game, 3);
|
||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('no'));
|
||
game = answer(game, f.target, 'no');
|
||
assert.equal(promise(game).answer, false);
|
||
game = act(game, f.target, { type: 'pledgeAid', amount: 2 });
|
||
const aid = structuredClone(game.aid[f.target]), spice = player(game, f.target).spice;
|
||
rejected(game, f.target, { type: 'pledgeAid', amount: 3 });
|
||
assert.deepEqual(game.aid[f.target], aid);
|
||
assert.equal(player(game, f.target).spice, spice);
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
void test('an unaffordable Yes is unavailable and cannot be forced by the client', () => {
|
||
const f = fixture(), available = player(f.game, f.target).spice;
|
||
const game = ask(f, f.game, available + 1);
|
||
assert.deepEqual(viewGame(game, f.target).truthPledgeAnswers, ['no']);
|
||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||
});
|
||
|
||
void test('an unfulfilled Yes prevents the owner voluntarily spending its remaining pledge capacity', () => {
|
||
const f = fixture();
|
||
let game = ask(f, f.game, 4);
|
||
game = answer(game, f.target, 'yes');
|
||
const available = player(game, f.target).spice;
|
||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: available - 1 });
|
||
assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||
const guide = viewGame(game, f.target).pledgePromiseGuide; assert.ok(guide);
|
||
game = act(game, f.target, guide.action);
|
||
assert.equal(promise(game).fulfilled, true);
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
void test('a No pledge commitment stops applying when actual Bidding closes', () => {
|
||
const f = fixture();
|
||
let game = ask(f, f.game, 3);
|
||
game = answer(game, f.target, 'no');
|
||
game = advance(game, state => state.phase === 4 && clean(state));
|
||
assert.ok(promise(game).released || promise(game).phase !== game.phase,
|
||
'The earlier phase commitment cannot constrain a new phase');
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
void test('Basic Shipment and Movement Yes preserves existing escrow and uses native pledge funding once', () => {
|
||
const f = fixture(5);
|
||
let game = act(f.game, f.target, { type: 'pledgeAid', amount: 1 });
|
||
const before = structuredClone(game), recipientSpice = player(game, f.ally).spice;
|
||
game = ask(f, game, 4);
|
||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
|
||
game = answer(game, f.target, 'yes');
|
||
assert.equal(promise(game).phase, 5);
|
||
assert.equal(game.aid[f.target].amount, 1);
|
||
assert.equal(player(game, f.target).spice, player(before, f.target).spice);
|
||
const guide = viewGame(game, f.target).pledgePromiseGuide;
|
||
assert.ok(guide); assert.equal(guide.action.amount, 4);
|
||
game = act(game, f.target, guide.action);
|
||
assert.equal(game.aid[f.target].recipient, f.ally);
|
||
assert.equal(game.aid[f.target].amount, 4);
|
||
assert.equal(player(game, f.target).spice, player(before, f.target).spice - 3);
|
||
assert.equal(player(game, f.ally).spice, recipientSpice, 'Pledged spice has not been paid to the ally');
|
||
assert.equal(promise(game).fulfilled, true);
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
void test('Basic Shipment and Movement No rejects qualifying aid and retains the accepted lower escrow', () => {
|
||
const f = fixture(5);
|
||
let game = ask(f, f.game, 4);
|
||
game = answer(game, f.target, 'no');
|
||
game = act(game, f.target, { type: 'pledgeAid', amount: 3 });
|
||
const before = structuredClone(game);
|
||
rejected(game, f.target, { type: 'pledgeAid', amount: 4 });
|
||
assert.deepEqual(game.aid[f.target], before.aid[f.target]);
|
||
assert.equal(player(game, f.target).spice, player(before, f.target).spice);
|
||
game = act(game, f.target, { type: 'pledgeAid', amount: 2 });
|
||
assert.equal(game.aid[f.target].amount, 2);
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
for (const response of ['yes', 'no'] as const) {
|
||
void test(`a live ${response} pledge excludes a new independent structured action question`, () => {
|
||
const f = fixture();
|
||
let game = ask(f, f.game, 3);
|
||
game = answer(game, f.target, response);
|
||
const remainingTruth = f.truths.find(id => player(game, f.asker).hand.some(card => card.id === id));
|
||
assert.ok(remainingTruth, 'The native source retains its second physical Truthtrance');
|
||
game = declareTruthBribe({ ...f, truths: [remainingTruth] }, game).game;
|
||
rejected(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||
turn: game.turn, recipient: f.asker, minimum: 1 } });
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
}
|
||
|
||
void test('another player’s final deal acceptance releases a now-unaffordable pledge Yes after the real payment', () => {
|
||
const f = fixture();
|
||
const minimum = 4, available = player(f.game, f.target).spice;
|
||
const amount = available - minimum + 1;
|
||
assert.ok(amount > 0);
|
||
// The donor actually offers and therefore consents to the immediate
|
||
// payment before the later Truthtrance answer. No pending deal is staged.
|
||
let game = act(f.game, f.target, { type: 'dealOffer', target: f.asker,
|
||
terms: 'A previously agreed payment.',
|
||
payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount } });
|
||
const deal = game.deals?.at(-1); assert.ok(deal);
|
||
game = ask(f, game, minimum);
|
||
game = answer(game, f.target, 'yes');
|
||
assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||
const before = structuredClone(game), oldBribes = player(game, f.asker).bribes;
|
||
// The other player’s final acceptance, rather than a new voluntary action
|
||
// by the payer, executes the already-consented native immediate bribe.
|
||
game = act(game, f.asker, { type: 'dealRespond', deal: deal.id, accept: true });
|
||
assert.equal(player(game, f.target).spice, player(before, f.target).spice - amount);
|
||
assert.equal(player(game, f.asker).bribes, oldBribes + amount);
|
||
assert.deepEqual(game.aid[f.target], before.aid[f.target]);
|
||
assert.equal(promise(game).released, true);
|
||
assert.ok(!promise(game).fulfilled);
|
||
assert.deepEqual(inventory(game), f.physical);
|
||
});
|
||
|
||
void test('a hidden pledge amount does not reveal whether a public Yes is fulfilled', () => {
|
||
const f = fixture();
|
||
let bound = ask(f, f.game, 3);
|
||
bound = answer(bound, f.target, 'yes');
|
||
// Both are real updates to the same alliance after the same public answer.
|
||
// Their publicly logged action and event count are identical, but only the
|
||
// private escrow amount and fulfillment differ.
|
||
const below = act(bound, f.target, { type: 'pledgeAid', amount: 2 });
|
||
const fulfilled = act(bound, f.target, { type: 'pledgeAid', amount: 3 });
|
||
assert.ok(!promise(below).fulfilled);
|
||
assert.equal(promise(fulfilled).fulfilled, true);
|
||
const left = viewGame(below, f.asker), right = viewGame(fulfilled, f.asker);
|
||
assert.deepEqual(left.log, right.log);
|
||
assert.deepEqual(left.truthHistory, right.truthHistory);
|
||
assert.deepEqual(left.aid, right.aid);
|
||
assert.deepEqual(left.pledgePromises, []);
|
||
assert.deepEqual(right.pledgePromises, []);
|
||
assert.deepEqual(left.truthPledgeTargets, right.truthPledgeTargets);
|
||
assert.deepEqual(left.truthBribeTargets, right.truthBribeTargets);
|
||
|
||
const remainingTruth = f.truths.find(id => player(below, f.asker).hand.some(card => card.id === id));
|
||
assert.ok(remainingTruth);
|
||
const rejection = (source: Game) => {
|
||
const waiting = declareTruthBribe({ ...f, truths: [remainingTruth] }, source).game;
|
||
const before = structuredClone(waiting);
|
||
let reason = '';
|
||
try {
|
||
act(waiting, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||
turn: waiting.turn, recipient: f.asker, minimum: 1 } });
|
||
} catch (error) {
|
||
reason = error instanceof Error ? error.message : String(error);
|
||
}
|
||
assert.ok(reason, 'The independent future-action question remains unavailable');
|
||
assert.deepEqual(waiting, before);
|
||
return reason;
|
||
};
|
||
assert.equal(rejection(below), rejection(fulfilled));
|
||
assert.deepEqual(inventory(below), f.physical);
|
||
assert.deepEqual(inventory(fulfilled), f.physical);
|
||
});
|