Commit Graph
4224 Commits
Author SHA1 Message Date
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
Navaneeth PrabhaandGitHub 1a34321ac8 Fix transport ships targeting unreachable inland-lake shores (#4577)
Resolves #4555

## Description:

Fixes the bug where transport boats could not be sent to certain targets
(e.g. between the arrows on the Four Islands map) when the attacker's
territory was far enough away that it approached the target from its
inland-lake side.

**Root cause:** The transport landing tile was selected purely by
distance. `targetTransportTile()` called `SpatialQuery.closestShore()`,
which returns the nearest shore owned by the target using a
Manhattan-distance BFS and only checks `isShore && isLand && owned` — it
never considers whether that shore is reachable by water. Water bodies
are tracked as connected components (an inland lake is a separate
component from the surrounding ocean), and the *source* selection
(`closestShoreByWater()`) correctly requires the attacker to have a
shore in the same water component as the destination. So when the
nearest owned shore happened to face a disconnected inland lake, the
source search found no shore in the lake's component and the transport
silently failed — even though the same target had an ocean-facing shore
that the attacker's boats could actually reach.

**Fix:** Make destination selection reachability-aware so a lake-facing
shore is never chosen when a reachable one exists.

- Added `SpatialQuery.closestReachableShore(targetOwner, attacker,
tile)`: it first collects the set of water components adjacent to the
attacker's own shoreline, then returns the nearest target-owned shore
whose water component is in that reachable set. Shores that only border
a disconnected water body (an inland lake) are skipped. It returns
`null` only when the target has no reachable shore at all.
- `targetTransportTile()` now takes the attacker and delegates to
`closestReachableShore()` instead of `closestShore()`. Its two callers —
`canBuildTransportShip()` and `TransportShipExecution.init()` — already
have the attacker in hand and pass it through, so both the build-time
check and the actual execution agree on the same reachable destination.
- `closestShore()` is left unchanged; this adds a reachability-aware
variant rather than altering existing behavior.

**Testing performed:** Reproduced the bug first on a generated map with
an inland lake enclosed by a thick land moat (confirmed the lake
resolves to a separate water component and that `closestShoreByWater()`
returns `null` for the lake-facing destination while a reachable ocean
shore exists). Added unit tests for `closestReachableShore()` (picks the
reachable ocean shore; returns `null` when every target shore is in an
unreachable water body) and a `canBuildTransportShip()` regression test
for the lake scenario. Full suite: 1878 tests pass; `tsc --noEmit`,
ESLint, and Prettier are all clean.

## 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 user-facing text added in
this PR
- [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:
Navaneeth Prabha#0825
2026-07-14 14:21:59 -07:00
Zixer1andGitHub 2422a1a7a0 feat(anon): memorable, collision-proof animal anonymous names (#4611)
## Description:

Makes OpenFront's anonymised names **memorable and collision-proof**.

Anonymised lobbies (e.g. OFM tournaments) previously showed opaque names
— the `Anon420` fallback and hash-derived civ-tribe names in the overlay
— that were hard to remember and could repeat within a viewer's view, so
players couldn't reliably track opponents across a match.

**What changed**
- **`src/core/AnonAnimals.ts`** (new): an 80-word animal bank +
`anonAnimalName(slot, offset)`. Consecutive slots map to **distinct**
handles — the 80 animals fill first (`round 0` → a bare `AnonWolf`),
then a single round digit counts up (`AnonWolf1`, …). At a fixed offset,
two slots can never collide.
- **`GameServer.anonName`**: assigns each player its **join-order slot**
in `allClients` (stable — late-joiners append so existing names never
shift; reconnects reuse their slot) plus a **per-viewer offset**. So
within any one viewer's view no two players can share a name, while
different viewers still see different names for the same player
(**anti-team preserved**). Replaces the old per-pair hash and removes
the now-dead `anonymousUsername` helper.
- **Client fallback** (`genAnonUsername`): draws a random slot through
the same helper (no roster client-side → best-effort); the overlay is
what guarantees uniqueness in-game.
- `createRandomName` (nation/other display names) is untouched.

**Desync safety — audited against #4426**

#4426 fixed a desync where `PlayerExecution` seeded `removeClusters()`
from `player.name()`, a value anonymize-names makes per-client. This
change preserves that invariant:

| Check | Result |
|---|---|
| Deterministic state hash reads `name()`? | **No** — `PlayerImpl.hash =
simpleHash(id)·(troops+numTilesOwned) + Σ unit.hash`; `UnitImpl.hash =
tile + simpleHash(type)·id` |
| `removeClusters` seed | `simpleHash(player.id())` — id-based (#4426
fix), untouched |
| Where `anonName` is called | only `startInfoFor` / `gameInfo` —
per-viewer wire payloads, never the sim |
| Archived record | uses `wireGameStartInfo` (real names) — untouched →
replay/scoring unaffected |
| Net new sim inputs | none — only the display string + server-side
roster order |

The name is display-only and the simulation is name-blind, so this
cannot desync.

**Testing**
- New `tests/AnonAnimals.test.ts`: the no-collision guarantee (250
distinct slots → 250 distinct names), round roll-over, per-viewer
variation, wire-validity/length.
- Existing `tests/server/AnonymizeNames.test.ts` overlay suite still
passes.
- Full suite green locally (`npm test` + `npm run test:coverage`), plus
`build-prod`, `eslint`, and `prettier --check .` clean.

## Please complete the following:

- [x] I have added screenshots for all UI updates — _N/A, no UI changes
(in-game name string only)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — _N/A, generated handles are not
translatable identifiers_
- [x] I have added relevant tests to the test directory —
`tests/AnonAnimals.test.ts`

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

<!-- TODO: fill in Discord username -->
2026-07-14 14:18:33 -07:00
crunchybbbandGitHub 99e6b8ac50 Adds impassable terrain to Korea Map (#4604)
> **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 #4603 (issue number)

## Description:

Korea was supposed to be a two teams country map, but it released for
v32 before impassable terrain existed. Currently the Chinese portion of
the map covers far too much area, making it cannot be used for two teams
and making it not a true Korea map. The PR will convert the china and
russia portions to impassable terrain.

- Japan is still on the map to serve as a "island base" or something
- One missing south korean province equivalent was added
- The map was totally redone (because i deleted the original qgis file
for korea) so the terrain and map size is slightly different but it
should not be too easy to notice
- Adds "Countries" category

<img width="1089" height="2189" alt="image"
src="https://github.com/user-attachments/assets/82c8729f-cc10-4451-890e-27f02cfde919"
/>
<img width="367" height="579" alt="Screenshot 2026-07-13 174100"
src="https://github.com/user-attachments/assets/e24ad971-c2c5-4791-b092-74e0ff90cefc"
/>


## Please complete the following:

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

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

DISCORD_USERNAME crunchybbbbb
2026-07-14 14:11:32 -07:00
RickD004andGitHub 0cbb0a0b19 Standarize the SVG of flags in flags folder, and other flag fixes (#4605)
## Description:

ALL OF THESE FLAG FILES ARE ALREADY IN GAME. NO NEW FLAGS WERE ADDED. 

Standarizes the SVGs of multiple flags. Multiple flags had issues like
not having black borders, or having borders that were too thick:

armenian SSR, azerbaijan ssr, byelorussian SSR, emirate of asir, emirate
of granada, estonian ssr, far Eastern republic, gwent, karelo-finnish
ssr, kirguiz ssr, lithuanian ssr, lower silesia, macedonia, ma clique,
moldavian ssr, Nepal, polish lithuanian Commonwealth, seville, taiping
heavenly, tannu tuva, upper silesia

renamed some flags in the menu to more common and accurate names:

holy see -> vatican
habsburg Austria -> austrian empire
circassia -> Adygea
macedonia -> macedonian empire

alkebulan, kingdom of Portugal and aztec empire used fictional or flags
that were not public domain, so they are replaced with public domain and
historical accurate ones. Countries.json also gets some missing flags,
these flags are fairly generic so they were probably accidentally left
out.

This change is so that flags have consistent designs for stuff like
using them for nations in maps

## 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-14 14:10:44 -07:00
RyanandGitHub 7dc739b89a bugfix for not being in a clan on mobile (#4613)
## Description:

fixes a bug that didn't show the join clan popup on mobile

before:
<img width="464" height="534" alt="image"
src="https://github.com/user-attachments/assets/9b7c0e2d-b8e8-4901-ab0e-37768e658722"
/>


after:
<img width="414" height="359" alt="image"
src="https://github.com/user-attachments/assets/9a655f24-7ec1-49c9-938b-8a677cf65ab3"
/>


## 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-14 14:09:38 -07:00
4def3ee2cc fix(home): remove background wrap-around seam when nav is hidden (#4607)
## Problem

The home-screen background layer was lowered with `background-position:
center calc(50% + 2cm)` on a `bg-cover` layer sized exactly to the
viewport. The shift exposes a 2cm strip at the top with no image
content, and since `background-repeat` defaults to `repeat`, CSS fills
it by wrapping the image's bottom edge around to the top — a visible
seam.

The nav bar normally covers that strip, but when a game starts loading,
the `in-game` body class hides the nav while the background layer stays
visible, exposing the seam.

## Fix

A pure translate can't be gap-free (there is no image content above the
image's top edge), so the downward shift now comes from oversizing
instead:

- Layer stays pinned to the viewport top (`top-0`) and extends 4cm below
it (`-bottom-[4cm]`)
- Image is anchored `center top`

The artwork sits ~2cm lower at mid-screen (matching the previous look
there) and the image's top edge stays pinned to the viewport top, so no
edge is exposed at any viewport size or UI state.

## Verification

Drove the real app headless (Playwright):

- Home page renders normally with the artwork sitting lower
- Reproduced the loading state (`in-game` on body, nav hidden):
background renders clean to the top edge, no seam
- Control: re-applied the old styles in-browser, which reproduced the
exact wrap-around band at the top

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:47:28 -07:00
62abdcd64e fix(nav): show loading spinner on account button until auth resolves (#4606)
## Problem

While `/users/@me` is still resolving, the desktop nav account button
says "Sign in", then flips to the avatar once the response lands. For
logged-in users this briefly (and wrongly) claims they're signed out.

## Fix

Start the account pill in a neutral loading state and only commit to a
real state once auth resolves:

- **DesktopNavBar.ts** — added a small inline spinner (same `border-2 …
rounded-full animate-spin` pattern used across the client, e.g.
TokenLoginModal, UsernameInput) inside the pill, visible by default; the
person icon and "Sign in" text now start `hidden`.
- **Main.ts** — `updateAccountNavButton` hides the spinner as soon as it
runs; each of its paths (avatar / email badge / sign-in) already
un-hides the correct elements.
- **CrazyGamesAccountButton.ts** — same spinner hide in
`updateCrazyGamesNavButton`, which updates the same pill on CrazyGames
instead.

The mobile hamburger account item says "Account" rather than "Sign in",
so it's unaffected.

## Testing

Verified end-to-end with headless Chromium against the dev server:

- On load the pill shows only the spinner (sign-in text and person icon
hidden).
- Once auth resolves it swaps to the person icon + "Sign in" (avatar
path exercised by existing `updateAccountNavButton` logic, unchanged).
- Stalled `/auth/refresh` via request interception to screenshot the
loading state — spinner renders centered in the pill, no layout shift
with neighboring nav items.

| Loading | Resolved |
| --- | --- |
| small spinner in pill | person icon + "Sign in" |

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:20:36 -07:00
376061793a Handle ranked play limits in the client (#4602)
## Summary

Client-side handling for the new server-enforced ranked play limits
(infra#427): free players get a lifetime allowance of ranked matches,
then a small daily allowance; subscribers on tiers with the
`unlimitedRanked` entitlement are never limited.

- **Queue rejection**: the matchmaking socket closing with code 1008 and
reason `ranked_limit_reached` now renders a limit-reached view in the
matchmaking modal (message + "Get unlimited ranked" button that opens
the store on the subscriptions tab) and does **not** auto-reconnect.
Auth-failure 1008 (`Invalid session`) is disambiguated by the reason
string and keeps the existing reconnect-with-refreshed-token path; code
1000 ("replaced by another tab") is unchanged. Both 1v1 and 2v2 go
through the same modal, so both queues get identical handling.
- **`/users/@me`**: added required `player.unlimitedRanked: boolean` to
`UserMeResponseSchema`.
- **cosmetics.json**: added required `unlimitedRanked: boolean` to
`SubscriptionSchema`; store subscription tiles now show an "Unlimited
ranked" perk line for tiers that include it.

Display copy avoids hardcoding the limit numbers since the server
constants may be tuned.

## ⚠️ Deploy ordering

Both new schema fields are **required**, so this must ship **after** the
API starts serving them (infra#427). Against the current API,
`/users/@me` parsing fails (players appear logged out) and a catalog
without the subscription field fails the whole cosmetics parse.

## Testing

- Schema tests for both new fields (present / rejected-when-missing) in
`tests/ApiSchemas.test.ts` and `tests/CosmeticSchemas.test.ts`; full
suite, tsc, ESLint, Prettier all pass.
- Verified in the running app (headless Chromium): forced the modal into
the limit-reached state, confirmed the rendered view, and confirmed the
upsell click closes matchmaking and opens the store on the subscriptions
tab.
- Not verifiable locally (needs deployed infra#427): the real close
frame, the midnight-UTC reset, and the perk line against a real catalog
— covered by the handoff QA checklist.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:55:40 -07:00
bf70ef6f45 Return the live TileSet from Player.tiles() instead of cloning (#4600)
Fixes #4598

## Problem

`PlayerImpl.tiles()` copied the entire owned-tile set into a fresh
native `Set` on every call — an O(owned tiles) allocation that grows
with player success (100k+ entries for a large late-game player). The
issue reporter measured 71.8% of sim CPU inside this clone in a bot
harness that samples tiles on large players.

## Why the copy existed, and why it's now safe to drop

The snapshot dates to ed4201ab8 (Aug 2024), which added a dead-defender
loop that conquers tiles out of the set while iterating it — the copy
guarded against mutation during iteration, back when `_tiles` was a
native collection.

`TileSet` has since made that guard obsolete: it guarantees
Set-identical iteration semantics under mutation (entries deleted during
iteration are skipped, compaction is deferred while any iterator is
live, so positions never shift under an iterator). All three call sites
that mutate while iterating (`AttackExecution.handleDeadDefender`,
`SpawnExecution`'s relinquish loop, the existing `PlayerImpl.test.ts`
conquer loop) only ever delete the entry currently being visited, which
is safe under Set semantics — so the live set is behavior-identical to
the snapshot.

## Change

- `Player.tiles()` now returns `ReadonlyTileSet` (matching
`borderTiles()`) and `PlayerImpl` returns `this._tiles` directly —
zero-copy, mirroring the approach in #4230.
- `ReadonlyTileSet` mirrors the `ReadonlySet` read surface (`size`,
`has`, `forEach`, `values`, iteration); every existing caller typechecks
unchanged.
- Added regression tests pinning the new live-view semantics and the
relinquish-during-iteration pattern `SpawnExecution` relies on.

## Verification

- `npx tsc --noEmit` clean
- Full suite: 21 files, 166 tests pass
- `npm run lint` + prettier clean

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:27:49 -07:00
3a5fba2e12 Add 2v2 ranked matchmaking (#4596)
## Description:

Implements 2v2 ranked matchmaking end-to-end against the matchmaking
API's 2v2 queues (API PR #419): core team pinning, the server's second
checkin loop and game creation, and the client UI.

## Core — deterministic team pinning

The matcher's assignment specifies exactly who plays with whom (`teams:
[[a,d],[b,c]]`), but team assignment previously only did clan/friend
balancing and could scramble the ELO-balanced split.

- `PlayerSchema`/`PlayerInfo` gain an optional **`teamIndex`** — a
server-stamped index into the game's team list, part of `GameStartInfo`
so it's identical on every client (same category as `clanTag`/`friends`,
which already feed deterministic team assignment).
- `assignTeams` honors pins **unconditionally** — before clan/friend
grouping and past `maxTeamSize` (the matcher's balancing is
authoritative) — and seeds the counts that balancing of any unpinned
players sees. Pinned players still participate in the friend graph, so
an unpinned friend is pulled toward a pinned player's team.
- publicIds never enter core: the game server resolves publicId →
teamIndex per client at game start.

## Server

- **One checkin long-poll per mode.** Both loops send `mode` explicitly
(the API deployed ahead of the client, so no omit-for-back-compat
needed).
- **`get2v2Config()`**: Team mode, `playerTeams: 2`, `maxPlayers: 4`,
always-compact map, donations enabled (matching public team games),
`rankedType: "2v2"` (the API's 2v2 ingestion has shipped; `RankedType`
gains `TwoVTwo`).
- **The assignment payload is now used** (it was previously discarded):
`players` → `allowedPublicIds` so only the matched accounts can take the
slots (also hardens 1v1), and `teams` → `teamIndex` stamps at game
start. A malformed assignment logs a warning and falls back to creating
the game without pins rather than stranding matched players.
- The 3-clients-per-IP cap on public games applies to matchmade games
too (an allowlist doesn't stop one person multi-tabbing multiple
accounts). It is now skipped in dev, where local testing (multi-tab, the
4-player e2e) is inherently same-IP — matching the existing dev/prod
gating of Turnstile and the duplicate-account kick.

## Client

- Ranked modal's 2v2 card is enabled; it passes the mode through
`open-matchmaking` (dispatchers without a detail — homepage button,
requeue URL — still mean 1v1).
- Matchmaking modal joins with `&mode=1v1`/`&mode=2v2`, shows a 2v2
title (`matchmaking_modal.title_2v2` in en.json), and shows the real 2v2
ELO from the new `leaderboard.twoVtwo` field in `/users/@me` (the ranked
modal's 2v2 card does too).
- WinModal shows requeue for any ranked game and carries the mode back
into the right queue (`/?requeue=2v2`).
- 2v2 ranked stats surface in the player stats tree (labeled via
`player_stats_tree.ranked_2v2`).

## Harnesses (`tests/matchmaking/`)

- Contained: the fake server captures the `mode` query param; asserts
each queue sends its mode explicitly. **10/10.**
- E2E: `MM_MODE=2v2` runs four real browser players through the real
local worker's 2v2 queue and rides the flow into the started game.
Asserts same gameId for all four, the 2v2 config, allowlist admission,
and a **deterministic 2 vs 2 in-game split read from each client's
GameView** (the software-WebGL gate is spoofed in test pages only).
**8/8.** 1v1 e2e still **6/6.**

## Verification

- `npm test`: 2,053 tests pass, including 7 new (6 `assignTeams` pinning
unit tests + a full-game pinned-split test through `setup()`).
- `npx tsc --noEmit`, ESLint clean.
- Live e2e against a local `wrangler dev` API worker: 1v1 (6/6) and 2v2
(8/8) as above.

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


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

(UI changes — the ranked modal's 1v1/2v2 cards — were verified with
before/after screenshots in the live app during development.)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:22:52 -07:00
Zixer1andGitHub 9b69b7f422 fix(doomsday): keep doomed warships on patrol instead of idling at ports (#4597)
Follow-up to #4587 (the 5% drain floor).

## Description:

A side below the Doomsday Clock bar cannot repair its navy: warship
healing is suppressed in `WarshipExecution.healWarship` so the decay
actually lands. Since #4587 floors the drain at 5% of max instead of
sinking ships to zero, warships now survive at 5% health, which is below
the 75% `warshipRetreatHealthPercent` retreat threshold. The result was
that every doomed warship peeled off to the nearest port, docked, and
then never healed, sitting idle and out of combat, instead of the
intended crippled-but-still-fighting state.

This gates the repair-retreat on the Doomsday Clock, mirroring the
existing heal gate: a doomed warship no longer starts a retreat, and any
in-flight retreat or dock is cancelled so it returns to patrol. It keeps
fighting at the floor until its side climbs back above the bar (healing
resumes) or it is destroyed in combat.

No config, schema, or UI change. Deterministic (a boolean check over
existing player/warship state). Covered by three new tests in
`tests/Warship.test.ts` (doomed stays on patrol, non-doomed still
retreats, doomed undocks).

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

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

zixer._
2026-07-13 12:15:30 -07:00
Zixer1andGitHub 290d6922eb feat(playlist): add the Doomsday Clock to the public modifier rotation (#4589)
## Description:

Adds the anti-stall **Doomsday Clock** to the public "special" modifier
rotation, so it rolls into public games like the other spice modifiers,
with a lobby badge.

- `MapPlaylist`: new `isDoomsdayClock` ticket in `SPECIAL_MODIFIER_POOL`
(weight 4, ~20% of special games). When rolled, enables `doomsdayClock`
at a speed picked per game (slow/normal/fast/veryfast).
- `PublicGameModifiers` (type + schema): `isDoomsdayClock` flag drives
the badge.
- `getActiveModifiers` + `en.json`: a "Doomsday Clock" badge/label.
- Test covering the badge wiring.

## 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-13 11:11:11 -07:00
Zixer1andGitHub a2321eb824 balance(doomsday): floor the drain at 5% of max instead of wiping to zero (#4587)
## Description:

The Doomsday Clock drain wiped a caught side's troops (and warship
health) to **zero**, an effective death sentence, so a brief dip below
the bar was unrecoverable. Floor the drain at **5% of each max**
(`drainFloorPercent`) for both troops and warships: a doomed side is
crippled, not eliminated, and recovers if it climbs back above the bar.
The rising territory bar still forces a finish (a 5%-strength side is
trivial to conquer, and the leader is drain-exempt).

- Config: `drainFloorPercent` (5), applied to both drains.
- Execution: clamp troop + warship-health removal to what sits above the
floor (integer-only, deterministic).
- HUD: the Collapsing rate is shown net of the floor.
- Tests: drain-to-zero / warship-scuttle cases updated to the floor +
added coverage.

## 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-13 11:10:06 -07:00
Zixer1andGitHub d07f7d46dd feat(highlight): replace the small-player highlight toggle with a strength slider (#4588)
## Description:

Replaces the "Highlight small players" on/off toggle with a **0-500%
Strength slider** controlling the glow's **width** (0 = off, 100% = the
previous default).

- `UserSettings`: `highlightSmallPlayers()`/toggle →
`highlightGlowStrength()`/set, float-backed, clamped to [0, 5].
- `SmallPlayerGlowPass` takes the strength via a setter (pushed by
`ClientGameRunner` on the settings-changed event), so it stays a pure
consumer and the slider still updates live while the settings modal has
the sim paused. Width = a non-linear blur-iteration count; intensity
compensated so wider stays about as bright.
- `MapRenderer` persists the strength across a WebGL context restore.
- `SettingsModal`: the toggle row becomes a range input.

Behavior note: with the toggle gone the glow **defaults ON at 100%**
(old toggle defaulted OFF); can default to 0 (opt-in) if preferred.

## 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-13 11:07:22 -07:00
e76b34be22 Handle matchmaking socket close codes per API contract (#4595)
## What

Implements the close-code contract from the matchmaking API handoff (API
PR #419) in the matchmaking modal, plus two runnable integration
harnesses. Previously `onclose` only logged, so an API deploy while
queued left the player on the "searching" spinner forever (the queue is
in-memory on the API worker).

| Close | Behavior |
| --- | --- |
| 1008 `Invalid session` | Reconnect and re-send `join` —
`getPlayToken()` refreshes an expired token internally, so the rejoin
carries a fresh JWT |
| 1000 `Replaced by newer connection` | Another tab/window took the
queue slot: show a message (new `matchmaking_modal.replaced` string) and
stop. No retry |
| Any other close before assignment | Server restart/deploy: reconnect
and re-send `join`, with exponential backoff (1s doubling to a 15s cap)
|

Intentional closes (user backs out of the modal, assignment received)
don't reconnect. A pending 2s join timer from a previous socket is
cleared before each reconnect so it can't fire on the new socket.

## Test harnesses (`tests/matchmaking/`)

- **`npm run test:matchmaking`** (contained): drives the real modal in a
headless browser against a fake in-process matchmaking server speaking
the documented protocol; covers the whole close-code table over real
WebSockets. Needs only `npm run dev`.
- **`npm run test:matchmaking:e2e`**: real integration against the API
worker on `localhost:8787` — two browser players join the real queue,
the dev game server receives the checkin assignment, and both clients
end up in the same created game.

## Why now

This is required by the matchmaking API's client contract independent of
the upcoming 2v2 work ("clients must already handle unexpected close →
reconnect and rejoin"). The rest of the 2v2 integration is planned for
the next version.

## Verification

- Contained harness: 8/8 checks pass (join, 1006 reconnect+rejoin, 1008
rejoin with fresh token, assignment, replaced → message + no retry,
intentional close → no retry/no message).
- E2E harness against a local worker: 3/3 — both players matched into
the same game, game created via checkin and joinable by both.
- `npm test` (all 2,046 pass), `npx tsc --noEmit`, ESLint clean.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:55:15 -07:00
RickD004andGitHub 558f2e20db Adds Gulf of Guinea map (#4590)
## Description:

Adds Gulf of Guinea map to the game:
https://en.wikipedia.org/wiki/Gulf_of_Guinea

Final map of the "pirate maps" for v33 , alongside Irish Sea and Levant.
The map is designed so that all the trade from the mainland, which
covers 2 of the four corners of the "square" (inverse L) passes through
3 small islands, which although small in size they will super-boost
pirates in that zone. The mainland is otherwise fairly lineal, playing
like maps like Conakry.

Also some sub-saharan african representation

The map is decorated with Impassable terrain in its southern border, and
has additional nations for Hvn and Solo games


https://github.com/user-attachments/assets/9c9fd8c0-fefb-46c4-8720-55e90401681e

## 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-13 09:17:02 -07:00
Josh HarrisandGitHub e57939aba1 sec: require strict majority in 1v1 winner-vote consensus (backport) (#4581)
**Add approved & assigned issue number here:**

Resolves #4136

## Description:

Backport of #4580 to `main`. `VoteRound.result()` (used by
`handleWinner` in `GameServer.ts` to reach consensus on a reported game
winner) accepted an exact tie as a majority: `votes * 2 >=
totalUniqueIPs`. In a 1v1 game the electorate is 2 unique IPs, so a
single client's vote alone satisfied `1 * 2 >= 2` and was accepted
immediately — letting one player unilaterally declare themselves the
winner without any agreement from the other player, and without the
server ever checking the report against the actual simulation outcome.

This fixes the comparison to require a strict majority (`votes * 2 >
totalUniqueIPs`), so a 1v1 now requires both clients to agree.
Legitimate forfeit-on-disconnect is unaffected: once a player actually
disconnects, the electorate shrinks to 1 unique IP, and the sole
remaining vote still resolves immediately (`1 * 2 > 1`).

The `v32` hotfix (#4580) is being deployed first; this PR keeps `main`
in sync with the same fix.

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

jish
2026-07-12 12:47:01 +01:00
d76691372c Feature/reuse private lobby (#4536)
**Add approved & assigned issue number here:**

Resolves #4476 

## Description:

Lets a private-lobby host reuse the same group for **back-to-back games
without
re-sharing the invite link**.

**Flow:** in a private game, the host clicks a **"New lobby"** button in
the
top-right bar (next to pause). The game's **server** creates a fresh
private
lobby (same creator, default settings) and broadcasts its id to everyone
still
connected. Non-hosts get a one-click **"Join"** banner at the top of the
screen;
the host is taken straight back to the host view for the new lobby. The
chain
can repeat indefinitely.

### Key design decision: the server creates and broadcasts the successor
lobby

The successor lobby is minted by the **finished game's server**, not the
host's
browser. The old game's server is the only thing still connected to
every player, so it has to be what announces the new
lobby; because it also *creates* that lobby, the id everyone is
redirected to is
**authoritative**. A real lobby the server just made for the
authenticated
creator, not an id a client handed it to trust and fan out. The request
is
**creator-only** and **idempotent** per game, and every successor is
wired the
same way, so the group can keep playing game after game.

### How it works

1. Host clicks "New lobby" (host + private only) → confirm dialog → the
client
   sends a `create_next_lobby` message.
2. The game server verifies the sender is the lobby creator, mints a
successor
private lobby on the same worker, stores it (idempotent), and broadcasts
a
   `new_lobby` message with the new id to all connected clients.
3. Each client reacts: the host is navigated to the new lobby's host
view
(`/…/game/<id>?host`); everyone else sees a dismissible "Host started a
new
   lobby — Join" banner that navigates to the join URL in one click.

Two new Zod wire messages in `src/core/Schemas.ts` (`create_next_lobby`,
`new_lobby`) carry the request and the broadcast.

### Screenshots

<table>
  <tr>
    <td width="50%" align="center" valign="top">
<img width="220" alt="In-game New lobby button"
src="https://github.com/user-attachments/assets/9a4d4425-a7f6-4b3a-9c4f-9205300b1e5b"
/><br />
<sub><b>1.</b> In-game <b>New lobby</b> button (top-right, next to
pause) — shown only to the host of a private lobby</sub>
    </td>
    <td width="50%" align="center" valign="top">
<img width="320" alt="Confirmation dialog"
src="https://github.com/user-attachments/assets/fa023f5a-58e8-439b-8899-5150235a1e8c"
/><br />
<sub><b>2.</b> Confirmation so a stray click doesn't pull everyone into
a new lobby</sub>
    </td>
  </tr>
  <tr>
    <td colspan="2" align="center">
<img width="100%" alt="Join banner for non-hosts"
src="https://github.com/user-attachments/assets/7e5ef490-aa7e-4449-add9-e857fe273bde"
/><br />
<sub><b>3.</b> Everyone else gets a one-click <b>Join</b> banner at the
top of the screen</sub>
    </td>
  </tr>
  <tr>
    <td colspan="2" align="center">
<img width="330" alt="Host view for the new lobby"
src="https://github.com/user-attachments/assets/9920b070-4ed3-41f8-9345-78778b4648a7"
/><br />
<sub><b>4.</b> The host lands back in the host view for the brand-new
lobby</sub>
    </td>
  </tr>
</table>


### Design Decisions

**A. The server creates & broadcasts the successor, not the client.**
The finished game's server mints the successor and broadcasts its id.
Why this
and not "host's browser calls `POST /api/create_game`, then asks the
server to
relay the id"?
- The broadcast id is **authoritative/verified**: it's a real lobby the
server
just created for the **authenticated** lobby creator (creator identity
comes
from the JWT the game already holds), not an arbitrary id a client hands
the
  server to fan out to everyone.
- The **old game server is the only thing still connected to all the
players**,
so it must be the one to broadcast. Having it also create the lobby
keeps it
to one authoritative round-trip instead of "client creates, then client
asks
  server to trust an id it didn't make."
- The server can **authorise** (only the creator) and stay
**idempotent**.

**B. The successor starts with default settings (not a copy of the old
game).**
A deliberate scope choice. The host lands in the normal host view and
reconfigures. Copying the exact config would mean reverse-mapping every
`GameConfig` field back into the host-modal controls, which I thought
would be out of scope for this PR. Same **creator**
is preserved; same **settings** intentionally is not.

**C. It's a brand-new lobby, not the same game resurrected.**
This directly follows @evanpelle's guidance on the issue: *"A 'lobby' is
really
just a game that hasn't started yet. So making a persistent lobby isn't
really
possible. I think instead having a simple way to transfer players to a
new lobby
is probably the way to go."* A `GameServer` runs exactly one game
(start → end → archive), so rather than reworking that lifecycle to
resurrect the
old game, the server spins up a fresh successor lobby and transfers the
group
into it, which is also why the feature is framed as "reuse the group,"
not
"reuse the game object."

**D. The host returns via a `?host` URL flag + full reload ("attach
mode").**
Navigating to a normal join URL (`/game/<id>`) always lands you in the
**join**
view, which has no Start button. So the host can't just use the join
URL. The
`?host` flag routes the creator to the **host view** instead
(`Main.handleUrl` → `HostLobbyModal` in "attach" mode, which binds to
the
existing lobby id and skips creating a new one). A full reload is used
because
it cleanly tears down the finished game and mirrors the existing
win-screen
"Requeue" button's `window.location.href` pattern.

**E. Each successor can spawn its own successor (recursive factory).**
The first version only chained **one** generation. A spawned lobby had
no
factory of its own, so the *second* "New lobby" click did nothing
(button just
greyed out). Fixed by `wireSuccessorLobby` (a small dependency-injected
helper)
that wires every successor the same way. This is the whole point of the
issue
("back-to-back games"), so it has its own regression test.

**F. Private-only.**
The successor factory is installed **only** on the private `POST
/api/create_game` path in `Worker.ts`. Public games (scheduled by the
master)
and singleplayer never get a factory, so `handleCreateNextLobby` is a
no-op for
them. The client button is also gated on `isLobbyCreator &&
isPrivateLobby`.

**G. In-game button + confirm; the win-modal button was removed.**
An earlier version put the "New lobby" button on the win screen. I moved
it to
the in-game bar so the host can reuse the lobby **at any time** (without
dying
or waiting for the game to end), and added a **confirm** (matching the
adjacent
Exit button) so a stray click next to pause/exit doesn't yank everyone
into a
new lobby. The win-screen button became redundant and was removed.


### Testing

- **Unit tests:** wire-message schema round-trips
(`tests/NewLobbyMessages.test.ts`); the server handler — authorisation,
  broadcast, idempotency — against a real `GameServer`
(`tests/server/CreateNextLobby.test.ts`); and successor **chaining**
across
  multiple generations (`tests/server/SuccessorLobby.test.ts`).
- **Full suite:** `npm test` passes — **1782 tests across 154 files**.
- **Manual:** created a private lobby with multiple clients and played
consecutive games via the button; verified non-hosts get the Join
banner, the
host lands back in the host view, and the chain works for 3+ games in a
row.

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

MushroomLamp

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:51:49 -07:00
a794eca4d6 Show USD-equivalent value in cosmetic info tooltip (#4571)
## Summary

fixes #4171

The question-mark info tooltip on store cosmetics now shows the item's
equivalent USD value, but only when both:

1. The item has **no product** (i.e. it can't be purchased directly with
money), and
2. It **is purchasable with plutonium** (`priceHard`) — caps-only items
show nothing.

The value is computed at the fixed rate of 20 plutonium = $1.00 (same
rate as the custom currency card) and rendered as e.g. `Value: $2.50`.

## Changes

- `CosmeticButton.ts` — compute `usdValue` (`priceHard / 20`) when the
item has no product and has a plutonium price; pass it to
`<cosmetic-info>`
- `CosmeticInfo.ts` — new optional `usdValue` property rendered as a
tooltip line between the ad-free and color lines
- `en.json` — new `cosmetics.usd_value` key with a `{usd}` placeholder
so translators can position the amount

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:40:09 -07:00
8405c8e896 feat(store): show plutonium and caps balances in store header (#4570)
## Summary
- Show the player's plutonium (hard) and caps (soft) balances on the
right side of the store modal's title bar, next to the existing
not-logged-in warning
- Reuses the existing `currency-display` component from the account
modal
- Renders only when the user is logged in and has currency data
(`player.currency` is optional in the API schema); balances stay current
via the `userMeResponse` document event the store already listens to

## Test plan
- [ ] Open the store while logged in with currency balances — plutonium
and caps appear to the right of the title
- [ ] Open the store while logged out — only the not-logged-in warning
shows, no currency display

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:14:26 -07:00
RickD004andGitHub 1cd36325ac Adds Irish Sea map (#4546)
## Description:

Adds map of the Irish Sea: https://en.wikipedia.org/wiki/Irish_Sea

A relatively small-medium size map of 970k tiles, centered around Isle
of Man (slightly modified to be more centered), surrounded in all sides
by land.

Since all trade will go around the island, this will create crazy strong
pirate players, which have become popular thanks to Youtubers and the
Tradeship buff in v32.

19 default nations, with 43 extra nations for a total of 62, for the
Humans vs Nations gamemode and Solo games.


https://github.com/user-attachments/assets/18e0ee2c-8d7f-4ce7-813c-2e7c64d75acc

## 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-10 14:19:55 -07:00
crunchybbbGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
c25f898809 Adds Scandinavia map (#4561)
> **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 #4543 

## Description:

Adds Map of Scandinavia. This is highly requested and I'm surprised this
map doesnt exist already. The layout is based on the Territorial
version.
Land area: 2.47M pixels
Nations: 24, with additional nations totaling to 62.

<img width="2006" height="2243" alt="image"
src="https://github.com/user-attachments/assets/a959acd3-7871-4d94-8722-b0b69defa9db"
/>
<img width="551" height="590" alt="Screenshot 2026-07-08 162324"
src="https://github.com/user-attachments/assets/e4a49073-04dc-416d-841a-ab945a40d260"
/>


## 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: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-10 14:18:08 -07:00
JB940andGitHub f1fc5434be fix classes for controlpanel unit display and control panel/playerinfo gold display (#4562)
> **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 #4411

## Description:

This PR removes three extra classes in the UI causing issues but no
additional functionality. Gold UI elements were artificially not scaled.
I have confirmed on mobile devices and different layouts, the containers
behave exactly the same way, unless people have an accessibility feature
like scaled text size OR the text grows too large, in which case they
now properly fit into the container as the fix suggests.
Before:
<img width="653" height="116" alt="image"
src="https://github.com/user-attachments/assets/70c81cdd-8bdc-40cc-977e-b045078eafc0"
/>
After:
<img width="636" height="119" alt="image"
src="https://github.com/user-attachments/assets/926fcb94-7944-46f9-a58a-adc7984278b1"
/>

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

JB940
2026-07-10 12:38:12 -07:00
e48a424b32 Remove in-game CrazyGames banner ad (#4567)
## Summary

- Remove the bottom-left 300×250 CrazyGames banner ad shown during
gameplay (created when the spawn phase ends in `InGamePromo`), including
its cleanup path in `hideAd()`
- Remove the now-unused `createBottomLeftAd()` / `clearBottomLeftAd()`
wrappers and the `banner` portion of the CrazyGames SDK type declaration

On CrazyGames, `window.adsEnabled` is already forced to `false`
(Main.ts), so the Playwire in-game path can't activate there — with this
change the game screen shows no ads at all on the CrazyGames platform.
Midgame video interstitials (singleplayer start, game exit) are
unchanged.

## Test plan

- [x] `npx tsc --noEmit` passes
- [x] ESLint passes on touched files
- [ ] On CrazyGames: no banner appears bottom-left after spawn phase
ends

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:28:23 -07:00
bf61f5847b feat(rewards): claimable subscription rewards UI (#4566)
## Summary

Subscription perks (signup bonus + daily soft/hard currency) now land as
**unclaimed rewards** the player must explicitly claim, instead of
crediting balances directly (API side: openfrontio/infra#409). This PR
adds the client UI:

- **`RewardsPanel`** — shared Lit element listing pending rewards
(currency icon, `+amount`, server `note` with translated fallbacks for
known reasons), with per-reward **Claim** and **Claim All**. Both claim
endpoints return post-claim balances, so the wallet updates without
re-fetching `/users/@me`.
- **Account modal** — renders the panel above the subscription panel
(rewards survive subscription lapse, so it doesn't depend on an active
sub).
- **`RewardsModal`** — popup at login when rewards are pending. Reuses
`RewardsPanel`, auto-closes when the last reward is claimed. Only opens
on a clean homepage load (`pathname === "/"`, empty hash) so it never
pops over join links, `#modal=...` deep links, or the Stripe
`#purchase-completed` return — that flow reloads clean, so the signup
bonus popup appears right after purchase.
- **Schemas/API** — `RewardSchema` + `rewards[]` on `/users/@me`
(optional for older API versions), `claimReward` / `claimAllRewards` in
`Api.ts`. Reward `id`/`amount` stay opaque bigint strings; a 404 on
single claim means "already claimed elsewhere" (double-click / second
device) and re-syncs instead of erroring.

## Screenshots

Login popup (also embedded in the account modal):

- Panel: currency icon + amount + note per row, Claim / Claim All
buttons
- Amounts formatted via `BigInt` (can exceed `Number.MAX_SAFE_INTEGER`)

## Test plan

- [x] `npm test` — 161 tests pass, including new coverage for
`RewardSchema`, `/users/@me` with/without `rewards`, and both claim
response shapes
- [x] `tsc --noEmit`, ESLint, Prettier clean
- [x] Verified in the running app (Playwright): panel renders in the
account-modal style; single claim against a stubbed endpoint credits
balances and removes the row; Claim All empties the list and auto-closes
the popup; body scroll restored

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:21:52 -07:00
TKTK123456andGitHub 6a6c142388 Fixed pirating not being disabled when a warship's owner has no port in that body of water (#4458)
> **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 #4291 

## Description:

Fixes pirating not being disabled when a warship's owner has no port in
that body of water

## Please complete the following:

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

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

tktk1234567
2026-07-10 16:31:21 +00:00