mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:40:42 +00:00
aa22339f968e0e7bc740795132c4505c19258b02
399 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aa22339f96 |
Add a main-thread perf harness for the worker → client update pipeline (#4243)
## What `npm run perf:client` — a headless harness (companion to `npm run perf:game` from #4228) that measures the **main-thread burst** the client runs every simulation tick. The sim ticks at 10Hz in a worker; each tick the main thread synchronously runs deserialization → `GameView.update()` → `WebGLFrameBuilder.update()` → HUD ticks. On low-end devices that burst exceeds the 16.7ms frame budget and shows up as a stutter every 100ms. Before optimizing that path, this gives us numbers. Per tick it runs the real pipeline end to end and times three stages: - **clone** — `structuredClone` of the `GameUpdateViewData` with the same transfer list `Worker.worker.ts` uses (serialize+deserialize, an upper bound on the main-thread share of the real `postMessage`) - **view** — the real client `GameView.update()`, including all `populateFrame()` derivations - **builder** — the real `WebGLFrameBuilder.update()` against a no-op GL stub that counts payload sizes It reports mean/p50/p95/p99/max per stage, slowest bursts with their tile counts, payload stats, a filtered V8 CPU profile table, and writes a `.cpuprofile`. Not covered (browser-only): CPU inside the WebGL view's `update*()` methods and HUD layer ticks. Same flags as `perf:game`: `--map --ticks --bots --nations --seed --top --no-cpu-profile`. ## Determinism - Prints the sim **Final hash**, which matches the `perf:game` references on all three standard configs (world/200t/100b → `5607618202213430`, default → `29309648281599524`, giantworldmap/600t → `39945089450032050`) — the harness's worker side is faithful. - Prints a **View hash** (FNV over the tile-state buffer, FrameData deriveds, and per-player/unit view state) — verified stable across runs. Client-side optimizations should keep it identical, the same workflow as the sim hash. ## Baseline (this machine; low-end devices are ~5–20× slower) Default run (world, 400 bots, 1800 ticks): | stage | mean | p50 | p95 | p99 | max | |---|---|---|---|---|---| | clone (serialize+deserialize) | 1.02ms | 0.96 | 1.53 | 2.11 | 9.15 | | GameView.update | 0.62ms | 0.58 | 0.93 | 1.25 | 5.09 | | WebGLFrameBuilder.update | 0.04ms | 0.04 | 0.05 | 0.07 | 0.17 | | **TOTAL burst** | **1.67ms** | **1.60** | **2.46** | **3.47** | **10.3** | giantworldmap/600t: TOTAL mean 2.54ms, p99 5.65ms, max 6.42ms. Notable: the clone is the largest stage (~60%) — the packed tile/motion buffers transfer for free, so the cost is structured-cloning the `updates` object (~278 partial player updates/tick on world, ~508 on giantworldmap). Inside `view`, the recurring cost is `populateFrame`'s derivations (`computePlayerStatus`, the O(players²) relation matrix, alliance clusters); tile apply dominates the land-grab spikes. ## Code changes outside the harness - `WebGLFrameBuilder`: the `./render/gl` import is now `import type` so the module loads under Node — a value import pulls `GPURenderer` and its `.glsl?raw` shader imports. No behavior change (the symbols were only used in type positions). - `tests/perf/client/Shims.ts`: an in-memory `localStorage` shim so `UserSettings`/theme code runs under Node (all settings resolve to defaults, which is also the deterministic choice). ## Verification - Sim + view hashes identical on repeat runs. - `npm test` (1474 tests), `eslint`, `prettier --check`, `tsc --noEmit` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d96c055df1 |
Better troop management for nations 🤖 (#4239)
## Description:
When human pro players have non-allied players with similar troops next
to them, they wouldn't send out a big attack.
But nations are doing exactly that.
With this PR, they no longer do. On hard and impossible.
On easy and medium they are stupid 😀
```
1. Troop send cap: the nation must retain a minimum fraction of its
strongest non-allied neighbor's troop count (Hard: 75%, Impossible:
90%). Attacks that would drop below this floor are scaled down or
skipped entirely. Allied and same-team neighbors are ignored since
they pose no threat. The cap applies to land attacks, boat attacks,
and random boat attacks.
2. Minimum attack strength: if the capped troop count is less than 20%
of the target's troop count, the attack is skipped as too weak to be
worthwhile. Only applies on Hard and Impossible.
```
_Coded by MiMo 2.5 Pro, reviewed by MiniMax M3_
## Please complete the following:
- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
FloPinguin
|
||
|
|
71af72606a |
Fix nuke trajectory preview missing SAM interception for would-be-betrayed allies (#4235)
## Summary Fixes #4226 (Release Blocker, V32 regression). The WebGL nuke trajectory preview built its SAM threat set by unconditionally excluding own + allied SAMs (`BuildPreviewController.updateNukeTrajectoryPreview`). But when the strike targets allied territory, the alliance breaks at launch — `NukeExecution.maybeBreakAlliances()` — so the betrayed ally's SAMs **do** engage the nuke. The preview therefore showed a fully white trajectory with no intercept X over an allied SAM, even though the bomb would be shot down (V31 previewed this correctly). ## Fix - Compute the would-be-betrayed player set with `listNukeBreakAlliance()` — the exact function the sim uses at launch, so preview and sim can't drift. - Keep an allied SAM in the threat set iff its owner is in that set (extracted as pure `samThreatensNukePreview()`). - Other (non-betrayed) allies' SAMs remain excluded, matching sim behavior where only alliances over the blast threshold break. Both missing artifacts in the issue (red post-intercept segment and X marker) come from `tSamIntercept` staying at 1.0 because no SAM was supplied, so this one change restores both. Cost note: this adds one `circleSearch` per throttled ghost update (50ms) when the player has allies — same order as the existing `wouldNukeBreakAlliance` call for the red warning circle. ## Testing - Unit tests for the new threat-set predicate (4 cases) in `tests/client/controllers/BuildPreviewController.test.ts` - `tsc --noEmit`, ESLint, Prettier clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2e6f70c098 |
Speed up the core sim: inline sfc32 PRNG and allocation-free player updates (#4233)
## Summary Follow-up to #4230. Two more core-sim optimizations — these are **behavior-affecting in controlled ways** (unlike #4230, which was hash-identical), so both come with dedicated test coverage written before the change. Combined results (`npm run perf:game`, same machine, before → after): | run | mean tick | ticks/sec | p99 | peak heap | |---|---|---|---|---| | default (world, 400 bots, 1800 ticks) | 7.98 → **6.96 ms** | 125 → **144** | 21.2 → **19.0 ms** | 438 → **294 MB** | | giantworldmap, 600 ticks | 17.4 → **15.2 ms** | 58 → **66** | 32.6 → 30.5 ms | | Cumulative with #4230 vs. the original baseline: default run mean 9.04 → 6.96 ms (111 → 144 ticks/sec); giantworldmap 22.5 → 15.2 ms (44 → 66 ticks/sec, max tick 52.8 → 40.1 ms). ### 1. `PseudoRandom`: seedrandom ARC4 → inline sfc32 - ARC4 was ~4% of profiled self time. The new engine is sfc32 with splitmix32 seed expansion and a warmup, using only 32-bit integer ops — sequences are identical across platforms. The class API is unchanged. - This **removes the `seedrandom` dependency entirely**, making `src/core` actually dependency-free (the import was the only violation of that rule). - ⚠️ **The random stream differs, so the deterministic game-state hash changes.** All clients run the same code, so cross-client sync is unaffected; the harness reproduces the same hash on repeated runs per seed. New reference hashes: - `--map world --ticks 200 --bots 100` → `5607618202213430` - default run → `29309648281599524` - `--map giantworldmap --ticks 600` → `39945089450032050` - New `tests/PseudoRandom.test.ts` (15 tests) pins the engine-agnostic contract: per-seed determinism, ranges, uniformity, adjacent-seed decorrelation, and every API method. The tests were verified green against the old engine first, then the swap. - The stream change exposed a test that passed **by RNG luck**: in `AiAttackBehavior.test.ts`, "nation cannot attack allied player" was actually being blocked by the difficulty dice gate in `shouldAttack`, not the alliance check — hiding that the test's `AiAttackBehavior` was constructed without its `NationEmojiBehavior`. The test now supplies one and verifies the real protection layer (`AttackExecution`'s alliance check), robust to any dice outcome. ### 2. `PlayerImpl.toFullUpdate`: allocation-free empty collections - `toFullUpdate` runs for every player every tick and allocated ~10 collections each (allies, embargoes Set, attacks, alliance views, …) even when all were empty — the common case for most of 472 players. Because `lastSentUpdate` retains each snapshot for a full tick, these objects survived minor GC, got promoted, and accumulated as old-space garbage between major GCs — that's the peak-heap drop. - Empty collections now reuse shared **frozen** module-level singletons, so `diffPlayerUpdate`'s existing `a === b` fast paths skip structural comparison entirely. Non-empty collections build in single passes. Freezing makes accidental in-worker mutation throw loudly instead of silently corrupting every player; consumers across the worker boundary get mutable structured clones as before. (`Set` cannot be frozen — `EMPTY_EMBARGOES` is documented as never-mutate.) - Value-identical: the game-state hash is unchanged by this part (verified against the post-PRNG baseline). - New `tests/PlayerUpdateDiff.test.ts` (8 tests): full-snapshot shape, null-when-unchanged, embargo/alliance/target/attack diffs through the real tick pipeline, and the freeze contract. ### Verification - Full suite passes: 124 files / 1408 tests (23 new) + server tests; lint and prettier clean. - Hash reproducibility confirmed: repeated runs with identical args produce identical hashes on all three configs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
182d008ddd |
Generate a single MapInfo list; move SPECIAL_TEAM_MAPS and en.json map names into info.json (#4231)
**Add approved & assigned issue number here:** N/A — maintainer follow-up to #4227. ## Description: Follow-up to #4227, finishing the "info.json is the single source of truth" refactor. **Maps.gen.ts now generates one `MapInfo` interface and a `maps` list** instead of parallel lookup records. `mapCategories`, `mapTranslationKeys`, and `multiplayerFrequency` are gone — consumers read the list directly (`map.categories`, `map.translationKey`, `map.multiplayerFrequency`). MapPicker got simpler in the process: it renders from `MapInfo` objects, so the reverse `Object.entries(GameMapType)` lookup to recover the enum key is gone. The featured-rank sort moved out of the Go codegen into the picker, where the presentation concern belongs. **`SPECIAL_TEAM_MAPS` moves into info.json** as an optional `special_team_count` field (set on the same 17 maps with the same values). MapPlaylist derives its map from the generated list; `SPECIAL_TEAM_FORCE_CHANCE` and the frequency multiplier behavior are unchanged. **The en.json `map` section is now generated.** A new optional `display_name` field in info.json (defaulting to `name`) is written to `resources/lang/en.json` by the generator, preserving the section's non-map UI keys (`map`, `featured`, `all`, `favorites`, `random`). The 8 maps whose English display name intentionally differs from the frozen enum value (e.g. `MENA`, `Milky Way`, `Europe (Classic)`, `Baikal (Nuke Wars)`) declare it via `display_name`, so no display text changes. The section is emitted alphabetically; since #4232 already sorted en.json and every value matches, regeneration is byte-identical and this PR has no en.json diff. Other languages remain Crowdin-managed. The generator also now validates `translation_key` is exactly `map.<folder>` and `special_team_count >= 2`. MapConsistency tests compare info.json directly against the generated list and the en.json section, and fail with a "run `npm run gen-maps`" message on drift. No behavior changes: enum values, playlist frequencies, special-team counts, featured order, and display names are all byte-identical. ## Please complete the following: - [x] I have added screenshots for all UI updates (no UI changes — internal refactor, rendering output identical) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: evanpelle 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
be177f445a |
Sort en.json keys alphabetically at every level (#4232)
**Add approved & assigned issue number here:** N/A — maintainer change, groundwork for #4231. ## Description: One-time recursive key sort of `resources/lang/en.json` (`jq -S` + prettier), with a test (`tests/EnJsonSorted.test.ts`) that enforces the invariant from now on. Why: sorted keys make the file deterministic, give translation PRs stable insertion points instead of everyone appending at section ends, and let the map-generator (#4231) rewrite the en.json map section with a plain JSON unmarshal/marshal round-trip — Go's `encoding/json` sorts object keys on marshal, so under this invariant a full-file rewrite is a no-op for everything it doesn't change. Crowdin matches translation entries by key path, not file position, so existing translations are unaffected. Only en.json is touched and checked; other language files remain Crowdin-managed (they may get reordered by Crowdin's next export, which is cosmetic). The diff is 100% line moves — no key or value changes (JSON-equal before and after). ## Please complete the following: - [x] I have added screenshots for all UI updates (no UI changes) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: evanpelle 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2789db8b96 |
Optimize core simulation hot paths (no behavior change) (#4230)
## Summary Pure performance optimizations to the attack/conquer/cluster hot paths in `src/core`, driven by the full-game perf harness from #4228. **No behavior change**: the final game-state hash is identical before/after on every config tested — world quick run (2 different seeds), giantworldmap, and the default 1800-tick run. ### Changes - **Flat-arithmetic neighbor iteration**: `forEachNeighbor` / `forEachNeighborWithDiag` / `isBorder` / `isOceanShore` are now implemented inside `GameMapImpl` using raw `ref±1` / `ref±width` index math, skipping the per-neighbor `ref()` coordinate validation (`Number.isInteger` etc.). `GameImpl` and `GameView` delegate. - **New `neighbors4(ref, out)`**: zero-allocation, callback-free neighbor query for hot loops (W, E, N, S — same order as `forEachNeighbor`). - **`AttackExecution`**: the per-tile closures in `tick()` / `addNeighbors()` are replaced with reusable neighbor buffers, a cached `GameMap` reference, and integer `smallID()` owner comparisons instead of owner-object lookups. - **`GameImpl`**: the per-conquer `updateBorders` closure is hoisted to a method with a reusable buffer; `removeInactiveExecutions` compacts the executions array in place instead of allocating a new ~4200-element array every tick. - **`PlayerExecution`**: `surroundedBySamePlayer` / `isSurrounded` / `getCapturingPlayer` de-closured (`neighbors4` + integer compares; neighbor visit order preserved, so `getCapturingPlayer`'s Map-insertion-order tie-breaking is unchanged); flood-fill visit closure hoisted out of the while loop. - **`FlatBinaryHeap.dequeue`**: returns the tile directly instead of allocating a `[tile, priority]` tuple per dequeued tile (AttackExecution is the only caller). ### Performance (`npm run perf:game`, same machine, before → after) | run | mean tick | ticks/sec | max tick | |---|---|---|---| | default (world, 400 bots, 1800 ticks) | 9.04 → **7.98 ms** | 111 → **125** | 31.7 → 35.7 ms | | giantworldmap, 600 ticks | 22.5 → **17.4 ms** | 44 → **58** | 52.8 → **36.2 ms** | The giantworldmap tail improvement (max tick −31%) is the most relevant for the 100 ms tick budget. ### Determinism verification Identical `Final hash` before and after on all configs: | config | hash | |---|---| | `--map world --ticks 200 --bots 100` | `5455008589403520` | | same + `--seed second-seed-check` | `5580840142777488` | | `--map giantworldmap --ticks 600` | `37373734953428430` | | default run | `26773450321979388` | ### Tests - New `tests/NeighborIteration.test.ts` pins the exact neighbor iteration orders (W,E,N,S cardinal; dx-major diagonal — conquest order and RNG consumption depend on them) and conquer/border-tile invariants checked mid-battle. - New `tests/FlatBinaryHeap.test.ts` covers heap ordering, clear, and growth. - Full suite passes (122 files / 1386 tests + server tests); lint and prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3de5fb4204 |
Move map metadata into info.json and generate map TypeScript from it (#4227)
**Add approved & assigned issue number here:** N/A — maintainer refactor. ## Description: Makes each map's `info.json` the single source of truth for map metadata — adding a map is now a folder with `image.png` + `info.json`, a `gen-maps` run, and an en.json display name. **info.json / manifest.json carry full map metadata.** Every `map-generator/assets/maps/<map>/info.json` declares `id` (the `GameMapType` enum key), `name` (the enum value — wire format, unchanged for all 94 maps), `translation_key`, `categories`, and `multiplayer_frequency` (the public-playlist weight that used to be the `FREQUENCY` record in MapPlaylist.ts). The generator validates everything and mirrors it into `resources/maps/<map>/manifest.json`. 23 stale info.json `name` values were normalized to the canonical enum value; enum values are byte-identical, so replays and stored game configs are unaffected. **The generator emits the TypeScript and discovers maps itself.** New `map-generator/codegen.go` generates `src/core/game/Maps.gen.ts` (`GameMapType`, `GameMapName`, `mapCategories`, `mapTranslationKeys`, `multiplayerFrequency` — now a full `Record<GameMapName, number>`, killing the old `Partial`) on every run; `Game.ts` re-exports it. The hardcoded map registry in `main.go` is gone — maps are auto-discovered from the `assets/maps` / `assets/test_maps` directories. MapConsistency tests fail with a "run `npm run gen-maps`" message if info.json, manifest.json, and Maps.gen.ts drift. The tracked `map-generator/map-generator` binary is rebuilt to match. **New categories: continents + world/cosmic/tournament/other, multi-category support.** `continental`/`regional`/`fantasy`/`arcade` are replaced by `featured`, `world`, `europe`, `asia`, `north_america`, `africa`, `south_america`, `oceania`, `antarctica`, `cosmic`, `tournament`, and `other`. Maps can list multiple categories, so straddlers (Black Sea, Bosphorus, Caucasus, Between Two Seas, Bering Sea/Strait, Mena, Strait of Gibraltar, Hawaii, Arctic) appear under both regions. Featured is itself a category (same 7 maps as before). MapPlaylist keeps its arcade exclusion via an explicit set. **Map picker UI.** Two tabs: **Featured** (default — featured maps plus a Favorites section when maps are starred) and **All** (one prominent collapsible bar per category with a map count, collapsed by default). The selected map is prepended to the featured grid when it lives elsewhere. `getMapName()` resolves through the generated `mapTranslationKeys`, which also fixes tourney maps never resolving a valid translation key. ## Please complete the following: - [ ] I have added screenshots for all UI updates (maintainer change — picker described above) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: evanpelle 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8da2291a49 |
Add full-game perf harness for the core simulation (#4228)
## Summary Adds a full-game performance harness under `tests/perf/fullgame/` that runs the **real simulation pipeline** headlessly — `GameRunner` + `Executor` with the real `Config`, nations from the map manifest, and bots on a production map from `resources/maps/` — for a configurable number of ticks, then reports where the time goes. ```bash npm run perf:game # world, 400 bots, 1800 ticks npm run perf:game -- --map giantworldmap --ticks 3600 npm run perf:game -- --no-exec-profile # purest CPU profile (no timing wrappers) ``` ## What it reports 1. **Per-tick wall time** — mean / p50 / p95 / p99 / max, count of ticks over the 100ms budget, and the slowest ticks by tick number. 2. **Time per Execution class** — every `Execution`'s `init()`/`tick()` is timed and aggregated by class name (`AttackExecution`, `NationExecution`, …). 3. **Top functions by self time** — via the V8 sampling profiler (`node:inspector`), so no instrumentation skew. Also writes a `.cpuprofile` to `tests/perf/output/` (gitignored) that opens in Chrome DevTools as a flame graph. ## Determinism The run is fully deterministic for a given `--seed`/`--map`/`--bots` (verified: identical final hashes across runs), and the final game-state hash is printed — so an optimization can be checked to not change simulation behavior. ## Sample output (world, 400 bots, 1800 ticks) ``` --- Per-tick wall time (game phase) --- mean 9.04ms | p50 7.90ms | p95 17.1ms | p99 21.5ms | max 31.7ms Over 100ms budget: 0 / 1800 ticks --- Time by Execution class --- execution total ms % tick ms init ms ticks instances AttackExecution 6568 48.8 6288 280 212536 4200 PlayerExecution 2832 21.0 2832 0.36 492049 472 NationExecution 2508 18.6 2508 0.23 144654 72 TransportShipExecution 703 5.2 96.0 607 30440 257 ... --- Top functions by self time (V8 sampling profiler) --- self ms % function location 1065 6.5 forEachNeighborWithDiag src/core/game/GameImpl.ts 979 6.0 conquer src/core/game/GameImpl.ts 948 5.8 (anonymous) src/core/execution/AttackExecution.ts 595 3.6 toFullUpdate src/core/game/PlayerImpl.ts ... ``` The harness lives in a subdirectory so the existing `npm run perf` micro-benchmark runner (which globs `tests/perf/*.ts`) doesn't pick it up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
03a5d691ee |
Add white glow behind hovered player's name
The hovered player (tile owner under the cursor, already tracked via uHighlightOwnerID for cull bypass) now gets a soft white glow behind their name. The glow is derived from the MSDF distance field: a white band past the outline with quadratic falloff, composited behind the glyph and clamped to the SDF margin so it never clips at quad edges. Glow size and strength are tunable via hoverGlowWidth/hoverGlowAlpha in render-settings.json, exposed as sliders in the graphics settings modal (persisted as graphics overrides) and in the debug GUI. Includes schema and apply tests for the new override fields, covering the 0 edge case (0 disables the glow, not "unset"). |
||
|
|
2d747d0f8b |
Flash alliance icon when renewal prompt is active
When an alliance is within the renewal-prompt window, the alliance icon above the player's name now pulses, ramping from 2 Hz to 5 Hz as expiry approaches (same effect as the traitor flash). The flash window is driven by allianceExtensionPromptOffset() — the same Config value that triggers the "renew alliance" prompt in the actionable events display — so the two always stay in sync. The shader only knew the alliance fraction, not absolute time, so computePlayerStatus now also emits allianceRemainingTicks, packed into the free pd7.w slot of the player-data texture. |
||
|
|
1db02acdc2 |
Move theme data into the render-settings JSON pipeline (#4223)
**Add approved & assigned issue number here:** N/A — maintainer refactor. ## Description: Replaces the theme class hierarchy (`BaseTheme`/`PastelTheme`/`ColorblindTheme`) with theme JSON files — `default-theme.json` and `colorblind-theme.json` — combined with `render-settings.json` at runtime into a single graphics-configuration pipeline (`settings.theme`). One `SettingsTheme` class keeps the algorithms (color allocation, team-variation generation, LAB-contrast structure colors) and reads all data from `ThemeSettings`; adding a theme is now just adding a JSON file. Colorblind mode (#4150) is fully preserved: - Same palettes — the 32-color CVD-safe pool and Okabe-Ito team colors are baked into `colorblind-theme.json` - The relative border rule (`l × 0.6`) is expressed as a `borderLightnessScale` knob alongside the default theme's absolute `borderDarken` - The mid-game re-theme wiring (`refreshPlayerColors`/`refreshPalette`) and the affiliation/friend-foe tint overrides are unchanged; `applyGraphicsOverrides` now also swaps the `settings.theme` slice - `deepAssign` replaces arrays wholesale so differing palette lengths survive theme switches Verified against the previous implementation with an equivalence test (since removed): default-theme colors are byte-identical including allocation order; colorblind team/derived colors are byte-identical, and FFA assignment may permute within the same palette (hex baking rounds upstream's fractional-RGB colord objects, which can flip the allocator's greedy delta-E ordering — rendered colors round identically either way). Also removes dead theme surface (`terrainColor`, `backgroundColor`, `falloutColor`, `font`, `textColor`, spawn-highlight variants, `PastelThemeDark`) — GL terrain colors and dark mode were already handled in the renderer. Note this means the colorblind terrain bands from #4150 were dead code (nothing calls `terrainColor`; GL terrain comes from `ColorUtils.encodeTerrainTile`); wiring CVD-safe terrain into the terrain texture would be a follow-up. ## Please complete the following: - [x] I have added screenshots for all UI updates — N/A, no UI changes (verified color-identical) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — N/A, no user-visible text - [x] I have added relevant tests to the test directory — `tests/Colors.test.ts` updated for the new pipeline (team colors from theme JSON, colorblind palette/border tests) ## Please put your Discord username so you can be contacted if a bug or regression is found: evanpelle 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3c0ff7a6f2 |
Fail open on clan tag ownership checks when API is unavailable
The clan-tag ownership check previously failed closed: when the API service was unreachable (e.g. during local development), the client dropped the tag with a "couldn't verify" error and the server's FailOpenPrivilegeChecker treated every unverifiable tag as reserved. This made clan tags unusable whenever the API was down. - Client: checkClanTagOwnership keeps the tag when the existence probe is inconclusive; the server still re-checks authoritatively. - Server: FailOpenPrivilegeChecker passes tags through instead of dropping non-member tags; decideClanTag now takes a non-nullable reserved set since the null case is gone. - Remove the now-unused username.tag_check_failed translation key. - Update Privilege and ClanApiQueries tests for fail-open behavior. Trade-off: if the reserved-tag list is unavailable in production, real clan tags can be impersonated until the first successful PrivilegeRefresher load; after that the last good checker is retained. |
||
|
|
21776e81af |
Feature/colorblind mode (#4150)
**Add approved & assigned issue number here:** Resolves #2549 ## Description: Adds colorblind mode. Similar to dark mode, it exists as a toggle in settings. When enabled, it swaps the game's theme (which is refactored to extend from a theme base class) to use more colorblind-friendly colors and brightness variations. Borders are darkened, and terrarin is separated by lightness. Friendly/Foe colors and switched to blue/orange instead of red/green. The theme refactor supports adding new themes without having to reimplement the color distribution system. New themes can extend the BaseTheme and supply the data, such as palettes, team-color variations, and terrain. New setting: <img width="880" height="273" alt="Screenshot 2026-06-04 at 11 30 27 AM" src="https://github.com/user-attachments/assets/d5d573d5-cc64-4ac1-95c2-00627faf17cc" /> New color palette: <img width="1119" height="757" alt="Screenshot 2026-06-04 at 11 30 59 AM" src="https://github.com/user-attachments/assets/2bb15bc9-992b-41ae-ab0e-b01fe0c3c6bb" /> ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: jetaviz |
||
|
|
9189aac687 |
Improve railroad visibility: own-rail contrast color and thickness setting
Local-player rails previously rendered in the white focused-border color from the palette, making them hard to see on light territory. Rails now use a dedicated local rail color: white normally, flipped to black when the territory backdrop is too light for white to read against (patterns average their primary/secondary brightness). Also add a railThickness render setting (0.5-3, default 1), exposed in the Graphics Settings modal and the debug GUI, and persisted via GraphicsOverrides. In the medium-zoom LOD, rails are now drawn as screen-space anti-aliased lines around each tile's rail centerline, accumulated from the 3x3 neighborhood so thick lines spill cleanly into neighboring tiles; detailed mode scales its sub-grid band widths. - PlayerView: compute railColor() (white/black by backdrop brightness) - RailroadPass/shader: uLocalPlayerID, uLocalRailColor, uRailThickness - render-settings.json, RenderSettings, GraphicsOverrides, RenderOverrides: new railroad.railThickness knob - GraphicsSettingsModal: "Train track thickness" slider (+ en.json keys) - tests: schema + apply coverage for railroad overrides |
||
|
|
9e9708468c |
Fix/nation names special caracters (#4195)
> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. **Add approved & assigned issue number here:** Resolves #4165 ## Description: This PR update the test checking validity of Nation Names to include the new character constraint explained below. It also fixes the 10 Nations that invalid characters (that did not render correctly on the map). **The new character constraint** According to testing, the game map renders correctly all safe Extended-ASCII characters (non colored in www.ascii-code.com = [0x20–0x7E] or [0xA0-0xFF]). Other characters, when present in Nation Names, are rendered correctly in the rest of the game but not on the map, where they are trimmed to the last byte, which is then interpreted as Extended-ASCII and rendered if possible. **How to quickly check my assertion** 1. Change the file resources/maps/world/manifest.json, renaming one of the countries to "a.á.आ!š!慢!". 2. Start a game on the world map without any bots 3. Verify that the nation name is well displayed in its overlay but is shown as "a.á.!a!b!" on the map. (characters before a point are preserved, but characters before an exclamation mark are missing/changed). 4. run `npm run test` and notice that the NationName test fails and lists the three non-valid characters. Explanation: The string is represented in UNICODE-16 as \u0061\u002e\u00e1\u002e\u0906\u0021\u0161\u0021\u6162\u0021. Which, when we keep only the right-most byte of each character gives: 61 2e e1 2e 06 21 61 21 62 21 And, converted in Extended-ASCII gives: a.á.�!a!b! (which matches the showed name if we discard the control character). **The 10 Nations which needed a fix** Utqiaġvik from the Bearing Strait. Ar Rayyān from the Strait of Hormuz. 6 Nations in the Bosphorus Straits. 2 Easter-egg Nations from Luna. The 8 real-world Nations were adapted by simply removing the diacritics (after confirmation from a speaker of arabic and turkish, but sadly none for the Utqiaġvik Nation). The Secret Base from Luna was renamed "T0Þ $e¢®ët Mi|¡tªr¥ ß@§£", all within Extended-ASCII, keeping the same spirit as the original name. However, the Monolith Nation (previously named ▊, without any flag) has changed quite a lot and needs some explanation. **Easter-egg Nation Monolith** The new name is "ΜΟΝΟʟΙȚΗ", which is entirely outside of the valid character zone but in a way that entirely disappears on the map (as the आ character in the example above). This means that on the map, the Nation has no name and only its Monolith-flag. However, in all other places (leaderboard, overlay, alliances, warnings, etc.) the name is displayed correctly. The included test excludes this precise name from its violation list. <img width="1512" height="632" alt="image" src="https://github.com/user-attachments/assets/998693f2-edb4-417c-9054-35dc4819a57d" /> The Monolith Nation without its name but with a Monolith flag. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: Katokoda |
||
|
|
2d28d5463b |
Add territory saturation and opacity graphics settings
Expose two new user-configurable map-overlay controls in the graphics settings modal: territory saturation (mutes fill colors toward grayscale) and territory opacity (lets terrain show through the fill). The territory fragment shader blends the fill toward its luminance based on uSaturation and applies uTerritoryAlpha as the absolute fill opacity. Both are wired through RenderSettings, the GraphicsOverrides schema, applyGraphicsOverrides, the debug Layout sliders, and TerritoryPass uniforms, with defaults (saturation 1, alpha 0.588) in render-settings.json. Adds the corresponding en.json label/description strings. |
||
|
|
ee8c28331b |
Perf: Maintain a per-player alliance list (#4172)
# Maintain a per-player alliance list (drop O(all-alliances) scan)
## Summary
`PlayerImpl.alliances()` was implemented as a full scan of the global
alliance
list on every call:
```ts
alliances(): MutableAlliance[] {
return this.mg.alliances_.filter(
(a) => a.requestor() === this || a.recipient() === this,
);
}
```
This is O(all-alliances-in-game) **per call**, and it's called a lot —
most
notably twice per player per tick from `PlayerImpl.toFullUpdate()` (once
for
`allies`, once for `alliances`), which runs for every player every tick
on the
worker/core thread.
This PR makes each player own its alliance list: a per-player
`_alliances`
array (mirroring the existing `_incomingAttacks` / `_outgoingAttacks`
pattern),
maintained incrementally as alliances form/break/expire, so
`alliances()`
becomes an O(1) field read.
It turned out the global `mg.alliances_` list was only ever read by this
scan —
the `Game`-level `alliances()` getter had **zero callers** (all 17
`.alliances()`
callsites use the player-level accessor), and the list isn't used in
serialization. So rather than keep two structures in sync, this removes
the
global list entirely and makes the per-player lists the single source of
truth.
## Motivation
Profiling the worker/core thread showed `player.toFullUpdate` at ~**4%
of CPU**.
Breaking down where that time goes (microbenchmark, 100 players, ~100
alliances):
| Component | µs/tick | Share |
| --- | --- | --- |
| FULL (current: alliance scan ×2 + allocate collections) | 61.5 | 100%
|
| Alliance scan only (the two global `.filter()`s) | 41.7 | **~68%** |
| Allocation only (build arrays/objects, per-player list, no scan) | 6.4
| ~10% |
The global alliance scan — not the object allocation — is the dominant
cost, and
it gets *worse* with game size: the scan is O(players × total-alliances)
while
allocation is only O(players × own-alliances). Removing the scan targets
the
dominant ~2/3 of `toFullUpdate`'s cost.
It also speeds up `alliances()` everywhere, not just `toFullUpdate` —
it's called
in **17 places**, including AI hot paths (`NationAllianceBehavior`,
`PlayerExecution`).
> Note: this builds on the already-merged `diffPlayerUpdate`
typed-comparison
> change (commit `be87c76`), which addressed the diff/serialization
cost. This PR
> addresses the snapshot-construction cost.
## Changes
- **`PlayerImpl`**: add `public _alliances: MutableAlliance[]`;
`alliances()`
returns it directly.
- **`GameImpl`**: remove the global `alliances_` field and the unused
`alliances()` getter. Maintain the per-player lists at the mutation
sites:
- **add** — `acceptAllianceRequest` pushes the new alliance onto both
participants.
- **remove** — `breakAlliance`, `expireAlliance`, and
`removeAlliancesByPlayerSilently` all funnel through a small
`detachAlliance()` helper that removes the alliance from both
participants.
- **`Game` interface**: drop `alliances(): MutableAlliance[]` (no
callers).
## Correctness notes
- `alliances()` now returns the internal array by reference. This
matches the
existing `outgoingAttacks()` / `incomingAttacks()` accessors, which
already do
the same. All 17 callsites were checked — none mutate the returned
array.
- `detachAlliance` reassigns the array (`filter`) rather than splicing
in place,
so the `for (const alliance of player.alliances())` loop in
`PlayerExecution`
(which can expire alliances mid-iteration) iterates a stable snapshot
and is
safe. `removeAlliancesByPlayerSilently` likewise snapshots the player's
list
before detaching.
## Tests
New `tests/PlayerAllianceList.test.ts` asserts both participants' lists
stay in
sync through every mutation path:
- forming an alliance adds it to both lists
- `alliances()` agrees with `isAlliedWith` / `allianceWith`
- breaking removes it from both lists
- expiring removes it from both lists
- a player tracks multiple alliances independently (breaking one keeps
the other)
- `removeAllAlliances` clears the player and every partner
Full suite green: **1360 tests / 120 files**.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||
|
|
be87c7658f |
Speed up diffPlayerUpdate with typed field comparisons
diffPlayerUpdate runs once per player per tick on the worker thread. The array/object fields (outgoingAttacks, incomingAttacks, alliances, outgoingEmojis) were compared via JSON.stringify — two string allocations per field, run on every call even when nothing changed. This made the cost flat at ~3.4µs/call regardless of what actually changed. Replace jsonEqual with three typed structural comparators (attackArrayEqual, allianceArrayEqual, emojiArrayEqual) that short-circuit on reference/length, compare known fields with ===, early-exit on the first difference, and allocate nothing — matching the existing numberArrayEqual/stringArrayEqual style. ~9-10x faster across all cases (276k -> 2.4M ops/sec when unchanged). Add tests/perf/DiffPlayerUpdatePerf.ts (BEFORE/AFTER benchmark, run via npm run perf) and warnings on PlayerUpdate and diffPlayerUpdate noting that new fields must be wired into the diff/apply functions or their changes are silently dropped after the first emission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c6296c0bb1 |
Fix/warship freezing no path (#4151)
**Add approved & assigned issue number here:** Resolves #4113 ## Description: Warships now reject the PatrolTile change when the new one is a different water component. Adds a test ensuring this behavior. ## Please complete the following: - [x] I have added screenshots for all UI updates There are none - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file No texts - [x] I have added relevant tests to the test directory I also have tested in game and tested that the test does indeed fail if my fix is not present. ## Please put your Discord username so you can be contacted if a bug or regression is found: Katokoda |
||
|
|
00a7b6d14d |
Fix PrGateRules tests for checkRepoAccess refactor
Update test imports, mock data, and evaluate calls to match the
checkOrgMember/author_association → checkRepoAccess/getRepoPermission
rename in
|
||
|
|
74b3bd275b |
Allow mappers to omit nation coordinates in manifest.json for random spawn 🎲 (#4156)
## Description: Previously, every nation in a map's manifest.json required explicit coordinates. Additional nations already supported optional coordinates to trigger random spawn placement, but regular nations did not. Idea from PlaysBadly. Reasoning (copied off discord): > I've been working on World Inverted by adding realistic 'nations' in the form sunken ship names with their flags and location. However after searching around for other possible nation locations that are ocean related I realised that I might not have enough info for proper 'realisitc' coverage of the map. Currently Im at ~170 nations with cordinates. This is not including the additional nations with no locations. This will be reduced to ~62 as the default with the rest turning into additional nations. > > The problem is the end process is proving difficult. Trying to blance the nation placment on the map is a little much at this volume. So being able to add a few no-cordinate nations would be a great way to fill in the map. This PR also improves the MapConsistency test to check the additional nations too. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin |
||
|
|
9c2ac05506 |
clantag part 1 (#4066)
If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #(issue number) ## Description: adds a check to see if you're in a clan or not. if not, checks to see if the clan exists, if it does, warns the user, if it doesn't, lets them use it. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n |
||
|
|
48609fa70a |
Reduce lobby broadcast bandwidth via counts-only deltas (#4116)
## Description:
- The lobby WebSocket broadcast (`/lobbies`) was re-sending the full
`PublicGames` snapshot — including each lobby's `gameConfig` — to every
connected client every 500ms. Almost nothing in that payload changes
tick-to-tick; only `numClients` moves.
- `WorkerLobbyService` now tracks the sorted set of `gameID`s it last
sent as a full snapshot. On each incoming broadcast it sends a `full`
only when that set changes; otherwise it sends a `counts` delta carrying
just `{gameID → numClients}`.
- This relies on the master-side coupling at
[MasterLobbyService.ts:140-159](src/server/MasterLobbyService.ts#L140-L159):
when master finds a lobby without `startsAt`, it both sets `startsAt`
AND schedules a fresh lobby on the same tick, so the gameID change
brings the `startsAt` (and `gameConfig`) along with it.
- New WS connections are primed with the worker's cached last `full` so
late joiners don't have to wait for the next structural change.
- `LobbySocket` parses the new discriminated union (`PublicLobbyMessage
= full | counts`), keeps the last full snapshot in memory, and merges
counts into it before invoking the existing callback. `GameModeSelector`
is unchanged.
- Master → worker IPC is unchanged — still sends the full snapshot every
500ms. The optimization only applies to the worker → WS-client boundary,
which is the fan-out point.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
|
||
|
|
775ae77e0a |
Fix nations not spawning when random spawn is enabled 🤖 (#4117)
## Description: When random spawn is active, human SpawnExecutions are pre-created in GameRunner.init() and fire on the same tick as NationExecution. Because humans were added first, their SpawnExecution ticked first, called endSpawnPhase() (in singleplayer), and NationExecution then saw inSpawnPhase()=false, found the nation not alive, and deactivated it before ever queuing a SpawnExecution. Two changes fix this: 1. GameRunner.init(): Move nationExecutions() before spawnPlayers() so NationExecution ticks first and queues its SpawnExecution before the human SpawnExecution can end the spawn phase. 2. NationExecution.tick(): After the spawn-phase block, add a guard that waits when spawnExecAdded is true but the nation hasn't actually spawned yet. This prevents NationExecution from deactivating on the very next tick (via !isAlive()) before its queued SpawnExecution has had a chance to fire and give the nation territory. I tested it in singleplaye with and without random spawn and also in public lobbies. Nations now always spawn. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin |
||
|
|
f1045a2022 |
Update & refactor dark mode (#4114)
## Description: - The renderer no longer knows what "dark mode" is. `RenderSettings.dayNight.mode` (`"light" | "dark"`) is gone — passes read neutral values (`lighting.ambient: number`, `lighting.enabled: boolean`). - `render-settings.json` holds the light-mode baseline. Dark mode is just another override layer, applied the same way as graphics settings (`darkNames`, `classicIcons`, etc.). - New `src/client/render/gl/RenderOverrides.ts` exposes two in-place mutators with matching shapes: - `applyGraphicsOverrides(settings, overrides)` — replaces the old `generateRenderSettings` - `applyDarkModeOverride(settings, isDark)` - `ClientGameRunner` regenerates the live settings each time the user setting changes via `deepAssign(live, createRenderSettings())` + the override chain. No per-slice copy list, no intermediate object — adding a new override that touches a new section just works. - Renamed `dayNight` → `lighting`; collapsed `nightAmbient`/`dayAmbient` into single `ambient`; renamed `enableLightCompositing` → `enabled`. - Bumped dark-mode ambient from 0.15 → 0.35 so terrain stays readable. <img width="1250" height="846" alt="Screenshot 2026-06-02 at 11 47 28 AM" src="https://github.com/user-attachments/assets/b41e8ffb-6011-4ba0-9e1f-c2a21ff90794" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
2c8a66625c |
Feature/Move theme system from core to client-side ThemeProvider (#4108)
**Add approved & assigned issue number here:** Resolves #2549 ## Description: Themes are purely for the client's rendering, and the server doesn't need context on them. This PR moves `Theme.ts` from `src/core/configuration` to `src/client/theme` and moves affiliation colors to `render-settings.json`. This is to support the ability to add additional themes more quickly, such as colorblind-friendly themes. No visible changes occur from this refactor. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: jetaviz --------- Co-authored-by: Josh Harris <josh@wickedsick.com> |
||
|
|
f3ba95574c |
fix(core): prevent bots from invading/attacking themselves (#3865) (#4014)
Resolves #4094 ## Description: In Free-For-All (FFA) mode where teams default to 0, player isOnSameTeam checks returned false for oneself, allowing players to attack themselves. Consequently, if a bot conquered the targeted tile between queueing a transport ship action and its actual initialization, the target became itself, causing the bot to execute a self-invasion. This fix adds a reflexive check in PlayerImpl.ts's isFriendly method to always treat oneself as friendly. It also adds a safety guard in TransportShipExecution.ts's init method to abort ship execution if the target has shifted to the attacker. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: barfires |
||
|
|
413efed895 |
Add per-recipient cooldown to QuickChatExecution (#4012)
`QuickChatExecution` had no cooldown, allowing a player to spam quick-chat intents and flood a recipient's chat UI. This could bury incoming alliance request notifications, preventing them from being seen or accepted. This fix mirrors the existing emoji cooldown pattern: - Added `quickChatCooldown()` to `Config` (default: 30 ticks / 3 seconds) - Added `canSendQuickChat(recipient)` and `recordQuickChat(recipient)` to `Player` / `PlayerImpl`, tracking outgoing chats per recipient - `QuickChatExecution.tick()` now checks `canSendQuickChat` before displaying and records before the display calls (so the cooldown is always written even if display throws) |
||
|
|
b56e9438d1 |
github PR gate (#4070)
## Description: ## Summary Adds a GitHub Action that auto-closes PRs which don't follow the contribution workflow, so maintainer review time goes to legitimate contributions instead of off-roadmap or AI-generated submissions. Triggered on `pull_request_target: [opened, reopened]` and **defaults to dry-run** so it's safe to merge before flipping live. ## Gate logic (first match wins) 1. **Maintainer bypass** — PR carries `bypass-pr-check` label → pass. 2. **Org/repo member** — `author_association` is `OWNER` / `MEMBER` / `COLLABORATOR` → pass. 3. **Approved work** — PR body links an issue (`Closes/Fixes/Resolves #N`) that carries the `approved` label and the PR author is in the issue's assignees → pass. 4. **Small fix** — `additions + deletions ≤ 50` → pass + apply `small-fix` label. 5. **Otherwise** — apply `auto-closed-needs-issue` label, post rejection comment, close. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
fc3d80ec73 |
Add Classic Icons toggle to Graphics Settings
Adds a "Classic icons" toggle in the structure-icons section of the Graphics Settings modal. Off (default) keeps today's renderer look; on switches to a classic style — lighter player-colored shape behind a dark icon glyph, with 0.75 alpha for a subtle translucent feel. Exposes the underlying tuning as new render-settings knobs (`structure.fillDarken`, `borderDarken`, `iconAlpha`, `iconR/G/B`) and threads them through the structure shader as uniforms, replacing the previously hardcoded `darken(_, 0.65)` / `darken(_, 0.35)` calls and the hardcoded white `vec3(1.0)` icon color. The `classicIcons` boolean in the override schema is the single user-facing knob; the generator derives the five underlying field values from it. Extends the ClientGameRunner live-apply path to copy the `structure` slice too, and adds tests covering the schema and preset derivation. |
||
|
|
e938e5936b |
Add Graphics Settings name color toggle and unit tests
Adds a single "Name color" toggle (Colored / Black) to the Graphics Settings modal, backed by a `darkNames` boolean in the override schema that derives the five underlying name-rendering fields (fill/outline player-color flags + static outline RGB). Forcing the outline RGB to 0 in dark mode is what makes the shader's defaultFill ramp actually render black — flipping the boolean uniforms alone wasn't enough because the fill is derived from uOutlineColor when fillUsePlayerColor is false. Flips the render-settings.json defaults so black names are the renderer baseline; the modal's no-override state follows the JSON source of truth. Adds tests covering schema parse behavior and the generateRenderSettings derivation for each override field. |
||
|
|
aa3959bffe |
feat: territory png based skins (#4006)
## Description: Add image-based territory skins as a new cosmetic type, rendered alongside the existing 1-bit patterns. Skins render a single PNG centered on each player's spawn tile — opaque pixels show the skin (multiplied by team color in team games, raw colors in FFA), transparent pixels and tiles outside the image bounds fall through to the regular player palette color. **Cosmetic plumbing** - `SkinSchema` in `CosmeticSchemas.ts`, optional `skins` map on `CosmeticsSchema` - `PlayerSkin`, `PlayerCosmetics.skin`, `PlayerCosmeticRefs.skinName` in `Schemas.ts` - Server-side resolution: `PrivilegeCheckerImpl.isSkinAllowed` (gated by `skin:*` / `skin:<name>` flares) - Client persistence: stored under `PATTERN_KEY` (`pattern:` and `skin:` share one slot — they're mutually exclusive) - `getPlayerCosmeticsRefs` only emits a `skinName` when cosmetics are loaded, the skin exists in the catalog, and the user has the right flare — otherwise drops the ref and clears storage **Renderer** - `SkinAtlasArray` — fixed `TEXTURE_2D_ARRAY`, 1024×1024 per layer, exact layer count allocated once at game start from the locked-in player set. No resize, no callbacks, no retained `HTMLImageElement`. Zero GPU cost when no players have skins (1×1 placeholder). - `skinLayerTex` (R8UI 4096×1) — per-player `layer + 1` (`0` = no skin) - `skinAnchorTex` (RG16UI 4096×1) — per-player spawn tile, so the PNG center anchors at each player's spawn (re-uploads when the player re-picks during spawn phase) - `WebGLFrameBuilder.syncPlayers` collects unique skin URLs on first sync and calls `view.initSkinAtlas(urls)` once; `clearCaches()` resets so seek/replay re-initializes - `territory.frag.glsl`: skin branch is mutually exclusive with patterns; bounds-checks UVs against `[0, 1]` so the image is a single stamp, not tiled; alpha-blends against the player palette color so transparent pixels and out-of-bounds tiles render as the regular player color **Hover highlight (global UX change, not skin-scoped)** - Existing hover highlight changed from "brighten toward white" to "saturation boost." Applies to all players regardless of skin/pattern/flat-color — looks better across the board. **UI** - `CosmeticButton` renders skins as a single `<img>` (object-contain) - `TerritoryPatternsModal` merges patterns + skins into one grid; single "default" tile clears both - Selecting a pattern clears the skin and vice versa (mutually exclusive) - `Store` pattern tab includes skin entries (purchasable, not-yet-owned) - `PatternInput` lobby button previews the active skin when one is set **Memory** - 0 skin players → ~4 bytes (placeholder) + ~40 KB fixed per-player tables - 1 skin player → ~5.6 MB GPU - 5 skin players → ~28 MB GPU - 10 skin players → ~56 MB GPU **Tests** - `tests/Privilege.test.ts`: 13 new cases covering `isSkinAllowed` (wildcard, exact-match, missing flare, missing skin, forged refs) and `isAllowed` integration (allowed/forbidden paths, short-circuit when invalid skin is paired with valid other cosmetics) ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
38f0709e53 |
fix(core): destroy defense posts on tile capture instead of downgrading and transferring ownership (#1563) (#4016)
## Description: Capturing defense posts previously demoted their level by 1, and transferred ownership to the invading player if their level was still above 0. The expected strategic behavior is that defense posts should always be destroyed (deleted) upon capture. This fix updates PlayerExecution.ts's structure tick loop to immediately destroy the Defense Post unit via u.delete(true, captor) instead of transferring ownership. It also rewrites the corresponding unit tests in PlayerExecution.test.ts to verify the complete destruction of Defense Posts of all levels (including level 2+) when the tile owner changes. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: barfires |
||
|
|
2d6342cd22 |
Add stale-if-error to app shell Cache-Control (#4009)
## Description: Adds `stale-if-error=86400` to the `Cache-Control` header set on the rendered app shell (`/`) in [src/server/RenderHtml.ts](src/server/RenderHtml.ts). This lets shared caches (CloudFlare, nginx `proxy_cache`) keep serving the last good `index.html` for up to 24h if origin returns a 5xx, alongside the existing `stale-while-revalidate` window. Pairs with enabling HTML caching for the `/` route on CloudFlare in "respect origin headers" mode — it already honors `s-maxage` (5 min edge TTL) and `stale-while-revalidate`; this just extends the same safety net to origin-error cases. No behavior change for successful responses; browsers still revalidate every load via `max-age=0`. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: jish --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b4a14f9b9d |
Move attack troop overlay to WebGL (#3996)
## Description: Replaces the DOM-based `AttackingTroopsOverlay` with `AttackingTroopsController`, rendering attack troop counts through `WorldTextPass` instead of a separate fixed-position DOM container. ## Summary - New `AttackingTroopsController` polls `attackClusteredPositions()` every 200ms and pushes labels to the WebGL view each frame, lerping cluster positions over 250ms for smooth front-line movement (replaces the old CSS `transform 0.25s` transition). - `WorldTextPass` gains `setAttackTroopLabels()` and renders them at a fixed on-screen size (zoom-independent) using `screenScale / zoom`. - World text now draws on top of `NamePass` so attack callouts aren't hidden behind centered player names. - Fragment shader adds a soft quadratic dark halo around every world-text label; extent uses the remaining SDF range after the hard outline so it fades smoothly to zero (no rectangular clipping). - Deletes `AttackingTroopsOverlay.ts`; existing unit tests repointed to the controller's exported `alignClusterOrder`. <img width="369" height="395" alt="Screenshot 2026-05-24 at 4 43 51 PM" src="https://github.com/user-attachments/assets/4dbffe20-77f9-4c0f-b956-ecf543538f8d" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
db501c68d2 |
Put friends on the same team (#3994)
Fixes #3911 ## Description: - Server captures `publicId` and `friends` from `getUserMe()` and includes each player's in-game friend `clientID`s in `PlayerSchema` on game start - Team assignment treats friends as a **soft preference** (best-effort): a non-clan player goes to the team where the most of their friends already are; if that team is full they spill to the next-emptiest team rather than getting kicked - Clans remain strict (kick overflow) since clan membership is an explicit opt-in; friends are implicit, so a friend-of-friend chain that doesn't fit shouldn't bench anyone - Friendship is symmetric — an edge from either direction counts, which keeps things working when one side's `getUserMe` is stale - Lobby preview unchanged — friend grouping only takes effect once the game actually starts (avoids exposing friend lists in the lobby payload) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
545ad313e3 |
cleanup: drop unused disposer return from installSafariPinchZoomBlocker (#3992)
Follow-up to #3901 (cc @evanpelle). ## Description: In the review on #3901, evanpelle pointed out that the disposer returned by `installSafariPinchZoomBlocker` is never called at the call site in `Main.ts`, and asked whether there's any reason to return it. There isn't — the listeners live for the document's lifetime and the browser releases them on teardown — so this PR drops the disposer. ### Changes - `installSafariPinchZoomBlocker` now returns `void`. Removed the `return () => { ... }` block and the `@returns` JSDoc line. Added a sentence explaining why no disposer is needed. - Tests: dropped the disposer-removal test, switched the behavior tests to use fresh detached `<div>` elements (no document state leak across tests), and verified the default-target = `document` case with `vi.spyOn(document, 'addEventListener').mockImplementation(() => {})` so no real listener actually attaches to the shared jsdom document. Net diff: -23 lines (30 insertions, 53 deletions). ### What I tested - `npm test` — 1245 + 65 tests pass, including the 4 surviving tests for this helper - `npm run build-prod` — succeeds (tsc + vite) - `npx eslint` — clean - `npx prettier --check` on the touched files — clean ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: @vansszh |
||
|
|
a14cf0edc1 |
Clan Game History (#3988)
## Description: Adds <img width="1046" height="901" alt="image" src="https://github.com/user-attachments/assets/930b0d27-4707-4836-b068-620346e7e3a7" /> continuation of infra https://github.com/openfrontio/infra/pull/345 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n |
||
|
|
19beab9a70 |
flags (#3985)
# Dynamic flag atlas (runtime TEXTURE_2D_ARRAY) Replaces the build-time `flag-atlas.png` with a runtime `TEXTURE_2D_ARRAY` populated on demand from each player's server-resolved flag URL. Layers are deduped by URL (every "Mercia" bot shares one slot), so the per-game working set is bounded by unique flags, not player count. ## Why The store will eventually ship hundreds of custom flags fetched from the CDN, which can't be baked into a static atlas. Moving to a runtime array also lets the catalog grow without bloating the client bundle. ## Side effect (bonus) Human players' country flags (`country:US`, etc.) now display next to their names in-game. The old atlas only contained nation names, so non-nation flags were silently dropped. ## Notes - Cell size is fixed at 128×85; loaded images are aspect-fit and centered. - Layer cap is 512 (clamped to `MAX_ARRAY_TEXTURE_LAYERS`). Past the cap, further flag requests render no icon. - Mipmaps are regenerated after each layer upload. - Recommend store pipeline caps custom flag uploads at SVG or PNG ≤ 256×170, ≤ 50 KB (decode-time RAM and bandwidth, not VRAM). ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
2ee2fb97e3 |
WebGL: return of factory/defence post radii, and railroad highlighting when placing city/port right on top (#3981)
## Description: Show factory and defence post radius for ghost structure when placing structures from build bar (unitdisplay). Show when city/port is placed directly over existing railroad, by highlighting the railroad green. The railroad is not highlighted when instead a city/port nearby the ghost structure will be upgraded instead of placing it on the railroad. This works with the existing code in buildableUnits in PlayerImpl: it would already return an empty array [] for overlappingRailroads and for ghostRailPaths when canUpgrade is false. So the old checks for uiState for Canvas2D in BuildPreviewController weren't even needed per se, they followed the same logic as buildableUnits in PlayerImpl already did. Both changes emulate how it worked before the move to WebGL. - OverlappingRailroads now returns TileRefs instead of a railroad ID, and it does so with less allocations than the previous code. It's a determistic outcome, sorted and deduplicated. In doubt about this a bit, because it's better also in case we ever do desync checks using this data, but for the rendering it isn't needed per se and could be more performant without allocations. - Also: Cleanup obsolete Canvas2D rail highlighting state (UIState) that was superseded by GhostPreviewData. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 |
||
|
|
41ef675e98 |
Improve Notification Panel (#3913)
Resolves #3910 ## Description: - Split the events HUD into two components: a new **`<actionable-events>`** that owns alliance prompts (request / renew) and a slimmed-down **`<events-display>`** for everything else. - Reworked `<events-display>` into two visual tiers: dim/scrolling tier 2 on top (trade results, unit losses, donations, alliance status), prominent tier 1 anchored at the bottom (inbound nukes, naval invasion, attack requests, alliance broken, conquered player, chat). Tier 2 caps at the 4 newest entries; events expire after 8s. - Added a transient **+gold pip** above the gold pill in `<control-panel>`, animated with a small fade-in. Fires for trade ships, trains, donations, and conquest. Trade-ship and train arrivals are removed from the events scroll since they're surfaced here instead. - New `MessageType.NUKE_DETONATED` and a server-side emission in `NukeExecution.detonate` — once an inbound nuke lands or gets intercepted, the inbound warning vanishes and a "detonated" entry takes its place. - `displayMessage` gained optional `unitID` and `focusPlayerID` params so events can link to a unit or a player. Unit captures and destructions now navigate to the unit's last tile when clicked; donations navigate to the other player. - ActionableEvents card width matches `<events-display>`; cards persist until the user clicks Accept/Reject/Renew/Ignore or the server-side request timeout expires. - Removed the in-events category filter UI and the gold-amount banner — `<events-display>` is now a lightweight log that hides entirely when empty. <img width="570" height="444" alt="Screenshot 2026-05-21 at 1 42 30 PM" src="https://github.com/user-attachments/assets/f103efb3-0e11-4b72-a11b-91ff6896177c" /> <img width="430" height="296" alt="Screenshot 2026-05-21 at 1 41 34 PM" src="https://github.com/user-attachments/assets/ae58475a-b252-4aa6-9ce5-99dea7575ce3" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
513057a62c |
WebGL: show alliance request+duration icon, show ally and team mate targets too, some optimization (#3971)
## Description: Show nuke icons during replay too (when there's no localPlayer). Show alliance request envelope icon, and duration in alliance icon (weren't calculated yet). Show ally and team mates' targets too (weren't calculated yet). Remove unnecessary allocations. Nukes loop allocated two new sets, transitive targets was a new set and now uses predicate with fallback to localPlayer.targets, localPlayer.allies and localPlayer.embargoes were both put in new set instead of using .includes directly. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 |
||
|
|
a2aa7823a4 |
Display player flags next to their names again (#3965)
## Description: Display flags again. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 |
||
|
|
62e15d2794 |
Cut worker→main bandwidth ~3.3× by switching PlayerUpdate to deltas (#3967)
## Description: Cut worker→main bandwidth ~3.3× by switching PlayerUpdate from a full per-tick snapshot to a field-level diff. PlayerImpl.toUpdate() now caches the last sent update and returns only changed fields, or null if nothing changed. The client-side applyStateUpdate() merges instead of overwriting. Per-tick total dropped from ~297 KB to ~89 KB; the Player bucket alone went from 258 KB/tick to 50 KB/tick. Diff/apply logic lives in a new GameUpdateUtils.ts module with unit tests. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan |
||
|
|
7863529b2c |
rename client/graphics → client/hud
The contents (Lit web components for in-game chat, build menu, leaderboard, attack displays, etc.) are HUD, not graphics — the actual graphics is in client/render/. |
||
|
|
f23789883b |
Merge webgl2 — full WebGL2 renderer migration
relates to #893 Replaces the canvas2D + Pixi.js map renderer with a pure WebGL2 pipeline. Map-space visuals (terrain, names, structures, units, FX, selection boxes, build ghosts, status icons, nuke trajectories, defense zones, spawn glow, water-nuke terrain deltas) all render through dedicated passes in src/client/render/gl/passes/. Controllers in src/client/controllers/ push state directly to the WebGL view; no relay events. Assets unified under resources/ + assetUrl(). Mode toggle wired to the existing darkMode UserSetting (no more day/night cycle). One input system (InputHandler + EventBus + TransformHandler). Known regressions to address in follow-up work: - [ ] webgl: highlight structures when hover on build menu - [ ] webgl: custom flags, flag atlas - [ ] webgl: territory patterns - [ ] webgl: defense post outline - [ ] webgl: territory expanse smoothing |
||
|
|
4cd22a9b5c |
rename render/ files to UpperCamelCase to match client convention
The render/ tree was the only place in the client still using kebab-case filenames. Brings ~80 files in line with the rest of src/client/ (BuildPreviewController, TransformHandler, etc.). Directories kept as they were (name-pass/, fx-pass/, passes/, utils/, debug/) since the codebase already mixes those. Two collisions surfaced and got resolved: render/types/ is a directory, not a file, so its imports kept the lowercase form; and the sed pass incidentally normalized core/pathfinding imports, which had to be reverted since that file is actually lowercase on disk despite some imports having referenced it as ./Types under macOS case-insensitive resolution. |
||
|
|
a743a31897 |
delete dead canvas2D utilities, rename mountWebGLDebugRenderer → mountWebGLFrameLoop
ProgressBar and StructureDrawingUtils had no production callers — only their own test referenced ProgressBar, and StructureDrawingUtils was a canvas2D-era helper module that nothing imports anymore. mountWebGLDebugRenderer was named back when WebGL was a side-by-side debug overlay; it's the only renderer now, so the "Debug" prefix is misleading. Also dropped the `\` keybind that hid the GL canvas — with no other renderer, hiding it just blanks the game. |
||
|
|
eb046e5a58 |
move TransformHandler/UIState/Controller out of graphics/, drop dead GhostStructureChangedEvent
graphics/ was a canvas2D-era directory name — TransformHandler, UIState, and the Controller interface aren't graphics, they're cross-cutting client state. Hoist them to src/client/ so the path matches what they are. GhostStructureChangedEvent had three emitters and zero listeners; removed. |