diff --git a/apps/mapgen-studio/src/app/StudioShell.tsx b/apps/mapgen-studio/src/app/StudioShell.tsx index 35f0eac98a..42dcfc2b2d 100644 --- a/apps/mapgen-studio/src/app/StudioShell.tsx +++ b/apps/mapgen-studio/src/app/StudioShell.tsx @@ -44,6 +44,7 @@ import { normalizeStudioSetupConfig, optionRowsFromParameter, studioSetupConfigFromLiveSnapshot, + studioSetupDriftsFromSavedConfig, updateStudioSetupSavedConfig, type Civ7SetupSnapshotLike, type Civ7StudioSetupConfig, @@ -1398,6 +1399,19 @@ 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). + const savedSetupConfigModified = useMemo(() => { + const selectedId = setupConfig.savedConfig?.id; + if (!selectedId) return false; + const savedConfig = savedSetupConfigs.configurations.find((config) => config.id === selectedId); + if (!savedConfig) return false; + return studioSetupDriftsFromSavedConfig(setupConfig, savedConfig); + }, [savedSetupConfigs.configurations, setupConfig]); + const handleSavedSetupConfigChange = useCallback((configId: string) => { const savedConfig = savedSetupConfigs.configurations.find((config) => config.id === configId); if (!savedConfig) { @@ -2240,6 +2254,7 @@ export function StudioShell(props: StudioShellProps) { onShowGridChange={setShowGrid} setupConfig={setupConfig} setupOptions={setupControlOptions} + savedConfigModified={savedSetupConfigModified} onSetupConfigChange={setSetupConfig} onSavedConfigChange={handleSavedSetupConfigChange} onHeaderHeightChange={handleHeaderHeightChange} diff --git a/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts b/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts index 2bb333a812..fe49551017 100644 --- a/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts +++ b/apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts @@ -308,6 +308,24 @@ export function updateStudioSetupSavedConfig( }); } +/** + * 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. + */ +export function studioSetupDriftsFromSavedConfig( + config: Civ7StudioSetupConfig, + savedConfig: Civ7SavedSetupConfigFile, +): boolean { + return !studioSetupConfigsEqual(config, updateStudioSetupSavedConfig(config, savedConfig)); +} + export function labelForCiv7SetupValue(value: unknown): string { if (typeof value !== "string") return String(value ?? ""); const stripped = value diff --git a/apps/mapgen-studio/src/ui/components/AppHeader.tsx b/apps/mapgen-studio/src/ui/components/AppHeader.tsx index ec2a9fc522..9025ccdc2e 100644 --- a/apps/mapgen-studio/src/ui/components/AppHeader.tsx +++ b/apps/mapgen-studio/src/ui/components/AppHeader.tsx @@ -25,6 +25,12 @@ 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. + */ + savedConfigModified?: boolean; onHeaderHeightChange?: (height: number) => void; /** * The live-game command cluster (Pass-5 zoning v2: top = Game, bottom = @@ -40,6 +46,7 @@ export const AppHeader: React.FC = ({ onShowGridChange, setupConfig, setupOptions, + savedConfigModified = false, onSetupConfigChange, onSavedConfigChange, onHeaderHeightChange, @@ -120,7 +127,17 @@ export const AppHeader: React.FC = ({ onValueChange={(value) => onSavedConfigChange(value)} options={setupOptions.savedConfigOptions} ariaLabel="Saved config" - className="w-44 max-w-[34vw]" /> + className={`w-44 max-w-[34vw] ${savedConfigModified ? 'border-warning text-warning ring-1 ring-warning/40' : ''}`} /> + {savedConfigModified && setupConfig.savedConfig ? ( + + ) : null} {gameConsole ? ( diff --git a/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts b/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts index 7d4868f288..6c3570bac6 100644 --- a/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts +++ b/apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { studioSetupConfigFromSavedConfigFile, studioSetupConfigFromLiveSnapshot, + studioSetupDriftsFromSavedConfig, + updateStudioSetupGameOption, updateStudioSetupSavedConfig, updateStudioSetupPlayerOption, } from "../../src/features/civ7Setup/setupConfig"; @@ -162,4 +164,61 @@ describe("Civ7 Studio setup config", () => { }); expect(updated.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. + describe("saved-config drift detection", () => { + const savedConfig = { + id: "tot-config", + displayName: "ToT Config", + fileName: "ToT Config.Civ7Cfg", + path: "/tmp/ToT Config.Civ7Cfg", + sizeBytes: 128, + modifiedAt: "2026-06-01T00:00:00.000Z", + source: "local-disk" as const, + summary: {}, + setupOptions: { + Difficulty: "DIFFICULTY_CUSTOM", + GameSpeeds: "GAMESPEED_STANDARD", + }, + playerOptions: [ + { + playerId: 0, + options: { + PlayerLeader: "LEADER_ALEXANDER", + PlayerCivilization: "CIVILIZATION_GREECE", + }, + }, + ], + }; + const applied = updateStudioSetupSavedConfig( + { gameOptions: { AgeLength: "AGE_LENGTH_STANDARD" }, playerOptions: [{ playerId: 0, options: {} }] }, + savedConfig, + ); + + it("is clean immediately after applying the saved config", () => { + expect(studioSetupDriftsFromSavedConfig(applied, savedConfig)).toBe(false); + }); + + it("drifts when a game-setup dropdown supersedes a governed option", () => { + const drifted = updateStudioSetupGameOption(applied, "GameSpeeds", "GAMESPEED_QUICK"); + expect(studioSetupDriftsFromSavedConfig(drifted, savedConfig)).toBe(true); + }); + + it("drifts when a player option supersedes the saved leader", () => { + const drifted = updateStudioSetupPlayerOption(applied, "PlayerLeader", "LEADER_ASHOKA"); + expect(studioSetupDriftsFromSavedConfig(drifted, savedConfig)).toBe(true); + }); + + it("does NOT drift when an ungoverned option changes (the apply merge keeps it)", () => { + const edited = updateStudioSetupGameOption(applied, "AgeLength", "AGE_LENGTH_LONG"); + expect(studioSetupDriftsFromSavedConfig(edited, savedConfig)).toBe(false); + }); + + it("re-applying the saved config clears the drift (sync back)", () => { + const drifted = updateStudioSetupGameOption(applied, "GameSpeeds", "GAMESPEED_QUICK"); + const resynced = updateStudioSetupSavedConfig(drifted, savedConfig); + expect(studioSetupDriftsFromSavedConfig(resynced, savedConfig)).toBe(false); + }); + }); });