import test from 'node:test'; import assert from 'node:assert/strict'; import { baseDeck, ixDeck, treacheryDeck } from '../game/cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { CARD_CATEGORIES, printedCardCategory, type CardCategory } from '../game/card-category'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { richeseCards } from '../game/richese-cards'; import { handInventoryFactMatches, handInventoryFactText, parseHandInventoryFact, type HandInventoryFact, } from '../game/truthtrance-hand-inventory'; import type { TruthFact } from '../game/truthtrance'; const inventoryFact = ( category: HandInventoryFact['category'], compare: HandInventoryFact['compare'], value: number, ): HandInventoryFact => ({ kind: 'handInventory', category, compare, value }); void test('the complete implemented catalog has one exhaustive primary printed role, including Ix alternate-slot and canonical Richese edges', () => { const catalog = [...baseDeck(), ...ixDeck(), ...richeseCards(), ...ecazTreacheryCards()]; const before = structuredClone(catalog); const counts: Record<'all' | CardCategory, number> = { all: 60, weapon: 17, defense: 13, worthless: 6, hero: 2, special: 22, }; assert.deepEqual(CARD_CATEGORIES, ['weapon', 'defense', 'worthless', 'hero', 'special']); for (const category of CARD_CATEGORIES) assert.equal(catalog.filter((card) => printedCardCategory(card) === category).length, counts[category]); const category = (name: string) => printedCardCategory(catalog.find((card) => card.name === name)!); for (const name of ['Poison Blade', 'Poison Tooth', 'Artillery Strike', 'Weirding Way', 'Stone Burner', 'Mirror Weapon']) assert.equal(category(name), 'weapon', name); for (const name of ['Shield Snooper', 'Chemistry', 'Portable Snooper']) assert.equal(category(name), 'defense', name); for (const name of ['Reinforcements', 'Harass & Withdraw']) assert.equal(category(name), 'special', `${name} is only eligible for a battle slot`); for (const name of ['Stone Burner', 'Mirror Weapon', 'Portable Snooper']) { const canonical = richeseCards().find((card) => card.name === name)!; assert.equal(printedCardCategory({ ...canonical, id: `forged-${canonical.id}` }), 'special'); } for (const [category, count] of Object.entries(counts) as [ 'all' | CardCategory, number, ][]) { assert.ok(handInventoryFactMatches(catalog, inventoryFact(category, 'eq', count))); assert.ok(!handInventoryFactMatches(catalog, inventoryFact(category, 'gte', count + 1))); assert.ok(!handInventoryFactMatches(catalog, inventoryFact(category, 'lte', count - 1))); } assert.ok(handInventoryFactMatches(catalog, inventoryFact('all', 'lte', Number.MAX_SAFE_INTEGER))); assert.deepEqual(catalog, before); }); void test('the parser normalizes records and rejects malformed categories, comparisons, and unsafe counts immutably', () => { const valid = { kind: 'handInventory', category: 'weapon', compare: 'gte', value: 2, privateDetail: 'ignored', }; const before = structuredClone(valid); assert.deepEqual( parseHandInventoryFact(valid), inventoryFact('weapon', 'gte', 2), ); assert.deepEqual(valid, before); for (const category of ['weapons', 'Weapon', '', 1, null, undefined]) assert.throws( () => parseHandInventoryFact({ ...valid, category }), /known primary card role/, ); for (const compare of ['gt', 'lt', '=', '', 1, null, undefined]) assert.throws( () => parseHandInventoryFact({ ...valid, compare }), /Compare held cards/, ); for (const value of [ -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined, ]) assert.throws( () => parseHandInventoryFact({ ...valid, value }), /whole card count/, ); assert.throws( () => parseHandInventoryFact({ ...valid, kind: 'handCount' }), /known primary card role/, ); }); void test('inventory question text identifies current custody, the comparator, and primary-role semantics', () => { assert.equal( handInventoryFactText(inventoryFact('all', 'eq', 1)), 'you currently hold exactly 1 card in your hand', ); assert.equal( handInventoryFactText(inventoryFact('weapon', 'gte', 2)), 'you currently hold at least 2 cards whose primary role is a weapon', ); assert.equal( handInventoryFactText(inventoryFact('special', 'lte', 0)), 'you currently hold at most 0 Special cards without a primary battle role', ); assert.equal( handInventoryFactText(inventoryFact('poisonWeapon', 'gte', 2)), 'you currently hold at least 2 cards whose primary role is a poison weapon', ); }); void test('primary battle subtypes count hybrids and default roles without inventing conditional roles', () => { const catalog = [...baseDeck(), ...ixDeck(), ...richeseCards()]; const names = ['Poison Blade', 'Poison Tooth', 'Weirding Way', 'Chemistry', 'Shield Snooper', 'Portable Snooper', 'Artillery Strike', 'Stone Burner', 'Mirror Weapon']; const hand = names.map(name => { const card = catalog.find(candidate => candidate.name === name); assert.ok(card, name); return card; }); for (const [category, count] of [ ['projectileWeapon', 2], ['poisonWeapon', 2], ['projectileDefense', 1], ['poisonDefense', 3], ] as const) { assert.ok(handInventoryFactMatches(hand, inventoryFact(category, 'eq', count)), category); assert.ok(!handInventoryFactMatches(hand, inventoryFact(category, 'gte', count + 1)), category); assert.deepEqual(parseHandInventoryFact(inventoryFact(category, 'eq', count)), inventoryFact(category, 'eq', count)); } const weirding = hand.find(card => card.name === 'Weirding Way')!; const chemistry = hand.find(card => card.name === 'Chemistry')!; assert.ok(handInventoryFactMatches([weirding, chemistry], inventoryFact('projectileDefense', 'eq', 0))); assert.ok(handInventoryFactMatches([weirding, chemistry], inventoryFact('poisonWeapon', 'eq', 0))); }); function takeDeck(g: Game, id: string, name: string) { const index = g.deck.findIndex((card) => card.name === name); assert.ok(index >= 0, name); g.players .find((player) => player.id === id)! .hand.push(g.deck.splice(index, 1)[0]); } function takeRichese(g: Game, id: string, name: string) { const index = g.richeseCache!.findIndex((card) => card.name === name); assert.ok(index >= 0, name); g.players .find((player) => player.id === id)! .hand.push(g.richeseCache!.splice(index, 1)[0]); } function fixture() { const g = createGame('INVTRUTH', newPlayer('a', 'Asker', 'atreides'), false, [ 'ix', 'choam', ]); g.players.push( newPlayer('r', 'Target', 'richese'), newPlayer('h', 'Observer', 'harkonnen'), ); Object.assign(g, { status: 'playing', turn: 2, phase: 4, order: ['h', 'r', 'a'], deck: treacheryDeck(['ix']), richeseCache: richeseCards(), }); for (const player of g.players) { player.spice = 20; player.traitors = [player.leaders[0].id]; } const selected = new Set(g.players.flatMap((player) => player.traitors)); g.traitorReserve = g.players .flatMap((player) => player.leaders.map((leader) => leader.id)) .filter((id) => !selected.has(id)); takeDeck(g, 'a', 'Truthtrance'); takeDeck(g, 'a', 'Shield'); takeDeck(g, 'h', 'Truthtrance'); takeDeck(g, 'h', 'Snooper'); for (const name of ['Poison Blade', 'Chemistry', 'Cheap Hero', 'Harvester']) takeDeck(g, 'r', name); return g; } function declare(state: Game, id = 'a') { const player = state.players.find((candidate) => candidate.id === id)!; return applyAction(state, id, { type: 'card', card: player.hand.find((card) => card.effect === 'truthtrance')!.id, }); } function finishPriority(state: Game) { let g = state; while (g.truthtrance?.stage === 'priority') { const player = g.players.find( (candidate) => !g.truthtrance!.passed.includes(candidate.id), )!; g = applyAction(g, player.id, { type: 'truthPass' }); } assert.equal(g.truthtrance?.stage, 'ask'); return g; } function pending(state: Game, fact: TruthFact, asker = 'a', target = 'r') { return applyAction(finishPriority(declare(state, asker)), asker, { type: 'truthAsk', question: { kind: 'fact', target, fact }, }); } function rejectUnchanged(g: Game, id: string, action: Action) { const before = structuredClone(g); assert.throws(() => applyAction(g, id, action)); assert.deepEqual(g, before); } function physicalCardIds(g: Game) { return [ ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...g.players.flatMap((player) => player.hand), ] .map((card) => card.id) .sort(); } void test('real declaration, priority, question, and answer use only current hand custody and discard Truthtrance once', () => { const cases: [HandInventoryFact, 'yes' | 'no'][] = [ [inventoryFact('all', 'eq', 4), 'yes'], [inventoryFact('weapon', 'eq', 1), 'yes'], [inventoryFact('defense', 'gte', 2), 'no'], [inventoryFact('worthless', 'eq', 0), 'yes'], [inventoryFact('hero', 'gte', 2), 'no'], [inventoryFact('special', 'eq', 1), 'yes'], [inventoryFact('projectileWeapon', 'eq', 1), 'yes'], [inventoryFact('poisonWeapon', 'eq', 1), 'yes'], [inventoryFact('projectileDefense', 'eq', 1), 'no'], [inventoryFact('poisonDefense', 'eq', 1), 'yes'], ]; for (const [fact, expected] of cases) { const initial = fixture(); const targetHand = structuredClone(initial.players[1].hand); const g = pending(initial, fact); assert.equal(viewGame(g, 'r').truthAnswer, expected); const done = applyAction(g, 'r', { type: 'truthAnswer', answer: expected, }); assert.equal(done.truthtrance, null); assert.deepEqual(done.truthHistory!.at(-1), { turn: 2, phase: 4, asker: 'a', question: { kind: 'fact', target: 'r', fact }, answer: expected, }); assert.deepEqual(done.players[1].hand, targetHand); assert.equal( done.discard.filter((card) => card.effect === 'truthtrance').length, 1, ); } const g = pending(fixture(), inventoryFact('weapon', 'eq', 1)); rejectUnchanged(g, 'r', { type: 'truthAnswer', answer: 'no' }); rejectUnchanged(g, 'r', { type: 'truthAnswer', answer: 'unknown' }); rejectUnchanged(g, 'h', { type: 'truthAnswer', answer: 'yes' }); }); void test('malformed inventory leaves reject atomically even behind an already-true OR clause', () => { const g = finishPriority(declare(fixture())); const valid = inventoryFact('weapon', 'gte', 1); const invalid = [ { ...valid, category: 'weapons' }, { ...valid, category: 'Weapon' }, { ...valid, category: null }, { ...valid, compare: 'gt' }, { ...valid, value: -1 }, { ...valid, value: 1.5 }, { ...valid, value: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, value: '3' }, ]; for (const leaf of invalid) rejectUnchanged(g, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'r', fact: { kind: 'or', terms: [inventoryFact('all', 'gte', 0), leaf], }, }, }); assert.equal(g.truthtrance?.stage, 'ask'); assert.equal(g.truthtrance.question, null); assert.equal(g.discard.length, 0); }); void test('combined inventory facts preserve unknown semantics and disclose only the aggregate boolean', () => { const initial = fixture(); initial.players[1].traitorChoices = ['atreides-1']; initial.traitorReserve = initial.traitorReserve!.filter( (id) => id !== 'atreides-1', ); const unknown: TruthFact = { kind: 'traitor', leader: 'atreides-1' }; const scenarios: [TruthFact, 'yes' | 'no' | 'unknown'][] = [ [ { kind: 'and', terms: [inventoryFact('weapon', 'eq', 1), unknown] }, 'unknown', ], [{ kind: 'and', terms: [inventoryFact('weapon', 'eq', 2), unknown] }, 'no'], [ { kind: 'or', terms: [inventoryFact('defense', 'eq', 1), unknown] }, 'yes', ], [ { kind: 'or', terms: [inventoryFact('defense', 'eq', 2), unknown] }, 'unknown', ], ]; for (const [fact, expected] of scenarios) { const g = pending(initial, fact); assert.equal(viewGame(g, 'r').truthAnswer, expected); for (const id of ['a', 'h']) { const view = viewGame(g, id); assert.equal(view.truthAnswer, null); assert.equal( view.players.find((player) => player.id === 'r')!.hand, undefined, ); assert.equal( view.players.find((player) => player.id === 'r')!.traitorChoices, undefined, ); for (const card of g.players[1].hand) assert.equal(JSON.stringify(view).includes(card.id), false); } const done = applyAction(g, 'r', { type: 'truthAnswer', answer: expected, }); assert.equal(done.truthHistory!.at(-1)!.answer, expected); } }); function swapHeldCard( g: Game, playerId: string, heldName: string, deckName: string, ) { const player = g.players.find((candidate) => candidate.id === playerId)!; const held = player.hand.findIndex((card) => card.name === heldName); const deck = g.deck.findIndex((card) => card.name === deckName); assert.ok(held >= 0, heldName); assert.ok(deck >= 0, deckName); const [replacement] = g.deck.splice(deck, 1); g.deck.push(player.hand.splice(held, 1, replacement)[0]); } void test('equivalent hidden inventories yield the same outsider projection and all four bot profiles return only the legal boolean', () => { for (const difficulty of DIFFICULTIES) for (const fact of [ inventoryFact('all', 'eq', 4), inventoryFact('weapon', 'gte', 1), inventoryFact('defense', 'lte', 0), inventoryFact('special', 'eq', 1), inventoryFact('poisonWeapon', 'eq', 1), inventoryFact('projectileDefense', 'eq', 0), inventoryFact('poisonDefense', 'eq', 1), ]) { const first = fixture(); first.players[1].bot = difficulty; const second = structuredClone(first); swapHeldCard(second, 'r', 'Poison Blade', 'Poison Tooth'); swapHeldCard(second, 'h', 'Snooper', 'Gom Jabbar'); const worlds = [pending(first, fact), pending(second, fact)]; assert.deepEqual(viewGame(worlds[0], 'a'), viewGame(worlds[1], 'a')); const expected = handInventoryFactMatches(worlds[0].players[1].hand, fact) ? 'yes' : 'no'; for (const world of worlds) { const targetView = viewGame(world, 'r'); const snapshot = structuredClone(targetView); assert.equal(targetView.truthAnswer, expected); assert.deepEqual(botActions(targetView), [ { type: 'truthAnswer', answer: expected }, ]); assert.deepEqual(targetView, snapshot); assert.deepEqual(botActions(viewGame(world, 'h')), []); const done = applyAction(world, 'r', botActions(targetView)[0]); assert.equal(done.truthHistory!.at(-1)!.answer, expected); } } }); void test('a JSON-restored answer records the old fact while a later legal Richese gift changes the next current answer', () => { const initial = fixture(); for (const name of ['Cheap Hero', 'Harvester']) { const index = initial.players[1].hand.findIndex( (card) => card.name === name, ); assert.ok(index >= 0); initial.deck.push(initial.players[1].hand.splice(index, 1)[0]); } takeRichese(initial, 'r', 'Stone Burner'); const stone = initial.players[1].hand.find( (card) => card.name === 'Stone Burner', )!; const ids = physicalCardIds(initial); let g = pending(initial, inventoryFact('weapon', 'eq', 2)); g = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'r', { type: 'truthAnswer', answer: 'yes', }); assert.equal(g.truthHistory!.at(-1)!.answer, 'yes'); g.players[1].ally = 'h'; g.players[2].ally = 'r'; g = applyAction(g, 'r', { type: 'richeseGift', card: stone.id }); assert.equal( g.players[1].hand.some((card) => card.id === stone.id), false, ); assert.equal( g.players[2].hand.some((card) => card.id === stone.id), true, ); g = pending(g, inventoryFact('weapon', 'eq', 1), 'h', 'r'); assert.equal(viewGame(g, 'r').truthAnswer, 'yes'); const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'r', { type: 'truthAnswer', answer: 'yes', }); assert.deepEqual( done.truthHistory!.map((record) => record.answer), ['yes', 'yes'], ); assert.deepEqual(physicalCardIds(done), ids); assert.equal( done.discard.filter((card) => card.effect === 'truthtrance').length, 2, ); });