From 6eb417ff3263c2b6e881f06dd2e29bf97645fa74 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:46:42 +1100 Subject: [PATCH] Add Truthtrance weapon and defense types --- components/truthtrance-card-count.tsx | 4 +- docs/CURRENT_STATUS.md | 14 +++++++ docs/TRUTHTRANCE_HAND_INVENTORY.md | 19 +++++++++ game/reference.ts | 2 +- game/truthtrance-hand-inventory.ts | 33 +++++++++++---- ...uthtrance-hand-inventory-controls.test.tsx | 17 ++++++++ tests/truthtrance-hand-inventory.test.ts | 40 +++++++++++++++++-- 7 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 tests/truthtrance-hand-inventory-controls.test.tsx diff --git a/components/truthtrance-card-count.tsx b/components/truthtrance-card-count.tsx index 5480bf39..d2f6eed3 100644 --- a/components/truthtrance-card-count.tsx +++ b/components/truthtrance-card-count.tsx @@ -102,7 +102,9 @@ export function CardCountFields({ Primary roles follow the cards: Weirding Way is a weapon and Chemistry is a defense. Worthless cards stay separate, including for Bene Gesserit or CHOAM. Alternate uses and faction powers do not change - this count. + this count. Poison Blade and Shield Snooper match both of their + printed attack or defense types; Mirror Weapon has no fixed poison or + projectile primary role.

)} {invalid && ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d6ec1d45..8c4a6e4f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,20 @@ open mode gates. ## Current checkpoint and work +**Truthtrance card types — 29 October. Prototyped / Partial.** Verified +current-hand questions can distinguish primary projectile and poison weapons +and primary projectile and poison defenses. Physical hybrid cards count for +each applicable type, while alternate battle roles remain separate. Eighteen +focused cases pass, including human and bot answers; typecheck, lint and the +production build pass. Full Truthtrance and mode acceptance remain open. +[Scope](TRUTHTRANCE_HAND_INVENTORY.md#primary-attack-and-defense-types--29-october-2026). + +**Group-alliance lobby remains unexposed.** A source inspection found that +the Basic shipment-promise search checks the scalar Fremen–Guild alliance +even though a three-member coalition clears that scalar. The native transport +permission has a group-aware path. This is a candidate legal-action gap to +reproduce and resolve before exposing group play; no group lobby was added. + **Whole-board Truthtrance forces — 29 October. Prototyped / Partial.** Players can ask a verified physical-force question across the current Arrakis board. The room's current locations include a placed Mobile Stronghold and diff --git a/docs/TRUTHTRANCE_HAND_INVENTORY.md b/docs/TRUTHTRANCE_HAND_INVENTORY.md index 2a89fcf9..6b319c98 100644 --- a/docs/TRUTHTRANCE_HAND_INVENTORY.md +++ b/docs/TRUTHTRANCE_HAND_INVENTORY.md @@ -4,6 +4,25 @@ expansion starts remain gated. Freeform interpretation and broader commitments are still incomplete. +## Primary attack and defense types — 29 October 2026 + +The inventory question now also compares the number of held primary +projectile weapons, poison weapons, projectile defenses or poison defenses. +This is a game-related current-hand fact under the existing FAQ authority, +not a new Voice command or promise about future battle slots. Exact, minimum +and maximum comparisons use the same private answer and physical-card flow. + +The question names a **primary** role. Poison Blade contributes to both weapon +types; Shield Snooper contributes to both defense types. Poison Tooth is a +poison weapon; Portable Snooper and Chemistry are poison defenses. Weirding +Way defaults to a projectile weapon, even though it can later be used as a +projectile defense; Chemistry's alternate poison-weapon role likewise is not +counted while in hand. Lasgun, Artillery Strike, Stone Burner and Mirror Weapon +are distinct primary weapons and do not supply a generic poison or projectile +type merely because their conditional battle effects might overlap. These +subtypes use the existing default-category and battle-type helpers, not a new +classification of a played card. Counts may overlap across subtype questions. + ## Contract and authority The [GF9 November 2020 FAQ, p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) diff --git a/game/reference.ts b/game/reference.ts index d8af56e6..37e19e88 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3208,7 +3208,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Declare Truthtrance, choose Verified current facts, then Hand size or primary card role. Select all cards or a primary role, a comparison and a whole count of zero or more. Review the question before asking.', 'Count only physical cards currently in the target’s hand, including reserved cards still held there. Decks, discards, separate caches and cards already on the table do not count. Every held physical copy counts once.', - 'Primary roles are the cards’ default roles. Weirding Way is a weapon; Chemistry is a defense. Alternate battle roles do not change these counts. Worthless cards and Cheap Heroes or Heroines are separate categories. Bene Gesserit and CHOAM powers do not turn held Worthless cards into another category.', + 'Primary roles are the cards’ default roles. Choose projectile or poison weapons, or projectile or poison defenses, to count those primary types. A dual-type card counts once in each matching type. Weirding Way is primarily a weapon; Chemistry is primarily a defense. Alternate battle roles do not change these counts. Worthless cards and Cheap Heroes or Heroines are separate categories. Bene Gesserit and CHOAM powers do not turn held Worthless cards into another category.', 'Richese cards use their printed roles: Stone Burner and Mirror Weapon are weapons; Portable Snooper is a defense. Other Special cards belong to the Special category. A classification question does not make an unfinished card effect available.', 'Only the target receives the verified answer before answering. Other players see the question and its final aggregate Yes/No answer, without a list of matching cards. An exact comparison answered Yes necessarily confirms that count. AND/OR combinations publish only their combined result.', 'These are current facts, not promises to keep cards or to use them in battle. All four AI levels answer from their own verified result. Broader freeform questions and future-action commitments remain unfinished.', diff --git a/game/truthtrance-hand-inventory.ts b/game/truthtrance-hand-inventory.ts index a96eeae0..a5e40caf 100644 --- a/game/truthtrance-hand-inventory.ts +++ b/game/truthtrance-hand-inventory.ts @@ -4,11 +4,17 @@ import { printedCardCategory, type CardCategory, } from './card-category'; +import { defenseTypes, weaponTypes } from './battle-cards'; + +export const HAND_INVENTORY_TYPES = [ + 'projectileWeapon', 'poisonWeapon', 'projectileDefense', 'poisonDefense', +] as const; +type HandInventoryType = (typeof HAND_INVENTORY_TYPES)[number]; /** A current custody question, not a promise to retain cards or use them. */ export type HandInventoryFact = { kind: 'handInventory'; - category: 'all' | CardCategory; + category: 'all' | CardCategory | HandInventoryType; compare: 'eq' | 'gte' | 'lte'; value: number; }; @@ -23,6 +29,10 @@ export const HAND_INVENTORY_LABELS: Record< worthless: 'Worthless cards', hero: 'Cheap Heroes and Heroines', special: 'Special cards without a primary battle role', + projectileWeapon: 'Projectile weapons · primary role', + poisonWeapon: 'Poison weapons · primary role', + projectileDefense: 'Projectile defenses · primary role', + poisonDefense: 'Poison defenses · primary role', }; export function parseHandInventoryFact( @@ -31,7 +41,8 @@ export function parseHandInventoryFact( if ( v.kind !== 'handInventory' || (v.category !== 'all' && - !CARD_CATEGORIES.includes(v.category as CardCategory)) + !CARD_CATEGORIES.includes(v.category as CardCategory) && + !HAND_INVENTORY_TYPES.includes(v.category as HandInventoryType)) ) throw new Error('Choose all held cards or a known primary card role.'); if (v.compare !== 'eq' && v.compare !== 'gte' && v.compare !== 'lte') @@ -56,11 +67,15 @@ export function handInventoryFactMatches( hand: readonly Card[], fact: HandInventoryFact, ): boolean { - const count = - fact.category === 'all' - ? hand.length - : hand.filter((card) => printedCardCategory(card) === fact.category) - .length; + const category = fact.category; + const count = category === 'all' ? hand.length : hand.filter((card) => { + const primary = printedCardCategory(card); + if (category === 'projectileWeapon' || category === 'poisonWeapon') + return primary === 'weapon' && weaponTypes(card).includes(category === 'projectileWeapon' ? 'projectile' : 'poison'); + if (category === 'projectileDefense' || category === 'poisonDefense') + return primary === 'defense' && defenseTypes(card).includes(category === 'projectileDefense' ? 'shield' : 'snooper'); + return primary === category; + }).length; return fact.compare === 'eq' ? count === fact.value : fact.compare === 'gte' @@ -83,6 +98,10 @@ export function handInventoryFactText(fact: HandInventoryFact): string { worthless: `Worthless ${cards}`, hero: `Cheap Hero or Cheap Heroine ${cards}`, special: `Special ${cards} without a primary battle role`, + projectileWeapon: `${cards} whose primary role is a projectile weapon`, + poisonWeapon: `${cards} whose primary role is a poison weapon`, + projectileDefense: `${cards} whose primary role is a projectile defense`, + poisonDefense: `${cards} whose primary role is a poison defense`, }; return `you currently hold ${comparison} ${fact.value} ${description[fact.category]}`; } diff --git a/tests/truthtrance-hand-inventory-controls.test.tsx b/tests/truthtrance-hand-inventory-controls.test.tsx new file mode 100644 index 00000000..eeba429c --- /dev/null +++ b/tests/truthtrance-hand-inventory-controls.test.tsx @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CardCountFields } from '../components/truthtrance-card-count'; +import type { HandInventoryFact } from '../game/truthtrance-hand-inventory'; + +void test('inventory controls offer precise printed primary attack and defense types', () => { + const value: HandInventoryFact = { kind: 'handInventory', category: 'poisonWeapon', compare: 'gte', value: 1 }; + const html = renderToStaticMarkup(createElement(CardCountFields, { value, onChange() {} })); + for (const category of ['projectileWeapon', 'poisonWeapon', 'projectileDefense', 'poisonDefense']) + assert.match(html, new RegExp(`value="${category}"`)); + assert.match(html, /Poison Blade and Shield Snooper match both/); + assert.match(html, /Mirror Weapon has no fixed poison or/); + assert.match(html, /Alternate uses and faction powers do not change/); + assert.match(html, /Number of cards/); +}); diff --git a/tests/truthtrance-hand-inventory.test.ts b/tests/truthtrance-hand-inventory.test.ts index 3b956ca4..0ce8f41b 100644 --- a/tests/truthtrance-hand-inventory.test.ts +++ b/tests/truthtrance-hand-inventory.test.ts @@ -3,7 +3,7 @@ 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 } from '../game/card-category'; +import { CARD_CATEGORIES, printedCardCategory, type CardCategory } from '../game/card-category'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { applyAction, @@ -31,7 +31,7 @@ const inventoryFact = ( 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 = { + 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']); @@ -51,7 +51,7 @@ void test('the complete implemented catalog has one exhaustive primary printed r assert.equal(printedCardCategory({ ...canonical, id: `forged-${canonical.id}` }), 'special'); } for (const [category, count] of Object.entries(counts) as [ - HandInventoryFact['category'], number, + 'all' | CardCategory, number, ][]) { assert.ok(handInventoryFactMatches(catalog, inventoryFact(category, 'eq', count))); assert.ok(!handInventoryFactMatches(catalog, inventoryFact(category, 'gte', count + 1))); @@ -119,6 +119,33 @@ void test('inventory question text identifies current custody, the comparator, a 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) { @@ -224,6 +251,10 @@ void test('real declaration, priority, question, and answer use only current han [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(); @@ -353,6 +384,9 @@ void test('equivalent hidden inventories yield the same outsider projection and 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;