Files
OpenFrontIO/tests/Colors.test.ts
T
noahschmal 21776e81af Feature/colorblind mode (#4150)
**Add approved & assigned issue number here:**

Resolves #2549

## Description:

Adds colorblind mode. Similar to dark mode, it exists as a toggle in
settings. When enabled, it swaps the game's theme (which is refactored
to extend from a theme base class) to use more colorblind-friendly
colors and brightness variations. Borders are darkened, and terrarin is
separated by lightness. Friendly/Foe colors and switched to blue/orange
instead of red/green.

The theme refactor supports adding new themes without having to
reimplement the color distribution system. New themes can extend the
BaseTheme and supply the data, such as palettes, team-color variations,
and terrain.

New setting:
<img width="880" height="273" alt="Screenshot 2026-06-04 at 11 30 27 AM"
src="https://github.com/user-attachments/assets/d5d573d5-cc64-4ac1-95c2-00627faf17cc"
/>

New color palette:
<img width="1119" height="757" alt="Screenshot 2026-06-04 at 11 30
59 AM"
src="https://github.com/user-attachments/assets/2bb15bc9-992b-41ae-ab0e-b01fe0c3c6bb"
/>

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

jetaviz
2026-06-11 10:53:03 -07:00

157 lines
4.9 KiB
TypeScript

import { colord, Colord } from "colord";
import {
ColorAllocator,
selectDistinctColorIndex,
} from "../src/client/theme/ColorAllocator";
import { ColorblindTheme } from "../src/client/theme/ColorblindTheme";
import {
blue,
botColor,
green,
orange,
purple,
red,
teal,
yellow,
} from "../src/client/theme/Colors";
import { PastelTheme } from "../src/client/theme/PastelTheme";
import { ColoredTeams } from "../src/core/game/Game";
const mockColors: Colord[] = [
colord({ r: 255, g: 0, b: 0 }),
colord({ r: 0, g: 255, b: 0 }),
colord({ r: 0, g: 0, b: 255 }),
];
const fallbackMockColors: Colord[] = [
colord({ r: 0, g: 0, b: 0 }),
colord({ r: 255, g: 255, b: 255 }),
];
const fallbackColors = [...fallbackMockColors, ...mockColors];
describe("ColorAllocator", () => {
let allocator: ColorAllocator;
beforeEach(() => {
allocator = new ColorAllocator(mockColors, fallbackMockColors);
});
test("returns a unique color for each new ID", () => {
const c1 = allocator.assignColor("a");
const c2 = allocator.assignColor("b");
const c3 = allocator.assignColor("c");
expect(c1.isEqual(c2)).toBe(false);
expect(c1.isEqual(c3)).toBe(false);
expect(c2.isEqual(c3)).toBe(false);
});
test("returns the same color for the same ID", () => {
const c1 = allocator.assignColor("a");
const c2 = allocator.assignColor("a");
expect(c1.isEqual(c2)).toBe(true);
});
test("falls back when colors are exhausted", () => {
allocator.assignColor("1");
allocator.assignColor("2");
allocator.assignColor("3");
const fallback = allocator.assignColor("4");
const fallback2 = allocator.assignColor("5");
const match = fallbackColors.some((color) => color.isEqual(fallback));
expect(match).toBe(true);
const match2 = fallback.isEqual(fallback2);
expect(match2).toBe(false);
});
test("assignBotColor returns deterministic color from botColors", () => {
const allocator = new ColorAllocator(mockColors, mockColors);
const id1 = "bot123";
const id2 = "bot456";
const c1 = allocator.assignColor(id1);
const c2 = allocator.assignColor(id2);
const c1Again = allocator.assignColor(id1);
const c2Again = allocator.assignColor(id2);
expect(c1.isEqual(c1Again)).toBe(true);
expect(c2.isEqual(c2Again)).toBe(true);
});
});
describe("PastelTheme team colors", () => {
test("teamColor returns the base color from the team", () => {
const theme = new PastelTheme();
expect(theme.teamColor(ColoredTeams.Blue)).toEqual(blue);
expect(theme.teamColor(ColoredTeams.Red)).toEqual(red);
expect(theme.teamColor(ColoredTeams.Teal)).toEqual(teal);
expect(theme.teamColor(ColoredTeams.Purple)).toEqual(purple);
expect(theme.teamColor(ColoredTeams.Yellow)).toEqual(yellow);
expect(theme.teamColor(ColoredTeams.Orange)).toEqual(orange);
expect(theme.teamColor(ColoredTeams.Green)).toEqual(green);
expect(theme.teamColor(ColoredTeams.Bot)).toEqual(botColor);
expect(theme.teamColor(ColoredTeams.Humans)).toEqual(blue);
expect(theme.teamColor(ColoredTeams.Nations)).toEqual(red);
});
test("teamColorForPlayer is stable for the same playerID", () => {
const theme = new PastelTheme();
const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player123");
const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player123");
expect(a.isEqual(b)).toBe(true);
});
test("teamColorForPlayer differs for different playerIDs", () => {
const theme = new PastelTheme();
const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player1");
const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player2");
expect(a.isEqual(b)).toBe(false);
});
});
describe("ColorblindTheme", () => {
test("applies a palette distinct from PastelTheme", () => {
const pastel = new PastelTheme();
const colorblind = new ColorblindTheme();
// At least one team's base color should differ — the colorblind theme
// swaps the team palettes for CVD-safe (Okabe-Ito) colors.
const teams = [
ColoredTeams.Blue,
ColoredTeams.Red,
ColoredTeams.Teal,
ColoredTeams.Purple,
ColoredTeams.Yellow,
ColoredTeams.Orange,
ColoredTeams.Green,
];
const anyDifferent = teams.some(
(team) => !pastel.teamColor(team).isEqual(colorblind.teamColor(team)),
);
expect(anyDifferent).toBe(true);
});
});
describe("selectDistinctColor", () => {
test("returns the most distant color", () => {
const assignedColors = [colord({ r: 255, g: 0, b: 0 })]; // bright red
const availableColors = [
colord({ r: 254, g: 1, b: 1 }), // too close
colord({ r: 0, g: 255, b: 0 }), // distinct green
colord({ r: 0, g: 0, b: 255 }), // distinct blue
];
const result = selectDistinctColorIndex(availableColors, assignedColors);
const rgb = availableColors[result].toRgb();
expect([
{ r: 0, g: 255, b: 0, a: 1 },
{ r: 0, g: 0, b: 255, a: 1 },
]).toContainEqual(rgb);
});
});