Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/mapgen-studio/src/app/StudioShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
normalizeStudioSetupConfig,
optionRowsFromParameter,
studioSetupConfigFromLiveSnapshot,
studioSetupDriftsFromSavedConfig,
updateStudioSetupSavedConfig,
type Civ7SetupSnapshotLike,
type Civ7StudioSetupConfig,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2240,6 +2254,7 @@ export function StudioShell(props: StudioShellProps) {
onShowGridChange={setShowGrid}
setupConfig={setupConfig}
setupOptions={setupControlOptions}
savedConfigModified={savedSetupConfigModified}
onSetupConfigChange={setSetupConfig}
onSavedConfigChange={handleSavedSetupConfigChange}
onHeaderHeightChange={handleHeaderHeightChange}
Expand Down
18 changes: 18 additions & 0 deletions apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion apps/mapgen-studio/src/ui/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -40,6 +46,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({
onShowGridChange,
setupConfig,
setupOptions,
savedConfigModified = false,
onSetupConfigChange,
onSavedConfigChange,
onHeaderHeightChange,
Expand Down Expand Up @@ -120,7 +127,17 @@ export const AppHeader: React.FC<AppHeaderProps> = ({
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 ? (
<button
type="button"
onClick={() => onSavedConfigChange(setupConfig.savedConfig!.id)}
aria-label={`Game setup modified from ${setupConfig.savedConfig.displayName} — click to re-apply the saved config`}
title={`Game setup modified from ${setupConfig.savedConfig.displayName} — click to re-apply the saved config`}
className="shrink-0 rounded border border-warning/40 px-1.5 py-0.5 text-label text-warning cursor-pointer transition-colors hover:bg-warning/10">
Modified
</button>
) : null}
</div>

{gameConsole ? (
Expand Down
59 changes: 59 additions & 0 deletions apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
import {
studioSetupConfigFromSavedConfigFile,
studioSetupConfigFromLiveSnapshot,
studioSetupDriftsFromSavedConfig,
updateStudioSetupGameOption,
updateStudioSetupSavedConfig,
updateStudioSetupPlayerOption,
} from "../../src/features/civ7Setup/setupConfig";
Expand Down Expand Up @@ -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);
});
});
});