Files
OpenFrontIO/tests/GraphicsPresets.test.ts
T
1360b27bbd feat(client): graphics presets — simplify settings behind an Advanced section (#4663)
## Summary

The graphics settings modal had grown too complex for average players
(~25 sliders/toggles/color pickers in one flat list). This reworks
graphics configuration around **presets**:

- **Shared `<graphics-preset-selector>` component**
(`src/client/components/`): a self-contained dropdown of built-in +
saved presets that applies a preset wholesale, shows a disabled "Custom"
entry while the current overrides match no preset, and offers delete for
saved presets. It reads/writes `UserSettings` directly and re-renders on
the settings-changed events, so both hosts stay in sync automatically.
- **In-game graphics modal**: preset dropdown up top, everything else
collapsed under an **Advanced settings** expander. Advanced also holds
the custom-preset tools: save-as-preset, copy settings JSON to
clipboard, and paste-JSON import (Zod-validated, inline error).
- **Main-menu settings modal**: a new "Graphics Preset" row using the
same component — replacing the colorblind toggle.
- **Built-in presets**: Default, Night, and Colorblind — defined as data
in `graphics-presets.json` (schema-parsed at load).

### Design notes

- **Theme palettes are an enum, not a boolean.**
`accessibility.colorblind` is replaced by `palette: "default" |
"colorblind"` — `PALETTE_NAMES` in `GraphicsOverrides.ts` is the single
source of truth (`ThemeName` derives from it; `ThemeProvider` builds one
theme per palette), so adding a future palette is one enum entry + one
theme JSON. Legacy stored booleans are translated to the enum at read
time.
- **The colorblind look is data.** The previously hardcoded Okabe-Ito
friend-foe colors are now generic override fields
(`affiliation.self/ally/enemyColor`,
`mapOverlay.friendly/embargoTintColor` + ratios) carried by the
Colorblind preset JSON; the palette selects only the theme. Selecting
Colorblind anywhere (menu or in-game) applies the full look. Side
benefit: any preset can customize friend/foe border colors.
- **Existing players are protected**: a one-time migration snapshots
pre-existing custom overrides into a "My settings" preset on first init,
so switching presets can never lose a hand-tuned config. Flag-only
colorblind users are upgraded to the full Colorblind preset so they keep
the blue/orange borders. Writing the presets key (even as `{}`) stamps
the migration done.
- Applying a preset reuses the existing `setGraphicsOverrides` →
change-event → live-regenerate path, so presets and imports take effect
immediately (including terrain-texture rebuilds for color changes).

## Test plan

- [x] Full suite: 176 files / 2100 tests pass; `tsc`, ESLint, Prettier
clean
- [x] `GraphicsPresets.test.ts` locks preset JSON to the renderer values
the old hardcoded colorblind block produced, schema-validates every
built-in preset, and covers legacy boolean → palette-enum translation
- [x] Driven headless in a real singleplayer game (WebGL): dropdown
present in both the main-menu settings and the in-game modal; selection
in one reflects in the other; presets apply + persist; "Custom" appears
after a manual slider tweak; save-as-preset selects the new entry;
delete via ✕ + confirm; copy JSON → clipboard; invalid-JSON inline
error; valid import applies live (ocean recolor visible in render)
- [x] Migration: seeded legacy overrides survive init untouched and
appear as an active "My settings" preset; legacy colorblind boolean
upgrades to the full preset; fresh profiles get no phantom preset;
migration is idempotent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 16:11:43 -07:00

178 lines
7.1 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
BUILTIN_PRESETS,
migrateLegacyGraphicsSettings,
parseGraphicsOverridesJson,
} from "../src/client/GraphicsPresets";
import builtinPresets from "../src/client/render/gl/graphics-presets.json";
import { GraphicsOverridesSchema } from "../src/client/render/gl/GraphicsOverrides";
import { applyGraphicsOverrides } from "../src/client/render/gl/RenderOverrides";
import {
createRenderSettings,
createThemeSettings,
} from "../src/client/render/gl/RenderSettings";
import {
GRAPHICS_KEY,
GRAPHICS_PRESETS_KEY,
UserSettings,
} from "../src/core/game/UserSettings";
describe("built-in graphics presets", () => {
it("every preset's overrides validate against the schema", () => {
for (const preset of builtinPresets) {
const parsed = GraphicsOverridesSchema.safeParse(preset.overrides);
expect(parsed.success, `${preset.nameKey}: ${parsed.error}`).toBe(true);
}
});
it("colorblind preset applies the Okabe-Ito friend-foe colors and theme", () => {
const colorblind = builtinPresets.find(
(p) => p.nameKey === "graphics_setting.preset_colorblind",
);
expect(colorblind).toBeDefined();
const settings = createRenderSettings();
applyGraphicsOverrides(
settings,
GraphicsOverridesSchema.parse(colorblind!.overrides),
);
// Alt-view affiliation borders: self/ally blue family, enemy orange.
expect(settings.affiliation.selfR).toBeCloseTo(0, 2);
expect(settings.affiliation.selfG).toBeCloseTo(0.447, 2);
expect(settings.affiliation.selfB).toBeCloseTo(0.698, 2);
expect(settings.affiliation.allyR).toBeCloseTo(0.337, 2);
expect(settings.affiliation.allyG).toBeCloseTo(0.706, 2);
expect(settings.affiliation.allyB).toBeCloseTo(0.914, 2);
expect(settings.affiliation.enemyR).toBeCloseTo(0.835, 2);
expect(settings.affiliation.enemyG).toBeCloseTo(0.369, 2);
expect(settings.affiliation.enemyB).toBeCloseTo(0, 2);
// Normal-view relationship border tints: friendly blue, enemy orange,
// applied strongly so the cue doesn't rely on subtle hue.
expect(settings.mapOverlay.friendlyTintR).toBeCloseTo(0, 2);
expect(settings.mapOverlay.friendlyTintG).toBeCloseTo(0.447, 2);
expect(settings.mapOverlay.friendlyTintB).toBeCloseTo(0.698, 2);
expect(settings.mapOverlay.embargoTintR).toBeCloseTo(0.835, 2);
expect(settings.mapOverlay.embargoTintG).toBeCloseTo(0.369, 2);
expect(settings.mapOverlay.embargoTintB).toBeCloseTo(0, 2);
expect(settings.mapOverlay.friendlyTintRatio).toBe(0.85);
expect(settings.mapOverlay.embargoTintRatio).toBe(0.85);
// The palette swap rides on the palette enum.
expect(settings.theme).toEqual(createThemeSettings("colorblind"));
});
});
describe("legacy colorblind flag", () => {
it("accessibility.colorblind stored by old clients surfaces as the colorblind palette", async () => {
const { UserSettings } = await import("../src/core/game/UserSettings");
const userSettings = new UserSettings();
// Old clients stored {accessibility:{colorblind:true}}; write it through
// the settings cache in the pre-palette shape.
userSettings.setGraphicsOverrides({
accessibility: { colorblind: true },
} as never);
expect(userSettings.graphicsOverrides().palette).toBe("colorblind");
// A save in the new shape sticks and stops the translation.
userSettings.setGraphicsOverrides({});
expect(userSettings.graphicsOverrides().palette).toBeUndefined();
});
});
describe("migrateLegacyGraphicsSettings", () => {
const userSettings = new UserSettings();
beforeEach(() => {
userSettings.removeCached(GRAPHICS_KEY);
userSettings.removeCached(GRAPHICS_PRESETS_KEY);
});
it("snapshots pre-existing custom overrides into a preset and leaves them active", () => {
userSettings.setGraphicsOverrides({ name: { nameScaleFactor: 2 } });
migrateLegacyGraphicsSettings(userSettings);
expect(userSettings.graphicsOverrides()).toEqual({
name: { nameScaleFactor: 2 },
});
expect(Object.values(userSettings.graphicsPresets())).toEqual([
{ name: { nameScaleFactor: 2 } },
]);
});
it("upgrades a palette-only legacy colorblind config to the full Colorblind preset", () => {
userSettings.setGraphicsOverrides({
accessibility: { colorblind: true },
} as never);
migrateLegacyGraphicsSettings(userSettings);
const colorblind = BUILTIN_PRESETS.find(
(p) => p.nameKey === "graphics_setting.preset_colorblind",
);
expect(userSettings.graphicsOverrides()).toEqual(colorblind!.overrides);
// Matches a built-in, so no phantom saved preset.
expect(userSettings.graphicsPresets()).toEqual({});
});
it("grafts the Colorblind borders onto legacy colorblind configs with other tweaks", () => {
userSettings.setGraphicsOverrides({
accessibility: { colorblind: true },
name: { nameScaleFactor: 2 },
} as never);
migrateLegacyGraphicsSettings(userSettings);
const overrides = userSettings.graphicsOverrides();
expect(overrides.name).toEqual({ nameScaleFactor: 2 });
expect(overrides.palette).toBe("colorblind");
// The Okabe-Ito borders the old colorblind boolean hardcoded.
expect(overrides.affiliation).toEqual({
selfColor: "#0072b2",
allyColor: "#56b4e9",
enemyColor: "#d55e00",
});
expect(overrides.mapOverlay).toEqual({
friendlyTintColor: "#0072b2",
embargoTintColor: "#d55e00",
friendlyTintRatio: 0.85,
embargoTintRatio: 0.85,
});
expect(Object.values(userSettings.graphicsPresets())).toEqual([overrides]);
});
it("stamps fresh profiles with no phantom preset and never runs twice", () => {
migrateLegacyGraphicsSettings(userSettings);
expect(userSettings.graphicsPresets()).toEqual({});
// Custom tweaks made after the stamp are not re-snapshotted.
userSettings.setGraphicsOverrides({ name: { nameScaleFactor: 2 } });
migrateLegacyGraphicsSettings(userSettings);
expect(userSettings.graphicsPresets()).toEqual({});
});
});
describe("parseGraphicsOverridesJson", () => {
it("accepts valid overrides JSON", () => {
expect(
parseGraphicsOverridesJson(
'{"palette":"colorblind","lighting":{"ambient":0.36}}',
),
).toEqual({ palette: "colorblind", lighting: { ambient: 0.36 } });
});
it("rejects invalid JSON and non-objects", () => {
expect(parseGraphicsOverridesJson("not json")).toBeNull();
expect(parseGraphicsOverridesJson("5")).toBeNull();
expect(parseGraphicsOverridesJson("null")).toBeNull();
expect(parseGraphicsOverridesJson("[]")).toBeNull();
});
it("rejects unknown keys instead of stripping them to an empty config", () => {
// The schema strips unknown keys when reading stored data; a paste that
// survives only by stripping must not silently wipe the user's settings.
expect(parseGraphicsOverridesJson('{"nmae":{"nameScaleFactor":2}}')).toBe(
null,
);
expect(parseGraphicsOverridesJson('{"name":{"nameScale":2}}')).toBe(null);
expect(
parseGraphicsOverridesJson('{"accessibility":{"colorblind":true}}'),
).toBe(null);
});
});