Commit Graph
4250 Commits
Author SHA1 Message Date
cade1cc4ce Add lemon drizzle cake recipe (#4688)
## Description:

Adds `resources/lemon-drizzle.md`, a markdown recipe for a classic lemon
drizzle cake (ingredients, method, and tips).

This is a standalone documentation/content file — no code, UI, or game
logic is touched.

## Please complete the following:

- [ ] I have added screenshots for all UI updates — N/A, no UI changes
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file — N/A, no in-game user-facing text
- [ ] I have added relevant tests to the test directory — N/A, no code
changes

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Jt1cb7ep49UDEbX9SbRH6Q)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 14:30:02 +00:00
Josh HarrisandGitHub 2ecdbdfe8d Steam desktop authentication support (SteamSDK, Auth branch, audience config, username seeding) (#4683)
> **Maintainer note:** part of the private OpenFront Steam release
(Milestone 2, tracked in Linear), not a public GitHub issue. Per the
contributor policy this needs an approved/assigned issue or a maintainer
label to avoid auto-close — flagging for a maintainer to handle.

Resolves: _n/a — private Steam-release work (see note above)_

## Description:

Adds the client-side pieces for authenticating the OpenFront **Steam
desktop build** (an Electron shell) against the existing API, mirroring
the established CrazyGames integration. The native Steamworks code lives
in the desktop shell and is exposed to this client through a
`window.openfrontDesktop.steam` bridge; this PR is the renderer half.

- **`SteamSDK.ts`** (new) — thin wrapper over the desktop shell's Steam
bridge (`isOnSteam` / `getTicket` / `getUser`), mirroring
`CrazyGamesSDK`; narrows the loosely-typed `window.openfrontDesktop`
locally rather than re-declaring it.
- **`Auth.ts`** — a Steam branch at the top of `doRefreshJwt()` plus
`doSteamLogin()`, exchanging a Steam Web-API ticket at `POST
/auth/steam` for a session JWT, exactly paralleling `doCrazyGamesLogin`.
Re-exchanges on expiry (the refresh cookie is cross-site-blocked from
the `app://` origin); no ticket / not on Steam falls through to the
existing flow.
- **`Api.ts`** — `getAudience()` now sources from `BOOTSTRAP_CONFIG`
(`ClientEnv.jwtAudience()`) instead of `window.location`, so the desktop
app (running at `app://openfront`) resolves the real API base.
Behavior-preserving on web, where the injected audience already equals
the hostname-derived one.
- **`UsernameInput.ts`** — seeds the in-game display name from the Steam
persona on first launch and persists it (unlike CrazyGames). Writes only
the local display name; never the account username.
- **`SteamLinkSignpost.ts`** (new) — a one-time, dismissible notice
shown on first Steam launch that account linking is coming.

## Please complete the following:

- [ ] I have added screenshots for all UI updates — ⚠️ _the new
first-launch link signpost is a UI addition; it renders only inside the
Steam desktop shell. Screenshots to be added by the maintainer._
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — signpost strings go through
`translateText()` with keys added to `resources/lang/en.json`
(`steam.link_signpost`, `common.got_it`).
- [x] I have added relevant tests to the test directory — unit tests for
`SteamSDK` (incl. bridge-throws degradation), the `Auth.ts` Steam
exchange + fall-through, the `Api.ts` audience change, username seeding,
and the signpost gating. Full suite green (1976) + `tsc --noEmit` clean.

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

_⚠️ maintainer to fill_

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

https://claude.ai/code/session_01BWxUzYb2uqjcBFQuNSJhMy
2026-07-23 14:13:14 +01:00
Josh HarrisandGitHub ddc1abe146 Fix getApiBase() returning https://undefined when API_DOMAIN unset (#4685)
Resolves #4684

## Description:

Fixes `getApiBase()` returning `https://undefined` on `localhost` when
`API_DOMAIN` is unset (a plain `npm run dev`).

- `src/client/Api.ts`: use the exact `process.env.API_DOMAIN` form so
Vite's `define` replaces it (the previous `process?.env?.API_DOMAIN`
optional-chaining form wasn't matched, leaving the literal string
`"undefined"`).
- `vite.config.ts`: `JSON.stringify(env.API_DOMAIN ?? "")` so an unset
value becomes a falsy empty string instead of the truthy string
`"undefined"`.

Net: an unset `API_DOMAIN` now falls back to `http://localhost:8787` as
intended.

## Please complete the following:

- [x] I have added screenshots for all UI updates — _no UI changes_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — _no user-facing text_
- [x] I have added relevant tests to the test directory — new
`tests/Api.test.ts` reproduces `https://undefined` (RED) and asserts the
`http://localhost:8787` fallback (GREEN); full suite green (2149 tests)

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

_⚠️ maintainer to fill_

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

