diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0b21f11d..b61ba7df 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,27 @@ open mode gates. ## Current checkpoint and work +**Basic Richese reveals during responses — 29 October. Prototyped / Partial.** +The owner can reveal an existing No-Field during clean CHOAM Charity, +Atreides normal-auction inspection, Richese cache-auction declaration and +Fremen allied-revival grant responses. The original response and passes +continue without a new wait. Real Basic games exercise the paid marker, +physical reveal, original Karama response and continuation; the Fremen case +also uses real battle losses and a Nexus alliance. All four minimal bot profiles +can advance the preserved Charity response. Six related files pass 24 cases; +type checking, lint and production build pass. Other responses, nested +children, active commitments, Truthtrance and optional modules remain fenced; +complete Richese and deployed acceptance remain open. +[Scope](RICHESE_NO_FIELD_RULES.md#basic-phase-opening-and-economic-interruptions--29-october-2026). + +**Gitea HTTP reproduction — 29 October. Open.** A new targeted test follows +rejected stale-seat and foreign-origin POSTs with authorized reads through a +single-connection agent, without retrying POSTs. All three discussion HTTP +cases and 20 repetitions of the new case passed on a temporary Miniflare +database. This did not reproduce run #103's closed connection or establish its +cause; no production transport change was made. The temporary server was +stopped without touching the running development server or existing games. + **Paid current-turn travel deals — 29 October. Prototyped / Partial.** A two-human Basic classic offer can pair the recipient's travel term with an immediate bribe from the proposer. Final consent binds the existing native @@ -242,8 +263,9 @@ cases pass. Run #101 succeeded. Run #103 built the container but failed one integration test after a revoked-seat POST: the following restored-seat GET lost its connection before receiving a response. Unread request-body handling on a reused connection is a hypothesis, not a confirmed cause. Run #104 for -`82c639c` was running at the latest check; publication after the #103 failure -was skipped. A disposable-container keep-alive reproduction remains open. +`82c639c` succeeded; run #105 for `a1b2aac` was running at the +latest check. Publication after the #103 failure was skipped. A +disposable-container keep-alive reproduction remains open. **Truthtrance ally funding — 29 October. Prototyped / Partial.** Supported quiet Basic Bidding and Shipment/Movement questions can bind a future diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index a4c6c622..ee4ebcb6 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -4,6 +4,13 @@ Audit date: 2026-09-06. This document distinguishes the printed token lifecycle ## Basic phase-opening and economic interruptions — 29 October 2026 +The same voluntary reveal is also prototyped for the clean CHOAM Charity, +Atreides auction-inspection, Richese cache-auction declaration and Fremen +allied revival-grant responses. The owner keeps the original response and +its passes; materializing the marker adds no confirmation or waiting period. +This ordering is provisional. Other nested responses and active commitments +remain fenced, including shipment and revival transactions. + **Prototyped / Partial.** The publisher's CHOAM & Richese rulebook, printed p. 6, permits Richese to reveal a No-Field before Battle. In supported Basic native Ix/CHOAM games, the owner can now reveal during an Ix phase opening or diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d3b4e1dc..d94da4ee 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Richese reveal during selected Basic responses — 29 October 2026 + +The publisher CHOAM & Richese rules, printed p. 6, allow Richese to reveal +before Battle. Allowing that optional reveal during the independent CHOAM +Charity, Atreides normal-auction inspection, Richese cache-auction declaration +and Fremen allied-revival grant responses is a provisional online ordering; +the pending response and original passes continue unchanged. Other response +parents, Truthtrance and optional modules retain their existing fences. +[Scope and source](RICHESE_NO_FIELD_RULES.md#basic-phase-opening-and-economic-interruptions--29-october-2026). + ## Worm rides into the mobile stronghold — 29 October 2026 The [publisher FAQ, p. 5](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 b427f98c..1ce9c0bc 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1144,7 +1144,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Basic and Advanced ordinary mixed battles have a provisional private pool: physical forces already here plus the token value capped by current reserves. The Battle Wheel labels that interpretation explicitly. Both plans seal before materialization, then native support, casualties and aftermath use actual forces. This first version does not adjudicate the FAQ mixed dial cap.', 'In supported card profiles, provisional materialization-first order forecasts the own marker privately without moving forces early. Both plans seal, the original token reveals and materializes its reserve-limited ordinary counters at its sector, then Harass selects/returns actual undialed counters. Reinforcements must leave three original reserves after that reveal and before any future Harass return; an unfunded card cannot seal a plan. Marker0, last-used identity and original reveal-event history remain native.', 'Concealed collection, occupancy and control use one-force presence. Exposed storm sectors and unprotected worm destruction reveal and remove the resulting actual forces. Storm shelter and other sectors remain separate. Trip to Gamont at a location with a marker reveals it, even if ordinary forces also occupy that sector, then returns one actual force if present. It is still used if zero forces remain. Canceling the CHOAM power leaves the marker concealed.', - 'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Unimplemented interruptions, including pending responses and Truthtrance, retain priority.', + 'All four AI profiles choose from their own private token inventory, pay only the one-force shipment price, move the marker explicitly and reveal positive tokens when useful. They retain zero-token presence and use only public information to assess opponents. They also offer safe positive tokens from their own inventory during the allied opportunity, fund them with their own spice and accept or decline private offers using their own force and payment information. A human Richese player’s offer window holds an AI ally’s shipment. Supported Basic Charity, auction and Fremen grant responses can retain their original choices while the owner reveals; other unimplemented interruptions and Truthtrance retain priority.', 'Marker-only and ordinary-plus-marker No-Fields compose with the selected Advanced Occupy profile under the same provisional additive pool. Either selected lead gets its actual variable-owner quote; original fixed Ecaz strength stays separate. Prevention restores only the chosen lead’s own pool. Basic mixed co-side, Homeworld custody, wider Truthtrance and other profile/module boundaries remain. Complete Richese starts and final acceptance stay gated.', ], related: [ @@ -1206,6 +1206,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/richese-reveal-interruption-runtime.test.ts', 'tests/richese-reveal-interruption-controls.test.tsx', 'tests/richese-reveal-interruption-privacy.test.ts', + 'tests/richese-reveal-response-runtime.test.ts', + 'tests/richese-reveal-fremen-grant-runtime.test.ts', 'tests/richese-no-field-engine.test.ts', 'tests/richese-no-field-review.test.ts', 'tests/richese-no-field-bots.test.ts', diff --git a/game/richese-reveal-interruption.ts b/game/richese-reveal-interruption.ts index 284778e8..cdf548cb 100644 --- a/game/richese-reveal-interruption.ts +++ b/game/richese-reveal-interruption.ts @@ -1,4 +1,5 @@ import type { Game } from './engine'; +import { validateFremenRevivalGrant } from './fremen-revival-grant'; const factions = new Set([ 'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', @@ -18,7 +19,7 @@ export function richeseRevealInterruptSupported(g: Game): boolean { g.players.some(player => (player.faction === 'ixians' || player.faction === 'tleilaxu') && !g.expansions.includes('ix')) || g.coalitionState || g.coalitionPreview || g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards || g.discoveryEnabled || g.discoveries || g.discoveryStash || - g.greatMaker || g.sandtrout || g.ecazTreachery || g.truthtrance || g.response || + g.greatMaker || g.sandtrout || g.ecazTreachery || g.truthtrance || g.biddingEnd || g.battle || g.stormResolution || g.spiceResolution || g.spiceSequence || g.victoryContinuation || g.currentAuctionSale || g.richeseAllyOffer || g.bureaucratPayments?.pending) return false; @@ -53,6 +54,46 @@ export function richeseRevealInterruptSupported(g: Game): boolean { g.mixedAllianceQuestions, g.mixedMovementQuestions, g.mixedTraitorQuestions, g.mixedBattleQuestions]; if (mixed.some(rows => rows?.some(row => row.turn === g.turn && row.binding))) return false; + if (g.response) { + // Only independent, un-nested native responses with a stable original + // source are admitted. In particular, shipment and revival transactions + // still need their own continuation proofs. + const response = g.response; + if (g.decision || g.phaseOpening || g.choamMarket || + !Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length || + response.passed.some(id => !g.players.some(player => player.id === id))) return false; + const owner = g.players.find(player => player.id === response.owner); + if (!owner) return false; + const keys = Object.keys(response).sort().join(','); + if (response.kind === 'choamCharity') + return keys === 'kind,owner,passed' && g.phase === 2 && owner.faction === 'choam' && + g.choamCharity?.turn !== g.turn; + if (response.kind === 'atreidesAuction') { + if (keys !== 'kind,owner,passed' || g.phase !== 3 || owner.faction !== 'atreides') return false; + return !g.richeseAuction && !!g.auction && Array.isArray(g.auction.cards) && Number.isSafeInteger(g.auction.index) && g.auction.index >= 0 && + !!g.auction.cards[g.auction.index]; + } + if (response.kind === 'richeseAuction') { + const round = g.richeseBidding; + return keys === 'kind,owner,passed' && g.phase === 3 && owner.faction === 'richese' && + !!round && round.turn === g.turn && round.owner === response.owner && round.stage === 'declaration' && + (round.position === 'first' || round.position === 'last') && + Number.isSafeInteger(round.normalCount) && round.normalCount! >= 0 && round.normalCount! <= g.players.length && + !round.cacheCanceled && !g.auction && !g.richeseAuction && !g.ixAuction && Array.isArray(g.richeseCache) && + (round.normalCountBeforeCache === undefined || Number.isSafeInteger(round.normalCountBeforeCache) && + round.normalCountBeforeCache >= round.normalCount! && round.normalCountBeforeCache <= g.players.length && + round.normalCountBeforeCache - round.normalCount! <= 1); + } + if (response.kind === 'fremenRevivalGrant') { + if (keys !== 'fremenGrant,kind,owner,passed,recipient' || g.phase !== 4 || + owner.faction !== 'fremen' || !response.fremenGrant || + response.fremenGrant.owner !== response.owner || response.fremenGrant.recipient !== response.recipient) + return false; + try { validateFremenRevivalGrant(g, response.fremenGrant); return true; } + catch { return false; } + } + return false; + } if (g.phaseOpening) return g.expansions.includes('ix') && !!g.amalWindowOpen && !g.decision && !g.choamMarket; const decision = g.decision; diff --git a/tests/richese-reveal-fremen-grant-runtime.test.ts b/tests/richese-reveal-fremen-grant-runtime.test.ts new file mode 100644 index 00000000..904a0513 --- /dev/null +++ b/tests/richese-reveal-fremen-grant-runtime.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { actFremenRevivalGrant as act, nextFremenRevivalGrantStep, fremenRevivalGrantClean as clean } from './fixture-fremen-revival-grant'; +import { truthMovementInventory as inventory } from './fixture-truth-movement'; + +const owner = 'richese', fremen = 'fremen', ally = 'atreides', observer = 'emperor'; +const player = (game: Game, id: string) => { + const found = game.players.find(row => row.id === id); assert.ok(found); return found; +}; +function next(game: Game) { + // Richese pays for the real marker during its original first movement turn. + if (game.turn === 1 && game.phase === 5 && game.active === owner && clean(game)) { + const richese = player(game, owner); + if (!richese.shipped) { + const marker = richese.noField!.tokens.find(token => token.value === 3); assert.ok(marker); + return {actor:owner, action:{type:'ship', noField:marker.id, event:richese.noFieldEvent, + territory:'polar_sink', sector:0, allyPayment:0} satisfies Action}; + } + return {actor:owner, action:{type:'endMovement'} satisfies Action}; + } + return nextFremenRevivalGrantStep(game); +} +function reach(game: Game, until: (state: Game) => boolean): Game { + for (let count = 0; count < 2000; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The native game ended before its grant response.'); + const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = act(game, step.actor, step.action); + } + throw Error('The native Fremen grant response was not reached.'); +} + +/** Genuine Basic game: native starting components, real Richese paid shipment, + * Atreides invasion and Fremen battle losses, a subsequent Shai-Hulud and + * Fremen/Atreides alliance. Only original unopened cards and scalar lotteries + * are selected; no board force, wallet, alliance, response or casualty is staged. */ +function nativeGrantSource() { + let game = createGame('RICHESEFREMENGRANT', newPlayer(fremen, 'Fremen', 'fremen'), false, ['choam']); + joinGame(game, newPlayer(ally, 'Atreides', 'atreides')); + joinGame(game, newPlayer('choam', 'CHOAM', 'choam')); + joinGame(game, newPlayer(owner, 'Richese', 'richese')); + joinGame(game, newPlayer(observer, 'Emperor', 'emperor')); + for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); + game = act(game, game.host, {type:'start'}); + assert.equal(game.status, 'setup'); + const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 4); + const karama = game.deck.find(card => card.effect === 'karama'); + assert.equal(ordinary.length, 4); assert.ok(karama); + for (const [position, card] of [...ordinary, karama].entries()) { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + game = reach(game, state => state.status === 'playing'); + assert.ok(player(game, observer).hand.some(card => card.id === karama.id)); + for (const position of [0,1]) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && + !card.discovery && card.territory !== 'sietch_tabr'); + assert.ok(from >= position); + [game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]]; + } + game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === ally && clean(state)); + game = act(game, ally, {type:'ship', territory:'sietch_tabr', sector:14, amount:6, elite:0, allyPayment:0}); + game = reach(game, state => state.turn === 1 && state.phase === 6 && clean(state) && !!state.active); + const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === 'sietch_tabr'); assert.ok(choice); + game = act(game, choice.chooser, {type:'chooseBattle', territory:'sietch_tabr', + target:choice.attacker === choice.chooser ? choice.defender : choice.attacker}); + for (const actor of [fremen, ally]) { + game = reach(game, state => !!state.battle && clean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false); + const leaders = player(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt) + .sort((a,b) => a.strength - b.strength || a.id.localeCompare(b.id)); + const leader = actor === fremen ? leaders.at(-1) : leaders[0]; assert.ok(leader); + game = act(game, actor, {type:'battlePlan', leader:leader.id, dial:actor === fremen ? 6 : 0, + support:0, weapon:null, defense:null}); + } + game = reach(game, state => !state.battle && clean(state)); + assert.equal(player(game, ally).tanks, 6); + const marker = player(game, owner).noField?.deployed; assert.ok(marker); + const event = player(game, owner).noFieldEvent; assert.ok(event); + game = reach(game, state => state.turn === 2 && state.phase === 1 && clean(state) && + !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0); + const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper); + assert.ok(worm >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]); + game = reach(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state)); + game = act(game, fremen, {type:'alliance', target:ally}); + game = act(game, ally, {type:'alliance', target:fremen}); + assert.equal(player(game, fremen).ally, ally); assert.equal(player(game, ally).ally, fremen); + game = reach(game, state => state.turn === 2 && state.phase === 4 && clean(state)); + game = act(game, fremen, {type:'grantRevival'}); + assert.equal(game.response?.kind, 'fremenRevivalGrant'); + assert.ok(player(game, observer).hand.some(card => card.id === karama.id)); + return {game, token:marker.tokenId, event, cards:inventory(game)}; +} + +void test('voluntary No-Field reveal preserves the real Fremen ally grant response and revival continuation', () => { + const {game,token,event,cards} = nativeGrantSource(), response = structuredClone(game.response), + reserves = player(game, owner).reserves, field = player(game, owner).forces['polar_sink:0'] ?? 0, + tanks = player(game, ally).tanks; + assert.equal(viewGame(game, owner).richeseNoField?.canReveal, true); + const after = act(game, owner, {type:'revealNoField', token, event}); + assert.deepEqual(after.response, response); + assert.equal(player(after, owner).noField?.deployed, null); + assert.equal(player(after, owner).reserves, reserves - 3); + assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(after), cards); + const control = act(game, observer, {type:'passResponse'}); + const continued = act(after, observer, {type:'passResponse'}); + assert.notEqual(continued.response?.kind, 'fremenRevivalGrant'); + assert.deepEqual(continued.freeRevival, control.freeRevival); + assert.equal(player(continued, ally).tanks, tanks); + assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(continued), cards); +}); diff --git a/tests/richese-reveal-interruption-quote.test.ts b/tests/richese-reveal-interruption-quote.test.ts index f2922a74..5a2dcf81 100644 --- a/tests/richese-reveal-interruption-quote.test.ts +++ b/tests/richese-reveal-interruption-quote.test.ts @@ -58,6 +58,87 @@ void test('a clean Ix phase opening remains admitted before or after its owner p game.decision = null; game.amalWindowOpen = false; assert.equal(supported(game), false); }); +void test('supported native responses admit the reveal without changing their original source or passes', () => { + const charity = source(); charity.phase = 2; + charity.response = {kind:'choamCharity', owner:'c', passed:['e']}; + const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides')); + auction.auction = {cards:[{id:'lot', name:'Shield', kind:'shield'}], index:0, bid:0, + bidder:null, active:'a', passed:[], opener:0}; + auction.response = {kind:'atreidesAuction', owner:'a', passed:['e']}; + const declaration = source(); + declaration.richeseCache = []; + declaration.richeseBidding = {owner:'r', event:'round', turn:declaration.turn, + stage:'declaration', position:'first', normalCount:1, normalCountBeforeCache:1, + blackMarketSold:false, cacheCanceled:false}; + declaration.response = {kind:'richeseAuction',owner:'r',passed:['e']}; + const grant = source(); grant.phase = 4; + grant.players.push(newPlayer('f','Fremen','fremen')); + grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f'; + grant.freeRevival = []; + grant.response = {kind:'fremenRevivalGrant', owner:'f', recipient:'r', passed:['e'], + fremenGrant:{turn:grant.turn, owner:'f', recipient:'r'}}; + for (const game of [charity, auction, declaration, grant]) { + const before = structuredClone(game); + assert.equal(supported(game), true, game.response!.kind); + assert.deepEqual(game, before); + } +}); + +void test('response admission requires the original phase, owner, source and no nested interaction', () => { + const charity = source(); charity.phase = 2; charity.response = {kind:'choamCharity',owner:'c',passed:[]}; + for (const patch of [ + {phase:3}, {decision:{kind:'auctionPayment',player:'e'}}, {phaseOpening:{passed:[],initialize:true}}, + {pendingShipment:{}}, {response:{kind:'choamCharity',owner:'e',passed:[]}}, + {response:{kind:'choamCharity',owner:'c',passed:['unknown']}}, + {response:{kind:'choamCharity',owner:'c',passed:['e','e']}}, + {response:{kind:'choamCharity',owner:'c',passed:[],intent:'extra'}}, + {choamCharity:{turn:2,canceled:false}}, + ]) assert.equal(supported(Object.assign(structuredClone(charity), patch)), false, JSON.stringify(patch)); + + const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides')); + auction.auction = {cards:[{id:'lot',name:'Shield',kind:'shield'}], index:0, bid:0, + bidder:null, active:'a', passed:[], opener:0}; + auction.response = {kind:'atreidesAuction',owner:'a',passed:[]}; + const missingLot = structuredClone(auction); missingLot.auction!.index = 1; + assert.equal(supported(missingLot), false); + const wrongOwner = structuredClone(auction); wrongOwner.response!.owner = 'e'; + assert.equal(supported(wrongOwner), false); + const unsupportedLot = structuredClone(auction); + unsupportedLot.richeseAuction = {} as NonNullable; + assert.equal(supported(unsupportedLot), false); + const otherResponse = structuredClone(auction); otherResponse.response!.kind = 'richeseAuction'; + assert.equal(supported(otherResponse), false); + + const declaration = source(); declaration.richeseCache = []; + declaration.richeseBidding = {owner:'r',event:'round',turn:declaration.turn,stage:'declaration', + position:'first',normalCount:1,normalCountBeforeCache:1,blackMarketSold:false,cacheCanceled:false}; + declaration.response = {kind:'richeseAuction',owner:'r',passed:[]}; + for (const patch of [ + {phase:4}, {richeseCache:undefined}, {auction:auction.auction}, + {richeseBidding:{...declaration.richeseBidding,turn:1}}, + {richeseBidding:{...declaration.richeseBidding,owner:'e'}}, + {richeseBidding:{...declaration.richeseBidding,stage:'lot'}}, + {richeseBidding:{...declaration.richeseBidding,position:undefined}}, + {richeseBidding:{...declaration.richeseBidding,normalCount:-1}}, + {richeseBidding:{...declaration.richeseBidding,normalCountBeforeCache:3}}, + {richeseBidding:{...declaration.richeseBidding,cacheCanceled:true}}, + {pendingRevival:{}}, + ]) assert.equal(supported(Object.assign(structuredClone(declaration), patch)), false, JSON.stringify(patch)); + const grant = source(); grant.phase = 4; grant.players.push(newPlayer('f','Fremen','fremen')); + grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f'; + grant.freeRevival = []; + grant.response = {kind:'fremenRevivalGrant',owner:'f',recipient:'r',passed:[], + fremenGrant:{turn:grant.turn,owner:'f',recipient:'r'}}; + const stale = structuredClone(grant); stale.response!.fremenGrant!.turn--; + assert.equal(supported(stale), false); + const noAlly = structuredClone(grant); noAlly.players.find(p => p.id === 'r')!.ally = null; + assert.equal(supported(noAlly), false); + const wrongRecipient = structuredClone(grant); wrongRecipient.response!.recipient = 'e'; + assert.equal(supported(wrongRecipient), false); + const unrelatedChild = structuredClone(grant); unrelatedChild.pendingRevival = {} as NonNullable; + assert.equal(supported(unrelatedChild), false); +}); + void test('a legacy table without public Truthtrance history keeps its earlier interruption behavior', () => { const game = parent('auctionPayment'); delete game.truthHistory; diff --git a/tests/richese-reveal-response-runtime.test.ts b/tests/richese-reveal-response-runtime.test.ts new file mode 100644 index 00000000..461bdfb1 --- /dev/null +++ b/tests/richese-reveal-response-runtime.test.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; +import { truthMovementInventory as inventory } from './fixture-truth-movement'; + +const owner = 'richese', choam = 'choam', observer = 'emperor'; +const player = (game: Game, id: string) => { + const found = game.players.find(row => row.id === id); assert.ok(found); return found; +}; +const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && + !game.truthtrance && !game.pendingTreacheryDiscard; +function next(game: Game) { + if (game.phase === 5 && game.active === observer && clean(game)) + return { actor: observer, action: { type:'endMovement' } satisfies Action }; + return nextTruthMovementNativeEarlyStep(game, { + position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false, + }); +} +function reach(game: Game, until: (state: Game) => boolean): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The native game ended before its response.'); + const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = act(game, step.actor, step.action); + } + throw Error('The native response was not reached.'); +} + +/** Genuine Basic setup with CHOAM and Ix decks, a paid first-turn No-Field + * shipment and the original next-turn Charity response. Only unopened starting + * deal and Spice card positions are selected; the physical Karama is dealt to + * Emperor and held through the intervening native turns. */ +function nativeCharitySource(withAtreides = false) { + let game = createGame('RICHESECHARITY', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']); + joinGame(game, newPlayer(choam, 'CHOAM', 'choam')); + if (withAtreides) joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); + joinGame(game, newPlayer(observer, 'Emperor', 'emperor')); + for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); + game = act(game, game.host, {type:'start'}); + assert.equal(game.status, 'setup'); + const ordinaryCount = withAtreides ? 3 : 2; + const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, ordinaryCount); + const karama = game.deck.find(card => card.effect === 'karama'); + assert.equal(ordinary.length, ordinaryCount); assert.ok(karama); + for (const [position, card] of [...ordinary, karama].entries()) { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + game = reach(game, state => state.status === 'playing'); + assert.ok(player(game, observer).hand.some(card => card.id === karama.id)); + for (const position of [0, 1]) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); + [game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]]; + } + game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === owner && clean(state)); + const token = player(game, owner).noField!.tokens.find(item => item.value === 3); assert.ok(token); + const shipmentEvent = player(game, owner).noFieldEvent; assert.ok(shipmentEvent); + game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent, + territory:'polar_sink', sector:0, allyPayment:0}); + game = reach(game, clean); + assert.equal(player(game, owner).noField?.deployed?.tokenId, token.id); + game = act(game, owner, {type:'endMovement'}); + game = reach(game, state => state.turn === 2 && state.phase === 2 && state.response?.kind === 'choamCharity'); + assert.equal(game.response?.owner, choam); + assert.ok(player(game, observer).hand.some(card => card.id === karama.id)); + const event = player(game, owner).noFieldEvent; assert.ok(event); + return {game, token:token.id, event, karama:karama.id, cards:inventory(game)}; +} +let saved: ReturnType | undefined; +const source = () => saved ??= nativeCharitySource(); +let savedAtreides: ReturnType | undefined; +const atreidesSource = () => savedAtreides ??= nativeCharitySource(true); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +void test('Richese reveals during the real CHOAM Charity response while its original cancel opportunity and continuation remain', () => { + const {token,event,karama,cards} = source(); + let before = structuredClone(source().game); + before = act(before, owner, {type:'passResponse'}); + assert.equal(before.response?.kind, 'choamCharity'); + assert.ok(before.response.passed.includes(owner)); + const response = structuredClone(before.response), cash = player(before, choam).spice, + reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0; + assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); + reject(before, observer, {type:'revealNoField', token, event}); + reject(before, owner, {type:'revealNoField', token, event:'stale'}); + reject(before, owner, {type:'revealNoField', token:'stale', event}); + let after = act(before, owner, {type:'revealNoField', token, event}); + assert.deepEqual(after.response, response); + assert.equal(after.turn, before.turn); assert.equal(after.phase, before.phase); + assert.equal(player(after, owner).noField?.deployed, null); + assert.equal(player(after, owner).noField?.lastShipped, token); + assert.notEqual(player(after, owner).noFieldEvent, event); + assert.equal(player(after, owner).reserves, reserves - 3); + assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); + assert.equal(player(after, choam).spice, cash); + assert.ok(player(after, observer).hand.some(card => card.id === karama)); + assert.deepEqual(inventory(after), cards); + reject(after, owner, {type:'revealNoField', token, event}); + after = act(after, observer, {type:'passResponse'}); + assert.notEqual(after.response?.kind, 'choamCharity'); + assert.equal(player(after, choam).spice, cash + 2 * before.players.length); + assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(after), cards); +}); + +void test('all minimal bot profiles can continue the preserved CHOAM Charity response', () => { + const {game, token, event} = source(); + const revealed = act(structuredClone(game), owner, {type:'revealNoField', token, event}); + for (const difficulty of DIFFICULTIES) { + const automated = structuredClone(revealed); + player(automated, observer).bot = difficulty; + const choice = botActions(viewGame(automated, observer)).find(action => + action.type === 'passResponse' || action.type === 'card' && action.mode === 'cancel'); + assert.ok(choice, `${difficulty} needs a legal response`); + const continued = applyAction(automated, observer, choice); + assert.ok(continued.response?.kind !== 'choamCharity' || + continued.response.passed.includes(observer) || continued.pendingKarama || continued.pendingKull || + continued.pendingTreacheryDiscard, + `${difficulty} must advance the original response: ${JSON.stringify({choice, response:continued.response, + decision:continued.decision, discard:!!continued.pendingTreacheryDiscard})}`); + assert.equal(player(continued, owner).forces['polar_sink:0'], 3); + } +}); + +void test('Richese reveals during the real Atreides normal-auction inspection response and preserves its lot', () => { + const {token,event,karama,cards} = atreidesSource(); + const before = reach(structuredClone(atreidesSource().game), state => state.turn === 2 && state.phase === 3 && + state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction); + assert.equal(before.response?.owner, 'atreides'); + assert.ok(player(before, observer).hand.some(card => card.id === karama)); + const response = structuredClone(before.response), lot = structuredClone(before.auction), + reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0; + assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); + const after = act(before, owner, {type:'revealNoField', token, event}); + assert.deepEqual(after.response, response); assert.deepEqual(after.auction, lot); + assert.equal(player(after, owner).noField?.deployed, null); + assert.equal(player(after, owner).reserves, reserves - 3); + assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(after), cards); + const control = act(before, observer, {type:'passResponse'}); + const continued = act(after, observer, {type:'passResponse'}); + assert.notEqual(continued.response?.kind, 'atreidesAuction'); + assert.deepEqual(continued.auction, control.auction); + assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(continued), cards); +}); + +void test('Richese reveals during the real cache-auction prevention response without replaying its declaration', () => { + const {token,event,karama,cards} = source(); + const before = reach(structuredClone(source().game), state => state.turn === 2 && state.phase === 3 && + state.response?.kind === 'richeseAuction'); + assert.equal(before.response?.owner, owner); + assert.ok(player(before, observer).hand.some(card => card.id === karama)); + const response = structuredClone(before.response), round = structuredClone(before.richeseBidding), + reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0; + assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); + const after = act(before, owner, {type:'revealNoField', token, event}); + assert.deepEqual(after.response, response); assert.deepEqual(after.richeseBidding, round); + assert.equal(player(after, owner).noField?.deployed, null); + assert.equal(player(after, owner).reserves, reserves - 3); + assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(after), cards); + const control = act(before, observer, {type:'passResponse'}); + const continued = act(after, observer, {type:'passResponse'}); + assert.notEqual(continued.response?.kind, 'richeseAuction'); + assert.ok(continued.richeseBidding?.event); + assert.deepEqual({...continued.richeseBidding, event:undefined}, {...control.richeseBidding, event:undefined}); + assert.deepEqual(continued.richeseAuction, control.richeseAuction); + assert.deepEqual({...continued.decision, event:undefined}, {...control.decision, event:undefined}); + assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3); + assert.deepEqual(inventory(continued), cards); +}); diff --git a/tests/table-talk-http.test.ts b/tests/table-talk-http.test.ts index 167c75cf..87435fdb 100644 --- a/tests/table-talk-http.test.ts +++ b/tests/table-talk-http.test.ts @@ -1,6 +1,8 @@ // @dune-suite integration import test from 'node:test'; import assert from 'node:assert/strict'; +import { Agent as HttpAgent, request as httpRequest } from 'node:http'; +import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'; import type { GameView } from '../game/engine'; import type { TalkPage } from '../lib/table-talk'; const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000'; @@ -230,3 +232,59 @@ void test('discussion HTTP retries save once and seat recovery inherits conversa { messages: [], before: null, muted: false }, ); }); + +/** Reuse one connection when the server permits it; a server that explicitly + * closes a connection may instead provide a fresh one. Never retry a POST. */ +void test('rejected discussion posts leave the next recovered-seat request usable', async () => { + const f = await fixture(); + const endpoint = f.path + '/messages'; + const message = { id: crypto.randomUUID(), recipientId: f.b.data.me, text: 'Keep-alive recovery witness.' }; + assert.equal((await request(endpoint, message, f.a.cookie)).status, 200); + const recoverySecret = 'b8'.repeat(32); + assert.equal((await request(f.path + '/control', { + type: 'setRecoveryKey', version: f.version, recoverySecret, + }, f.a.cookie)).status, 200); + const recovered = await request(f.path + '/control', { + type: 'recoverSeat', playerId: f.a.data.me, recoverySecret, + operationId: crypto.randomUUID(), + newSessionToken: crypto.randomUUID().replaceAll('-', '') + crypto.randomUUID().replaceAll('-', ''), + }); + assert.equal(recovered.status, 200); + assert.ok(recovered.cookie); + + const address = new URL(base); + const agent = address.protocol === 'https:' + ? new HttpsAgent({ keepAlive: true, maxSockets: 1 }) + : new HttpAgent({ keepAlive: true, maxSockets: 1 }); + async function onConnection(path: string, cookie: string, body?: unknown, origin?: string) { + const url = new URL(path, base); + const payload = body === undefined ? undefined : JSON.stringify(body); + const send = url.protocol === 'https:' ? httpsRequest : httpRequest; + return await new Promise<{status: number; body: string}>((resolve, reject) => { + const req = send(url, { + agent, method: payload === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000), + headers: { cookie, ...(origin ? { origin } : {}), + ...(payload === undefined ? {} : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) }, + }, response => { + const chunks: Buffer[] = []; + response.on('data', chunk => chunks.push(Buffer.from(chunk))); + response.on('error', reject); + response.on('end', () => resolve({status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8')})); + }); + req.on('error', reject); + req.end(payload); + }); + } + try { + const restoredPath = endpoint + `?recipient=${f.b.data.me}`; + assert.equal((await onConnection(endpoint, f.a.cookie!, message)).status, 409); + const restored = await onConnection(restoredPath, recovered.cookie); + assert.equal(restored.status, 200); + assert.deepEqual((JSON.parse(restored.body) as TalkPage).messages.map(row => row.id), [message.id]); + assert.equal((await onConnection(endpoint, recovered.cookie, message, 'https://foreign.invalid')).status, 403); + assert.equal((await onConnection(restoredPath, recovered.cookie)).status, 200); + assert.equal((await onConnection(restoredPath, f.a.cookie!)).status, 409); + } finally { + agent.destroy(); + } +});