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
17 changes: 9 additions & 8 deletions apps/mapgen-studio/src/app/StudioShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
52 changes: 28 additions & 24 deletions apps/mapgen-studio/src/features/civ7Setup/setupConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
39 changes: 30 additions & 9 deletions apps/mapgen-studio/src/ui/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -121,20 +132,30 @@ export const AppHeader: React.FC<AppHeaderProps> = ({
<span className={`text-label font-medium uppercase tracking-wider shrink-0 ${textMuted}`}>
Config
</span>
{/* 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. */}
<OptionSelect
value={setupConfig.savedConfig?.id ?? ""}
onValueChange={(value) => 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 ? (
<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`}
aria-label={`Game setup is Custom (drifted from ${setupConfig.savedConfig.displayName}) — click to re-apply the saved config`}
title={`Game setup is Custom (drifted 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
Re-apply
</button>
) : null}
{/* Game-setup disclosure: the gear rides the config cluster (the
Expand Down
63 changes: 35 additions & 28 deletions apps/mapgen-studio/test/civ7Setup/setupConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";

import {
clearStudioSetupSavedConfig,
studioSetupConfigFromSavedConfigFile,
studioSetupConfigFromLiveSnapshot,
studioSetupDriftsFromSavedConfig,
updateStudioSetupGameOption,
updateStudioSetupSavedConfig,
updateStudioSetupPlayerOption,
} from "../../src/features/civ7Setup/setupConfig";

Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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);
Expand All @@ -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);
});
});
Expand Down
68 changes: 68 additions & 0 deletions apps/mapgen-studio/test/ui/AppHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -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<AppHeaderProps> = {}) {
return renderToStaticMarkup(
<TooltipProvider>
<AppHeader
themePreference="dark"
onThemeCycle={vi.fn()}
showGrid={false}
onShowGridChange={vi.fn()}
setupConfig={{
savedConfig: SAVED_CONFIG_REF,
gameOptions: { Difficulty: "DIFFICULTY_CUSTOM" },
playerOptions: [{ playerId: 0, options: {} }],
}}
setupOptions={{
savedConfigOptions: [
{ value: "", label: "No saved config" },
{ value: "tot-config", label: "ToT Config" },
],
leaderOptions: [{ value: "", label: "Leader" }],
civilizationOptions: [{ value: "", label: "Civilization" }],
difficultyOptions: [{ value: "", label: "Difficulty" }],
gameSpeedOptions: [{ value: "", label: "Speed" }],
}}
onSetupConfigChange={vi.fn()}
onSavedConfigChange={vi.fn()}
{...overrides}
/>
</TooltipProvider>
);
}

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)");
});
});