547 lines
20 KiB
TypeScript
547 lines
20 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
RuleError,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { saleOptions } from '../game/choam-market';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
function fixture() {
|
|
const g = createGame('MARKET22', newPlayer('c', 'CHOAM', 'choam'), false, [
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 2;
|
|
g.choamCharity = { turn: 1, canceled: false };
|
|
g.order = ['c', 'e', 'b'];
|
|
g.deck = baseDeck();
|
|
g.players.forEach((p) => {
|
|
p.spice = 20;
|
|
p.traitorChoices = [];
|
|
});
|
|
return g;
|
|
}
|
|
function ready(state: Game) {
|
|
let g = state;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
return g;
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
while (g.response) {
|
|
const p = g.players.find(
|
|
(p) =>
|
|
!viewGame(g, p.id).responseControls?.hasPassed &&
|
|
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
|
)!;
|
|
g = applyAction(g, p.id, { type: 'passResponse' });
|
|
}
|
|
return g;
|
|
}
|
|
function hold(g: Game, id: string, name: string) {
|
|
const i = g.deck.findIndex((c) => c.name === name);
|
|
assert.ok(i >= 0, name);
|
|
const card = g.deck.splice(i, 1)[0];
|
|
g.players.find((p) => p.id === id)!.hand.push(card);
|
|
return card;
|
|
}
|
|
function contest(g: Game, count = 1, id = 'e') {
|
|
for (let n = 0; n < count; n++) {
|
|
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
|
assert.ok(index >= 0);
|
|
g.players.find((p) => p.id === id)!.hand.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
return g;
|
|
}
|
|
function cancel(g: Game) {
|
|
const card = g.players
|
|
.find((p) => p.id === 'e')!
|
|
.hand.find((card) => card.effect === 'karama');
|
|
assert.ok(card, 'canceller must hold Karama before the declaration');
|
|
return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' });
|
|
}
|
|
const done = (g: Game) => applyAction(g, 'c', { type: 'decision', done: true });
|
|
const sell = (g: Game, card: string, witness?: string) =>
|
|
applyAction(g, 'c', { type: 'decision', mode: 'sell', card, witness });
|
|
function allied(g: Game) {
|
|
g.players[0].ally = 'e';
|
|
g.players[1].ally = 'c';
|
|
return g;
|
|
}
|
|
function offer(g: Game, card: string) {
|
|
return applyAction(g, 'c', { type: 'decision', mode: 'trade', card });
|
|
}
|
|
const live = (g: Game) =>
|
|
[
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...(g.auction?.cards.slice(g.auction.index) ?? []),
|
|
]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
void test('outside Bidding the market preserves private empty and nonempty hand opportunities', () => {
|
|
for (const cards of [false, true]) {
|
|
const initial = fixture();
|
|
if (cards) hold(initial, 'c', 'Baliset');
|
|
let g = ready(initial);
|
|
assert.equal(g.phase, 2);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
assert.equal(g.auction, null);
|
|
assert.deepEqual(viewGame(g, 'e').choamMarket, { owner: 'c' });
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'decision', done: true }),
|
|
/pending decision/,
|
|
);
|
|
assert.throws(
|
|
() => applyAction(g, 'c', { type: 'charity' }),
|
|
/pending decision/,
|
|
);
|
|
g = done(g);
|
|
assert.equal(g.phase, 3);
|
|
assert.ok(g.auction);
|
|
assert.equal(g.choamMarket, null);
|
|
}
|
|
});
|
|
void test('Worthless sale reveals its card but only discards and pays after the response', () => {
|
|
const initial = contest(fixture());
|
|
const card = hold(initial, 'c', 'Baliset');
|
|
let g = sell(ready(initial), card.id);
|
|
assert.equal(g.response?.kind, 'choamSale');
|
|
assert.equal(g.players[0].hand.length, 1);
|
|
assert.equal(g.players[0].spice, 20);
|
|
g = allow(g);
|
|
assert.equal(g.players[0].spice, 22);
|
|
assert.equal(g.players[0].hand.length, 0);
|
|
assert.equal(g.discard.at(-1)?.id, card.id);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
assert.equal(g.phase, 2);
|
|
assert.deepEqual(live(g), live(initial));
|
|
});
|
|
void test('clean CHOAM sale offers Semuta after committed income before the market resumes', () => {
|
|
const g = fixture();
|
|
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
|
g.players[1].spice = 20;
|
|
g.richeseCache = richeseCards();
|
|
g.semutaPreview = true;
|
|
const index = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
assert.ok(index >= 0);
|
|
const semuta = g.richeseCache.splice(index, 1)[0];
|
|
g.players[1].hand.push(semuta);
|
|
contest(g, 1, 'b');
|
|
const sold = hold(g, 'c', 'Baliset');
|
|
const original = live(g);
|
|
const absent = structuredClone(g);
|
|
absent.players[1].hand = [absent.richeseCache!.splice(0, 1)[0]];
|
|
absent.richeseCache!.push(semuta);
|
|
const offered = (state: Game) => allow(sell(ready(state), sold.id));
|
|
const pending = offered(g), neutral = offered(absent);
|
|
const event = pending.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamSaleDiscard');
|
|
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(pending.choamMarket?.sale, undefined);
|
|
assert.equal(pending.decision, null);
|
|
assert.equal(pending.players[0].spice, 22);
|
|
assert.equal(pending.players[0].hand.some(card => card.id === sold.id), false);
|
|
assert.equal(pending.discard.filter(card => card.id === sold.id).length, 1);
|
|
assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c'));
|
|
assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b'));
|
|
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
|
|
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
|
|
for (const change of [
|
|
(state: Game) => {
|
|
const next = state.pendingTreacheryDiscard!.continuation;
|
|
if (next.kind === 'choamSaleDiscard') next.price = 3;
|
|
},
|
|
(state: Game) => { state.players[0].spice += 2; },
|
|
(state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
|
|
]) {
|
|
const corrupt: Game = JSON.parse(JSON.stringify(pending));
|
|
change(corrupt);
|
|
const before = JSON.stringify(corrupt);
|
|
assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event }));
|
|
assert.equal(JSON.stringify(corrupt), before);
|
|
}
|
|
let declined: Game = JSON.parse(JSON.stringify(pending));
|
|
for (const id of ['c', 'b', 'e'])
|
|
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
|
const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e',
|
|
botActions(viewGame(pending, 'e'))[0]!);
|
|
for (const state of [declined, claimed]) {
|
|
assert.equal(state.pendingTreacheryDiscard, null);
|
|
assert.equal(state.decision?.kind, 'choamMarket');
|
|
assert.equal(state.players[0].spice, 22);
|
|
assert.deepEqual(live(state), original);
|
|
assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event }));
|
|
}
|
|
assert.equal(declined.discard.filter(card => card.id === sold.id).length, 1);
|
|
assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1);
|
|
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
|
const resumed = done(claimed);
|
|
assert.equal(resumed.choamMarket, null);
|
|
assert.equal(resumed.phase, 3);
|
|
});
|
|
void test('CHOAM duplicate sale retains its witness and three spice through Semuta', () => {
|
|
const g = fixture();
|
|
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
|
g.players[1].spice = 20;
|
|
g.richeseCache = richeseCards();
|
|
g.semutaPreview = true;
|
|
const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
const semuta = g.richeseCache.splice(semutaIndex, 1)[0];
|
|
g.players[1].hand.push(semuta);
|
|
contest(g, 1, 'b');
|
|
const sold = hold(g, 'c', 'Snooper'), witness = hold(g, 'c', 'Snooper');
|
|
const pending = allow(sell(ready(g), sold.id, witness.id));
|
|
const continuation = pending.pendingTreacheryDiscard?.continuation;
|
|
assert.equal(continuation?.kind, 'choamSaleDiscard');
|
|
if (continuation?.kind === 'choamSaleDiscard') {
|
|
assert.equal(continuation.witness, witness.id);
|
|
assert.equal(continuation.price, 3);
|
|
}
|
|
assert.equal(pending.players[0].spice, 23);
|
|
assert.deepEqual(pending.players[0].hand.map(card => card.id), [witness.id]);
|
|
const event = pending.pendingTreacheryDiscard!.batch.event;
|
|
const claimed = applyAction(pending, 'e', { type: 'semutaCommit', event });
|
|
assert.equal(claimed.players[0].spice, 23);
|
|
assert.deepEqual(claimed.players[0].hand.map(card => card.id), [witness.id]);
|
|
assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1);
|
|
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
|
assert.equal(claimed.decision?.kind, 'choamMarket');
|
|
});
|
|
void test('canceled CHOAM sale creates no Semuta event or bank income', () => {
|
|
const g = fixture();
|
|
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
|
g.richeseCache = richeseCards();
|
|
g.semutaPreview = true;
|
|
const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
g.players[1].hand.push(g.richeseCache.splice(semutaIndex, 1)[0]);
|
|
contest(g, 1, 'b');
|
|
const sold = hold(g, 'c', 'Baliset');
|
|
const declaration = sell(ready(g), sold.id);
|
|
const karama = declaration.players[2].hand.find(card => card.effect === 'karama')!;
|
|
const canceled = applyAction(declaration, 'b',
|
|
{ type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.ok(!canceled.pendingTreacheryDiscard);
|
|
assert.equal(canceled.players[0].spice, 20);
|
|
assert.equal(canceled.players[0].hand.filter(card => card.id === sold.id).length, 1);
|
|
assert.equal(canceled.choamMarket?.sale, undefined);
|
|
assert.equal(canceled.decision?.kind, 'choamMarket');
|
|
});
|
|
void test('surplus sales keep one exact duplicate and permit selling several surplus copies', () => {
|
|
let g = fixture();
|
|
const cards = [
|
|
hold(g, 'c', 'Snooper'),
|
|
hold(g, 'c', 'Snooper'),
|
|
hold(g, 'c', 'Snooper'),
|
|
];
|
|
const inventory = live(g);
|
|
g = ready(g);
|
|
g = allow(sell(g, cards[0].id, cards[2].id));
|
|
g = allow(sell(g, cards[1].id, cards[2].id));
|
|
assert.equal(g.players[0].spice, 26);
|
|
assert.deepEqual(
|
|
g.players[0].hand.map((c) => c.id),
|
|
[cards[2].id],
|
|
);
|
|
assert.throws(() => sell(g, cards[2].id, cards[2].id), /exact duplicate/);
|
|
assert.deepEqual(live(g), inventory);
|
|
});
|
|
void test('same-role cards do not count as identical duplicates and forged witnesses fail atomically', () => {
|
|
let g = fixture();
|
|
const snooper = hold(g, 'c', 'Snooper');
|
|
const shield = hold(g, 'c', 'Shield');
|
|
assert.equal(
|
|
saleOptions([
|
|
snooper,
|
|
{ ...shield, name: 'Shield Snooper', kind: 'shieldSnooper' },
|
|
]).length,
|
|
0,
|
|
);
|
|
g = ready(g);
|
|
const before = structuredClone(g);
|
|
for (const witness of [shield.id, snooper.id, 'missing'])
|
|
assert.throws(() => sell(g, snooper.id, witness), /exact duplicate/);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('Karama preserves the sale card and price, prevents reselling that card this phase and allows other sales', () => {
|
|
let g = fixture();
|
|
const first = hold(g, 'c', 'Baliset');
|
|
const second = hold(g, 'c', 'Kulon');
|
|
g = cancel(sell(ready(contest(g)), first.id));
|
|
assert.equal(g.players[0].spice, 20);
|
|
assert.equal(g.players[0].hand.length, 2);
|
|
assert.throws(() => sell(g, first.id), /available/);
|
|
g = allow(sell(g, second.id));
|
|
assert.equal(g.players[0].spice, 22);
|
|
assert.ok(g.players[0].hand.some((c) => c.id === first.id));
|
|
});
|
|
void test('an allied trade requires two chosen cards and CHOAM confirmation before either moves', () => {
|
|
let g = allied(fixture());
|
|
const a = hold(g, 'c', 'Shield');
|
|
const b = hold(g, 'e', 'Snooper');
|
|
const inventory = live(g);
|
|
g = offer(ready(g), a.id);
|
|
assert.equal(g.decision?.kind, 'choamTradeReply');
|
|
assert.equal(viewGame(g, 'e').choamMarket?.offered?.id, a.id);
|
|
assert.deepEqual(viewGame(g, 'b').choamMarket, { owner: 'c' });
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'decision', card: a.id }),
|
|
/your cards/,
|
|
);
|
|
g = applyAction(g, 'e', { type: 'decision', card: b.id });
|
|
assert.equal(g.players[0].hand[0].id, a.id);
|
|
assert.equal(g.players[1].hand[0].id, b.id);
|
|
assert.equal(viewGame(g, 'c').choamMarket?.returned?.id, b.id);
|
|
assert.equal(viewGame(g, 'b').choamMarket?.returned, undefined);
|
|
g = applyAction(g, 'c', { type: 'decision', accept: true });
|
|
assert.equal(g.players[0].hand[0].id, b.id);
|
|
assert.equal(g.players[1].hand[0].id, a.id);
|
|
assert.equal(g.choamTradeTurn, 1);
|
|
assert.throws(() => offer(g, b.id), /once per turn/);
|
|
assert.deepEqual(live(g), inventory);
|
|
});
|
|
void test('either ally may decline; no cards move and the once-per-turn exchange remains available', () => {
|
|
for (const confirm of [false, true]) {
|
|
let g = allied(fixture());
|
|
const a = hold(g, 'c', 'Shield');
|
|
const b = hold(g, 'e', 'Snooper');
|
|
g = offer(ready(g), a.id);
|
|
if (confirm) g = applyAction(g, 'e', { type: 'decision', card: b.id });
|
|
g = applyAction(g, confirm ? 'c' : 'e', {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
assert.equal(g.choamTradeTurn, undefined);
|
|
assert.equal(g.players[0].hand[0].id, a.id);
|
|
assert.equal(g.players[1].hand[0].id, b.id);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
}
|
|
});
|
|
void test('an empty ally hand can decline and a player without an ally cannot offer a one-way gift', () => {
|
|
let g = fixture();
|
|
const a = hold(g, 'c', 'Shield');
|
|
g = ready(g);
|
|
assert.throws(() => offer(g, a.id), /need an ally/);
|
|
g = offer(allied(g), a.id);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'decision', accept: true }),
|
|
/cards/,
|
|
);
|
|
g = applyAction(g, 'e', { type: 'decision', decline: true });
|
|
assert.equal(g.players[0].hand[0].id, a.id);
|
|
});
|
|
void test('trade confirmation checks current card custody and does not create or lose cards after an interruption', () => {
|
|
let g = allied(fixture());
|
|
const a = hold(g, 'c', 'Shield');
|
|
const b = hold(g, 'e', 'Snooper');
|
|
g = offer(ready(g), a.id);
|
|
g = applyAction(g, 'e', { type: 'decision', card: b.id });
|
|
// Represents a completed intervening card effect before confirmation.
|
|
g.players[1].hand = [];
|
|
g.discard.push(b);
|
|
const inventory = live(g);
|
|
g = applyAction(g, 'c', { type: 'decision', accept: true });
|
|
assert.equal(g.players[0].hand[0].id, a.id);
|
|
assert.equal(g.choamTradeTurn, undefined);
|
|
assert.deepEqual(live(g), inventory);
|
|
});
|
|
void test('end-of-phase tech income settles once, after market completion', () => {
|
|
let g = fixture();
|
|
hold(g, 'c', 'Shield');
|
|
g.techTokens = createTechTokens();
|
|
g.techTokens.production.owner = 'c';
|
|
g.techTokens.production.spice = 2;
|
|
const card = hold(g, 'c', 'Baliset');
|
|
g = allow(sell(ready(g), card.id));
|
|
assert.equal(g.players[0].spice, 22);
|
|
g = done(g);
|
|
assert.equal(g.players[0].spice, 24);
|
|
assert.equal(g.techTokens!.production.spice, 0);
|
|
});
|
|
void test('allied auction escrow is returned only once after the bidding closing window', () => {
|
|
let g = fixture();
|
|
hold(g, 'c', 'Shield');
|
|
g.phase = 3;
|
|
g.active = 'c';
|
|
const card = g.deck.shift()!;
|
|
g.auction = {
|
|
cards: [card],
|
|
index: 0,
|
|
bid: 0,
|
|
bidder: null,
|
|
active: 'c',
|
|
passed: [],
|
|
opener: 0,
|
|
};
|
|
g.aid = { c: { recipient: 'e', amount: 3 } };
|
|
g.players[0].spice = 17;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'passBid' });
|
|
assert.equal(g.players[0].spice, 17);
|
|
g = done(g);
|
|
assert.equal(g.players[0].spice, 20);
|
|
assert.deepEqual(g.aid, {});
|
|
});
|
|
void test('storm closing resumes forecast and the next phase after the market, rather than skipping the window', () => {
|
|
let g = fixture();
|
|
hold(g, 'c', 'Baliset');
|
|
g.phase = 0;
|
|
g.stormPending = 0;
|
|
g = ready(g);
|
|
assert.equal(g.phase, 0);
|
|
assert.equal(g.choamMarket?.resume, 'storm');
|
|
g = done(g);
|
|
assert.equal(g.phase, 1);
|
|
assert.equal(g.choamMarket, null);
|
|
});
|
|
void test('automatically skipped battle phase still offers its own closing window', () => {
|
|
let g = fixture();
|
|
hold(g, 'c', 'Baliset');
|
|
g.phase = 5;
|
|
g.active = 'c';
|
|
g.movementRemaining = ['c'];
|
|
g = applyAction(g, 'c', { type: 'endMovement' });
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
g = done(g);
|
|
assert.equal(g.phase, 6);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
g = done(g);
|
|
assert.equal(g.phase, 7);
|
|
});
|
|
void test('closing an empty auction opens the market before revival reset and next-phase Amal', () => {
|
|
let g = fixture();
|
|
hold(g, 'c', 'Baliset');
|
|
g.phase = 3;
|
|
g.active = 'c';
|
|
const card = g.deck.shift()!;
|
|
g.auction = {
|
|
cards: [card],
|
|
index: 0,
|
|
bid: 0,
|
|
bidder: null,
|
|
active: 'c',
|
|
passed: [],
|
|
opener: 0,
|
|
};
|
|
g.players[0].revived = 2;
|
|
g.expansions.push('ix');
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'passBid' });
|
|
assert.equal(g.phase, 3);
|
|
assert.equal(g.players[0].revived, 2);
|
|
g = done(g);
|
|
assert.equal(g.phase, 4);
|
|
assert.ok(g.phaseOpening);
|
|
assert.equal(g.players[0].revived, 2);
|
|
g = ready(g);
|
|
assert.equal(g.players[0].revived, 0);
|
|
});
|
|
void test('a pending sale survives JSON persistence and remains server-owned', () => {
|
|
let g = contest(fixture());
|
|
const card = hold(g, 'c', 'Baliset');
|
|
g = sell(ready(g), card.id);
|
|
g = JSON.parse(JSON.stringify(g)) as Game;
|
|
assert.throws(() => done(g), RuleError);
|
|
g = allow(g);
|
|
assert.equal(g.players[0].spice, 22);
|
|
g = done(g);
|
|
assert.equal(g.phase, 3);
|
|
});
|
|
void test('all AI levels sell legally and complete empty or declined market choices', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = allied(fixture());
|
|
hold(g, 'c', 'Baliset');
|
|
hold(g, 'c', 'Shield');
|
|
g.players.forEach((p) => {
|
|
p.bot = difficulty;
|
|
});
|
|
g = ready(g);
|
|
for (let i = 0; i < 20 && g.phase === 2; i++) {
|
|
const id = g.response
|
|
? g.players.find(
|
|
(p) =>
|
|
!viewGame(g, p.id).responseControls?.hasPassed &&
|
|
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
|
)!.id
|
|
: g.decision!.player;
|
|
const actions = botActions(viewGame(g, id));
|
|
assert.ok(actions.length, difficulty);
|
|
g = applyAction(g, id, actions[0]);
|
|
}
|
|
assert.equal(g.phase, 3);
|
|
assert.equal(g.players[0].spice, 22);
|
|
assert.equal(g.players[0].hand[0].name, 'Shield');
|
|
}
|
|
});
|
|
|
|
void test('Truthtrance can interrupt a pending sale without selling a card it consumed', () => {
|
|
let g = contest(fixture());
|
|
const first = hold(g, 'c', 'Truthtrance');
|
|
const second = hold(g, 'c', 'Truthtrance');
|
|
const inventory = live(g);
|
|
g = sell(ready(g), first.id, second.id);
|
|
g = applyAction(g, 'c', { type: 'card', card: first.id });
|
|
while (g.truthtrance?.stage === 'priority') {
|
|
const p = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!;
|
|
g = applyAction(g, p.id, { type: 'truthPass' });
|
|
}
|
|
g = applyAction(g, 'c', {
|
|
type: 'truthAsk',
|
|
question: {
|
|
kind: 'fact',
|
|
target: 'e',
|
|
fact: { kind: 'hand', name: 'Shield' },
|
|
},
|
|
});
|
|
g = applyAction(g, 'e', { type: 'truthAnswer', answer: 'no' });
|
|
assert.equal(g.response?.kind, 'choamSale');
|
|
g = allow(g);
|
|
assert.equal(g.players[0].spice, 20);
|
|
assert.deepEqual(
|
|
g.players[0].hand.map((c) => c.id),
|
|
[second.id],
|
|
);
|
|
assert.deepEqual(live(g), inventory);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
});
|
|
|
|
void test('the one-trade limit spans phase boundaries and renews when the turn advances', () => {
|
|
let g = allied(fixture());
|
|
const a = hold(g, 'c', 'Shield');
|
|
const b = hold(g, 'e', 'Snooper');
|
|
g = offer(ready(g), a.id);
|
|
g = applyAction(g, 'e', { type: 'decision', card: b.id });
|
|
g = applyAction(g, 'c', { type: 'decision', accept: true });
|
|
g = done(g);
|
|
g.phase = 4;
|
|
g.auction = null;
|
|
g.ready = [];
|
|
g = ready(g);
|
|
assert.throws(() => offer(g, b.id), /once per turn/);
|
|
g.phase = 8;
|
|
g = done(g);
|
|
g = applyAction(g, 'c', { type: 'decision', done: true });
|
|
assert.equal(g.turn, 2);
|
|
g.phase = 4;
|
|
g.ready = [];
|
|
g.response = null;
|
|
g = ready(g);
|
|
g = offer(g, b.id);
|
|
assert.equal(g.decision?.kind, 'choamTradeReply');
|
|
});
|