diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md index 2bd93791..4564d22d 100644 --- a/docs/ADVANCED_PREVIEW.md +++ b/docs/ADVANCED_PREVIEW.md @@ -52,9 +52,10 @@ that prediction. The increase adds to the ordinary solo and alliance stronghold targets, while independent Ecaz joint-occupation and Homeworld special-victory conditions stay -separate under the provisional interpretation. The original Mentat phase -continues unless CHOAM's completed end-of-Mentat opportunity was the source, -in which case the next turn begins. The final turn defaults to ten; an agreement +separate under the provisional interpretation. Fresh Advanced phases check +victory after players finish Mentat opportunities, so accepting continuation +starts the next turn. Basic and older already-open non-CHOAM phases retain their +opening-time check and resume Mentat. The final turn defaults to ten; an agreement at the current final turn provisionally extends it by one, including a turn-ten agreement. The Fremen and Guild final-victory checks move to the agreed final turn under this interpretation, even though the supplied Fremen sheet names the diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 9020cc4c..ac01e9c6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,21 @@ open mode gates. ## Current checkpoint and work +**Advanced Mentat victory timing — 29 October. Prototyped / Partial.** +Fresh Advanced non-CHOAM phases now check victory after players finish their +Mentat opportunities, so a real Mentat action can precede the result. The +existing CHOAM closing check remains. A shared-victory agreement resumes the +completed phase once; Basic and already-open older non-CHOAM phases retain their +previous opening-time behavior. Moritani placement, cancellation and Stronghold +Card settlement use the same new boundary. Genuine setups with staged conserved +winning positions exercise a binding Truthtrance/bribe, no winner, final-turn +fallback, Tech and Ecaz Homeworld outcomes, all four legal bot profiles, and +Moritani placement choices. Twelve related files pass 103 cases, including eight +new cases; type checking, lint and the production build pass. The end-of-phase +ordering is a provisional online interpretation, not explicit source timing; +complete combinations and deployed acceptance remain open. +[Source and scope](RULE_DECISIONS.md#advanced-mentat-victory-timing--29-october-2026). + **Advanced advisor conversion after native worm rides — 29 October. Prototyped / Partial.** A Fremen ride that leaves Bene Gesserit advisors alone opens the native conversion/Karama response before destination reactions and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1bef029d..f31ec64e 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,18 @@ # Rule decision index +## Advanced Mentat victory timing — 29 October 2026 + +The supplied revision, physical p. 14 (printed p. 12), places victory during +Mentat; it does not prescribe an ordering for ordinary optional actions. Fresh +Advanced non-CHOAM phases now provisionally check after players finish those +opportunities, matching the existing CHOAM closing check. Moritani placement +and Extortion retain their opening order and do not trigger an early check. +An accepted shared-victory agreement advances the completed phase once. Basic +and previously opened unstamped non-CHOAM Mentat phases retain their original +opening-time check and saved agreement continuations. This is an online timing +interpretation, not a publisher clarification or complete victory certification. +[Preview](ADVANCED_PREVIEW.md#simultaneous-victories). + ## Semuta and Ghola during a CHOAM sale — 29 October 2026 The existing printed Semuta, Ghola and Tleilaxu income rules are composed @@ -759,7 +772,8 @@ retains play without applying that result. An agreed increase adds to the ordinary solo and alliance stronghold targets. It does not alter independent Ecaz joint-occupation or Homeworld special-victory conditions under this provisional composition. The original Mentat opportunity -resumes, or the completed CHOAM end-of-Mentat continuation advances once. Older +resumes for opening-time agreements; a completed end-of-Mentat continuation +advances once. Older games without a saved `victoryFinalTurn` retain turn ten as their final turn. A unanimous agreement on that final turn provisionally moves it one turn later; a further agreement may move it again. An earlier agreement leaves the existing diff --git a/game/engine.ts b/game/engine.ts index b8fcb841..77339214 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1497,6 +1497,8 @@ export type Game = { /** Agreed addition to the ordinary stronghold win targets. Older games omit it. */ victoryExtraStrongholds?: number; victoryContinuation?: VictoryContinuation | null; + /** Fresh Advanced non-CHOAM Mentat phases check victory after their opportunities. */ + mentatVictoryAfterActions?: number; charityPromises?: CharityPromise[]; revivalOrder?: RevivalOrder; charityOrder?: CharityOrder; @@ -13862,7 +13864,7 @@ function finishMoritaniPlacement(g: Game) { return; } requireRule(!obligation, 'Finish the pending Extortion payments first.'); - if (!byFaction(g, 'choam')) victory(g); + if (!byFaction(g, 'choam') && g.mentatVictoryAfterActions !== g.turn) victory(g); } function finishSetup(g: Game) { const tleilaxu = byFaction(g, 'tleilaxu'); @@ -30338,6 +30340,10 @@ function advancePhase(g: Game) { g.decision = { kind: 'choamMentat', player: choam.id }; return; } + if (g.phase === 8 && g.mentatVictoryAfterActions === g.turn) { + victory(g, undefined, 'advance'); + if (g.status !== 'playing' || g.victoryContinuation) return; + } const ecaz = byFaction(g, 'ecaz'); if (g.phase === 4 && ecaz && g.ecazPlacementTurn !== g.turn) { g.ecazAmbassadors ??= createAmbassadors(random); @@ -30388,7 +30394,10 @@ function completePhase(g: Game) { } if (g.phase === 8) requireRule(!g.moritaniExtortion, 'Settle Extortion before ending the turn.'); - if (g.phase === 8) settleStrongholdOwnership(g); + if (g.phase === 8) { + settleStrongholdOwnership(g); + delete g.mentatVictoryAfterActions; + } g.phase++; if (g.phase === 9) { if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn); @@ -30556,6 +30565,7 @@ function beginPhase(g: Game) { collect(g); } if (g.phase === 8) { + if (g.advanced && !byFaction(g, 'choam')) g.mentatVictoryAfterActions = g.turn; collectBankerIncome(g); drawHarkonnenNexusBetrayalReplacement(g); replaceMoritaniAssassinationTraitors(g); @@ -37585,9 +37595,12 @@ function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) { ), ); } else if (receipt.kind === 'moritaniPlacement') { - // Mentat opening income is already settled. CHOAM retains its own later - // market/Mentat choice; otherwise this receipt reaches victory now. - if (!byFaction(g, 'choam')) finalVictory = currentVictoryQuote(g); + // Fresh Advanced Mentat phases and CHOAM retain their later victory check. + // Older unstamped phases keep their original opening-time check. + if (!byFaction(g, 'choam')) { + const quote = currentVictoryQuote(g); + if (g.mentatVictoryAfterActions !== g.turn) finalVictory = quote; + } } else if (!byFaction(g, 'choam')) { const resources = currentPhaseResources(g); if (!g.expansions.includes('ix')) { diff --git a/game/reference.ts b/game/reference.ts index 7781f6a4..68033376 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -173,7 +173,7 @@ const phaseDetails = [ 'Review the table chronicle and your spice, then mark yourself ready.', ], [ - 'Deferred bribes become available when Mentat begins. At CHOAM tables, victory waits until Mentat actions, the closing market and a final Trip to Gamont opportunity finish. Other tables currently check victory on entering the phase.', + 'Deferred bribes become available when Mentat begins. In fresh Advanced phases, victory waits until players finish their Mentat opportunities; CHOAM tables also finish the closing market and its final opportunity. Basic and older already-open non-CHOAM phases retain their opening-time check. The Advanced end-of-phase order is a provisional online interpretation.', 'A single faction needs three strongholds; an alliance or a player in a two-player game needs four. A stronghold still occupied by opponents under storm does not count toward victory.', 'On the final turn, check normal wins, then the Fremen special condition, then Guild. Without Guild, Fremen wins; without either faction, the highest individual stronghold count wins, including ties.', 'If play continues, mark yourself ready to start the next turn.', @@ -4504,7 +4504,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.', steps: [ - 'Victory is checked when the Mentat pause begins. A faction normally needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds. The host may enable the printed optional three-stronghold target for two-member alliances with at least three controllers; two-controller games retain their four-stronghold target.', + 'In fresh Advanced phases, victory is checked after Mentat opportunities; Basic and older already-open non-CHOAM phases retain their opening-time check. This end-of-phase order is a provisional online interpretation. A faction normally needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds. The host may enable the printed optional three-stronghold target for two-member alliances with at least three controllers; two-controller games retain their four-stronghold target.', 'In the provisional two-person setup, each person controls two separately played factions and switches between their seats. They are not automatically allied and do not pool holdings unless they form an alliance. Each side needs four strongholds. Ordinary two-seat games also use four.', 'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.', 'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.', diff --git a/tests/advanced-mentat-moritani-victory.test.ts b/tests/advanced-mentat-moritani-victory.test.ts new file mode 100644 index 00000000..611db0b8 --- /dev/null +++ b/tests/advanced-mentat-moritani-victory.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { createMoritaniStrongholdsFixture } from './fixture-moritani-strongholds'; + +/** This inherited fixture uses real Advanced Moritani/Guild/Emperor setup and + * native phase actions through the opening Moritani placement decision, with + * the Stronghold Cards module. Only physical board groups are staged after + * that opening to expose a rare three-stronghold Guild victory. The prevention + * case also transfers one original Karama to Guild's hand without duplication. + * It is not a naturally earned full game or module-combination certification. */ +function position() { + const fixture = createMoritaniStrongholdsFixture(); + const game = structuredClone(fixture.beforePlacement); + assert.equal(game.phase, 8); assert.equal(game.decision?.kind, 'moritaniPlacement'); + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); + player.elites.forces = {}; + } + } + const winner = game.players.find(player => player.faction === 'guild')!; + for (const name of ['arrakeen', 'carthag', 'sietch_tabr']) + winner.forces[`${name}:${territory(name).sectors[0]}`] = 1; + winner.reserves -= 3; + const owner = game.players.find(player => player.faction === 'moritani')!; + return { game, winner: winner.id, owner: owner.id }; +} + +function pending(game: Game) { + assert.equal(game.phase, 8); assert.equal(game.status, 'playing'); + assert.deepEqual(game.winner, []); assert.ok(!game.victoryContinuation); +} +function finish(game: Game, winner: string) { + const turn = game.turn; + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + assert.equal(game.turn, turn); assert.equal(game.status, 'finished'); + assert.deepEqual(game.winner, [winner]); + assert.equal(game.strongholdCards?.claimedTurn, turn); +} +function passPlacement(game: Game) { + while (game.response) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(actor); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + return game; +} +function stageKarama(game: Game, id: string) { + const player = game.players.find(seat => seat.id === id)!; + const held = player.hand.find(card => card.effect === 'karama'); + if (held) return held.id; + const deckIndex = game.deck.findIndex(card => card.effect === 'karama'); + let card = deckIndex >= 0 ? game.deck.splice(deckIndex, 1)[0] : undefined; + if (!card) { + const donor = game.players.find(seat => seat.id !== id && seat.hand.some(candidate => candidate.effect === 'karama')); + assert.ok(donor, 'One original Karama is still in the unplayed supply'); + card = donor.hand.splice(donor.hand.findIndex(candidate => candidate.effect === 'karama'), 1)[0]; + } + if (player.hand.length >= 4) game.deck.push(player.hand.pop()!); + player.hand.push(card); + return card.id; +} + +void test('Advanced Moritani declining placement leaves the winner pending until Mentat closes', () => { + const f = position(); + const game = applyAction(f.game, f.owner, { type: 'decision', decline: true }); + pending(game); assert.equal(game.decision, null); + assert.equal(game.moritaniTerror!.placementTurn, game.turn); + finish(game, f.winner); +}); + +void test('Advanced Moritani placement completes before the later victory check', () => { + const f = position(); + const token = f.game.moritaniTerror!.tokens.find(candidate => candidate.status === 'available')!; + let game = applyAction(f.game, f.owner, { type: 'decision', token: token.id, territory: 'tueks_sietch' }); + game = passPlacement(game); + pending(game); assert.equal(game.moritaniTerror!.placementTurn, game.turn); + assert.equal(game.moritaniTerror!.tokens.find(candidate => candidate.id === token.id)?.location, 'tueks_sietch'); + finish(game, f.winner); +}); + +void test('Advanced Moritani placement canceled by Karama still leaves victory until Mentat closes', () => { + const f = position(), card = stageKarama(f.game, f.winner); + const token = f.game.moritaniTerror!.tokens.find(candidate => candidate.status === 'available')!; + const original = structuredClone(f.game.moritaniTerror!.tokens); + let game = applyAction(f.game, f.owner, { type: 'decision', token: token.id, territory: 'tueks_sietch' }); + assert.equal(game.response?.kind, 'moritaniPlacement'); + game = applyAction(game, f.winner, { type: 'card', mode: 'cancel', card }); + pending(game); assert.equal(game.moritaniTerror!.placementTurn, game.turn); + assert.deepEqual(game.moritaniTerror!.tokens, original); + assert.ok(game.discard.some(candidate => candidate.id === card)); + finish(game, f.winner); +}); diff --git a/tests/advanced-mentat-victory.test.ts b/tests/advanced-mentat-victory.test.ts new file mode 100644 index 00000000..4df7f353 --- /dev/null +++ b/tests/advanced-mentat-victory.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createTechTokens } from '../game/tech-tokens'; +import { bindTruthBribe, createTruthBribeFixture, truthBribePlayer } from './fixture-truth-bribe'; + +/** Original Advanced Harkonnen/Atreides/Emperor setup and native actions reach + * Collection. Only the conserved force locations are staged to expose a rare + * victory board; the agreement case also stages a complete physical Tech set, + * and the final-turn case stages turn ten. This is not a naturally earned + * late-game board, a save/recovery test, or full module acceptance. */ +function position(options: { holds?: number; simultaneous?: boolean; finalTurn?: boolean } = {}) { + const source = createTruthBribeFixture({ advanced: true, phase: 7 }); + const game = structuredClone(source.game); + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); + player.elites.forces = {}; + } + } + const assign = (id: string, names: string[]) => { + const player = truthBribePlayer(game, id); + for (const name of names) player.forces[`${name}:${territory(name).sectors[0]}`] = 1; + player.reserves -= names.length; + }; + const holds = options.holds ?? 3; + assign('atreides', ['arrakeen', 'carthag', 'sietch_tabr'].slice(0, holds)); + if (options.simultaneous) { + assign('emperor', ['tueks_sietch', 'habbanya_ridge_sietch']); + game.techTokens = createTechTokens(); + for (const token of Object.values(game.techTokens)) token.owner = 'emperor'; + } + if (options.finalTurn) game.turn = 10; + return { source, game }; +} + +function enterMentat(initial: Game) { + let game = initial; + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + assert.equal(game.phase, 8); + assert.equal(game.status, 'playing'); + assert.deepEqual(game.winner, []); + assert.equal(game.victoryContinuation, undefined); + return game; +} +function finishMentat(initial: Game) { + let game = initial; + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + return game; +} + +void test('Advanced winner waits through a real Truthtrance and its required Mentat payment', () => { + const f = position(); + let game = enterMentat(f.game); + const bound = bindTruthBribe(f.source, 'yes', 3, f.source.recipient, game); + game = bound.game; + assert.throws(() => applyAction(game, f.source.payer, { type: 'ready' }), /Truthtrance|bribe|payment/i); + const guide = viewGame(game, f.source.payer).bribePromiseGuide; + assert.ok(guide); + const payerSpice = truthBribePlayer(game, f.source.payer).spice; + game = applyAction(game, f.source.payer, guide.action); + assert.equal(truthBribePlayer(game, f.source.payer).spice, payerSpice - 3); + assert.equal(truthBribePlayer(game, f.source.recipient).bribes, 3); + game = finishMentat(game); + assert.equal(game.status, 'finished'); + assert.deepEqual(game.winner, ['atreides']); +}); + +void test('Advanced Mentat with no winner advances once only after every player is ready', () => { + let game = enterMentat(position({ holds: 2 }).game); + const turn = game.turn; + for (const player of game.players.slice(0, -1)) { + game = applyAction(game, player.id, { type: 'ready' }); + assert.equal(game.phase, 8); assert.equal(game.turn, turn); + } + game = applyAction(game, game.players.at(-1)!.id, { type: 'ready' }); + assert.equal(game.status, 'playing'); assert.equal(game.turn, turn + 1); assert.equal(game.phase, 0); + assert.deepEqual(game.winner, []); +}); + +void test('Advanced final-turn fallback is checked after Mentat opportunities', () => { + const game = enterMentat(position({ holds: 2, finalTurn: true }).game); + const settled = finishMentat(game); + assert.equal(settled.turn, 10); assert.equal(settled.status, 'finished'); + assert.deepEqual(settled.winner, ['atreides']); +}); + +void test('Advanced simultaneous qualifiers vote after Mentat and a unanimous agreement advances once', () => { + const game = finishMentat(enterMentat(position({ simultaneous: true }).game)); + const offer = game.victoryContinuation; + assert.ok(offer); assert.equal(offer.resume, 'advance'); + assert.deepEqual(new Set(offer.voters), new Set(['atreides', 'emperor'])); + let next = applyAction(game, offer.voters[0], { type: 'victoryContinuation', event: offer.event, mode: 'continue', extra: 1 }); + for (const voter of offer.voters.slice(1)) + next = applyAction(next, voter, { type: 'victoryContinuation', event: offer.event, mode: 'accept' }); + assert.equal(next.status, 'playing'); assert.equal(next.turn, game.turn + 1); assert.equal(next.phase, 0); + assert.equal(next.victoryExtraStrongholds, 1); assert.ok(!next.victoryContinuation); +}); + +void test('all four minimal bot profiles can finish Advanced Mentat before victory', () => { + const initial = enterMentat(position().game); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(initial); + for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find(seat => seat.id === player.id)!.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'ready'); + assert.ok(action, `${difficulty} can finish ${player.id}'s Mentat opportunity`); + game = applyAction(game, player.id, action); + } + assert.equal(game.status, 'finished'); assert.deepEqual(game.winner, ['atreides']); + } +}); diff --git a/tests/advanced-victory-stronghold-tiebreak.test.ts b/tests/advanced-victory-stronghold-tiebreak.test.ts index f2cc95ec..de08a992 100644 --- a/tests/advanced-victory-stronghold-tiebreak.test.ts +++ b/tests/advanced-victory-stronghold-tiebreak.test.ts @@ -34,6 +34,12 @@ function actual(g: Game) { let result = structuredClone(g); result.phase = 7; for (const p of result.players) result = applyAction(result, p.id, { type: 'ready' }); + if (result.advanced) { + assert.equal(result.phase, 8); + assert.equal(result.status, 'playing'); + assert.ok(!result.victoryContinuation); + for (const p of result.players) result = applyAction(result, p.id, { type: 'ready' }); + } return result; } diff --git a/tests/ecaz-homeworld-victory-runtime.test.ts b/tests/ecaz-homeworld-victory-runtime.test.ts index 2769e13e..09204f17 100644 --- a/tests/ecaz-homeworld-victory-runtime.test.ts +++ b/tests/ecaz-homeworld-victory-runtime.test.ts @@ -39,6 +39,12 @@ function originalMentat(game: Game) { assert.equal(game.phase, 8); return game; } +function finishMentat(game: Game) { + assert.equal(game.phase, 8); + for (const player of game.players) if (!game.ready.includes(player.id)) + game = applyAction(JSON.parse(JSON.stringify(game)), player.id, { type: 'ready' }); + return game; +} void test('actual original Mentat awards the high Ecaz two-native-world alliance win with one joint stronghold and no ordinary point inflation', () => { const fixture = ecazHomeworldFixture(true); @@ -59,11 +65,19 @@ void test('actual original Mentat awards the high Ecaz two-native-world alliance const ready = botActions(view).find(action => action.type === 'ready') ?? { type: 'ready' as const }; result = applyAction(result, player.id, ready); } + assert.equal(result.phase, 8); assert.equal(result.status, 'playing'); + for (const player of result.players) { + const view = viewGame(result, player.id); view.players.find(seat => seat.id === player.id)!.bot = difficulty; + const ready = botActions(view).find(action => action.type === 'ready') ?? { type: 'ready' as const }; + result = applyAction(result, player.id, ready); + } assert.equal(result.status, 'finished'); assert.deepEqual(new Set(result.winner), new Set(['ec', 'al'])); assert.deepEqual(biddingPhysicalIds(result), cards); } game = originalMentat(game); + assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); + game = finishMentat(game); assert.equal(game.status, 'finished'); assert.deepEqual(new Set(game.winner), new Set(['ec', 'al'])); assert.deepEqual(biddingPhysicalIds(game), cards); assert.deepEqual(game.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks })), forces); @@ -77,6 +91,8 @@ void test('native population6 does not award this win despite the same actual jo assert.equal(viewGame(game, 'ec').ecazHomeworldVictory!.high, false); game = originalMentat(game); assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); + game = finishMentat(game); + assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); }); for (const predicted of ['ecaz', 'guild'] as const) void test(`actual BG prediction of ${predicted} replaces the printed Ecaz alliance win without changing public progress`, () => { @@ -85,6 +101,8 @@ for (const predicted of ['ecaz', 'guild'] as const) void test(`actual BG predict ecazSeat(game, 'observer').prediction = { faction: predicted, turn: game.turn }; assert.equal(viewGame(game, 'al').ecazHomeworldVictory!.qualifies, progress.qualifies); game = originalMentat(game); + assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); + game = finishMentat(game); assert.deepEqual(game.winner, ['observer']); assert.equal(game.status, 'finished'); }); diff --git a/tests/victory-continuation-runtime.test.ts b/tests/victory-continuation-runtime.test.ts index ae1d72b5..ee803407 100644 --- a/tests/victory-continuation-runtime.test.ts +++ b/tests/victory-continuation-runtime.test.ts @@ -48,9 +48,14 @@ function reachMentat(game: Game): Game { g = applyAction(g, g.decision.player, { type: 'decision', done: true }); return g; } +function finishMentat(game: Game): Game { + let g = game; + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + return g; +} function offer(game: Game): NonNullable { const pending = game.victoryContinuation; - assert.ok(pending, 'The original Collection and Mentat transition offers a public agreement'); + assert.ok(pending, 'The Mentat victory check offers a public agreement'); return pending; } function act(game: Game, player: string, mode: 'finish' | 'continue' | 'accept', extra?: number): Game { @@ -72,10 +77,12 @@ function completeVotes(game: Game, extra: number): Game { } void test('Advanced public simultaneous victory waits for each qualifying participant and rejects other actions', () => { - const game = reachMentat(position()); + const opened = reachMentat(position()); + assert.equal(opened.phase, 8); assert.ok(!opened.victoryContinuation); + const game = finishMentat(opened); assert.equal(game.phase, 8); assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); const pending = offer(game); - assert.equal(pending.resume, 'mentat'); + assert.equal(pending.resume, 'advance'); assert.deepEqual(pending.sides, [['ix'], ['at', 'em']]); assert.deepEqual(pending.voters, ['ix', 'at', 'em']); assert.deepEqual(pending.accepted, []); @@ -91,7 +98,7 @@ void test('Advanced public simultaneous victory waits for each qualifying partic }); void test('declining the common proposal settles the original winners and correct private prediction', () => { - const game = reachMentat(position({ prediction: true })); + const game = finishMentat(reachMentat(position({ prediction: true }))); const vote = offer(game); assert.deepEqual(game.winner, []); const publicIx = viewGame(game, 'ix').victoryContinuation; @@ -105,11 +112,11 @@ void test('declining the common proposal settles the original winners and correc assert.ok(!next.victoryContinuation); }); -void test('unanimous continuation raises the ordinary solo and alliance targets without losing Mentat', () => { - const original = reachMentat(position()); +void test('unanimous continuation raises targets and advances once after completed Mentat', () => { + const original = finishMentat(reachMentat(position())); const beforeForces = original.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves })); const next = completeVotes(original, 2); - assert.equal(next.status, 'playing'); assert.equal(next.phase, 8); assert.equal(next.turn, original.turn); + assert.equal(next.status, 'playing'); assert.equal(next.phase, 0); assert.equal(next.turn, original.turn + 1); assert.deepEqual(next.winner, []); assert.ok(!next.victoryContinuation); assert.equal(next.victoryExtraStrongholds, 2); assert.deepEqual(next.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves })), beforeForces); @@ -117,15 +124,11 @@ void test('unanimous continuation raises the ordinary solo and alliance targets assert.equal(progress.find(row => row.player === 'ix')?.target, 5); assert.equal(progress.find(row => row.player === 'at')?.target, 6); assert.equal(progress.find(row => row.player === 'at')?.qualifies, false); - let completed = next; - for (const p of completed.players) completed = applyAction(completed, p.id, { type: 'ready' }); - assert.equal(completed.turn, original.turn + 1); - assert.equal(completed.victoryExtraStrongholds, 2); }); void test('zero-increase agreement preserves ordinary targets for the next turn', () => { - const next = completeVotes(reachMentat(position()), 0); - assert.equal(next.status, 'playing'); assert.equal(next.phase, 8); + const next = completeVotes(finishMentat(reachMentat(position())), 0); + assert.equal(next.status, 'playing'); assert.equal(next.phase, 0); assert.equal(next.victoryExtraStrongholds ?? 0, 0); const progress = viewGame(next, 'ix').victoryProgress; assert.equal(progress.find(row => row.player === 'ix')?.target, 3); @@ -138,13 +141,28 @@ void test('Basic multiple qualifiers can agree while one qualifying side still w assert.ok(basic.victoryContinuation); const settled = act(basic, basic.victoryContinuation.voters[0], 'finish'); assert.equal(settled.status, 'finished'); - const single = reachMentat(position({ onlyAlliance: true })); + const single = finishMentat(reachMentat(position({ onlyAlliance: true }))); assert.equal(single.status, 'finished'); assert.ok(single.winner.length > 0); assert.ok(!single.victoryContinuation); }); +void test('a saved legacy Advanced agreement resumes its original Mentat opportunity', () => { + // Stage the old opening-time receipt using the still-supported Basic path. + // This controlled saved frame is not a historical-room recovery proof. + const game = reachMentat(position({ advanced: false })); + assert.equal(offer(game).resume, 'mentat'); + game.advanced = true; + assert.equal(game.mentatVictoryAfterActions, undefined); + const next = completeVotes(game, 1); + assert.equal(next.status, 'playing'); assert.equal(next.phase, 8); + assert.equal(next.turn, game.turn); assert.ok(!next.victoryContinuation); + const advanced = finishMentat(next); + assert.equal(advanced.turn, game.turn + 1); + assert.equal(advanced.phase, 0); +}); + void test('all four minimal bot profiles produce a legal response for the public continuation', () => { - const game = reachMentat(position()); + const game = finishMentat(reachMentat(position())); for (const level of DIFFICULTIES) { const view = viewGame(game, 'ix'); view.players.find(player => player.id === 'ix')!.bot = level; diff --git a/tests/victory-extended-turn-runtime.test.ts b/tests/victory-extended-turn-runtime.test.ts index 0f046942..6955fbbf 100644 --- a/tests/victory-extended-turn-runtime.test.ts +++ b/tests/victory-extended-turn-runtime.test.ts @@ -53,6 +53,11 @@ function enterMentat(source: Game): Game { g = applyAction(g, g.decision.player, { type: 'decision', done: true }); assert.equal(g.decision?.kind, 'choamMentat'); g = applyAction(g, g.decision.player, { type: 'decision', done: true }); + } else if (g.advanced) { + // Fresh Advanced Mentat evaluates victory after all ordinary opportunities. + assert.equal(g.status, 'playing'); + assert.ok(!g.victoryContinuation); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); } return g; } @@ -110,7 +115,7 @@ void test('unanimous turn-ten agreement opens real turn eleven without replaying const opening = enterMentat(original); const before = opening.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })); const agreed = accept(opening, 1); - assert.equal(agreed.turn, 10); assert.equal(agreed.phase, 8); + assert.equal(agreed.turn, 11); assert.equal(agreed.phase, 0); assert.equal(agreed.victoryFinalTurn, 11); assert.equal(agreed.victoryExtraStrongholds, 1); assert.deepEqual(agreed.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before); const next = finishMentat(agreed); @@ -151,6 +156,9 @@ void test('accepted turn eleven follows native phases through its next Mentat ch } assert.equal(game.turn, 11); assert.equal(game.phase, 8, 'The original phase sequence reaches the next Mentat check'); + assert.equal(game.status, 'playing'); + assert.ok(!game.victoryContinuation); + for (const p of game.players) game = actAdvancedRevivalOrder(game, p.id, { type: 'ready' }, actions); assert.ok(game.status === 'finished' || game.victoryContinuation, 'The extended final-turn check settles a winner or offers the next agreement'); assert.equal(game.victoryFinalTurn, 11); diff --git a/tests/victory-quote.test.ts b/tests/victory-quote.test.ts index 3955bf6a..c15e7412 100644 --- a/tests/victory-quote.test.ts +++ b/tests/victory-quote.test.ts @@ -62,6 +62,10 @@ function actual(g: Game) { g = structuredClone(g); g.phase = 7; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + if (g.advanced && g.phase === 8 && g.status === 'playing' && !g.victoryContinuation) { + // Fresh Advanced games check the quote after every Mentat opportunity. + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + } return g; } function compare(g: Game, winner: string[]) {