Commit Graph

4135 Commits

Author SHA1 Message Date
bijx aadca4e0ab Update warship instructions for veterancy (#4489)
## Description:

Adds simple help instructions for new warship veterancy to help screen.
Just to be clear about what levelling does and how many levels can be
achieved.
<img width="1136" height="388" alt="image"
src="https://github.com/user-attachments/assets/c14425b4-ea23-4b62-89b3-881f8c16e1f3"
/>


## 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-02 14:32:04 -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
iamlewis c9850c472b docs: remove Wicked Sick service-provider paragraph from privacy policy (#4472)
Removes the paragraph in Section 1 ("Who We Are") of the privacy policy
that named Wicked Sick Limited (company number 11117702) as a service
provider, and bumps the "Last Updated" date to 7/1/2026.

- Repo-wide search confirmed no other references to "Wicked Sick" /
"11117702" anywhere in the codebase.
- Section 1 reads correctly after removal (data-controller block flows
into the EEA/UK representative paragraph).

Replaces closed PR #4471.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:56:50 -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
Evan 3833bfb496 Remove ports-disabled modifier from public games (#4464)
## Summary
Removes the `isPortsDisabled` ticket from `SPECIAL_MODIFIER_POOL` in
`src/server/MapPlaylist.ts`, so the "ports disabled" modifier can no
longer be randomly selected for public games.

The reasoning is that many games with disabled ports were filling up
very slowly or not at all (one game I saw had 3 players after 2 minutes)

## Notes
- Private/custom lobbies are unaffected — the modifier itself still
exists (type, schema, client UI), it's just no longer in the public
auto-selection pool.
- The now-unreachable public-game handling for `isPortsDisabled` (the
`FULL_LAND_MAPS` exclusion and the `disabledUnits` branch) is left in
place to keep the change surgical; it simply never triggers for public
games now.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:35:38 -07:00
Evan cb7f721e73 Restore subscriptions in store and account modal (#4461)
Reverts cdcc7747 ("Hide subscriptions in store and account modal behind
a feature flag").

This re-adds the Subscriptions tab to the store, restores the
subscription panel on the account modal, and re-enables the cosmetics
fetch — removing the `SUBSCRIPTIONS_ENABLED` feature flag that gated
them all behind `false`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-30 12:35:49 -07:00
bijx 36b23d314f QoL: Add billions to money utils (#4460)
## Description:

A simple QoL to handle cases where money surpasses $1B. I have seen this
happen in both custom games and post-game, and generally looks nicer
than just showing a thousand millions.

<img width="242" height="71" alt="image"
src="https://github.com/user-attachments/assets/abc9adb5-2e8f-485f-93ff-47a43a019d85"
/>


## Please complete the following:

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

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

bijx
2026-06-30 12:09:30 -07:00
RickD004 ae0d9f8d5e Adds "Levant" map (#4456)
## Description:

Adds map of the Levant / Levantine Sea. This map is a square map with 3
sides being land, with an inner sea, which itself has a large island
(Cyprus, slightly moved and resized for balancing purposes).

After v32 added a trading buff, island players have been going crazy,
with a map like this we could have crazy strong pirate players, which
will make for fun and interesting gameplay.

Nations are based on medieval states from the Crusades. Also adds more
additional nations for a total of 62, for gamemodes like HumanVSNations
and Solo. Also adds flags for the map



https://github.com/user-attachments/assets/286432bd-011b-4716-85c9-20811777ff65


## Please complete the following:

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

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

tri.star1011
2026-06-30 11:49:21 -07:00
RickD004 cd5f8a6998 Adds map "Tierra del Fuego" (featuring impassable terrain) (#4437)
## Description:

Adds map of Tierra del Fuego, the southernmost tip of South America:
https://en.wikipedia.org/wiki/Tierra_del_Fuego

This map uses impassable terrain to stylize the borders of this map into
a triangular shape, this zone is known as "end of the world" so i
thought it would be visually fitting if the borders converged at the
bottom of the map.

800k land tiles similar to Caucasus.

This map has a lot of islands, incluiding the Falklands for people who
enjoy island-plays, and also a chokepoint (the strait of Magallanes) for
piracy.

Also adds flags of Chilean and Argentinian provinces for the map and the
menu.

<img width="818" height="544" alt="image"
src="https://github.com/user-attachments/assets/59812868-7514-4e03-86a2-072cf4013aeb"
/>


https://github.com/user-attachments/assets/acc15020-bd7a-49e0-a504-8575a1e74f75

## Please complete the following:

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

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

tri.star1011
2026-06-30 09:42:55 -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
evanpelle f4b47ce06c feat: add search bar to effects picker modal
Mirror the flag/pattern modals with a search input in the EffectsModal
header. Filters the effects grid by name — matching either the raw effect
id or its displayed label — and hides the Default tile while searching,
the same way FlagInputModal hides its no-flag tile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:38:37 -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
Antonio Lentini e196d399b4 feat: Change factory icons from circles to hexagons (#4439)
Resolves #4413 

## Description:

The factory and city icons are currently undistinguishable when zooming
out, this PR changes the factory icon to be hexagon-shaped.

It also increases the icon scale (now equal to port) to put some padding
around.

*Before*
<img width="159" height="154" alt="Before"
src="https://github.com/user-attachments/assets/2967fbc1-c775-4281-9592-6a52cbadd5a4"
/>

*After (with `scale = 1.0`)*
<img width="133" height="135" alt="After (with `scale = 1.0`)"
src="https://github.com/user-attachments/assets/f2bb9d8a-2f41-434a-b4df-9c1475f51cce"
/>

*After (with `scale = 1.08`)*
<img width="192" height="191" alt="After (with `scale = 1.08`)"
src="https://github.com/user-attachments/assets/c81026d2-f6fe-4115-a00e-e7490d342787"
/>

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

lents
2026-06-29 13:42:36 -07:00
Ryan a05ab1bd60 fix broken import... oops (#4448)
> **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 #(issue number)

## Description:

Describe the PR.

## Please complete the following:

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

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

DISCORD_USERNAME
2026-06-29 13:41:09 -07:00
Ryan 6a884eba1b store popup (#4435)
## Description:

change the generic popup:
<img width="1095" height="540" alt="image"
src="https://github.com/user-attachments/assets/94d2c120-5ec5-4838-b8b4-09d43b4e83f8"
/>


into a popup i added for clan system:
<img width="1108" height="774" alt="image"
src="https://github.com/user-attachments/assets/d7de1666-7667-4422-a1bd-03b90b4ff8ab"
/>

caps doesn't have a "buy" button:
<img width="1141" height="803" alt="image"
src="https://github.com/user-attachments/assets/d26dd397-1f14-4963-8ac8-afa5f32ed8ec"
/>

also works for win modal:
<img width="1023" height="766" alt="image"
src="https://github.com/user-attachments/assets/83f7bc87-0ecc-4470-b84d-c5783560d6a3"
/>

## 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-29 13:24:09 -07:00
Evan 0f1a95cbeb Restore default terrain colors changed by #4391 (#4447)
## Description

The terrain colors settings refactor (#4391) inadvertently changed the
**default** land terrain colors. The PR moved the hardcoded terrain
colors into configurable settings, but the new default hex values in
`render-settings.json` — combined with the new shading math — no longer
reproduced the original sand/plains/highland appearance.

In particular the new shading formulas use each JSON color as a *base*
and apply offsets differently (e.g. highland now adds `2*(magnitude-10)`
instead of `2*magnitude`), so the default hex values needed to
compensate.

## Fix

Surgical, JSON-only change to the defaults so the new formulas produce
**pixel-identical** output to the pre-#4391 code:

| Terrain | Was (#4391) | Restored |
| --- | --- | --- |
| sand | `#CC9E9E` | `#CCCB9E` |
| plains | `#BECD8A` | `#BEDC8A` |
| highland | `#C8B78A` | `#DCCB9E` |
| mountain | `#e6e6e6` | `#e6e6e6` (unchanged) |
| ocean | `#4785b5` | `#4785b5` (unchanged) |

The settings/UI machinery from #4391 stays intact and users can still
override colors — only the defaults are corrected. Verified across all
magnitude/shoreline combinations that the rendered land colors match the
original output exactly.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:16:31 -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
bijx 06d505ebc9 feat: Expand maps button (#4431)
## Description:

QoL fix w.r.t. "All" maps category. Maps used to all be shown in a big
list, now the category sections need to be expanded one by one.
Categories are super clean and useful, but to visually see and pick maps
when you're not certain which one you want to play becomes challenging
(to click and expand each category manually). This just adds a simple
"Expand All" and "Collapse All" button to the all category list on Solo
and Private Match screens.


https://github.com/user-attachments/assets/e5d7a754-a6b6-461c-b039-7b6a8d3bee46


## Please complete the following:

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

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

bijx
2026-06-28 19:19:22 -07:00
Ryan 966dcf47a5 removes the "subscribe" and "purchase" text (#4436)
## Description:

removes the "subscribe" and "purchase" text:

<img width="988" height="496" alt="image"
src="https://github.com/user-attachments/assets/9566b450-0943-4684-8321-8024422bbd96"
/>

<img width="993" height="505" alt="image"
src="https://github.com/user-attachments/assets/f26bfd99-661d-48e3-beb3-e3e5d212e6f1"
/>

<img width="1002" height="511" alt="image"
src="https://github.com/user-attachments/assets/c8f2aadf-15d1-4e29-8142-f684c6e492f0"
/>

this is what it looks like if you're subbed to something now:
<img width="997" height="491" alt="image"
src="https://github.com/user-attachments/assets/5f011213-7ced-4a64-860e-45a6b0a7418f"
/>


## 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-28 18:41:32 -07:00
Antonio Lentini d42b095304 fix: Remove FFA collusion warnings on replay and ranked (#4414) (#4434)
> **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 #4414 

## Description:

The 2 lines added check whether it's a replay or a ranked game. If so,
don't show the FFA collusion warning

## Please complete the following:

- [X] I have added screenshots for all UI updates (couldn't sign in via
the dev server)
- [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:

lents (not in the development server yet, sent request to join)
2026-06-28 13:34:39 +00: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
Evan 6b95a23606 Fix anonymize-names desync: seed cluster-recalc offset from id() not name() (#4426)
## Description

Fixes a hard desync that hit anonymous-names lobbies (e.g. the OFM
tournament): in an anonymized FFA, roughly half the clients desynced.

### Root cause

`PlayerExecution.init()` seeded the per-player phase offset that
schedules `removeClusters()` from the player's **username**:

```ts
this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
```

`removeClusters()` is not display-only — it both sets
`largestClusterBoundingBox` (read by `AiAttackBehavior` for targeting)
**and removes disconnected/surrounded territory, mutating tile
ownership**.

The anonymize-names option (#4318) sends each client a *different*
username for the same player (`anonName(target + viewer)`). So
`simpleHash(name()) % 20` differs per client → `removeClusters()` runs
on different ticks per client → `numTilesOwned()` diverges → the
every-10-tick state hash (`simpleHash(id) * (troops + numTilesOwned) +
units`) diverges → **desync**.

**Why only half the lobby:** clients granted real-name reveal (host /
admins / casters via `nameReveals` / `nameRevealPublicIds`) all see real
names, compute identical offsets, and stay in sync with each other and
the server record. Every non-granted (anonymized) client sees a unique
random name per player and diverges. Hence the clean split.

This line has been latent and harmless since 2024 (`f01949f0`) because
`name()` used to be identical on every client; #4318 was the first
feature to feed a per-client value into the core. The PR comment in
`GameServer.ts` even states the assumption — *"username … neither of
which the simulation reads"* — which turned out to be false.

### Fix

Seed the offset from `id()` instead of `name()`. Player `id()` is
assigned from a `gameID`-seeded PRNG by spawn order, so it is identical
on every client while still spreading cluster recalculation across
players (the line's original load-balancing intent). One-line sim
change; no schema/wire change.

### Verification / scope

- Audited every `name()`/`username` read in `src/core/`: this was the
**only** state-affecting one (all others are `console.log` / error
strings / display-only update fields). So this closes the whole desync
class.
- Confirmed player order, ids, config, `clanTag`/`friends` and cosmetics
are all client-identical under anonymize-names — `username` was the sole
per-client field reaching the sim.
- Swept the other recent core commits (impassable terrain, rail network,
nations AI, inline sfc32 PRNG, troop/economy changes) for independent
determinism regressions — none found.

### Follow-up

`name()` should be removed from the core `Player` surface entirely so a
per-client username can never re-enter the sim again (the remaining
reads are logging + the display payload the renderer needs). Tracked
separately.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:10:47 -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 95171fd995 Fix lobby status bar scrolling out of view when many players join 🎯 (#4425)
## Description:

Make the lobby status bar (status label + player count) sticky at the
bottom of the player list scroll area in JoinLobbyModal. Previously,
when many players joined a lobby the status bar would scroll out of
view. The bar is now pinned with `sticky bottom-0` and has a semi-opaque
blurred background (`bg-black/60 backdrop-blur-md`) to cleanly occlude
content scrolling behind it.

**Before:**

<img width="951" height="789" alt="image"
src="https://github.com/user-attachments/assets/8a2a2f1d-1530-4f13-82be-837eaaa00256"
/>

**With this PR:**

<img width="938" height="688" alt="image"
src="https://github.com/user-attachments/assets/6c5259fb-a969-4a5f-b951-a4310f6c68c0"
/>

## 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 10:14:56 -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
crunchybbb f83e768631 Adds map of the Caspian Sea (With Team Spawns) (#4408)
> **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 #4389 

## Description:

It is basically a vertical version of Black Sea. This one is slighyly
smaller. It would be the first Central Asian map and it will also
complete all of the collection of caucasian maps that we will ever need.
There are 12 nations. (No additional nations, yet, possibly in a future
update i will add them for many maps.)
The map is split in half for team spawns.


https://www.youtube.com/watch?v=XSKXD6Qm6IQ

<img width="1008" height="1728" alt="image"
src="https://github.com/user-attachments/assets/0cbf6a29-6805-4089-9d42-ecdf265d23ab"
/>
<img width="334" height="463" alt="Screenshot 2026-06-24 174215"
src="https://github.com/user-attachments/assets/3549aa22-c828-45ea-a3f4-146f07e4a72d"
/>


## Please complete the following:



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

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

DISCORD_USERNAME crunchybbbbb
2026-06-26 16:42:18 -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
Vivacious Box 2bd203968f Add terrain colors settings (#4391)
## Description:

Add terrain color settings for all terrain types

<img width="977" height="485" alt="image"
src="https://github.com/user-attachments/assets/ac1cef11-4b1a-45f2-8cf6-94f557ba8f6e"
/>

## Please complete the following:

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

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

MR. Box
2026-06-25 18:12:17 -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
evanpelle f025f89b96 feat: speed up spawn-phase ring pulse for better visibility
Bump spawnOverlay animSpeed 0.005 -> 0.008 so the breathing pulse on
spawn-phase highlight rings cycles faster, making own and teammate spawn
locations easier to spot. Addresses reports that players had trouble
finding their teammates during the spawn phase in team games.
2026-06-25 11:30:52 -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
RickD004 4c55f82e87 Adds map of the USA (made for new impassable terrain feature) (#4405)
## Description:

Adds map of the continental USA. This map was made for the brand new
impassable terrain feature:
https://github.com/openfrontio/OpenFrontIO/pull/4340

Only the territory of the US is playable, with canada and mexico being
impassable terrain. Also, adds a new category called "countries" for
Country maps like this map, that use Impassable Terrain.

49 default nations (Lower 48 + D.C.) , with additional nations (native
nations and proposed states) for a total of 62, for private games and
Human Vs Nations gamemode.

Also standarizes many of the flags of the US states, since they did not
have the black border like the other flags in the game

<img width="857" height="567" alt="Captura de pantalla 2026-06-24
165158"
src="https://github.com/user-attachments/assets/70a8d760-851f-40ed-ad79-d3e210dadb90"
/>
<img width="872" height="510" alt="Captura de pantalla 2026-06-24
165510"
src="https://github.com/user-attachments/assets/c998cc10-ee89-41a7-b5e9-091be5e90da0"
/>

## Please complete the following:

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

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

tri.star1011
2026-06-24 21:47:05 -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
Evan 82efcecb80 Add "assign to me" checkbox to issue templates (#4402)
## Summary

Adds an **Assignment** section with a clickable markdown task-list
checkbox to the user-facing issue templates, so issue creators can opt
in to working on the issue themselves:

```markdown
- [ ] I'd like to be assigned to this issue and work on it myself
```

GitHub renders `- [ ]` as a real clickable checkbox in the issue.
Checking it only signals intent — a maintainer still performs the actual
assignment.

## Templates changed

- `bug_report.md`
- `feature_request.md`
- `new-contribution-template-clean.md`
- `new-contribution-template-examples.md`

Intentionally **excluded** the API/DB template (`database_request.md`)
since that area is maintainer-handled, not contributor work.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:23:16 -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
Demonessica d01be405c7 Cache maxTroops during leaderboard update (#4379)
**Add approved & assigned issue number here:**

Resolves #4316

## Description:

At the beginning of the leaderboard update cycle, evaluates `maxTroops`
once for each `PlayerView` and uses the cached value for the rest of the
`maxTroops` lookups in the function.

Measured a reduction in `updateLeaderboard` processing time from 6ms/sec
down to 2ms/sec (measured over the first minute of a singleplayer game
on world map and default settings).

## Please complete the following:

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

Demonessica
2026-06-23 17:19:40 -07:00
unne27 06cc01668b Fix non-structures being deleteable (#4385)
Resolves #4307

## Description:
Adds a check for the "delete" element of the raidial menu, to make sure
the unit attemped to be removed is a structure.
Previously, non-structure units such as trains could be deleted using
the menu.

## Please complete the following:

- [ ] I have added screenshots for all UI updates (No UI updates)
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file (No text added)
- [ ] I have added relevant tests to the test directory (Not sure how
this works or if it's needed)

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

unne27

---------

Co-authored-by: unne27 <uno.gunnar.johansson@gmail.com>
2026-06-23 17:17:57 -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
Evan 1cb84a79df Fix stale bot re-commenting on case-mismatched labels (#4394)
## Problem

The issue-lifecycle stale rule checked labels with case-sensitive
`Array.includes()`, so an issue carrying the `Stale` label (created by
the `actions/stale` PR bot) was never recognized as stale.
`hasStaleLabel` stayed `false` and the bot re-posted the 7-day warning
on **every** daily cron run.

Example: [#3441](https://github.com/openfrontio/OpenFrontIO/issues/3441)
got the same "hasn't had activity in 7 days" comment ~16 days in a row.

## Fix

GitHub label names are case-insensitive (you can't have both `Stale` and
`stale`), so the gate should be too. Adds a `hasLabel()` helper in
`github.ts` and routes all label checks through it (`STALE`,
`KEEP_OPEN`, `APPROVED`, `NOT_APPROVED`).

Now an issue gets one stale warning when marked, then silence until the
14-day close.

## Note

The Dependabot PR-exemption change (`pr-stale.yml`) is being applied
separately — the CI token here lacks `workflow` scope to push
workflow-file changes.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:31:27 -07:00