Commit Graph

460 Commits

Author SHA1 Message Date
Evan 7fa81c6bb9 perf: reduce core live-memory footprint by 45% on large maps (#4507)
## Summary

Reduces the simulation's steady-state memory footprint. On Giant World
Map at 20 game-minutes (12 000 ticks, 400 bots, seed `perf-default`),
live memory after a full GC drops **293 MB → 161 MB (−45%)**; unforced
peak heap drops **326 MB → 165 MB**. The simulation also runs ~10%
faster (85 → 94 ticks/s). The final game-state hash is **bit-identical**
(`57830793797434300`) — no behavior change.

## Measurement (first commit)

The full-game perf harness gains a footprint mode:

- `--footprint` — forces a full GC at every `--window` boundary and
records the live heap / ArrayBuffer / RSS curve across the game
(requires `NODE_OPTIONS=--expose-gc`).
- `--snapshot-at 0,2000,12000` — writes V8 `.heapsnapshot` files at
chosen ticks.
- `HeapSnapshotRetainers.ts` — attributes every heap node to its nearest
meaningfully-named retainer (e.g. `PlayerImpl._tiles`), plus prints
retainer chains for all nodes ≥128 KB. `HeapSnapshotSummary.ts` is a
streaming fallback for snapshots too large to `JSON.parse`.

Baseline attribution at tick 12 000: player `_tiles`/`_borderTiles` Sets
**83 MB**, GameMap `refToX`/`refToY` lookup tables **38 MB**, two
duplicate 30.5 MB visited-scratch arrays, trade-ship stepper paths **15
MB**, a construction-only flood-fill queue **9.5 MB**.

## Optimizations

**Map-sized buffers (second commit):**
- `GameMap.x()/y()` compute `ref % width` / `(ref / width) | 0` instead
of reading two per-tile Uint16 tables (−38 MB). The arithmetic is
cheaper than the tables' random-access cache misses — this is where the
speedup comes from.
- `PlayerExecution` and `SpatialQuery` each kept their own per-game
generation-stamped visited `Uint32Array`; both now share one via
`TileTraversalScratch` (−30 MB).
- `PathFinderStepper` stores numeric paths as `Uint32Array` (half the
bytes; steppers hold their full path for a unit's whole journey).
- `ConnectedComponents` frees its flood-fill queue after `initialize()`.

**Player tile sets (third commit):**
- New `TileSet`: insertion-ordered set of tile refs backed by a dense
`Uint32Array` plus an open-addressing hash index — ~12 bytes/element vs
~34 for a native `Set<number>`. Deletes tombstone; compaction is
deferred while iteration is in progress so positions never shift under
an iterator.
- Iteration semantics match `Set` exactly (insertion order, entries
added mid-iteration visited, deleted ones skipped, delete+re-add moves
to end) — the simulation relies on this order for determinism, and the
unchanged hash confirms it.
- `Player.borderTiles()` now returns `ReadonlyTileSet` (a native `Set`
still satisfies it structurally); `GameRunner.playerBorderTiles` copies
into a real `Set` since that result crosses the worker boundary via
structured clone.

## Footprint curve (giant world map, live MB after forced GC)

| checkpoint | before | after |
|---|---|---|
| spawn end | 20 + 100 buf | 20 + 55 buf |
| tick 6301 | 119 + 161 buf | 29 + 127 buf |
| tick 12301 | 130 + 161 buf | 32 + 129 buf |

## Validation

- Final hash `57830793797434300` identical across baseline / round 1 /
round 2 runs (12 000 ticks).
- Full suite passes (1798 + 126 tests), including new `TileSet` tests:
order semantics, mutation-during-iteration parity with `Set`, tombstone
compaction, and a 20 000-op randomized differential test against native
`Set`.
- Runs recorded in
`tests/perf/output/footprint-{baseline,round1,round2}-giant.txt`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:25:29 -07:00
Zixer1 66063d6178 feat(doomsday-clock): decay warships alongside troops for doomed sides (#4499)
## Description: 

Follow-up to #4469.

The Doomsday Clock drains a doomed side's troops but leaves its navy
untouched, so a coastal or island turtle can sit below the bar
indefinitely on warship defense, exactly the stall the clock is meant to
break.

This decays the warships of a flagged (sub-threshold, non-leader) side
on the same ramp as its troops:

- Each warship loses a percentage of its (veterancy-adjusted) max health
per second, reusing `doomsdayClockDrain`, so the fleet and the army
bleed in lockstep and reach zero together (~55s from full at the default
rate).
- Destruction passes **no attacker**, so it routes through
`UnitImpl.delete` as an environmental loss: no kill credit, no
boat-destroy stats, no veterancy granted. Scoring integrity is
preserved.
- Healing is suppressed for a flagged owner
(`WarshipExecution.healWarship` early-returns), so the decay actually
sinks the fleet instead of being out-healed at a port. Inert when the
mode is off, since the mark is never set.
- The leader's fleet is spared, same as its troops.

No new config: warships reuse the existing drain curve. No HUD change,
since warships count as part of the side's forces alongside troops.

Tested: 4 new unit tests (same-ramp decay, no-kill-credit destruction,
leader spared, warn-window grace), the full `DoomsdayClockExecution` and
`Warship` suites, the whole test suite (1784 passing), `build-prod`, and
a headless full-game sim run (resolves cleanly with the decay live,
deterministic).
2026-07-03 15:07:28 -07:00
Evan 20c81ca5f6 perf: cut core-sim GC churn another 36% (75% cumulative) (#4498)
## Summary

Round 3 of GC-churn reduction (follow-up to #4494 and #4496). All
changes are **behavior-preserving** — final game-state hash unchanged on
three seeded runs.

### Changes

| Site | Change | Churn target |
|---|---|---|
| `MiniMapTransformer` | Path upscaling works in pure numeric
coordinates and emits main-map `TileRef`s directly, replacing three
intermediate `Cell`-object arrays per path (cell path → scaled path →
smoothed path → final map). Identical arithmetic, so identical rounding
and identical tiles. | ~7.1 GB |
| `ShoreCoercingTransformer` | Reused neighbor buffers instead of
`neighbors()` arrays; no per-call `{water, original}` objects.
Tie-breaking preserved (helpers share the unified N,S,W,E order since
#4495). | ~1.5 GB |
| `diffPlayerUpdate` | Allocation-free all-equal fast path. Runs per
player per tick and usually returns `null` (gold/troops/tiles travel via
packed arrays), but previously allocated the diff object + a closure
first. Field list matches the diff exactly. | ~2.6 GB |
| Large-`Set` iteration | `for..of` over a `Set` allocates an
iterator-result object per element — significant on 100k-tile border
sets. `calculateClusters`, `calculateBoundingBox` (indexed fast path for
arrays too) and `getAttackFrontTiles` (also dropped its `neighbors()`
arrays) now use `Set.forEach`. | ~3.6 GB |

### Results (Giant World Map, 400 bots, 12,000 ticks, seed
`perf-default`)

| Metric | Before | After | vs. original (pre-#4494) |
|---|---|---|---|
| Sampled allocations (incl. collected) | 37.8 GB | **24.1 GB (−36%)** |
97.7 GB (**−75%**) |
| Ticks/sec | 82 | **88** | 66 (+33%) |
| Mean / p99 tick | 12.2 / 36.0 ms | 11.3 / 34.8 ms | 15.2 / 49.9 ms |
| Peak heap | 762 MB | 529 MB | 758 MB |

## Determinism

Final hash unchanged on all three reference runs:
- Giant World Map 12,000 ticks: `57830793797434300` ✓
- Giant World Map 2,000 ticks: `55125379638382860` ✓
- World 1,800 ticks: `32337437717390864` ✓

## Test plan

- [x] Full suite green (1,906 tests; the `getAttackFrontTiles` test stub
gained a `neighbors4` implementation to match the real interface)
- [x] Hash equality on 3 seeded headless runs (2 maps)
- [x] Before/after 20-min GC benchmarks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:44:21 -07:00
Evan 9e9c608053 perf: cut core-sim GC churn another 36% (61% cumulative) (#4496)
## Summary

Round 2 of GC-churn reduction, attacking the next tier of allocation
sources found by the profiling harness from #4494. All changes are
**behavior-preserving** — the simulation is bit-identical (final hash
unchanged on three seeded runs).

### Changes

| Site | Change | Churn target |
|---|---|---|
| `Player.units()` / `Game.units()` | Rest parameter → fixed-arity +
array overloads (`units()`, `units(types[])`, `units(t1, t2?, t3?)`).
The rest array was allocated on **every call** of one of the hottest
functions in the sim. Spread call sites (`units(...Structures.types)`)
now pass the array directly. `GameImpl.units()` builds one flat array
instead of `Array.from().flatMap()` per-player intermediates. | ~18 GB |
| `PlayerExecution` cluster flood fill | Results are plain `TileRef[]`
in mark order instead of `Set<TileRef>` — the generation-stamped visited
array already deduplicates, and consumers only iterate/measure. DFS
stack reused across fills. | ~3.7 GB |
| `SpatialQuery.bfsNearest` | Fused generation-stamped BFS with per-game
scratch buffers (`WeakMap`-keyed, same pattern as `PlayerExecution`)
instead of materializing a `Set` of the entire search area per query.
Identical traversal and tie-breaking. | ~2.2 GB |
| `NationWarshipBehavior` ship tracking | Single-pass loops instead of
`filter().forEach()`; dropped defensive `Array.from(set)` copies
(deleting the current entry while iterating a `Set` is well-defined). |
~1.4 GB |

### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-min, seed
`perf-default`)

| Metric | Before | After | vs. pre-#4494 |
|---|---|---|---|
| Sampled allocations (incl. collected) | 59.2 GB | **37.8 GB (−36%)** |
97.7 GB (**−61%**) |
| GC count / total pause | 1,076 / 1,830 ms | 772 / 1,442 ms | 1,682 /
3,313 ms |
| Ticks/sec | 73 | **82** | 66 (+24%) |
| Mean / p99 tick | 13.6 / 39.2 ms | 12.2 / 36.0 ms | 15.2 / 49.9 ms |

`units()` no longer appears in the top-30 allocator list at all. The
remaining leaders (possible round 3): the minimap pathfinding `Cell`
pipeline (~8.5 GB), `diffPlayerUpdate`/`toFullUpdate` per-tick
serialization (~4.6 GB), and iterator allocations (~3.3 GB).

## Determinism

Final game-state hash unchanged on all three reference runs:
- Giant World Map 12,000 ticks: `57830793797434300` ✓
- Giant World Map 2,000 ticks: `55125379638382860` ✓
- World 1,800 ticks: `32337437717390864` ✓

## Test plan

- [x] Full suite green (1,905 tests), including updated `units()`
semantics tests (array overload, snapshot isolation, insertion order)
- [x] Hash equality on 3 seeded headless runs (2 maps)
- [x] Before/after 20-min GC benchmarks on the same commit base

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:02:36 -07:00
blon 3c196cb7e7 crit fix: indian subcontinent map crash (#4479)
Resolves #4401

NOTE: While this PR is an improvment, the Indian subcontinent crash IS
NOT caused by >253 water components, as the map only has ~15 water
components.

## Description:

Fixes a critical browser tab crash ("Aw, Snap! Something went wrong")
when loading the game on the new Indian Subcontinent map (or any map
with >= 253 water components) in Solo Mode.

### Technical Cause:
1. When a map contains >= 253 disconnected water components, the array
mapping tiles to component IDs is dynamically promoted from a Uint8Array
to a Uint16Array.
2. This promotion upgrades the land sentinel LAND_MARKER from 0xff (255)
to LAND_MARKER_WIDE (0xffff / 65535).
3. The BFS local search filter in AStarWaterHierarchical had a hardcoded
sentinel check: (t: TileRef) => this.graph.getComponentId(t) !==
LAND_MARKER (evaluating against 255).
4. On promoted maps, land tiles (65535) matched this check as water. The
local BFS then traversed the entire landmass of the map, resulting in
CPU exhaustion and memory/stack overflows that crashed the rendering
process.

### Solution:
Changed the hardcoded sentinel check to query the map's terrain directly
via this.map.isWater(t). This makes the check immune to any component ID
promotions or sentinel representation upgrades. Verified that the
existing water pathfinding test suite passes successfully.

## Please complete the following:

- [ ] I have added screenshots for all UI updates (N/A)
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file (N/A)
- [x] I have added relevant tests to the test directory (Existing tests
in tests/core/pathfinding/PathFinding.Water.test.ts cover water
pathfinding behavior and run successfully)

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

blontd6
2026-07-03 12:45:30 -07:00
Evan 22d5aba5ae refactor: standardize cardinal-neighbor iteration on neighbors() N,S,W,E order (#4495)
## Summary

Follow-up to #4494. That PR added `forEachNeighborNSWE` as a third
neighbor iterator because the existing allocation-free helpers
(`forEachNeighbor`, `neighbors4`) visit in W,E,N,S order while
`neighbors()` visits N,S,W,E — and substituting one for the other
changes simulation behavior at order-sensitive call sites.

This PR removes that duplication by standardizing on **one order
everywhere**: `forEachNeighbor` and `neighbors4` now visit in the same
N,S,W,E order as `neighbors()`, and `forEachNeighborNSWE` is deleted.

## ⚠️ Intentional behavior change

Callers of the flipped helpers that are order-sensitive now make
different (equally valid) decisions:

- `AttackExecution.addNeighbors` — PRNG values are drawn per neighbor
while building the conquest frontier, so attack expansion patterns
differ
- `AttackExecution.handleDeadDefender` — a dead defender's tiles go to
the *first-visited* adjacent player
- `WarshipExecution.bestNeighborToward` — distance ties break by visit
order
- `PlayerExecution` surrounded-cluster flood fill — set insertion order
propagates to conquer order

Game outcomes for a given seed differ from previous builds (verified:
the 12k-tick reference run ends with 31 players alive vs 24 before).
Determinism across clients *within* a build is unaffected — all clients
run the same code, so there is no desync risk. Replays/verification
pinned to old hashes will not match this build.

New reference hashes for the headless perf harness (seed
`perf-default`):

| Run | Final hash |
|---|---|
| giantworldmap, 12,000 ticks | `57830793797434300` |
| giantworldmap, 2,000 ticks | `55125379638382860` |
| world, 1,800 ticks | `32337437717390864` |

## Verification

- [x] Full suite green (1,901 tests), including new exact-order contract
tests: `forEachNeighbor` and `neighbors4` must match `neighbors()`
contents **and order** for every tile
- [x] 20-game-minute Giant World Map benchmark: no perf regression (73
ticks/sec, GC 1.2% of wall, allocation profile unchanged)
- [x] Order-sensitivity audit of every `forEachNeighbor`/`neighbors4`
call site (sensitive ones listed above; the rest are booleans, counts,
or min/max accumulations)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:42:22 -07:00
Evan be77ab4fc9 feat: structures cosmetic effect (hover-shown gradient/transition recolor) (#4492)
## Description:

Adds a new `structures` cosmetic effect type: an equippable effect that
recolors the owner's structure icons (City, Port, Factory, Defense Post,
SAM Launcher, Missile Silo) with gradient or transition color styles.
The effect is **shown while the owner's territory is hovered** —
structures otherwise keep their normal player colors, so the map stays
readable.

**Cosmetics / selection**
- `StructuresEffectAttributesSchema` (`CosmeticSchemas.ts`): its own
discriminated union (`gradient` / `transition`) — structurally identical
to the trail attributes today, but structures aren't trails, so it's a
separate schema free to diverge.
- Slot = the effectType itself: `effectTypeForSlot` is generalized to
map any non-nukeExplosion effect type to itself, so server privilege
checks (`Privilege.ts`), client selection, and persistence all work with
no per-type code.
- Effects tab, Default tile, and the store preview (shared color swatch)
come from `EFFECT_TYPES`; the only UI addition is the
`effects.type.structures` label in `en.json`.

**Rendering**
- The shared per-player effect palette grows from 2 to 3 blocks
(`EFFECT_PALETTE_BLOCKS`; structures = block 2, pinned by a
build-breaking guard). `syncPlayerEffects` resolves the `structures`
selection through the same `writeEffectEntry` used by trails.
- `StructurePass` binds the effect texture plus `uTime` and
`uHoverOwner` (fed from the existing `HoverHighlightController` →
`setHighlightOwner` path, now forwarded to the pass).
- `structure.frag.glsl` recolors the **fill only** — the border keeps
the player color for ownership legibility; alt view and construction
gray bypass the effect entirely.
- Style semantics:
- `gradient` — the palette spans each icon's diagonal once (a visible
gradient across the shape), sliding one full cycle every `colorSize · 4
· count / movementSpeed` seconds (the trail-equivalent pace; world-space
banding like the trail's would put a whole icon inside one band and read
as a flat color)
- `transition` — the whole icon is one color at a time, cross-fading at
`frequency` colors/s
- Glyph contrast: the inner icon's black/white decision is now a smooth
luminance fade (`smoothstep(0.25, 0.45)`) instead of a hard flip at
0.25, so animated fills cross-fade the glyph instead of snapping it
between black and white.

## 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
- [x] I have added relevant tests to the test directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:41:39 -07:00
Evan 5e4b2791aa perf: reduce core-sim GC churn 42% and add GC-churn profiling to the perf harness (#4494)
## Summary

Reduces core-simulation GC churn by **42%** on a 20-game-minute Giant
World Map run, and extends the headless full-game perf harness so churn
is measurable and regressions are visible.

### 1. GC-churn measurement (`tests/perf/fullgame/GcProfiler.ts`)

`npm run perf:game` now reports:

- **GC pauses** by kind (minor/major/incremental) via a
`PerformanceObserver` on `'gc'` entries, bucketed into tick windows by
timestamp (V8 only delivers these entries on a timer task, so they're
flushed after the run)
- **Allocation rate** per `--window N` ticks (default 1000) from
used-heap deltas sampled every tick, so churn can be tracked across game
phases
- **Top allocating functions** from the V8 sampling heap profiler with
`includeObjectsCollectedBy{Major,Minor}GC` — i.e. actual churn including
short-lived garbage, not live memory — plus a `.heapprofile` loadable in
Chrome DevTools (Memory → Allocation sampling)

New flags: `--window N`, `--no-gc-profile`, `--no-alloc-profile`.

### 2. Allocation reductions in the hot paths it found

| Site | Change |
|---|---|
| `GameMap.bfs` | inline neighbor enumeration instead of an array per
visited tile |
| `GameMap`/`Game` | new `forEachNeighborNSWE` — allocation-free
iterator matching `neighbors()` N,S,W,E order for order-sensitive
callers (`forEachNeighbor` visits W,E,N,S, so substituting it would
change sim behavior) |
| `PlayerImpl.nearby` / `sharesBorderWith` / `shoreReachableNeighbors` |
no per-call neighbor arrays; no materialized shore-tile array |
| `PlayerImpl.units(types)` | gather into a reusable scratch buffer,
return one exact-size slice (still a fresh snapshot array per call) |
| `AiAttackBehavior.maybeAttack` | single pass over border neighbors
replacing the `flatMap`/`filter`/`map` chain over every border tile |
| `AiAttackBehavior.isBorderingNukedTerritory` | reusable `neighbors4`
buffer with early exit |
| `SharedWaterCache.build` | allocation-free neighbor iteration |
| `SpatialQuery.bfsNearest` | first-minimum scan instead of
collect-then-stable-sort (identical result incl. tie-breaking) |

### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-minutes,
seed `perf-default`)

| Metric | Before | After |
|---|---|---|
| Sampled allocations (incl. collected) | 97.7 GB | **56.9 GB (−42%)** |
| GC count / total pause | 1,682 / 3,313 ms (1.8% of wall) | 1,058 /
2,087 ms (1.2%) |
| Ticks/sec | 66 | 70 |
| p99 / max tick | 49.9 ms / 988 ms | 43.5 ms / 689 ms |
| Ticks over 100 ms budget | 31 | 19 |

## Determinism

Every rewrite preserves exact iteration order (the new NSWE iterator
exists precisely for the order-sensitive sites). Verified by identical
final game-state hashes on three runs: Giant World Map 12,000 ticks
(`67286276735690560`), Giant World Map 2,000 ticks, and World 1,800
ticks.

## Test plan

- [x] Full suite green (1,896 tests)
- [x] New tests: `forEachNeighborNSWE` order contract vs `neighbors()`
over every tile; `units()` filtering semantics (insertion order,
fresh-array guarantee, duplicate types, Set path)
- [x] Final-hash equality on 3 seeded headless runs (2 maps)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:30:28 -07:00
Zixer1 78ef7b56fd feat(doomsday-clock): battle-royale style zone gamemode (#4469)
Resolves Issue #4463

## Description:

An optional game mode that (almost) guarantees a finish instead of
letting late-game
stalemates drag on.
Originally called sudden death, renamed to Doomsday clock

Once enabled, every side (each player in FFA, each whole team in team
modes)
must hold a rising share of the map. A side below the bar is skulled;
after a
short warn its troops bleed to zero, forcing consolidation to a winner.

### How it works
- **Rising zone:** a grace period, then the required share ramps up
linearly to
each level with 30s pauses between (a battle-royale "zone"). Levels
track the
  ofstats FFA territory median (3/5/10/20/30%).
- **Four speed presets** (slow / normal / fast / very fast) change only
the pace:
  normal ends ~30 min, very fast ~15.
- **Troop decay:** a linear ramp as a % of max capacity, ~50s from
caught to zero
  (10s warn + ~50s ≈ 1 min total).
- **UI:** a HUD panel (live share vs target, wave/decay countdowns,
red/orange
cues) and an on-map skull above flagged players (blinks in danger,
steady while
  draining).

### Notes for review
- Off by default; no effect on existing games. However, as discussed we
can add it to the modifier pool for public games to see how popular the
gamemode is vs normal play.
- Sim is deterministic (integer-only, in `src/core`), covered by unit +
  integration tests.
- One-line addition to `GameServer.updateGameConfig` so the setting
survives the
  host → server → client round-trip.
- Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2
=
none/danger/draining); the skull is composited into the icon atlas at
load.

### Testing
`npm test`, `npm run lint`, `npx prettier --check .`, `npm run
build-prod` all pass.

### UI:
<img width="243" height="100" alt="Image"
src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74"
/>

Dropdown between slow, normal, fast, very fast

Before zone:
<img width="302" height="175" alt="Image"
src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d"
/>

Zone started, player not affected the pannel also blinks orange for 10s:
<img width="297" height="175" alt="Image"
src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899"
/>

Player affected, grace period (Danger):
<img width="314" height="170" alt="Image"
src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f"
/>

Skull icon blinking over player (everyone sees it) - older screenshot,
the clipping has been fixed
<img width="462" height="145" alt="Image"
src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad"
/>

Player affected, grace period ended (Draining):
<img width="360" height="159" alt="Image"
src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2"
/>

Skull icon no longer blinking, everyone can see you are in a state of
decay, and troops are draining:
<img width="732" height="146" alt="image"
src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901"
/>


Skull is visible like alliances icon also on player tab
<img width="558" height="81" alt="Image"
src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f"
/>

(just UI example, best way to see it is to hop on a solo game and play
against AI)

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

zixer._
2026-07-02 18:42:03 -07:00
Evan b72956d0c0 Gate users without GPU-accelerated WebGL2 instead of running at ~1fps (#4324)
## Problem

After the WebGL2 renderer migration, a small number of users (~a dozen
of 100k DAU) report ~5fps. Root cause: they run WebGL **without GPU
acceleration** (hardware acceleration disabled, blocklisted driver, or a
locked-down machine), so they get a SwiftShader/software context.
Software-rendered WebGL is hopeless for a real-time game — ~1fps
locally.

We are not supporting a canvas2d fallback. Instead: demand a
GPU-accelerated context, and if we can't get one, **gate** the user with
actionable instructions rather than letting the game crawl.

## What this does

- **`initGL()`**
([initGL.ts](../blob/webgl-software-render-gate/src/client/render/gl/initGL.ts))
— demands `failIfMajorPerformanceCaveat: true` **and** inspects the
unmasked renderer string. The flag alone isn't enough: when hardware
acceleration is turned off in browser *settings* (vs. a blocklisted
driver), Chrome still hands back a SwiftShader context, so we'd
otherwise run at 1fps. Classifies the outcome as `ok` / `software` /
`unsupported`.
- **`GPURenderer`** throws `GLUnavailableError` on a non-accelerated
context; the game-start `catch` shows the gate and removes the orphaned
canvas.
- **`<webgl-gate>`** Lit component renders a full-screen blocking gate
with per-browser steps (Chrome / Edge / Firefox / Safari) for enabling
hardware acceleration / WebGL.
- **`gl_init` analytics event** fires every session (`status` +
`renderer` for non-ok) via the existing Google Tag, so we can size the
real affected % within a day.

## Notes / decisions

- The gate copy is **intentionally inlined (not translated)** — it's a
rarely-seen, browser-specific troubleshooting screen; 28 Crowdin keys
would be poor cost/benefit, and a non-English user still has to navigate
English browser menus.
- `showGLGate` lazy-loads the component (`import()`), so the `render/gl`
module that `Renderer.ts` imports doesn't statically pull a UI component
into its graph.

## Update: fingerprint-capped contexts (#4357)

A third failure class, integrated after the initial PR:
`privacy.resistFingerprinting` (default-on in LibreWolf and Mullvad
Browser, opt-in in Firefox) caps `MAX_TEXTURE_SIZE` at 2048 on an
otherwise hardware-accelerated context. The renderer unconditionally
allocates a 4096-wide palette texture, so the oversized `texImage2D`
calls fail silently and the whole map renders **black** (#4357).

- `initGL` now reads `MAX_TEXTURE_SIZE` after the software check and
classifies the context as **`limited`** when it's below
`getPaletteSize()` (4096 — the hard floor every game needs).
- Unlike `software`/`unsupported`, **`limited` is a warning, not a hard
block**: `initGL` still returns the context, the game starts normally,
and the gate is shown with a "Continue anyway" button. `GPURenderer`
exposes the capped renderer/size via `glLimited` (surfaced through
`MapRenderer`), which `ClientGameRunner` uses to show the warning and
log analytics.
- The gate shows fingerprinting-specific instructions for `limited` (add
the site to `privacy.resistFingerprinting.exemptedDomains` in
`about:config`) instead of the hardware-acceleration steps.
- `gl_init` reports `max_texture_size` alongside the renderer for this
status, so we can size the RFP-affected population too.

Fixes #4357

## Test plan

- [x] Unit tests for `initGL`'s `ok` / `software` / `unsupported`
branching, incl. the "returns a context but renderer is software" case
(`tests/client/initGL.test.ts`).
- [x] lint / prettier / tsc clean.
- [x] **Verified in real browsers (macOS).** All three gate states
reproduced:
- `software`: Chrome with `--use-gl=angle --use-angle=swiftshader`
(confirmed "Software only" at `chrome://gpu`), and Chrome with hardware
acceleration toggled off in settings — both show the hard gate instead
of a 1fps game.
- `unsupported`: Firefox with `webgl.disabled=true` shows the
unsupported gate.
- `limited`: Firefox with `privacy.resistFingerprinting=true`
(MAX_TEXTURE_SIZE capped to 2048, same as LibreWolf's default) shows the
dismissible warning; "Continue anyway" starts the game, and exempting
the site via `privacy.resistFingerprinting.exemptedDomains` removes the
warning.

## Acceptance criteria

- Accelerated users: unchanged.
- Software / no-accel users: see the enable-acceleration gate, not a
1fps game.
- No-WebGL2 users: see the unsupported gate.
- `gl_init` fires every session with status (+ renderer for non-ok).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:54:06 -07:00
Evan b6317964a7 feat: sparkles nuke-explosion visual type (#4490)
## Description:

Follow-up to #4485: adds a second nuke-explosion visual, `"sparkles"` —
a firework burst of twinkling glints that start at the detonation point
and ride outward with the expanding front, reaching the cosmetic's full
`size` at fade-out.

**Schema (`CosmeticSchemas.ts`)**
- `NukeExplosionAttributesSchema` is now a discriminated union on `type`
(`"shockwave" | "sparkles"`), matching `TrailEffectAttributesSchema`.
Old clients drop sparkles entries via `lenientRecord` and render the
default ring.
- The sparkles member adds `density` (required, positive) — roughly the
total number of glints in the burst.
- Literal attribute semantics, consistent with shockwave:
  - `size` — final burst width (diameter) in world tiles at fade-out
- `speed` — tiles/s the width grows; duration = size / speed, clamped
0.1–15 s
- `thickness` — **average** sparkle size in tiles; each glint
hash-varies ±50% around it
  - `density` — approximate glint count; renderer clamps to 2–5000
- `colors` + `transitionSpeed` — shared palette-cycle semantics, with a
hashed per-glint palette offset on top

**Rendering**
- `NukeExplosionRenderParams` now carries the visual type through to the
pass as a matching TS union (previously any cosmetic was hardwired to
the EMP style — this closes that gap for future visuals).
- Sparkles are style 2 in the same `FxShockwavePass` instance stream:
one new float (grid cell pitch, derived CPU-side from density), no other
layout changes.
- Fragment shader: one hashed glint per rotated front-normalized grid
cell (jittered, cell-confined so each fragment samples only its own
cell, ~1/3 dropout for organic scatter), hashed birth stagger. Glints
are **fully opaque** — twinkle modulates color brightness, not alpha —
holding full opacity through life and fading only over the last quarter.
- SAM interceptions, the classic ring, and EMP shockwaves are unchanged.

**Store / selection**
- New `<sparkles-swatch>` preview (burst scales from center,
density-scaled dot count, size-varied dots, palette cycling), branched
in `CosmeticButton` by `attributes.type`.

**Verification**
- Schema tests incl. the real `rgb_nuke_sparkles` catalog entry,
missing/non-positive `density` rejection.
- Verified in-game via headless Chromium: size-250 RGB burst renders
opaque red/white/blue glints expanding from the detonation point; sparse
(40) vs dense (400) density comparison; no page errors.

## 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
- [x] I have added relevant tests to the test directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:21:26 -07:00
Evan 6ff202afb5 feat: nuke-explosion cosmetic effects (per-bomb-type shockwave customization) (#4485)
## Description:

Adds a new `nukeExplosion` cosmetic effect type: when a bomb detonates,
every client renders the shockwave in the firing player's equipped
effect for that bomb type.

**Cosmetics / selection**
- New `nukeExplosion` effect schema (`CosmeticSchemas.ts`) with per-bomb
selection slots — a slot is the effectType for trails and the `nukeType`
for explosions (`atom` / `hydro` / `mirvWarhead`), so players can equip
a distinct explosion per bomb type.
- Slot resolution + validation is one shared helper
(`findEffectForSlot`) used by client selection, server privilege checks
(`Privilege.ts`), and the renderer; a compile-time guard keeps the
nukeType and effectType slot namespaces disjoint.
- Effects picker gains an Atom / Hydrogen / MIRV sub-tab bar when
browsing nuke explosions; selections persist per slot in UserSettings
and are validated/dropped like other cosmetics.

**Rendering**
- `WebGLFrameBuilder` resolves each dead nuke's owner cosmetic onto the
dead-unit event; `FxShockwavePass` renders an EMP-style procedural ring
(jagged crackling front, rotating lightning arcs, inner energy fill)
from per-instance attributes. SAM interceptions and players with no
cosmetic keep the classic white ring.
- Catalog attributes have literal units:
- `size` — final ring width (diameter) in world tiles at fade-out,
absolute — independent of the bomb's blast radius
- `speed` — tiles/s the width grows; duration = size / speed, clamped to
0.1–15 s
- `thickness` (required) — ring band thickness in tiles, constant while
the ring expands
- `colors` — palette of up to 4 colors, cycled at `transitionSpeed`
steps/s (0 = static, negative = reverse; same semantics as trail
transitions)
- The shockwave quad is sized radius + thickness so the absolute-width
band isn't clipped into a box while the ring is young.

## 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
- [x] I have added relevant tests to the test directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:21:01 -07:00
bijx 006f1690a5 Warship veterancy (#4433)
## Description:

Warship veterancy! This is an idea inspired by the unit veterancy
feature of games like C&C: Red Alert 2 in which unit eliminations
increases the level of individual units. I've been trying to build this
mechanic for months with different ideas, and I finally landed on this
being one of the more balanced implementation.

Warships can earn up to three levels, represented by the gold bar
insignia in the bottom right of their warship sprite.

<img width="622" height="202" alt="image"
src="https://github.com/user-attachments/assets/a8c31a45-4ae9-41a9-b054-9c4a7f4ab1f1"
/>

A veterancy bar grants 20% health from the base amount, and a 20%
increase in shell damage applied _after_ the random damage roll. For
example, a level 3 warship will apply a 60% damage boost on top of the
random shell damage value (something between 200-325. If the random
value is 250, the final damage output will be `250 * 1.60 = 400`.

There are three ways to achieve a veteran level:

1. **Eliminate another warship:** any time a warship neutralizes another
warship, it immediately get's a veterancy increase.


https://github.com/user-attachments/assets/6a9e0958-5171-4ca3-94f6-9c2300a12f8b

2. **Eliminate transport boats:** Destroying 10 transport boats will
level a warship to the next veterancy bar.


https://github.com/user-attachments/assets/619ce0c0-033c-4e0b-9c64-b41eabaa791b

3. **Steal trade ships:** If the warship captures 25 trade ships, it
will earn a veterancy bar.


## 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-07-01 21:38:09 -07:00
Ryan 1d5a6ae246 Account Modal - Games Tab (#4473)
## Description:

Continuation of https://github.com/openfrontio/infra/pull/386, adds play
games sessions

<img width="971" height="771" alt="image"
src="https://github.com/user-attachments/assets/42c6bcbb-d690-4cd1-b859-3299a03f4350"
/>

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

w.o.n

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:12:37 -07:00
Evan 2794ab1270 feat: nuke-trail cosmetic effect + tabbed effects picker (#4466)
## What

Adds a **`nukeTrail`** cosmetic effectType alongside
`transportShipTrail`, so nukes leave a trail colored by their own
gradient/transition effect — independent of the boat-trail effect (a
player can run both). Also reorganizes the effects picker and store into
per-effectType **tabs**.

## Rendering

Boat and nuke trails are stamped into **one** trail texture keyed only
by owner, so independent coloring needs a per-tile unit-class signal:

- **Trail texture** `R8UI` → `R16UI`: texel = `ownerID(bits 0-11) |
nukeBit(bit 12)`. `TrailManager` stamps the bit (and preserves it when
repainting on unit death); the `Uint8Array`→`Uint16Array` ripple +
`UNSIGNED_SHORT` uploads flow through `GpuResources`, `TrailPass`,
`Upload`, `MapRenderer`, `Renderer`, `FrameData`.
- **Effect texture** widened to two stacked blocks
(`TRAIL_EFFECT_BLOCKS`): rows 0–7 = transportShipTrail, rows 8–15 =
nukeTrail. `writeEffectEntry(…, rowBase)`; `syncPlayerEffects` resolves
both effectTypes.
- **Shader** masks the owner, derives `rowBase` from the nuke bit,
offsets every row, and reuses the gradient/transition decode.
- Bonus: the 12-bit owner mask lifts the old `R8UI` >255-player
truncation.

## Schema / server / UI

- Shared attributes schema renamed `TransportShipTrail…` →
**`TrailEffectAttributesSchema`** (it's no longer ship-specific);
`NukeTrailEffectSchema` added to `EffectSchema` +
`CosmeticsSchema.effects`. `EFFECT_TYPES = [transportShipTrail,
nukeTrail]`.
- Server `Privilege`, selection, and the picker grid all iterate
`EFFECT_TYPES`, so they handle the new type with **no per-type code**.
- **Tabs:** the selection modal uses one tab per effectType
(`BaseModal`'s native tabs); the **store's** EFFECTS panel gets an
internal sub-tab bar (its top-level PACKS/EFFECTS tabs can't nest). Tabs
are always present, so a type you own entirely still appears as an empty
tab (previously the boat-trail section vanished from the store when you
owned everything).

## Review

A 3-angle adversarial review (bit-packing, type-ripple, GLSL/data-flow)
**refuted** the correctness concerns — the R16UI format, masking, and
block layout agree across `TrailManager` / shader / builder. The minor
survivors (a preview that only resolved boat trails, stale comments)
were fixed.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` — all clean.
- Schema/`Privilege` tests updated for `nukeTrail` (96 tests pass).
- The GL trail + tab UI are visual — not yet verified in a running game.
- The catalog (`cosmetics.json`, closed-source API) must ship the
`effects.nukeTrail` block for the effect to appear in production.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:13:41 -07:00
TKTK123456 0d2179f5f3 Input handler.ts rework (#4225)
> **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 #4193 

## Description:

Use activeKeys set in places where it is checking if a key is being
pressed in a different way, and it makes more sense to use the
activeKeys set. Make the overall code of the InputHandler.ts file more
consistent and to make it easier to add new keybinds in the future.

<img width="1920" height="1080" alt="Screenshot from 2026-06-13
20-49-56"
src="https://github.com/user-attachments/assets/94f6f81c-7278-4bca-845c-2442b6caea39"
/>


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

tktk1234567
2026-06-30 14:17:25 +00:00
Evan 200f276ab2 feat: transport-ship trail transition effect + animated store swatch (#4455)
## What

Adds a second transport-ship trail style, **transition**, alongside the
existing **gradient** (#4454). Where `gradient` paints a spatial band of
colors along the trail, `transition` makes the whole trail one color at
a time, cross-fading through the color list over time.

```json
"attributes": {
  "type": "transition",
  "colors": ["#002aff", "#4805ff"],
  "frequency": 1
}
```

## How

- **Schema** ([CosmeticSchemas.ts](src/core/CosmeticSchemas.ts)) —
`TransportShipTrailAttributesSchema` is now a discriminated union on
`type`:
  - `gradient`: `{ colors, colorSize, movementSpeed }`
- `transition`: `{ colors, frequency }` — `frequency` = color changes
per second.
- **Renderer** — the effect texture gained a `styleId` discriminator
(row 1's alpha; 0 = gradient, 1 = transition), with the gradient scalars
shifted down a row.
- [WebGLFrameBuilder.ts](src/client/WebGLFrameBuilder.ts) encodes
`styleId` + the style's scalars.
-
[trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl):
for `transition`, the trail color is `mix(colors[i], colors[i+1],
fract(t))` with `i = floor(uTime · frequency) mod count` — one color
step every `1/frequency` seconds.
- **Store/picker swatch**
([EffectPreview.ts](src/client/components/EffectPreview.ts)) — the
swatch is now a `<trail-swatch>` Lit element. For `transition` it
cross-fades through the colors via the Web Animations API, timed to
match the shader (each step `1/frequency` s); gradient/solid stay
static. The animation is canceled on disconnect.

## Notes

- Animation is render-only (local time) — no simulation/determinism
impact.
- `gradient` swatches remain static (they don't scroll like the in-game
trail) — easy to add later if wanted.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema tests cover the transition member (parse + required
`frequency`); 95 tests pass.
- The animated swatch is visual-only (no automated coverage) and not yet
verified in a running store.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:53:33 -07:00
Evan 7c151e76ad feat: render transport-ship trail cosmetic as a gradient (#4454)
## What

Renders the `transportShipTrail` cosmetic effect in-game. Transport
ships already left a trail, but it was always drawn in the player's
**territory color** — this wires the selected effect through to the
renderer so the trail shows the player's chosen **gradient**.

## How

- **Per-player effect texture** (`RGBA32F`, mirrors the palette texture)
keyed by `smallID`, sampled by the trail fragment shader. Each row holds
a gradient color; spare alpha channels carry the color count,
`colorSize`, and `movementSpeed`.
- **Shader**
([trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl))
cycles a flowing gradient through the color list: 1 color → flat, 2+ →
animated bands scrolling along the trail. No effect (count 0) falls back
to the territory color; alt-view keeps affiliation colors.
- **WebGLFrameBuilder** resolves each player's catalog attributes (the
in-game cosmetic is only `{ name, effectType }`; the style/colors live
in the catalog) and encodes them. Resolution is decoupled from the
first-seen palette path so it retries until the catalog loads, and
unparseable colors are dropped so bad catalog data degrades to the
territory color rather than rendering black.

## Schema

Collapses the trail attributes to a single gradient shape:

```ts
{ type: "gradient", colors: string[], colorSize: number, movementSpeed: number }
```

- `colors` — solid = one color, rainbow = the spectrum, gradient = two
or more.
- `colorSize` — band width (tiles per color band; `1` is the default, ~4
tiles).
- `movementSpeed` — scroll rate along the trail (tiles/sec; `0` =
static).

## Notes

- Animation is render-only (local time), no simulation/determinism
impact.
- The catalog (`cosmetics.json`, served by the closed-source API) must
ship effects in this `{ type: "gradient", colors, colorSize,
movementSpeed }` shape.
- Band thickness (`4.0` base in the shader) and the gradient frequency
are visual constants picked without in-game verification — easy to tune.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema + Privilege test suites updated for the gradient shape (92
tests pass).
- Not yet visually verified in a running game (effect selection is
flare-gated).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:28:47 -07:00
Ryan dae129c6a3 replace leave lobby popup with custom popup (#4449)
## Description:
old:
<img width="1009" height="491" alt="image"
src="https://github.com/user-attachments/assets/0b95877c-dac7-4025-bdfa-62ab6879d208"
/>

new:
<img width="1017" height="561" alt="image"
src="https://github.com/user-attachments/assets/cfb49b31-eb46-4d64-bd9e-3f25bb7cd0fb"
/>




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

w.o.n
2026-06-29 16:16:46 -07:00
Evan bd9ef9a317 feat: effects cosmetic category (transport-ship trail) + UI (#4418)
## What

Adds a new **`effects`** cosmetic category alongside `skins`/`flags`.
Each effect is discriminated by **`effectType`** (only
`transportShipTrail` today), whose visual config lives in
**`attributes`** (`solid` / `rainbow` / `pulse` / `gradient`). Schema
matches the production cosmetics.json shape exactly (incl. the `url`
field).

**This PR is UI + taxonomy only — the in-game WebGL trail rendering is
intentionally deferred.**

## UI

- **Store** gains an **"Effects"** tab.
- **Home page** gains an **"Effects"** button opening a picker modal.
- Both render effects **grouped by `effectType` with a sub-header per
type**, via a shared `<effects-grid>` Lit element (`mode="select"` for
the picker, `mode="purchase"` for the store). The picker shows owned
effects + a Default tile and persists per-type; the store shows
purchasable effects.

## Data flow

- Ownership via `effect:*` / `effect:<name>` flares (reuses
`cosmeticRelationship`).
- Selection is a per-`effectType` map persisted in UserSettings
(`settings.effects`).
- Server validates in `isEffectAllowed`, wired into `isAllowed`.
- `getPlayerCosmeticsRefs` / `getPlayerCosmetics` resolve effects the
same way as skins/flags (kept-on-fetch-failure, server is authority).

## Tests

- `tsc --noEmit`, ESLint, Prettier clean; full suite green.
- New: `CosmeticSchemas` parse tests (incl. parsing the **real**
`read_transport_trail` entry), `UserSettings` per-type selection, and
`Privilege` effect validation.

## Notes / follow-ups

- The effect's display label shows **"Boat Trail"** for the
`transportShipTrail` type (friendlier than the id).
- Closed-source API gap: `/shop/purchase` (`purchaseWithCurrency`) needs
to learn `"effect"` for **currency** purchase of effects; the
**dollar/product** purchase path already works. Client types were
widened accordingly.
- In-game wake rendering can be ported from #4416.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:13:48 -07:00
FloPinguin ccd0745ad4 Prevent AI from placing ports on small lakes 🚢 (#4429)
## Description:

AI nations were placing ports on small decorative ponds scattered across
maps (Missisipi for example), wasting structure slots on strategically
useless water bodies. This fix adds a water component size check to the
port placement logic so the AI skips lakes that are too small for
meaningful port use. We already had a check for available trade
partners, but trading in small lakes is usually stupid.

**How it works:**
- `ConnectedComponents` now tracks component sizes during its existing
flood-fill (zero extra cost - counts tiles as they're visited)
- `AbstractGraph`, `WaterManager`, and the `Game` interface expose
`getWaterComponentSize(tile)` so callers can query the size of any water
body
- `NationStructureBehavior.randCoastalTileArray()` filters out non-ocean
water components below `MIN_PORT_WATER_COMPONENT_SIZE` (3000 minimap
tiles, ~12000 full-map tiles)
- Ocean tiles bypass the check entirely since they're always large
enough

## 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-28 19:19:58 -07:00
Ryan c622e8581c collapse skins under one banner (#4432)
## Description:

merges skins under one item with a circular button below it:

<img width="877" height="647" alt="image"
src="https://github.com/user-attachments/assets/a405ba34-a970-4e8c-9287-fe0055d6a02e"
/>


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

w.o.n
2026-06-27 19:09:05 -07:00
FloPinguin 71d70dfb0e fix: prevent client from bypassing random spawn selection 🛡️ (#4428)
## Description:

When random spawn mode is active, players are supposed to receive
randomly chosen spawns rather than choosing their own. However,
`SpawnExecution.getSpawn()` checks `center !== undefined` first, which
means if a player manually injects coordinates into the spawn intent
(bypassing the client-side UI guard), the random selection logic is
completely bypassed and the player gets their chosen coordinates.

This was fully exploitable in singleplayer (where no pre-created
`SpawnExecution` objects exist) and was a defense-in-depth gap in
multiplayer (relying on execution order of pre-created spawns to block
it via the `hasSpawned()` guard).

The fix forces `center` to `undefined` in `getSpawn()` when random
spawns are enabled, ensuring the random selection code path is always
taken regardless of what the client sends.

## Changes:
- `src/core/execution/SpawnExecution.ts`: Pass `undefined` to
`getSpawn()` when `isRandomSpawn()` is true, ignoring any
client-specified tile
- `tests/core/execution/SpawnExecution.test.ts`: Added test verifying
that a client-specified tile is ignored when random spawn is enabled

## 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-27 11:10:24 -07:00
FloPinguin 23e05f0115 Fix nations always attacking nuked territory instead of waiting for the correct strategy 🤖 (#4422)
## Description:

Nations always rushed nuked (fallout) TerraNullius instead of
retaliating or attacking enemies. The bug needed two commits to compose:

**#3786** introduced `PlayerImpl.nearby()` (renamed from `neighbors()`)
and wired it into the early expansion gate in
`AiAttackBehavior.maybeAttack()` via a second disjunct:

```ts
const hasNonNukedTerraNullius =
  border.some((t) => !hasOwner(t) && !hasFallout(t)) ||  // already filtered
  playerNeighbors.some((n) => !n.isPlayer());             // via nearby()
```

The first disjunct correctly excludes fallout, but the second one went
through `nearby()`, whose direct-neighbor loop never filtered fallout
(unlike the `shoreReachableNeighbors()` sibling introduced in the same
commit). So a nation bordering directly-adjacent nuked TN reported it as
plain TerraNullius and set the gate true. The bug stayed **dormant**
because #3786 also introduced `hasLandBorderWithTerraNullius()` *with* a
fallout filter, so `sendAttack(terraNullius())` still rejected nuked TN
and the early `return` never fired.

**#3814** removed the fallout filter from
`hasLandBorderWithTerraNullius()` so the `nuked` strategy could capture
fallout tiles. That unblocked the land path of `sendAttack`: now the
early gate fired on nuked-only borders *and* `sendAttack` succeeded,
pre-empting every attack strategy (retaliate, bots, assist, ...) on
every difficulty.

Fix: filter nuked (fallout) unowned tiles in `nearby()`'s
direct-neighbor loop, making it consistent with
`shoreReachableNeighbors()`. The early gate now only fires for non-nuked
TerraNullius, and the `nuked` strategy still fires (and captures
territory) when the nation has nothing better to do, preserving the
behaviour #3814 intended.

Added `tests/AiAttackBehaviorNukedTerritory.test.ts` covering:

- `nearby()` excludes directly-adjacent nuked TerraNullius
- `maybeAttack` retaliates against an incoming attacker instead of nuked
TN
- the early gate is bypassed when only nuked TN borders the nation
- the `nuked` strategy still captures tiles when the nation is idle
(Impossible and Easy difficulties)
- `isUnitDisabled(MissileSilo)` short-circuits the `nuked` strategy

## 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-27 08:50:04 -07:00
Evan 2436eebaa7 fix: don't re-challenge Turnstile on lobby reconnect (#4420)
## Problem

A player who joins a **private** lobby and waits for the start timer can
get an alert — `connection refused: Unauthorized: Turnstile token
rejected` — the moment the game starts. Turnstile is only supposed to
gate the *first* join, so this looks wrong.

## Root cause

A websocket **reconnect during the lobby phase** re-sends the original
Turnstile token via `joinGame()` (`ClientGameRunner.ts` lobby
`onconnect` → `Transport.joinGame()`, line 417). Cloudflare Turnstile
tokens are **single-use** and `lobbyConfig.turnstileToken` is never
refreshed, so re-verifying the already-redeemed token returns `rejected`
→ `ws.close(1002, ...)` (`Worker.ts`).

Normally the server skips Turnstile for reconnects: a `join` first tries
`rejoinClient` and returns early if the player is a known member
(`Worker.ts:359-366`). But on a **lobby-phase disconnect**, the close
handler **deletes** the `persistentId → clientId` mapping to free the
slot (`GameServer.ts`, `if (!this._hasStarted) {
persistentIdToClientId.delete(...) }`). With the mapping gone,
`rejoinClient` fails and the reconnect falls through to a full join + a
doomed Turnstile re-check.

**Why at game start:** `GameManager.tick()` calls `prestart()`
immediately but schedules `start()` 2s later, so `_hasStarted` is still
`false` for ~2s — exactly while the client runs its heavy terrain-decode
+ WebGL init, which stalls the ping loop and makes a socket drop (`1006`
→ `reconnect()`) likely. A reconnect in that window re-sends the spent
token and gets rejected.

## Fix

Decouple **"was admitted"** from the slot-mapping:

- `GameServer` tracks `admittedPersistentIds` (populated on a successful
`joinClient`) that **survives** lobby-phase disconnects, plus a
`wasAdmitted()` accessor.
- `GameManager.wasAdmitted(gameID, persistentID)` exposes it.
- `Worker` skips the Turnstile check for an already-admitted player: `if
(env !== Dev && !gm.wasAdmitted(gameID, persistentId))`.

A reconnecting admitted player now proceeds through `joinClient`
normally instead of failing on the spent token.

### Safety
Only the Turnstile check is skipped. Every other gate still runs on
every join: token-signature, ban, flares, clan tag, cosmetics,
allowlist, maxPlayers, and **kick**. Genuine first joins are still
challenged (no admission record yet). The set is per-game and excludes
kicked players, and `persistentId` comes from the verified token so it
can't be spoofed.

## Testing
- New `tests/server/TurnstileReadmit.test.ts` (4 tests), incl. a
regression that fires the real `ws.on("close")` handler and asserts
`getClientIdForPersistentId` goes null **but `wasAdmitted` stays true**.
- Full server suite: 126/126 pass · `tsc --noEmit` clean · eslint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:54:57 -07:00
Zixer1 06c5a4ef35 fix:name reveal works by publicid during game config (#4415)
## Description:

Adds nameRevealPublicIds to GameConfig — the same per-player reveal as
nameReveals but keyed by stable account publicId instead of per-game
clientID. Lets an automated host (the admin bot / OFM) grant casters and
observers real-name vision at create_game, where it only knows publicIds
and never learns a client's per-game clientID.

viewerSeesAllNames resolves the viewer's clientID to its publicId via
allClients and checks membership; nameReveals (clientID) is unchanged.

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

zixer._
2026-06-25 14:49:20 -07:00
Zixer1 61236879b7 fix: kick_player can target a disconnected account by publicId (#4409)
## Description:

kick_player resolved a targetPublicID only against activeClients, but a
client is dropped from activeClients on socket close (so players
technically can disconnect right before getting kicked, then reconnect
at a later point and continue playing), aka a disconnected account
cannot not be kicked. Fall back to allClients (which persists) so the
kick lands and bans the persistentID, blocking rejoin and reconnect.

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

zixer._
2026-06-25 09:53:13 -07:00
TKTK123456 904a407a35 Fixed rail network path length limit and readded tests for it (#4406)
> **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 #4396 

## Description:

Makes the max rail length 1.4142 * the max station radius to be minimum
amount outside of the factory effect radius.

Bug:
<img width="1921" height="1078" alt="image"
src="https://github.com/user-attachments/assets/91b3b3fa-037a-4d9a-b06b-afe2fe2c8ea8"
/>
Fixed:
<img width="1922" height="1079" alt="image"
src="https://github.com/user-attachments/assets/3719cf73-bc41-494f-9d86-548f308f5896"
/>


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

tktk1234567
2026-06-24 21:39:33 -07:00
Zixer1 c8a42d4c33 feat: include publicID in admin-bot live stats players (#4404)
## Description:

The live stats endpoint enriches each player with username/connected but
not publicId, so an account-keyed caller (e.g. an admin bot, which knows
players by account, not per-session clientID) can't map a stats row back
to a player directly. Add publicId from the same activeClients source —
mirrors the kick_player targetPublicID path.

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

zixer._
2026-06-24 18:44:51 -07:00
Evan 181368f962 Add live game stats endpoint to the admin bot API (#4399)
## Summary

The game simulation runs **client-side**, so the server can't directly
see what's happening in a running game. This adds a way for the admin
bot to observe a live game: clients report a live stats snapshot every
~10s, the server reaches consensus on it (reusing the winner's vote
mechanism), and a new admin-bot endpoint serves it.

## How it works

1. **`LiveStatsController`** (client) emits a snapshot every **100
turns** (~10s at 100ms/turn) — only deterministic sim values, with
players sorted by clientID, so in-sync clients produce an identical
payload.
2. The snapshot is sent as a new **`live_stats`** wire message wrapping
a `LiveStats` object (`turn` + per-human-player
`tilesOwned`/`troops`/`gold`/`isAlive`/`team`).
3. **`GameServer.handleLiveStats`** tallies a per-turn **IP-weighted
majority vote** — the same consensus the winner uses — and keeps the
latest agreed snapshot.
4. **`GET /api/adminbot/game/:id/stats`** returns it, enriched with
usernames the server already holds. `liveStats` is `null` until the
first consensus.

The winner's vote tally was extracted into a small reusable
**`VoteRound`** (`src/server/VoteTally.ts`) and is now used for both
winner and live-stats consensus.

Names are deliberately **excluded** from the voted payload (they vary
per client under name anonymization, which would break exact-match
consensus); the server joins `clientID → username` instead.

## Changes

- `src/server/VoteTally.ts` *(new)* — reusable IP-weighted `VoteRound`
- `src/core/Schemas.ts` — `PlayerLiveStatsSchema`, `LiveStatsSchema`,
`ClientSendLiveStatsSchema` + unions
- `src/client/controllers/LiveStatsController.ts` *(new)* — per-100-turn
snapshot reporter
- `src/client/Transport.ts` — `SendLiveStatsEvent` + sender
- `src/client/hud/GameRenderer.ts` — register the controller
- `src/server/GameServer.ts` — refactor winner onto `VoteRound`; add
live-stats consensus + `liveStats()` accessor
- `src/server/AdminBotRoutes.ts` — `GET …/stats` endpoint

## Testing

- **Unit:** `tests/server/VoteTally.test.ts` (majority/dedup/ties),
`tests/server/LiveStats.test.ts` (consensus, disagreement, per-client
dedup, stale-turn rejection, turn advance, out-of-sync exclusion, +
endpoint 200/404/400). Full suite green (`npm test`), typecheck + lint
clean.
- **Manual e2e** against the dev server: created an admin-bot game,
joined it in a browser, force-started via `toggle_game_start_timer`, and
confirmed `GET …/stats` returned the consensus snapshot with username
enrichment and an advancing `turn`. Also verified wrong-worker → 400 and
missing-key → 401.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:21:52 -07:00
Ryan 8ffb19d938 Discord (#4367)
Resolves #(issue number)

## Description:

continuation of https://github.com/openfrontio/infra/pull/359
adds ability to put discord URL into a dedicated slot 

pc:
<img width="1917" height="921" alt="image"
src="https://github.com/user-attachments/assets/100a25d5-e998-4744-904e-df40b74ccd76"
/>

mobile:
<img width="385" height="826" alt="image"
src="https://github.com/user-attachments/assets/de904f83-c88f-41e7-9c98-81c2296ec9a2"
/>


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

w.o.n

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:15:05 -07:00
Zixer1 8ce5f3439c feat: kick_player can target a publicId (admin bot) (#4403)
## Description:

Add an optional `targetPublicId` to KickPlayerIntent; the server
resolves it against the connected clients to the live clientID, then
kicks as before. Existing clientID targeting (lobby / in-game kick) is
unchanged. That way you can kick player with both the clientID and
playerID

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

zixer._
2026-06-24 14:34:17 -07:00
Zixer1 3b84a6f569 Feat/anonymize names (#4318)
**Add approved & assigned issue number here:**

Resolves #4296

## Description:

Adds an "Anonymous players" option to private lobbies (host toggle, off
by default).

When it is on, the server sends each client anonymized usernames for
everyone except themselves. The lobby creator and admins still see real
names so they can moderate. Names are hidden on every player-facing
surface: the game start message, lobby info, /api/game/:id, and the link
preview. It is enforced server-side, so a client extension cannot read
real names off the wire. Initially added as part of our overhaul of
OpenFront masters, but this feature can very well be useful for content
creators, and other tournament hosts.

Anonymized names reuse the existing tribe word lists (no emoji), so they
pass UsernameSchema, and they are seeded per user, so a player looks
different to different users but stays consistent from the lobby into
the game.

The saved game record keeps real names (anonymization is a per-send
transform, gameStartInfo is never mutated), so replays and stats are
unaffected. Nothing changes for normal games.

New option selection:
<img width="990" height="918" alt="image"
src="https://github.com/user-attachments/assets/31df0b0b-7757-4b2b-9bff-84310faee8d9"
/>

The host, when enabling the option, gets a little eye icon next to the
players(including himself to enable/disable the anon names for himself,
and/or other player)

By default(the names everyone will see are random and unique):
<img width="979" height="188" alt="image"
src="https://github.com/user-attachments/assets/f0caa4a4-9f14-41d3-89c6-9a38e8c2e6f0"
/>

Toggling the eye ON for yourself (the host, or any given player, will
allow them to see the real names of everyone, in the lobby and in game):
<img width="969" height="138" alt="image"
src="https://github.com/user-attachments/assets/89abf0e0-1433-43ea-9870-49d96ca46d30"
/>


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

zixer._
2026-06-24 07:54:44 -07:00
Evan 67f7d09fe5 Add admin bot HTTP API for managing private games (#4388)
## What

A trusted, server-side HTTP API so a bot authenticated with a shared
secret can **create private games, change their settings, start them,
kick players, and pause/resume** — without opening a WebSocket or
joining as a player.

Two endpoints under `/api/adminbot/`, reaching the owning worker via the
existing `/wN/` nginx routing. They reuse the existing Zod schemas and
`GameServer` methods, mirroring the WebSocket intent flow rather than
inventing a new wire protocol.

| Endpoint | Purpose |
| --- | --- |
| `POST /api/adminbot/create_game` | Create a private game; the worker
mints a self-owned id and returns it (body:
`GameConfigSchema.partial()`) |
| `POST /api/adminbot/game/:id/intent` | Send a lobby-management intent
(body: base `IntentSchema`) |

## How it works

- **Auth:** `ADMIN_BOT_API_KEY` env var via the `x-admin-bot-key` header
(timing-safe compare). The whole API is **disabled — 404 — when the var
is unset**, so non-configured environments expose nothing. It's distinct
from the per-instance `ADMIN_TOKEN`, which an external bot can't know.
- **`GameServer.handleIntent`** is the unified intent dispatch for both
the WebSocket `case "intent"` path and the admin-bot HTTP API. An
`IntentActor` carries identity + authority (per-connection
lobby-creator/role checks for the WS path; admin authority for the bot).
It honors `update_game_config`, `toggle_game_start_timer`,
`kick_player`, and `toggle_pause` — **on private games only**
(`isPublic()` → 403). Gameplay intents and `mark_disconnected` are
rejected (400).
- **Private games only.** `create_game` rejects any `gameType` other
than `Private` (Public *and* Singleplayer → 400); an omitted `gameType`
defaults to `Private`.
- **The bot is never a player.** It sends no `clientID`; the server
stamps a placeholder `ADMIN_BOT_CLIENT_ID = "ADMINBOT"` (collision-proof
— contains `I`/`O`, which `generateID()` never emits). A gameplay intent
stamped with it would resolve to no player, so puppeteering is
structurally impossible on top of the explicit 400.
- **Determinism unchanged:** the only intent that reaches the sim is
`toggle_pause`, via the same `addIntent` → turn queue →
`ServerTurnMessage` path the WS uses.

## Notable details for review

- **`hostCheats` is assigned unconditionally — on purpose.**
`updateGameConfig` sets `this.gameConfig.hostCheats =
gameConfig.hostCheats` unconditionally, unlike its sibling fields (which
are guarded on `!== undefined`). The WS host clears cheats by re-sending
the *full* config with `hostCheats: undefined`, so here `undefined` must
mean "clear", not "leave unchanged". **Caveat for the admin bot**, which
is a *partial*-update client: a partial `update_game_config` that omits
`hostCheats` will clear it — the bot should send `hostCheats` explicitly
(or a full config) when it wants to keep a previously-set value.
- **Deploy wiring:** `ADMIN_BOT_API_KEY` is piped through the deploy
steps' `env:` in `deploy.yml`/`release.yml` → `deploy.sh` heredoc →
container via `update.sh`'s `--env-file`. The remaining manual step is
creating the GitHub secret itself.

## Tests

19 new tests:
- `GameServer.handleIntent` admin-bot behavior (per-intent,
private-only, post-start guards, placeholder clientID, rejected
gameplay/`mark_disconnected` intents).
- `create_game` gameType guard (Public and Singleplayer both rejected).
- `requireAdminBotKey` middleware (404 disabled / 401 missing / 401
wrong / pass).

tsc + eslint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:09:14 -07:00
Evan c55ea6bb5a Mint game ids on the server, randomly route create-game across workers (#4393)
## What

Game creation no longer requires the caller to pick the `gameID` or
compute its owning worker. The client POSTs to a prefix-less
`/api/create_game`; **nginx (prod) and the vite dev proxy randomly route
it to a worker**, which **mints an id that hashes back to itself** and
returns it along with its `workerIndex`.

## Why it stays correct

The minted id still hashes to the creating worker (via the existing
`generateGameIdForWorker`), so everything downstream that derives the
worker from the gameID — websocket connect, share URL, join flow — keeps
working unchanged. The only thing that moved is *who picks the id and
worker*.

## Changes

- **`src/server/Worker.ts`** — factor create into a shared
`createGameForId`; add `POST /api/create_game` (no id) that mints a
self-owned id and returns `gameInfo` + `workerIndex`/`workerPath`. The
existing `POST /api/create_game/:id` stays.
- **`nginx.conf`** — `location = /api/create_game` proxies to a `random`
worker upstream.
- **`generate-nginx-upstream.sh` + `Dockerfile`** — the entrypoint
generates that upstream from `NUM_WORKERS` at container **start** time.
`NUM_WORKERS` isn't known at image build time (the image is built once
and deployed with different env), so it can't be baked into `nginx.conf`
— hence runtime generation of exactly the live worker ports (no
dead-server padding).
- **`vite.config.ts`** — dev-only middleware forwards `POST
/api/create_game` to a random worker. Vite's `http-proxy` can't pick a
per-request random target, so this is a small middleware plugin (same
pattern as the existing `serveProprietaryDir`), registered before the
`/api` proxy.
- **`src/client/HostLobbyModal.ts`** — stop generating the id
client-side; use the server's.

## Behavior change to note

The host's share link used to be copied **instantly** from a
client-generated id. Now the id comes from the server, so the copy waits
one create round-trip — I moved the URL build/copy into the create
`.then` (and kept the failure path that clears the clipboard). Brief
empty-link state in the modal until create resolves.

## Verification

- tsc + eslint clean; full suite green (1543 tests).
- nginx additions validated with `nginx -t` in isolation (the full file
references container-only paths like `/etc/nginx/mime.types`); upstream
+ `proxy_pass` resolve.
- `generate-nginx-upstream.sh` tested with `NUM_WORKERS` set and unset
(defaults to 1).

Not yet exercised live end-to-end (needs a dev-server restart —
`vite.config.ts` + `Worker.ts` changes aren't hot-reloaded).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:15:09 -07:00
Evan c63bfb6d94 Exempt Dependabot PRs from the PR gate (#4395)
## What

Adds a trusted-bot exception to the PR gate so Dependabot's PRs are no
longer auto-closed.

## Why

The PR gate (`scripts/pr-gate/`, run by `.github/workflows/pr-gate.yml`)
auto-closes PRs that don't fit the contribution workflow. Dependabot PRs
were getting closed because the bot:

- has no repo permission,
- links no `approved` issue, and
- opens dependency bumps that often exceed the 50-line small-fix cap.

## How

- `config.ts` — new `TRUSTED_BOT_AUTHORS` constant (currently
`["dependabot[bot]"]`), so the allowlist is easy to extend.
- `rules.ts` — new `checkTrustedBot()` rule, wired into `evaluate()`
right after the maintainer bypass and before the repo-access check.
- `tests/PrGateRules.test.ts` — unit tests for the rule plus an
`evaluate()`-level test proving a 5000-line Dependabot PR now passes
instead of closing.
- `README.md` — documented the new rule in the gate-logic ordering.

The match is exact, so a lookalike login (e.g. `not-dependabot[bot]`)
won't slip through. Add more bots (Renovate, etc.) to
`TRUSTED_BOT_AUTHORS` as needed.

## Testing

`npx vitest tests/PrGateRules.test.ts --run` → 39 passed. Lint +
prettier clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:45:26 -07:00
AmanorsElliot 170f506200 Fix transport ship's troop count to update when a hydro hits the player. (#4381)
**Add approved & assigned issue number here:**

Resolves #4308 

## Description:

When nuclear damage reduces a player's troop count, it also affects any
transports ships in the water. This works well and is useful to avoid
exploiting tranports to avoid hydro damages.

`UnitImpl.setTroops()` changes the transports troop count without
queuing a unit update. the core value changes, but the client never
receives a fresh UnitUpdate unless something else touches the ship.

- UnitImpl.ts now emits a UnitUpdate when a unit troop count actually
changes.
- NukeExecution.ts batches transport ship nuke losses, then applies one
final troop update per ship.
- Attack.test.ts now asserts the nuke tick includes a transport
UnitUpdate with the reduced troop count.

## Please complete the following:

- [N/A] I have added screenshots for all UI updates
- [N/A] 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:

elliotlepley
2026-06-22 13:20:01 -07:00
Zixer1 758063651d Add allowlist for private lobbies (OFM) (#4351)
**Add approved & assigned issue number here:**

Resolves #4349

## Description:

1. **Private-lobby allowlist.** `create_game` accepts an optional
`allowedPublicIds`. It's set by whoever creates the lobby (admin-token
gated, no client UI), the game server pulls it out of the config so it's
never broadcast to clients or written to the game record, and it rejects
any joiner whose OF publicId isn't on the list before they take a slot
(stickily, so they can't retry on reconnect). Lobbies created without it
behave exactly as before.

It is off by default
Previews:
<img width="241" height="140" alt="image"
src="https://github.com/user-attachments/assets/30c4e47b-399d-4720-b25b-a04c63668577"
/>
<img width="982" height="456" alt="image"
src="https://github.com/user-attachments/assets/1b5c68b7-9b99-4ccc-b987-e70c8ec25dce"
/>
<img width="547" height="369" alt="image"
src="https://github.com/user-attachments/assets/1623090b-ea2b-4657-9cd8-903fbabca51b"
/>


I am not able to manually test all of it since it needs to also run the
auth API (infra) and actually be connected to disc and whatnot (but
still tested the refused flow)..
Also, we would need to place some guards and visual error feedback, but
since this only would affect casual of players and is more of a
improvement to the feature, I will consider it out of scope for now.

## Please complete the following:

- [x] I have added screenshots for all UI updates (no UI changes in this
PR)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new user-facing text)
- [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:

zixer._
2026-06-21 15:10:48 -07:00
TKTK123456 08af8470fa Fixed factory ghost radius (#4337)
> **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 #4323 

## Description:

Made stations use euclidean distance for radius for checking if other
stations are close enough, removed redundant if check and unneeded
config

<img width="1920" height="1080" alt="Screenshot from 2026-06-18
14-19-48"
src="https://github.com/user-attachments/assets/a84f29f8-0cc1-46ea-9b96-3d70d6b0b20a"
/>


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

tktk1234567
2026-06-19 19:27:54 -07:00
FloPinguin 805f0968b1 Add impassable terrain 🗺️ (#4340)
## Description:

Relates to #3725

Adds a new **Impassable** terrain type that enables non-rectangular maps
and creates impassable barriers on the map. Painted with pure black
(`#000`) in the map editor's `image.png`.

**Encoding:** Impassable terrain is encoded in the binary format as
`isLand=1, magnitude=31` (previously unused). The Go map generator
detects `#000` pixels and produces this encoding. The map generator's
minimap downscaling gives impassable highest priority (Impassable >
Water > Land). Thumbnails render impassable as transparent so the map
picker background shows through.

**Rendering:** Impassable tiles render as the map background colour
(`rgb(60, 60, 60)`, matching `gl.clearColor` in `Renderer.ts`), making
them visually indistinguishable from the area outside the map quad. This
enables maps to appear non-rectangular.

**Gameplay restrictions:** Impassable terrain cannot be:
- Owned (`conquer()` throws)
- Attacked (`AttackExecution` skips impassable tiles in both `tick()`
and `addNeighbors()`)
- Nuked (targeting rejected in `nukeSpawn()`, blast radius filtered in
`tilesToDestroy()`)
- Spawned on (nations, human players, and structures all reject
impassable tiles)
- Converted to water (guarded in `WaterManager` and `setWater()`)

**Nuke trajectories:** Nuke trajectories cannot cross impassable
terrain, matching the existing map-border enforcement. This is checked
at launch time in `NukeExecution.tick()`. The client-side trajectory
preview turns red with a red X where the arc crosses impassable terrain
(reusing the existing SAM-intercept visual pipeline in
`NukeTrajectory.ts`). The nuke ghost preview is completely hidden when
hovering over impassable terrain (same as hovering outside the map).


https://github.com/user-attachments/assets/ff131146-9749-41e0-892a-617e5cd16c54

Impassable terrain is transparent on the thumbnail:

<img width="213" height="152" alt="Screenshot 2026-06-18 211640"
src="https://github.com/user-attachments/assets/ede16f8c-9239-4ab1-be5d-0ba81cce5e9e"
/>

Tested with water nukes, made sure there is no water depth gradient near
the impassable terrain, just like at the world border:

<img width="774" height="771" alt="Screenshot 2026-06-18 212348"
src="https://github.com/user-attachments/assets/4429069d-911b-48e8-91e3-7307d42c9397"
/>

Models used: GLM 5.2 and 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-19 14:54:09 -07:00
Zixer1 6e892839e8 Ofm tournament - Log Final standings and Per-Kill eliminations (#4350)
**Add approved & assigned issue number here:**

Resolves #4349

## Description:

The infra related PR is linked to this one and would need to be pushed
first (376)

Two changes for organized/tournament matches:

1. **Final standings.** `setWinner` snapshots each player's tiles owned
at game end into `PlayerStats` (`finalTiles`). It's a deterministic
integer captured in the sim, so it's replay-safe and rides into the
existing game record. This lets standings be derived directly (winner,
then surviving players by territory, then eliminated players by when
they died) without re-simulating, which matters because a domination win
ends with many players still alive.

2. **Per-kill log**. Records, per player, which humans they eliminated
and at what tick (kills on PlayerStats). This lets standings attribute
each kill to the victim's final placement, and gives a deterministic
kill graph for integrity review. Hooked once in conquerPlayer (the
single elimination funnel), humans only. Additive optional field that
rides the existing game record, no archive or wire changes.

These are off by default with no effect on normal play.

## Please complete the following:

- [x] I have added screenshots for all UI updates (no UI changes in this
PR)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new user-facing text)
- [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:

zixer._
2026-06-19 12:27:20 -07:00
Josh Harris ff5eb78689 Login with Google — client UI (#4028) (#4279)
Resolves #4028 (client half — backend is openfrontio/infra#368, which
must be deployed first).

## Description:

Adds "Login with Google" to the client, alongside the existing Discord
login. Companion to the backend PR (openfrontio/infra#368).

- `Auth.ts` — `googleLogin()` (full-page redirect to
`/auth/login/google?redirect_uri=…`, mirrors `discordLogin()`).
- `ApiSchemas.ts` — `GoogleUserSchema` + optional `user.google` on
`UserMeResponseSchema`.
- `AccountModal.ts` — a "Login with Google" button (Google brand
guidelines: white surface, dark text, the multicolor "G" mark) in the
login options, and the logged-in view now renders a Google-authenticated
user's email (also added `google` to `isLinkedAccount()`).
- `en.json` — `main.login_google`.
- `resources/images/GoogleLogo.svg` — the Google "G" mark.

> **Draft.** Depends on infra#368 being deployed (the button hits the
live `/auth/login/google`).

## Please complete the following:

- [x] I have added screenshots for all UI updates <!-- TODO: add
screenshot of the Google button -->
- [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 <!-- no client
tests exist for AccountModal/Auth; verified via tsc --noEmit + eslint.
Backend behaviour is covered in infra#368 -->

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

jish
2026-06-19 11:47:40 +01:00
Evan 21291b9fa3 Add trade ship captured event with toggle setting (#4344)
## What

Notify a player when one of their trade ships is captured. The alert
appears in the **less-important (top) events tier** and is gated behind
a new in-game setting (on by default).

## Why

Previously there was no notification to the player who *lost* a trade
ship — only the capturer got a transient +gold pip on the ship's
arrival. This surfaces the loss to the victim, while letting players opt
out if they find it noisy.

## Changes

- **`src/core/execution/TradeShipExecution.ts`** — On capture detection,
emit a display message (`events_display.trade_ship_captured`, type
`UNIT_DESTROYED`) to the original owner. Fires once, guarded by the
existing `wasCaptured` flag. `UNIT_DESTROYED` is not a Tier-1 type, so
it lands in the top/less-important tier.
- **`src/client/hud/layers/EventsDisplay.ts`** — Suppress the message
when the setting is off, following the existing key-based filter
pattern.
- **`src/core/game/UserSettings.ts`** — New `tradeShipCapturedEvents()`
getter (default `true`) + `toggleTradeShipCapturedEvents()`.
- **`src/client/hud/layers/SettingsModal.ts`** — New toggle in the
in-game settings modal.
- **`resources/lang/en.json`** — New
`events_display.trade_ship_captured` and
`user_setting.trade_ship_captured_label`/`_desc` keys.
- **`tests/core/executions/TradeShipExecution.test.ts`** — Tests that
the notification is sent to the original owner with the right args and
only once across ticks.

## Notes

- The setting is gated client-side (in `EventsDisplay`), keeping
`src/core` free of client-local localStorage settings — consistent with
how display events are already filtered there.
- Reused `MessageType.UNIT_DESTROYED` (red/"loss" styling) rather than
adding a new message type, to keep the change minimal. Happy to add a
dedicated type/color if preferred.

## Testing

- `npx vitest tests/core/executions/TradeShipExecution.test.ts --run` —
7 passed
- lint clean, no type errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:18:00 -07:00
Evan 117fa43947 Fix nuke preview showing teammate SAMs as threats (#4342)
## Problem

In the nuke trajectory preview, the SAM-intercept **"X"** marker was
drawn over **teammates'** SAMs — implying their SAM would shoot down
your missile. It shouldn't: like allies, a teammate's SAM never engages
your nuke. The bug only affected teammates; allies already worked.

## Cause

The preview built its threat set from `myPlayer.allies()` only — formal
alliances — and never considered teammates. That diverged from the sim
([`SAMLauncherExecution.ts`](src/core/execution/SAMLauncherExecution.ts#L118-L134)),
which skips any nuke whose owner it's `isFriendly()` with (**same team
OR allied**).

## Fix

`samThreatensNukePreview` now takes a teammate set and excludes
teammates **unconditionally**.

The subtlety: allies keep the existing *betrayal* exception — a strike
close enough to break the alliance makes that ally's SAM engage at
launch (`listNukeBreakAlliance`, the same function the sim uses).
Teammates get **no** such exception, because a strike can break an
alliance but never a team relationship. So even a player who is both a
teammate *and* a betrayed ally is correctly left off the threat set.

## Notes

- The sim has an "aftergame fun" exception where teammate SAMs *do*
target teammate nukes once there's a winner. The preview only appears
while aiming a buildable mid-game (no winner yet), so that case doesn't
apply here.

## Tests

Updated `samThreatensNukePreview` unit tests for the new signature and
added coverage for: teammate excluded, and teammate stays excluded even
when listed as betrayed. All 11 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:37:45 -07:00
Evan 661d96ba28 Fix structure cost double-counting units under construction (#4320)
## Problem

The ghost/build-menu price of a structure can show the wrong (inflated)
cost. Concretely: a player who owns a **captured** city and then starts
building their **first** city sees the 3rd-city price (**500k**) for
that build instead of the 2nd-city price (**250k**).

## Root cause

Structure cost scales as `2^(units built) × base` (city: 125k / 250k /
500k …), counted via:

```ts
Math.min(player.unitsOwned(type), player.unitsConstructed(type))
```

The `Math.min` is deliberate — it caps the count at how many you've
actually **built**, so **captured** units (owned but not built) don't
inflate the price.

`unitsConstructed()` defeated that by double-counting in-progress
builds:

```ts
const built = this.numUnitsConstructed[type] ?? 0;   // already includes the building unit
let constructing = 0;
for (const unit of this._units) {
  if (unit.type() !== type) continue;
  if (!unit.isUnderConstruction()) continue;
  constructing++;                                     // counts the SAME unit again
}
return constructing + built;                          // doubled
```

`recordUnitConstructed()` is called in `buildUnit()` the moment the unit
is created — while it is still under construction — so
`numUnitsConstructed` already accounts for in-progress builds. The extra
loop counted them a second time.

With one captured city + one city under construction: `unitsOwned = 2`,
double-counted `unitsConstructed = 2`, so `Math.min(2, 2) = 2` → 500k.
Without the double-count it's `Math.min(2, 1) = 1` → 250k. 

The redundant loop is a leftover from #2378, which removed the separate
`UnitType.Construction` unit. Back then in-progress builds were a
distinct unit type **not** recorded in `numUnitsConstructed`, so the
loop was needed; afterward it became a pure double-count. This is a
long-standing latent bug — present identically on `v31` — not a recent
regression.

## Fix

`unitsConstructed()` now just returns `numUnitsConstructed[type]`, which
already includes under-construction builds.

## Tests

`tests/economy/ConstructionCost.test.ts` covers both:
- pure case (first city under construction) → still 250k
- captured city + first city under construction → was 500k, now 250k
(fails without the fix with `expected 2 to be 1`)

All related suites (economy, PlayerImpl, nation structure behavior,
upgrades, MIRV pricing, stats) — 144 tests — pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:19:04 -07:00
Evan 83cd864018 Show rail ghost for initial factory 🚂 (#4294)
## Problem

Fixes #4284. When you build a factory in an area with **no pre-existing
factory** (e.g. just a city nearby), no rail ghost preview appeared —
even though building the factory *would* lay rail lines connecting it to
that city.

## Root cause

`computeGhostRailPaths` in `RailNetworkImpl.ts` had two factory-hostile
assumptions:

1. It bailed out early unless a `Factory` was already in range
(`hasUnitNearby(..., UnitType.Factory)`).
2. It only matched neighbors that were *already* train stations
(`findStation(...)` → skipped if null).

But a **Factory** always becomes a station itself and *promotes* nearby
City/Port/Factory into the rail network (see `FactoryExecution`). So it
needs no pre-existing factory, and its neighbors won't be stations yet
on first build. A **City/Port** only joins the network when a factory
already exists (`CityExecution`/`PortExecution`) — so their behavior is
correctly left unchanged.

## Fix

- Skip the "factory must be nearby" gate when the placed unit is itself
a `Factory`.
- For a factory build, pathfind to nearby City/Port/Factory even if they
aren't stations yet. City/Port keep connecting only to existing
stations.

## Tests

Added two cases to `RailNetwork.test.ts` (factory connects with no
pre-existing factory; city still doesn't without one). All 25 tests
pass.

## Note on scope

As @Katokoda noted on the issue, a fully build-exact preview
(neighboring structures also connecting to *each other*, merging
existing networks, etc.) is larger and order-dependent. This PR resolves
the reported bug — the initial factory now shows its rail ghost — and
leaves the exact-match cascade as a separate follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:22:01 -07:00
FloPinguin 094aa766ce Improve "Better troop management for nations 🤖" (#4278)
## Description:

**Allow Hard/Impossible nations to retaliate and expand freely**

Previously, nations on Hard/Impossible difficulty could be stuck unable
to fight back if their `troopSendCap` or `isAttackTooWeak` checks
blocked them from sending enough troops. **@legan320** on the main
discord noticed it. Now:

- `troopSendCap` raises the cap to at least the total incoming attack
troops, so nations can match the force being used against them
- `isAttackTooWeak` bypasses the 20% minimum check entirely when under
attack
- `troopSendCap` no longer applies when attacking Terra Nullius, so
nations can always expand into unowned land with full troops

All checks still apply normally for unprovoked attacks against other
players.

## 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-14 18:53:01 -07:00
FloPinguin 6c8ce958b2 Fix nations being blocked by PVP immunity 🛡️ (#4282)
## Description:

### Problem

PVP immunity (the extended spawn immunity setting) was incorrectly
preventing AI nations from attacking human players. The intent of PVP
immunity is to protect human-vs-human combat only, but nations were
subject to the same restriction.

### Root Cause

In `canAttackPlayer()`, only `PlayerType.Bot` was exempt from checking
target immunity. Nations fell through to the same path as humans, so
when a nation tried to attack an immune human, `player.isImmune()`
returned true and the attack was blocked.

### Fix

Changed the immunity bypass condition from `this.type() ===
PlayerType.Bot` to `this.type() !== PlayerType.Human`. Now only human
attackers check target immunity. Both bots and nations bypass it (they
only check alliance status).

This does not affect nation spawn immunity
(`nationSpawnImmunityDuration`), which is a separate mechanism that
protects newly spawned nations from all attackers and continues to work
independently.

## 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-14 17:54:08 -07:00
Evan 769da27257 Fix railroad glowing green for non-snapping structures (#4281)
## Problem

When placing a building near a railroad, the railroad glows green to
show the building would snap to it. This should only apply to **City**,
**Port**, and **Factory** — but missile silos, SAMs, and defense posts
(which cannot be placed on railroads) were also triggering the green
highlight.

## Root cause

The core's `overlappingRailroads()` populated snap tiles for *every*
buildable type. In v31 the green highlight didn't leak because the
client renderer (`RailroadLayer.ts`) gated it with a
`SNAPPABLE_STRUCTURES = [Port, City, Factory]` allowlist:

```ts
if (!SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure)) return;
```

That guard was lost when the rendering was rewritten into the WebGL
`RailroadPass`, which now unconditionally highlights every tile in
`overlappingRailroads`. The data was always there; only the renderer's
filter was protecting it.

## Fix

Filter by unit type inside `overlappingRailroads()`, mirroring the
existing guard in `computeGhostRailPaths()`. This keeps the
snap-eligible type list defined once in the core (`RailNetworkImpl`) and
fixes the leak regardless of which renderer consumes the data — rather
than re-adding a client-side allowlist a future rewrite could drop
again.

## Tests

Updated `tests/core/game/RailNetwork.test.ts` for the new signature and
added a case asserting `MissileSilo`/`DefensePost`/`SAMLauncher` return
`[]` (and don't even query the rail grid). All 23 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:52:17 -07:00
Evan 52bcae5106 Replace dark mode with player-adjustable lighting (#4280)
## What

Removes the binary **dark mode** feature and replaces it with a
player-adjustable **Lighting** section in graphics settings.

### In-game settings
- Removed the Dark Mode toggle from both `SettingsModal` and
`UserSettingModal`, and `darkMode()`/`toggleDarkMode()`/`DARK_MODE_KEY`
from `UserSettings`.

### New Lighting section (Graphics Settings)
- **Ambient light** slider (1–3): mapped to the renderer's ambient as
`ambient = 1 / level`. **1.0 = no effect (unchanged look), 3.0 = darkest
with the strongest structure glow.**
- **Light falloff** slider (1–3): writes straight to
`lighting.falloffPower`.
- Lighting auto-enables only when ambient < 1, so the default (slider at
1) has zero GPU cost — off by default.

### Removed dark-mode overrides
- Deleted `applyDarkModeOverride()` + `DARK_AMBIENT` and their wiring in
`ClientGameRunner`, `gl/index.ts`, and the `DARK_MODE_KEY` listener.
- Removed the `.dark` HUD-class toggle in `Main.ts` and the
`userSettings.darkMode()` read in `PlayerIcons`.

### Train glow
- `UT_TRAIN` light reduced (intensity `2.0 → 0.5`, radius `8 → 6`) so
structures dominate the glow.

## Notes
- Removing the dark-mode setting also retires the HUD's Tailwind dark
theme (same setting). The dormant `dark:` CSS variants and unused
white-icon assets are left in place (out of scope).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:42:19 -07:00