https://claude.ai/code/session_01BWxUzYb2uqjcBFQuNSJhMy
2026-07-23 12:58:37 +01:00
RyanandGitHub d76a0c4009 feat(client): configurable leaderboard and team stats columns (#4646)
## Description:

- rips out some duped code, making tables use the same layout, where the
only thing different is the row content.
- updated text to be emojis to save space
- updated columns to be as wide as they need to be
- updated cells to have left/right thin borders to read easier
- added more supported column types
- removed the + button to always show everyone, where the current player
is either at the bottom or in the top 5
- added a cogwheel to change options
- can select different options for each menu type (player/team stats)

<img width="582" height="630" alt="image"
src="https://github.com/user-attachments/assets/e54dc3d5-eb2c-4bab-8732-06bea68aec45"
/>

works fine on mobile, will probably need some future love though (has
h-scroll, so all options can be seen by scrolling):


## 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-07-22 13:13:43 -07:00
Josh HarrisandGitHub 3e2251bbbb Exempt the Electron desktop shell from loading Admiral ads (#4651)
**Add approved & assigned issue number here:**

## Description:

Admiral (`src/client/Admiral.ts`), OpenFront's ad-recovery vendor, is
injected via a runtime `document.createElement("script")` call rather
than a static HTML `<script>` tag. That makes it invisible to HTML-level
ad stripping done in the (private, separate) Electron/Steam shell repo,
so it was attempting to load — and cascading into further ad-exchange
calls — inside the paid desktop client.

`Main.ts` already exempts CrazyGames-hosted sessions from Admiral via
`window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames()`. This
extends that same, existing gate to also cover the Electron desktop
shell, detected via a new `src/client/DesktopShell.ts`
(`isDesktopShell()`), which mirrors `CrazyGamesSDK.ts`'s
`isOnCrazyGames()` pattern: it checks for `window.openfrontDesktop`, a
global only the Electron app's own preload script ever sets. No behavior
change for web/CrazyGames users — the change is a single additional `&&`
clause.

## Please complete the following:

- [x] I have added screenshots for all UI updates — n/a, no UI change
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — n/a, no user-facing text added
- [ ] I have added relevant tests to the test directory — no unit test
added; following existing convention in this area (`CrazyGamesSDK.ts`'s
`isOnCrazyGames()`, the pattern this mirrors, also has no dedicated unit
test). Verified instead via `tsc --noEmit`, `eslint`/`prettier`, a full
`build-prod`, and the Electron shell's own e2e network-isolation test
confirming Admiral's script (and downstream ad-exchange calls) no longer
attempt to load when `window.openfrontDesktop` is present.

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

jish
2026-07-22 13:08:04 -07:00
Josh HarrisandGitHub ebd22af95e perf: group owned cosmetic variants (#4671)
> **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.

## Description:

The owned Cosmetics grid rendered every pattern/palette combination as
an independent tile. For accounts with large cosmetics entitlements this
generated more than 1,200 canvas-backed PNG previews and caused a
multi-second main-thread microtask storm.

This change groups owned palettes into one tile per pattern, matching
the existing Store UI. Palettes remain selectable through the existing
swatch row, and the player's currently selected palette remains the
tile's initial preview and selection.

Validation:
- `vitest run tests/client/CosmeticButton.test.ts
tests/ResolveCosmetics.test.ts` (28 tests)
- `npm run build-prod`

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

jish
2026-07-22 12:47:07 -07:00
ItsTimeTooSleepandGitHub 42be9db71c fix(client): improve spawn phase progress bar visibility (#4666)
Resolves #4579

## Description:

This PR fixes issue #4579 where the spawn phase progress bar color
blends with the ocean on certain maps, making it hard to see.

Changes:
- Added a new CSS variable `--color-bright-blue: #1e90ff` in
`src/client/styles.css`
- Updated `src/client/hud/layers/SpawnTimer.ts` to use
`--color-bright-blue` instead of `--color-malibu-blue`
- Increased opacity from 0.7 to 0.85 for better visibility

The new color (#1e90ff) with 85% opacity provides better contrast
against both ocean and UI grey backgrounds.
**Before:**
<img width="3200" height="1660" alt="image"
src="https://github.com/user-attachments/assets/703d5401-de4c-4e2f-b8c4-16a1cc1ba8d4"
/>

**After:**
<img width="3200" height="1660" alt="image"
src="https://github.com/user-attachments/assets/f6237f64-e6bf-4b46-a491-c61f2bbde3cd"
/>

**Note:** The original issue suggested using `--color-deep-blue:
#003d5c`. However, after testing, this color was too dark and blended
with the top UI background. The final solution uses
`--color-bright-blue: #1e90ff` with increased opacity (0.85), which
provides better contrast against both ocean and UI elements.

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

ItsTimeTooSleep
2026-07-22 09:50:34 -07:00
Luke-DawesandGitHub 8b2a274a62 Fix cosmetics blocking buttons on mobile (#4669)
Resolves #4652

## Description:

On mobile the buttons "keep playing", "requeue" (ranked), and "quit"
where hidden after a player on mobile won. This is now fixed and means
that the cosmetics box can be squished instead of the buttons not
appearing on the page. Examples are below

It used to look like this:
<img width="355" height="769" alt="image"
src="https://github.com/user-attachments/assets/3e78ddf8-0618-4a91-9300-fd6105e17890"
/>
<img width="1668" height="769" alt="image"
src="https://github.com/user-attachments/assets/d3ee6da0-bb4a-4384-a468-ef72962dbf9b"
/>


It now looks like this:
(I dont know why this isnt embedded sorry)

https://github.com/user-attachments/assets/dd6f0cfa-11df-40bd-ab0b-fc384f5bb272
- iphone 14

https://github.com/user-attachments/assets/ae66bc99-f0b1-4f2f-b8ea-76d69b22a35f
- pc + iphone 11

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

Do i need to add tests?

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

LDTigerboy
2026-07-22 13:43:34 +00:00
Zixer1andGitHub 8de73deac8 Feat/featured stream (#4335)
**Add approved & assigned issue number here:**
Resolves #4333

## Description:
Adds a small homepage panel that embeds a live Twitch stream, shown only
while a configured channel is actually live. When nothing configured is
live, it renders nothing.
Config-gated and off by default. The client reads a served config like
news.json: an on/off toggle plus the Twitch channel(s), with a bundled
fallback so it stays off until the backend serves config. So OF admins
control whether it is on and which channel is linked, no redeploy. First
live channel in the list wins.
You can drag the panel between the corners or minimise it, both
persisted across refresh. Clicking it opens the channel on Twitch. It
shows the live broadcast title, fetched from a third party with a clean
fallback to the channel name if that is unavailable.

Mainly designed OFM tournament streams, but OF can point it at its own
channel for major releases.

Notes:
- Off by default, no impact until OF turns it on. Activation needs the
matching backend config endpoint (`featured-stream.json`), which I will
follow up on the infra side.
- Twitch requires the embed to stay visible while playing, so
"minimised" stays a small visible thumbnail and the panel pauses during
a game instead of hiding (The stream can still be muted, but will keep
being played, although it can be paused. This is so that the viewership
still is present on twitch, and players in OF can see what is
happening).

Showcase of how it would look like:

https://github.com/user-attachments/assets/4874bcd6-e7e8-49d8-94ab-20512ab1f71c

## Please complete the following:

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

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

zixer._
2026-07-21 20:43:15 -07:00
1360b27bbd feat(client): graphics presets — simplify settings behind an Advanced section (#4663)
## Summary

The graphics settings modal had grown too complex for average players
(~25 sliders/toggles/color pickers in one flat list). This reworks
graphics configuration around **presets**:

- **Shared `<graphics-preset-selector>` component**
(`src/client/components/`): a self-contained dropdown of built-in +
saved presets that applies a preset wholesale, shows a disabled "Custom"
entry while the current overrides match no preset, and offers delete for
saved presets. It reads/writes `UserSettings` directly and re-renders on
the settings-changed events, so both hosts stay in sync automatically.
- **In-game graphics modal**: preset dropdown up top, everything else
collapsed under an **Advanced settings** expander. Advanced also holds
the custom-preset tools: save-as-preset, copy settings JSON to
clipboard, and paste-JSON import (Zod-validated, inline error).
- **Main-menu settings modal**: a new "Graphics Preset" row using the
same component — replacing the colorblind toggle.
- **Built-in presets**: Default, Night, and Colorblind — defined as data
in `graphics-presets.json` (schema-parsed at load).

### Design notes

- **Theme palettes are an enum, not a boolean.**
`accessibility.colorblind` is replaced by `palette: "default" |
"colorblind"` — `PALETTE_NAMES` in `GraphicsOverrides.ts` is the single
source of truth (`ThemeName` derives from it; `ThemeProvider` builds one
theme per palette), so adding a future palette is one enum entry + one
theme JSON. Legacy stored booleans are translated to the enum at read
time.
- **The colorblind look is data.** The previously hardcoded Okabe-Ito
friend-foe colors are now generic override fields
(`affiliation.self/ally/enemyColor`,
`mapOverlay.friendly/embargoTintColor` + ratios) carried by the
Colorblind preset JSON; the palette selects only the theme. Selecting
Colorblind anywhere (menu or in-game) applies the full look. Side
benefit: any preset can customize friend/foe border colors.
- **Existing players are protected**: a one-time migration snapshots
pre-existing custom overrides into a "My settings" preset on first init,
so switching presets can never lose a hand-tuned config. Flag-only
colorblind users are upgraded to the full Colorblind preset so they keep
the blue/orange borders. Writing the presets key (even as `{}`) stamps
the migration done.
- Applying a preset reuses the existing `setGraphicsOverrides` →
change-event → live-regenerate path, so presets and imports take effect
immediately (including terrain-texture rebuilds for color changes).

## Test plan

- [x] Full suite: 176 files / 2100 tests pass; `tsc`, ESLint, Prettier
clean
- [x] `GraphicsPresets.test.ts` locks preset JSON to the renderer values
the old hardcoded colorblind block produced, schema-validates every
built-in preset, and covers legacy boolean → palette-enum translation
- [x] Driven headless in a real singleplayer game (WebGL): dropdown
present in both the main-menu settings and the in-game modal; selection
in one reflects in the other; presets apply + persist; "Custom" appears
after a manual slider tweak; save-as-preset selects the new entry;
delete via ✕ + confirm; copy JSON → clipboard; invalid-JSON inline
error; valid import applies live (ocean recolor visible in render)
- [x] Migration: seeded legacy overrides survive init untouched and
appear as an active "My settings" preset; legacy colorblind boolean
upgrades to the full preset; fresh profiles get no phantom preset;
migration is idempotent

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 16:11:43 -07:00
AotumuriGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
6734f76488 mls (v5.8) (#4656)
## Description:

Version identifier within MLS: v5.8

[Changed languages]
- bg
- cs
- da
- fa
- fr
- hu
- ja
- ru
- uk

[Change volume]
- Changed languages: 9
- Changed files: 9
- Changed lines: 23690
- metadata.json: unchanged

Final reviewer: meow

This PR was generated by the PR sender tool, then checked and submitted
by the final reviewer.

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-21 15:58:04 -07:00
Raka HouriantoandGitHub 6dd9eb8323 Reuse cached TradeShip paths for motion plans (#4654)
**Approved and assigned issue:** #4643

Resolves #4643

## Description

`TradeShipExecution` currently calls `WaterPathFinder.next()` and then
performs a second one-shot `findPath()` for the same destination when it
records a motion plan. `next()` has already calculated and cached the
traversal route, so the second call repeats the expensive water-path
calculation solely to recreate the route for the client.

This change lets `PathFinderStepper` return a copy of the active path
after `next()` advances it. Numeric paths remain `Uint32Array`s,
matching the compact representation already used by the stepper and
supported by motion-plan packing.

`WaterPathFinder.pathForTraversal()` retains the existing second
water-graph freshness check. If the graph refresh replaces the stepper
between `next()` and motion-plan recording, it falls back to the
original one-shot query. Otherwise it returns the cached remainder. The
method also normalizes the result to begin at the tile returned by
`next()`, so `TradeShipExecution` does not need to mutate the path.

`findPath()` remains a stateless one-shot query. The change is limited
to TradeShip and the minimal pathfinder support; it includes no
TransportShip changes.

## Performance

Current upstream `main` (`da2e0918`) was compared with the same commit
plus only this change. Each variant was run once per replay on macOS
arm64 with CPU profiling disabled. The replay intents were identical
between variants, and the current-client final hash and hash tick
matched for every pair.

| Game ID | Workload | Whole replay | TradeShip execution | Throughput |
| --- | --- | ---: | ---: | ---: |
| `DWmULj4H` | Antarctica, TradeShip-heavy | 68.960 s → 60.271 s
(**-12.60%**) | 18.981 s → 11.844 s (**-37.60%**) | 446 → 510 ticks/s |
| `efA7EZv9` | Australia | 14.660 s → 14.346 s (**-2.14%**) | 1.216 s →
0.920 s (**-24.34%**) | 441 → 450 ticks/s |
| `waxvMCue` | Svalmel | 16.666 s → 16.537 s (**-0.77%**) | 1.169 s →
0.931 s (**-20.36%**) | 551 → 555 ticks/s |

Matched current-client final hashes:

- `DWmULj4H`: `49212092378184090` at tick 31,060
- `efA7EZv9`: `82334231069422620` at tick 6,760
- `waxvMCue`: `34697926985435590` at tick 9,480

## Validation

- `npm run build-prod`
- Full Node 24 coverage suite: 174 test files and 2,091 tests passed
- Full ESLint check
- Full Prettier check
- Current-main baseline/patched replay hashes matched for all three
benchmark games

## Checklist

- [x] No UI updates; screenshots are not applicable.
- [x] No user-facing text was added or changed.
- [x] Relevant tests were added.

## LLM disclosure

This change, PR description, and benchmark analysis were authored by
**GPT-5.6 Sol**, an LLM, under human direction.
2026-07-21 15:57:19 -07:00
RyanandGitHub 5ae2040d65 add game history to a profile (#4662)
## Description:

adds game tab to player profile:

<img width="1235" height="928" alt="image"
src="https://github.com/user-attachments/assets/660fba48-18d0-4538-a9d5-f33abc0e693d"
/>

also make ui a bit more uniform, both account + profile modal match, and
changed the filtering options to be the same as the games tab

<img width="972" height="581" alt="image"
src="https://github.com/user-attachments/assets/6f10f78e-e32b-42a6-8df4-7aa6f0cf5146"
/>
<img width="1006" height="623" alt="image"
src="https://github.com/user-attachments/assets/caffb742-1d64-4d21-aa13-d3a7191088ca"
/>

## 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-07-21 13:56:28 -07:00
RyanandGitHub 3c5c96c141 profilelink (#4661)
## Description:

<img width="930" height="755" alt="image"
src="https://github.com/user-attachments/assets/f606bdce-fbeb-4fc3-98fb-ba6622634350"
/>

<img width="875" height="368" alt="image"
src="https://github.com/user-attachments/assets/81c8243d-5dc0-4687-bed4-cadb6bafb808"
/>

## 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-07-21 12:13:11 -07:00
49c358d304 feat: cancel ranked 2v2 matches that don't fill or fully spawn (#4655)
## Problem

A ranked 2v2 that starts with a missing player, or where a matched
player idles through the spawn phase, plays out as a lopsided 2v1 — and
records a ranked result (ELO) for a match that was never fair.

## Solution

Void the match from inside the sim: `WinCheckExecution` runs a one-time
check on the first check tick after the spawn phase ends. For
`rankedType === "2v2"`, if fewer than `maxPlayers` humans have spawned,
the game ends immediately with **no winner**. A player who never joined
isn't in the game start info at all, so the single spawn-count check
covers both the missing-at-start and never-spawned cases.

- `setWinner` now accepts `null` and emits a `WinUpdate` with `winner:
undefined` — the wire schema (`WinnerSchema`) already allowed an absent
winner.
- `WinModal`'s previously-empty `winner === undefined` branch shows
"Match cancelled — not all players spawned" and still votes the
winnerless result to the server, so the winner-vote consensus is reached
and the record archives promptly.
- The archived record simply has no `winner`; the API voids winnerless
ranked records (same shape any ranked game already produces when the
winner vote never reaches consensus).
- Only server change: winner votes are keyed with `JSON.stringify(winner
?? null)`, since `JSON.stringify(undefined)` is not a string and would
break the `VoteRound` key contract.

The check counts via `allPlayers()` rather than `players()`, since the
latter filters out tile-less players — which is exactly what a
never-spawned player is.

Ranked 1v1 is deliberately untouched (the existing last-human-connected
walkover still applies).

## Tests

`tests/Ranked2v2Cancel.test.ts`:
- 3 of 4 players spawn → game ends with a winnerless `WinUpdate`
- a 4th player never joined (absent from start info) → same
- all 4 spawn → no cancellation
- unranked team game short a player → no cancellation

Full suite (2094 tests), tsc, ESLint, and Prettier all pass.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:18:26 -07:00
fabd1bd2d1 fix(core): reject spawn intents after the spawn phase (anti-teleport) (#4657)
## Problem
Patches sending the `spawn` intent over the game WebSocket while a match
is underway:

```js
ws.send(JSON.stringify({ type: "intent", intent: { type: "spawn", tile: tileRef } }));
```

The server relays it like any intent, and `SpawnExecution.tick()` then
**relinquishes the player's entire territory and re-conquers it at the
target tile** — instant teleport anywhere on the map.

The root cause is that `SpawnExecution.tick()` had no spawn-phase gate
(only a random-spawn re-roll guard), and the engine ticks the execution
**both during and after** the spawn phase (`activeDuringSpawnPhase()` is
`true`, and after the phase `!inSpawnPhase()` is also `true`).

## Fix

Ignore a spawn intent when the game is no longer in the spawn phase
**and** the player has already spawned:

```js
if (!this.mg.inSpawnPhase() && player.hasSpawned()) {
  return;
}
```

Because `inSpawnPhase()` / `hasSpawned()` are deterministic game state,
the intent becomes a no-op identically on every client — no desync.

Unaffected:
- First-time spawns (`hasSpawned()` is false)
- In-phase spawn picks and re-picks (`inSpawnPhase()` is true)
- Existing random-spawn re-roll protection (kept as-is)

## Tests

- Added `Spawn intent after the spawn phase cannot relocate territory
(anti-teleport)` — establishes a player, then asserts a later `spawn`
intent leaves `spawnTile()` and `numTilesOwned()` unchanged.
- Simplified `Spawn on specific tile` to a single first spawn (the
previous version relied on an out-of-phase re-pick, which this fix now
correctly forbids).
- Full `tests/core` suite green (259 tests).

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:22:41 -07:00
unne27GitHubunne27 <>
da2e0918a4 feat/highlight the owner of hovered naval units (#4610)
Resolves #4445

## Description:

Changes HoverHighlightController.ts to check for naval units if the
mouse is not currently hovering over land. It then highlights the owner
of the closest naval unit within 50px of the mouse.

The activation radius feels a little to large right now, but it is
consistent with the PlayerInfoOverlay functionality. Some input would be
appreciated

It is also currently disabled by default, not sure if this should be the
case.

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

unne27


https://github.com/user-attachments/assets/7b8262e5-a097-44c8-b2ae-1ac336466623

<img width="534" height="901" alt="image"
src="https://github.com/user-attachments/assets/50e9e126-08a6-4a24-9c26-cd4099a2603e"
/>

---------

Co-authored-by: unne27 <>
2026-07-20 14:21:50 -07:00
RickD004andGitHub 43545196b1 Adds Baltics map (#4614)
## Description:

Adds Baltics map. Basic map for v33 just to have something simple since
most maps have special shapes due to impassable terrain.

One mainland with detached "islands" to have multiple island players for
interesting gameplay.

Regional European maps are also always popular.

36 nations with additional nations for a total of over +60

<img width="547" height="526" alt="image"
src="https://github.com/user-attachments/assets/3a911a17-8fbe-446d-ab0b-77bd9c34885a"
/>

## 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-07-20 14:20:23 -07:00
826d8b1bcf feat(client): account usernames + verified badge on player-facing lists (#4653)
## Summary

Client side of openfrontio/infra#436 — every player-facing GET now
returns the player's **account username** next to their `publicId`, and
this PR renders it everywhere, with a blue verified check for
premium/indefinite bare-name holders.

### Schemas (all additive, `.nullable().optional()` — no deploy-order
constraint)
- `FriendEntry`, `PlayerProfile`: `username`
- `ClanMember`, `ClanJoinRequest`: `username`; `ClanBan`: `username` +
`bannedByUsername`
- `RankedLeaderboardEntry`: `accountUsername` (the existing session
`username` field is untouched)

### `<player-name>` element
One component owns player-identity rendering on account surfaces:
- displays `username ?? publicId`
- click-to-copy copies the **username when set**, the publicId
otherwise; `copyText` overrides (profile modal copies the share URL)
- `onNameClick` replaces copying with an action (clan member rows +
leaderboard rows open the player's profile); `nameClass` overrides the
chip styling so leaderboard names keep their original bold look
- appends the verified check (same mark as the username-row toggle) when
`isVerifiedUsername(username)`

Used by: friends list + requests, all clan member/request/ban rows (bans
badge both the banned player and the issuing officer), ranked
leaderboard rows + sticky self row (showing `accountUsername ??
public_id` — the session name is deliberately dropped ahead of its API
removal), profile modal header.

### Verified inference
No API flag needed: account-username bases can never contain dots, so a
dotless display name **is** the bare-name claim (premium/indefinite).
`TEMPORARY####` server renames are excluded, matching the play-toggle
eligibility rule. The leaderboard derives the badge from
`accountUsername` only — session names are free-form and would
false-positive.

Also: clan member/request/ban search filters now match usernames, not
just publicIds, and sending a friend request refetches the request lists
instead of inserting the raw input (which may be a username, not a
publicId).

## Test plan
- [x] `npm test` — full suite green (2087 + 176 server)
- [x] New schema tests: each field's set / null / absent states;
`accountUsername` coexists with session `username`
- [x] `isVerifiedUsername` unit tests (bare / suffixed / null /
TEMPORARY)
- [x] tsc, ESLint, Prettier clean

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:15:10 -07:00
ad8d7e995a feat(client): verified-name toggle (play under your account name) (#4648)
## Summary

The client half of the verified-name plan: subscribers with a claimed
bare name can opt in to play under it, and the game renders a
**server-validated** blue verified check next to their name.

### Verified toggle (username row)
- Blue check-circle badge + "Verified" label act as a toggle button in
the play username row, shown to all users (hidden on CrazyGames via
`no-crazygames`); both turn blue when active and the input locks to the
bare account name — `getUsername()` feeds every join path.
- Non-subscribers (logged out, `unclaimed`, lapsed `claimed`) get a
subscribe-first dialog whose **View store** routes to
`#modal=store&tab=subscriptions`. Entitled players without a usable name
(never set, or `TEMPORARY####`) are routed to the account modal instead.
- The opt-in persists in localStorage but never auto-enables while
ineligible; unchecking restores the saved free-form name. Anonymity
stays a first-class option.
- After a successful username save the page reloads so every consumer
restarts from a fresh `/users/@me`.

### In-game badge (GL name pass)
- New `verified` boolean on `PlayerCosmeticRefsSchema` (client claim)
and `PlayerCosmeticsSchema` (resolved). `getPlayerCosmeticsRefs()` sets
it from the toggle state, covering both the multiplayer join and
locally-resolved singleplayer paths.
- **Server-validated at join, today**: the Worker already fetches
`/users/@me` with the client's token on every authenticated join
(flares/friends/clans), and that response carries the account username
since #4644 — so `verifiedBadgeAllowed` keeps the claim only when the
bare-name status is `premium`/`indefinite` AND the join name exactly
matches the account's resolved display name. Zero extra requests, no
token-claim staleness. Mismatches strip the badge rather than rejecting
the join; a pre-start rejoin identity change also drops it (that path
skips join-time validation). Anonymous persistent-ID joins exist only in
Dev and keep the claim for local testing.
- Rendering: 10th instanced slot in the name pass's `StatusIconProgram`,
anchored just right of the name text (`nameHalfWidth` was already in the
player data texture), slightly below the name line's center. The badge
art is a new cell (index 11) in `status-atlas.png`; the flag rides the
free `pd8.y` column. Anonymized viewers never see it (cosmetics are
already stripped for hidden players).

## Test plan

- Full suite passes (2,047 + 173), including new tests: cosmetics schema
`verified` (optional/boolean-only), `Privilege.isAllowed` pass-through,
and `verifiedBadgeAllowed` (exact match, case rejection, unentitled
statuses, missing name).
- Headless real-app verification of every toggle state (dialogs,
persistence, silent drops, store-tab routing, save→reload) with stubbed
API routes.
- Drove a real singleplayer game headless (WebGL via ANGLE Metal): the
blue check renders to the right of "Bob", scaled and tucked to the name;
bot/nation names show no badge. Screenshot-verified.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:28:21 -07:00
FloPinguinandGitHub d9976babbd Add tribe name themes system with custom tribes support 🏰 (#4647)
## Description:

Adds a theme system for bot tribe names that maps can customize via
their info.json. Instead of all bots using the same global name pool,
maps can now specify one or more themed name sets and define custom
tribe names with higher priority.

Key changes:
- New `src/core/execution/utils/tribeNameThemes.json` containing 17
themed name sets (default, north_america, south_america, europe, africa,
asia, oceania, space, fantasy, war, western, under_ocean, tournament,
funny, scary, weird, vs). The default theme preserves the original
`TribeNames.ts` values. **Mappers are encouraged to change that json
file, the new themes just serve as an example and are currently not used
by any map.**
- Maps can specify `"themes": ["europe", "war"]` in info.json to merge
prefixes/suffixes from multiple themes into one name pool.
- Maps can specify `"custom_tribes": ["Holy Roman Empire", "Kalmar
Union"]` for exact tribe names that take priority over theme-generated
names. Custom tribes are used first (random selection, no duplicates
until exhausted), then theme-based prefix+suffix combos.
- `TribeNameResolver` resolves themes per-map at runtime, with fallback
to the default theme and a console warning for unknown theme names.
- Go codegen (`codegen.go`) updated to propagate `themes` and
`custom_tribes` from info.json to Maps.gen.ts.
- Germany map now has 404 custom tribes (all German Landkreise and
kreisfreie Städte) to showcase the new feature.
- Fixed a bug where tribe bots could inherit nation flags if their
random name coincidentally matched a nation name (name-based cosmetics
lookup now only applies to actual Nations, not Bots).

## 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-07-19 18:46:51 -07:00
9c14ab04e0 feat(client): account-level custom usernames (base.suffix + premium bare names) (#4644)
## Summary

Client integration for account-level usernames (backend:
openfrontio/infra#434). Players get one canonical account name stored as
a base plus a server-assigned 4-digit suffix (`bob.4821`); subscribers
display the bare base (`bob`) with an exclusive case-insensitive claim
on it.

- **Schemas** (`src/core/ApiSchemas.ts`): new `player` username fields
on `GET /users/@me`, `UsernameStatusSchema`,
`PutUsernameResponseSchema`, and an `isTemporaryUsername()` helper. The
display name is rendered exactly as the server resolves it — the client
never assembles `base.suffix`. Discriminators stay strings (leading
zeros).
- **API** (`src/client/Api.ts`): `updateUsername()` for `PUT
/users/@me/username`, returning a discriminated result covering every
documented failure: `invalid` (400), `profane` (400 +
`USERNAME_PROFANE`), `taken` (both 409 bodies), `cooldown` (429 +
`Retry-After`), `failed`.
- **UI** (`src/client/components/UsernamePanel.ts`, in the Account tab):
set/change form prefilled with the base, live 3–20 / `[a-zA-Z0-9_-]`
validation (`validateAccountUsername`), form locked with the date while
the 30-day cooldown runs, grace-period warning for lapsed claim holders,
and a free-rename notice after a server-side `TEMPORARY####` rename. The
confirm dialog composes warnings by state: 30-day lock always,
case-only-change notice, and abandon-reservation warning for `claimed`
players. Suffixes are never mentioned in user-facing copy — subscribers
should feel like they own the bare name outright.
- **Load prompt** (`src/client/Main.ts`): on a clean homepage load, a
subscriber renamed to `TEMPORARY####` is prompted to pick a new name
(free); takes priority over the rewards popup (the account modal shows
rewards anyway).

## Deploy notes

**No deploy-order constraint.** All new `/users/@me` fields are optional
in the schema: against the current API the response still parses and the
panel simply doesn't render (`usernameStatus === undefined`). The client
can ship before or after infra#434.

## Test plan

- New schema tests (old-API absence, leading-zero discriminators,
unknown statuses, PUT payload) and validation tests (dots, spaces,
unicode, trim) — full suite passes (2,031 + 171).
- Drove the real app headless with synthetic `/users/@me` payloads:
verified all panel states (unclaimed, never-set, claimed+grace,
TEMPORARY, cooldown-locked, old API hidden), the confirm-dialog
warnings, and the inline error path on a failed PUT.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:05:31 -07:00
AotumuriandGitHub d6b4b0e00c Remove Discord URL placeholder from translations (#4645)
## Description:

This value was a sample URL used as an input placeholder, and it was
removed because translating it would be meaningless and could
potentially alter the URL format incorrectly.

## Please complete the following:

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

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

aotumuri
2026-07-19 08:03:56 +00:00
c1dfa9641b Add map More than luck (#4584)
Resolves #4563 

## Description:
- 1344x1348 and ~916000 land tiles
- 35 default (5x7), 400 total
- 5 sided map. Hard to nuke neighboring leafs due to angle. Organic
design.
https://youtu.be/z4xlCXfOW7k
https://discord.com/channels/1284581928254701718/1519889161220522054

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

---------

Co-authored-by: Aaron Tidwell <Aaron.Tidwell@gmail.com>
2026-07-18 16:16:00 -07:00
a5480ec7f7 feat(client): shareable player profile modal (#modal=profile&publicID=x) (#4639)
## Description:

Add player profile (stats only for now):
<img width="952" height="241" alt="image"
src="https://github.com/user-attachments/assets/2cc0c1ed-baf6-4cb5-837c-1f82d2850005"
/>

and clans modal:
<img width="927" height="778" alt="image"
src="https://github.com/user-attachments/assets/87e7ae53-6531-41cd-8795-4ea1d8fb67af"
/>

with dedicated url:
<img width="1273" height="916" alt="image"
src="https://github.com/user-attachments/assets/dd1ccff7-5d3c-4c70-a7d2-0c7399a88a08"
/>



## 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 Fable 5 <noreply@anthropic.com>
2026-07-18 16:04:09 -07:00
Patrick Plays BadlyandGitHub ea464d44bc Update map dyslexdria (#4642)
Resolves #4621 

## Description
- Change from 3083x1344 > 3308x1442 (actually smaller, but there's a
black frame for the tilt)
- Tilt map by using impassable.
- Increase map size by ~11% due to performance gains. 
- Adjust map colors/land tiles to show more detail.
- Please do not report updates; per the theme of the map.

https://discord.com/channels/1359946986937258015/1450487807242932336/1527108909343572040
https://youtu.be/wJYVfCoDYmY
---

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

PatrickPlaysBadly
2026-07-18 16:02:55 -07:00
7e073dda0b fix(server): keep matchmaking games out of lobby reports; salvage invalid entries (#4641)
## Problem

Prod masters log `Invalid IPC message from worker` for every ranked
1v1/2v2 match created:

1. The matchmaking poll loop creates the match game with the 1v1/2v2
playlist config (`gameType: Public`) but no `publicGameType` —
correctly, since ranked games are invite-only and should never be
advertised.
2. While that game sits in its ~7s lobby window,
`GameManager.publicLobbies()` includes it (it only checks phase +
`isPublic()`), and `WorkerLobbyService.sendMyLobbiesToMaster()` reports
it with `publicGameType: gi.publicGameType!` — the non-null assertion
hides the `undefined`, and IPC serialization drops the key.
3. The master's `WorkerMessageSchema` parse fails on the missing
required field and **discards the worker's entire lobby report**.

So for the lobby window of every ranked match, the master's view of that
worker freezes: stale player counts and countdowns broadcast to all
clients, `maybeScheduleLobby` repeatedly resetting the next lobby's
countdown, and occasional duplicate scheduled lobbies (with no cleanup
path for scheduled types). With matchmaking running continuously, some
worker is blocked at any given moment on a busy server.

## Fix

- **Worker** (`WorkerLobbyService`): filter games without a
`publicGameType` out of the report. Matchmaking games are never
advertised — which also guarantees they can't leak into the public lobby
browser.
- **Master** (`MasterLobbyService` + `IPCBridgeSchema`): validate lobby
entries individually instead of failing the whole message. Malformed
entries are logged and dropped; valid lobbies survive. A future bug of
this class now degrades to one missing entry instead of a frozen worker
view. The send side keeps compile-time safety via the existing per-entry
`satisfies` annotations.

## Testing

- New test: a matchmaking-style game (Public, `allowedPublicIds`, no
`publicGameType`) is excluded from the worker's `lobbyList` report while
a normal FFA lobby still goes through.
- New test: a report containing a malformed entry keeps its valid
lobbies in the next broadcast instead of being rejected wholesale.
- Full `tests/server` suite passes (173 tests); `tsc --noEmit` and
eslint clean.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:08:59 -07:00
evanpelleandClaude Fable 5 eee7d793f8 chore(glow): raise default small-player glow strength to 35%
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:56:08 -07:00
4544e18ce6 refactor(glow): move small-player glow strength into graphics overrides (#4634)
## Summary

Moves the small-player glow strength from a standalone UserSettings key
(`settings.highlightGlowStrength`) into the graphics overrides blob
(`smallPlayerGlow.strength`), and moves its slider from the basic
settings modal into the **Effects** section of the graphics settings
modal.

The pass now reads strength from its live `RenderSettings` slice like
every other graphics knob, flowing through `applyGraphicsOverrides`.
This removes the dedicated push chain (ClientGameRunner listener →
MapRenderer → Renderer → pass setter) and MapRenderer's context-restore
reapply — strength survives WebGL context restores automatically now,
participates in the graphics modal's "Reset to defaults", and shows up
in the render debug GUI.

## Bug fix

The old slider had no visible effect below ~115%: strength only drove
the blur-iteration count (`round(strength ** 2.74)`, floored at 1 pass),
and the composite intensity (`alpha * pulse * sqrt(passes)`) never
factored strength directly — so 10% rendered identical to 100%.

Strength is now a linear opacity fade on a fixed-width aura:

- 100% = the aura's full brightness, 10% = barely visible, 0% = off
- Slider capped at 100% (the >100% aura-widening mechanism became
unreachable and is removed: `passesFor`, `lastPasses`, and the
blur-iteration loop)
- Default is 25%
- The pass clamps persisted values above 1 so stale settings from the
old 500% range can't over-brighten

## Notes

- Reuses the existing `user_setting.highlight_glow_strength_label` /
`highlight_small_players_desc` translation keys — no en.json change
- The legacy `settings.highlightGlowStrength` localStorage key is not
migrated (the slider only shipped in #4588 a few days ago); users reset
to the 25% default

## Test plan

- [x] `tests/GraphicsOverrides.test.ts` — schema validation + apply
coverage for the new override group
- [x] Full suite (171 tests), tsc, eslint all pass

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:09:07 -07:00
1f97f2b252 fix(render): standardize gradient colorSize to game tiles (#4633)
## Summary

Effect cosmetic attributes are meant to be standardized on **game
tiles** for spatial values and **per-second** for rates. An audit of
every effect attribute against its renderer consumer found one
violation: the gradient trail's `colorSize`.

- The trail shader computed the palette cycle as `colorSize * 4.0 *
count`, so `colorSize: 1` produced ~4-tile bands — while
`CosmeticSchemas.ts` already documented `colorSize` as "band width in
tiles".
- This PR removes the hidden ×4 in `trail.frag.glsl` and the mirrored
factor in the structures gradient pace in `structure.frag.glsl`, so
`colorSize` is now exactly the band width in tiles, and updates the
pace-formula doc comment in `CosmeticSchemas.ts`.

All other attributes were verified already standard: gradient
`movementSpeed` (tiles/s), transition `frequency` (colors/s), spiral
`radius` (tiles) / `rotationSpeed` (rad/s), explosion `size`/`thickness`
(tiles), `speed` (tiles/s), `transitionSpeed` (colors/s). Every animated
path runs off a `uTime` uniform that is genuinely in seconds.

## Catalog impact

- Production `cosmetics.json` ships **zero** effects today, so there is
no live migration.
- Dev catalog gradient entries need `colorSize` ×4 to preserve their
current look (`rainbow_trail` 0.2→0.8, `red_black_trail` 1→4,
`tiel_red_gradient_nuke_trail` 0.5→2, `rwb_structure_gradient` 5→20).

## Test plan

- `npx vitest tests/CosmeticSchemas.test.ts --run` — 51 passed (schema
shape unchanged; this is a shader-semantics fix).

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:27:58 -07:00
evanpelle cd75d54c5f ci: remove PR description validation check
Drop the validate-description job from the PR workflow; the
has-milestone check remains.
2026-07-17 17:56:53 -07:00
828d7b1d96 feat(client): spiral nuke trail cosmetic effect (#4630)
## Summary

Adds a **spiral** `nukeTrail` effect type — a 3D vortex of glowing helix
strands projected onto the map, trailing behind nukes whose owner has
the cosmetic equipped.

Catalog attributes (`spiral_tail` shape):

| attribute | meaning |
| --- | --- |
| `colors` | palette, wrapped once around the vortex circumference |
| `radius` | helix amplitude in tiles |
| `strands` | helix strand count (renderer clamps to 8) |
| `rotationSpeed` | vortex spin, radians/sec |

## How it works

The per-tile trail texture holds persistent state; the vortex is a
transient animation that follows a path — so it renders as **ribbon
geometry**, not tile stamps.

**Path recording** (`SpiralTrails`): each live spiral-owner nuke gets an
append-only centerline polyline — ~2 samples per tile of travel carrying
position, a smoothed perpendicular (blended across tick segments so
curved paths don't kink), and cumulative distance. Params are pushed
once per player by `WebGLFrameBuilder` when the cosmetics catalog
resolves; a ribbon is dropped the moment its unit disappears, matching
stamped-trail cleanup.

**Rendering** (`SpiralRibbonPass`): one triangle-strip VBO per nuke (2
verts per sample, streamed append-only via `bufferSubData`, grown by
doubling); the vertex shader swings each sample sideways by the helix
offset and evaluates the head-cone convergence as a function of
`uHeadDist − d`, so uploaded vertices are immutable — the cone feeding
the strands into the missile needs no rewriting as the nuke flies. One
draw per strand reuses the same strip with a different phase offset
(`uPhase0`).

The glow look is a bloom-style split:

- **halo** — wide quadratic falloff, rendered premultiplied into a
quarter-resolution buffer (`mapOverlay.spiralResolutionScale = 0.25`,
~16× cheaper fragments) and composited **additively** over the scene, so
it reads as emitted light and the bilinear upsample keeps it soft;
- **core** — sharp full-resolution ribbons on top, with a white-hot
center on segments facing the viewer (neon-tube look).

Shading spins the helix angle with time: a `cos` depth cue brightens
facing segments and darkens receding ones, and the palette cross-fades
around the circumference.

The spiral nuke still stamps its plain centerline through the unchanged
`TrailManager` — `trail.frag` styleId 2 draws it flat in the first color
as the missile's spine, so alt view, death cleanup, and trail overlap
behave identically to non-cosmetic nukes. Ribbons draw above the plain
trails, below the missiles, and are skipped in alt view.

**Perf**: both ribbon stages are skipped entirely (CPU-side, before any
GL work) while no spiral nuke is in flight — games without the cosmetic,
and frames without a spiral nuke, pay nothing new. Vertex uploads stream
only newly appended samples; the halo's fragment cost is capped by the
quarter-res buffer. MIRV warheads are explicitly excluded from ribbons
(one MIRV splits into 350 of them).

**Store preview**: `TrailSwatch` mirrors the bloom split in SVG — a
screen-blended blurred halo, a crisp colored core, and a white-hot
center line — with a phase-offset per-strand fade matching the in-game
depth-shaded spin.

Note: requires the `spiral_tail` catalog entry on the API side to be
purchasable/selectable; without it nothing changes visually and the
schema tolerates its absence.

## Testing

- New `tests/SpiralTrails.test.ts`: ribbon gating by owner/unit type,
sample spacing + monotonic distances, strand clamp + pitch-derived
twist, death cleanup mutating the live array, params staying fixed for
in-flight ribbons
- New `tests/TrailManager.test.ts`: baseline stamping behavior (plain
boat trails, nuke-bit stamping up to lastPos, death cleanup with overlap
repaint)
- `tests/CosmeticSchemas.test.ts`: spiral attribute parsing incl. the
exact catalog shape, required-field/positivity rejections
- Full suite green; `tsc` and lint clean
- Verified visually: a standalone WebGL harness drove the real ribbon
shaders (glow split, palette colors, spin, cone convergence), and a real
solo game boots with zero GL errors

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:31 -07:00
RyanandGitHub 774d98ddad bugfix: trim archived map names (#4632)
## Description:

fixed a bug where "Deglaciated Antarctica" had a trailing space in an
earlier version of the game and caused visual issues:
<img width="1045" height="323" alt="image"
src="https://github.com/user-attachments/assets/11628e19-523e-43f9-b3dc-15a5ee1bcd8a"
/>
and prevented stats from loading:
<img width="1070" height="529" alt="image"
src="https://github.com/user-attachments/assets/590a7c2b-7dc5-4d8d-816f-469731d42fd4"
/>
 

now works:
<img width="969" height="387" alt="image"
src="https://github.com/user-attachments/assets/35d4ac89-5f97-4534-adbc-d0ce824ea4d8"
/>
<img width="1013" height="787" alt="image"
src="https://github.com/user-attachments/assets/44d6c20a-caf6-417a-b7fe-08f0765e1b16"
/>


## 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-07-17 15:09:39 -07:00
RyanandGitHub c269354c74 stats modal: change to the UI (#4629)
## Description:

update to the stats modal to make it look better...

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:

w.o.n
2026-07-17 21:05:05 +00:00
RyanandGitHub c4447cd434 stats button on clan games history (#4628)
## Description:

adds stats button to clan games tab 

<img width="1011" height="660" alt="image"
src="https://github.com/user-attachments/assets/fe39f000-6da9-4f1b-9a20-62e8177e6fdb"
/>


## 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-07-17 10:20:32 -07:00
RyanandGitHub f070d4843d make it a dedicated stats modal, not under account (#4627)
## Description:

seperates the stats modal into a dedicated path, with gameID support
e.g. :

#modal=stats&gameID=VvyXpL9x

## 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-07-17 09:18:32 -07:00
RyanandGitHub 0fc80e931e convert stats modal to embed instead of popup (#4626)
## Description:

convert stats modal to embed instead of popup

before:
<img width="1193" height="939" alt="image"
src="https://github.com/user-attachments/assets/4f96843a-3b96-4874-8292-e670181bd87c"
/>

after:
<img width="1226" height="863" alt="image"
src="https://github.com/user-attachments/assets/801273cc-77cd-41b6-bc77-4fdc65afef71"
/>


also has context to remember where we were in the scrolled list, so
pressing "back" keeps the position

## 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-07-17 08:49:44 -07:00
Zixer1andGitHub 95840a2074 Doomsday Clock: judge teams against the same bar as solo sides (#4624)
**Add approved & assigned issue number here:**

Resolves #(to be filed — happy to open the matching issue; flagging
directly since this is live in prod team lobbies)

## Description:

**Bug.** In Team mode the Doomsday Clock threshold is scaled by the
team's *alive* headcount (`base × members.length`, capped at the whole
map). In real team lobbies this breaks the mechanic:

- With ~16 players per team, wave 1 already demands `4 % × 16 = 64 %+`
of the map per team while all humans combined hold ~35 % (bots own the
rest). Every non-leading team is skulled ~1 minute into wave 1 and
drained to the 5 % floor ~90 s later — even on **slow**, whose squeeze
is designed to run to 45:00. The game is decided by whoever leads at
grace-end.
- The bar/forecast **drops when a teammate dies** (players see "will
rise to 68 %" → "44 %" mid-game), and disconnected-but-alive teammates
silently inflate it.
- Past the 100 % cap, the per-capita scaling stops meaning anything
anyway.

**Evidence.** Prod game `JEUieLzK` (`Team, playerTeams: 4, 69 players,
doomsdayClock slow, bots: 400, no timer`): ended **16:47** via the 95 %
team-domination check. From the archived record: 25 deaths before 10:00,
only 8 after — the drain cripples rather than kills, so the collapse is
invisible in death stats but decisive in outcome. Reconstruction with
the real schedule + drain constants: every non-leading team skulled
~11:00–11:30 and pinned at the floor by ~12:30; the crown-exempt leader
rolled to 95 % by 16:47.

**Why flat is correct.** The wave levels encode a viable-**side** count
— `floor(100 / wave%)` sides fit above the bar (25 → 11 → 6 → 3 → 2 →
1). Elimination and victory happen at side granularity (a team is out
when the whole team is out; the winner is a team), so the slots must be
counted in sides. A team now faces exactly the bar a solo player faces,
judged on its combined territory — and each speed preset means the same
thing in teams as it does in FFA.

**Changes:**

- `DoomsdayClock.ts`: remove `doomsdayClockSideRequiredTiles`; the sim
and the HUD both use `doomsdayClockRequiredTiles` (hoisted out of the
per-side loop — it no longer varies per side).
- `DoomsdayClockExecution.ts`: flat per-side bar; comments updated.
- `DoomsdayClockPanel.ts`: drop `scalePct`/headcount from the readout —
the zone forecast becomes one universal, monotonic number for every
player (fixes the 68 % → 44 % whipsaw). `sideStats` → `sideTiles`.
- Tests: headcount-scaling expectations replaced with flat-bar +
death-invariance regression tests.

(History note: an earlier commit restricted team lobbies to normal/fast
presets; it was reverted in-branch — with the flat bar the presets carry
the same meaning as in FFA, so the full rotation stays. Squash-merge
leaves the fix only.)

FFA behaviour is unchanged throughout (sides of size 1: flat ≡ current).
Deterministic integer math untouched (strictly fewer ops). `npm test`
fully green.

Rule comparison on `JEUieLzK`'s shape (real death ticks + constants;
modeled share trajectories):

| rule | trailing teams skulled | all non-leaders at 5 % floor |
|---|---|---|
| current (× alive) | 11:01–11:27 | **12:34** ← matches the real 16:47
blowout |
| flat per-side bar | 26:01–30:42 | 31:49 (late-game backstop, as
designed) |

## Please complete the following:

- [ ] I have added screenshots for all UI updates — _no layout/element
changes; the existing readout shows unscaled values (numbers only)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — _no new strings; existing
`doomsday_clock.*` keys reused with the same params_
- [x] I have added relevant tests to the test directory

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

zixer
2026-07-17 08:46:47 -07:00
RyanandGitHub 943740de32 Game Stats - backward compatibility (#4623)
## Description:

fixes a bug where old games under the old schema don't open correctly

main.openfront.dev:
<img width="1043" height="405" alt="image"
src="https://github.com/user-attachments/assets/d94811b9-ecfe-4243-9afb-5b25df4366f8"
/>


this branch:
<img width="1034" height="942" alt="image"
src="https://github.com/user-attachments/assets/8dcf07e6-2934-4a85-85a7-3f51a493c504"
/>


## 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-07-16 15:22:20 -07:00
d59b5cdfd7 feat(client): gate in-game ads by adblock detection + Admiral recovery (#4534)
## What

Two related pieces, wired into the existing `window.adsEnabled` /
`userMeResponse` ad flow:

1. **`AdGatekeeper`** — decides whether the *intrusive* in-game ad may
show. Once a blocker is **ever** detected, the ad is suppressed
**permanently** (terminal state, persisted to
`localStorage["adblock-detected"]`). Ad-block users are highly
ad-sensitive, so disabling the blocker does **not** unlock the ad — in
this or any future session. Detection = a DOM bait probe, refined by
Admiral's `measure.detected` signal (`adblocking && !whitelisted`) when
it fires. Clean users are never latched.
2. **`Admiral.ts`** — injects the ad-recovery tag (command-queue stub +
payload + GAM targeting shim) for **ad-eligible users only**.
Paid/`adfree` users have `window.adsEnabled === false`, so Admiral never
loads and its adblock popup can't fire for them.

Only the in-game ad (`InGamePromo`) is gated — it now loads via
`adGatekeeper.whenClear(...)`. Passive homepage/gutter ads are
unchanged.

## Why

- Paid users (any shop purchase → `adfree` for life) must never see ads
*or* load Admiral.
- Free adblock users get Admiral's recovery popup, but should never be
hit with an intrusive in-game ad even if they disable their blocker.

## How it behaves

| Visitor | Admiral | In-game ad |
|---|---|---|
| Paid (`adfree`) | never loaded | never shown |
| Free, no adblock | loaded | shown |
| Free, adblock on (or ever was) | loaded (recovery popup) | suppressed
forever |
| Free, adblock blocks Admiral too | callback never fires | bait
fallback suppresses |

## Testing

- **Unit:** `tests/AdGatekeeper.test.ts` (9 cases) — terminal latch,
"disabling blocker doesn't unlock", cross-session persistence, seed
path, no-false-positive. `tsc` clean, `eslint` clean.
- **Manual (headless Chromium, real bootstrap):** free user →
`adsEnabled: true`, Admiral tag injected + payload initialized,
`persisted: null` (no false positive); simulated blocker → flag latches
to `"1"`; reload with no blocker → still `"1"` (forever); reset clean
afterward.

## Notes / follow-ups

- The GAM targeting shim (block 3 of the provider's tag) is ported
verbatim but is likely a no-op here since serving is via Playwire RAMP,
not Google Ad Manager. Kept for fidelity; can drop if unused.
- `ADMIRAL_PAYLOAD_SRC` is a disguised, rotating domain — re-sync from
the provider when they reissue the tag.
- Admiral's own popup is dashboard-configured and typically
domain-locked; best verified on the production domain with a real
blocker.
- `res.subscribed` (Admiral's own ad-free pass) is intentionally ignored
— OpenFront's ad-free is the server `adfree` flag.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:05:23 -07:00
b379502516 fix(store): specific rate-limit message on tier change + use shared dialogs for subscription flows (#4622)
## Summary

- The change-tier API allows one tier change per minute per player
(`playerRateLimit` on `/subscriptions/@me/change-tier`). The client
previously lumped the 429 in with every other failure and showed
"Couldn't update your subscription. Please try again." — retrying
immediately just fails again. A 429 now gets its own message: *"You just
changed tiers. Please wait a minute before changing again."*
- Replaced all native `alert()` / `window.confirm()` calls in the
subscription flows with the shared `showInGameAlert` /
`showInGameConfirm` helpers (styled `<confirm-dialog>`, consistent with
the rest of the store, and safe on CrazyGames):
- Tier change: already-subscribed notice, upgrade/downgrade confirmation
(warning variant with "Change Tier" heading),
rate-limited/failure/success notices
  - Dollar checkout failures (first-time subscribe path)
- Subscription panel: cancel confirmation (danger variant), cancel
failure/success, portal-open failure

The currency-purchase alerts in Cosmetics.ts (`login_required`,
`purchase_failed`, `purchase_success`) are untouched — subscriptions
can't reach that path.

## Test plan

- [x] Typecheck, ESLint, Prettier
- [ ] Manual: change tier twice within a minute against the real API and
confirm the rate-limit dialog appears

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:35:33 -07:00
blonandGitHub 46162c0e27 fix(client): fallback to full border recompute on massive tile changes (#4576)
### **Add approved & assigned issue number here:**

Resolves #3604
(related)
## Description:

Fixes severe client-side lag spikes (framerate dropping to single
digits) when multiple nuclear bombs detonate close to each other with
`"isWaterNukes": true` enabled.
* Water nukes convert land to water on detonation. For an Atom Bomb
(radius 30), this changes up to 2,800 tiles.
* The client splits these changes across 16 frames (`dripBuckets`),
processing ~150 tile changes per frame.
* For each tile change, `BorderComputePass.patchTile` computes updates.
If the victim's territory is highlighted, the pass expands the repaint
area of each tile change to a `highlightThicken` Chebyshev box of radius
4 (meaning `9x9 = 81` points).
* This forces the CPU to calculate and push up to `150 * 81 = 12,150`
points *per frame* to the border scatter buffer, uploading it, and
rendering it as thousands of overlapping GPU `POINTS` using the complex
border shader.
* Under consecutive detonations, this completely freezes the client
rendering thread, leading to disconnections.

* Optimizes `BorderComputePass.ts` to fallback to a full-screen quad
border recompute if the scatter point count exceeds a threshold of
`2,048` points.
* Bypasses the expensive CPU coordinate expansions and array resizing
for subsequent tile updates in the same frame.
* The GPU renders a simple fullscreen quad to rebuild all borders, which
is extremely parallelized and executes in **`<0.1ms`**, keeping gameplay
smooth.

## Please complete the following:

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

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

blontd6
2026-07-15 18:28:27 -07:00
Zixer1andGitHub 4621084500 feat(lobby): surface Doomsday Clock preset + anonymous names as lobby settings (#4616)
## Description:

Public rotation lobbies can run the **Doomsday Clock** at one of four
presets (slow/normal/fast/veryfast), and lobbies can **anonymize player
names**, but the lobby UI surfaced neither: the Doomsday Clock badge was
generic and there were no settings cards for either. Players couldn't
tell what a lobby was actually set to.

This surfaces both as regular lobby settings, matching how the other
modifiers already display:

- **Settings cards** (join/waiting view): adds a "Doomsday Clock" card
showing the active preset name, and an "Anonymous Players" card,
alongside the existing modifier cards.
- **Lobby badge** (home lobby list): the Doomsday Clock badge now reads
"Doomsday Clock: Fast" instead of the generic label. Falls back to the
plain label when no speed is present (older payloads / non-rotation
lobbies).

Client-only. Both `doomsdayClock.speed` and `anonymizeNames` already
ship in the public `GameConfig`, so no server or schema changes are
needed.


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

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

zixer._
2026-07-15 17:20:53 -07:00
Zixer1andGitHub f6e1b8fbae feat(livestats): per-player killedBy + deathPosition + winner for live standings (#4593)
## Description:

Enriches the admin-bot live-stats snapshot
(`/api/adminbot/game/:id/stats`) with the per-player fields a semi-live
standings board needs, so it can score kills + placement live instead of
waiting for the post-game record.

Per player in `liveStats.players[]`:
- **`killedBy`** — the eliminator's clientID (null while alive /
non-client killer). Invert → live kill points.
- **`deathPosition`** — finishing place at elimination = non-bot players
still standing + 1 (frozen at "last of the then-standing"). Null while
alive → live placement.

The **full roster** is reported (dead players are retained in the client
view), plus top-level **`winner`** (the decided winner's clientID, else
null).

Deterministic / consensus-safe: `killedBy` (stamped at `recordKill`) and
`deathPosition` (stamped in `PlayerExecution`) are pure sim values, so
in-sync clients agree on the majority vote; `winner` is added
server-side in `GameServer.liveStats()` from the winner vote (like the
existing publicID/username/connected enrichment).

## 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
2026-07-15 17:20:30 -07:00
a639a6aa6b Gate public lobby listing on the canCreatePublicLobbies entitlement (#4620)
## What

The API now returns an explicit `canCreatePublicLobbies` boolean on
`/users/@me` (player) and on each subscription tier in `cosmetics.json`.
This PR gates public lobby listing on that entitlement instead of
inferring it from subscription status (`active`/`trialing`), and
advertises the perk on the store's subscription tiles.

## Changes

**Entitlement check**
- `UserMeResponseSchema` gains a required `canCreatePublicLobbies`
boolean (same pattern as `unlimitedRanked`)
- The worker's listing endpoint and `HostLobbyModal`'s Public toggle
both check the boolean directly; the Dev bypass is unchanged
- `hasActiveSubscription()` is removed — these were its only call sites
- The server error stays `subscription_required` (subscribing is still
how you get the entitlement; renaming would ripple through i18n for no
behavior change)

**Store**
- `SubscriptionSchema` (cosmetics catalog) gains a required
`canCreatePublicLobbies` boolean
- Subscription tiles show a "Create public lobbies" badge below
"Unlimited ranked" when the tier grants it (`cosmetics.public_lobbies`
added to en.json)

**Tests**
- Schema tests for both new fields (accepts true, rejects missing,
rejects non-boolean); `UserMeResponse` fixtures updated

## Deploy ordering

Both new schema fields are required, so this client must deploy after
the API serves them (same constraint as `unlimitedRanked` / infra#427).
The API already returns both fields.

## Verified

Typecheck, ESLint, and the affected test files pass (165 tests).
Confirmed the live payloads against a local API: `/users/@me` returns
`player.canCreatePublicLobbies` and both subscription tiers in
`cosmetics.json` carry the field.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:54:01 -07:00
766fda991a Render custom crown cosmetics above player names (#4619)
## What

Renders each player's crown cosmetic in-game (follow-up to #4618). The
crown **skins the first-place crown**: when the tile leader owns a crown
cosmetic, their image replaces the default crown icon above their name —
on the map name plate and in the hover overlay. Players without the
cosmetic keep the default crown.

The image pipeline follows custom flags: the server-resolved crown URL
rides `PlayerStatic` into the GPU name pass and loads on demand into a
runtime `TEXTURE_2D_ARRAY` (layers deduped by URL, square 128×128
cells).

## Changes

**GL name pass**
- `FlagAtlasArray` cell size is now a constructor parameter; a second
instance holds crown images (square cells, no letterbox margins)
- Per-player data texture widens 8 → 9 columns (`PLAYER_DATA_COLS`);
column 8 carries the crown atlas layer
- `status-icon.vert/frag.glsl`: status slot 0 (first-place crown)
samples the crown atlas instead of the status atlas when the player has
a crown cosmetic — same size and position as the default crown
- `WebGLFrameBuilder.syncPlayers` resolves `cosmetics.crown.url` →
`PlayerStatic.crown`

**DOM**
- `getPlayerIcons` swaps the first-place crown icon src for the cosmetic
image (hover overlay)

**Dev hook**: `localStorage.setItem("dev-crown", "<image url>")` forces
a crown in singleplayer (mirrors `dev-pattern`)

## Verified

Drove a headless solo game (real-GPU WebGL via ANGLE Metal) with the
dev-crown hook, expanded until first place — the cosmetic replaces the
default crown above the name on the map and in the hover overlay
(default `CrownIcon.svg` absent, cosmetic present).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:24:25 -07:00
8f1bbdd20e let admins see clan tags in FFA (#4459)
Resolves #4446 

## Description:

left is an admin, right is a normal player:
<img width="3834" height="669" alt="image"
src="https://github.com/user-attachments/assets/c280485c-ae62-4287-bc66-53a2bb68cb2b"
/>

admin can see their own tag, and the UON tag, the non-admin can only see
their own tag.

## 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: iamlewis <lewismmmm@gmail.com>
2026-07-15 15:06:36 -07:00
e05dce3437 Add crown cosmetic type (#4618)
## What

Adds **crowns** as a new cosmetic category, wired end-to-end like
flags/skins, plus a Crowns tab in the lobby cosmetics modal (#4617).

Catalog shape in cosmetics.json:

```json
"crowns": {
  "gold_crown": {
    "name": "gold_crown",
    "url": "...",
    "affiliateCode": null,
    "product": null,
    "priceHard": 5,
    "artist": "...",
    "rarity": "common"
  }
}
```

## Changes

- **Core**: `CrownSchema` + optional `crowns` record in
`CosmeticsSchema`; `crownName` in `PlayerCosmeticRefs`; resolved `crown:
{ name, url }` in `PlayerCosmetics`
- **Server**: `isCrownAllowed` in the privilege checker — requires a
`crown:*` or `crown:<name>` flare, resolves the ref to the catalog URL
- **Client**: crown selection persisted under the `crown` localStorage
key (stale/unowned selections self-clear), `crownRelationship` +
resolve/refs wiring, `"crown"` purchase type, Crowns tab in the store,
and a Crowns tab in the cosmetics modal (owned crowns + Default tile;
selecting persists and keeps the modal open)
- **i18n**: `store.crowns`, `store.no_crowns`
- Tests for schema parsing, privilege checks, and cosmetic resolution

## Not in this PR

- In-game rendering of `player.cosmetics.crown` (name pass /
leaderboards / player panel)
- API-side support (`/shop/purchase` accepting `cosmeticType: "crown"`,
granting `crown:<name>` flares, serving `crowns` in cosmetics.json)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:49:45 -07:00
7636f4770c Merge skin/effects pickers into one lobby Cosmetics modal (#4617)
## What

Replaces the three lobby cosmetic buttons (skin, flag, effects) with
two: **Flag** and **Cosmetics**. The new Cosmetics button opens a single
tabbed modal (`#modal=cosmetics`) with:

- **Skins** — the combined patterns + image-skins grid (from
TerritoryPatternsModal)
- **Effects** — all effect types via the tabbed effects-grid, same
layout as the Store

## Changes

- New `CosmeticsModal` and `CosmeticsInput` (lobby button previews the
selected skin/pattern, shows a "Cosmetics" label on defaults, hidden on
CrazyGames via `no-crazygames`)
- Removed `TerritoryPatternsModal`, `EffectsModal`, `PatternInput`,
`EffectsInput` and their wiring in Main.ts / index.html / LangSelector
- Selecting a skin or pattern no longer closes the modal — the tile
highlight moves to the new selection (matches the Effects tab behavior)
- i18n: added `cosmetics.title/button_title/search`; removed the keys
orphaned by the deleted components

## Notes

- Deep links to the old `#modal=territory-patterns` and `#modal=effects`
no longer resolve
- A follow-up PR (crowns cosmetic) stacks on this branch

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:39:56 -07:00
FloPinguinandGitHub ef6e8be2be Allow mobile players to take part in the aftergame 🙂 (#4615)
## Description:

In team games, after the game is over, players should be able to nuke
their teammates (the core simulation already allows this via
`nukeSpawn()`). On desktop this works because players can use keyboard
shortcuts to select nukes directly. However, on mobile, the radial menu
unconditionally replaces the attack submenu with "donate gold" when
targeting a friendly player, making it impossible to access nukes
targeting teammates in the aftergame.

The fix checks whether any buildable attack units (AtomBomb,
HydrogenBomb, MIRV) are actually available for the target tile. Since
`nukeSpawn()` only returns a valid silo for teammate tiles after game
over, this naturally detects the aftergame state and shows the attack
submenu. During normal gameplay, `nukeSpawn()` blocks teammate nukes so
`hasBuildableAttacks` stays false and donate gold is shown as before.

## 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-07-15 12:16:08 -07:00