201 lines
12 KiB
TypeScript
201 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 { windowCardOpportunity } from '../game/window-card-play';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { actTruthBid as act, createTruthBidFixture, truthBidInventory as inventory,
|
|
truthBidPlayer as player } from './fixture-truth-bid';
|
|
|
|
/** Genuine Advanced setup and native Bidding; swap one original Truthtrance
|
|
* and one original Karama between dealt hands as a controlled custody fixture. */
|
|
function fixture() {
|
|
const f = createTruthBidFixture({ advanced: true, targetKarama: true });
|
|
const game = structuredClone(f.game), harkonnen = player(game, f.asker), emperor = player(game, f.target);
|
|
const truth = harkonnen.hand.findIndex(card => card.effect === 'truthtrance');
|
|
const karama = emperor.hand.findIndex(card => card.effect === 'karama');
|
|
assert.ok(truth >= 0 && karama >= 0);
|
|
const truthCard = harkonnen.hand[truth], karamaCard = emperor.hand[karama];
|
|
harkonnen.hand[truth] = karamaCard; emperor.hand[karama] = truthCard;
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
const window = windowCardOpportunity(game, 'karamaHarkonnenSpecial'); assert.ok(window);
|
|
return { game, asker: emperor.id, owner: harkonnen.id, opponent: f.opponent,
|
|
truth: truthCard.id, karama: karamaCard.id, source: window.source, cards: f.originalCards };
|
|
}
|
|
function reject(game: Game, id: string, action: Action) {
|
|
const before = structuredClone(game); assert.throws(() => act(game, id, action)); assert.deepEqual(game, before);
|
|
}
|
|
function declare(f: ReturnType<typeof fixture>) {
|
|
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
|
while (game.truthtrance?.stage === 'priority') {
|
|
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
|
|
game = act(game, seat.id, { type: 'truthPass' });
|
|
}
|
|
assert.equal(game.truthtrance?.stage, 'ask');
|
|
return game;
|
|
}
|
|
function question(f: ReturnType<typeof fixture>, target = f.owner) {
|
|
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
|
kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source: f.source,
|
|
} } };
|
|
}
|
|
function ask(f: ReturnType<typeof fixture>, game = declare(f)) {
|
|
return act(game, f.asker, { type: 'truthAsk', question: question(f) });
|
|
}
|
|
function special(f: ReturnType<typeof fixture>): Action {
|
|
return { type: 'card', card: f.karama, mode: 'special', target: f.opponent, amount: 1 };
|
|
}
|
|
|
|
void test('Advanced Harkonnen special Yes records the physical special role and retains private hand exchange', () => {
|
|
const f = fixture(), asked = ask(f);
|
|
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
|
assert.equal(viewGame(asked, f.opponent).truthTurnActionAnswers, null);
|
|
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
|
const guide = viewGame(game, f.owner).turnActionGuide; assert.ok(guide);
|
|
assert.equal(guide.action.type, 'card');
|
|
assert.equal(guide.action.mode, 'special');
|
|
assert.equal(guide.action.card, f.karama);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const bot = viewGame(game, f.owner);
|
|
bot.players.find(p => p.id === f.owner)!.bot = difficulty;
|
|
assert.deepEqual(botActions(bot)[0], guide.action);
|
|
}
|
|
// A normal free-purchase use of the sole Karama cannot evade this Yes.
|
|
reject(game, f.owner, { type: 'card', card: f.karama, mode: 'purchase' });
|
|
const bound = game, before = structuredClone(game), lot = structuredClone(game.auction);
|
|
game = act(game, f.owner, special(f));
|
|
assert.deepEqual(bound, before, 'Native action works on an immutable input');
|
|
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
|
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.fulfilled, true);
|
|
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.windowCards!.karamaHarkonnenSpecial!.played, true);
|
|
assert.equal(game.decision?.kind, 'handExchange');
|
|
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
|
const observer = viewGame(game, f.asker);
|
|
assert.equal(observer.players.find(p => p.id === f.owner)!.hand, undefined);
|
|
assert.equal(observer.players.find(p => p.id === f.opponent)!.hand, undefined);
|
|
assert.deepEqual(observer.turnActionPromises, []);
|
|
const returned = player(game, f.owner).hand[0]; assert.ok(returned);
|
|
game = act(game, f.owner, { type: 'decision', returnCards: [returned.id] });
|
|
assert.deepEqual(game.auction, lot);
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
});
|
|
|
|
void test('Advanced Harkonnen special No rejects that role and leaves the original Karama held', () => {
|
|
const f = fixture(), asked = ask(f);
|
|
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
|
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
|
reject(game, f.owner, special(f));
|
|
assert.ok(player(game, f.owner).hand.some(card => card.id === f.karama));
|
|
assert.notEqual(player(game, f.owner).specialKaramaUsed, true);
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
});
|
|
|
|
void test('a Harkonnen special source cannot bind another respondent or a forged turn', () => {
|
|
const f = fixture(), game = declare(f);
|
|
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
|
|
const malformed = question(f);
|
|
malformed.mixed.action.source = JSON.stringify(['karamaHarkonnenSpecial', game.turn + 1, f.owner]);
|
|
reject(game, f.asker, { type: 'truthAsk', question: malformed });
|
|
});
|
|
|
|
/** Stage an existing Hajr into the other player's private hand without adding
|
|
* or duplicating a physical card. The Harkonnen respondent does not know that
|
|
* face and cannot treat it as an owned future Movement play. */
|
|
function withOpponentHajr() {
|
|
const f = fixture(), game = structuredClone(f.game), other = player(game, f.opponent);
|
|
const index = game.deck.findIndex(card => card.effect === 'hajr');
|
|
assert.ok(index >= 0, 'The original undealt Hajr must be available to stage');
|
|
const prior = other.hand.findIndex(card => card.effect !== 'hajr'); assert.ok(prior >= 0);
|
|
[game.deck[index], other.hand[prior]] = [other.hand[prior], game.deck[index]];
|
|
assert.ok(!player(game, f.owner).hand.some(card => card.effect === 'hajr'));
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
return { ...f, game };
|
|
}
|
|
function compound(f: ReturnType<typeof fixture>, kind: 'and' | 'or') {
|
|
const simple = question(f);
|
|
return { ...simple, mixed: { kind, terms: [simple.mixed,
|
|
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
|
] } };
|
|
}
|
|
|
|
void test('a hidden opponent Hajr cannot fund special-Karama AND Hajr Yes', () => {
|
|
const f = withOpponentHajr(), declared = declare(f);
|
|
const asked = act(declared, f.asker, { type: 'truthAsk', question: compound(f, 'and') });
|
|
assert.ok(!viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
|
assert.equal(viewGame(asked, f.owner).players.find(p => p.id === f.opponent)!.hand, undefined);
|
|
// The same Hajr in the undealt deck rather than that opponent's hidden hand
|
|
// must not change the respondent's available answers.
|
|
const alternate = structuredClone(f.game), other = player(alternate, f.opponent);
|
|
const held = other.hand.findIndex(card => card.effect === 'hajr');
|
|
const spare = alternate.deck.findIndex(card => card.name === 'Baliset');
|
|
assert.ok(held >= 0 && spare >= 0);
|
|
[other.hand[held], alternate.deck[spare]] = [alternate.deck[spare], other.hand[held]];
|
|
const f2 = { ...f, game: alternate };
|
|
const asked2 = act(declare(f2), f2.asker, { type: 'truthAsk', question: compound(f2, 'and') });
|
|
assert.deepEqual(viewGame(asked2, f2.owner).truthTurnActionAnswers,
|
|
viewGame(asked, f.owner).truthTurnActionAnswers);
|
|
assert.deepEqual(inventory(alternate), f.cards);
|
|
reject(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
|
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
});
|
|
|
|
void test('special-Karama OR Hajr preserves either alternative and No forbids the actual special', () => {
|
|
const f = withOpponentHajr(), declared = declare(f);
|
|
const asked = act(declared, f.asker, { type: 'truthAsk', question: compound(f, 'or') });
|
|
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
|
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
|
const yes = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
|
const after = act(yes, f.owner, special(f));
|
|
assert.equal(after.turnActionPromises!.find(row => row.player === f.owner)!.fulfilled, true);
|
|
const no = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
|
reject(no, f.owner, special(f));
|
|
assert.ok(player(no, f.owner).hand.some(card => card.id === f.karama));
|
|
assert.deepEqual(inventory(no), f.cards);
|
|
});
|
|
|
|
void test('Harkonnen special guide uses its own promise event after an earlier unrelated whole-turn answer', () => {
|
|
const f = fixture(), game = structuredClone(f.game);
|
|
// Move the second original Truthtrance from Harkonnen to the asker; no card
|
|
// is created or removed and Harkonnen retains the real Karama.
|
|
const owner = player(game, f.owner), asker = player(game, f.asker);
|
|
const index = owner.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
|
const second = owner.hand.splice(index, 1)[0]; asker.hand.push(second);
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
let current = act(game, f.asker, { type: 'card', card: f.truth, cards: [second.id] });
|
|
while (current.truthtrance?.stage === 'priority') {
|
|
const seat = current.players.find(p => !current.truthtrance!.passed.includes(p.id)); assert.ok(seat);
|
|
current = act(current, seat.id, { type: 'truthPass' });
|
|
}
|
|
assert.equal(current.truthtrance?.stage, 'ask');
|
|
current = act(current, f.asker, { type: 'truthAsk', question: {
|
|
kind: 'turnActions', target: f.owner, turn: current.turn,
|
|
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
|
} });
|
|
assert.ok(viewGame(current, f.owner).truthTurnActionAnswers?.includes('no'));
|
|
current = act(current, f.owner, { type: 'truthAnswer', answer: 'no' });
|
|
assert.equal(current.truthtrance?.stage, 'ask');
|
|
current = act(current, f.asker, { type: 'truthAsk', question: question(f) });
|
|
assert.ok(viewGame(current, f.owner).truthTurnActionAnswers?.includes('yes'));
|
|
current = act(current, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
|
const rows = current.turnActionPromises!.filter(row => row.player === f.owner && !row.fulfilled && !row.released);
|
|
assert.equal(rows.length, 2);
|
|
const specialRow = rows.find(row => row.windowCards?.karamaHarkonnenSpecial); assert.ok(specialRow);
|
|
const guide = viewGame(current, f.owner).turnActionGuide; assert.ok(guide);
|
|
assert.equal(guide.event, specialRow.event);
|
|
assert.equal(guide.action.turnActionPromiseEvent, specialRow.event);
|
|
assert.equal(guide.action.mode, 'special');
|
|
current = act(current, f.owner, guide.action);
|
|
assert.ok(current.turnActionPromises!.find(row => row.event === specialRow.event)?.fulfilled);
|
|
if (current.decision?.kind === 'handExchange') {
|
|
const returned = player(current, f.owner).hand[0]; assert.ok(returned);
|
|
current = act(current, f.owner, { type: 'decision', returnCards: [returned.id] });
|
|
} else {
|
|
// After spending the only held card, the native exchange automatically
|
|
// returns the one unseen card because it is the entire remaining hand.
|
|
assert.equal(current.pendingExchange, null);
|
|
assert.ok(current.log.some(entry => entry.text.includes('returned 1 cards') && entry.text.includes('automatically')));
|
|
}
|
|
assert.deepEqual(inventory(current), f.cards);
|
|
});
|