Commit Graph

656 Commits

Author SHA1 Message Date
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
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
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
FloPinguin 91cd4214a5 Reduce compact map chance in 1v1 from 50% to 20% 🗺️ (#4374)
## Description:

Changes the probability of getting a compact map in 1v1 games from 50%
(Math.random() < 0.5) to 20% (Math.random() < 0.2) in `MapPlaylist.ts`.

Reasoning is feedback on discord.

This suggestion thread:
https://discord.com/channels/1284581928254701718/1514204284173025301

And this, for example:

<img width="634" height="225" alt="image"
src="https://github.com/user-attachments/assets/0b4d53de-582c-4e6a-8d18-0007433c12ee"
/>

Pro players seemingly want to get rid of compact maps completely, but in
the thread you can also see people liking them. So lets do 20% for now?

## 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-22 12:32: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
FrederikJA 19db66f424 Delayed lobby start (#4184)
Resolves #4169

## Description:

Adds a delayed lobby start option.
Utilizes the same system as for public lobbies.
The default for the option is for lobbies to take 3 seconds to start,
however this can easily be changed.

The current setting is controlled through an enable-disable slider,
however there are multiple other options for how to control this.
For example we could do a slider, an input field, a dropdown etc. And i
dont necessarily know if the currently implemented option is the best.

Furhtermore im not sure if i have used the language file completely
correctly. There is now a duplicate field for both private and public
lobby. However there is not category shared between the two. So i
decided to reuse the field from public for private games, as this
simplified the code a bit.

**Host video**

https://github.com/user-attachments/assets/6f3db6e4-7323-4fad-8544-efb8cef4d969

**Non-host video**

https://github.com/user-attachments/assets/ee02a072-1f42-4dde-a5d9-120fda862eb7

## Please complete the following:

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


## Please put your Discord username so you can be contacted if a bug or
regression is found:
FrederikJA
2026-06-12 12:22:03 -07:00
Evan 182d008ddd Generate a single MapInfo list; move SPECIAL_TEAM_MAPS and en.json map names into info.json (#4231)
**Add approved & assigned issue number here:**

N/A — maintainer follow-up to #4227.

## Description:

Follow-up to #4227, finishing the "info.json is the single source of
truth" refactor.

**Maps.gen.ts now generates one `MapInfo` interface and a `maps` list**
instead of parallel lookup records. `mapCategories`,
`mapTranslationKeys`, and `multiplayerFrequency` are gone — consumers
read the list directly (`map.categories`, `map.translationKey`,
`map.multiplayerFrequency`). MapPicker got simpler in the process: it
renders from `MapInfo` objects, so the reverse
`Object.entries(GameMapType)` lookup to recover the enum key is gone.
The featured-rank sort moved out of the Go codegen into the picker,
where the presentation concern belongs.

**`SPECIAL_TEAM_MAPS` moves into info.json** as an optional
`special_team_count` field (set on the same 17 maps with the same
values). MapPlaylist derives its map from the generated list;
`SPECIAL_TEAM_FORCE_CHANCE` and the frequency multiplier behavior are
unchanged.

**The en.json `map` section is now generated.** A new optional
`display_name` field in info.json (defaulting to `name`) is written to
`resources/lang/en.json` by the generator, preserving the section's
non-map UI keys (`map`, `featured`, `all`, `favorites`, `random`). The 8
maps whose English display name intentionally differs from the frozen
enum value (e.g. `MENA`, `Milky Way`, `Europe (Classic)`, `Baikal (Nuke
Wars)`) declare it via `display_name`, so no display text changes. The
section is emitted alphabetically; since #4232 already sorted en.json
and every value matches, regeneration is byte-identical and this PR has
no en.json diff. Other languages remain Crowdin-managed.

The generator also now validates `translation_key` is exactly
`map.<folder>` and `special_team_count >= 2`. MapConsistency tests
compare info.json directly against the generated list and the en.json
section, and fail with a "run `npm run gen-maps`" message on drift. No
behavior changes: enum values, playlist frequencies, special-team
counts, featured order, and display names are all byte-identical.

## Please complete the following:

- [x] I have added screenshots for all UI updates (no UI changes —
internal refactor, rendering output identical)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

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

evanpelle

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:06:48 -07:00
Evan 3de5fb4204 Move map metadata into info.json and generate map TypeScript from it (#4227)
**Add approved & assigned issue number here:**

N/A — maintainer refactor.

## Description:

Makes each map's `info.json` the single source of truth for map metadata
— adding a map is now a folder with `image.png` + `info.json`, a
`gen-maps` run, and an en.json display name.

**info.json / manifest.json carry full map metadata.** Every
`map-generator/assets/maps/<map>/info.json` declares `id` (the
`GameMapType` enum key), `name` (the enum value — wire format, unchanged
for all 94 maps), `translation_key`, `categories`, and
`multiplayer_frequency` (the public-playlist weight that used to be the
`FREQUENCY` record in MapPlaylist.ts). The generator validates
everything and mirrors it into `resources/maps/<map>/manifest.json`. 23
stale info.json `name` values were normalized to the canonical enum
value; enum values are byte-identical, so replays and stored game
configs are unaffected.

**The generator emits the TypeScript and discovers maps itself.** New
`map-generator/codegen.go` generates `src/core/game/Maps.gen.ts`
(`GameMapType`, `GameMapName`, `mapCategories`, `mapTranslationKeys`,
`multiplayerFrequency` — now a full `Record<GameMapName, number>`,
killing the old `Partial`) on every run; `Game.ts` re-exports it. The
hardcoded map registry in `main.go` is gone — maps are auto-discovered
from the `assets/maps` / `assets/test_maps` directories. MapConsistency
tests fail with a "run `npm run gen-maps`" message if info.json,
manifest.json, and Maps.gen.ts drift. The tracked
`map-generator/map-generator` binary is rebuilt to match.

**New categories: continents + world/cosmic/tournament/other,
multi-category support.** `continental`/`regional`/`fantasy`/`arcade`
are replaced by `featured`, `world`, `europe`, `asia`, `north_america`,
`africa`, `south_america`, `oceania`, `antarctica`, `cosmic`,
`tournament`, and `other`. Maps can list multiple categories, so
straddlers (Black Sea, Bosphorus, Caucasus, Between Two Seas, Bering
Sea/Strait, Mena, Strait of Gibraltar, Hawaii, Arctic) appear under both
regions. Featured is itself a category (same 7 maps as before).
MapPlaylist keeps its arcade exclusion via an explicit set.

**Map picker UI.** Two tabs: **Featured** (default — featured maps plus
a Favorites section when maps are starred) and **All** (one prominent
collapsible bar per category with a map count, collapsed by default).
The selected map is prepended to the featured grid when it lives
elsewhere. `getMapName()` resolves through the generated
`mapTranslationKeys`, which also fixes tourney maps never resolving a
valid translation key.

## Please complete the following:

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

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

evanpelle

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:36:53 -07:00
evanpelle 3c0ff7a6f2 Fail open on clan tag ownership checks when API is unavailable
The clan-tag ownership check previously failed closed: when the API
service was unreachable (e.g. during local development), the client
dropped the tag with a "couldn't verify" error and the server's
FailOpenPrivilegeChecker treated every unverifiable tag as reserved.
This made clan tags unusable whenever the API was down.

- Client: checkClanTagOwnership keeps the tag when the existence
  probe is inconclusive; the server still re-checks authoritatively.
- Server: FailOpenPrivilegeChecker passes tags through instead of
  dropping non-member tags; decideClanTag now takes a non-nullable
  reserved set since the null case is gone.
- Remove the now-unused username.tag_check_failed translation key.
- Update Privilege and ClanApiQueries tests for fail-open behavior.

Trade-off: if the reserved-tag list is unavailable in production,
real clan tags can be impersonated until the first successful
PrivilegeRefresher load; after that the last good checker is retained.
2026-06-11 12:22:33 -07:00
RickD004 7405339ea7 Add Titan map with random spawn nations - along new Cosmic map category (#4183)
Resolves #4182

## Description:

Adds "Titan" (real moon of Saturn with methane seas) map . Uses new
random spawn nation feature by FloPinguin.
https://github.com/openfrontio/OpenFrontIO/pull/4156

Also adds new Cosmic map category. The "Other" map category has become a
wastebasket of unrelated maps, and with increasing number of maps, i
think its a good addition to have better categories for these maps.

I figured these 2 changes should go together since im adding a cosmic
map, and a cosmic category.

proof of nations spawning randomly and how the cosmic category looks in
the menu:


https://github.com/user-attachments/assets/b84bd3ef-6b8f-46fe-a6ea-ea5e79c6dc00

## Please complete the following:

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

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

tri.star1011

---------

Co-authored-by: Evan <evanpelle@gmail.com>
2026-06-11 10:55:46 -07:00
RickD004 af2849a2d7 Adds "Juan De Fuca Strait" map - 3 way team map (#4215)
Resolves #4148

## Description:

Adds "Juan de Fuca Strait" map. This is the Strait in Washington and
British Columbia: https://en.wikipedia.org/wiki/Strait_of_Juan_de_Fuca

This map is meant to be a brand new 3-team way map, since all the team
maps we have are either made for 2 or 4 teams.
The map is bumped towards this gamemode similar to how Baikal is bumped
to 2 teams.

Map also has Additional Nations, for a total fof 62, for Human vs
Nations and solo games
<img width="1365" height="602" alt="image"
src="https://github.com/user-attachments/assets/9cb86727-db06-4fcb-bee4-85e7b5d47d15"
/>
<img width="1319" height="488" alt="image"
src="https://github.com/user-attachments/assets/13fd9a01-7ec6-49ab-81c3-40b566cbf6e0"
/>
data from OpenTopography, already credited

## Please complete the following:

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

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

tri.star1011
2026-06-10 20:00:53 -07:00
crunchybbb 855695b78e Adds Hong Kong map (#4191)
> **Before opening a PR:** discuss new features on
[Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small
improvements as
[issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose).
You must be assigned to an `approved` issue — unsolicited PRs will be
auto-closed.

**Add approved & assigned issue number here:**

Resolves #4152(issue number)

## Description:


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

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

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

## Please complete the following:

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

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

DISCORD_USERNAME crunchybbbbb

---------

Co-authored-by: RickD004 <realtacoco@gmail.com>
2026-06-09 18:39:48 -07:00
Patrick Plays Badly 1e3f50436c Add map world inverted (#4189)
Resolves #4187

## Description:
Add Map - World Inverted

1248x2500, 1,561,000 land tiles
~100 standard. Over 250+ total.
https://www.youtube.com/watch?v=w2LVZQXZoaU
https://discord.com/channels/1284581928254701718/1509034328766812210

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

PlaysBadly
2026-06-08 13:43:55 -07:00
RickD004 27517e3698 Adds Mississippi River map - vertical pipe map (#4176)
Resolves #4153 

## Description:

Add Mississippi river, inspired by Amazon, but vertical. Pipe-type map
along Amazon and Passage. 11 nations, with 51 additional nations for a
total of 62 for Humans vs Nations gamemode.


https://github.com/user-attachments/assets/6596a7bf-b529-442a-99b1-815493ee0e96


https://github.com/user-attachments/assets/5bb4959b-8ef3-428a-8e3a-94c424fa092b


https://github.com/user-attachments/assets/e4d4622e-ea42-4edf-9d86-d9d00c0fdde4

## 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-06 19:26:34 -07:00
Patrick Plays Badly b4058b5a58 Add map chopping block (#4143)
Resolves #4080

## Description:

Add Map Chopping Block
https://youtu.be/NpX73lHiKO8

Increased multiplier for 4 player team games and water nukes (plug in
center among other shortcuts). This map was made as a faster alternative
to Labyrinth. Map has been modified since last submission to be 'less
crazy'.

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

## Discord username:

PlaysBadly
2026-06-04 15:58:34 -07:00
Ryan 9c2ac05506 clantag part 1 (#4066)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #(issue number)

## Description:

adds a check to see if you're in a clan or not. if not, checks to see if
the clan exists, if it does, warns the user, if it doesn't, lets them
use it.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

w.o.n
2026-06-03 14:25:55 -07:00
Evan 48609fa70a Reduce lobby broadcast bandwidth via counts-only deltas (#4116)
## Description:

- The lobby WebSocket broadcast (`/lobbies`) was re-sending the full
`PublicGames` snapshot — including each lobby's `gameConfig` — to every
connected client every 500ms. Almost nothing in that payload changes
tick-to-tick; only `numClients` moves.
- `WorkerLobbyService` now tracks the sorted set of `gameID`s it last
sent as a full snapshot. On each incoming broadcast it sends a `full`
only when that set changes; otherwise it sends a `counts` delta carrying
just `{gameID → numClients}`.
- This relies on the master-side coupling at
[MasterLobbyService.ts:140-159](src/server/MasterLobbyService.ts#L140-L159):
when master finds a lobby without `startsAt`, it both sets `startsAt`
AND schedules a fresh lobby on the same tick, so the gameID change
brings the `startsAt` (and `gameConfig`) along with it.
- New WS connections are primed with the worker's cached last `full` so
late joiners don't have to wait for the next structural change.
- `LobbySocket` parses the new discriminated union (`PublicLobbyMessage
= full | counts`), keeps the last full snapshot in memory, and merges
counts into it before invoking the existing callback. `GameModeSelector`
is unchanged.
- Master → worker IPC is unchanged — still sends the full snapshot every
500ms. The optimization only applies to the worker → WS-client boundary,
which is the fan-out point.

## Please complete the following:

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

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

evan
2026-06-02 15:52:14 -07:00
RickD004 95377f0361 Adds map of Southeast Asia (#4105)
Resolves #4098

## Description:

Adds Southeast Asia map for v32. Very requested map. 31 default nations
(with an extra 31 named for HvN).

Map for intense warship and naval warfare with many, many islands. Also
adds flags of the region to be used by nations in the map. More info
specified in issue


https://github.com/user-attachments/assets/b4151db4-825a-4c1c-8bf8-7b760ae056d2

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-06-01 17:29:56 -07:00
RickD004 c049a81b86 Adds map of the Caribbean 🏴‍☠️ (#4067)
## Description:

Fixes #4069

Adds map of the Caribbean sea and its islands. Archipelago map with lots
of islands, lots of water and a lot of trade.

This map has multiple large landmasses of similar size to prevent
steamrolls (the largest islands and landmasses are around 30%), and
many, many small islands where players can survive and trade. Players
will have to island hop in order to win. 34 nations of Caribbean
countries and territories, with an extra 28 AdditionalNations for a
total of 62 nations for crowded HvN.

Heavy Island maps are very popular in the broader community and we dont
have one for v32, so i figured it would be nice to have a very requested
and popular world location

570k land tiles, fairly small for a map, would be right placed before
World (600k tiles). Also adds some flags of caribbean regions.


https://github.com/user-attachments/assets/9eae81ec-58eb-4594-89fd-2f95742f8b3a

Terrain source from OpenTopography, already credited. No modification to
the tests are needed for new maps added in Game.ts

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-29 19:23:21 -07:00
TKTK123456 9d4080fbe8 Adds onion map (#4057)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #4055 

## Description:

Adds a 512*512 onion map with 3 nations (Leafer Confederation, Outer
Enclave and Inner Tribe)

<img width="128" height="128" alt="thumbnail"
src="https://github.com/user-attachments/assets/8d97d8dc-6286-4e79-a459-767c936d49ec"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tktk1234567
2026-05-29 15:58:02 -07:00
RickD004 b043dc6c15 Team Maps Expansion: New team spawnzones for multiple maps (#4058)
## Description:

Lets give Teams and HvN gamemodes some attention. Adds team spawnzones
to the following maps, and boosts them to appear more frequently as this
gamemode:

- Straitofgibraltar - 2 teams
- Aegean - 2 teams
- Beringsea - 2 teams
- Beringstrait - 2 teams
- Bosphorusstraits - 2 teams
- Conakry - 2 teams
- Falklandislands - 2 teams
- Straitofhormuz - 2 teams
- Tradersdream - 2 teams
- Surrounded - 2 teams & 4 teams
- Pluto - 2 teams
- Gulf of St. Lawrence - 3 teams

These maps (especially the ones for 2 teams) are all very symmetrical
and would be nice gift for the playerbase, which enjoys these kind of
games like FourIslands4Teams and Baikal2Teams. This is also nice for
HvN, as it centralizes the players and gives them a better chance at
defeating the nations.

Screenshots of the maps with the new team spawnzones:


<img width="1320" height="486" alt="Captura de pantalla 2026-05-28
001558"
src="https://github.com/user-attachments/assets/e0b4bea6-d1b7-4793-a995-ec2a139a5af6"
/>
<img width="1177" height="528" alt="Captura de pantalla 2026-05-28
001913"
src="https://github.com/user-attachments/assets/28ec5bf8-3a02-4660-ba62-3edbcabeaf51"
/>
<img width="1147" height="531" alt="Captura de pantalla 2026-05-28
002032"
src="https://github.com/user-attachments/assets/b148f1ae-473a-4505-b0f4-ca8820fbbb55"
/>
<img width="1219" height="536" alt="Captura de pantalla 2026-05-28
002348"
src="https://github.com/user-attachments/assets/89af4d27-eadf-447c-9bde-d0dcfe1ff757"
/>
<img width="923" height="524" alt="Captura de pantalla 2026-05-28
002704"
src="https://github.com/user-attachments/assets/50ad1b11-1685-41fb-b14d-088a2f0db88b"
/>
<img width="1307" height="456" alt="Captura de pantalla 2026-05-28
002859"
src="https://github.com/user-attachments/assets/4ef18da9-336a-4698-8af0-2769467148b4"
/>
<img width="1219" height="548" alt="Captura de pantalla 2026-05-28
003134"
src="https://github.com/user-attachments/assets/d0a514bf-e6e6-43f6-89b7-2168bc395010"
/>
<img width="1200" height="538" alt="Captura de pantalla 2026-05-28
003449"
src="https://github.com/user-attachments/assets/c1672296-db4d-4baf-9992-4bb380fab4e9"
/>
<img width="1032" height="501" alt="Captura de pantalla 2026-05-28
003650"
src="https://github.com/user-attachments/assets/8dd5ee07-3ac3-4f03-a56e-31c01d612655"
/>
<img width="1074" height="525" alt="Captura de pantalla 2026-05-28
003951"
src="https://github.com/user-attachments/assets/e140706b-3f1c-4e09-b70c-efc3e6536c60"
/>
<img width="914" height="513" alt="Captura de pantalla 2026-05-28
004632"
src="https://github.com/user-attachments/assets/e0dd6820-62f4-48b6-8356-df20c0e6ed8f"
/>
<img width="988" height="509" alt="Captura de pantalla 2026-05-28
005518"
src="https://github.com/user-attachments/assets/0da95c41-1191-4de4-a3ce-873839c00605"
/>
<img width="986" height="514" alt="Captura de pantalla 2026-05-28
000505"
src="https://github.com/user-attachments/assets/4eb20c73-56ba-4f9f-90af-8a047aa399eb"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-28 15:34:26 -07:00
crunchybbb 2cb5244ad4 Adds Map of the Yellow Sea (#4026)
## Description:

"A high-stakes naval theater where empires clash over narrow corridors,
bottleneck straits, and heavily fortified shorelines."

Modeled to the exact strategic proportions of the classic Black Sea map,
Yellow Sea shifts the focus of global conflict to East Asia. The map is
defined by its massive central body of water, making naval dominance
absolutely essential for survival. However, unlike wide-open oceans,
control of the Yellow Sea is entirely dictated by its unique coastal
geography.
The Shandong And Liaoning Peninsulas are The definitive feature of the
map. Two massive, opposing peninsulas project deep into the sea, acting
as natural, heavily contestable daggers. They create tight naval choke
points in the central waters while forcing land-based players into
brutal, linear frontlines where every pixel of territory is bought with
blood.
The Continental Rim: A sprawling mainland coast wraps around the
northern and western edges of the map, offering expansive land routes
for players who prefer sweeping land invasions over amphibious assaults.

Scale Class: Medium

Gameplay Style: Naval/Land Hybrid, Tactical Choke Points, Frontline
Bottlenecks

Nations: 8
North Korea South Korea Liaoning Shandong Beijing Hebei Tianjin Jilin



description mostly generated by google gemini 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

DISCORD_USERNAME crunchybbbbb

<img width="1660" height="1266" alt="Screenshot 2026-05-24 220103"
src="https://github.com/user-attachments/assets/800c6732-677d-44f1-ba5c-c60da5f199e0"
/>


<img width="1500" height="1152" alt="yellow_sea2"
src="https://github.com/user-attachments/assets/9b3ba34a-3f9c-4485-9235-f953fd07be4c"
/>

Game play video https://youtu.be/IcRPTM0rHM0

---------

Co-authored-by: RickD004 <realtacoco@gmail.com>
2026-05-28 15:30:47 -07:00
Evan aa3959bffe feat: territory png based skins (#4006)
## Description:

Add image-based territory skins as a new cosmetic type, rendered
alongside the existing 1-bit patterns. Skins render a single PNG
centered on each player's spawn tile — opaque pixels show the skin
(multiplied by team color in team games, raw colors in FFA), transparent
pixels and tiles outside the image bounds fall through to the regular
player palette color.

**Cosmetic plumbing**
- `SkinSchema` in `CosmeticSchemas.ts`, optional `skins` map on
`CosmeticsSchema`
- `PlayerSkin`, `PlayerCosmetics.skin`, `PlayerCosmeticRefs.skinName` in
`Schemas.ts`
- Server-side resolution: `PrivilegeCheckerImpl.isSkinAllowed` (gated by
`skin:*` / `skin:<name>` flares)
- Client persistence: stored under `PATTERN_KEY` (`pattern:` and `skin:`
share one slot — they're mutually exclusive)
- `getPlayerCosmeticsRefs` only emits a `skinName` when cosmetics are
loaded, the skin exists in the catalog, and the user has the right flare
— otherwise drops the ref and clears storage

**Renderer**
- `SkinAtlasArray` — fixed `TEXTURE_2D_ARRAY`, 1024×1024 per layer,
exact layer count allocated once at game start from the locked-in player
set. No resize, no callbacks, no retained `HTMLImageElement`. Zero GPU
cost when no players have skins (1×1 placeholder).
- `skinLayerTex` (R8UI 4096×1) — per-player `layer + 1` (`0` = no skin)
- `skinAnchorTex` (RG16UI 4096×1) — per-player spawn tile, so the PNG
center anchors at each player's spawn (re-uploads when the player
re-picks during spawn phase)
- `WebGLFrameBuilder.syncPlayers` collects unique skin URLs on first
sync and calls `view.initSkinAtlas(urls)` once; `clearCaches()` resets
so seek/replay re-initializes
- `territory.frag.glsl`: skin branch is mutually exclusive with
patterns; bounds-checks UVs against `[0, 1]` so the image is a single
stamp, not tiled; alpha-blends against the player palette color so
transparent pixels and out-of-bounds tiles render as the regular player
color

**Hover highlight (global UX change, not skin-scoped)**
- Existing hover highlight changed from "brighten toward white" to
"saturation boost." Applies to all players regardless of
skin/pattern/flat-color — looks better across the board.

**UI**
- `CosmeticButton` renders skins as a single `<img>` (object-contain)
- `TerritoryPatternsModal` merges patterns + skins into one grid; single
"default" tile clears both
- Selecting a pattern clears the skin and vice versa (mutually
exclusive)
- `Store` pattern tab includes skin entries (purchasable, not-yet-owned)
- `PatternInput` lobby button previews the active skin when one is set

**Memory**
- 0 skin players → ~4 bytes (placeholder) + ~40 KB fixed per-player
tables
- 1 skin player → ~5.6 MB GPU
- 5 skin players → ~28 MB GPU
- 10 skin players → ~56 MB GPU

**Tests**
- `tests/Privilege.test.ts`: 13 new cases covering `isSkinAllowed`
(wildcard, exact-match, missing flare, missing skin, forged refs) and
`isAllowed` integration (allowed/forbidden paths, short-circuit when
invalid skin is paired with valid other cosmetics)

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-05-27 13:00:07 -07:00
Berk ddf63066fa fix(game): patch Desync DoS vulnerability with strict majority consensus (#3956)
Resolves #3959

## Description:

This PR fixes a Denial of Service (DoS) vulnerability in 1v1 matches
related to desync reporting. The `findOutOfSyncClients` logic previously
forced a game-ending desync if half or more players reported conflicting
hashes (`outOfSyncClients.length >= Math.floor(this.activeClients.length
/ 2)`). In a 1v1, this meant a single malicious player sending a bad
hash could trigger a global desync, crashing their opponent's game
session.

The logic has been corrected to require a **strict majority** (`>
Math.floor(this.activeClients.length / 2)`) to declare a lobby-wide
desync. In a 1v1 game, a single malicious actor will now simply be
flagged as the out-of-sync client and disconnected, allowing the honest
player to continue their session uninterrupted.

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

barfires

Co-authored-by: Josh Harris <josh@wickedsick.com>
2026-05-27 14:10:43 +00:00
Patrick Plays Badly 172113193f Add May Labyrinth (#4002)
## Description:

Labyrinth is a maze type map. My attempt at making a more chess style
board for play. Games with bots appear stable at over 45min average run
times. The map has been setup for team spawn zones for 2, 3, 4, 5, 6,
and 7 teams. Some of the team spawns for odd numbers are experimental
and I would like to see how they play out with live players. Additional
nation names included. There are other design factors like each of the
large squares being within the blast radius of a hydro; small islands
are within the blast radius of nukes.

This is meant as a slower playing game. My intentions are to get some
sort of literal rotation of the map in the future if easily implemented.
That way every time players load the game there would be some
randomization.

As an additional note one of my last edit to the map was the "+" shape
to the islands to allow train passage. Zooming out I can see now that
the pattern is squares and + through out. Did not fully intend on that,
but it felt like good vibes.


https://discord.com/channels/1284581928254701718/1293201128858587207/threads/1497062552784605316
https://www.youtube.com/watch?v=e8c-TylT4hs
https://www.youtube.com/watch?v=0-yqrfr3nv0


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username 

PlaysBadly

---------

Co-authored-by: RickD004 <realtacoco@gmail.com>
2026-05-26 19:25:43 -07:00
crunchybbb b086881a4e Add Korea Map (#3977)
## Description:

Add map of korean peninsula. Size 1092x2149
Nations: 35
based on provinces

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

DISCORD_USERNAME
crunchybbbbb @crunchybbbbb_59469


<img width="1092" height="2149" alt="Korea-2"
src="https://github.com/user-attachments/assets/874100e8-4a68-4f57-b2f7-49aa87b8194d"
/>

two teams nations war video https://www.youtube.com/watch?v=n4h7GAfAHTM

---------

Co-authored-by: Ricky G.P. <realtacoco@gmail.com>
2026-05-26 19:11:13 -07:00
Josh Harris 2d6342cd22 Add stale-if-error to app shell Cache-Control (#4009)
## Description:

Adds `stale-if-error=86400` to the `Cache-Control` header set on the
rendered app shell (`/`) in
[src/server/RenderHtml.ts](src/server/RenderHtml.ts). This lets shared
caches (CloudFlare, nginx `proxy_cache`) keep serving the last good
`index.html` for up to 24h if origin returns a 5xx, alongside the
existing `stale-while-revalidate` window.

Pairs with enabling HTML caching for the `/` route on CloudFlare in
"respect origin headers" mode — it already honors `s-maxage` (5 min edge
TTL) and `stale-while-revalidate`; this just extends the same safety net
to origin-error cases.

No behavior change for successful responses; browsers still revalidate
every load via `max-age=0`.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

jish

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:13:48 +01:00
Evan 3811d3cd89 Hide clan tags in public FFA games to prevent teaming (#4000)
## Description:

- Added optional `disableClanTags` to `GameConfig`. When set, the server
strips clan tags from `gameInfo` (lobby broadcasts/HTTP) and
`gameStartInfo` (start payload) before sending to clients. Archive keeps
the originals.
- Enabled `disableClanTags` for public FFA games (both the regular FFA
playlist and special when randomized to FFA). No UI; clients still see
their own clan tag via local input state.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-05-24 16:52:10 +01:00
RickD004 5a2c0504eb adds map of the Balkans (using Additional nations feature) (#3998)
## Description:

Adds map of the Balkan Peninsula and surroundings. Heavily requested map
with multiple posts on the Discord all with over 10 or 20 upvotes.

23 NPC/Nations based on countries and relevant regions of the area. Adds
an extra 39 nations for crowded Humans vs Nations gamemode for a total
of 62 NPCs, based on regions of multiple countries. Also some flags for
some regions.

Source from NASA DEM, already credited

Photo of base map, and 62 HvN:


<img width="614" height="588" alt="Captura de pantalla 2026-05-24
030105"
src="https://github.com/user-attachments/assets/5742a4c3-1b1f-4ca7-858d-91529861dd81"
/>

<img width="548" height="547" alt="image"
src="https://github.com/user-attachments/assets/758d8ad0-1515-41b8-8d42-14e76cdd54ed"
/>

This map completes the quartet row of "polemic" maps for v32

<img width="678" height="119" alt="image"
src="https://github.com/user-attachments/assets/9e6f4ef1-f0cc-48ea-a59f-b7ff69033b73"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-24 16:09:37 +01:00
evanpelle 14f2e36d15 set dev public lobby time back to 5 seconds 2026-05-23 21:43:37 +01:00
evanpelle 8f982ce123 Extend friend grouping to the lobby team preview
The preview was calling assignTeams without friend data, so the
team layout shown in the lobby could differ from the layout the
game actually started with. Wire friends through ClientInfo so
the preview matches.

Extract the publicId→clientID translation used by both start()
and gameInfo() into buildFriendsLookup() to remove the duplicate.
2026-05-23 20:52:13 +01:00
Evan db501c68d2 Put friends on the same team (#3994)
Fixes #3911

## Description:

- Server captures `publicId` and `friends` from `getUserMe()` and
includes each player's in-game friend `clientID`s in `PlayerSchema` on
game start
- Team assignment treats friends as a **soft preference** (best-effort):
a non-clan player goes to the team where the most of their friends
already are; if that team is full they spill to the next-emptiest team
rather than getting kicked
- Clans remain strict (kick overflow) since clan membership is an
explicit opt-in; friends are implicit, so a friend-of-friend chain that
doesn't fit shouldn't bench anyone
- Friendship is symmetric — an edge from either direction counts, which
keeps things working when one side's `getUserMe` is stale
- Lobby preview unchanged — friend grouping only takes effect once the
game actually starts (avoids exposing friend lists in the lobby payload)


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-05-23 18:02:41 +01:00
RickD004 b1ec3ac70f Adds Indian Subcontinent map (#3975)
## Description:

Adds Map of the Indian Subcontinent, with indian and pakistani states
and surrounding countries, important rivers like the Ganges, Brahmaputra
and Indus, and Tibet/ theHimalayas

2M land pixels and 52 Nations (i think its fitting that India has the
most nations of a regional map, only continental maps have more)

Should be nice to boost whatever indian playerbase this game might have.
This region also doesnt have any representation aside from continental
maps

<img width="584" height="598" alt="image"
src="https://github.com/user-attachments/assets/4089049a-800b-4e37-ab34-2afc5de821e8"
/>
<img width="418" height="462" alt="image"
src="https://github.com/user-attachments/assets/a68e2424-5972-4105-86c9-0312ab095024"
/>

Elevation data from NASA DEM, already credited in CREDITS.md

No reference test is needed, the test suite automatically iterates over
all GameMapType enum values — no map is hardcoded by name in the tests

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-23 17:38:03 +01:00
RickD004 6591b055c3 Adds map of Venice 🛶 (#3935)
## Description:

Adds map of Venice. A relatively small map (similar land area to World)
for heavy trade and lots of boating.

Because of the very low difference of elevation of the zone, terrain is
instead used to show buildings.

Map source from OpenStreetMap, already credited in CREDITS.md

Very requested map, with 2 discord posts suggesting it with +15 upvotes
each

<img width="794" height="569" alt="image"
src="https://github.com/user-attachments/assets/ca7d44f2-cfc9-4e93-b7d4-43dbe62f74d4"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-18 19:43:02 -07:00
evanpelle f23789883b Merge webgl2 — full WebGL2 renderer migration
relates to #893

Replaces the canvas2D + Pixi.js map renderer with a pure WebGL2 pipeline.
Map-space visuals (terrain, names, structures, units, FX, selection
boxes, build ghosts, status icons, nuke trajectories, defense zones,
spawn glow, water-nuke terrain deltas) all render through dedicated
passes in src/client/render/gl/passes/. Controllers in
src/client/controllers/ push state directly to the WebGL view; no
relay events. Assets unified under resources/ + assetUrl(). Mode
toggle wired to the existing darkMode UserSetting (no more day/night
cycle). One input system (InputHandler + EventBus + TransformHandler).

Known regressions to address in follow-up work:

- [ ] webgl: highlight structures when hover on build menu
- [ ] webgl: custom flags, flag atlas
- [ ] webgl: territory patterns
- [ ] webgl: defense post outline
- [ ] webgl: territory expanse smoothing
2026-05-18 12:09:11 -07:00
evanpelle b27c2984fd include atlases/ in the public asset manifest
resources/atlases/ wasn't in the manifest glob list, so the build
skipped hashing/copying it into static/_assets/ and the deploy
pipeline's R2 uploader had no keys for it — atlases 404'd on staging.
2026-05-17 13:08:08 -07:00
Berk 5fefc21cb8 security: remove duplicate express.json() middleware (SEC-04) (#3947)
## Description:

The app had `express.json()` registered twice in `app.ts`. This can
cause issues with body parsing and is redundant.

**Fix:** Removed the second call to `app.use(express.json())`.

## Please complete the following:

- [x] I have added screenshots for all UI updates (N/A - no UI changes)
- [x] I process any text displayed to the user through translateText()
(N/A)
- [x] I have added relevant tests to the test directory (N/A - existing
tests pass)
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:

barfires
2026-05-16 19:33:47 -07:00
Berk 749f496318 fix: prevent sendStartGameMsg from crashing server on client disconnect (#3939)
The catch block in sendStartGameMsg() re-throws the error, which means a
single client's WebSocket failure (e.g. disconnected during game start)
propagates up and can crash the entire game server. The start() method
calls sendStartGameMsg() in a forEach loop over all clients, so one bad
client kills the game for everyone.

Changes:
- Added readyState check before sending
- Replaced re-throw with structured error logging
- A single client failure now logs the error and continues gracefully

If this PR fixes an issue, link it below. If not, delete these two
lines.
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
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

DISCORD_USERNAME
2026-05-16 11:20:18 -07:00
Berk 48b957c297 fix: guard all ws.send() calls with readyState check to prevent server crashes (#3936)
## Description:

Several ws.send() calls in GameServer.ts were missing WebSocket.OPEN
readyState guards. This can lead to server crashes if a client
disconnects precisely between a check and the send. Added guards to
prestart, kickClient, and handleSynchronization.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:

barfires

Co-authored-by: 22314621 <22314621@student.ciu.edu.tr>
2026-05-16 11:17:05 -07:00
RickD004 7dc5d472a7 Change name of map "The Straits" into "Danish Straits" (#3929)
## Description:

Renames TheStraits map. The people that suggested this map told me they
would prefer a more specific name for the map, rather than the generic
one it has right now. So im renaming it into Danish Straits

This map is for v32, it has not been released, it should be fine to
rename


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-15 08:59:28 +00:00
RickD004 7359e2bc3b Adds Northwest Passage map (using new additionalNations feature) (#3920)
## Description:

Adds map "Northwest Passage", map of the Canadian Arctic Archipelago ,
Greenland and surroundings. "Northwest Passage" (NWP) is the sea lane
between the Atlantic and Pacific oceans
(https://en.wikipedia.org/wiki/Northwest_Passage) .

21 default nations, based on the towns of the region.

This map uses the brand new additionalNations feature made by FloPinguin
https://github.com/openfrontio/OpenFrontIO/pull/3902 . Adds 39 extra
nations for a total of 60 nations (so that in gamemodes like Humans vs
Nations all the nations have names of real places)

Comparison: 
- Map with default nations
- Map with extra named nations, tested by raising the number of nations
in Solo

<img width="1050" height="412" alt="image"
src="https://github.com/user-attachments/assets/12ed94f1-0615-4fb3-b0d0-dcecb65006ea"
/>
<img width="1089" height="436" alt="image"
src="https://github.com/user-attachments/assets/6e7c11bf-7382-4e36-9433-229a9d463b68"
/>

Terrain source from OpenTopography, already credited in CREDITS.md

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-14 19:27:46 -07:00
FloPinguin 990eba6134 Improve MapPlaylist 🎲 (#3904)
## Description:

### 1. `SPECIAL_MODIFIER_POOL` rebalanced
Ticket weights adjusted to roughly track the community "favorite
modifier" poll

<img width="486" height="724" alt="Screenshot 2026-05-11 210740"
src="https://github.com/user-attachments/assets/bb1d2461-beb3-41c0-8d7b-b604db5fc033"
/>

- `isRandomSpawn`: 2 to 4
- `goldMultiplier`: 4 to 6
- `isWaterNukes`: 3 to 4
- `startingGold25M`: 1 to 3
- `startingGold5M`: 5 to 4
- `startingGold1M`: 3 to 2

### 2. New `SPECIAL_TEAM_MAPS` config
Replaces the hardcoded per-map branches in `getTeamCount` and
`buildMapsList`. Each entry maps a `GameMapType` to its preferred
`TeamCountConfig`. Shared constants:
- `SPECIAL_TEAM_FORCE_CHANCE = 0.75` (probability of overriding the
random team weights roll)
- `SPECIAL_TEAM_FREQ_MULTIPLIER = 2` (frequency boost in the team
playlist)

Current entries: Baikal (2), FourIslands (4), Luna (2). Behavior
preserved for the existing maps, but adding another special team map is
now a one-line entry.

### 3. New `FULL_LAND_MAPS` config (TheBox, Alps)
- Water nukes forced on 75% of the time in the special rotation
(overrides `WATER_NUKES_BOOSTED_MAPS`, which still applies its 50% boost
to FourIslands, Baikal, Luna, ArchipelagoSea). Because they make a lot
of fun on these two maps.
- The `isPortsDisabled` modifier is excluded unless water nukes is
boosted on, since ports are pointless on full-land maps. Because this
happened:
<img width="516" height="292" alt="image"
src="https://github.com/user-attachments/assets/cd9ce31d-25d0-4b35-a8ba-bb3ec1c02b70"
/>

### 4. Misc
- Renamed `frequency` constant to `FREQUENCY` for consistency with other
module-level constants.

### 5. Exclude `isNukesDisabled` on special team maps in team mode

On `SPECIAL_TEAM_MAPS` (FourIslands, Baikal, Luna) in team mode, the
`isNukesDisabled` modifier is now excluded from the pool. Otherwise an
extreme warship spam will follow.

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

FloPinguin
2026-05-11 19:27:02 -07:00
Evan 275fd0dccc refactor: collapse per-env Configs into ClientEnv + ServerEnv (#3906)
## Description:

This is a refactor to simplify config handling.

Replaces the per-environment DevConfig/PreprodConfig/ProdConfig class
hierarchy with two static classes: ClientEnv (browser main thread, reads
from window.BOOTSTRAP_CONFIG) and ServerEnv (Node server, reads from
process.env). The four config classes are deleted, the abstract
DefaultServerConfig is gone, and DefaultConfig is renamed to Config.

The values that flow server → client (gameEnv, numWorkers,
turnstileSiteKey, jwtAudience, instanceId) used to be baked into the
hardcoded per-env classes. They're now real env vars on the server,
embedded into a single window.BOOTSTRAP_CONFIG object in index.html at
request time (alongside the existing gitCommit/assetManifest/cdnBase
globals, which moved into the same object), and read back by ClientEnv
on the client. The dev defaults previously hidden inside DevServerConfig
are now explicit in start:server-dev (NUM_WORKERS=2,
TURNSTILE_SITE_KEY=1x..., JWT_AUDIENCE=localhost, etc.) and in
vite.config.ts's html plugin inject.data. Production deploys plumb
NUM_WORKERS and TURNSTILE_SITE_KEY through deploy.yml (GitHub vars) into
the remote env file; JWT_AUDIENCE is derived from DOMAIN in deploy.sh.
The dynamic /api/instance endpoint is gone — INSTANCE_ID rides along in
BOOTSTRAP_CONFIG now.

ServerEnv is the only thing server code touches; ClientEnv is
browser-only. The two classes have intentional overlap (env, numWorkers,
jwtIssuer, gameCreationRate, workerIndex, etc.) since they derive
identical logic from different sources — there's a TODO in each to
consolidate via a shared helper later. The game-logic Config no longer
stores a ServerConfig/ClientEnv reference and its serverConfig() getter
is gone; the one caller (MultiTabModal) now reads ClientEnv.env()
directly. Worker init no longer carries server-config values since
nothing in the worker actually reads them.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-05-11 19:24:01 -07:00
RickD004 834a9757d7 Adds map "The Straits" (#3896)
## Description:

**Adds "The Straits" map:**

A map located around Denmark and the many surrounding straits: Kattegat,
Skagerrak and the Danish straits (thus the name, meant to be a creative
name like "Between Two Seas" and "Gateway to the Atlantic"). This map is
themed in the early 1900s, the nations/NPCs are traditional and
historical regions of Sweden-Norway, Denmark and the Germany.

Relatively small map with ~700k land tiles, similar to World

Inspired by this Discord thread with nearly 20 upvotes:
https://canary.discord.com/channels/1284581928254701718/1482089104110911634/1482089104110911634

<img width="365" height="506" alt="image"
src="https://github.com/user-attachments/assets/5ee16218-34c0-4b8b-9f9b-d33f219760b0"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

tri.star1011
2026-05-11 21:35:41 +00:00