From f76f1335892ee96c8d2ec15919b736c622661a1e Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 13 Jun 2026 19:07:17 -0700 Subject: [PATCH] Structure level numbers: classic bitmap font by default + graphics toggle (#4264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Structure **level numbers** now render in the **`round_6x6_modified`** bitmap font by default (matching the old PIXI-based `StructureLayer` / `v31`), with a graphics setting to switch back to the smooth `overpass-bold` MSDF font. Two commits: 1. **Default to the classic bitmap font** — `StructureLevelPass` drew level digits from the `overpass-bold` MSDF atlas (the one `NamePass` uses for player names); switch the default to the `round_6x6_modified` pixel font (white digits with a baked-in dark outline). 2. **Add a runtime toggle** — load both fonts and switch between them live via a new `Classic level numbers` graphics setting. ## How - `StructureLevelPass` loads both atlases up front and selects one per frame from `settings.structureLevel.classicFont`, re-laying-out the digits when the toggle flips (digit advances differ between the fonts). The fragment shader is a single program with a `uClassic` branch: direct bitmap sample (white fill + baked outline) vs. MSDF median + synthesized outline. - New override `structure.classicNumbers` in `GraphicsOverrides` (default `true` = classic), applied onto `settings.structureLevel.classicFont` in `applyGraphicsOverrides` — so it switches live, like the existing colorblind/classic-icons toggles. - `GraphicsSettingsModal` gets a `Classic level numbers` toggle next to `Classic icons` (with `en.json` strings). ## Testing - `tsc --noEmit`, ESLint, Prettier, and `npm run build-prod` all pass. - Ran the game headless, built/upgraded cities to level 2–3, and confirmed: the classic toggle renders the pixel font, flipping it renders the smooth MSDF font, and flipping back restores the pixel font — switching live with no shader errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 --- resources/lang/en.json | 2 + .../hud/layers/GraphicsSettingsModal.ts | 30 +++ src/client/render/gl/GraphicsOverrides.ts | 1 + src/client/render/gl/RenderOverrides.ts | 4 + src/client/render/gl/RenderSettings.ts | 3 + .../render/gl/passes/StructureLevelPass.ts | 194 +++++++++++++----- src/client/render/gl/render-settings.json | 3 +- .../structure-level/structure-level.frag.glsl | 44 ++-- tests/GraphicsOverrides.test.ts | 33 +++ 9 files changed, 245 insertions(+), 69 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index f4b489432..28cf8fd05 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -516,6 +516,8 @@ "black": "Black", "classic_icons_desc": "Lighter outline with near-black interior", "classic_icons_label": "Classic icons", + "classic_numbers_desc": "Pixel font for structure level numbers (off uses the smooth font)", + "classic_numbers_label": "Classic level numbers", "colored": "Colored", "colored_names_desc": "Show player names in their player color or in black", "colored_names_label": "Name color", diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 39d12890b..4719bfc42 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -328,6 +328,16 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchStructure({ classicIcons: !this.currentClassicIcons() }); } + private currentClassicNumbers(): boolean { + return ( + this.userSettings.graphicsOverrides().structure?.classicNumbers ?? true + ); + } + + private onToggleClassicNumbers() { + this.patchStructure({ classicNumbers: !this.currentClassicNumbers() }); + } + private patchPassEnabled(patch: Partial) { const current = this.userSettings.graphicsOverrides(); this.userSettings.setGraphicsOverrides({ @@ -423,6 +433,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const hoverGlowAlpha = this.currentHoverGlowAlpha(); const namesColored = !this.currentDarkNames(); const classicIcons = this.currentClassicIcons(); + const classicNumbers = this.currentClassicNumbers(); const highlightFill = this.currentHighlightFill(); const highlightBrighten = this.currentHighlightBrighten(); const highlightThicken = this.currentHighlightThicken(); @@ -637,6 +648,25 @@ export class GraphicsSettingsModal extends LitElement implements Controller { + +
diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index 916ed458a..6fbe86bd5 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -15,6 +15,7 @@ export const GraphicsOverridesSchema = z structure: z .object({ classicIcons: z.boolean(), + classicNumbers: z.boolean(), }) .partial(), mapOverlay: z diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index 44d51cdf7..697ec8c55 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -36,6 +36,10 @@ export function applyGraphicsOverrides( settings.structure.iconDarken = 0.3; settings.structure.iconAlpha = 0.9; } + + if (overrides.structure?.classicNumbers !== undefined) { + settings.structureLevel.classicFont = overrides.structure.classicNumbers; + } if (overrides.mapOverlay?.highlightFillBrighten !== undefined) { settings.mapOverlay.highlightFillBrighten = overrides.mapOverlay.highlightFillBrighten; diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 9be2c64fb..05f77cc5d 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -190,8 +190,11 @@ export interface RenderSettings { }; structureLevel: { scale: number; + /** MSDF outline width in px; unused by the classic bitmap font. */ outlineWidth: number; offsetY: number; + /** true = round_6x6_modified bitmap font, false = overpass-bold MSDF. */ + classicFont: boolean; }; bar: { healthBarW: number; diff --git a/src/client/render/gl/passes/StructureLevelPass.ts b/src/client/render/gl/passes/StructureLevelPass.ts index 0964181df..d43a6e3c2 100644 --- a/src/client/render/gl/passes/StructureLevelPass.ts +++ b/src/client/render/gl/passes/StructureLevelPass.ts @@ -1,8 +1,13 @@ /** - * StructureLevelPass — MSDF-rendered level numbers above structures. + * StructureLevelPass — level numbers above structures. * - * Renders level digits for structures with level > 1 using the same MSDF - * atlas and glyph infrastructure as NamePass. One instanced draw call per frame. + * Renders level digits for structures with level > 1 in one of two fonts, + * switchable at runtime via settings.structureLevel.classicFont: + * - classic: the round_6x6_modified bitmap font (white digits with a + * baked-in dark outline), matching the v31 StructureLayer look. + * - new: the overpass-bold MSDF font shared with NamePass. + * Both fonts are loaded up front; draw() binds the active one. One instanced + * draw call per frame. * * Only visible when zoom > dotsThreshold (matching structure icon visibility). */ @@ -24,13 +29,15 @@ import type { GlyphTables } from "./name-pass/AtlasData"; import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData"; import { buildGlyphMetricsTex } from "./name-pass/DataTextures"; import { layoutString } from "./name-pass/TextLayout"; +import type { BMChar, ParsedAtlas } from "./name-pass/Types"; import { CHAR_RANGE, MAX_CHARS } from "./name-pass/Types"; import { assetUrl } from "src/core/AssetUrls"; import fragSrc from "../shaders/structure-level/structure-level.frag.glsl?raw"; import vertSrc from "../shaders/structure-level/structure-level.vert.glsl?raw"; -const atlasUrl = assetUrl("atlases/msdf-atlas.png"); +const classicAtlasUrl = assetUrl("fonts/round_6x6_modified.png"); +const msdfAtlasUrl = assetUrl("atlases/msdf-atlas.png"); // --------------------------------------------------------------------------- // Constants @@ -51,6 +58,51 @@ const MAX_LEVEL_CHARS = 4; const FLOATS_PER_INSTANCE = 5; // worldX, worldY, cursorX, charCode, atlasIdx const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; +// --------------------------------------------------------------------------- +// round_6x6_modified bitmap font (digits only) +// --------------------------------------------------------------------------- +// Atlas-level metrics, taken from resources/fonts/round_6x6_modified.xml. +const FONT_SIZE = 16; +const FONT_BASE = 16; +const FONT_SCALE_W = 208; +const FONT_SCALE_H = 114; + +/** + * Digit glyph metrics for round_6x6_modified. Level labels only ever contain + * digits, which sit in a uniform 16×16 grid at y=64 (x = digit·16) and share + * xadvance=14, xoffset=0, yoffset=0. See resources/fonts/round_6x6_modified.xml. + */ +function buildDigitChars(): BMChar[] { + const chars: BMChar[] = []; + for (let d = 0; d <= 9; d++) { + chars.push({ + id: 48 + d, + char: String(d), + width: 16, + height: 16, + xoffset: 0, + yoffset: 0, + xadvance: 14, + x: d * 16, + y: 64, + page: 0, + }); + } + return chars; +} + +/** Per-font GPU + CPU resources. */ +interface FontBundle { + glyph: GlyphTables; + metricsTex: WebGLTexture; + fontSize: number; + base: number; + atlasScaleH: number; + distanceRange: number; + atlasTex: WebGLTexture | null; + atlasReady: boolean; +} + // --------------------------------------------------------------------------- // StructureLevelPass // --------------------------------------------------------------------------- @@ -66,40 +118,41 @@ export class StructureLevelPass { private uDotsThreshold: WebGLUniformLocation; private uScaleFactor: WebGLUniformLocation; private uIconGrowZoom: WebGLUniformLocation; + private uFontSize: WebGLUniformLocation; + private uAtlasScaleH: WebGLUniformLocation; + private uBase: WebGLUniformLocation; private uDistRange: WebGLUniformLocation; private uOutlineWidth: WebGLUniformLocation; private uLevelScale: WebGLUniformLocation; private uLevelOffsetY: WebGLUniformLocation; private uHighlightMask: WebGLUniformLocation; private uHighlightDimAlpha: WebGLUniformLocation; + private uClassic: WebGLUniformLocation; private vao: WebGLVertexArrayObject; private instanceBuf: DynamicInstanceBuffer; private instanceCount = 0; - private glyphMetricsTex: WebGLTexture; - private atlasTex: WebGLTexture | null = null; - private atlasReady = false; - - // CPU-side glyph tables for layoutString - private glyph: GlyphTables; - private kernTable: Int8Array; + // Both fonts are loaded; draw() selects per settings.structureLevel.classicFont. + private classic: FontBundle; + private msdf: FontBundle; + private kernTable: Int8Array; // shared zero table — digits don't kern private mapW: number; // Reusable buffers for layoutString private charCodes = new Uint8Array(MAX_CHARS); private cursors = new Float32Array(MAX_CHARS); - private distanceRange: number; - private fontSize: number; - private atlasScaleH: number; - private base: number; - /** unitType string → atlas column index (0–5). */ private typeToAtlasCol = new Map(); /** Build-button hover highlight bitmask (0 = off). */ private highlightMask = 0; + // Last units uploaded + which font that layout used, so draw() can re-layout + // when the font toggles (digit advances differ between fonts). + private lastUnits: Map | null = null; + private layoutClassic: boolean | null = null; + constructor( gl: WebGL2RenderingContext, header: RendererConfig, @@ -117,14 +170,42 @@ export class StructureLevelPass { if (col >= 0) this.typeToAtlasCol.set(header.unitTypes[i], col); } - // Parse atlas data (same source as NamePass) - const atlas = parseAtlasData(); - this.glyph = buildGlyphTables(atlas.chars); this.kernTable = new Int8Array(CHAR_RANGE * CHAR_RANGE); // digits don't kern - this.distanceRange = atlas.distanceRange; - this.fontSize = atlas.fontSize; - this.atlasScaleH = atlas.scaleH; - this.base = atlas.base; + + // Classic bitmap font (round_6x6_modified) — digits only. + const classicChars = buildDigitChars(); + const classicAtlas: ParsedAtlas = { + fontSize: FONT_SIZE, + base: FONT_BASE, + scaleW: FONT_SCALE_W, + scaleH: FONT_SCALE_H, + distanceRange: 0, + chars: classicChars, + kernings: [], + }; + this.classic = { + glyph: buildGlyphTables(classicChars), + metricsTex: buildGlyphMetricsTex(gl, classicAtlas), + fontSize: classicAtlas.fontSize, + base: classicAtlas.base, + atlasScaleH: classicAtlas.scaleH, + distanceRange: classicAtlas.distanceRange, + atlasTex: null, + atlasReady: false, + }; + + // New MSDF font (overpass-bold) — same atlas/data as NamePass. + const msdfAtlas = parseAtlasData(); + this.msdf = { + glyph: buildGlyphTables(msdfAtlas.chars), + metricsTex: buildGlyphMetricsTex(gl, msdfAtlas), + fontSize: msdfAtlas.fontSize, + base: msdfAtlas.base, + atlasScaleH: msdfAtlas.scaleH, + distanceRange: msdfAtlas.distanceRange, + atlasTex: null, + atlasReady: false, + }; // Compile shaders this.program = createProgram(gl, vertSrc, fragSrc); @@ -134,18 +215,7 @@ export class StructureLevelPass { gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0); gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1); - // Static uniforms - gl.uniform1f( - gl.getUniformLocation(this.program, "uFontSize")!, - this.fontSize, - ); - gl.uniform1f( - gl.getUniformLocation(this.program, "uAtlasScaleH")!, - this.atlasScaleH, - ); - gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, this.base); - - // Dynamic uniform locations + // Uniform locations this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; this.uZoom = gl.getUniformLocation(this.program, "uZoom")!; this.uIconSize = gl.getUniformLocation(this.program, "uIconSize")!; @@ -155,6 +225,9 @@ export class StructureLevelPass { )!; this.uScaleFactor = gl.getUniformLocation(this.program, "uScaleFactor")!; this.uIconGrowZoom = gl.getUniformLocation(this.program, "uIconGrowZoom")!; + this.uFontSize = gl.getUniformLocation(this.program, "uFontSize")!; + this.uAtlasScaleH = gl.getUniformLocation(this.program, "uAtlasScaleH")!; + this.uBase = gl.getUniformLocation(this.program, "uBase")!; this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!; this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!; this.uLevelScale = gl.getUniformLocation(this.program, "uLevelScale")!; @@ -167,12 +240,11 @@ export class StructureLevelPass { this.program, "uHighlightDimAlpha", )!; + this.uClassic = gl.getUniformLocation(this.program, "uClassic")!; - // Glyph metrics data texture - this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas); - - // Start async MSDF atlas load - this.loadAtlas(); + // Start async atlas loads (both fonts) + this.loadAtlas(classicAtlasUrl, this.classic); + this.loadAtlas(msdfAtlasUrl, this.msdf); // Instance buffer const glBuf = gl.createBuffer()!; @@ -212,7 +284,7 @@ export class StructureLevelPass { gl.bindVertexArray(null); } - private loadAtlas(): void { + private loadAtlas(url: string, bundle: FontBundle): void { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { @@ -224,15 +296,19 @@ export class StructureLevelPass { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); - this.atlasTex = tex; - this.atlasReady = true; + bundle.atlasTex = tex; + bundle.atlasReady = true; }; - img.src = atlasUrl; + img.src = url; } updateStructures(units: Map): void { - let count = 0; + this.lastUnits = units; + const classic = this.settings.structureLevel.classicFont; + this.layoutClassic = classic; + const glyph = classic ? this.classic.glyph : this.msdf.glyph; + let count = 0; for (const unit of units.values()) { if (!unit.isActive) continue; if (!STRUCTURE_TYPES.has(unit.unitType)) continue; @@ -241,7 +317,7 @@ export class StructureLevelPass { const levelStr = unit.level.toString(); layoutString( levelStr, - this.glyph, + glyph, this.kernTable, this.charCodes, this.cursors, @@ -282,7 +358,15 @@ export class StructureLevelPass { } draw(cameraMatrix: Float32Array, zoom: number): void { - if (!this.atlasReady || this.instanceCount === 0) return; + const classic = this.settings.structureLevel.classicFont; + // Re-layout if the font toggled since the buffer was built — digit advances + // (and so cursor positions) differ between the two fonts. + if (this.lastUnits !== null && this.layoutClassic !== classic) { + this.updateStructures(this.lastUnits); + } + + const font = classic ? this.classic : this.msdf; + if (!font.atlasReady || this.instanceCount === 0) return; const gl = this.gl; const ss = this.settings.structure; @@ -295,17 +379,21 @@ export class StructureLevelPass { gl.uniform1f(this.uDotsThreshold, ss.dotsZoomThreshold); gl.uniform1f(this.uScaleFactor, ss.iconScaleFactorZoomedOut); gl.uniform1f(this.uIconGrowZoom, ss.iconGrowZoom); - gl.uniform1f(this.uDistRange, this.distanceRange); + gl.uniform1f(this.uFontSize, font.fontSize); + gl.uniform1f(this.uAtlasScaleH, font.atlasScaleH); + gl.uniform1f(this.uBase, font.base); + gl.uniform1f(this.uDistRange, font.distanceRange); gl.uniform1f(this.uOutlineWidth, sl.outlineWidth); gl.uniform1f(this.uLevelScale, sl.scale); gl.uniform1f(this.uLevelOffsetY, sl.offsetY); gl.uniform1i(this.uHighlightMask, this.highlightMask); gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha); + gl.uniform1i(this.uClassic, classic ? 1 : 0); gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!); + gl.bindTexture(gl.TEXTURE_2D, font.atlasTex!); gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, this.glyphMetricsTex); + gl.bindTexture(gl.TEXTURE_2D, font.metricsTex); gl.bindVertexArray(this.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); @@ -328,7 +416,9 @@ export class StructureLevelPass { gl.deleteProgram(this.program); this.instanceBuf.dispose(); gl.deleteVertexArray(this.vao); - gl.deleteTexture(this.glyphMetricsTex); - if (this.atlasTex) gl.deleteTexture(this.atlasTex); + gl.deleteTexture(this.classic.metricsTex); + gl.deleteTexture(this.msdf.metricsTex); + if (this.classic.atlasTex) gl.deleteTexture(this.classic.atlasTex); + if (this.msdf.atlasTex) gl.deleteTexture(this.msdf.atlasTex); } } diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index b78bbb998..ec126f055 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -155,7 +155,8 @@ "structureLevel": { "scale": 1, "outlineWidth": 8, - "offsetY": -0.4 + "offsetY": -0.4, + "classicFont": true }, "bar": { "healthBarW": 11, diff --git a/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl b/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl index c4127fbdb..a764155ce 100644 --- a/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl +++ b/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl @@ -6,6 +6,7 @@ uniform float uDistRange; uniform float uOutlineWidth; uniform int uHighlightMask; uniform float uHighlightDimAlpha; +uniform int uClassic; // 1 = round_6x6 bitmap font, 0 = overpass MSDF in vec2 vUV; flat in float vAlive; @@ -19,32 +20,43 @@ float median(float r, float g, float b) { void main() { if (vAlive <= 0.0) discard; - vec3 msd = texture(uAtlas, vUV).rgb; - float sd = median(msd.r, msd.g, msd.b); + vec3 color; + float alpha; - vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0)); - vec2 screenTexSize = 1.0 / fwidth(vUV); - float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); + if (uClassic == 1) { + // round_6x6_modified bitmap: white digits with a baked-in dark outline + // on a transparent background (non-premultiplied RGBA). + vec4 texel = texture(uAtlas, vUV); + if (texel.a <= 0.0) discard; + color = texel.rgb; + alpha = texel.a; + } else { + // overpass-bold MSDF: white fill with a synthesized dark outline. + vec3 msd = texture(uAtlas, vUV).rgb; + float sd = median(msd.r, msd.g, msd.b); - float screenPxDist = screenPxRange * (sd - 0.5); - float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0); + vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0)); + vec2 screenTexSize = 1.0 / fwidth(vUV); + float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); - // White text with dark outline - float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); - float effectiveOutline = min(uOutlineWidth, maxOutline); - float outlineDist = screenPxDist + effectiveOutline; - float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + float screenPxDist = screenPxRange * (sd - 0.5); + float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0); - vec3 color = mix(vec3(0.0), vec3(1.0), fillAlpha); - float finalAlpha = outlineAlpha; + float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); + float effectiveOutline = min(uOutlineWidth, maxOutline); + float outlineAlpha = clamp(screenPxDist + effectiveOutline + 0.5, 0.0, 1.0); + + color = mix(vec3(0.0), vec3(1.0), fillAlpha); + alpha = outlineAlpha; + } // Dim level text for non-highlighted structure types if (uHighlightMask != 0) { int bit = 1 << int(vAtlasIdx + 0.5); if ((uHighlightMask & bit) == 0) { - finalAlpha *= uHighlightDimAlpha; + alpha *= uHighlightDimAlpha; } } - fragColor = vec4(color, finalAlpha); + fragColor = vec4(color, alpha); } diff --git a/tests/GraphicsOverrides.test.ts b/tests/GraphicsOverrides.test.ts index bcc8dc5f6..eced3eab4 100644 --- a/tests/GraphicsOverrides.test.ts +++ b/tests/GraphicsOverrides.test.ts @@ -38,6 +38,9 @@ describe("GraphicsOverridesSchema", () => { { structure: {} }, { structure: { classicIcons: true } }, { structure: { classicIcons: false } }, + { structure: { classicNumbers: true } }, + { structure: { classicNumbers: false } }, + { structure: { classicIcons: true, classicNumbers: false } }, { name: { darkNames: true }, structure: { classicIcons: true } }, ]; for (const c of cases) { @@ -90,6 +93,11 @@ describe("GraphicsOverridesSchema", () => { structure: { classicIcons: "yes" }, }).success, ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + structure: { classicNumbers: "yes" }, + }).success, + ).toBe(false); expect( GraphicsOverridesSchema.safeParse({ mapOverlay: { territorySaturation: "full" }, @@ -254,6 +262,31 @@ describe("applyGraphicsOverrides", () => { expect(absent.iconAlpha).toBe(0.9); }); + test("classicNumbers=true → classic bitmap font", () => { + expect( + gen({ structure: { classicNumbers: true } }).structureLevel.classicFont, + ).toBe(true); + }); + + test("classicNumbers=false → smooth MSDF font", () => { + expect( + gen({ structure: { classicNumbers: false } }).structureLevel.classicFont, + ).toBe(false); + }); + + test("classicNumbers absent → defaults to classic bitmap font", () => { + expect(gen({ structure: {} }).structureLevel.classicFont).toBe(true); + expect(gen({}).structureLevel.classicFont).toBe(true); + }); + + test("classicNumbers is independent of classicIcons", () => { + const s = gen({ + structure: { classicIcons: false, classicNumbers: true }, + }); + expect(s.structureLevel.classicFont).toBe(true); + expect(s.structure.iconDarken).toBe(0); + }); + test("applies territorySaturation override (including 0)", () => { expect( gen({ mapOverlay: { territorySaturation: 0.4 } }).mapOverlay