diff --git a/apps/mapgen-studio/src/app/StudioShell.tsx b/apps/mapgen-studio/src/app/StudioShell.tsx index d3c94b8a6a..e8381067bd 100644 --- a/apps/mapgen-studio/src/app/StudioShell.tsx +++ b/apps/mapgen-studio/src/app/StudioShell.tsx @@ -40,12 +40,13 @@ import { type RunInGameOperationStatus, } from "../features/runInGame/status"; import { + clearStudioSetupSavedConfig, getLocalPlayerSetup, normalizeStudioSetupConfig, optionRowsFromParameter, studioSetupConfigFromLiveSnapshot, + studioSetupConfigFromSavedConfigFile, studioSetupDriftsFromSavedConfig, - updateStudioSetupSavedConfig, type Civ7SetupSnapshotLike, type Civ7StudioSetupConfig, } from "../features/civ7Setup/setupConfig"; @@ -1399,11 +1400,11 @@ export function StudioShell(props: StudioShellProps) { }; }, [liveSetup.setup, savedSetupConfigs.configurations, savedSetupConfigs.status, setupCatalog.catalog, setupConfig]); - // Config precedence (Y2): the saved-config selector must SHOW when the - // game-setup dropdowns (or a live sync) have superseded values the selected - // saved config governs — drift is "re-applying the file would change the - // state". The header renders the orange Modified affordance off this flag; - // clicking it re-applies the saved config (sync back). + // Config precedence (Y2, hardened in P7): the selector claims the saved + // config ONLY while the authored setup state equals the file-derived state + // — any difference (dropdown edit, live sync, stray persisted key) means + // the launch would not be the file, so the header shows "Custom" instead. + // Re-selecting the config re-applies the file exactly and returns to clean. const savedSetupConfigModified = useMemo(() => { const selectedId = setupConfig.savedConfig?.id; if (!selectedId) return false; @@ -1415,10 +1416,10 @@ export function StudioShell(props: StudioShellProps) { const handleSavedSetupConfigChange = useCallback((configId: string) => { const savedConfig = savedSetupConfigs.configurations.find((config) => config.id === configId); if (!savedConfig) { - setSetupConfig((current) => updateStudioSetupSavedConfig(current, undefined)); + setSetupConfig((current) => clearStudioSetupSavedConfig(current)); return; } - setSetupConfig((current) => updateStudioSetupSavedConfig(current, savedConfig)); + setSetupConfig(studioSetupConfigFromSavedConfigFile(savedConfig)); const nextSeed = savedConfig.summary.mapSeed ?? savedConfig.summary.gameSeed; if (nextSeed !== undefined) { const seedPolicy = parseCiv7StudioSeed(nextSeed); diff --git a/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts b/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts index fe49551017..3b424e0a2e 100644 --- a/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts +++ b/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts @@ -282,6 +282,19 @@ export function updateStudioSetupMapScript( }); } +/** + * Selecting a saved config applies the file EXACTLY (config-precedence rule). + * + * Why full replacement: at launch the engine loads the saved configuration + * file into Civ7 first and then re-applies EVERY studio game/player option on + * top of it (`prepareCiv7SinglePlayerSetup` in @civ7/direct-control), so any + * studio key the file does not specify would silently override the loaded + * file. The only state in which "the selected config is what launches" holds + * is studio state that equals the file-derived state — stale keys from + * earlier sessions, live syncs, or dropdown edits are deliberately wiped + * here. Anything the user changes afterwards flips the selector to "Custom" + * (see `studioSetupDriftsFromSavedConfig`). + */ export function studioSetupConfigFromSavedConfigFile(savedConfig: Civ7SavedSetupConfigFile): Civ7StudioSetupConfig { return normalizeStudioSetupConfig({ savedConfig, @@ -292,38 +305,29 @@ export function studioSetupConfigFromSavedConfigFile(savedConfig: Civ7SavedSetup }); } -export function updateStudioSetupSavedConfig( - config: Civ7StudioSetupConfig, - savedConfig: Civ7SavedSetupConfigFile | undefined, -): Civ7StudioSetupConfig { - if (!savedConfig) return normalizeStudioSetupConfig({ ...config, savedConfig: undefined }); - return normalizeStudioSetupConfig({ - ...config, - savedConfig, - gameOptions: { - ...config.gameOptions, - ...savedConfig.setupOptions, - }, - playerOptions: savedConfig.playerOptions.length > 0 ? savedConfig.playerOptions : config.playerOptions, - }); +/** + * Deselect the saved config while keeping the current options as free-form + * custom setup state. Clearing the ref is not a reset — it only stops + * claiming that a file governs the next launch. + */ +export function clearStudioSetupSavedConfig(config: Civ7StudioSetupConfig): Civ7StudioSetupConfig { + return normalizeStudioSetupConfig({ ...config, savedConfig: undefined }); } /** - * Drift detection for the saved-config selector (config-precedence rule): a - * saved config GOVERNS the setup options it specifies plus the player - * options it carries; the game-setup dropdowns must never silently supersede - * it. The studio has drifted from the selected saved config exactly when - * RE-APPLYING the file would change the current state — i.e. a later - * dropdown change (or live sync) overwrote a governed value. Keys the file - * does not specify are deliberately ungoverned (apply is a merge that keeps - * them), so editing them never counts as drift, and re-apply ("sync back") - * is a no-op precisely when the selector is clean. + * Drift detection for the saved-config selector (config-precedence rule): + * the studio launches the selected saved config exactly when the authored + * setup state equals the file-derived state + * (`studioSetupConfigFromSavedConfigFile`). ANY difference — a dropdown + * edit, a live sync, or a stray key rehydrated from persistence — means the + * launch would not be the file, so the selector must show "Custom". + * Re-selecting the config (re-apply) is always the way back to clean. */ export function studioSetupDriftsFromSavedConfig( config: Civ7StudioSetupConfig, savedConfig: Civ7SavedSetupConfigFile, ): boolean { - return !studioSetupConfigsEqual(config, updateStudioSetupSavedConfig(config, savedConfig)); + return !studioSetupConfigsEqual(config, studioSetupConfigFromSavedConfigFile(savedConfig)); } export function labelForCiv7SetupValue(value: unknown): string { diff --git a/apps/mapgen-studio/src/ui/components/AppHeader.tsx b/apps/mapgen-studio/src/ui/components/AppHeader.tsx index a48b9a4f63..6828720cf0 100644 --- a/apps/mapgen-studio/src/ui/components/AppHeader.tsx +++ b/apps/mapgen-studio/src/ui/components/AppHeader.tsx @@ -11,6 +11,15 @@ import { type Civ7StudioSetupConfig } from '../../features/civ7Setup/setupConfig'; import type { ThemePreference } from '../types'; + +/** + * Sentinel selector value for the drifted state: the authored game setup no + * longer equals the selected saved config's file, so the launch would be a + * custom configuration. Not a selectable target — choosing a real config + * re-applies that file. + */ +const CUSTOM_SETUP_VALUE = '__custom-setup__'; + export interface AppHeaderProps { themePreference: ThemePreference; onThemeCycle: () => void; @@ -27,9 +36,11 @@ export interface AppHeaderProps { onSetupConfigChange: (config: Civ7StudioSetupConfig) => void; onSavedConfigChange: (configId: string) => void; /** - * Config precedence: true when the game-setup dropdowns (or a live sync) - * superseded values the selected saved config governs. Renders the orange - * Modified affordance on the selector; clicking it re-applies the file. + * Config precedence: true when the authored setup state no longer equals + * the selected saved config's file-derived state (any dropdown edit, live + * sync, or stray persisted key counts — what would launch is no longer the + * file). The selector then shows "Custom" in warning orange; the companion + * re-apply affordance restores the file exactly. */ savedConfigModified?: boolean; onHeaderHeightChange?: (height: number) => void; @@ -121,20 +132,30 @@ export const AppHeader: React.FC = ({ Config + {/* Config-precedence display: while the setup state matches the + selected file the selector names it; once it drifts the + selector itself says "Custom" (the launch is no longer the + file). Picking the config again re-applies the file exactly. */} onSavedConfigChange(value)} - options={setupOptions.savedConfigOptions} + value={savedConfigModified ? CUSTOM_SETUP_VALUE : setupConfig.savedConfig?.id ?? ""} + onValueChange={(value) => { + if (value !== CUSTOM_SETUP_VALUE) onSavedConfigChange(value); + }} + options={ + savedConfigModified + ? [{ value: CUSTOM_SETUP_VALUE, label: "Custom" }, ...setupOptions.savedConfigOptions] + : setupOptions.savedConfigOptions + } ariaLabel="Saved config" className={`w-44 max-w-[34vw] ${savedConfigModified ? 'border-warning text-warning ring-1 ring-warning/40' : ''}`} /> {savedConfigModified && setupConfig.savedConfig ? ( ) : null} {/* Game-setup disclosure: the gear rides the config cluster (the diff --git a/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts b/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts index 6c3570bac6..01ec8fb3e1 100644 --- a/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts +++ b/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { + clearStudioSetupSavedConfig, studioSetupConfigFromSavedConfigFile, studioSetupConfigFromLiveSnapshot, studioSetupDriftsFromSavedConfig, updateStudioSetupGameOption, - updateStudioSetupSavedConfig, updateStudioSetupPlayerOption, } from "../../src/features/civ7Setup/setupConfig"; @@ -123,20 +123,12 @@ describe("Civ7 Studio setup config", () => { }); }); - it("applies a saved config without dropping explicit neighboring setup choices", () => { - const updated = updateStudioSetupSavedConfig({ - gameOptions: { - AgeLength: "AGE_LENGTH_STANDARD", - }, - playerOptions: [ - { - playerId: 0, - options: { - PlayerLeader: "LEADER_HARRIET_TUBMAN", - }, - }, - ], - }, { + // Config-precedence pin (P7): selection applies the file EXACTLY. At launch + // the engine loads the saved config file first and re-applies every studio + // option on top, so any pre-existing studio key the file does not specify + // would silently override the file — selection must wipe it. + it("applying a saved config replaces prior studio options instead of merging over them", () => { + const applied = studioSetupConfigFromSavedConfigFile({ id: "tot-config", displayName: "ToT Config", fileName: "ToT Config.Civ7Cfg", @@ -158,15 +150,33 @@ describe("Civ7 Studio setup config", () => { ], }); - expect(updated.gameOptions).toEqual({ - AgeLength: "AGE_LENGTH_STANDARD", - Difficulty: "DIFFICULTY_CUSTOM", + // No stale keys survive: the launch payload is the file, nothing else. + expect(applied.gameOptions).toEqual({ Difficulty: "DIFFICULTY_CUSTOM" }); + expect(applied.playerOptions).toEqual([ + { playerId: 0, options: { PlayerLeader: "LEADER_ALEXANDER" } }, + ]); + }); + + it("deselecting keeps the current options as free-form custom state", () => { + const cleared = clearStudioSetupSavedConfig({ + savedConfig: { + id: "tot-config", + displayName: "ToT Config", + fileName: "ToT Config.Civ7Cfg", + path: "/tmp/ToT Config.Civ7Cfg", + }, + gameOptions: { Difficulty: "DIFFICULTY_CUSTOM" }, + playerOptions: [{ playerId: 0, options: { PlayerLeader: "LEADER_ALEXANDER" } }], }); - expect(updated.playerOptions[0]?.options.PlayerLeader).toBe("LEADER_ALEXANDER"); + + expect(cleared.savedConfig).toBeUndefined(); + expect(cleared.gameOptions).toEqual({ Difficulty: "DIFFICULTY_CUSTOM" }); + expect(cleared.playerOptions[0]?.options.PlayerLeader).toBe("LEADER_ALEXANDER"); }); - // Config-precedence pins (Y2): the saved-config selector shows Modified - // exactly when a governed value drifted; re-apply (sync back) clears it. + // Config-precedence pins (Y2, hardened in P7): the selector shows "Custom" + // whenever the authored state differs AT ALL from the file-derived state — + // re-selecting the config re-applies the file exactly and clears the drift. describe("saved-config drift detection", () => { const savedConfig = { id: "tot-config", @@ -191,10 +201,7 @@ describe("Civ7 Studio setup config", () => { }, ], }; - const applied = updateStudioSetupSavedConfig( - { gameOptions: { AgeLength: "AGE_LENGTH_STANDARD" }, playerOptions: [{ playerId: 0, options: {} }] }, - savedConfig, - ); + const applied = studioSetupConfigFromSavedConfigFile(savedConfig); it("is clean immediately after applying the saved config", () => { expect(studioSetupDriftsFromSavedConfig(applied, savedConfig)).toBe(false); @@ -210,14 +217,14 @@ describe("Civ7 Studio setup config", () => { expect(studioSetupDriftsFromSavedConfig(drifted, savedConfig)).toBe(true); }); - it("does NOT drift when an ungoverned option changes (the apply merge keeps it)", () => { + it("drifts when ANY option the file does not specify is added (it would override the file at launch)", () => { const edited = updateStudioSetupGameOption(applied, "AgeLength", "AGE_LENGTH_LONG"); - expect(studioSetupDriftsFromSavedConfig(edited, savedConfig)).toBe(false); + expect(studioSetupDriftsFromSavedConfig(edited, savedConfig)).toBe(true); }); it("re-applying the saved config clears the drift (sync back)", () => { const drifted = updateStudioSetupGameOption(applied, "GameSpeeds", "GAMESPEED_QUICK"); - const resynced = updateStudioSetupSavedConfig(drifted, savedConfig); + const resynced = studioSetupConfigFromSavedConfigFile(savedConfig); expect(studioSetupDriftsFromSavedConfig(resynced, savedConfig)).toBe(false); }); }); diff --git a/apps/mapgen-studio/test/ui/AppHeader.test.tsx b/apps/mapgen-studio/test/ui/AppHeader.test.tsx new file mode 100644 index 0000000000..8f776f659e --- /dev/null +++ b/apps/mapgen-studio/test/ui/AppHeader.test.tsx @@ -0,0 +1,68 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { AppHeader, type AppHeaderProps } from "../../src/ui/components/AppHeader"; +import { TooltipProvider } from "../../src/components/ui/tooltip"; + +// Config-precedence pins (P7): the saved-config selector claims the file only +// while the authored setup state equals the file-derived state. Once drifted, +// the selector itself goes "Custom" (warning emphasis) and the companion +// Re-apply affordance restores the file exactly. Static markup cannot open +// the Radix select, so these pins assert the drifted chrome + affordances. + +const SAVED_CONFIG_REF = { + id: "tot-config", + displayName: "ToT Config", + fileName: "ToT Config.Civ7Cfg", + path: "/tmp/ToT Config.Civ7Cfg", +}; + +function renderHeader(overrides: Partial = {}) { + return renderToStaticMarkup( + + + + ); +} + +describe("AppHeader saved-config precedence display", () => { + it("claims the saved config cleanly when the setup state matches the file", () => { + const html = renderHeader({ savedConfigModified: false }); + + expect(html).not.toContain("Re-apply"); + expect(html).not.toContain("ring-warning"); + }); + + it("shows Custom emphasis and the Re-apply affordance when the setup drifts from the file", () => { + const html = renderHeader({ savedConfigModified: true }); + + // The selector trigger carries the warning (Custom) emphasis... + expect(html).toContain("ring-warning"); + // ...and the re-apply affordance names the drift and the way back. + expect(html).toContain("Re-apply"); + expect(html).toContain("Game setup is Custom (drifted from ToT Config)"); + }); +});