diff --git a/components/truthtrance-knowledge.tsx b/components/truthtrance-knowledge.tsx index a486cc18..b0461b41 100644 --- a/components/truthtrance-knowledge.tsx +++ b/components/truthtrance-knowledge.tsx @@ -70,7 +70,7 @@ export function KnowledgeFactFields({ {TRUTH_CARD_NAMES.map((name) => )} -
In Basic Bidding, this asks whether the named card is in the Ixian’s recorded inspected auction pool. If that knowledge is unavailable, the answer is I don’t know. Only the answer is published; the pool itself remains private.
+During Bidding, this asks whether the named card is in the Ixian’s recorded inspected auction pool. If that knowledge is unavailable, the answer is I don’t know. Only the answer is published; the pool itself remains private.
> ) : value.kind === 'cacheLot' ? ( <> diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5ac375b7..8cd03b30 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Advanced Ixian auction knowledge — 29 October. Prototyped / Partial.** +Native Ixians can answer a structured Truthtrance question about their recorded +auction group in Advanced Bidding. The public-start journey covers native +inspection, Technology decline and exchange, private answers and legal bot +responses. The Semuta card inspector also describes the current Advanced +preview. Twenty-four related cases, quick type/lint checks and the production +build pass; borrowed +Homeworld knowledge, wider interactions and deployed acceptance remain open. +[Scope](TRUTHTRANCE_KNOWLEDGE.md#advanced-ixian-auction-knowledge--29-october-2026). + **Advanced CHOAM and Residual Poison Semuta — 29 October. Prototyped / Partial.** The public Advanced Richese preview now offers Semuta after supported CHOAM sales, printed Worthless costs and clean pre-leader Residual Poison. Claim or diff --git a/docs/TRUTHTRANCE_KNOWLEDGE.md b/docs/TRUTHTRANCE_KNOWLEDGE.md index a451586b..c339c32f 100644 --- a/docs/TRUTHTRANCE_KNOWLEDGE.md +++ b/docs/TRUTHTRANCE_KNOWLEDGE.md @@ -39,6 +39,24 @@ hands or positions. Controlled component callbacks exercise the selectors; `npm run check:quick` and the production build also passed. Browser and deployed acceptance remain open. +## Advanced Ixian auction knowledge — 29 October 2026 + +**Prototyped / Partial.** Native Advanced Ixians can answer the existing +structured auction-group question using the private group they actually +inspected in the current Bidding phase and turn. Advanced Technology updates +the recorded group when a substitution succeeds; a canceled exchange keeps +the previous record. An absent, stale, canceled or unauthorized inspection +yields unknown. This is the known group, including later sold cards, not a +prediction of the next lot or another player's hand. The respondent sees the +verified answer and may answer normally; the group remains private. + +The [Advanced journey](../tests/truthtrance-ix-pool-advanced.test.ts) starts a +native Advanced game, exercises actual inspection and a substitution, and +checks the human and minimal legal bot paths. The selector is a software +interpretation of the FAQ's game-related yes/no question permission, not a +printed restriction. Borrowed Homeworld knowledge, wider interactions, full +games and deployed acceptance remain open. + ## Basic Ixian auction knowledge — 28 October 2026 **Prototyped / Partial.** The structured fact `{kind:'ixPoolPeek', name}` @@ -52,8 +70,8 @@ alone sees the verified scalar, and the normal answer publishes the response. The [GF9 FAQ p. 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8) permits game-related yes/no and compound questions. This selector and its -boundary are a software interpretation, not a printed restriction. Advanced -Technology and borrowed Homeworld inspection are separate and remain open. +boundary are a software interpretation, not a printed restriction. At that +checkpoint Advanced Technology and borrowed Homeworld inspection were separate. Eight related unit files pass 27 cases, including a genuine Basic inspection, private questions and all-profile responses. Missing and aged observations are explicitly staged; broader combinations and deployed acceptance remain open. diff --git a/game/card-presentation.ts b/game/card-presentation.ts index fb23fea0..e82a78ee 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { if (definition?.card.effect === 'distrans') return 'Choose a recipient and card in the Distrans transfer panel.'; if (definition?.card.effect === 'semutaDrug') - return 'Use the neutral fresh-discard response when offered. Fresh bare Basic Richese previews enable it with their selected native families; older games and separate module previews keep their existing scope. Connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.'; + return 'Use the neutral fresh-discard response when offered. Supported fresh Basic Richese games and Advanced games with Richese, the CHOAM deck and classic or CHOAM opponents offer some discard reactions. Older games and other module previews keep their existing scope. Connected producers are listed in the table preview and rules guide. Commit before inspecting private discarded faces; choose exactly one when the fresh group has multiple eligible cards. Taking a card never refunds a paid cost or replays its original effect. The approved full-hand exchange spends Semuta without increasing the completed hand.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; diff --git a/game/reference.ts b/game/reference.ts index 78b9965b..7455435a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3288,13 +3288,13 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Ask about a recorded prediction, submitted storm dial, Fremen forecast, Atreides peek, revealed Prescience or Basic Ixian auction group, with a single fact or an AND/OR combination.', + summary: 'Ask about a recorded prediction, submitted storm dial, Fremen forecast, Atreides peek, revealed Prescience or Ixian auction group, with a single fact or an AND/OR combination.', steps: [ 'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.', 'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.', 'Ask whether the current ordinary auction card or Advanced Richese Black Market card inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Other special-auction inspections and occupied Homeworld inspections are not yet included.', 'In a Basic Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.', - 'In Basic Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.', + 'In Basic or Advanced Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.', 'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields I don’t know.', 'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.', 'A missing prediction, an unsubmitted or expired storm dial, or an unknown forecast yields I don’t know. A forecast question never gives another faction the Fremen’s private knowledge.', @@ -3304,7 +3304,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['card-truthtrance', 'truthtrance-spice', 'privacy'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Basic Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] }, diff --git a/game/truthtrance-knowledge.ts b/game/truthtrance-knowledge.ts index f2c1cc4a..b3e0dd35 100644 --- a/game/truthtrance-knowledge.ts +++ b/game/truthtrance-knowledge.ts @@ -122,9 +122,10 @@ export function truthKnowledgeOf( const cacheLot = g.phase === 3 && !g.advanced && g.players.some(seat => seat.id === p.id) && g.richeseAuction?.source === 'cache' && !g.richeseAuction.outcome ? g.richeseCache?.find(card => card.id === g.richeseAuction!.cardId)?.name : undefined; - // Basic native Ixians may answer from their own successful inspection. The - // record describes a known group, not the currently unsold auction cards. - const ixKnown = g.phase === 3 && !g.advanced && p.faction === 'ixians' + // Native Ixians may answer from their own successful inspection. The + // recorded group is updated by Advanced Technology substitutions; it is + // not a direct view of the currently unsold auction cards. + const ixKnown = g.phase === 3 && p.faction === 'ixians' ? g.ixAuctionKnown : null; const ixPool = ixKnown && ixKnown.turn === g.turn && (ixKnown.controller === undefined || ixKnown.controller === p.id) && diff --git a/tests/richese-card-presentation.test.ts b/tests/richese-card-presentation.test.ts index 4edcce42..254a1fde 100644 --- a/tests/richese-card-presentation.test.ts +++ b/tests/richese-card-presentation.test.ts @@ -47,6 +47,17 @@ void test('canonical Richese inspectors describe available previews and retain c assert.doesNotMatch(cardPresentation(definition.card).availability ?? '', /expansion starts remain disabled/i); }); +void test('Semuta inspector explains the supported Basic and Advanced previews', () => { + const semuta = RICHESE_CARD_DEFINITIONS.find(row => row.card.effect === 'semutaDrug'); + assert.ok(semuta); + const guide = richeseCardActionBlock(semuta.card) ?? ''; + assert.match(guide, /fresh Basic Richese games/); + assert.match(guide, /Advanced games with Richese, the CHOAM deck and classic or CHOAM opponents/); + assert.match(guide, /some discard reactions/); + assert.match(guide, /Older games and other module previews keep their existing scope/); + assert.match(guide, /Commit before inspecting private discarded faces/); +}); + void test('missing, altered and merely similar Richese identities receive no canonical full guide', () => { for (const card of richeseCards()) { const impostors: Card[] = [ diff --git a/tests/truthtrance-ix-pool-advanced.test.ts b/tests/truthtrance-ix-pool-advanced.test.ts new file mode 100644 index 00000000..0bf2f252 --- /dev/null +++ b/tests/truthtrance-ix-pool-advanced.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { knowledgeFactAnswer, truthKnowledgeOf } from '../game/truthtrance-knowledge'; +import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; +import { truthMovementInventory } from './fixture-truth-movement'; +import { askKnowledge, rejectKnowledge } from './truthtrance-knowledge-fixture'; + +function advance(state: Game, done: (game: Game) => boolean): Game { + let game = state; + for (let i = 0; i < 500 && !done(game); i++) { + const next = nextStrongholdFactionsNativeStep(game); + assert.ok(next, 'Native setup or Bidding must have a legal next action'); + game = applyAction(game, next.actor, next.action); + } + assert.ok(done(game), 'Native Bidding must reach the requested boundary'); + return game; +} + +/** Start the actual public Advanced expansion preview and retain genuine Ixian + * auction inspection. Only the question card and first Spice Blow are staged, + * by moving original physical cards; no auction or knowledge record is forged. */ +function inspected(exchange: boolean) { + let game = createGame('ADVIXTRUTH', newPlayer('atreides', 'Atreides', 'atreides'), true, ['ix']); + for (const faction of ['ixians', 'emperor', 'fremen'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = applyAction(game, 'atreides', { type: 'start', advancedPreview: true }); + assert.equal(game.status, 'setup'); + game = advance(game, state => state.status === 'playing'); + const cards = truthMovementInventory(game); + const truth = [...game.deck, ...game.discard, + ...game.players.filter(player => player.id !== 'ixians').flatMap(player => player.hand)] + .find(card => card.effect === 'truthtrance'); + assert.ok(truth, 'An original Truthtrance remains outside the Ixian starting hand'); + moveSemutaEmperorCard(game, truth.id, 'atreides'); + assert.deepEqual(truthMovementInventory(game), cards); + const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); + assert.ok(index >= 0); + const [blow] = game.spiceDeck.splice(index, 1); + game.spiceDeck.unshift(blow); + game = advance(game, state => state.decision?.kind === 'ixAuction' && !state.response); + const pool = game.ixAuction!.cards; + assert.ok(pool.length > 1); + const returned = pool[0].id; + const retained = pool.slice(1).map(card => card.id).sort(); + game = applyAction(game, 'ixians', { type: 'decision', card: returned, position: 'bottom' }); + assert.equal(game.decision?.kind, 'ixTechnology'); + assert.deepEqual(game.ixAuctionKnown!.cards.map(card => card.id).sort(), retained); + const before = [...game.ixAuctionKnown!.cards]; + const incoming = game.auction!.cards[game.auction!.index]; + const offered = game.players.find(player => player.id === 'ixians')!.hand[0]; + assert.ok(incoming && offered); + game = applyAction(game, 'ixians', exchange + ? { type: 'decision', card: offered.id } + : { type: 'decision', decline: true }); + if (exchange) { + // A response with no eligible counter resolves automatically; otherwise + // the native pass queue completes the same original exchange. + if (game.response) assert.equal(game.response.kind, 'ixTechnology'); + game = advance(game, state => !state.response && !state.decision && !state.phaseOpening && !!state.auction); + assert.equal(game.auction!.cards[game.auction!.index].id, offered.id); + assert.ok(game.players.find(player => player.id === 'ixians')!.hand.some(card => card.id === incoming.id)); + assert.deepEqual(game.ixAuctionKnown!.cards.map(card => card.id).sort(), + [...before.filter(card => card.id !== incoming.id && card.id !== offered.id), offered].map(card => card.id).sort()); + } else game = advance(game, state => !state.response && !state.decision && !state.phaseOpening && !!state.auction); + assert.deepEqual(truthMovementInventory(game), cards); + return { game, cards, returned, offered }; +} + +for (const exchange of [false, true]) void test(`Advanced native Ixian group knowledge after Technology ${exchange ? 'exchange' : 'decline'}`, () => { + const { game, cards, offered } = inspected(exchange); + const group = structuredClone(game.ixAuctionKnown!.cards); + const name = exchange ? offered.name : group[0].name; + const questioned = askKnowledge(game, 'ixians', { kind: 'ixPoolPeek', name }); + assert.equal(viewGame(questioned, 'ixians').truthAnswer, 'yes'); + for (const id of ['atreides', 'emperor', 'fremen']) assert.equal(viewGame(questioned, id).truthAnswer, null); + rejectKnowledge(questioned, 'ixians', { type: 'truthAnswer', answer: 'no' }); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(questioned, 'ixians'); + view.players.find(player => player.id === 'ixians')!.bot = difficulty; + const actions = botActions(view); + assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]); + const done = applyAction(questioned, 'ixians', actions[0]); + assert.equal(done.truthHistory!.at(-1)!.answer, 'yes'); + assert.deepEqual(done.ixAuctionKnown!.cards, group); + assert.deepEqual(truthMovementInventory(done), cards); + } +}); + +void test('Advanced auction-group answers require current native Ixian knowledge', () => { + const { game } = inspected(false); + const ix = game.players.find(player => player.id === 'ixians')!; + const other = game.players.find(player => player.id === 'emperor')!; + const name = TRUTH_CARD_NAMES.find(candidate => !game.ixAuctionKnown!.cards.some(card => card.name === candidate)); + assert.ok(name); + const fact = { kind: 'ixPoolPeek', name } as const; + assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, ix)), 'no'); + assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, other)), 'unknown'); + assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, phase: 4 }, ix)), 'unknown'); + assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, ixAuctionKnown: null }, ix)), 'unknown'); + assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, ixAuctionKnown: { ...game.ixAuctionKnown!, turn: game.turn - 1 } }, ix)), 'unknown'); + assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, ixAuctionKnown: { ...game.ixAuctionKnown!, controller: other.id } }, ix)), 'unknown'); +}); diff --git a/tests/truthtrance-ix-pool-knowledge.test.ts b/tests/truthtrance-ix-pool-knowledge.test.ts index 00f49a0f..39ef0dec 100644 --- a/tests/truthtrance-ix-pool-knowledge.test.ts +++ b/tests/truthtrance-ix-pool-knowledge.test.ts @@ -14,7 +14,7 @@ void test('Ixian pool question accepts only canonical card names and describes t assert.match(knowledgeFactText({ kind: 'ixPoolPeek', name: 'Shield' }), /recorded Ixian auction group includes Shield/); }); -void test('only native Basic Ixians with the current recorded inspection can answer', () => { +void test('only native Ixians with the current recorded inspection can answer', () => { const game = createGame('IXKNOW', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix']); const ix = game.players[0]; const other = newPlayer('other', 'Other', 'atreides'); @@ -28,7 +28,7 @@ void test('only native Basic Ixians with the current recorded inspection can ans assert.equal(knowledgeFactAnswer(no, truthKnowledgeOf(game, ix)), 'no'); assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf(game, other)), 'unknown'); assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, phase: 4 }, ix)), 'unknown'); - assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, advanced: true }, ix)), 'unknown'); + assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, advanced: true }, ix)), 'yes'); assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: null }, ix)), 'unknown'); assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: { turn: game.turn - 1, cards: [shield] } }, ix)), 'unknown'); assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: { turn: game.turn, cards: [shield], controller: 'other' } }, ix)), 'unknown'); diff --git a/tests/truthtrance-knowledge-controls.test.tsx b/tests/truthtrance-knowledge-controls.test.tsx index a8b18592..07f4fdb3 100644 --- a/tests/truthtrance-knowledge-controls.test.tsx +++ b/tests/truthtrance-knowledge-controls.test.tsx @@ -46,7 +46,7 @@ void test('recorded Ixian pool selector appears in standalone and compound facts assert.match(single, /Recorded Ixian inspected auction pool includes/); assert.match(single, /value="Shield" selected/); assert.match(single, /pool itself remains private/); - assert.match(single, /In Basic Bidding/); + assert.match(single, /During Bidding/); assert.match(single, /If that knowledge is unavailable/); const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] }; const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, { value: compound, game: view, target: 'b', disabled: false, onChange() {} }));