Commit Graph

284 Commits

Author SHA1 Message Date
Zixer1 2a1381b41e feat(doomsday-clock): show the zone readout to spectators and replay viewers (#4497)
## Description:
Follow-up to #4469.

The Doomsday Clock panel hides itself unless the viewer has a living
player, so spectators, replay viewers, and eliminated or not-yet-spawned
players see nothing, even though the clock is the most useful thing to
watch in those cases.

This shows the panel whenever the mode is on and there's no winner yet:

- **Live player:** unchanged. Full personal readout (your-share line,
Stable/Unstable/Collapsing status, danger pulse).
- **Spectator / replay / eliminated / not-spawned:** zone-only readout.
The rising threshold bar and the wave countdown ("Rising to X%", "Final
X%", "Next wave X% in mm:ss"), with no personal line and no false danger
pulse.

One gate drives it: `live = me && me.isAlive()`. The zone math was
already player-independent, so nothing new there.

Tested: `tsc --noEmit`, ESLint, Prettier, the `DoomsdayClockExecution`
suite (28/28), and `build-prod`, all clean.
2026-07-03 15:00:32 -07:00
Evan be77ab4fc9 feat: structures cosmetic effect (hover-shown gradient/transition recolor) (#4492)
## Description:

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

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

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

## Please complete the following:

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

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

---------

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

## Description:

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

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

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

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

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

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

Dropdown between slow, normal, fast, very fast

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

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

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

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

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

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


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

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

## Please complete the following:

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

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

zixer._
2026-07-02 18:42:03 -07:00
bijx ad760a0f3d feat: Achievement medal overview (#4487)
## Description:

In the spirit of achievement hunting, seeing how many medals a player
has achieved helps show overall progress. Overview only toggles on when
user clicks toggle achievements button. Works on mobile too.


https://github.com/user-attachments/assets/ea77075b-5e91-4e62-8ac9-52bcdf95cc64

## Please complete the following:

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

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

bijx
2026-07-02 18:31:19 -07:00
Evan b72956d0c0 Gate users without GPU-accelerated WebGL2 instead of running at ~1fps (#4324)
## Problem

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

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

## What this does

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

## Notes / decisions

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

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

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

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

Fixes #4357

## Test plan

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

## Acceptance criteria

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

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

---------

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

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

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

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

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

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

## Please complete the following:

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

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

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

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

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

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

## Please complete the following:

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:21:01 -07:00
Ryan 1d5a6ae246 Account Modal - Games Tab (#4473)
## Description:

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

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

## Please complete the following:

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

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

w.o.n

---------

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

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

## Rendering

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

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

## Schema / server / UI

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

## Review

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

## Testing

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:13:41 -07:00
Evan 200f276ab2 feat: transport-ship trail transition effect + animated store swatch (#4455)
## What

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

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

## How

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

## Notes

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

## Testing

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

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

---------

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

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

## How

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

## Schema

Collapses the trail attributes to a single gradient shape:

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

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

## Notes

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

## Testing

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:28:47 -07:00
evanpelle f4b47ce06c feat: add search bar to effects picker modal
Mirror the flag/pattern modals with a search input in the EffectsModal
header. Filters the effects grid by name — matching either the raw effect
id or its displayed label — and hides the Default tile while searching,
the same way FlagInputModal hides its no-flag tile.

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

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




## Please complete the following:

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

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

w.o.n
2026-06-29 16:16:46 -07:00
Ryan a05ab1bd60 fix broken import... oops (#4448)
> **Before opening a PR:** discuss new features on
[Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small
improvements as
[issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose).
You must be assigned to an `approved` issue — unsolicited PRs will be
auto-closed.

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

Resolves #(issue number)

## Description:

Describe the PR.

## Please complete the following:

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

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

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

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


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

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

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

## Please complete the following:

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

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

w.o.n

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:24:09 -07:00
Evan bd9ef9a317 feat: effects cosmetic category (transport-ship trail) + UI (#4418)
## What

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

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

## UI

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

## Data flow

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

## Tests

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

## Notes / follow-ups

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:13:48 -07:00
bijx 06d505ebc9 feat: Expand maps button (#4431)
## Description:

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


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


## Please complete the following:

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

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

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

removes the "subscribe" and "purchase" text:

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

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

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

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


## Please complete the following:

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

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

w.o.n
2026-06-28 18:41:32 -07:00
Ryan c622e8581c collapse skins under one banner (#4432)
## Description:

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

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


## Please complete the following:

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

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

w.o.n
2026-06-27 19:09:05 -07:00
Ryan 8ffb19d938 Discord (#4367)
Resolves #(issue number)

## Description:

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

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

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


## Please complete the following:

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

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

w.o.n

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:15:05 -07:00
Zixer1 3b84a6f569 Feat/anonymize names (#4318)
**Add approved & assigned issue number here:**

Resolves #4296

## Description:

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

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

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

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

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

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

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

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


## Please complete the following:

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

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

zixer._
2026-06-24 07:54:44 -07:00
evanpelle cbf1e5c513 Make host lobby start button yellow during countdown
Add a `warning` variant to o-button that reuses the existing
`--color-cyber-yellow` brand token, and apply it to the host lobby's
start button while the "Starting in Xs" countdown is active. The button
stays primary blue in the Waiting/Start states.
2026-06-22 12:46:18 -07:00
Evan 678112492c Fix per-frame layout jank when focusing a toggle-input-card field (#4314)
## Problem

Focusing the number field of a `toggle-input-card` (Game Timer / Gold
Multiplier / Starting Gold, in both the single-player and host-lobby
modals) cost several ms of layout/paint **every tick** for as long as
the field stayed focused.

## Root cause

The input was rendered **conditionally** — `${this.checked ?
html`…<input>…` : nothing}`. Enabling a toggle therefore **freshly
inserts** the `<input>` into the DOM, and **focusing a just-inserted
input** is what forced the per-frame layout/paint. An input that was
already present in the DOM doesn't do this.

## Fix

Keep the input **permanently mounted** and toggle a `hidden` class when
unchecked, instead of conditionally rendering it. Focusing it is then
always focusing an element that was already there. Because both modals
share `<toggle-input-card>`, this single change fixes both.

Also restores the **autofocus + select** of the field on enable (it had
been removed earlier while chasing this bug) — safe now that the input
isn't freshly inserted.

No other UX change: the toggle behavior, checkmark, styling, and all
three cards behave identically.

## Testing

Hard-reload, then in both the Solo and Host-lobby modals, enable each of
Game Timer / Gold Multiplier / Starting Gold, type a value, and keep the
field focused — smooth, no per-frame jank, and the field autofocuses on
enable.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:15:24 -07:00
Evan 0904ad76d1 Fix per-frame render jank from toggle-input-card focus (#4300)
## Problem

Enabling **Starting Gold** (or **Game Timer** / **Gold Multiplier**) in
the single-player / host-lobby modal made the page spend several ms of
"Render" time **every frame** — for as long as the toggle stayed
enabled. Disabling the option made it stop.

## Root cause

Each `toggle-input-card`, on enable, auto-focused and selected its
number input so you could type immediately:

```ts
input.focus();
input.select();
```

A focused/selected editable inside the modal keeps the browser doing
layout/paint work every frame for as long as it stays focused. It
reproduces for any of the toggle-input cards because they all auto-focus
on enable, which is why Starting Gold, Game Timer, and Gold Multiplier
all triggered it.

> **Note on the earlier revision of this PR:** the first attempt passed
`{ preventScroll: true }` to `focus()`, on the theory that
scroll-into-view was the cause. It successfully stopped the scroll
(verified: modal scroll container `scrollTop 0 → 0`), but the per-frame
render cost remained. That ruled out scroll-into-view and proved the
focused editable itself — not the scroll — was the trigger.

## Fix

Remove the auto-focus entirely. Enabling a toggle no longer focuses its
number input, and the per-frame render cost is gone.

## Trade-off

You no longer get type-to-replace on enable — click the field before
typing the value. Worth it to eliminate the per-frame render.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:29:14 -07:00
FloPinguin b997099dfe Add map search 🔍 (#4283)
## Description:

Add a search input field to the map picker section header, allowing
users to quickly filter maps by name.

- Place transparent search input on the right side of the "Maps" section
header
- Filter maps by translated name and map ID as the user types
- Hide Featured/All/Favourites tab buttons while search is active
- Show filtered results with a count heading, or a "no results" empty
state
- Clear button appears when search input has text

<img width="857" height="463" alt="Screenshot 2026-06-15 001415"
src="https://github.com/user-attachments/assets/35e1101a-177e-4923-bb1d-34eb683c6f80"
/>

No search results:

<img width="855" height="454" alt="Screenshot 2026-06-15 001433"
src="https://github.com/user-attachments/assets/bf27211d-5891-4739-a92f-0fc44b3c9c61"
/>

## Please complete the following:

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

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

FloPinguin
2026-06-14 17:54:43 -07:00
FrederikJA 19db66f424 Delayed lobby start (#4184)
Resolves #4169

## Description:

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

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

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

**Host video**

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

**Non-host video**

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

## Please complete the following:

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


## Please put your Discord username so you can be contacted if a bug or
regression is found:
FrederikJA
2026-06-12 12:22:03 -07:00
Evan eeb5f7e850 Fix unstyled modals in dev: re-read document styles after page load (#4242)
## Problem

Since #4229, modals render unstyled in `npm run dev` (no black backdrop,
no Tailwind styling). Production/staging is unaffected.

`documentStylesSheet()` reads the document's `<style>` tags once, at
module-eval time. In dev, Vite injects the Tailwind styles *during*
module evaluation — after that read — so the shared constructed
stylesheet ended up with 7 CSS rules instead of the full Tailwind sheet.
In production the styles come from a `<link rel=stylesheet>` that is
fetched by URL, so timing doesn't matter there.

## Fix

If the document hasn't finished loading when the sheet is first created,
re-populate it once on the window `load` event (which fires after the
entry module graph — and therefore all style injection — completes).
Constructed stylesheets are live, so already-rendered components pick
the styles up without re-rendering. The existing HMR re-populate hook is
unchanged.

## Test plan

- [x] Reproduced in dev with headless Chromium: shared sheet had 7
rules, modal unstyled
- [x] After fix: sheet has full Tailwind rules, solo modal renders with
correct dark styling (screenshot-verified)
- [x] `npx tsc --noEmit`, ESLint clean
- [x] Client test suite: 458 tests pass

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:26:21 -07:00
Evan 182d008ddd Generate a single MapInfo list; move SPECIAL_TEAM_MAPS and en.json map names into info.json (#4231)
**Add approved & assigned issue number here:**

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

## Description:

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

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

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

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

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

## Please complete the following:

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

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

evanpelle

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:06:48 -07:00
Evan 94f2293149 Reduce main bundle size by ~44% gzipped (732 KB → 412 KB) (#4229)
## Summary

Cuts the main JS chunk from **2,891 KB (732 KB gzip)** to **1,679 KB
(412 KB gzip)** by fixing two bundling issues and removing/replacing
heavy dependencies. Measured with a per-module `renderedLength` analysis
of the rolldown output (its prod sourcemaps are malformed, so
sourcemap-based tools misattribute sizes).

| Chunk | Before | After |
|---|---|---|
| `index-*.js` (min) | 2,891 KB | 1,679 KB |
| `index-*.js` (gzip) | 732 KB | **412 KB** |

## Changes

- **Sim worker moved out of the main bundle (~512 KB).** The
`?worker&inline` payload is now reached through a dynamic `import()`, so
it lands in its own lazy chunk fetched when a game starts. The worker
itself still uses Vite's inline Blob mechanism (with its `data:` URL
fallback) — runtime instantiation is byte-for-byte unchanged.
- **Replaced `lit-markdown` with `marked` + the already-bundled
DOMPurify (~380 KB).** lit-markdown transitively pulled sanitize-html,
htmlparser2, postcss, and two copies of entities into the client just to
render news markdown. New `src/client/Markdown.ts` matches its
image-stripping default.
- **Dropped `colorjs.io` (~114 KB).** It was only used for ΔE2000
distance in `ColorAllocator`; colord's lab plugin (already imported
there) provides the same CIEDE2000 via `.delta()`. Only relative
magnitudes are compared, so allocation behavior is unchanged.
- **`msdf-atlas.json` (~319 KB) fetched at runtime** like the atlas PNG,
preloaded in parallel with worker init in `ClientGameRunner` so
game-load latency is unaffected.
- **Tailwind CSS no longer shipped twice (~158 KB).** `o-modal` imported
`styles.css?inline`, duplicating the emitted stylesheet as a JS string.
It now adopts a constructed stylesheet built from the document's own CSS
(HTTP-cache hit in prod, `<style>` tags + HMR re-sync in dev) via
`SharedStyles.ts`.
- **Debug GUI lazy-loaded.** lil-gui + `gl/debug/*` now load on first
toggle (46 KB lazy chunk) instead of shipping in the main bundle.

Also looked at the `import * as d3` in RadialMenu (~84 KB) but left it:
rolldown tree-shakes the metapackage well and all but ~2 KB is the
genuine dependency closure of the selection/transition/shape/color APIs
in use.

## Test plan

- [x] `tsc --noEmit` clean
- [x] ESLint clean
- [x] Full test suite passes (1,374 + 65 tests)
- [x] `npm run build-prod` succeeds; worker/debug chunks present in
`asset-manifest.json` for the R2 upload
- [ ] Manual smoke test in dev: start a game (worker dev path), open a
modal (shared stylesheet), open news modal (markdown rendering)

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

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

N/A — maintainer refactor.

## Description:

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

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

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

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

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

## Please complete the following:

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

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

evanpelle

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:36:53 -07:00
Evan 1db02acdc2 Move theme data into the render-settings JSON pipeline (#4223)
**Add approved & assigned issue number here:**

N/A — maintainer refactor.

## Description:

Replaces the theme class hierarchy
(`BaseTheme`/`PastelTheme`/`ColorblindTheme`) with theme JSON files —
`default-theme.json` and `colorblind-theme.json` — combined with
`render-settings.json` at runtime into a single graphics-configuration
pipeline (`settings.theme`). One `SettingsTheme` class keeps the
algorithms (color allocation, team-variation generation, LAB-contrast
structure colors) and reads all data from `ThemeSettings`; adding a
theme is now just adding a JSON file.

Colorblind mode (#4150) is fully preserved:

- Same palettes — the 32-color CVD-safe pool and Okabe-Ito team colors
are baked into `colorblind-theme.json`
- The relative border rule (`l × 0.6`) is expressed as a
`borderLightnessScale` knob alongside the default theme's absolute
`borderDarken`
- The mid-game re-theme wiring (`refreshPlayerColors`/`refreshPalette`)
and the affiliation/friend-foe tint overrides are unchanged;
`applyGraphicsOverrides` now also swaps the `settings.theme` slice
- `deepAssign` replaces arrays wholesale so differing palette lengths
survive theme switches

Verified against the previous implementation with an equivalence test
(since removed): default-theme colors are byte-identical including
allocation order; colorblind team/derived colors are byte-identical, and
FFA assignment may permute within the same palette (hex baking rounds
upstream's fractional-RGB colord objects, which can flip the allocator's
greedy delta-E ordering — rendered colors round identically either way).

Also removes dead theme surface (`terrainColor`, `backgroundColor`,
`falloutColor`, `font`, `textColor`, spawn-highlight variants,
`PastelThemeDark`) — GL terrain colors and dark mode were already
handled in the renderer. Note this means the colorblind terrain bands
from #4150 were dead code (nothing calls `terrainColor`; GL terrain
comes from `ColorUtils.encodeTerrainTile`); wiring CVD-safe terrain into
the terrain texture would be a follow-up.

## Please complete the following:

- [x] I have added screenshots for all UI updates — N/A, no UI changes
(verified color-identical)
- [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-visible text
- [x] I have added relevant tests to the test directory —
`tests/Colors.test.ts` updated for the new pipeline (team colors from
theme JSON, colorblind palette/border tests)

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

evanpelle

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:50:50 -07:00
bijx fe0b79ef21 Feat: Favourite maps tab (#4207)
Resolves #4202 

## Description:

As suggested in some suggestions in the main OF server
[[thread](https://discord.com/channels/1284581928254701718/1472496670267805782)],
we should have a map favouriting system since there are over 70 maps
already. People (myself included) have some maps we constantly play
during solo/private matches, so a favourite tab would be huge.

This feature adds the favourites tab to the solo and private match
selection screens. It works using localStorage for saving (device
persistence) but I can just as easily implement an infra update where
players have a 1-many relation with a `FavouriteMaps` table. That can be
a future solution. Video example right now:


https://github.com/user-attachments/assets/e8e278ab-d305-499a-81a9-d570e05db051


## Please complete the following:

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

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

bijx
2026-06-10 13:51:37 -07:00
Aotumuri dda47b0813 Make clan tag warning clickable (#4163)
> **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 #4154

## Description:

Adds a join path from reserved clan tag warnings to the clan detail
modal.


https://github.com/user-attachments/assets/cc0f4cb8-be8e-414a-8147-7a744069999e


## 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-06-10 13:42:22 -07:00
Blake Girardet 90e4dd0677 Fixes malformed flag svg url in playerRow (#4203)
Resolves #4194 

## Description:

Fixes the malformed flag svg link when viewing the player row component.

This has been tested by temporarily registering a route to the game-info
modal locally and confirming the flag svg now loads.

Local before

<img width="698" height="500" alt="image"
src="https://github.com/user-attachments/assets/a5bd0958-e4f2-4ab6-9203-b49e42a34ca7"
/>

---
Local after

<img width="770" height="573" alt="Screenshot 2026-06-09 at 6 56 17 PM"
src="https://github.com/user-attachments/assets/ffc64c50-f0d9-4c22-9325-34924b68c985"
/>

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

Caidora
2026-06-09 18:39:19 -07:00
noahschmal 2c8a66625c Feature/Move theme system from core to client-side ThemeProvider (#4108)
**Add approved & assigned issue number here:** 

Resolves #2549

## Description:

Themes are purely for the client's rendering, and the server doesn't
need context on them. This PR moves `Theme.ts` from
`src/core/configuration` to `src/client/theme` and moves affiliation
colors to `render-settings.json`.

This is to support the ability to add additional themes more quickly,
such as colorblind-friendly themes. No visible changes occur from this
refactor.

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

jetaviz

---------

Co-authored-by: Josh Harris <josh@wickedsick.com>
2026-06-02 09:32:08 +00:00
Evan 712b2bc473 Show bonus amount on currency packs (#3907)
Show bonus amount on currency packs

- Add `bonusAmount` field to `PackSchema` (non-negative int)
- Render a rotated green corner ribbon (`+X FREE!`) on pack tiles when
`bonusAmount > 0`
- Add `cosmetics.free` translation key with `numFree` param

<img width="720" height="359" alt="Screenshot 2026-05-12 at 7 40 12 AM"
src="https://github.com/user-attachments/assets/3dd70fc4-c922-47f4-aee6-055047b58563"
/>

Describe the PR.

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

regression is found:

evan
2026-05-31 15:09:36 +01:00
evanpelle 450f2944c9 don't show clan tab on crazy games 2026-05-31 15:09:08 +01:00
Evan aa3959bffe feat: territory png based skins (#4006)
## Description:

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

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

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

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

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

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

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

## Please complete the following:

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

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

evan
2026-05-27 13:00:07 -07:00
evanpelle 0e9ff93a84 Add team-grouping hint to friends panel
Surface why a player would want to add friends: a small info
card at the top of the panel noting that friends are placed on
the same team.
2026-05-23 21:07:06 +01:00
evanpelle 8f982ce123 Extend friend grouping to the lobby team preview
The preview was calling assignTeams without friend data, so the
team layout shown in the lobby could differ from the layout the
game actually started with. Wire friends through ClientInfo so
the preview matches.

Extract the publicId→clientID translation used by both start()
and gameInfo() into buildFriendsLookup() to remove the duplicate.
2026-05-23 20:52:13 +01:00
Evan fd6cd762e6 feat: friends panel (#3990)
## Description:

# Add Friends tab to Account modal

## Summary

- Adds a "Friends" tab to the Account modal, alongside Account / Stats /
Games.
- New `<friends-list>` Lit component covering the full friend lifecycle:
send request, accept / deny incoming, withdraw outgoing, remove friend,
paginated list with "Load more".
- New `FriendsApi.ts` wrapping `GET/POST/DELETE /friends*` endpoints
with typed error codes (`not_found` / `conflict` / `bad_request` /
`request_failed`).
- Zod schemas for the friend API responses in `core/ApiSchemas.ts`.
- Translations under a new `friends.*` block in `en.json`.

Friends and pending requests are displayed by public ID via
`copy-button`, matching the existing clan convention.


## Please complete the following:

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

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

evan
2026-05-23 16:16:16 +01:00
Ryan a14cf0edc1 Clan Game History (#3988)
## Description:

Adds 
<img width="1046" height="901" alt="image"
src="https://github.com/user-attachments/assets/930b0d27-4707-4836-b068-620346e7e3a7"
/>

continuation of infra https://github.com/openfrontio/infra/pull/345
## Please complete the following:

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

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

w.o.n
2026-05-22 22:30:16 +01:00
Evan ca565eaa1a Subscription upgrade/downgrade + tier management (#3927)
## Summary

- Tier upgrade/downgrade in the Store. The Subscriptions tab now shows
all tiers including the user's current one. Other tiers swap "Subscribe"
→ "Switch" when the user already has a sub, and clicking them calls the
new `POST /subscriptions/@me/change-tier` endpoint with a
direction-aware confirm (upgrade charges prorated diff now, downgrade
gives account credit).
- Owned-tier card renders a **Current Plan** badge in place of the
purchase button. Resolution logic in `resolveCosmetics` now reads
`userMeResponse.player.subscription.tier` (with flare fallback) and
marks that tier as `owned`.
- AccountModal's `<subscription-panel>` reworked into a proper
two-column layout:
- **Left**: tier name, `$X.XX/mo` price, description, daily Pu/Caps
amounts.
- **Right**: status badge (Active / Renews date / Cancels date),
`[Manage] [Change Tier]` button row, `[Cancel]` centered underneath.
When `cancelAtPeriodEnd === true`, the row collapses to a single
`[Reactivate]` button (opens the Stripe portal).
- New `<o-button size="xs">` variant (`py-2 px-3 text-xs`) for the
compact panel buttons.
- Store dollar-purchase price label now supports an optional suffix
(`/mo` for subs only) via a `priceSuffix` prop plumbed through
`CosmeticContainer` → `PurchaseButton`.
- `Api.ts` gains `changeSubscriptionTier(tierName)` with the same
401-handling pattern as the existing subscription helpers.


<img width="1114" height="728" alt="Screenshot 2026-05-14 at 7 09 20 PM"
src="https://github.com/user-attachments/assets/688f83d5-4010-4580-9214-6885af8ec98e"
/>

<img width="1038" height="276" alt="Screenshot 2026-05-14 at 7 09 33 PM"
src="https://github.com/user-attachments/assets/458197f5-a0d4-4c32-bc55-31e5679629b5"
/>

<img width="887" height="286" alt="Screenshot 2026-05-14 at 7 09 55 PM"
src="https://github.com/user-attachments/assets/8149ed82-89cc-4bbe-83de-3614f886b331"
/>

## Discord

evan
2026-05-15 12:01:31 -07:00
Aotumuri 4250320c9c Fix GitHub translation key category (#3926)
## Description:

The GitHub translation key was incorrectly categorized under news even
though it is used on the main page.
This changes its category to main.

## 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
- [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
2026-05-14 19:27:01 -07:00
Evan bcc453e8cf Add modal URL router (#modal=name&tab=key) (#3924)
## Description

Adds a modal URL router so modals can be opened, deep-linked, and
bookmarked via the hash. URLs of the form `#modal=<name>&tab=<key>&...`
open the named modal and pass remaining keys as args to `onOpen`. The
reverse direction also syncs: opening a modal via the UI updates the
URL, closing it clears the hash, and switching tabs updates `&tab=`.

Builds on the BaseModal refactor from #3923.

### What's new

**`ModalRouter.ts`** — small registry + two-way sync helper.
- `register(name, { tag, pageId? })` declares a modal as router-managed
- `routeFromHash()` parses `#modal=...` and dispatches to
`modal.open(args)`
- `syncOpened/syncClosed/syncTab` push state back into the URL via
`history.replaceState` (no history entries)
- A `routingFromUrl` flag prevents URL→modal→URL feedback loops
- Unknown modal names silently strip the hash

**`BaseModal`** — opt-in URL sync via a `routerName` property.
- When set, BaseModal calls into
`modalRouter.syncOpened/syncClosed/syncTab` from `open` / `close` /
`setActiveTab`
- Modals that own their own URL state (lobby modals) just leave
`routerName` undefined

**`Main.ts`** — registers all routable modals and wires the router.
- `handleUrl()`: adds a `modalRouter.routeFromHash()` branch after the
path-based lobby join
- `onHashUpdate`: when the hash is router-managed, routes via the router
instead of tearing down lobby state

### Routable modals

13 inline modals: store, settings, leaderboard, clan, account, help,
news, language, single-player, ranked, troubleshooting,
territory-patterns, flag-input.

Excluded by design: join-lobby, host-lobby (own URL state via
`/game/<id>`), matchmaking (no URL state).

### Example uses

- Deep link to store flags tab: `/#modal=store&tab=flags`
- Settings keybinds tab: `/#modal=settings&tab=keybinds`
- Cosmetics.ts now redirects to `#modal=store&tab=packs` when a
hard-currency purchase fails for insufficient plutonium (after the
alert), so users can top up directly

### URL behavior

- `replaceState` everywhere — no history entries added when modals open
/ close / switch tabs
- Browser back/forward still works for the existing path-based game flow
- `hashchange` events are router-aware so external hash changes (back
button, manual edit) correctly switch between routed modals without
tearing down lobby state

## Please complete the following:

- [x] I have added screenshots for all UI updates _(no visual changes;
smoke-tested in dev)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file _(no new user-visible strings)_
- [ ] I have added relevant tests to the test directory _(no test
coverage; manually tested URL load, UI open, tab switch, close,
hashchange, insufficient-plutonium redirect)_
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

DISCORD_USERNAME
2026-05-14 16:49:44 -07:00
Evan bbe727cc84 Refactor modal system: BaseModal renders shell, unified open(args) API (#3923)
## Description

Refactors the modal system so that `BaseModal` owns the `<o-modal>`
shell rendering, tab state, and lifecycle. Modal subclasses now provide
content via small hook methods (`renderHeaderSlot()`, `renderBody(tab)`,
`modalConfig()`) instead of each rebuilding the `<o-modal>` template and
inline-mode branching.

This sets up the foundation for a future modal URL router (e.g.
`#modal=store&tab=flags`), which will be a follow-up PR.

### What changed

**`BaseModal`** — `src/client/components/BaseModal.ts`
- Now renders the `<o-modal>` shell itself; subclasses no longer
duplicate it
- Owns `activeTab` state and dispatches per-tab rendering via
`renderBody(tab)`
- Single `modalConfig()` method returns `{ title?, tabs?, hideHeader?,
hideCloseButton?, alwaysMaximized?, maxWidth? }`
- Uniform `open(args?)` / `close(args?)` interface; subclasses interpret
args in `onOpen(args)` / `onClose(args)`
- Tabbed modals can lazy-load via `onTabEnter(tab)` lifecycle hook
- Re-entrancy guard on `open()` so `showPage()` re-invocations don't
clobber state set by the outer call
- Initial tab defaults to first entry in `modalConfig().tabs` so the
active tab is highlighted on first open

**17 modals migrated** to the new shape:
- Tabbed: Store, UserSetting, Leaderboard, Clan
- Non-tabbed: FlagInput, Account, TokenLogin, News, TerritoryPatterns,
Troubleshooting, SinglePlayer, Matchmaking, RankedModal, Help, Language
- Lobby: JoinLobbyModal, HostLobbyModal (kept their `confirmBeforeClose`
/ `closeAndLeave` / `closeWithoutLeaving` methods)

Per-modal diffs are mostly mechanical:
- Drop the `<o-modal>` wrapper template and the `if (this.inline) return
content` branch
- Drop the inner `<div class="${this.modalContainerClass}">` wrapper
(shell styling now lives on `<o-modal>`)
- Move header content into `renderHeaderSlot()` so it lives in the
sticky header area
- Convert `super.open()`/`super.close()` overrides into
`onOpen(args)`/`onClose(args)` hooks
- For tabbed modals: drop subclass `@state activeTab`, manual
`handleTabChange`, and the `render()` switch — all owned by BaseModal
now

**Other changes:**
- `Store`: in affiliate mode (`#affiliate=X`), tabs are hidden and a
single combined grid of purchasable affiliate items is shown
- `Main.ts`: `joinModal.open(lobbyId, lobbyInfo)` callsites converted to
the new `open({ lobbyId, lobbyInfo })` shape

### Follow-up

Modal URL router (`#modal=X&tab=Y&...`) is a separate PR on top of this
foundation.

## Please complete the following:

- [x] I have added screenshots for all UI updates _(no visual changes;
smoke-tested in dev)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file _(no new user-visible strings)_
- [ ] I have added relevant tests to the test directory _(no test
coverage; tested in browser)_
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

evan
2026-05-14 15:33:41 -07:00
Evan e0f73598d6 Add subscriptions: store tab, account panel, manage/cancel (#3918)
## Summary

- Add a **Subscriptions** tab to the Store. Each tier renders as a
`<cosmetic-button>` with description, daily Pu/Caps amounts, and a
Stripe checkout button driven by the existing `createCheckoutSession`
flow.
- Show the player's active subscription in the **Account modal** via a
new `<subscription-panel>` Lit component (status badge, period-end /
cancel-at-period-end, daily currency breakdown).
- **Manage** button opens the Stripe billing portal in a new tab (`POST
/subscriptions/@me/portal`).
- **Cancel** button (hidden once `cancelAtPeriodEnd === true`) calls
`POST /subscriptions/@me/cancel` after a `confirm()` prompt, then
invalidates the userMe cache and refetches.
- Block re-purchase: clicking Subscribe when the user already has a
`subscription:*` flare alerts "Already subscribed" before opening
checkout (upgrade/downgrade flows are out of scope for now).
- Schema additions:
- `CosmeticsSchema.subscriptions: Record<string, SubscriptionSchema>`
(optional) in `src/core/CosmeticSchemas.ts`.
- `UserMeResponse.player.subscription: { tier, status, currentPeriodEnd,
cancelAtPeriodEnd } | null` in `src/core/ApiSchemas.ts`.
- Translations: new `store.*` and `account_modal.sub_*` keys in
`resources/lang/en.json` (English only — Crowdin handles the rest).
- 
<img width="942" height="313" alt="Screenshot 2026-05-14 at 1 13 05 PM"
src="https://github.com/user-attachments/assets/3d28df13-9e03-49f0-bee8-a25f9ad0c420"
/>
<img width="545" height="439" alt="Screenshot 2026-05-14 at 1 13 32 PM"
src="https://github.com/user-attachments/assets/b413b275-d6f2-40dc-9230-d68cd11fb07a"
/>

## Discord

evanpelle
2026-05-14 13:47:16 -07:00
Evan 275fd0dccc refactor: collapse per-env Configs into ClientEnv + ServerEnv (#3906)
## Description:

This is a refactor to simplify config handling.

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

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

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

## Please complete the following:

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

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

evan
2026-05-11 19:24:01 -07:00
Ryan 005e1b6044 clan stats breakdown (#3869)
## Description:

improvements to clan ui. 
<img width="788" height="290" alt="image"
src="https://github.com/user-attachments/assets/736ca147-bff4-44d8-8180-7b80a85556fe"
/>
added "expand all" and new collapsible sections.


<img width="787" height="550" alt="image"
src="https://github.com/user-attachments/assets/deb2f813-854b-46a9-a767-52c4f749f30f"
/>

which changes to collapse all when expanded

also adds more info about team (d,t,q,2,3,4,5,6,7 team)

## Please complete the following:

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

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

w.o.n
2026-05-06 16:09:53 -06:00
Ryan 9432bb26f8 [bugfix] fixes border around clans ui (#3873)
## Description:

fixes border around clans ui
<img width="67" height="705" alt="image"
src="https://github.com/user-attachments/assets/5ee35eb5-b406-4403-b9b4-324769faf061"
/>


also fixes weird padding:
<img width="134" height="244" alt="image"
src="https://github.com/user-attachments/assets/32a84074-afa6-4e9a-98f1-e45aabe4aa2a"
/>

what it should be:
<img width="140" height="206" alt="image"
src="https://github.com/user-attachments/assets/b72b480e-c972-4495-b9da-5c3b411bf590"
/>

## Please complete the following:

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

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

w.o.n
2026-05-06 16:06:33 -06:00