mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:10:46 +00:00
webgl-software-render-gate
4055 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
769d0c687f |
Extend run-openfront skill with headless in-game WebGL testing (#4245)
## What
Extends the `run-openfront` Claude Code skill so agents can test the
*whole game* headlessly, not just the home page and modals. New
`game.mjs` driver plays an actual singleplayer game end-to-end:
- start a solo game with chosen options (bots, map, difficulty, …)
- spawn, attack/expand, open the radial build menu
- read ground-truth sim state (`ticks`, `inSpawnPhase`, `myPlayer`
troops/gold/tiles, `outgoingAttacks`) instead of guessing from pixels
- take real WebGL screenshots (SwiftShader renders the map fine
headless)
`node .claude/skills/run-openfront/game.mjs` runs a ~2 min smoke flow
that asserts territory growth after an expansion attack and that the
radial menu opens.
## How
No game-code changes were needed:
- `hud/GameRenderer.ts` already assigns the `GameView` and
`TransformHandler` onto the `<build-menu>` element, so page JS reaches
live sim state and world↔screen conversion through it.
- `launch({ rafIntervalMs })` stubs `requestAnimationFrame` to one frame
per interval. SwiftShader needs seconds of CPU per frame, and an
unthrottled frame loop starves the main thread — the singleplayer turn
loop drops from 10 ticks/s to ~0.3. Throttled, the sim runs near full
speed while frames still render for screenshots.
- `clickWorld()` absorbs the canvas-click pitfalls discovered while
testing: aims at tile centers (corner clicks floor onto the neighboring
tile), refuses to click through HUD elements covering
`#game-input-overlay`, and freezes the post-spawn camera animation so
computed coordinates don't go stale.
## Testing
Smoke flow run repeatedly on a headless 4-core box: game starts (123
players), spawn lands on the clicked tile, expansion attack grows
territory 52 → ~275 tiles, radial menu opens, screenshots show the
rendered map.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
aa4b490e68 |
Simplify WebGL renderer integration: remove dead extension code, untangle GameView naming (#4240)
## Summary The WebGL renderer was adapted from an external extension and carried a lot of machinery this integration never uses (replay playback, its own input/event system, a GL radial menu). This PR is two mechanical cleanup passes with **no behavior change**: delete the dead code, then untangle the `GameView` naming collision. **78 files, +142 / −2,197.** ### Pass 1 — remove dead extension baggage - **Replay/copy mode**: `FrameData.tileMode` was hard-coded `"live"`; the copy branches in `frame/Upload.ts`, `UploadOptions` (never passed), `applyFullFrame`/`applyFullTiles`/`applyDelta` on the facade and `GPURenderer`, `HeatManager.resetForSeek`, and the seek-upload methods on `TerritoryPass`/`TrailPass` were all unreachable. Also deletes `types/Replay.ts`, `types/FrameSource.ts`, `types/GameUpdates.ts`, `types/Game.ts` (imported only by the types barrel). - **FrameEvents**: trimmed from 14 fields to the 3 actually populated and read (`deadUnits`, `conquestEvents`, `bonusEvents`). The other 11 fed the extension's stats system and were never written or read here. - **GL radial menu**: `RadialMenuPass`, its 4 shaders, and ~10 API methods on facade + renderer had zero callers — the game uses the DOM/d3 radial menu in `hud/layers/RadialMenu.ts`. The pass was constructed and drawn every frame for nothing. - **Facade event system**: `GameViewEventMap` defined 10 event types (`click`, `hover`, `scroll`, …) but only `contextrestored` was ever emitted — input actually flows through `InputHandler` → EventBus → controllers. Replaced the listener map with a single `onContextRestored` callback and deleted `Events.ts`. Also fixed the stale header comment claiming the facade handles user interaction. - **Unused API surface**: removed ~20 facade/renderer methods with zero callers (camera passthroughs like `panTo`/`zoomTo`/`fitMap`/`screenToWorld`, hit-testing queries, SAM replay setters, `setSelectedUnit`, `clearFx`/`setFxTimeFn`, `onFrame`/`afterRender`/fps tracking). Deliberately left alone: `Camera`'s pan/zoom primitives (building blocks for a possible future camera unification) and the `timeFn` plumbing inside the FX passes (deeply embedded as defaults; only the dead renderer-level wrappers were removed). ### Pass 2 — untangle the three GameViews - `render/gl/GameView.ts` → **`MapRenderer.ts`** (class `MapRenderer`). Every importer was already aliasing it as `WebGLGameView` to dodge the collision with the simulation-mirror `GameView` in `client/view/`, so this removes aliasing rather than adding churn. `render/CLAUDE.md` updated. - Deleted the `src/core/game/GameView.ts` back-compat shim (its own TODO asked for this). All 51 importers now import from `src/client/view/` directly via a new 3-line barrel `view/index.ts`. ## Test plan - `tsc --noEmit` clean, `eslint` clean - Full test suite passes (1,385 + 65 server tests) - Manual verification via headless Chromium: started a singleplayer game and confirmed the renderer works end-to-end — terrain draws, spawn-phase overlay shows, territories fill with borders after spawning, player names/flags render, no renderer console errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
19db66f424 |
Delayed lobby start (#4184)
Resolves #4169 ## Description: Adds a delayed lobby start option. Utilizes the same system as for public lobbies. The default for the option is for lobbies to take 3 seconds to start, however this can easily be changed. The current setting is controlled through an enable-disable slider, however there are multiple other options for how to control this. For example we could do a slider, an input field, a dropdown etc. And i dont necessarily know if the currently implemented option is the best. Furhtermore im not sure if i have used the language file completely correctly. There is now a duplicate field for both private and public lobby. However there is not category shared between the two. So i decided to reuse the field from public for private games, as this simplified the code a bit. **Host video** https://github.com/user-attachments/assets/6f3db6e4-7323-4fad-8544-efb8cef4d969 **Non-host video** https://github.com/user-attachments/assets/ee02a072-1f42-4dde-a5d9-120fda862eb7 ## 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: FrederikJA |
||
|
|
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> |
||
|
|
eeb5f7e850 |
Fix unstyled modals in dev: re-read document styles after page load (#4242)
## Problem Since #4229, modals render unstyled in `npm run dev` (no black backdrop, no Tailwind styling). Production/staging is unaffected. `documentStylesSheet()` reads the document's `<style>` tags once, at module-eval time. In dev, Vite injects the Tailwind styles *during* module evaluation — after that read — so the shared constructed stylesheet ended up with 7 CSS rules instead of the full Tailwind sheet. In production the styles come from a `<link rel=stylesheet>` that is fetched by URL, so timing doesn't matter there. ## Fix If the document hasn't finished loading when the sheet is first created, re-populate it once on the window `load` event (which fires after the entry module graph — and therefore all style injection — completes). Constructed stylesheets are live, so already-rendered components pick the styles up without re-rendering. The existing HMR re-populate hook is unchanged. ## Test plan - [x] Reproduced in dev with headless Chromium: shared sheet had 7 rules, modal unstyled - [x] After fix: sheet has full Tailwind rules, solo modal renders with correct dark styling (screenshot-verified) - [x] `npx tsc --noEmit`, ESLint clean - [x] Client test suite: 458 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
27054bde83 |
[FIX] Filters actionable events to remove dead requestors (#4238)
Resolves #4220 ## Description: Filters `ActionableEvents` to remove dead requestors. ## 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 - [ ] I have added relevant tests to the test directory **I do not know how** to create and then kill a player in `tests/client/graphics/layers/ActionableEventsAlliance.test.ts`. ## Please put your Discord username so you can be contacted if a bug or regression is found: Katokoda |
||
|
|
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> |
||
|
|
1ef3fca2ac |
Remove committed map-generator binary from the repo (#4234)
**Add approved & assigned issue number here:** N/A — maintainer housekeeping. ## Description: Removes the committed `map-generator/map-generator` Go binary from the repo and adds it to `.gitignore`. The binary is a local build artifact: `npm run gen-maps` runs the generator with `go run .`, and nothing in the repo (scripts, CI workflows, docs) references the committed file. Tracking it just causes opaque binary churn in every PR that touches the generator (e.g. #4227, #4231). The `.gitignore` entry keeps locally built binaries from being accidentally re-committed. ## 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 (no user-visible text) - [x] I have added relevant tests to the test directory (no behavior to test — removes an unused build artifact) ## 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> |
||
|
|
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> |
||
|
|
b1e9955af3 |
Coordinate grid (#4224)
**Add approved & assigned issue number here:** Resolves #3839 ## Description: A bunch of small updates to make the coordinate grid a lot nicer. - Removes black backgrounds on text. - Allows user to modify the opacity of the coordinate grid - Persist the enable state of the coordinate grid ### Before <img width="2344" height="1168" alt="image" src="https://github.com/user-attachments/assets/22c2fb77-9db6-41bf-a50a-987f651cc19a" /> ### After <img width="2331" height="1174" alt="image" src="https://github.com/user-attachments/assets/0e5a9575-8a79-407b-8d78-8564df02b259" /> <img width="407" height="947" alt="image" src="https://github.com/user-attachments/assets/b9e5f9f1-3cc1-4832-b7d4-38e1f5e93d57" /> ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: FrederikJA |
||
|
|
94f2293149 |
Reduce main bundle size by ~44% gzipped (732 KB → 412 KB) (#4229)
## Summary Cuts the main JS chunk from **2,891 KB (732 KB gzip)** to **1,679 KB (412 KB gzip)** by fixing two bundling issues and removing/replacing heavy dependencies. Measured with a per-module `renderedLength` analysis of the rolldown output (its prod sourcemaps are malformed, so sourcemap-based tools misattribute sizes). | Chunk | Before | After | |---|---|---| | `index-*.js` (min) | 2,891 KB | 1,679 KB | | `index-*.js` (gzip) | 732 KB | **412 KB** | ## Changes - **Sim worker moved out of the main bundle (~512 KB).** The `?worker&inline` payload is now reached through a dynamic `import()`, so it lands in its own lazy chunk fetched when a game starts. The worker itself still uses Vite's inline Blob mechanism (with its `data:` URL fallback) — runtime instantiation is byte-for-byte unchanged. - **Replaced `lit-markdown` with `marked` + the already-bundled DOMPurify (~380 KB).** lit-markdown transitively pulled sanitize-html, htmlparser2, postcss, and two copies of entities into the client just to render news markdown. New `src/client/Markdown.ts` matches its image-stripping default. - **Dropped `colorjs.io` (~114 KB).** It was only used for ΔE2000 distance in `ColorAllocator`; colord's lab plugin (already imported there) provides the same CIEDE2000 via `.delta()`. Only relative magnitudes are compared, so allocation behavior is unchanged. - **`msdf-atlas.json` (~319 KB) fetched at runtime** like the atlas PNG, preloaded in parallel with worker init in `ClientGameRunner` so game-load latency is unaffected. - **Tailwind CSS no longer shipped twice (~158 KB).** `o-modal` imported `styles.css?inline`, duplicating the emitted stylesheet as a JS string. It now adopts a constructed stylesheet built from the document's own CSS (HTTP-cache hit in prod, `<style>` tags + HMR re-sync in dev) via `SharedStyles.ts`. - **Debug GUI lazy-loaded.** lil-gui + `gl/debug/*` now load on first toggle (46 KB lazy chunk) instead of shipping in the main bundle. Also looked at the `import * as d3` in RadialMenu (~84 KB) but left it: rolldown tree-shakes the metapackage well and all but ~2 KB is the genuine dependency closure of the selection/transition/shape/color APIs in use. ## Test plan - [x] `tsc --noEmit` clean - [x] ESLint clean - [x] Full test suite passes (1,374 + 65 tests) - [x] `npm run build-prod` succeeds; worker/debug chunks present in `asset-manifest.json` for the R2 upload - [ ] Manual smoke test in dev: start a game (worker dev path), open a modal (shared stylesheet), open news modal (markdown rendering) 🤖 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> |
||
|
|
cdcc774793 |
Hide subscriptions in store and account modal behind a feature flag
Subscriptions aren't ready yet. Add SUBSCRIPTIONS_ENABLED (currently false) in Cosmetics.ts to gate the Subscriptions store tab, the subscription panel on the account modal, and its cosmetics fetch. Flip the flag to true to re-enable. |
||
|
|
f50456c688 | Fix crash when HeadsUpMessage renders before game is assigned | ||
|
|
74fc239f96 |
Dim untargetable nukes so players can tell SAMs can't hit them
Nukes flying outside SAM-targetable range now render at reduced alpha (unit.untargetableAlpha, default 0.6), including the hydrogen bomb's glow halo. Adds a FLAG_FLICKER_UNTARGETABLE instance flag in UnitPass driven by the existing UnitState.targetable field. Also fixes the alt-view trade-friendly check to match its flag exactly, so retreating warships (flag 4) no longer render ally-yellow in alt view. |
||
|
|
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. |
||
|
|
95d5a0439b |
Make SAM intercept X marker 5x larger
Bump markerXRadius from 8 to 40 px in render-settings.json so the intercept point on nuke trajectories is easier to spot. The marker shader draws in normalized quad space, so the stroke and outline scale with it automatically. Also raise the debug GUI slider max for the X marker from 16 to 64 to keep the new value tunable. |
||
|
|
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. |
||
|
|
625d54c128 |
[small-fix 20 lines] Add FFA collusion warning (#4107)
Resolves #3900 ## Description: During the spawn phase in FFA games, display a collusion warning to clearly communicate to new users that pre-game agreement is not allowed. <img width="1362" height="662" alt="2026-06-01_20-31" src="https://github.com/user-attachments/assets/bd083e91-280a-41e1-a11a-d69d5f16bc8a" /> - [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: goose126 --------- Co-authored-by: evanpelle <evanpelle@gmail.com> |
||
|
|
7405339ea7 |
Add Titan map with random spawn nations - along new Cosmic map category (#4183)
Resolves #4182 ## Description: Adds "Titan" (real moon of Saturn with methane seas) map . Uses new random spawn nation feature by FloPinguin. https://github.com/openfrontio/OpenFrontIO/pull/4156 Also adds new Cosmic map category. The "Other" map category has become a wastebasket of unrelated maps, and with increasing number of maps, i think its a good addition to have better categories for these maps. I figured these 2 changes should go together since im adding a cosmic map, and a cosmic category. proof of nations spawning randomly and how the cosmic category looks in the menu: https://github.com/user-attachments/assets/b84bd3ef-6b8f-46fe-a6ea-ea5e79c6dc00 ## 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: tri.star1011 --------- Co-authored-by: Evan <evanpelle@gmail.com> |
||
|
|
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 |
||
|
|
7137347b7d |
Fade player names under the cursor, with a graphics setting to tune it (#4221)
## Description: Player name plates can block the view of what's underneath them (structures, units, terrain). This PR fades the entire name plate — name, troop count, flag, and emoji/status row — to 25% opacity while the cursor is over it, so you can see and click what's behind it. **How it works:** - `HoverHighlightController` pushes the cursor's world position into the renderer on mouse move. - `NamePass` hit-tests the cursor against each player's name plate bounds on the CPU (mirroring the lerp/sizing math in `name.vert.glsl`) and passes the matched player's ID to the text, icon, and status-icon programs, which apply the alpha multiplier in their shaders. **Graphics setting:** - New "Name opacity under cursor" slider in the Graphics Settings modal (Name Labels section), range 0–1, default 0.25. Setting it to 1 disables the fade entirely. - Wired through the existing `GraphicsOverrides` pipeline: changes apply live and are cleared by "Reset to defaults". - Tuning knob exposed as `name.hoverFadeAlpha` in `render-settings.json` and the debug GUI. ## Please complete the following: - [ ] 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 - [ ] 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 |
||
|
|
af2849a2d7 |
Adds "Juan De Fuca Strait" map - 3 way team map (#4215)
Resolves #4148 ## Description: Adds "Juan de Fuca Strait" map. This is the Strait in Washington and British Columbia: https://en.wikipedia.org/wiki/Strait_of_Juan_de_Fuca This map is meant to be a brand new 3-team way map, since all the team maps we have are either made for 2 or 4 teams. The map is bumped towards this gamemode similar to how Baikal is bumped to 2 teams. Map also has Additional Nations, for a total fof 62, for Human vs Nations and solo games <img width="1365" height="602" alt="image" src="https://github.com/user-attachments/assets/9cb86727-db06-4fcb-bee4-85e7b5d47d15" /> <img width="1319" height="488" alt="image" src="https://github.com/user-attachments/assets/13fd9a01-7ec6-49ab-81c3-40b566cbf6e0" /> data from OpenTopography, already credited ## 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: tri.star1011 |
||
|
|
a39413c947 |
Show newest actionable events on top to prevent misclicks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
b0e7d04f6e |
Add help notification system to control panel ℹ️ (#4212)
Resolves https://github.com/openfrontio/OpenFrontIO/issues/3445 ## Description: I copied the PR #3743 from @luctrate (Add army limit warning indicator for team games) to this PR because he didn't respond to requested changes but I thought it's important. I expanded on it, now its a full help message system: **Warnings (orange):** - Army limit: shown in team games with donations when troops exceed 80% of max - Low troops: shown when troops drop below 1k (=> new noob player who clicks too much) <img width="764" height="251" alt="582494157-cf19b13e-a0a9-44e4-8de8-86c007fe9c79" src="https://github.com/user-attachments/assets/6b4996d9-1993-4d2c-98ba-afba17a5ca4d" /> **Info messages (blue):** - Borders a traitor ally: "You can betray traitors without becoming a traitor yourself" (Because its not obvious for new players) - Borders an allied AFK player: "You can attack disconnected players even if you are allied with them" (Because its not obvious for new players) - Borders an AFK teammate: "You can attack disconnected teammates" (Because its not obvious for new players) Info messages only appear when the player has not attacked the relevant neighbor for at least 15 seconds, so they do not show up without reason. <img width="524" height="141" alt="image" src="https://github.com/user-attachments/assets/88d74661-d47e-45a7-9f91-d4f5361114b7" /> New "Help Messages" toggle in settings (default: on) <img width="409" height="105" alt="image" src="https://github.com/user-attachments/assets/24bc8bed-777b-4f72-9451-02116ac39db0" /> Implementation details: - Border detection uses async borderTiles() refreshed every 1s, cached in a Set of nearby player smallIDs - Outgoing attacks are tracked per-target to compute the 15-second idle threshold - New armyLimitWarningThreshold() on Config (returns 0.8) - All user-facing strings go through translateText() with en.json entries AI Model used: MiMo 2.5 Pro ## 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 |
||
|
|
000f1442c4 |
Pulse local spawn ring white→gold for visibility on any terrain
The local player's spawn ring was a static near-white tint, which could wash out against light backgrounds. Drive the ring color from the existing breath animation in SpawnOverlayPass so it pulses between white and gold at 60fps — one end of the pulse always contrasts with the terrain. Remove the now-unused hardcoded self tint from WebGLFrameBuilder; the pass owns the self color now. |
||
|
|
9e80d534fb |
Align nuke trajectory preview silo selection with NukeExecution
The preview arc could show a nuke originating from a silo the game would never fire from. The renderer's silo selection had diverged from the authoritative PlayerImpl.nukeSpawn that NukeExecution uses: - Eligibility: the renderer only excluded inactive silos, but the game also excludes silos that are reloading (isInCooldown) or under construction. - Distance: the renderer used Euclidean distance; the game uses Manhattan. Add isInCooldown() to UnitView mirroring UnitImpl, and update the trajectory preview to filter on isActive && !isInCooldown && !isUnderConstruction and pick the nearest silo by Manhattan distance. When no silo is eligible the trajectory clears, matching canBuild returning false. |
||
|
|
dceb2798b3 |
Smooth build-ghost range circle and nuke trajectory to cursor
The build-ghost icon already tracked the cursor at sub-tile precision, but the range circle (defense post / SAM / nuke radius) and the nuke trajectory arc still snapped to the hover tile, making them look jagged as the cursor moved. Range circle: cursorLoop now smooths radiusTileX/Y the same way as the icon, except when upgrading an existing structure (the circle stays anchored to that structure's real tile). Nuke trajectory: split the work by cadence. The throttled renderGhost path caches the static inputs (nearest silo + threatening SAMs) in nukeTrajectoryStatic; cursorLoop rebuilds the Bezier each frame with the live cursor as the destination. Source stays on the silo's tile; only the endpoint follows the cursor. All three previews now use the same tile-center (+0.5) convention. |
||
|
|
5cb155267d |
Only notify on warship and transport ship destruction
displayMessageOnDeleted fired a "Your {unit} was destroyed" message for
nearly every unit type (denylisting only MIRV warheads and train cars),
which spammed the event feed with low-stakes losses like ports, cities,
and trade ships.
Flip it to an allowlist: only surface destruction of warships and
transport ships, the two losses with real tactical weight (lost naval
control, lost army at sea). Everything else is either visible on the map
or not worth a notification.
|
||
|
|
9396df1ca4 |
Remove unit capture event messages
Drop the "Your {unit} was captured by {name}" and "Captured {unit} from
{name}" display messages on unit ownership change in UnitImpl. They fired
on every capture — dominated by warships taking trade ships — and were
too spammy to be useful, so players tuned them out.
Also clean up the now-unused pieces:
- Remove the UNIT_CAPTURED_BY_ENEMY message type, its category mapping,
and its case in getMessageTypeClasses.
- Remove the orphaned unit_captured_by_enemy and captured_enemy_unit
en.json keys.
CAPTURED_ENEMY_UNIT is kept — still used by the trade-ship gold message.
|
||
|
|
042820d56c |
Fix alliance request event display edge cases
Suppress the "Alliance request sent" event when the action actually accepts an existing incoming request rather than sending a new one. This happens both when clicking "Accept" on a request card and when sending a request to a player who already requested one from us — in both cases EventsDisplay now checks whether the recipient is already requesting an alliance with us and skips the confirmation. Also clear stale incoming alliance request cards in ActionableEvents once the request is resolved: handle AllianceRequestReply to remove the card when accepted/rejected, and drop ALLIANCE_REQUEST cards in tick() whose requestor is no longer requesting an alliance with us. |
||
|
|
90efb84168 |
Fix/expiration window staying after expiration (#4213)
Resolves #4209 ## Description: While filtering events in the src/client/hud/layers/ActionableEvents.ts tick function, we remove from the event list any event where the requestor is not requesting an alliance anymore. This excludes alliance requests where the requestor attacked (or bombed) AND alliance requests where the recipient accepted through the radial menu. ## 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 |
||
|
|
fe0b79ef21 |
Feat: Favourite maps tab (#4207)
Resolves #4202 ## Description: As suggested in some suggestions in the main OF server [[thread](https://discord.com/channels/1284581928254701718/1472496670267805782)], we should have a map favouriting system since there are over 70 maps already. People (myself included) have some maps we constantly play during solo/private matches, so a favourite tab would be huge. This feature adds the favourites tab to the solo and private match selection screens. It works using localStorage for saving (device persistence) but I can just as easily implement an infra update where players have a 1-many relation with a `FavouriteMaps` table. That can be a future solution. Video example right now: https://github.com/user-attachments/assets/e8e278ab-d305-499a-81a9-d570e05db051 ## 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: bijx |
||
|
|
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 |
||
|
|
dda47b0813 |
Make clan tag warning clickable (#4163)
> **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 #4154 ## Description: Adds a join path from reserved clan tag warnings to the clan detail modal. https://github.com/user-attachments/assets/cc0f4cb8-be8e-414a-8147-7a744069999e ## 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: aotumuri |
||
|
|
e38b25f206 |
Fix missing boat sprite icon in attacks panel (#4141)
Resolves #4100 ## Description: The boat row in the attacks panel (bottom-right UI) rendered an empty slot where the tinted boat sprite icon should appear, for both incoming and outgoing transport boats. Root cause: `loadAllSprites()` in `SpriteLoader.ts` was never called. It was previously invoked by a canvas layer that has since been deleted, so the sprite map stayed empty. As a result `getColoredSprite()` threw, `AttacksDisplay.getBoatSpriteDataURL()` caught the error and returned `""`, and the icon rendered blank. This fix calls `loadAllSprites()` from `AttacksDisplay.init()` (currently the only consumer of the sprite loader), so the sprite map is populated at startup. ### Demo after fix: <img width="800" height="572" alt="CleanShot 2026-06-03 at 18 51 01" src="https://github.com/user-attachments/assets/e64a1ef7-da48-4662-a8c4-7234a8307730" /> ## 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 (N/A — no user-facing text added; only a console error log) - [x] I have added relevant tests to the test directory (smoke tested locally, see demo recording above) ## Please put your Discord username so you can be contacted if a bug or regression is found: cool_clarky |
||
|
|
3aaf0ea05d |
Remove lakes from the game 🌊 (#4214)
## Description: Nametags look weird here because on the left is a lake: <img width="954" height="765" alt="Screenshot 2026-06-10 170116" src="https://github.com/user-attachments/assets/2b679a68-fab3-458e-8e29-e12b9a4f281b" /> I removed isLake from the nametag position calculation Because isLake was unused then, I removed it completely. Full changelog: - Remove isLake() from GameMap interface, GameMapImpl, GameImpl, and GameView - Remove TerrainType.Lake enum value - terrainType() now returns Ocean for all water tiles (previously distinguished lake vs ocean, but nothing treated them differently) - Remove Lake case from PastelTheme and PastelThemeDark (already fell through to Ocean) - Exclude lakes from nametag placement grid in NameBoxCalculator Maybe as a next step also remove lakes metadata from the map generator? AI Model used: MiMo 2.5 Pro ## 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 |
||
|
|
f17ca9bd65 | meta: increase trade ship sigmoid midpoint 200 => 400 | ||
|
|
3552b08f7a |
Color player name labels by player type
Name text fill now darkens based on player type so human players stand out from AI: human = black, nation = a bit gray, bot = greyer. Shades are tunable in render-settings.json (nameShadeNation, nameShadeBot; human is always 0). Repurpose the previously-unused pd3.z slot (was isHuman, dead in the fragment shader) to carry a per-player grayscale shade, and use it as the name fill color directly so it applies in both day and night. |
||
|
|
b5840d7887 |
Fix troop count precision in name labels, throttle/stagger updates
Replace the hand-rolled formatTroops() in the name-pass with the canonical renderTroops() so map name labels match troop precision used elsewhere in the UI (Leaderboard, PlayerPanel, etc.). Also refresh each player's troop string at most every 500ms instead of every simulation tick, staggered by slot index so GPU string uploads spread across the window rather than bursting on a single tick. |
||
|
|
cb9cab9aca |
Keep static spawn timer for singleplayer games
PR #4198 made the spawn-phase timer count down numSpawnPhaseTurns(), but singleplayer never adds SpawnTimerExecution (GameRunner.ts), so its spawn phase doesn't end on a timer — it ends when the player spawns. The countdown would tick to 0 at ~10s while the phase kept going. In GameRightSidebar.tick(), restore the old static display (maxTimerValue * 60, or 0 when unset) during spawn phase for Singleplayer games, leaving the countdown for all other game types. Uses an explicit gameType check rather than _isSinglePlayer so replays of multiplayer games still count down. |
||
|
|
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. |