diff --git a/CHANGELOG.md b/CHANGELOG.md index 590a1a0..a3decac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,19 @@ who reopens it with "see the full results" comes back to. the end of a timetable. Same shape as the three attempts before it: the thing that was verified was not the thing the player uses. +### `configWith` let the day count and the Revenue floor disagree + +`minCombinedRevenue` fell back to `SOLO_CONFIG`'s constant — the floor for a *five*-Day game — +whatever `days` said. So `configWith({ days: 1 })` asked a one-Day game to clear **15**, a figure a +full five-Day game averages barely half of, and `configWith({ days: 10 })` asked for the same 15 a +five-Day game does. It derives from the days it was actually given now. + +Not a live fault: `createLocalSession` is the only caller, and the page always writes the floor +itself, so no dealt game was ever wrong. It was found by a throwaway probe written to reproduce the +extension bug above, which passed only `days` — which is exactly how the next caller would reach for +it. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own floor is this same +formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivation. + ### Three wording and layout fixes - **The collision entries** on all three screens now read "The game ends immediately and results in @@ -90,7 +103,7 @@ not the thing the player uses. cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal". -870 tests pass, three of them new; one existing test asserted the opposite of the collision ruling +873 tests pass, six of them new; one existing test asserted the opposite of the collision ruling above and says so where it was reversed. --- diff --git a/src/web/game.ts b/src/web/game.ts index 66505b5..c7c656d 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -134,10 +134,25 @@ export type NewGameOptions = { /** The same config with the New Game dialog's answers in it. */ export function configWith(opts: NewGameOptions): GameConfig { + const days = opts.days ?? SOLO_CONFIG.days; return { ...SOLO_CONFIG, - days: opts.days ?? SOLO_CONFIG.days, - minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue, + days, + /** + * DERIVED FROM THE DAYS ACTUALLY IN PLAY, not from `SOLO_CONFIG`'s five-Day constant. + * + * It fell back to the constant until 2026-08-30, so `configWith({ days: 1 })` asked a one-Day + * game to clear **15** — a floor a five-Day game averages barely half of — and + * `configWith({ days: 10 })` asked for the same 15 a five-Day game does. The two fields silently + * disagreed, which is the one thing a "build me a config" helper must not let happen. + * + * Not a live fault when it was found: `createLocalSession` is the only caller, and the page + * always writes `minCombinedRevenue` itself (`solitaireDefaults` re-derives it from the preset). + * Found by a throwaway probe that passed only `days` — which is exactly how the next caller + * would use this. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own + * floor is this same formula at `DEFAULT_DAYS`. + */ + minCombinedRevenue: opts.minCombinedRevenue ?? collectiveRevenueFloor(1, days), maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay, maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal, optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) }, diff --git a/test/web.test.ts b/test/web.test.ts index 32177a6..4b4c294 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -32,6 +32,8 @@ import { createGame as createEngineGame } from '../src/engine/setup.ts'; import { advance as advanceEngine } from '../src/engine/advance.ts'; import type { GameConfig } from '../src/engine/state.ts'; import { + configWith, + SOLO_CONFIG, overHandLimit, actionGroups, actionMenu, @@ -3339,6 +3341,34 @@ describe('the Day rolling over says so (Gitea#10)', () => { // --------------------------------------------------------------------------- +describe('configWith', () => { + /** + * The Revenue floor is a function of how long the game is, so a helper that takes `days` and + * defaults the floor has to derive it. It fell back to `SOLO_CONFIG`'s five-Day constant until + * 2026-08-30, which let the two fields disagree: a one-Day game was asked to clear 15, a floor a + * five-Day game averages barely half of. Found by a probe that passed only `days` — which is how + * the next caller would reach for it. + */ + it('derives the Revenue floor from the days it was given', () => { + assert.equal(configWith({ days: 1 }).minCombinedRevenue, 3, 'a one-Day game kept the five-Day floor'); + assert.equal(configWith({ days: 10 }).minCombinedRevenue, 30, 'a ten-Day game kept the five-Day floor'); + }); + + it('leaves the default day count exactly where it was', () => { + // The fix must not move the floor anybody is actually playing against: SOLO_CONFIG's own floor + // is this same formula at DEFAULT_DAYS, so the default path is unchanged. + assert.equal(configWith({}).minCombinedRevenue, SOLO_CONFIG.minCombinedRevenue); + assert.equal(configWith({ days: SOLO_CONFIG.days }).minCombinedRevenue, SOLO_CONFIG.minCombinedRevenue); + }); + + it('still lets a caller name a floor that has nothing to do with the length', () => { + // Deriving is the DEFAULT, not a rule — the New Game dialog and the lobby both let a player set + // a floor directly, and that has to survive being passed through here. + assert.equal(configWith({ days: 10, minCombinedRevenue: 4 }).minCombinedRevenue, 4); + assert.equal(configWith({ days: 3, minCombinedRevenue: 0 }).minCombinedRevenue, 0, 'an off floor was re-derived'); + }); +}); + describe('the end-of-game results screen (Gitea#16)', () => { /** * A finished game, built by running the clock off the end of a real one rather than by hand — the