Commit Graph

4003 Commits

Author SHA1 Message Date
Evan 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>
2026-06-12 12:25:54 -07:00
FrederikJA 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
2026-06-12 12:22:03 -07:00
FloPinguin 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
2026-06-12 09:35:43 -07:00
Evan 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>
2026-06-12 09:29:10 -07:00
Evan 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>
2026-06-12 09:26:21 -07:00
Katokoda 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
2026-06-12 08:50:00 -07:00
Evan 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>
2026-06-12 08:15:01 -07:00
Evan 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>
2026-06-11 22:12:41 -07:00
Evan 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>
2026-06-11 21:06:48 -07:00
Evan 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>
2026-06-11 20:37:09 -07:00
FrederikJA 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
2026-06-11 20:11:34 -07:00
Evan 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>
2026-06-11 20:07:16 -07:00
Evan 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>
2026-06-11 19:58:42 -07:00
Evan 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>
2026-06-11 19:36:53 -07:00
Evan 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>
2026-06-11 18:52:18 -07:00
evanpelle 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.
2026-06-11 17:07:25 -07:00
evanpelle f50456c688 Fix crash when HeadsUpMessage renders before game is assigned 2026-06-11 15:48:38 -07:00
evanpelle 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.
2026-06-11 15:44:02 -07:00
evanpelle 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").
2026-06-11 15:27:56 -07:00
evanpelle 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.
2026-06-11 14:41:46 -07:00
evanpelle 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.
2026-06-11 14:12:12 -07:00
Evan 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>
2026-06-11 12:50:50 -07:00
evanpelle 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.
2026-06-11 12:22:33 -07:00
a-happy-goose 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>
2026-06-11 12:15:34 -07:00
RickD004 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>
2026-06-11 10:55:46 -07:00
noahschmal 21776e81af Feature/colorblind mode (#4150)
**Add approved & assigned issue number here:**

Resolves #2549

## Description:

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

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

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

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

## Please complete the following:

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

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

jetaviz
2026-06-11 10:53:03 -07:00
Evan 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
2026-06-11 09:25:13 -07:00
RickD004 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
2026-06-10 20:00:53 -07:00
evanpelle a39413c947 Show newest actionable events on top to prevent misclicks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:04:34 -07:00
evanpelle 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
2026-06-10 18:57:02 -07:00
FloPinguin 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
2026-06-10 17:00:24 -07:00
evanpelle 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.
2026-06-10 16:54:05 -07:00
evanpelle 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.
2026-06-10 16:26:29 -07:00
evanpelle 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.
2026-06-10 16:20:14 -07:00
evanpelle 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.
2026-06-10 16:00:29 -07:00
evanpelle 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.
2026-06-10 15:58:25 -07:00
evanpelle 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.
2026-06-10 15:40:41 -07:00
Katokoda 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
2026-06-10 13:53:26 -07:00
bijx 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
2026-06-10 13:51:37 -07:00
Katokoda 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
2026-06-10 13:44:37 -07:00
Aotumuri 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
2026-06-10 13:42:22 -07:00
Cameron Clark 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
2026-06-10 13:39:53 -07:00
FloPinguin 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
2026-06-10 13:20:52 -07:00
evanpelle f17ca9bd65 meta: increase trade ship sigmoid midpoint 200 => 400 2026-06-10 10:39:59 -07:00
evanpelle 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.
2026-06-09 19:58:46 -07:00
evanpelle 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.
2026-06-09 19:35:32 -07:00
evanpelle 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.
2026-06-09 19:22:11 -07:00
evanpelle 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.
2026-06-09 19:16:04 -07:00
crunchybbb 855695b78e Adds Hong Kong map (#4191)
> **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 #4152(issue number)

## Description:


- Adds a map of Hong Kong. The size is 2781x1997 with land area of 41%
(2.2mil pixels). The islands, straits, harbors, coastlines and
peninsulas make for some very intersting gameplay.
- HK is the second densest place on earth. To simulate this, there are
71 nations based on districts, parks, islands, etc. (Kowloon and HK
Island are so crowded with nations, there may be only 1-2 tribes that
spawn there!)
- Large coastal plains, passes and mountain ranges across islands and
the mainland

map image
<img width="2781" height="1997" alt="hk-improvedriver"
src="https://github.com/user-attachments/assets/ef324fca-88f7-487c-adb0-fa31fc370458"
/>

showcase https://www.youtube.com/watch?v=DosBDttQVmE

## 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:

DISCORD_USERNAME crunchybbbbb

---------

Co-authored-by: RickD004 <realtacoco@gmail.com>
2026-06-09 18:39:48 -07:00
Blake Girardet 90e4dd0677 Fixes malformed flag svg url in playerRow (#4203)
Resolves #4194 

## Description:

Fixes the malformed flag svg link when viewing the player row component.

This has been tested by temporarily registering a route to the game-info
modal locally and confirming the flag svg now loads.

Local before

<img width="698" height="500" alt="image"
src="https://github.com/user-attachments/assets/a5bd0958-e4f2-4ab6-9203-b49e42a34ca7"
/>

---
Local after

<img width="770" height="573" alt="Screenshot 2026-06-09 at 6 56 17 PM"
src="https://github.com/user-attachments/assets/ffc64c50-f0d9-4c22-9325-34924b68c985"
/>

## 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:

Caidora
2026-06-09 18:39:19 -07:00