From eef0f550ab530a62b8141c12acc6066a4dbb9fca Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:34:01 -0400 Subject: [PATCH 01/10] Add colour vision deficiency simulation for player colours --- src/client/theme/ColorVision.ts | 89 +++++++++++++++++++++++++++++++++ tests/Colors.test.ts | 55 ++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/client/theme/ColorVision.ts diff --git a/src/client/theme/ColorVision.ts b/src/client/theme/ColorVision.ts new file mode 100644 index 0000000000..f4c098bf82 --- /dev/null +++ b/src/client/theme/ColorVision.ts @@ -0,0 +1,89 @@ +import { Colord, colord } from "colord"; + +/** + * A vision model that colour distinctness is evaluated against. "normal" is + * unimpaired vision; the others are dichromatic colour vision deficiencies. + */ +export type Observer = "normal" | "protan" | "deutan" | "tritan"; + +const OBSERVER_NAMES: readonly string[] = [ + "normal", + "protan", + "deutan", + "tritan", +]; + +/** + * Machado, Oliveira & Fernandes (2009), "A Physiologically-based Model for + * Simulation of Color Vision Deficiency", severity 1.0. Row-major 3x3, applied + * to linear-light RGB — not to gamma-encoded sRGB. + */ +const CVD_MATRICES: Record, readonly number[]> = { + protan: [ + 0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, + -0.048116, 1.051998, + ], + deutan: [ + 0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, + 0.04294, 0.968881, + ], + tritan: [ + 1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, + 0.691367, 0.3039, + ], +}; + +/** sRGB channel (0–255) to linear light (0–1). */ +function toLinear(channel: number): number { + const v = channel / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; +} + +/** Linear light (0–1) back to an sRGB channel (0–255), clamped to gamut. */ +function toSrgb(linear: number): number { + const v = + linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055; + return Math.round(Math.min(255, Math.max(0, v * 255))); +} + +/** + * Narrow observer names read from a theme JSON. Throws on an unknown name so a + * typo in theme data fails loudly at startup rather than silently disabling an + * accessibility check. + */ +export function parseObservers(values: readonly string[]): Observer[] { + if (values.length === 0) { + throw new Error("Theme settings must list at least one observer"); + } + return values.map((value) => { + if (!OBSERVER_NAMES.includes(value)) { + throw new Error(`Unknown observer "${value}" in theme settings`); + } + return value as Observer; + }); +} + +/** `color` as seen by `observer`. Normal vision returns it unchanged. */ +export function simulate(color: Colord, observer: Observer): Colord { + if (observer === "normal") { + return color; + } + const m = CVD_MATRICES[observer]; + const { r, g, b } = color.toRgb(); + const lr = toLinear(r); + const lg = toLinear(g); + const lb = toLinear(b); + return colord({ + r: toSrgb(m[0] * lr + m[1] * lg + m[2] * lb), + g: toSrgb(m[3] * lr + m[4] * lg + m[5] * lb), + b: toSrgb(m[6] * lr + m[7] * lg + m[8] * lb), + }); +} + +/** `color` as seen by each observer, in the order given. */ +export function observerViews( + color: Colord, + observers: readonly Observer[], +): Colord[] { + return observers.map((observer) => simulate(color, observer)); +} diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 9e3ed8c12b..55f684a6d8 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -5,6 +5,11 @@ import { ColorAllocator, selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; +import { + observerViews, + parseObservers, + simulate, +} from "../src/client/theme/ColorVision"; import { SettingsTheme } from "../src/client/theme/ThemeProvider"; import { ColoredTeams } from "../src/core/game/Game"; @@ -156,3 +161,53 @@ describe("selectDistinctColor", () => { ]).toContainEqual(rgb); }); }); + +describe("ColorVision", () => { + test("normal vision returns the colour unchanged", () => { + expect(simulate(colord("#a3e635"), "normal").toHex()).toBe("#a3e635"); + }); + + test("simulates dichromacy against published reference values", () => { + // Machado et al. (2009) severity-1.0 matrices applied to linear-light sRGB. + expect(simulate(colord("#ff0000"), "protan").toHex()).toBe("#6d5f00"); + expect(simulate(colord("#ff0000"), "deutan").toHex()).toBe("#a39000"); + expect(simulate(colord("#0000ff"), "tritan").toHex()).toBe("#006b96"); + }); + + test("achromatic colours are unaffected by any deficiency", () => { + for (const observer of ["protan", "deutan", "tritan"] as const) { + expect(simulate(colord("#ffffff"), observer).toHex()).toBe("#ffffff"); + expect(simulate(colord("#000000"), observer).toHex()).toBe("#000000"); + } + }); + + test("collapses a pair the default palette treats as distinct", () => { + // #a3e635 and #fbbf24 are both in default-theme.json humanColors and are + // clearly different to normal vision, but converge under deuteranopia. + const a = colord("#a3e635"); + const b = colord("#fbbf24"); + expect(a.delta(b) * 100).toBeGreaterThan(20); + expect( + simulate(a, "deutan").delta(simulate(b, "deutan")) * 100, + ).toBeLessThan(5); + }); + + test("parseObservers narrows valid names", () => { + expect(parseObservers(["normal", "deutan"])).toEqual(["normal", "deutan"]); + }); + + test("parseObservers rejects an unknown name", () => { + expect(() => parseObservers(["normal", "deutran"])).toThrow(/deutran/); + }); + + test("parseObservers rejects an empty list", () => { + expect(() => parseObservers([])).toThrow(); + }); + + test("observerViews returns one view per observer, in order", () => { + const views = observerViews(colord("#ff0000"), ["normal", "deutan"]); + expect(views).toHaveLength(2); + expect(views[0].toHex()).toBe("#ff0000"); + expect(views[1].toHex()).toBe("#a39000"); + }); +}); From 8c99d249cbd8e88444dde60df8016eda2192ed1f Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:36:22 -0400 Subject: [PATCH 02/10] Add LCH candidate generator for exhausted colour palettes --- src/client/theme/ColorGenerator.ts | 52 ++++++++++++++++++++++++++++++ tests/Colors.test.ts | 27 ++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/client/theme/ColorGenerator.ts diff --git a/src/client/theme/ColorGenerator.ts b/src/client/theme/ColorGenerator.ts new file mode 100644 index 0000000000..be07495652 --- /dev/null +++ b/src/client/theme/ColorGenerator.ts @@ -0,0 +1,52 @@ +import { Colord, colord, extend } from "colord"; +import lchPlugin from "colord/plugins/lch"; + +extend([lchPlugin]); + +/** + * Bounds of the LCH sweep used when the curated palettes cannot supply a colour + * that clears a theme's distinctness floor. + * + * Lightness stops short of both ends: near-black and near-white territory fills + * read poorly against terrain and against the border colours derived from them. + * Chroma stays above 25 so candidates don't collapse into washed-out greys. + */ +const LIGHTNESS_MIN = 35; +const LIGHTNESS_MAX = 80; +const LIGHTNESS_STEP = 5; +const CHROMA_MIN = 25; +const CHROMA_MAX = 110; +const CHROMA_STEP = 8.5; +const HUE_STEP = 6; + +/** + * Candidate colours swept from LCH space, deduplicated by hex. + * + * Deduplication is load-bearing: LCH coordinates outside the sRGB gamut clamp + * on conversion and collapse onto the gamut surface, so a raw sweep contains + * repeats. Clamped colours are still valid, highly saturated candidates — they + * are kept, just not duplicated. Clamping also lifts a few results past the + * nominal lightness ceiling, to roughly 87. + * + * The constants above yield 6125 candidates from 6600 sweep points. + * + * Deterministic: no RNG, and the iteration order is fixed. + */ +export function generateCandidateColors(): Colord[] { + const seen = new Set(); + const candidates: Colord[] = []; + for (let l = LIGHTNESS_MIN; l <= LIGHTNESS_MAX; l += LIGHTNESS_STEP) { + for (let c = CHROMA_MIN; c <= CHROMA_MAX; c += CHROMA_STEP) { + for (let h = 0; h < 360; h += HUE_STEP) { + const color = colord({ l, c, h }); + const hex = color.toHex(); + if (seen.has(hex)) { + continue; + } + seen.add(hex); + candidates.push(color); + } + } + } + return candidates; +} diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 55f684a6d8..fc792298ba 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -5,6 +5,7 @@ import { ColorAllocator, selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; +import { generateCandidateColors } from "../src/client/theme/ColorGenerator"; import { observerViews, parseObservers, @@ -211,3 +212,29 @@ describe("ColorVision", () => { expect(views[1].toHex()).toBe("#a39000"); }); }); + +describe("ColorGenerator", () => { + test("produces a substantial candidate set", () => { + expect(generateCandidateColors().length).toBeGreaterThan(500); + }); + + test("contains no duplicate colours", () => { + const colors = generateCandidateColors(); + const hexes = new Set(colors.map((c) => c.toHex())); + expect(hexes.size).toBe(colors.length); + }); + + test("is deterministic across calls", () => { + const a = generateCandidateColors().map((c) => c.toHex()); + const b = generateCandidateColors().map((c) => c.toHex()); + expect(a).toEqual(b); + }); + + test("avoids near-black and near-white fills", () => { + for (const color of generateCandidateColors()) { + const lightness = color.toLch().l; + expect(lightness).toBeGreaterThan(20); + expect(lightness).toBeLessThan(95); + } + }); +}); From 4985e65f9f0ebfff4ef7756a396424636e3e4a01 Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:40:15 -0400 Subject: [PATCH 03/10] Score player colours against colour vision deficient observers --- src/client/theme/ColorAllocator.ts | 256 +++++++++++++++++++++++------ tests/Colors.test.ts | 89 ++++++++++ 2 files changed, 296 insertions(+), 49 deletions(-) diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index c55301a233..86b17fb3da 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -3,64 +3,227 @@ import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import { PseudoRandom } from "../../core/PseudoRandom"; import { simpleHash } from "../../core/Util"; +import { generateCandidateColors } from "./ColorGenerator"; +import { Observer, observerViews } from "./ColorVision"; + extend([lchPlugin]); extend([labPlugin]); +/** What to do once every candidate colour has been handed out. */ +export type ExhaustionPolicy = "generate" | "recycle"; + +export interface ColorAllocatorOptions { + /** + * Vision models a colour must stay distinct under. A candidate is scored by + * its *worst* separation across all of them. Defaults to normal vision only, + * which reproduces the previous behaviour. + */ + observers?: Observer[]; + /** + * Minimum ΔE2000 (0–100) a curated colour must reach before the allocator + * stops trusting the curated palettes and synthesises a colour instead. + * 0 keeps the curated palettes in use until they are exhausted. + */ + distinctnessFloor?: number; + /** Behaviour once every candidate is used. Defaults to "generate". */ + onExhausted?: ExhaustionPolicy; +} + +/** A candidate colour, its appearance per observer, and its cached score. */ +interface Candidate { + color: Colord; + views: Colord[]; + /** + * Smallest distance to any already-assigned colour, across all observers. + * Maintained incrementally so allocation costs O(candidates) rather than + * O(candidates * assigned). + */ + nearest: number; + used: boolean; +} + /** - * Assigns a stable, visually distinct color to each id from a pool, falling - * back to a larger list once the pool is exhausted. Theme-agnostic: it knows - * nothing about teams or palettes — a theme supplies the pool and owns any - * team-color logic. + * Assigns a stable, visually distinct colour to each id. + * + * Candidates are drawn from the theme's primary palette first, then its + * fallback palette, and finally — only if neither can supply a colour that + * clears `distinctnessFloor` — from colours generated on the fly. A colour is + * never handed out twice unless `onExhausted` is "recycle". + * + * Theme-agnostic: it knows nothing about teams or palettes. A theme supplies + * the pools and owns any team-colour logic. */ export class ColorAllocator { - private availableColors: Colord[]; - private fallbackColors: Colord[]; + private readonly observers: Observer[]; + private readonly distinctnessFloor: number; + private readonly onExhausted: ExhaustionPolicy; + /** Curated tiers in preference order: primary palette, then fallback. */ + private readonly curated: Candidate[][]; + private generated: Candidate[] | null = null; private assigned = new Map(); - constructor(colors: Colord[], fallback: Colord[]) { - this.availableColors = [...colors]; - this.fallbackColors = [...colors, ...fallback]; + constructor( + colors: Colord[], + fallback: Colord[], + options: ColorAllocatorOptions = {}, + ) { + this.observers = options.observers ?? ["normal"]; + this.distinctnessFloor = options.distinctnessFloor ?? 0; + this.onExhausted = options.onExhausted ?? "generate"; + this.curated = [this.toCandidates(colors), this.toCandidates(fallback)]; } /** - * Return the color assigned to `id`, allocating one on first request. New - * colors are chosen to be as visually distinct as possible from those already - * handed out (falling back to random selection once the pool is large or - * exhausted, for performance). Assignments are stable for the allocator's - * lifetime. + * Return the colour assigned to `id`, allocating one on first request. + * Assignments are stable for the allocator's lifetime. */ assignColor(id: string): Colord { - if (this.assigned.has(id)) { - return this.assigned.get(id)!; + const existing = this.assigned.get(id); + if (existing !== undefined) { + return existing; } + const candidate = this.select(id); + candidate.used = true; + this.assigned.set(id, candidate.color); + this.updateNearest(candidate); + return candidate.color; + } + + private toCandidates(colors: Colord[]): Candidate[] { + return colors.map((color) => ({ + color, + views: observerViews(color, this.observers), + nearest: Infinity, + used: false, + })); + } + + private select(id: string): Candidate { + if (this.assigned.size === 0) { + return this.seed(id); + } + + // Prefer curated colours, in tier order, whenever one is good enough. + for (const tier of this.curated) { + const best = bestUnused(tier); + if (best !== null && best.nearest >= this.distinctnessFloor) { + return best; + } + } + + const curatedBest = + bestUnused(this.curated[0]) ?? bestUnused(this.curated[1]); - if (this.availableColors.length === 0) { - this.availableColors = [...this.fallbackColors]; + if (this.onExhausted === "recycle") { + if (curatedBest !== null) { + return curatedBest; + } + // Every curated colour is spoken for. Reopen the primary palette and + // continue: hundreds of bots sharing a small palette is intended. + for (const candidate of this.curated[0]) { + candidate.used = false; + } + return bestUnused(this.curated[0])!; } - let selectedIndex: number; - - if (this.assigned.size === 0 || this.assigned.size > 50) { - // Randomly pick the first color if no colors have been assigned yet. - // - // Or if more than 50 colors assigned just pick a random one for perf reasons, - // as selecting a distinct color is O(n^2), and the color palette is mostly exhausted anyways. - const rand = new PseudoRandom(simpleHash(id)); - selectedIndex = rand.nextInt(0, this.availableColors.length); - } else { - const assignedColors = Array.from(this.assigned.values()); - selectedIndex = selectDistinctColorIndex( - this.availableColors, - assignedColors, - ); + const generatedBest = bestUnused(this.materialiseGenerated()); + if (curatedBest === null) { + return generatedBest!; + } + if (generatedBest === null) { + return curatedBest; } + return generatedBest.nearest >= curatedBest.nearest + ? generatedBest + : curatedBest; + } - const color = this.availableColors.splice(selectedIndex, 1)[0]; - this.assigned.set(id, color); - return color; + /** + * First colour of a game: chosen pseudo-randomly from the primary palette so + * that a given id lands on the same colour every time, as before. + */ + private seed(id: string): Candidate { + const primary = this.curated[0].filter((candidate) => !candidate.used); + const fallback = this.curated[1].filter((candidate) => !candidate.used); + const available = + primary.length > 0 + ? primary + : fallback.length > 0 + ? fallback + : this.materialiseGenerated().filter((candidate) => !candidate.used); + const random = new PseudoRandom(simpleHash(id)); + return available[random.nextInt(0, available.length)]; + } + + /** + * Build the generated candidate set on first use, scoring it against + * everything already assigned so it enters the pool fairly. + */ + private materialiseGenerated(): Candidate[] { + if (this.generated === null) { + this.generated = this.toCandidates(generateCandidateColors()); + for (const color of this.assigned.values()) { + const views = observerViews(color, this.observers); + for (const candidate of this.generated) { + candidate.nearest = Math.min( + candidate.nearest, + distance(candidate.views, views), + ); + } + } + } + return this.generated; + } + + /** Fold a newly assigned colour into every candidate's cached score. */ + private updateNearest(assigned: Candidate): void { + const tiers: Candidate[][] = [...this.curated]; + if (this.generated !== null) { + tiers.push(this.generated); + } + for (const tier of tiers) { + for (const candidate of tier) { + if (candidate.used) { + continue; + } + candidate.nearest = Math.min( + candidate.nearest, + distance(candidate.views, assigned.views), + ); + } + } } } +/** Highest-scoring unused candidate in a tier, or null if none remain. */ +function bestUnused(tier: Candidate[]): Candidate | null { + let best: Candidate | null = null; + for (const candidate of tier) { + if (candidate.used) { + continue; + } + if (best === null || candidate.nearest > best.nearest) { + best = candidate; + } + } + return best; +} + +/** + * Worst-case ΔE2000 between two colours across every observer, on the 0–100 + * scale. colord's lab plugin `.delta()` is CIEDE2000 normalised to 0..1. + */ +function distance(a: Colord[], b: Colord[]): number { + let worst = Infinity; + for (let i = 0; i < a.length; i++) { + const d = a[i].delta(b[i]) * 100; + if (d < worst) { + worst = d; + } + } + return worst; +} + /** * Index of the available color that is most perceptually different from the * already-assigned colors (the one whose nearest assigned neighbor is farthest @@ -78,21 +241,16 @@ export function selectDistinctColorIndex( let maxIndex = 0; for (let i = 0; i < availableColors.length; i++) { - const color = availableColors[i]; - const deltaE = minDeltaE(color, assignedColors); - if (deltaE > maxDeltaE) { - maxDeltaE = deltaE; + let nearest = Infinity; + for (const assigned of assignedColors) { + // colord's lab plugin .delta() is CIEDE2000 normalized to 0..1; only + // relative magnitudes matter here. + nearest = Math.min(nearest, availableColors[i].delta(assigned)); + } + if (nearest > maxDeltaE) { + maxDeltaE = nearest; maxIndex = i; } } return maxIndex; } - -/** Smallest delta-E 2000 distance from `color` to any of the assigned colors. */ -function minDeltaE(color: Colord, assignedColors: Colord[]) { - return assignedColors.reduce((min, assigned) => { - // colord's lab plugin .delta() is CIEDE2000 normalized to 0..1; only - // relative magnitudes matter here. - return Math.min(min, color.delta(assigned)); - }, Infinity); -} diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index fc792298ba..f9e5681a50 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -238,3 +238,92 @@ describe("ColorGenerator", () => { } }); }); + +describe("ColorAllocator distinctness guarantees", () => { + const humanColors = defaultTheme.humanColors.map((c) => colord(c)); + const fallbackPalette = defaultTheme.fallbackColors.map((c) => colord(c)); + const observers = ["normal", "deutan", "protan"] as const; + + const allocate = (count: number, floor: number) => { + const allocator = new ColorAllocator(humanColors, fallbackPalette, { + observers: [...observers], + distinctnessFloor: floor, + }); + return Array.from({ length: count }, (_, i) => + allocator.assignColor(`player_${i}`), + ); + }; + + const worstSeparation = (colors: Colord[]) => { + let worst = Infinity; + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + for (const observer of observers) { + const d = + simulate(colors[i], observer).delta(simulate(colors[j], observer)) * + 100; + if (d < worst) worst = d; + } + } + } + return worst; + }; + + test("never issues the same colour twice in a full lobby", () => { + // MAX_PLAYER_COUNT is 125 (src/server/MapPlaylist.ts) against a 63-colour + // pool, so a full public lobby exhausts the palette by design. + const colors = allocate(125, 5); + expect(new Set(colors.map((c) => c.toHex())).size).toBe(125); + }); + + test("honours the distinctness floor while candidates remain", () => { + expect(worstSeparation(allocate(48, 5))).toBeGreaterThanOrEqual(5); + }); + + test("generates no new colours for a 48-player game", () => { + // Everything up to this size comes from palette data shipped in the theme + // JSON, so lobbies of ordinary size keep the game's existing look — only + // which colour a given player receives changes. + const shipped = new Set([ + ...defaultTheme.humanColors.map((c) => colord(c).toHex()), + ...defaultTheme.fallbackColors.map((c) => colord(c).toHex()), + ]); + for (const color of allocate(48, 5)) { + expect(shipped.has(color.toHex())).toBe(true); + } + }); + + test("separates a full lobby well past the just-noticeable threshold", () => { + // The shipped allocator scores 0.00 here — two players share a colour. + expect(worstSeparation(allocate(125, 5))).toBeGreaterThan(2.3); + }); + + test("is deterministic for the same id sequence", () => { + expect(allocate(40, 5).map((c) => c.toHex())).toEqual( + allocate(40, 5).map((c) => c.toHex()), + ); + }); + + test("recycle policy reuses the palette instead of generating", () => { + const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; + const allocator = new ColorAllocator(pool, [], { + onExhausted: "recycle", + }); + const assigned = Array.from({ length: 6 }, (_, i) => + allocator.assignColor(`bot_${i}`), + ); + const palette = new Set(pool.map((c) => c.toHex())); + for (const color of assigned) { + expect(palette.has(color.toHex())).toBe(true); + } + }); + + test("generate policy leaves the palette once it is exhausted", () => { + const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; + const allocator = new ColorAllocator(pool, [], {}); + const assigned = Array.from({ length: 6 }, (_, i) => + allocator.assignColor(`player_${i}`), + ); + expect(new Set(assigned.map((c) => c.toHex())).size).toBe(6); + }); +}); From 91b62f879901dcfb3f8a72f7a99013576fc204cd Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:44:14 -0400 Subject: [PATCH 04/10] Add per-theme observer and distinctness floor settings --- src/client/render/gl/RenderSettings.ts | 14 +++++++++++ src/client/render/gl/colorblind-theme.json | 3 ++- src/client/render/gl/default-theme.json | 3 ++- tests/Colors.test.ts | 29 ++++++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index f1a6561ad4..a42ed9d1f7 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -22,6 +22,20 @@ export interface ThemeSettings { botColors: string[]; /** Used when the primary palettes are exhausted. */ fallbackColors: string[]; + /** + * Vision models player colours must stay distinct under. Names come from the + * theme module's Observer type ("normal", "protan", "deutan", "tritan"); they + * are validated when the theme is built, so a typo fails loudly rather than + * silently disabling an accessibility check. + */ + observers: string[]; + /** + * Minimum ΔE2000 (0–100) a curated palette colour must reach against the + * colours already in play. Below this the allocator generates a colour + * instead. Raising it favours separation over the curated palette; lowering + * it favours the curated palette. + */ + distinctnessFloor: number; /** Border = territory color darkened by this absolute amount. */ borderDarken: number; /** diff --git a/src/client/render/gl/colorblind-theme.json b/src/client/render/gl/colorblind-theme.json index 0d60fbbfd2..0efeecceb8 100644 --- a/src/client/render/gl/colorblind-theme.json +++ b/src/client/render/gl/colorblind-theme.json @@ -395,7 +395,6 @@ "#f0bef5", "#f5c3fa", "#fac8ff", - "#ffcdff", "#ffd2ff", "#ffd2fa", "#ffcdf5", @@ -420,6 +419,8 @@ "#fff5d2", "#fff0dc" ], + "observers": ["normal", "deutan", "protan", "tritan"], + "distinctnessFloor": 5, "borderDarken": 0, "borderLightnessScale": 0.6, "defendedBorderDarkenLight": 0.2, diff --git a/src/client/render/gl/default-theme.json b/src/client/render/gl/default-theme.json index fd0b97ec96..7a1ce7a07a 100644 --- a/src/client/render/gl/default-theme.json +++ b/src/client/render/gl/default-theme.json @@ -460,7 +460,6 @@ "#f0bef5", "#f5c3fa", "#fac8ff", - "#ffcdff", "#ffd2ff", "#ffd2fa", "#ffcdf5", @@ -485,6 +484,8 @@ "#fff5d2", "#fff0dc" ], + "observers": ["normal", "deutan", "protan"], + "distinctnessFloor": 5, "borderDarken": 0.125, "borderLightnessScale": 1, "defendedBorderDarkenLight": 0.2, diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index f9e5681a50..c3fd1a7f9e 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -1,4 +1,5 @@ import { colord, Colord } from "colord"; +import colorblindThemeJson from "../src/client/render/gl/colorblind-theme.json"; import defaultTheme from "../src/client/render/gl/default-theme.json"; import { createThemeSettings } from "../src/client/render/gl/RenderSettings"; import { @@ -327,3 +328,31 @@ describe("ColorAllocator distinctness guarantees", () => { expect(new Set(assigned.map((c) => c.toHex())).size).toBe(6); }); }); + +describe("theme colour settings", () => { + test("both themes declare observers and a distinctness floor", () => { + for (const theme of [defaultTheme, colorblindThemeJson]) { + expect(parseObservers(theme.observers)).toEqual(theme.observers); + expect(theme.distinctnessFloor).toBeGreaterThan(0); + } + }); + + test("the colorblind theme checks tritanopia and the default theme does not", () => { + expect(colorblindThemeJson.observers).toContain("tritan"); + expect(defaultTheme.observers).not.toContain("tritan"); + }); + + test("no palette contains a duplicate colour", () => { + for (const theme of [defaultTheme, colorblindThemeJson]) { + for (const palette of [ + theme.humanColors, + theme.nationColors, + theme.botColors, + theme.fallbackColors, + ]) { + const normalised = palette.map((c) => colord(c).toHex()); + expect(new Set(normalised).size).toBe(normalised.length); + } + } + }); +}); From 7b07c2a84d17b4e1512f52aa9ca538cb98ef8066 Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:54:46 -0400 Subject: [PATCH 05/10] Fix bot and nation colour pools being used as their own fallback --- src/client/theme/ThemeProvider.ts | 28 +++++++++++++++++++---- tests/Colors.test.ts | 38 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts index cc46725440..665991081a 100644 --- a/src/client/theme/ThemeProvider.ts +++ b/src/client/theme/ThemeProvider.ts @@ -9,7 +9,8 @@ import { ThemeSettings, } from "../render/gl/RenderSettings"; import { PlayerView } from "../view"; -import { ColorAllocator } from "./ColorAllocator"; +import { ColorAllocator, ColorAllocatorOptions } from "./ColorAllocator"; +import { parseObservers } from "./ColorVision"; /** * The color surface consumed by PlayerView and HUD components. Built from @@ -95,9 +96,28 @@ export class SettingsTheme implements Theme { const nationColors = settings.nationColors.map(colord); const fallbackColors = settings.fallbackColors.map(colord); - this.humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); - this.botColorAllocator = new ColorAllocator(botColors, botColors); - this.nationColorAllocator = new ColorAllocator(nationColors, nationColors); + const distinctness: ColorAllocatorOptions = { + observers: parseObservers(settings.observers), + distinctnessFloor: settings.distinctnessFloor, + }; + + this.humanColorAllocator = new ColorAllocator( + humanColors, + fallbackColors, + distinctness, + ); + // Bots deliberately share a small palette: a lobby carries hundreds of + // them, and giving each a maximally distinct colour would crowd out the + // human players it matters most to tell apart. + this.botColorAllocator = new ColorAllocator(botColors, [], { + ...distinctness, + onExhausted: "recycle", + }); + this.nationColorAllocator = new ColorAllocator( + nationColors, + [], + distinctness, + ); this.teamPalettes = buildTeamPalettes(settings); this._focusedBorderColor = colord(settings.focusedBorderColor); diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index c3fd1a7f9e..787ec7f1ff 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -13,7 +13,8 @@ import { simulate, } from "../src/client/theme/ColorVision"; import { SettingsTheme } from "../src/client/theme/ThemeProvider"; -import { ColoredTeams } from "../src/core/game/Game"; +import { PlayerView } from "../src/client/view"; +import { ColoredTeams, PlayerType } from "../src/core/game/Game"; const mockColors: Colord[] = [ colord({ r: 255, g: 0, b: 0 }), @@ -356,3 +357,38 @@ describe("theme colour settings", () => { } }); }); + +describe("SettingsTheme allocator wiring", () => { + const playerStub = (id: string, type: PlayerType) => + ({ + id: () => id, + team: () => null, + type: () => type, + }) as unknown as PlayerView; + + test("bots reuse their palette rather than generating new colours", () => { + const theme = new SettingsTheme(createThemeSettings("default")); + const palette = new Set( + createThemeSettings("default").botColors.map((c) => colord(c).toHex()), + ); + for (let i = 0; i < 120; i++) { + const color = theme.territoryColor( + playerStub(`bot_${i}`, PlayerType.Bot), + ); + expect(palette.has(color.toHex())).toBe(true); + } + }); + + test("humans in a full lobby all receive different colours", () => { + const theme = new SettingsTheme(createThemeSettings("default")); + const seen = new Set(); + for (let i = 0; i < 125; i++) { + seen.add( + theme + .territoryColor(playerStub(`human_${i}`, PlayerType.Human)) + .toHex(), + ); + } + expect(seen.size).toBe(125); + }); +}); From b734f2127cd4c9574390c38f7708634204af921d Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:04:12 -0400 Subject: [PATCH 06/10] Compute colour distance from cached LAB values Colour allocation compares each new colour against thousands of fixed candidates. colord's delta() converts both operands from sRGB to LAB on every call, so those conversions dominated: a 125-player lobby spent 2.2s in allocation. Converting once per candidate and taking LAB directly cuts that to 0.8s with no change in the colours chosen. The reference implementation is also more accurate. colord's delta() disagrees with the CIEDE2000 formula by up to 2.5 on some near-neutral pairs; this one matches the Sharma, Wu & Dalal (2005) test data to five decimal places. --- src/client/theme/ColorAllocator.ts | 30 +++++++---- src/client/theme/ColorDistance.ts | 83 ++++++++++++++++++++++++++++++ src/client/theme/ColorGenerator.ts | 7 +++ tests/Colors.test.ts | 77 +++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 src/client/theme/ColorDistance.ts diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 86b17fb3da..87102358a3 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -1,8 +1,9 @@ -import { Colord, extend } from "colord"; +import { Colord, extend, LabaColor } from "colord"; import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import { PseudoRandom } from "../../core/PseudoRandom"; import { simpleHash } from "../../core/Util"; +import { deltaE2000 } from "./ColorDistance"; import { generateCandidateColors } from "./ColorGenerator"; import { Observer, observerViews } from "./ColorVision"; @@ -32,7 +33,12 @@ export interface ColorAllocatorOptions { /** A candidate colour, its appearance per observer, and its cached score. */ interface Candidate { color: Colord; - views: Colord[]; + /** + * LAB coordinates of the colour as each observer sees it, converted once at + * construction. Allocation compares one colour against thousands of + * candidates, so converting per comparison would dominate the cost. + */ + labs: LabaColor[]; /** * Smallest distance to any already-assigned colour, across all observers. * Maintained incrementally so allocation costs O(candidates) rather than @@ -92,12 +98,17 @@ export class ColorAllocator { private toCandidates(colors: Colord[]): Candidate[] { return colors.map((color) => ({ color, - views: observerViews(color, this.observers), + labs: this.toLabs(color), nearest: Infinity, used: false, })); } + /** LAB coordinates of `color` under each observer this allocator checks. */ + private toLabs(color: Colord): LabaColor[] { + return observerViews(color, this.observers).map((view) => view.toLab()); + } + private select(id: string): Candidate { if (this.assigned.size === 0) { return this.seed(id); @@ -163,11 +174,11 @@ export class ColorAllocator { if (this.generated === null) { this.generated = this.toCandidates(generateCandidateColors()); for (const color of this.assigned.values()) { - const views = observerViews(color, this.observers); + const labs = this.toLabs(color); for (const candidate of this.generated) { candidate.nearest = Math.min( candidate.nearest, - distance(candidate.views, views), + distance(candidate.labs, labs), ); } } @@ -188,7 +199,7 @@ export class ColorAllocator { } candidate.nearest = Math.min( candidate.nearest, - distance(candidate.views, assigned.views), + distance(candidate.labs, assigned.labs), ); } } @@ -211,12 +222,13 @@ function bestUnused(tier: Candidate[]): Candidate | null { /** * Worst-case ΔE2000 between two colours across every observer, on the 0–100 - * scale. colord's lab plugin `.delta()` is CIEDE2000 normalised to 0..1. + * scale. Both operands are already in LAB, so no colour conversion happens + * here — this runs once per candidate per allocation. */ -function distance(a: Colord[], b: Colord[]): number { +function distance(a: LabaColor[], b: LabaColor[]): number { let worst = Infinity; for (let i = 0; i < a.length; i++) { - const d = a[i].delta(b[i]) * 100; + const d = deltaE2000(a[i], b[i]); if (d < worst) { worst = d; } diff --git a/src/client/theme/ColorDistance.ts b/src/client/theme/ColorDistance.ts new file mode 100644 index 0000000000..29d3073a58 --- /dev/null +++ b/src/client/theme/ColorDistance.ts @@ -0,0 +1,83 @@ +import { LabaColor } from "colord"; + +/** + * CIEDE2000 colour difference between two LAB colours, on the usual 0–100 + * scale. + * + * colord's lab plugin offers the same metric via `Colord.delta()`, but it + * converts both operands from sRGB to LAB on every call. Colour allocation + * compares one new colour against thousands of fixed candidates, so those + * conversions dominate. Taking LAB directly lets callers convert once and + * reuse the result. + * + * Validated against the Sharma, Wu & Dalal (2005) reference dataset in + * tests/Colors.test.ts. That matters beyond performance: `Colord.delta()` + * disagrees with the reference formula by up to ~2.5 on some near-neutral + * pairs, so this is also the more accurate of the two. + */ +export function deltaE2000(first: LabaColor, second: LabaColor): number { + const rad = Math.PI / 180; + const deg = 180 / Math.PI; + + const c1 = Math.hypot(first.a, first.b); + const c2 = Math.hypot(second.a, second.b); + const meanC = (c1 + c2) / 2; + const meanC7 = meanC ** 7; + const g = 0.5 * (1 - Math.sqrt(meanC7 / (meanC7 + 25 ** 7))); + + const a1 = first.a * (1 + g); + const a2 = second.a * (1 + g); + const cp1 = Math.hypot(a1, first.b); + const cp2 = Math.hypot(a2, second.b); + + const hue = (b: number, a: number): number => { + if (b === 0 && a === 0) return 0; + const h = Math.atan2(b, a) * deg; + return h >= 0 ? h : h + 360; + }; + const hp1 = hue(first.b, a1); + const hp2 = hue(second.b, a2); + + const deltaL = second.l - first.l; + const deltaC = cp2 - cp1; + + let deltaHue = 0; + if (cp1 * cp2 !== 0) { + deltaHue = hp2 - hp1; + if (deltaHue > 180) deltaHue -= 360; + else if (deltaHue < -180) deltaHue += 360; + } + const deltaH = 2 * Math.sqrt(cp1 * cp2) * Math.sin((deltaHue / 2) * rad); + + const meanL = (first.l + second.l) / 2; + const meanCp = (cp1 + cp2) / 2; + + let meanHp = hp1 + hp2; + if (cp1 * cp2 !== 0) { + if (Math.abs(hp1 - hp2) > 180) meanHp += hp1 + hp2 < 360 ? 360 : -360; + meanHp /= 2; + } + + const t = + 1 - + 0.17 * Math.cos((meanHp - 30) * rad) + + 0.24 * Math.cos(2 * meanHp * rad) + + 0.32 * Math.cos((3 * meanHp + 6) * rad) - + 0.2 * Math.cos((4 * meanHp - 63) * rad); + + const meanCp7 = meanCp ** 7; + const sl = + 1 + (0.015 * (meanL - 50) ** 2) / Math.sqrt(20 + (meanL - 50) ** 2); + const sc = 1 + 0.045 * meanCp; + const sh = 1 + 0.015 * meanCp * t; + const rt = + -2 * + Math.sqrt(meanCp7 / (meanCp7 + 25 ** 7)) * + Math.sin(60 * Math.exp(-(((meanHp - 275) / 25) ** 2)) * rad); + + const lTerm = deltaL / sl; + const cTerm = deltaC / sc; + const hTerm = deltaH / sh; + + return Math.sqrt(lTerm ** 2 + cTerm ** 2 + hTerm ** 2 + rt * cTerm * hTerm); +} diff --git a/src/client/theme/ColorGenerator.ts b/src/client/theme/ColorGenerator.ts index be07495652..77e1bd9e57 100644 --- a/src/client/theme/ColorGenerator.ts +++ b/src/client/theme/ColorGenerator.ts @@ -30,6 +30,13 @@ const HUE_STEP = 6; * * The constants above yield 6125 candidates from 6600 sweep points. * + * Resolution is a deliberate trade. Coarsening the steps to ~1500 candidates + * makes allocation roughly four times cheaper, but costs real separation in + * the case this exists to serve: worst-case ΔE across observers drops from + * 3.9 to 3.4 for a 100-player colourblind-theme lobby. Separation wins — + * generation only runs for lobbies that have already exhausted the curated + * palettes, and the cost is one-off per player rather than per frame. + * * Deterministic: no RNG, and the iteration order is fixed. */ export function generateCandidateColors(): Colord[] { diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 787ec7f1ff..ba0e886edc 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -6,6 +6,7 @@ import { ColorAllocator, selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; +import { deltaE2000 } from "../src/client/theme/ColorDistance"; import { generateCandidateColors } from "../src/client/theme/ColorGenerator"; import { observerViews, @@ -215,6 +216,82 @@ describe("ColorVision", () => { }); }); +describe("ColorDistance", () => { + test("matches the published CIEDE2000 reference dataset", () => { + // Sharma, Wu & Dalal (2005), "The CIEDE2000 color-difference formula: + // implementation notes, supplementary test data, and mathematical + // observations". These pairs exercise the hue-wraparound and near-neutral + // branches that naive implementations get wrong. + const cases: [number[], number[], number][] = [ + [[50, 2.6772, -79.7751], [50, 0, -82.7485], 2.0425], + [[50, 3.1571, -77.2803], [50, 0, -82.7485], 2.8615], + [[50, 2.8361, -74.02], [50, 0, -82.7485], 3.4412], + [[50, -1.3802, -84.2814], [50, 0, -82.7485], 1.0], + [[50, -0.9009, -85.5211], [50, 0, -82.7485], 1.0], + [[50, 0, 0], [50, -1, 2], 2.3669], + [[50, -1, 2], [50, 0, 0], 2.3669], + [[50, 2.49, -0.001], [50, -2.49, 0.0009], 7.1792], + [[50, 2.49, -0.001], [50, -2.49, 0.0011], 7.2195], + [[50, -0.001, 2.49], [50, 0.0009, -2.49], 4.8045], + [[50, 2.5, 0], [50, 0, -2.5], 4.3065], + [[50, 2.5, 0], [73, 25, -18], 27.1492], + [[50, 2.5, 0], [50, 3.1736, 0.5854], 1.0], + [[60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644], + [[63.0109, -31.0961, -5.8663], [62.8187, -29.7946, -4.0864], 1.263], + [[22.7233, 20.0904, -46.694], [23.0331, 14.973, -42.5619], 2.0373], + [[2.0776, 0.0795, -1.135], [0.9033, -0.0636, -0.5514], 0.9082], + ]; + for (const [first, second, expected] of cases) { + const value = deltaE2000( + { l: first[0], a: first[1], b: first[2], alpha: 1 }, + { l: second[0], a: second[1], b: second[2], alpha: 1 }, + ); + expect(value).toBeCloseTo(expected, 3); + } + }); + + test("agrees with colord's delta on ordinary palette colours", () => { + // Sanity check that swapping the metric did not change the allocator's + // behaviour in the common case. colord rounds delta() to three decimals + // (0.1 once scaled to 0-100) and diverges from the reference formula on + // some near-neutral pairs, so this is a mean check, not a per-pair one. + const palette = defaultTheme.humanColors.map((c) => colord(c)); + let total = 0; + let pairs = 0; + for (let i = 0; i < palette.length; i++) { + for (let j = i + 1; j < palette.length; j++) { + total += Math.abs( + deltaE2000(palette[i].toLab(), palette[j].toLab()) - + palette[i].delta(palette[j]) * 100, + ); + pairs++; + } + } + expect(total / pairs).toBeLessThan(0.1); + }); + + test("is zero for identical colours and symmetric", () => { + const a = colord("#2962ff").toLab(); + const b = colord("#eb3333").toLab(); + expect(deltaE2000(a, a)).toBeCloseTo(0, 10); + expect(deltaE2000(a, b)).toBeCloseTo(deltaE2000(b, a), 10); + }); + + test("handles achromatic colours without dividing by zero", () => { + const black = colord("#000000").toLab(); + const white = colord("#ffffff").toLab(); + const grey = colord("#808080").toLab(); + for (const value of [ + deltaE2000(black, white), + deltaE2000(black, grey), + deltaE2000(grey, white), + ]) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThan(0); + } + }); +}); + describe("ColorGenerator", () => { test("produces a substantial candidate set", () => { expect(generateCandidateColors().length).toBeGreaterThan(500); From 1cf6ddceb576aeb083d714c9c91f4430ae069aa9 Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:56:22 -0400 Subject: [PATCH 07/10] Avoid Math.pow in the colour distance inner loop The chroma weighting terms raise values to the seventh power, which ran as a Math.pow call for every candidate comparison. Replacing it with multiplication and hoisting the 25^7 constant halves the cost of the first generated-colour allocation. --- src/client/theme/ColorDistance.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/client/theme/ColorDistance.ts b/src/client/theme/ColorDistance.ts index 29d3073a58..9ac8c50475 100644 --- a/src/client/theme/ColorDistance.ts +++ b/src/client/theme/ColorDistance.ts @@ -15,15 +15,26 @@ import { LabaColor } from "colord"; * disagrees with the reference formula by up to ~2.5 on some near-neutral * pairs, so this is also the more accurate of the two. */ +const DEG = 180 / Math.PI; +const RAD = Math.PI / 180; +/** 25^7, the constant the chroma-weighting terms are scaled against. */ +const POW_25_7 = 6103515625; + +/** `value ** 7` by multiplication — this runs millions of times per lobby. */ +function pow7(value: number): number { + const cube = value * value * value; + return cube * cube * value; +} + export function deltaE2000(first: LabaColor, second: LabaColor): number { - const rad = Math.PI / 180; - const deg = 180 / Math.PI; + const rad = RAD; + const deg = DEG; const c1 = Math.hypot(first.a, first.b); const c2 = Math.hypot(second.a, second.b); const meanC = (c1 + c2) / 2; - const meanC7 = meanC ** 7; - const g = 0.5 * (1 - Math.sqrt(meanC7 / (meanC7 + 25 ** 7))); + const meanC7 = pow7(meanC); + const g = 0.5 * (1 - Math.sqrt(meanC7 / (meanC7 + POW_25_7))); const a1 = first.a * (1 + g); const a2 = second.a * (1 + g); @@ -65,15 +76,17 @@ export function deltaE2000(first: LabaColor, second: LabaColor): number { 0.32 * Math.cos((3 * meanHp + 6) * rad) - 0.2 * Math.cos((4 * meanHp - 63) * rad); - const meanCp7 = meanCp ** 7; - const sl = - 1 + (0.015 * (meanL - 50) ** 2) / Math.sqrt(20 + (meanL - 50) ** 2); + const meanCp7 = pow7(meanCp); + const dl = meanL - 50; + const dl2 = dl * dl; + const hueOffset = (meanHp - 275) / 25; + const sl = 1 + (0.015 * dl2) / Math.sqrt(20 + dl2); const sc = 1 + 0.045 * meanCp; const sh = 1 + 0.015 * meanCp * t; const rt = -2 * - Math.sqrt(meanCp7 / (meanCp7 + 25 ** 7)) * - Math.sin(60 * Math.exp(-(((meanHp - 275) / 25) ** 2)) * rad); + Math.sqrt(meanCp7 / (meanCp7 + POW_25_7)) * + Math.sin(60 * Math.exp(-(hueOffset * hueOffset)) * rad); const lTerm = deltaL / sl; const cTerm = deltaC / sc; From 75c9b9666d062e3e931b366d67ea16c81ff42e8e Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:32:26 -0400 Subject: [PATCH 08/10] Judge colour distinctness across every player on the map Human, nation and bot colours were allocated from separate palettes that never compared against one another. A player looking at the map cannot tell those types apart, so the separation that mattered was the one nobody was measuring: a nation could land 0.61 from a human, and in the colourblind theme a human could be handed a bot's exact colour. A shared registry now holds every colour in play. Bot palettes are reserved rather than allocated, since hundreds of bots share a small palette by design, but their colours are still on the map so everyone else keeps clear of them. Synthesised colours come from a low-discrepancy sequence rather than a grid sweep, covering the same volume with a third of the entries. Worst separation across a full World game, by pair: humans/nations 0.61 -> 3.63, humans/bots 1.98 -> 10.34, nations/bots 0.32 -> 3.45, and allocation 880ms -> 540ms. --- src/client/theme/ColorAllocator.ts | 237 ++++++++++------------------- src/client/theme/ColorGenerator.ts | 70 ++++----- src/client/theme/ColorRegistry.ts | 205 +++++++++++++++++++++++++ src/client/theme/ThemeProvider.ts | 39 ++--- tests/Colors.test.ts | 139 +++++++++++++---- 5 files changed, 450 insertions(+), 240 deletions(-) create mode 100644 src/client/theme/ColorRegistry.ts diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 87102358a3..8852301972 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -1,71 +1,61 @@ -import { Colord, extend, LabaColor } from "colord"; +import { Colord, extend } from "colord"; import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import { PseudoRandom } from "../../core/PseudoRandom"; import { simpleHash } from "../../core/Util"; -import { deltaE2000 } from "./ColorDistance"; -import { generateCandidateColors } from "./ColorGenerator"; -import { Observer, observerViews } from "./ColorVision"; +import { Candidate, ColorRegistry } from "./ColorRegistry"; +import { Observer } from "./ColorVision"; extend([lchPlugin]); extend([labPlugin]); -/** What to do once every candidate colour has been handed out. */ -export type ExhaustionPolicy = "generate" | "recycle"; +/** How a pool of players competes for colours. */ +export type AllocationPolicy = "distinct" | "shared"; export interface ColorAllocatorOptions { /** * Vision models a colour must stay distinct under. A candidate is scored by - * its *worst* separation across all of them. Defaults to normal vision only, - * which reproduces the previous behaviour. + * its *worst* separation across all of them. Defaults to normal vision only. + * Ignored when `registry` is supplied — the registry owns these. */ observers?: Observer[]; /** - * Minimum ΔE2000 (0–100) a curated colour must reach before the allocator - * stops trusting the curated palettes and synthesises a colour instead. - * 0 keeps the curated palettes in use until they are exhausted. + * Minimum ΔE2000 (0–100) a palette colour must reach before the allocator + * stops trusting the palettes and synthesises one instead. Ignored when + * `registry` is supplied. */ distinctnessFloor?: number; - /** Behaviour once every candidate is used. Defaults to "generate". */ - onExhausted?: ExhaustionPolicy; -} - -/** A candidate colour, its appearance per observer, and its cached score. */ -interface Candidate { - color: Colord; /** - * LAB coordinates of the colour as each observer sees it, converted once at - * construction. Allocation compares one colour against thousands of - * candidates, so converting per comparison would dominate the cost. + * `"distinct"` (default) gives every id its own colour, competing with every + * other allocator sharing the registry. `"shared"` hands out colours by + * stable hash without reserving them — for pools where hundreds of players + * are expected to share a small palette by design. */ - labs: LabaColor[]; + policy?: AllocationPolicy; /** - * Smallest distance to any already-assigned colour, across all observers. - * Maintained incrementally so allocation costs O(candidates) rather than - * O(candidates * assigned). + * Distinctness state shared with other allocators. Supply one registry to + * every allocator in a game so their colours cannot collide. Omitted, the + * allocator gets a private registry and competes with nobody. */ - nearest: number; - used: boolean; + registry?: ColorRegistry; } /** - * Assigns a stable, visually distinct colour to each id. + * Assigns a stable colour to each id. * - * Candidates are drawn from the theme's primary palette first, then its - * fallback palette, and finally — only if neither can supply a colour that - * clears `distinctnessFloor` — from colours generated on the fly. A colour is - * never handed out twice unless `onExhausted` is "recycle". + * Colours come from the primary palette first, then the fallback palette, and + * finally — only when neither holds one far enough from the colours already in + * play — from a colour synthesised by the registry. Assignments are stable for + * the allocator's lifetime. * * Theme-agnostic: it knows nothing about teams or palettes. A theme supplies * the pools and owns any team-colour logic. */ export class ColorAllocator { - private readonly observers: Observer[]; - private readonly distinctnessFloor: number; - private readonly onExhausted: ExhaustionPolicy; - /** Curated tiers in preference order: primary palette, then fallback. */ - private readonly curated: Candidate[][]; - private generated: Candidate[] | null = null; + private readonly registry: ColorRegistry; + private readonly policy: AllocationPolicy; + private readonly primary: Candidate[]; + private readonly fallback: Candidate[]; private assigned = new Map(); constructor( @@ -73,10 +63,23 @@ export class ColorAllocator { fallback: Colord[], options: ColorAllocatorOptions = {}, ) { - this.observers = options.observers ?? ["normal"]; - this.distinctnessFloor = options.distinctnessFloor ?? 0; - this.onExhausted = options.onExhausted ?? "generate"; - this.curated = [this.toCandidates(colors), this.toCandidates(fallback)]; + this.registry = + options.registry ?? + new ColorRegistry( + options.observers ?? ["normal"], + options.distinctnessFloor ?? 0, + ); + this.policy = options.policy ?? "distinct"; + this.primary = colors.map((color) => this.registry.candidate(color)); + this.fallback = fallback.map((color) => this.registry.candidate(color)); + if (this.policy === "distinct") { + this.registry.registerPool(this.primary); + this.registry.registerPool(this.fallback); + } else { + // Nothing reserves these, but they will be on the map, so everyone else + // has to keep away from them. + this.registry.reserve(colors); + } } /** @@ -88,128 +91,70 @@ export class ColorAllocator { if (existing !== undefined) { return existing; } - const candidate = this.select(id); - candidate.used = true; - this.assigned.set(id, candidate.color); - this.updateNearest(candidate); - return candidate.color; + const color = this.policy === "shared" ? this.share(id) : this.allocate(id); + this.assigned.set(id, color); + return color; } - private toCandidates(colors: Colord[]): Candidate[] { - return colors.map((color) => ({ - color, - labs: this.toLabs(color), - nearest: Infinity, - used: false, - })); + /** + * Stable hash into the primary palette, reserving nothing. Hundreds of bots + * sharing a handful of colours is intended: giving each one a colour of its + * own would crowd out the players it matters most to tell apart, and cost + * far more than it is worth. + */ + private share(id: string): Colord { + return this.primary[simpleHash(id) % this.primary.length].color; } - /** LAB coordinates of `color` under each observer this allocator checks. */ - private toLabs(color: Colord): LabaColor[] { - return observerViews(color, this.observers).map((view) => view.toLab()); + private allocate(id: string): Colord { + const candidate = this.select(id); + this.registry.commit(candidate); + return candidate.color; } private select(id: string): Candidate { - if (this.assigned.size === 0) { + if (this.registry.size === 0) { return this.seed(id); } - // Prefer curated colours, in tier order, whenever one is good enough. - for (const tier of this.curated) { - const best = bestUnused(tier); - if (best !== null && best.nearest >= this.distinctnessFloor) { + // Prefer palette colours, primary before fallback, while one is good enough. + for (const pool of [this.primary, this.fallback]) { + const best = bestUnused(pool); + if (best !== null && best.nearest >= this.registry.distinctnessFloor) { return best; } } - const curatedBest = - bestUnused(this.curated[0]) ?? bestUnused(this.curated[1]); - - if (this.onExhausted === "recycle") { - if (curatedBest !== null) { - return curatedBest; - } - // Every curated colour is spoken for. Reopen the primary palette and - // continue: hundreds of bots sharing a small palette is intended. - for (const candidate of this.curated[0]) { - candidate.used = false; - } - return bestUnused(this.curated[0])!; - } - - const generatedBest = bestUnused(this.materialiseGenerated()); - if (curatedBest === null) { - return generatedBest!; - } - if (generatedBest === null) { - return curatedBest; + const curated = bestUnused(this.primary) ?? bestUnused(this.fallback); + const generated = this.registry.generate(); + if (curated !== null && curated.nearest >= generated.nearest) { + return curated; } - return generatedBest.nearest >= curatedBest.nearest - ? generatedBest - : curatedBest; + return generated; } /** * First colour of a game: chosen pseudo-randomly from the primary palette so - * that a given id lands on the same colour every time, as before. + * a given id lands on the same colour every time. */ private seed(id: string): Candidate { - const primary = this.curated[0].filter((candidate) => !candidate.used); - const fallback = this.curated[1].filter((candidate) => !candidate.used); - const available = - primary.length > 0 - ? primary - : fallback.length > 0 - ? fallback - : this.materialiseGenerated().filter((candidate) => !candidate.used); - const random = new PseudoRandom(simpleHash(id)); - return available[random.nextInt(0, available.length)]; - } - - /** - * Build the generated candidate set on first use, scoring it against - * everything already assigned so it enters the pool fairly. - */ - private materialiseGenerated(): Candidate[] { - if (this.generated === null) { - this.generated = this.toCandidates(generateCandidateColors()); - for (const color of this.assigned.values()) { - const labs = this.toLabs(color); - for (const candidate of this.generated) { - candidate.nearest = Math.min( - candidate.nearest, - distance(candidate.labs, labs), - ); - } - } - } - return this.generated; - } - - /** Fold a newly assigned colour into every candidate's cached score. */ - private updateNearest(assigned: Candidate): void { - const tiers: Candidate[][] = [...this.curated]; - if (this.generated !== null) { - tiers.push(this.generated); - } - for (const tier of tiers) { - for (const candidate of tier) { - if (candidate.used) { - continue; - } - candidate.nearest = Math.min( - candidate.nearest, - distance(candidate.labs, assigned.labs), - ); - } + const available = this.primary.filter((candidate) => !candidate.used); + const source = + available.length > 0 + ? available + : this.fallback.filter((candidate) => !candidate.used); + if (source.length === 0) { + return this.registry.generate(); } + const random = new PseudoRandom(simpleHash(id)); + return source[random.nextInt(0, source.length)]; } } -/** Highest-scoring unused candidate in a tier, or null if none remain. */ -function bestUnused(tier: Candidate[]): Candidate | null { +/** Highest-scoring unused candidate in a pool, or null if none remain. */ +function bestUnused(pool: Candidate[]): Candidate | null { let best: Candidate | null = null; - for (const candidate of tier) { + for (const candidate of pool) { if (candidate.used) { continue; } @@ -220,22 +165,6 @@ function bestUnused(tier: Candidate[]): Candidate | null { return best; } -/** - * Worst-case ΔE2000 between two colours across every observer, on the 0–100 - * scale. Both operands are already in LAB, so no colour conversion happens - * here — this runs once per candidate per allocation. - */ -function distance(a: LabaColor[], b: LabaColor[]): number { - let worst = Infinity; - for (let i = 0; i < a.length; i++) { - const d = deltaE2000(a[i], b[i]); - if (d < worst) { - worst = d; - } - } - return worst; -} - /** * Index of the available color that is most perceptually different from the * already-assigned colors (the one whose nearest assigned neighbor is farthest diff --git a/src/client/theme/ColorGenerator.ts b/src/client/theme/ColorGenerator.ts index 77e1bd9e57..f345b5b3ef 100644 --- a/src/client/theme/ColorGenerator.ts +++ b/src/client/theme/ColorGenerator.ts @@ -4,56 +4,44 @@ import lchPlugin from "colord/plugins/lch"; extend([lchPlugin]); /** - * Bounds of the LCH sweep used when the curated palettes cannot supply a colour - * that clears a theme's distinctness floor. + * Bounds of the LCH volume colours are drawn from. * * Lightness stops short of both ends: near-black and near-white territory fills * read poorly against terrain and against the border colours derived from them. - * Chroma stays above 25 so candidates don't collapse into washed-out greys. + * Chroma stays above 25 so results don't collapse into washed-out greys. */ const LIGHTNESS_MIN = 35; -const LIGHTNESS_MAX = 80; -const LIGHTNESS_STEP = 5; +const LIGHTNESS_RANGE = 45; const CHROMA_MIN = 25; -const CHROMA_MAX = 110; -const CHROMA_STEP = 8.5; -const HUE_STEP = 6; +const CHROMA_RANGE = 85; /** - * Candidate colours swept from LCH space, deduplicated by hex. - * - * Deduplication is load-bearing: LCH coordinates outside the sRGB gamut clamp - * on conversion and collapse onto the gamut surface, so a raw sweep contains - * repeats. Clamped colours are still valid, highly saturated candidates — they - * are kept, just not duplicated. Clamping also lifts a few results past the - * nominal lightness ceiling, to roughly 87. - * - * The constants above yield 6125 candidates from 6600 sweep points. + * Plastic number, the 3-dimensional analogue of the golden ratio. Successive + * multiples of its reciprocal powers fill a volume evenly at every prefix + * length — the R3 low-discrepancy sequence (Roberts, 2018). + */ +const PLASTIC = 1.2207440846057596; +const ALPHA_HUE = 1 / PLASTIC; +const ALPHA_LIGHTNESS = 1 / (PLASTIC * PLASTIC); +const ALPHA_CHROMA = 1 / (PLASTIC * PLASTIC * PLASTIC); + +const fraction = (value: number): number => value - Math.floor(value); + +/** + * The `index`-th colour of a deterministic sweep through LCH space. * - * Resolution is a deliberate trade. Coarsening the steps to ~1500 candidates - * makes allocation roughly four times cheaper, but costs real separation in - * the case this exists to serve: worst-case ΔE across observers drops from - * 3.9 to 3.4 for a 100-player colourblind-theme lobby. Separation wins — - * generation only runs for lobbies that have already exhausted the curated - * palettes, and the cost is one-off per player rather than per frame. + * The sequence is chosen so that any prefix is already well spread: colour 5 is + * far from colours 0–4 without anyone having searched for it. A pool drawn from + * it therefore covers the usable volume with far fewer entries than a regular + * grid sweep of comparable quality, which is what keeps the registry's scan + * cheap enough to run on every allocation. * - * Deterministic: no RNG, and the iteration order is fixed. + * Deterministic and stateless: the same index always yields the same colour. */ -export function generateCandidateColors(): Colord[] { - const seen = new Set(); - const candidates: Colord[] = []; - for (let l = LIGHTNESS_MIN; l <= LIGHTNESS_MAX; l += LIGHTNESS_STEP) { - for (let c = CHROMA_MIN; c <= CHROMA_MAX; c += CHROMA_STEP) { - for (let h = 0; h < 360; h += HUE_STEP) { - const color = colord({ l, c, h }); - const hex = color.toHex(); - if (seen.has(hex)) { - continue; - } - seen.add(hex); - candidates.push(color); - } - } - } - return candidates; +export function sequenceColor(index: number): Colord { + const h = fraction(0.5 + ALPHA_HUE * index) * 360; + const l = + LIGHTNESS_MIN + fraction(0.5 + ALPHA_LIGHTNESS * index) * LIGHTNESS_RANGE; + const c = CHROMA_MIN + fraction(0.5 + ALPHA_CHROMA * index) * CHROMA_RANGE; + return colord({ l, c, h }); } diff --git a/src/client/theme/ColorRegistry.ts b/src/client/theme/ColorRegistry.ts new file mode 100644 index 0000000000..cdfe56cc8b --- /dev/null +++ b/src/client/theme/ColorRegistry.ts @@ -0,0 +1,205 @@ +import { Colord, LabaColor } from "colord"; +import { deltaE2000 } from "./ColorDistance"; +import { sequenceColor } from "./ColorGenerator"; +import { Observer, observerViews } from "./ColorVision"; + +/** A colour under consideration, with its cached distance to what's in play. */ +export interface Candidate { + color: Colord; + /** The colour as each observer sees it, converted once. */ + labs: LabaColor[]; + /** + * Smallest distance to any colour already in play, across all observers. + * Maintained incrementally so each allocation costs O(candidates) rather + * than O(candidates * assigned). + */ + nearest: number; + used: boolean; +} + +/** + * How many synthesised colours to keep available at a time. + * + * Sized against the largest public lobby (125) plus nations, with room to + * discard crowded candidates. Scored once on creation and maintained + * incrementally afterwards, so widening this costs a scan per allocation — it + * is not free, and 2048 already gives worst-case separation within ~0.1 of an + * exhaustive 6125-point sweep at a quarter of the cost. + */ +const GENERATED_POOL_SIZE = 2048; + +/** Worst-case ΔE2000 between two colours across every observer. */ +export function distance(first: LabaColor[], second: LabaColor[]): number { + let worst = Infinity; + for (let i = 0; i < first.length; i++) { + const value = deltaE2000(first[i], second[i]); + if (value < worst) { + worst = value; + } + } + return worst; +} + +/** + * The colours in play for one game, shared by every allocator that draws from + * them. + * + * Players are drawn from separate palettes by type — human, nation, bot — but a + * player looking at the map cannot tell those types apart; they just see + * territories. Distinctness therefore has to be judged across all of them at + * once. Allocators that share a registry compete for the same space, so a + * nation can never be handed a colour that a human is already using. + * + * When no palette colour is far enough from what's in play, the registry + * synthesises one from the LCH sequence in ColorGenerator. + */ +export class ColorRegistry { + private readonly inPlay: Candidate[] = []; + private readonly pools: Candidate[][] = []; + /** Synthesised colours, built on first need so ordinary lobbies never pay. */ + private generated: Candidate[] | null = null; + private cursor = 0; + + constructor( + readonly observers: Observer[], + readonly distinctnessFloor: number, + ) {} + + /** How many colours have been handed out. */ + get size(): number { + return this.inPlay.length; + } + + /** Wrap a colour with the per-observer LAB values scoring needs. */ + candidate(color: Colord): Candidate { + return { + color, + labs: observerViews(color, this.observers).map((view) => view.toLab()), + nearest: Infinity, + used: false, + }; + } + + /** + * Track a pool so its scores stay current as colours are handed out. Pools + * joining late are scored against everything already in play. + */ + registerPool(pool: Candidate[]): void { + this.pools.push(pool); + for (const candidate of pool) { + candidate.nearest = Math.min( + candidate.nearest, + this.distanceToInPlay(candidate.labs), + ); + } + } + + /** Put a colour into play and refresh every tracked pool against it. */ + commit(candidate: Candidate): void { + candidate.used = true; + this.inPlay.push(candidate); + for (const pool of this.pools) { + for (const other of pool) { + if (other.used) { + continue; + } + const value = distance(other.labs, candidate.labs); + if (value < other.nearest) { + other.nearest = value; + } + } + } + } + + /** + * Distance from a colour to the nearest colour already in play. + * + * `abortBelow` lets a caller comparing many candidates give up on one as soon + * as it cannot beat the best so far. The result is then only a lower bound — + * which is all a caller about to discard it needs. A candidate that survives + * without aborting always carries its exact distance. + */ + distanceToInPlay(labs: LabaColor[], abortBelow = -Infinity): number { + let worst = Infinity; + for (const candidate of this.inPlay) { + const value = distance(labs, candidate.labs); + if (value < worst) { + worst = value; + if (worst <= abortBelow) { + return worst; + } + } + } + return worst; + } + + /** + * The roomiest synthesised colour available. + * + * The pool is built once, on the first call, and then maintained + * incrementally like any other pool — the same colour is never re-scored + * against the same neighbour twice. Re-deriving a fresh batch per allocation + * was measurably worse on both axes: a 768-colour batch each time cost 6.3s + * across a full game and still separated a 125-player lobby only to 3.38, + * against 0.8s and 3.5 for a pool scored once. + */ + generate(): Candidate { + if (this.generated === null) { + this.generated = []; + for (let i = 0; i < GENERATED_POOL_SIZE; i++) { + this.generated.push(this.candidate(sequenceColor(this.cursor++))); + } + this.registerPool(this.generated); + } + let best: Candidate | null = null; + for (const candidate of this.generated) { + if (candidate.used) { + continue; + } + // Never hand back a colour already in play: that would put two players + // in the same colour, the defect this exists to prevent. Sequence + // colours round to 8-bit sRGB, so exact repeats are possible. + if (candidate.nearest <= 0) { + continue; + } + if (best === null || candidate.nearest > best.nearest) { + best = candidate; + } + } + if (best !== null) { + return best; + } + // Pool exhausted or wholly crowded — extend it and take the roomiest. + const grown = this.candidate(sequenceColor(this.cursor++)); + grown.nearest = this.distanceToInPlay(grown.labs); + this.generated.push(grown); + return grown; + } + + /** + * Treat colours as in play without assigning them to anyone. + * + * Bot palettes are handed out by hash rather than reserved, but bot + * territories are still on the map — so humans and nations have to steer + * clear of those colours even though no bot has claimed them yet. + */ + reserve(colors: Colord[]): void { + for (const color of colors) { + const reserved = this.candidate(color); + this.inPlay.push(reserved); + // Refresh pools here too, so reserving works whatever order the + // allocators happen to be constructed in. + for (const pool of this.pools) { + for (const other of pool) { + if (other.used) { + continue; + } + const value = distance(other.labs, reserved.labs); + if (value < other.nearest) { + other.nearest = value; + } + } + } + } + } +} diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts index 665991081a..6d325038b0 100644 --- a/src/client/theme/ThemeProvider.ts +++ b/src/client/theme/ThemeProvider.ts @@ -9,7 +9,8 @@ import { ThemeSettings, } from "../render/gl/RenderSettings"; import { PlayerView } from "../view"; -import { ColorAllocator, ColorAllocatorOptions } from "./ColorAllocator"; +import { ColorAllocator } from "./ColorAllocator"; +import { ColorRegistry } from "./ColorRegistry"; import { parseObservers } from "./ColorVision"; /** @@ -96,28 +97,28 @@ export class SettingsTheme implements Theme { const nationColors = settings.nationColors.map(colord); const fallbackColors = settings.fallbackColors.map(colord); - const distinctness: ColorAllocatorOptions = { - observers: parseObservers(settings.observers), - distinctnessFloor: settings.distinctnessFloor, - }; - - this.humanColorAllocator = new ColorAllocator( - humanColors, - fallbackColors, - distinctness, + // One registry for the whole game. Humans and nations are drawn from + // separate palettes, but a player looking at the map cannot tell the two + // apart — sharing the registry stops a nation being handed a colour a + // human is already using. + const registry = new ColorRegistry( + parseObservers(settings.observers), + settings.distinctnessFloor, ); + + this.humanColorAllocator = new ColorAllocator(humanColors, fallbackColors, { + registry, + }); + this.nationColorAllocator = new ColorAllocator(nationColors, [], { + registry, + }); // Bots deliberately share a small palette: a lobby carries hundreds of - // them, and giving each a maximally distinct colour would crowd out the - // human players it matters most to tell apart. + // them, and giving each one a colour of its own would crowd out the + // players it matters most to tell apart. this.botColorAllocator = new ColorAllocator(botColors, [], { - ...distinctness, - onExhausted: "recycle", + registry, + policy: "shared", }); - this.nationColorAllocator = new ColorAllocator( - nationColors, - [], - distinctness, - ); this.teamPalettes = buildTeamPalettes(settings); this._focusedBorderColor = colord(settings.focusedBorderColor); diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index ba0e886edc..530964280f 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -7,7 +7,8 @@ import { selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; import { deltaE2000 } from "../src/client/theme/ColorDistance"; -import { generateCandidateColors } from "../src/client/theme/ColorGenerator"; +import { sequenceColor } from "../src/client/theme/ColorGenerator"; +import { ColorRegistry } from "../src/client/theme/ColorRegistry"; import { observerViews, parseObservers, @@ -293,28 +294,52 @@ describe("ColorDistance", () => { }); describe("ColorGenerator", () => { - test("produces a substantial candidate set", () => { - expect(generateCandidateColors().length).toBeGreaterThan(500); + test("is deterministic for a given index", () => { + expect(sequenceColor(17).toHex()).toBe(sequenceColor(17).toHex()); + expect(sequenceColor(0).toHex()).not.toBe(sequenceColor(1).toHex()); }); - test("contains no duplicate colours", () => { - const colors = generateCandidateColors(); - const hexes = new Set(colors.map((c) => c.toHex())); - expect(hexes.size).toBe(colors.length); + test("avoids near-black and near-white fills", () => { + for (let i = 0; i < 300; i++) { + const lightness = sequenceColor(i).toLch().l; + expect(lightness).toBeGreaterThan(20); + expect(lightness).toBeLessThan(95); + } }); - test("is deterministic across calls", () => { - const a = generateCandidateColors().map((c) => c.toHex()); - const b = generateCandidateColors().map((c) => c.toHex()); - expect(a).toEqual(b); + test("spreads every prefix, not just the whole sequence", () => { + // The point of a low-discrepancy sequence: the first N terms are already + // well distributed for any N, so the allocator can take the first + // acceptable candidate instead of searching a large pool. + for (const count of [8, 16, 32]) { + const colors = Array.from({ length: count }, (_, i) => sequenceColor(i)); + let worst = Infinity; + for (let i = 0; i < colors.length; i++) { + for (let j = i + 1; j < colors.length; j++) { + const d = deltaE2000(colors[i].toLab(), colors[j].toLab()); + if (d < worst) worst = d; + } + } + // Measured worst across these prefixes is ~4.1. The bound is deliberately + // looser: the sequence only has to be a good starting point, since the + // allocator still checks every candidate against the distinctness floor. + expect(worst).toBeGreaterThan(3); + } }); - test("avoids near-black and near-white fills", () => { - for (const color of generateCandidateColors()) { - const lightness = color.toLch().l; - expect(lightness).toBeGreaterThan(20); - expect(lightness).toBeLessThan(95); + test("generated colours are never already in play", () => { + // Sequence colours are rounded to 8-bit sRGB, so a long run does repeat a + // few values (497 distinct in 500). Uniqueness is the registry's job, not + // the sequence's — this is the guarantee players actually depend on. + const registry = new ColorRegistry(["normal"], 5); + const seen = new Set(); + for (let i = 0; i < 200; i++) { + const candidate = registry.generate(); + expect(seen.has(candidate.color.toHex())).toBe(false); + seen.add(candidate.color.toHex()); + registry.commit(candidate); } + expect(seen.size).toBe(200); }); }); @@ -383,21 +408,16 @@ describe("ColorAllocator distinctness guarantees", () => { ); }); - test("recycle policy reuses the palette instead of generating", () => { + test("shared policy stays inside the palette", () => { const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; - const allocator = new ColorAllocator(pool, [], { - onExhausted: "recycle", - }); - const assigned = Array.from({ length: 6 }, (_, i) => - allocator.assignColor(`bot_${i}`), - ); + const allocator = new ColorAllocator(pool, [], { policy: "shared" }); const palette = new Set(pool.map((c) => c.toHex())); - for (const color of assigned) { - expect(palette.has(color.toHex())).toBe(true); + for (let i = 0; i < 20; i++) { + expect(palette.has(allocator.assignColor(`bot_${i}`).toHex())).toBe(true); } }); - test("generate policy leaves the palette once it is exhausted", () => { + test("distinct policy leaves the palette once it is exhausted", () => { const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; const allocator = new ColorAllocator(pool, [], {}); const assigned = Array.from({ length: 6 }, (_, i) => @@ -407,6 +427,73 @@ describe("ColorAllocator distinctness guarantees", () => { }); }); +describe("cross-pool distinctness", () => { + const observers = ["normal", "deutan", "protan"] as const; + const separation = (a: Colord, b: Colord) => { + let worst = Infinity; + for (const observer of observers) { + const d = deltaE2000( + simulate(a, observer).toLab(), + simulate(b, observer).toLab(), + ); + if (d < worst) worst = d; + } + return worst; + }; + + test("a shared registry keeps humans and nations apart", () => { + // Without a shared registry these pools allocate blind to each other, and + // a nation can land within 0.61 of a human — far below the ~2.3 + // just-noticeable threshold. On the map they are indistinguishable. + const registry = new ColorRegistry([...observers], 5); + const humans = new ColorAllocator( + defaultTheme.humanColors.map((c) => colord(c)), + defaultTheme.fallbackColors.map((c) => colord(c)), + { registry }, + ); + const nations = new ColorAllocator( + defaultTheme.nationColors.map((c) => colord(c)), + [], + { registry }, + ); + + const humanColors = Array.from({ length: 8 }, (_, i) => + humans.assignColor(`human_${i}`), + ); + const nationColors = Array.from({ length: 72 }, (_, i) => + nations.assignColor(`nation_${i}`), + ); + + let worst = Infinity; + for (const human of humanColors) { + for (const nation of nationColors) { + const d = separation(human, nation); + if (d < worst) worst = d; + } + } + expect(worst).toBeGreaterThan(2.3); + }); + + test("a shared registry never issues one colour to two players", () => { + const registry = new ColorRegistry([...observers], 5); + const humans = new ColorAllocator( + defaultTheme.humanColors.map((c) => colord(c)), + defaultTheme.fallbackColors.map((c) => colord(c)), + { registry }, + ); + const nations = new ColorAllocator( + defaultTheme.nationColors.map((c) => colord(c)), + [], + { registry }, + ); + const all = [ + ...Array.from({ length: 8 }, (_, i) => humans.assignColor(`h${i}`)), + ...Array.from({ length: 72 }, (_, i) => nations.assignColor(`n${i}`)), + ]; + expect(new Set(all.map((c) => c.toHex())).size).toBe(80); + }); +}); + describe("theme colour settings", () => { test("both themes declare observers and a distinctness floor", () => { for (const theme of [defaultTheme, colorblindThemeJson]) { From ad30b15bcb57ae573f58b90d50b867b878f73916 Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:31:48 -0400 Subject: [PATCH 09/10] Keep synthesised colours in character with the palette they extend Each player type's palette has its own look, and that look carries meaning: humans are vivid (mean chroma 53), nations noticeably more restrained (33), bots nearly grey (17). Players read those differences without being told which type they are looking at. Generation swept one fixed region of LCH regardless of who it was serving. Because most nation colours in a large game are synthesised, nations drifted 11 points darker and 15 points more saturated, and the human-to-nation chroma gap collapsed from 20 to 8 - nations started looking like players. Each allocator now derives its own region from the palette it extends, so a synthesised nation colour stays a nation colour. The gap holds at 19. The cost is separation: nations sit at 2.4-2.5 rather than 3.4, still above the just-noticeable threshold but with less headroom, since the nation palette occupies a narrow band by design. --- src/client/theme/ColorAllocator.ts | 68 ++++++++++++++++++++++- src/client/theme/ColorGenerator.ts | 89 ++++++++++++++++++++++++------ src/client/theme/ColorRegistry.ts | 58 ------------------- tests/Colors.test.ts | 66 ++++++++++++++-------- 4 files changed, 182 insertions(+), 99 deletions(-) diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 8852301972..00182e793e 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -3,9 +3,23 @@ import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import { PseudoRandom } from "../../core/PseudoRandom"; import { simpleHash } from "../../core/Util"; +import { + ColorEnvelope, + paletteEnvelope, + sequenceColor, +} from "./ColorGenerator"; import { Candidate, ColorRegistry } from "./ColorRegistry"; import { Observer } from "./ColorVision"; +/** + * How many synthesised colours to keep available at a time. + * + * Sized against the largest public lobby (125) plus nations, with room to + * discard crowded candidates. Scored once on creation and maintained + * incrementally afterwards, so widening this costs a scan per allocation. + */ +const GENERATED_POOL_SIZE = 2048; + extend([lchPlugin]); extend([labPlugin]); @@ -56,6 +70,13 @@ export class ColorAllocator { private readonly policy: AllocationPolicy; private readonly primary: Candidate[]; private readonly fallback: Candidate[]; + /** + * The LCH region this allocator's synthesised colours are drawn from, taken + * from its primary palette so they stay in character with it. + */ + private readonly envelope: ColorEnvelope; + /** Synthesised colours, built on first need so ordinary lobbies never pay. */ + private generated: Candidate[] | null = null; private assigned = new Map(); constructor( @@ -70,6 +91,7 @@ export class ColorAllocator { options.distinctnessFloor ?? 0, ); this.policy = options.policy ?? "distinct"; + this.envelope = paletteEnvelope(colors); this.primary = colors.map((color) => this.registry.candidate(color)); this.fallback = fallback.map((color) => this.registry.candidate(color)); if (this.policy === "distinct") { @@ -126,13 +148,55 @@ export class ColorAllocator { } const curated = bestUnused(this.primary) ?? bestUnused(this.fallback); - const generated = this.registry.generate(); + const generated = this.generate(); if (curated !== null && curated.nearest >= generated.nearest) { return curated; } return generated; } + /** + * The roomiest synthesised colour available, drawn from this allocator's own + * envelope so it stays in character with its palette. + * + * The pool is built once, on first need, and registered with the registry so + * its scores are maintained incrementally — the same colour is never + * re-scored against the same neighbour twice. + */ + private generate(): Candidate { + if (this.generated === null) { + this.generated = []; + for (let index = 0; index < GENERATED_POOL_SIZE; index++) { + this.generated.push( + this.registry.candidate(sequenceColor(index, this.envelope)), + ); + } + this.registry.registerPool(this.generated); + } + let best: Candidate | null = null; + for (const candidate of this.generated) { + // Never hand back a colour already in play: that would put two players in + // the same colour, the defect this exists to prevent. Sequence colours + // round to 8-bit sRGB, so exact repeats are possible. + if (candidate.used || candidate.nearest <= 0) { + continue; + } + if (best === null || candidate.nearest > best.nearest) { + best = candidate; + } + } + if (best !== null) { + return best; + } + // Pool wholly used or wholly crowded — extend it and take the newcomer. + const grown = this.registry.candidate( + sequenceColor(this.generated.length, this.envelope), + ); + grown.nearest = this.registry.distanceToInPlay(grown.labs); + this.generated.push(grown); + return grown; + } + /** * First colour of a game: chosen pseudo-randomly from the primary palette so * a given id lands on the same colour every time. @@ -144,7 +208,7 @@ export class ColorAllocator { ? available : this.fallback.filter((candidate) => !candidate.used); if (source.length === 0) { - return this.registry.generate(); + return this.generate(); } const random = new PseudoRandom(simpleHash(id)); return source[random.nextInt(0, source.length)]; diff --git a/src/client/theme/ColorGenerator.ts b/src/client/theme/ColorGenerator.ts index f345b5b3ef..9400f78559 100644 --- a/src/client/theme/ColorGenerator.ts +++ b/src/client/theme/ColorGenerator.ts @@ -4,16 +4,68 @@ import lchPlugin from "colord/plugins/lch"; extend([lchPlugin]); /** - * Bounds of the LCH volume colours are drawn from. + * The region of LCH space a palette occupies. * - * Lightness stops short of both ends: near-black and near-white territory fills - * read poorly against terrain and against the border colours derived from them. - * Chroma stays above 25 so results don't collapse into washed-out greys. + * Each player-type palette has its own character, and that character carries + * meaning: human colours are bright and saturated, nation colours noticeably + * more restrained, bot colours nearly grey. A player reads those differences + * without being told. Synthesised colours therefore have to be drawn from the + * same region as the palette they extend, or a nation starts looking like a + * player. */ -const LIGHTNESS_MIN = 35; -const LIGHTNESS_RANGE = 45; -const CHROMA_MIN = 25; -const CHROMA_RANGE = 85; +export interface ColorEnvelope { + lightnessMin: number; + lightnessMax: number; + chromaMin: number; + chromaMax: number; +} + +/** + * Absolute bounds applied on top of any palette's own range. Near-black and + * near-white fills read poorly against terrain and against the borders derived + * from them, whatever the palette does. + */ +const LIGHTNESS_FLOOR = 30; +const LIGHTNESS_CEILING = 88; + +/** Ignore this proportion at each end, so one outlier cannot stretch a range. */ +const TRIM = 0.05; + +const clamp = (value: number, low: number, high: number) => + Math.min(high, Math.max(low, value)); + +function trimmedRange(values: number[]): [number, number] { + const sorted = [...values].sort((a, b) => a - b); + const drop = Math.floor(sorted.length * TRIM); + return [sorted[drop], sorted[Math.max(drop, sorted.length - 1 - drop)]]; +} + +/** + * The LCH region a palette occupies, used to keep synthesised colours in + * character with it. Falls back to a broad range for palettes too small to + * describe a region. + */ +export function paletteEnvelope(colors: Colord[]): ColorEnvelope { + if (colors.length < 4) { + return { + lightnessMin: 35, + lightnessMax: 80, + chromaMin: 25, + chromaMax: 110, + }; + } + const lch = colors.map((color) => color.toLch()); + const [lLow, lHigh] = trimmedRange(lch.map((x) => x.l)); + const [cLow, cHigh] = trimmedRange(lch.map((x) => x.c)); + return { + lightnessMin: clamp(lLow, LIGHTNESS_FLOOR, LIGHTNESS_CEILING), + lightnessMax: clamp(lHigh, LIGHTNESS_FLOOR, LIGHTNESS_CEILING), + // Never collapse to a single value: a palette of near-identical chroma + // still needs somewhere to put a new colour. + chromaMin: Math.max(0, cLow), + chromaMax: Math.max(cHigh, cLow + 12), + }; +} /** * Plastic number, the 3-dimensional analogue of the golden ratio. Successive @@ -28,20 +80,25 @@ const ALPHA_CHROMA = 1 / (PLASTIC * PLASTIC * PLASTIC); const fraction = (value: number): number => value - Math.floor(value); /** - * The `index`-th colour of a deterministic sweep through LCH space. + * The `index`-th colour of a deterministic sweep through `envelope`. * * The sequence is chosen so that any prefix is already well spread: colour 5 is * far from colours 0–4 without anyone having searched for it. A pool drawn from - * it therefore covers the usable volume with far fewer entries than a regular - * grid sweep of comparable quality, which is what keeps the registry's scan - * cheap enough to run on every allocation. + * it therefore covers the region with far fewer entries than a regular grid of + * comparable quality, which is what keeps the registry's scan cheap enough to + * run on every allocation. * - * Deterministic and stateless: the same index always yields the same colour. + * Deterministic and stateless: the same index and envelope always yield the + * same colour. */ -export function sequenceColor(index: number): Colord { +export function sequenceColor(index: number, envelope: ColorEnvelope): Colord { + const lightnessRange = envelope.lightnessMax - envelope.lightnessMin; + const chromaRange = envelope.chromaMax - envelope.chromaMin; const h = fraction(0.5 + ALPHA_HUE * index) * 360; const l = - LIGHTNESS_MIN + fraction(0.5 + ALPHA_LIGHTNESS * index) * LIGHTNESS_RANGE; - const c = CHROMA_MIN + fraction(0.5 + ALPHA_CHROMA * index) * CHROMA_RANGE; + envelope.lightnessMin + + fraction(0.5 + ALPHA_LIGHTNESS * index) * lightnessRange; + const c = + envelope.chromaMin + fraction(0.5 + ALPHA_CHROMA * index) * chromaRange; return colord({ l, c, h }); } diff --git a/src/client/theme/ColorRegistry.ts b/src/client/theme/ColorRegistry.ts index cdfe56cc8b..7c0675e5c9 100644 --- a/src/client/theme/ColorRegistry.ts +++ b/src/client/theme/ColorRegistry.ts @@ -1,6 +1,5 @@ import { Colord, LabaColor } from "colord"; import { deltaE2000 } from "./ColorDistance"; -import { sequenceColor } from "./ColorGenerator"; import { Observer, observerViews } from "./ColorVision"; /** A colour under consideration, with its cached distance to what's in play. */ @@ -17,17 +16,6 @@ export interface Candidate { used: boolean; } -/** - * How many synthesised colours to keep available at a time. - * - * Sized against the largest public lobby (125) plus nations, with room to - * discard crowded candidates. Scored once on creation and maintained - * incrementally afterwards, so widening this costs a scan per allocation — it - * is not free, and 2048 already gives worst-case separation within ~0.1 of an - * exhaustive 6125-point sweep at a quarter of the cost. - */ -const GENERATED_POOL_SIZE = 2048; - /** Worst-case ΔE2000 between two colours across every observer. */ export function distance(first: LabaColor[], second: LabaColor[]): number { let worst = Infinity; @@ -56,9 +44,6 @@ export function distance(first: LabaColor[], second: LabaColor[]): number { export class ColorRegistry { private readonly inPlay: Candidate[] = []; private readonly pools: Candidate[][] = []; - /** Synthesised colours, built on first need so ordinary lobbies never pay. */ - private generated: Candidate[] | null = null; - private cursor = 0; constructor( readonly observers: Observer[], @@ -133,49 +118,6 @@ export class ColorRegistry { return worst; } - /** - * The roomiest synthesised colour available. - * - * The pool is built once, on the first call, and then maintained - * incrementally like any other pool — the same colour is never re-scored - * against the same neighbour twice. Re-deriving a fresh batch per allocation - * was measurably worse on both axes: a 768-colour batch each time cost 6.3s - * across a full game and still separated a 125-player lobby only to 3.38, - * against 0.8s and 3.5 for a pool scored once. - */ - generate(): Candidate { - if (this.generated === null) { - this.generated = []; - for (let i = 0; i < GENERATED_POOL_SIZE; i++) { - this.generated.push(this.candidate(sequenceColor(this.cursor++))); - } - this.registerPool(this.generated); - } - let best: Candidate | null = null; - for (const candidate of this.generated) { - if (candidate.used) { - continue; - } - // Never hand back a colour already in play: that would put two players - // in the same colour, the defect this exists to prevent. Sequence - // colours round to 8-bit sRGB, so exact repeats are possible. - if (candidate.nearest <= 0) { - continue; - } - if (best === null || candidate.nearest > best.nearest) { - best = candidate; - } - } - if (best !== null) { - return best; - } - // Pool exhausted or wholly crowded — extend it and take the roomiest. - const grown = this.candidate(sequenceColor(this.cursor++)); - grown.nearest = this.distanceToInPlay(grown.labs); - this.generated.push(grown); - return grown; - } - /** * Treat colours as in play without assigning them to anyone. * diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 530964280f..202cd9911a 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -7,7 +7,10 @@ import { selectDistinctColorIndex, } from "../src/client/theme/ColorAllocator"; import { deltaE2000 } from "../src/client/theme/ColorDistance"; -import { sequenceColor } from "../src/client/theme/ColorGenerator"; +import { + paletteEnvelope, + sequenceColor, +} from "../src/client/theme/ColorGenerator"; import { ColorRegistry } from "../src/client/theme/ColorRegistry"; import { observerViews, @@ -294,14 +297,20 @@ describe("ColorDistance", () => { }); describe("ColorGenerator", () => { - test("is deterministic for a given index", () => { - expect(sequenceColor(17).toHex()).toBe(sequenceColor(17).toHex()); - expect(sequenceColor(0).toHex()).not.toBe(sequenceColor(1).toHex()); + const wide = paletteEnvelope(defaultTheme.humanColors.map((c) => colord(c))); + + test("is deterministic for a given index and envelope", () => { + expect(sequenceColor(17, wide).toHex()).toBe( + sequenceColor(17, wide).toHex(), + ); + expect(sequenceColor(0, wide).toHex()).not.toBe( + sequenceColor(1, wide).toHex(), + ); }); test("avoids near-black and near-white fills", () => { for (let i = 0; i < 300; i++) { - const lightness = sequenceColor(i).toLch().l; + const lightness = sequenceColor(i, wide).toLch().l; expect(lightness).toBeGreaterThan(20); expect(lightness).toBeLessThan(95); } @@ -309,10 +318,11 @@ describe("ColorGenerator", () => { test("spreads every prefix, not just the whole sequence", () => { // The point of a low-discrepancy sequence: the first N terms are already - // well distributed for any N, so the allocator can take the first - // acceptable candidate instead of searching a large pool. + // well distributed for any N, so a modest pool covers the region. for (const count of [8, 16, 32]) { - const colors = Array.from({ length: count }, (_, i) => sequenceColor(i)); + const colors = Array.from({ length: count }, (_, i) => + sequenceColor(i, wide), + ); let worst = Infinity; for (let i = 0; i < colors.length; i++) { for (let j = i + 1; j < colors.length; j++) { @@ -320,26 +330,36 @@ describe("ColorGenerator", () => { if (d < worst) worst = d; } } - // Measured worst across these prefixes is ~4.1. The bound is deliberately - // looser: the sequence only has to be a good starting point, since the - // allocator still checks every candidate against the distinctness floor. + // The bound is deliberately loose: the sequence only has to be a good + // starting point, since the allocator still checks every candidate + // against the distinctness floor. expect(worst).toBeGreaterThan(3); } }); - test("generated colours are never already in play", () => { - // Sequence colours are rounded to 8-bit sRGB, so a long run does repeat a - // few values (497 distinct in 500). Uniqueness is the registry's job, not - // the sequence's — this is the guarantee players actually depend on. - const registry = new ColorRegistry(["normal"], 5); - const seen = new Set(); - for (let i = 0; i < 200; i++) { - const candidate = registry.generate(); - expect(seen.has(candidate.color.toHex())).toBe(false); - seen.add(candidate.color.toHex()); - registry.commit(candidate); + test("keeps synthesised colours inside their palette's character", () => { + // Each player type's palette has its own look — humans vivid, nations + // restrained, bots nearly grey — and that difference is information. A + // synthesised nation colour must not arrive looking like a human's. + const nationEnvelope = paletteEnvelope( + defaultTheme.nationColors.map((c) => colord(c)), + ); + const nationChroma = defaultTheme.nationColors.map( + (c) => colord(c).toLch().c, + ); + const ceiling = Math.max(...nationChroma); + for (let i = 0; i < 400; i++) { + const lch = sequenceColor(i, nationEnvelope).toLch(); + expect(lch.c).toBeLessThanOrEqual(ceiling + 1); } - expect(seen.size).toBe(200); + }); + + test("a muted palette yields a muted envelope", () => { + const bots = paletteEnvelope(defaultTheme.botColors.map((c) => colord(c))); + const humans = paletteEnvelope( + defaultTheme.humanColors.map((c) => colord(c)), + ); + expect(bots.chromaMax).toBeLessThan(humans.chromaMax); }); }); From 97a4a085b4082d38fc1811f59707956f6748c143 Mon Sep 17 00:00:00 2001 From: David Youngblood <70269796+thedavidyoungblood@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:48:08 -0400 Subject: [PATCH 10/10] Weigh both palettes when no colour clears the distinctness floor select() fell through to bestUnused(primary) ?? bestUnused(fallback), so the fallback palette was never considered while the primary held any unused colour. The allocator could then synthesise a colour no better than one the fallback already offered. Worst-case separation in a 125-player lobby improves from 3.18 to 3.40 (default theme) and 2.67 to 2.95 (colourblind). Also from review: merge the two colord extend calls, extract the pool-refresh loop shared by commit() and reserve(), move the CIEDE2000 doc block onto the function it describes, give the CVD matrices a fixed-length tuple type, and document the equal-observer invariant on distance(). Tests now cover the roomiest-choice rule and run the theme wiring checks over both palettes. --- src/client/theme/ColorAllocator.ts | 26 +++++++++-- src/client/theme/ColorDistance.ts | 22 ++++----- src/client/theme/ColorRegistry.ts | 36 ++++++++------- src/client/theme/ColorVision.ts | 15 ++++++- tests/Colors.test.ts | 71 ++++++++++++++++++++---------- 5 files changed, 115 insertions(+), 55 deletions(-) diff --git a/src/client/theme/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts index 00182e793e..8af7443261 100644 --- a/src/client/theme/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -20,8 +20,7 @@ import { Observer } from "./ColorVision"; */ const GENERATED_POOL_SIZE = 2048; -extend([lchPlugin]); -extend([labPlugin]); +extend([lchPlugin, labPlugin]); /** How a pool of players competes for colours. */ export type AllocationPolicy = "distinct" | "shared"; @@ -147,7 +146,14 @@ export class ColorAllocator { } } - const curated = bestUnused(this.primary) ?? bestUnused(this.fallback); + // Nothing clears the floor, so take the roomiest colour available. Both + // palettes have to be weighed here: picking the primary's best whenever the + // primary still holds anything would ignore a better fallback colour and + // could synthesise one that is no improvement on it. + const curated = roomiest( + bestUnused(this.primary), + bestUnused(this.fallback), + ); const generated = this.generate(); if (curated !== null && curated.nearest >= generated.nearest) { return curated; @@ -215,6 +221,20 @@ export class ColorAllocator { } } +/** Whichever candidate sits furthest from the colours already in play. */ +function roomiest( + first: Candidate | null, + second: Candidate | null, +): Candidate | null { + if (first === null) { + return second; + } + if (second === null) { + return first; + } + return first.nearest >= second.nearest ? first : second; +} + /** Highest-scoring unused candidate in a pool, or null if none remain. */ function bestUnused(pool: Candidate[]): Candidate | null { let best: Candidate | null = null; diff --git a/src/client/theme/ColorDistance.ts b/src/client/theme/ColorDistance.ts index 9ac8c50475..ab825798dc 100644 --- a/src/client/theme/ColorDistance.ts +++ b/src/client/theme/ColorDistance.ts @@ -1,5 +1,16 @@ import { LabaColor } from "colord"; +const DEG = 180 / Math.PI; +const RAD = Math.PI / 180; +/** 25^7, the constant the chroma-weighting terms are scaled against. */ +const POW_25_7 = 6103515625; + +/** `value ** 7` by multiplication — this runs millions of times per lobby. */ +function pow7(value: number): number { + const cube = value * value * value; + return cube * cube * value; +} + /** * CIEDE2000 colour difference between two LAB colours, on the usual 0–100 * scale. @@ -15,17 +26,6 @@ import { LabaColor } from "colord"; * disagrees with the reference formula by up to ~2.5 on some near-neutral * pairs, so this is also the more accurate of the two. */ -const DEG = 180 / Math.PI; -const RAD = Math.PI / 180; -/** 25^7, the constant the chroma-weighting terms are scaled against. */ -const POW_25_7 = 6103515625; - -/** `value ** 7` by multiplication — this runs millions of times per lobby. */ -function pow7(value: number): number { - const cube = value * value * value; - return cube * cube * value; -} - export function deltaE2000(first: LabaColor, second: LabaColor): number { const rad = RAD; const deg = DEG; diff --git a/src/client/theme/ColorRegistry.ts b/src/client/theme/ColorRegistry.ts index 7c0675e5c9..6164054f11 100644 --- a/src/client/theme/ColorRegistry.ts +++ b/src/client/theme/ColorRegistry.ts @@ -16,7 +16,14 @@ export interface Candidate { used: boolean; } -/** Worst-case ΔE2000 between two colours across every observer. */ +/** + * Worst-case ΔE2000 between two colours across every observer. + * + * Both arrays must come from the same observer list, in the same order — they + * are compared index by index. "Worst" here means the *smallest* separation, + * which is the right reading for a distinctness metric: a pair is only as + * distinguishable as the observer who can least tell them apart. + */ export function distance(first: LabaColor[], second: LabaColor[]): number { let worst = Infinity; for (let i = 0; i < first.length; i++) { @@ -79,16 +86,14 @@ export class ColorRegistry { } } - /** Put a colour into play and refresh every tracked pool against it. */ - commit(candidate: Candidate): void { - candidate.used = true; - this.inPlay.push(candidate); + /** Lower every tracked pool's score against one colour now in play. */ + private refreshPools(against: Candidate): void { for (const pool of this.pools) { for (const other of pool) { if (other.used) { continue; } - const value = distance(other.labs, candidate.labs); + const value = distance(other.labs, against.labs); if (value < other.nearest) { other.nearest = value; } @@ -96,6 +101,13 @@ export class ColorRegistry { } } + /** Put a colour into play and refresh every tracked pool against it. */ + commit(candidate: Candidate): void { + candidate.used = true; + this.inPlay.push(candidate); + this.refreshPools(candidate); + } + /** * Distance from a colour to the nearest colour already in play. * @@ -131,17 +143,7 @@ export class ColorRegistry { this.inPlay.push(reserved); // Refresh pools here too, so reserving works whatever order the // allocators happen to be constructed in. - for (const pool of this.pools) { - for (const other of pool) { - if (other.used) { - continue; - } - const value = distance(other.labs, reserved.labs); - if (value < other.nearest) { - other.nearest = value; - } - } - } + this.refreshPools(reserved); } } } diff --git a/src/client/theme/ColorVision.ts b/src/client/theme/ColorVision.ts index f4c098bf82..bdbe5ef7a5 100644 --- a/src/client/theme/ColorVision.ts +++ b/src/client/theme/ColorVision.ts @@ -18,7 +18,20 @@ const OBSERVER_NAMES: readonly string[] = [ * Simulation of Color Vision Deficiency", severity 1.0. Row-major 3x3, applied * to linear-light RGB — not to gamma-encoded sRGB. */ -const CVD_MATRICES: Record, readonly number[]> = { +/** Row-major 3x3. Fixed length so a dropped coefficient fails to compile. */ +type Matrix3x3 = readonly [ + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; + +const CVD_MATRICES: Record, Matrix3x3> = { protan: [ 0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998, diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 202cd9911a..e93fc465bb 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -1,6 +1,7 @@ import { colord, Colord } from "colord"; import colorblindThemeJson from "../src/client/render/gl/colorblind-theme.json"; import defaultTheme from "../src/client/render/gl/default-theme.json"; +import { PALETTE_NAMES } from "../src/client/render/gl/GraphicsOverrides"; import { createThemeSettings } from "../src/client/render/gl/RenderSettings"; import { ColorAllocator, @@ -428,6 +429,28 @@ describe("ColorAllocator distinctness guarantees", () => { ); }); + test("never settles for less separation than a palette could give", () => { + // Five near-identical reds, one distant blue in the fallback, and a floor + // neither palette can reach — so the allocator drops through to "take the + // roomiest available". Whatever it picks must be at least as far from the + // first colour as the fallback would have been; choosing the primary's best + // just because the primary is non-empty would fail this. + const primary = ["#ff0000", "#fb0202", "#f70404", "#f30606", "#ef0808"].map( + (c) => colord(c), + ); + const fallback = [colord("#0000ff")]; + const allocator = new ColorAllocator(primary, fallback, { + observers: ["normal"], + distinctnessFloor: 95, + }); + const first = allocator.assignColor("player_0"); + const second = allocator.assignColor("player_1"); + const fallbackWouldGive = deltaE2000(first.toLab(), fallback[0].toLab()); + expect(deltaE2000(first.toLab(), second.toLab())).toBeGreaterThanOrEqual( + fallbackWouldGive, + ); + }); + test("shared policy stays inside the palette", () => { const pool = [colord("#ff0000"), colord("#00ff00"), colord("#0000ff")]; const allocator = new ColorAllocator(pool, [], { policy: "shared" }); @@ -550,29 +573,31 @@ describe("SettingsTheme allocator wiring", () => { type: () => type, }) as unknown as PlayerView; - test("bots reuse their palette rather than generating new colours", () => { - const theme = new SettingsTheme(createThemeSettings("default")); - const palette = new Set( - createThemeSettings("default").botColors.map((c) => colord(c).toHex()), - ); - for (let i = 0; i < 120; i++) { - const color = theme.territoryColor( - playerStub(`bot_${i}`, PlayerType.Bot), + for (const name of PALETTE_NAMES) { + test(`${name}: bots reuse their palette rather than generating new colours`, () => { + const theme = new SettingsTheme(createThemeSettings(name)); + const palette = new Set( + createThemeSettings(name).botColors.map((c) => colord(c).toHex()), ); - expect(palette.has(color.toHex())).toBe(true); - } - }); + for (let i = 0; i < 120; i++) { + const color = theme.territoryColor( + playerStub(`bot_${i}`, PlayerType.Bot), + ); + expect(palette.has(color.toHex())).toBe(true); + } + }); - test("humans in a full lobby all receive different colours", () => { - const theme = new SettingsTheme(createThemeSettings("default")); - const seen = new Set(); - for (let i = 0; i < 125; i++) { - seen.add( - theme - .territoryColor(playerStub(`human_${i}`, PlayerType.Human)) - .toHex(), - ); - } - expect(seen.size).toBe(125); - }); + test(`${name}: humans in a full lobby all receive different colours`, () => { + const theme = new SettingsTheme(createThemeSettings(name)); + const seen = new Set(); + for (let i = 0; i < 125; i++) { + seen.add( + theme + .territoryColor(playerStub(`human_${i}`, PlayerType.Human)) + .toHex(), + ); + } + expect(seen.size).toBe(125); + }); + } });