diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 9422560d..a79c8f73 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,17 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Phase-bid admission repair — 27 October. Prototyped / Partial.** +A displayed shipment, movement, revival or travel answer now preserves an +existing phase-bid Yes when a known compatible route exists. The unfinished +combined-future planner can withhold a definite answer; a movement No no longer +blocks an otherwise funded bid. A proven funding loss after a quoted bribe +releases only the now-impossible bid answer. Focused cases use original +Basic/Advanced questions and bidding, with a labeled controlled force or spice +transfer for the revival and external-loss supplements. Seven focused files +pass 88/88; type/lint and build pass. Complete interactions and browser/deployed +acceptance remain open. + **Phase-wide Truthtrance bid — 27 October. Prototyped / Partial.** An owned Yes/No minimum ordinary-bid answer can span the remaining Bidding phase instead of expiring with the current lot. Actual accepted bids fulfill diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c81c7e30..25fff12d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,20 @@ # Dune implementation status +## 27 October 2026 — Phase-bid admission repair + +An independently projected movement, shipment, travel or revival answer now +checks the existing phase-bid Yes before it binds. An otherwise harmless +movement No no longer suppresses the later bid witness. For external funding +changes, preserve an answer only when its combined planner is actually missing; +a fully quoted unaffordable bribe/bid route releases the impossible Yes. +Provisional combined-future limitations remain. Native Basic and Advanced +sources exercise original questions, offered answers and actual bids; the +revival and external-loss cases label their controlled physical supplements. +The in-game Fremen–Ecaz guide now matches the existing Basic and Advanced +Habbanya victory rules. Seven focused files pass 88/88, and type/lint and +build pass. Browser and deployed play are not newly certified. Private proof is +`20261027-phase-bid-admission-proof-v1/`. + ## 27 October 2026 — Phase-wide ordinary bid Truthtrance Prototype a separate current-turn minimum ordinary-bid Yes/No that persists diff --git a/docs/TRUTHTRANCE_BID_PROMISES.md b/docs/TRUTHTRANCE_BID_PROMISES.md index 335d900b..e94a967c 100644 --- a/docs/TRUTHTRANCE_BID_PROMISES.md +++ b/docs/TRUTHTRANCE_BID_PROMISES.md @@ -39,6 +39,19 @@ have focused smoke coverage. Stateful browser interaction, full-game, recovery and deployed acceptance remain separate. The focused 12-file batch passes 224/224; type/lint and the production build pass. +### Admission and external funding correction — 27 October 2026 + +Later independent shipment, movement, revival and travel answers are checked +against an existing phase-bid Yes before they are offered. A movement No does +not itself require a movement or suppress a future bid. When the combined future +planner cannot prove a positive answer, it may withhold that definite answer; +this remains a conservative prototype boundary, not a completed planner. An +external change releases an impossible Yes when a bribe and bid are fully +quoted, while an unsupported future combination remains unknown. Original +Basic/Advanced questions and real bids exercise the acceptance path; revival +and external-loss supplements stage a conserved force or spice transfer. Seven +focused files pass 88/88; type/lint and build pass. + ## Current-lot prototype — 26 October 2026 **Prototyped / Partial.** The question binds one diff --git a/game/engine.ts b/game/engine.ts index 25426dca..e0da2b47 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16095,14 +16095,18 @@ function phaseBidHonorsTurnActions(g: Game, p: Player, lot: BidOpportunity, acti /** Current witnesses must also satisfy the existing native funding and * current-lot commitments. Future bidding with other financial/transport * promises awaits their combined planner; do not invent those resources. */ +function phaseBidFuturePlannerUnsupported(g: Game, p: Player): boolean { + return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) || + hasMovementPromises(g,p.id,true) || liveRevivalPromises(g.revivalPromises ?? [],p.id,g.turn).some(row => row.answer) || + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [],p.id,g.turn).some(row => row.answer) || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [],p.id,g.turn).some(row => row.answer) || + liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0 || + (!!bidOpportunity(g) && hasBribePromises(g,p.id,true)); +} function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBidPromise[]) { const lot = bidOpportunity(g); const live = livePhaseBidPromises(promises,p.id,g.turn); - const otherCommitments = liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) || - hasMovementPromises(g,p.id) || liveRevivalPromises(g.revivalPromises ?? [],p.id,g.turn).some(row => row.answer) || - liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [],p.id,g.turn).some(row => row.answer) || - liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [],p.id,g.turn).some(row => row.answer) || - liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0; + const otherCommitments = phaseBidFuturePlannerUnsupported(g,p); return phaseBidWitnesses(g,p).flatMap(witness => { if (witness.kind === 'future') { if (otherCommitments) return []; @@ -16151,12 +16155,20 @@ function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBid /** A new independent bribe must preserve an already-binding phase Yes. * Before Bidding, reserve currently fundable payment first; current-lot * independent combinations wait for a joint planner. */ +/** An independently admitted promise must not make an already-owned phase + * bid impossible. If the future joint planner has no witness, a definite + * answer is withheld rather than offered and rejected after binding. */ +function phaseBidCompatibleLaterPromises(g: Game, p: Player): boolean { + const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); + if (!live.some(row => row.answer)) return true; + return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? []) + .some(witness => !!phaseBidCompletionRange(g.phaseBidPromises ?? [],witness)); +} function phaseBidCompatibleBribeAnswer(g: Game, p: Player): boolean { const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); if (!live.some(row => row.answer)) return true; if (g.phase === 3 && hasBribePromises(g,p.id,true)) return false; - return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? []) - .some(witness => !!phaseBidCompletionRange(g.phaseBidPromises ?? [],witness)); + return phaseBidCompatibleLaterPromises(g,p); } function phaseBidRule(run: () => T): T { try { return run(); } @@ -16208,7 +16220,8 @@ function reconcilePhaseBids(g: Game, voluntary?: {actor:string;action:Action}) { const witnesses = viablePhaseBidWitnesses(g,player,g.phaseBidPromises); // An external event must not release an answer merely because the // combined future-obligation planner is not implemented yet. - const unknownFuture = change === 'external' && !witnesses.some(row => row.kind === 'future') && + const unknownFuture = change === 'external' && phaseBidFuturePlannerUnsupported(g,player) && + !witnesses.some(row => row.kind === 'future') && phaseBidWitnesses(g,player).some(row => row.kind === 'future'); if (unknownFuture && live.some(row => row.answer) && !witnesses.some(row => !!phaseBidCompletionRange(g.phaseBidPromises!,row))) continue; @@ -20987,7 +21000,7 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer // slot until the real answer binds. The authoritative order stays untouched. const trial = { ...answerSource, travelPromises: [...(g.travelPromises ?? []), promise], travelPromiseOrder: undefined }; const lot = bidOpportunity(trial); - if (travelCompletion(trial, p.id) && revivalCompatibleLaterPromises(trial, p, {}) && + if (phaseBidCompatibleLaterPromises(trial,p) && travelCompletion(trial, p.id) && revivalCompatibleLaterPromises(trial, p, {}) && bribeCompatibleLaterPromises(trial, p, {}) && (!lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p))) answers.push(answer ? 'yes' : 'no'); @@ -21342,7 +21355,8 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn for (const answer of [true, false]) { const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer }; const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; - if (groundMovementCompletion(answerSource, p.id, [...live, promise]) && + if (phaseBidCompatibleLaterPromises({ ...answerSource, movementPromises: [...(g.movementPromises ?? []), promise] },p) && + groundMovementCompletion(answerSource, p.id, [...live, promise]) && revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }) && bribeCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] })) answers.push(answer ? 'yes' : 'no'); @@ -21371,7 +21385,7 @@ function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpre asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [] }; const expressions = [...(g.movementExpressionPromises ?? []), promise]; const trial = { ...answerSource, movementExpressionPromises: expressions }; - if (groundMovementCompletion(trial, p.id) && + if (phaseBidCompatibleLaterPromises(trial,p) && groundMovementCompletion(trial, p.id) && revivalCompatibleLaterPromises(answerSource, p, { movementExpressionPromises: expressions }) && bribeCompatibleLaterPromises(answerSource, p, { movementExpressionPromises: expressions })) answers.push(answer ? 'yes' : 'no'); } @@ -30117,7 +30131,8 @@ function shipmentTruthAnswers( // Concurrent legacy readiness can reopen; a completed ordered turn cannot. const answerSource = (g.phase === 2 && !g.charityOrder) || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; - if (findShipmentCompletion(answerSource, p, promises) && + if (phaseBidCompatibleLaterPromises({ ...answerSource, shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] },p) && + findShipmentCompletion(answerSource, p, promises) && revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }) && bribeCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] })) answers.push(answer ? 'yes' : 'no'); @@ -33808,7 +33823,8 @@ function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' | return (['yes', 'no'] as const).filter(answer => { const promises = [...(g.revivalPromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }]; - if (!revivalPromiseCompletion(g, p, promises) || + if (!phaseBidCompatibleLaterPromises({ ...g, revivalPromises: promises },p) || + !revivalPromiseCompletion(g, p, promises) || !bribeCompatibleLaterPromises(g, p, { revivalPromises: promises })) return false; const lot = bidOpportunity(g); // An earlier Bid Yes reserves a future declaration even before it becomes @@ -33847,7 +33863,8 @@ function leaderRevivalTruthAnswers(g: Game, p: Player, claim: LeaderRevivalClaim const promises = [...(g.leaderRevivalPromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }]; const trial = { ...g, leaderRevivalPromises: promises }; - if (!revivalPromiseCompletion(trial, p) || !bribeCompatibleLaterPromises(trial, p, {})) return false; + if (!phaseBidCompatibleLaterPromises(trial,p) || !revivalPromiseCompletion(trial, p) || + !bribeCompatibleLaterPromises(trial, p, {})) return false; const lot = bidOpportunity(trial); return !lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p); }); @@ -33871,7 +33888,8 @@ function revivalExpressionTruthAnswers(g: Game, p: Player, claim: RevivalExpress asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: { total: 0, normal: 0, elite: 0, leaders: [] } }]; const trial = { ...g, revivalExpressionPromises: promises }; - if (!revivalPromiseCompletion(trial, p) || !bribeCompatibleLaterPromises(trial, p, {})) return false; + if (!phaseBidCompatibleLaterPromises(trial,p) || !revivalPromiseCompletion(trial, p) || + !bribeCompatibleLaterPromises(trial, p, {})) return false; const lot = bidOpportunity(trial); return !lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p); }); diff --git a/game/reference.ts b/game/reference.ts index 16dda29f..3c52a6c4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4434,11 +4434,11 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Integrated', - summary: 'Allied Ecaz and Fremen may share Sietch Tabr while qualifying for the Fremen special victory.', + summary: 'Allied Ecaz and Fremen may share Sietch Tabr, and in Advanced Habbanya Sietch, while qualifying for the Fremen special victory.', steps: [ 'At the final-turn victory check, ordinary stronghold victories come first. If none qualifies, Fremen’s special occupation conditions are checked before the Guild fallback. A qualifying Fremen victory includes its ally and is not replaced by a Bene Gesserit prediction.', 'Ecaz and Fremen must be current reciprocal allies and both have fighters in Sietch Tabr. Their co-occupation does not block the Fremen special condition. Ecaz alone, non-allied Ecaz or another faction’s fighters still block it. Accompanying Bene Gesserit advisors do not count as fighters.', - 'Habbanya Sietch retains its existing empty-or-Fremen condition; extending the Ecaz exception there is not yet supported. Tuek’s Sietch must contain no Atreides, Harkonnen or Emperor presence, and Advanced play also excludes Richese presence. A concealed No-Field counts without revealing its hidden value.', + 'In Basic, Habbanya Sietch must be empty or occupied only by Fremen. In Advanced, reciprocal allied Ecaz and Fremen may also share Habbanya Sietch when both have fighters there. Tuek’s Sietch must contain no Atreides, Harkonnen or Emperor presence, and Advanced play also excludes Richese presence. A concealed No-Field counts without revealing its hidden value.', 'The public victory guide lists the three territories and any blocking factions. It uses the same settled-board facts as automatic victory resolution. No extra confirmation is required; the table chronicle explains a successful special victory.', ], checklist: [ diff --git a/tests/truth-phase-bid-admission.test.ts b/tests/truth-phase-bid-admission.test.ts new file mode 100644 index 00000000..e19ba281 --- /dev/null +++ b/tests/truth-phase-bid-admission.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { actTruthBid as act, advanceTruthBid as advance, + truthBidClean as clean, truthBidInventory as inventory } from './fixture-truth-bid'; +import { bindTruthTurnBid, createTruthTurnBidFixture } from './fixture-truth-turn-bid'; + +/** Original H/A/E public Basic or Advanced setup, actual starting deal and + * native play through Charity. Only unopened starting deal/Spice positions + * and the native scalar lotteries are selected by the shared fixture. */ +function early(advanced: boolean) { + const f = createTruthTurnBidFixture({ advanced }); + let game = structuredClone(f.afterSetup); + for (const { position, from, card } of f.spiceOrder) { + const [selected] = game.spiceDeck.splice(from, 1); + assert.deepEqual(selected, card); game.spiceDeck.splice(position, 0, selected); + } + game = advance(game, state => state.turn === 1 && state.phase === 2 && clean(state)); + return { f, game }; +} + +function askSecond(game: Game, asker: string, card: string, question: object): Game { + game = act(game, asker, { type: 'card', card }); + for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { + const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(player); + game = act(game, player.id, { type: 'truthPass' }); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return act(game, asker, { type: 'truthAsk', question }); +} + +/** Every answer projected to the respondent must be accepted and leave the + * original phase promise usable; unknown creates no new binding obligation. */ +function acceptEach(asked: Game, answers: readonly string[] | null, target: string, asker: string, + prior: NonNullable[number], originalCards: string[]) { + assert.ok(answers?.length, 'The open Truthtrance must offer a usable response'); + for (const answer of answers) { + assert.ok(['yes', 'no', 'unknown'].includes(answer)); + let game = act(asked, target, { type: 'truthAnswer', answer }); + if (answer === 'unknown') { + assert.equal(game.truthtrance?.stage, 'unknown'); + game = act(game, asker, { type: 'truthSave' }); + } + assert.equal(game.truthtrance, null, `${answer} must have a usable continuation`); + assert.deepEqual(game.phaseBidPromises![0], prior); + game = advance(game, state => clean(state) && state.phase === 3 && state.auction?.active === target); + game = act(game, target, { type: 'bid', amount: 4, allyPayment: 0 }); + assert.ok(game.phaseBidPromises?.some(row => row.player === target && row.fulfilled)); + assert.deepEqual(inventory(game), originalCards); + } +} + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + void test(`${mode} displayed movement answers can be accepted alongside a pre-Bidding phase bid Yes`, () => { + const { f, game: source } = early(advanced); + let game = bindTruthTurnBid(f, 'yes', 4, source).game; + const prior = structuredClone(game.phaseBidPromises![0]); + game = askSecond(game, f.asker, f.truths[1], + { kind: 'movement', target: f.target, territory: 'carthag', minimum: 1 }); + const answers = viewGame(game, f.target).truthMovementAnswers; + assert.ok(answers?.includes('no'), 'An empty movement obligation does not spend the bid funds'); + acceptEach(game, answers, f.target, f.asker, prior, f.originalCards); + }); + + void test(`${mode} displayed shipment answers can be accepted alongside a pre-Bidding phase bid Yes`, () => { + const { f, game: source } = early(advanced); + let game = bindTruthTurnBid(f, 'yes', 4, source).game; + const prior = structuredClone(game.phaseBidPromises![0]); + game = askSecond(game, f.asker, f.truths[1], + { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 1 }); + acceptEach(game, viewGame(game, f.target).truthShipmentAnswers, f.target, f.asker, prior, f.originalCards); + }); + + void test(`${mode} displayed revival answers can be accepted alongside a pre-Bidding phase bid Yes`, () => { + const { f, game: source } = early(advanced); + // Controlled physical supplement: move one existing Emperor reserve force + // into its Tanks; no spice, card, phase or promise is installed. + const target = source.players.find(row => row.id === f.target)!; + target.reserves -= 1; target.tanks += 1; + let game = bindTruthTurnBid(f, 'yes', 4, source).game; + const prior = structuredClone(game.phaseBidPromises![0]); + game = askSecond(game, f.asker, f.truths[1], + { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 }); + acceptEach(game, viewGame(game, f.target).truthRevivalAnswers, f.target, f.asker, prior, f.originalCards); + }); +} + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + void test(`${mode} an external debit releases only the phase bid made impossible by a funded independent bribe`, () => { + const { f, game: source } = early(advanced); + assert.equal(source.players.find(row => row.id === f.target)!.spice, 10); + let game = bindTruthTurnBid(f, 'yes', 8, source).game; + game = askSecond(game, f.asker, f.truths[1], { kind: 'bribe', target: f.target, + turn: game.turn, recipient: f.asker, minimum: 2 }); + assert.ok(viewGame(game, f.target).truthBribeAnswers?.includes('yes')); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.equal(game.truthtrance, null); + assert.ok(game.bribePromises?.some(row => row.player === f.target && row.answer && !row.released)); + // Controlled external-loss supplement: transfer one conserved existing + // spice from the respondent to the asker; no cards or commitments change. + // After the two-spice obligation, at most seven remains for the bid. + const harmed = structuredClone(game); + harmed.players.find(row => row.id === f.target)!.spice -= 1; + harmed.players.find(row => row.id === f.asker)!.spice += 1; + const settled = normalizeAutomaticGame(harmed); + const phase = settled.phaseBidPromises?.find(row => row.player === f.target && row.minimum === 8); + assert.ok(phase?.released, 'An evaluated two-spice payment leaves no funded bid of eight'); + assert.ok(settled.bribePromises?.some(row => row.player === f.target && row.answer && !row.released)); + assert.equal(settled.players.find(row => row.id === f.target)!.spice, 9); + assert.deepEqual(inventory(settled), f.originalCards); + }); +}