diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a6b52c93..eef238f4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,14 @@ open mode gates. ## Current checkpoint and work +**Staged group actions after Truthtrance history — 29 October. Prototyped / Partial.** +Settled commitment records no longer permanently disable group Nexus and +funding. Unresolved records remain blocked until reconciliation, and active +bribe-expression and revival variants are also fenced. Group play is still +unavailable from the lobby. Five related files pass 60 cases; typecheck, lint +and the production build pass. The new cases stage commitment histories and exercise real group +actions, rather than a full Truthtrance journey. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026). + **Staged group funding with Karama shipment pricing — 29 October. Prototyped / Partial.** Group pledges remain available at a purchased Karama rate for physical reserve shipments and native Guild transport, with all shares paid to the bank. Group diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index c511c325..2df3875f 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,15 @@ # Optional three-stronghold alliance victory +## Group actions after Truthtrance history — 29 October 2026 + +**Staged Basic prototype, not a selectable game mode.** Fulfilled and released +Truthtrance records no longer disable later group Nexus negotiations or group +funding. Unresolved records remain fenced until their native reconciliation +closes them, including an unresolved older record; funding also fences live +bribe-expression and leader/expression revival commitments. The existing +committed-bid settlement exception is retained. Combining group actions with +unresolved commitments remains unfinished, and no lobby exposes group play. + ## Group funding with Karama shipment pricing — 29 October 2026 **Staged Basic prototype, not a selectable game mode.** Group members can diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index fd73d777..7d5f784f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Group actions after Truthtrance history — 29 October 2026 + +Settled commitment records no longer prevent staged group negotiation and +funding. Unresolved records continue to require native reconciliation before +these unsupported combinations can proceed. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026). + ## Group funding with Karama shipment pricing — 29 October 2026 The staged Basic group prototype retains separate donor shares when a player diff --git a/game/engine.ts b/game/engine.ts index e862facc..55bb68a4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16031,6 +16031,11 @@ function allianceQuestionWindow(g: Game): boolean { return !!allianceOpportunity(g) && !g.spiceWindow && !g.spiceResolution && !g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama && !g.pendingKull; } +/** Retained settled Truthtrance history is not an active commitment. Keep + * unresolved records fenced until native reconciliation has closed them. */ +function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean { + return !!records?.some(record => !record.fulfilled && !record.released); +} /** Group negotiation is deliberately unexposed until its other alliance * mechanics are integrated. This narrow profile keeps existing pair games * and unrelated commitments on their original paths. */ @@ -16044,8 +16049,8 @@ function coalitionNexusAvailable(g: Game): boolean { g.expansions.length === 0 && g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) && Object.values(g.aid).every(credit => credit.amount === 0) && - !(g.alliancePromises?.length || g.allianceExpressionPromises?.length || - g.bribePromises?.length || g.bribeExpressionPromises?.length || g.turnActionPromises?.length); + ![g.alliancePromises, g.allianceExpressionPromises, g.bribePromises, + g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises); } function coalitionRule(work: () => T): T { try { return work(); } @@ -16103,8 +16108,9 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction || - (!committedBid && (g.bidPromises?.length || g.phaseBidPromises?.length || g.turnActionPromises?.length || - g.revivalPromises?.length || g.bribePromises?.length))) return false; + (!committedBid && [g.bidPromises, g.phaseBidPromises, g.turnActionPromises, + g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises, + g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises))) return false; const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id)); return group?.members.length === 3; } diff --git a/game/reference.ts b/game/reference.ts index 48641bf4..6a1deaa1 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Guild transport, purchased-rate funding, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, funding after staged settled Truthtrance records, Guild transport, purchased-rate funding, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] }, ], related: [ 'mentat', diff --git a/tests/coalition-bid-funding.test.ts b/tests/coalition-bid-funding.test.ts index 869f8d1f..61fc1a13 100644 --- a/tests/coalition-bid-funding.test.ts +++ b/tests/coalition-bid-funding.test.ts @@ -104,6 +104,19 @@ void test('each donor share of a leading group bid remains locked until outbid', assert.equal(spice(game), 32); }); +void test('fulfilled and released bid promises leave group bid funding usable', () => { + for (const history of ['settled', 'prior-turn'] as const) { + const initial = fixture(); + initial.phaseBidPromises = [{event:`bid-${history}`, player:'d', asker:'a', + turn:history === 'prior-turn' ? initial.turn - 1 : initial.turn, + minimum:1, answer:true, ...(history === 'settled' ? {fulfilled:true} : {released:true})}]; + const game = act(pledge(initial), 'a', groupBid); + assert.equal(game.auction?.bidder, 'a'); + assert.deepEqual(game.auction?.coalitionContributions, groupBid.coalitionContributions); + assert.deepEqual(game.phaseBidPromises, initial.phaseBidPromises); + } +}); + void test('only the bidder sees the combined group payment, without raw donor allocations', () => { const game = act(pledge(fixture()), 'a', groupBid); const own = viewGame(game, 'a').auction; assert.ok(own); diff --git a/tests/coalition-nexus-engine.test.ts b/tests/coalition-nexus-engine.test.ts index 0189e6da..e279de5f 100644 --- a/tests/coalition-nexus-engine.test.ts +++ b/tests/coalition-nexus-engine.test.ts @@ -65,6 +65,45 @@ void test('native pair expands only after three separate group approvals', () => assert.deepEqual(game.ready, []); }); +void test('fulfilled and released Truthtrance records do not prevent group Nexus negotiation', () => { + for (const history of ['settled', 'prior-turn'] as const) { + const game = originalPair(); + // Stage only the public history; pair, proposal and answers are real actions. + game.bribePromises = [{event: `bribe-${history}`, player:'d', asker:'a', + turn:history === 'prior-turn' ? game.turn - 1 : game.turn, + recipient:'c', minimum:1, answer:true, progress:history === 'settled' ? 1 : 0, + ...(history === 'settled' ? {fulfilled:true} : {released:true})}]; + game.alliancePromises = [{event:`alliance-${history}`, source:'old-nexus', + player:'d', asker:'a', turn:game.turn - 1, partner:'c', answer:false, fulfilled:true}]; + assert.ok(viewGame(game, 'a').coalitionNexus, 'Old answers do not hide the group controls.'); + let formed = propose(game); + formed = answer(formed, 'b'); formed = answer(formed, 'c'); + assert.deepEqual(coalitionOf(formed.players, formed.coalitionState, 'a')!.members, ['a','b','c']); + assert.deepEqual(formed.bribePromises, game.bribePromises); + assert.deepEqual(formed.alliancePromises, game.alliancePromises); + } +}); + +void test('an unresolved prior-turn Truthtrance record still blocks group Nexus', () => { + const game = originalPair(); + game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1, + recipient:'c', minimum:1, answer:true, progress:0}]; + assert.equal(viewGame(game, 'a').coalitionNexus, null); + const pair = coalitionOf(game.players, game.coalitionState, 'a')!; + rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c', + expectedRevision:game.coalitionState!.revision}); +}); + +void test('a current unanswered group-incompatible Truthtrance commitment still blocks group Nexus', () => { + const game = originalPair(); + game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn, + recipient:'c', minimum:1, answer:true, progress:0}]; + assert.equal(viewGame(game, 'a').coalitionNexus, null); + const pair = coalitionOf(game.players, game.coalitionState, 'a')!; + rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c', + expectedRevision:game.coalitionState!.revision}); +}); + void test('any participant may decline and the original pair remains unchanged', () => { const original = originalPair(); const pending = propose(original); diff --git a/tests/coalition-shipment-funding.test.ts b/tests/coalition-shipment-funding.test.ts index c40e3c71..226a21dc 100644 --- a/tests/coalition-shipment-funding.test.ts +++ b/tests/coalition-shipment-funding.test.ts @@ -101,6 +101,54 @@ void test('two allies fund one ordinary shipment with donor-specific settlement assert.equal(spiceIncludingEscrow(game), originalSpice); }); +void test('fulfilled and released Truthtrance records leave group shipment funding usable', () => { + for (const history of ['settled', 'prior-turn'] as const) { + const initial = fixture(); + initial.bribePromises = [{event:`bribe-${history}`, player:'d', asker:'a', + turn:history === 'prior-turn' ? initial.turn - 1 : initial.turn, + recipient:'c', minimum:1, answer:true, progress:history === 'settled' ? 1 : 0, + ...(history === 'settled' ? {fulfilled:true} : {released:true})}]; + let game = pledge(initial); + assert.equal(viewGame(game, 'a').coalitionFunding?.incoming.length, 2); + game = finishResponses(send(game, 'a', shipment)); + assert.equal(game.players[0].forces['imperial_basin:9'], 3); + assert.equal(remaining(game, 'b'), 1); + assert.equal(remaining(game, 'c'), 1); + assert.deepEqual(game.bribePromises, initial.bribePromises); + conservedForces(game); + } +}); + +void test('an unreconciled prior-turn commitment blocks group shipment funding', () => { + const game = fixture(); + game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1, + recipient:'c', minimum:1, answer:true, progress:0}]; + assert.equal(viewGame(game, 'a').coalitionFunding, null); + const before = structuredClone(game); + assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4})); + assert.deepEqual(game, before); +}); + +void test('a live No bribe-expression commitment blocks group shipment funding', () => { + const game = fixture(); + game.bribeExpressionPromises = [{event:'live-expression-no', player:'d', asker:'a', + turn:game.turn, claim:{kind:'payment', recipient:'c', minimum:1}, answer:false, progress:[]}]; + assert.equal(viewGame(game, 'a').coalitionFunding, null); + const before = structuredClone(game); + assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4})); + assert.deepEqual(game, before); +}); + +void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => { + const game = fixture(); + game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn, + recipient:'c', minimum:1, answer:true, progress:0}]; + assert.equal(viewGame(game, 'a').coalitionFunding, null); + const before = structuredClone(game); + assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4})); + assert.deepEqual(game, before); +}); + void test('fake or insufficient donor contributions reject without payment or arrival', () => { const game = pledge(fixture()); for (const contributions of [