mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:20:18 +00:00
d07f7d46ddf7b00c4fb2c27fee0053eff98c35e8
2271
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
8e5f27949f |
feat: marketing email consent UI (post-login prompt + account settings) (#4554)
Resolves #4029 ## Description: Client-side marketing-email consent capture — the client half of #4029. It consumes the already-merged API (`GET /users/@me` marketing-consent state + `POST /marketing/consent`); **no backend changes**. **Three surfaces:** - **Post-login prompt** — a small, non-blocking toast docked top-right, shown once after login when the player's consent is undecided (`no_response`) and a verified email is on file. Yes / No thanks / dismiss all record a decision, and it never re-nags. - **Account → "Account Settings" tab** — a persistent on/off toggle to change or withdraw consent at any time (the GDPR change/withdraw path). - **No-email state** — when the account has no verified email, the tab offers to bind one (magic link to a plain email — the backend's `new-association` path — or link Google) so the player can subscribe. Styling matches the game's existing panels (`bg-surface`, `border-white/10`, `shadow-[var(--shadow-malibu-blue)]`, malibu-blue accent), reusing `o-button` and the account modal's existing email field/handlers. **Changes:** - New `<marketing-consent-toast>` (`src/client/MarketingConsentToast.ts`), mounted in `index.html`, registered in `Main.ts`. - `AccountModal.ts`: new "Account Settings" tab (toggle + bind-email state), optimistic with revert-on-failure. - `Api.ts`: `setMarketingConsent()` → `POST /marketing/consent`, invalidates cached `/users/@me`. - `ApiSchemas.ts`: optional `player.marketingConsent { consented, hasEmail }` on `UserMeResponse`. - `en.json`: `marketing_consent.*` + `account_modal.marketing_*` / `tab_settings`. ## Please complete the following: - [x] I have added screenshots for all UI updates <!-- attaching in a follow-up comment --> - [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: Iamlewis --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
921892e941 |
"stats" not "ranking" (#4560)
## Description: people confused what "ranking" means on the games stats modal <img width="606" height="255" alt="image" src="https://github.com/user-attachments/assets/ac695d74-c5b2-4940-b3af-abe3196d2f00" /> also sorta helps with the overflow before: <img width="350" height="334" alt="image" src="https://github.com/user-attachments/assets/8a788b79-2d76-4914-b14f-48838494fae5" /> after: <img width="357" height="332" alt="image" src="https://github.com/user-attachments/assets/164515c7-8b22-45c9-bd11-b5b47d4f17ed" /> ## 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 |
||
|
|
70d301fbb9 |
custom amount ui prettier (#4556)
## Description: fixed font, sizing, back box wrapping, remove "buy for" centre the text by removing the ^ v arrows before: <img width="939" height="491" alt="image" src="https://github.com/user-attachments/assets/3c747341-5238-47e8-b455-ea5827669e75" /> after: <img width="936" height="746" alt="image" src="https://github.com/user-attachments/assets/4b64f34b-98e4-4519-85bd-1f5c2799babd" /> ## 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 |
||
|
|
274b516ea3 |
feat(crazygames): backend login + surface the signed-in user (#4542)
Client-side CrazyGames login: exchange the SDK user token for our session, then surface that identity in the UI. Implements the client side of the [CrazyGames login handoff guide](https://docs.crazygames.com/sdk/user/). ## Part 1 — Backend login (token exchange) On CrazyGames we exchange the SDK's user token for our own session via `POST /auth/crazygames`, instead of the cookie-based `/auth/refresh` (the refresh cookie is `SameSite=Lax` and unusable from the CrazyGames iframe). - **`CrazyGamesSDK.ts`** — `getUserToken()` wrapper (awaits `ready()`, gates on `isUserAccountAvailable`, returns `null` on throw / no signed-in user). - **`Auth.ts`** — `doRefreshJwt()` routes to `doCrazyGamesLogin()` when on CrazyGames with a signed-in account; a `null` token (guest / no account) falls through to the existing `/auth/refresh` flow. `reauthAfterCrazyGamesChange()` drops the cached session on a mid-session sign-in. - **`Main.ts`** — `addAuthListener` re-runs auth + `getUserMe()` when the player signs into CrazyGames mid-session. Everything funnels through the existing `userAuth()` → `refreshJwt()` path, so **startup login and the 15-min re-exchange on expiry come for free** — no new expiry/polling code. ## Part 2 — Surface the signed-in user - **Account button** shows the CrazyGames avatar + username when signed in (clicking opens the account modal), or a **"Sign in"** that opens CrazyGames' own `showAuthPrompt()` when a guest. Wired across every entry point: the desktop nav pill, the mobile hamburger item (un-hidden on CrazyGames by dropping `.no-crazygames`), and — since CrazyGames renders below the `lg` breakpoint where the desktop nav is hidden — a new button in the **homepage top bar's** right slot. - **AccountModal** treats the CrazyGames user as logged-in: "Connected as" avatar + username, currency/subscription, and stats/games/friends. **No Discord/Google/email login+link buttons and no logout** (CrazyGames owns the account). A guest who reaches the modal gets a CrazyGames sign-in button, never Discord/Google. - **`CrazyGamesSDK.ts`** — `getUserProfile()` + `showAuthPrompt()` wrappers; `profilePictureUrl` added to the user type. Identity comes from the SDK (`getUser()`), not `/users/@me`, which doesn't surface CrazyGames identity yet. ## Behavior - **Signed-in CrazyGames account** → real backend session; avatar + username in the top bar; CrazyGames-only account modal. - **CrazyGames guest / not signed in** → silent fallback to guest; "Sign in" button opens `showAuthPrompt()`; auto-logged-in the moment they sign in (via `addAuthListener`). - **Not on CrazyGames** → completely unchanged. ## Testing - `npx tsc --noEmit` — clean - `npx eslint` on changed files — clean - No new i18n keys (reuses `main.sign_in`, `account_modal.*`). - No unit tests: this repo tests core sim only, and CrazyGames only initializes inside a crazygames.com iframe, so the CG paths can't run locally (the localhost SDK mock returns unsigned tokens the backend rejects). **Needs a manual pass on the CrazyGames game page (gameId `64178`)** covering: signed-in avatar/username + account modal, guest "Sign in" → prompt, and mid-session sign-in. ## Assumptions to confirm (backend) 1. The `/auth/crazygames` JWT carries the same `iss` (`getApiBase()`) and `aud` (`getAudience()`) as normal openfront.io tokens and satisfies `TokenPayloadSchema` (incl. base64url `sub`) — otherwise `userAuth()` will `logOut()`. 2. CrazyGames iframes our own origin (so `getApiBase()` → `https://api.openfront.io`), consistent with the existing integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f5ef306d99 |
feat(client): highlight small players with a pulsing glow (#4525)
## Description: Adds a **"Highlight small players"** client setting (in the in-game Settings menu). When it's on, human players holding **0.2% or less of the map** get a soft red glow that breathes (a pulsing aura) around their territory, starting **one minute into the game**. This makes near-eliminated players easy to spot on a busy map, even when their territory is scattered in fragments or sitting under structures. How it works: - **Purely client-side**, no simulation or determinism impact. `SmallPlayerGlowPass` renders a tile-space bloom: extract a sub-tile mask of the small players' tiles, run a separable Gaussian blur, then composite the soft aura over the map additively. It's camera-independent (no shimmer when panning/zooming), so scattered tiles blur into one clean halo. The aura breathes: its intensity fades fully to 0 at the trough for clear contrast. - The set is recomputed each tick in `WebGLFrameBuilder`: alive human players with `tilesOwned / (landTiles - fallout) <= 0.002` (0.2%), suppressed during the spawn phase and the first minute. - Backed by a persisted `UserSettings` flag, toggleable live from the in-game Settings modal (with a new, distinct icon). - Drawn after the structure passes so buildings can't hide it. - Mirrors the existing FalloutBloom pipeline and reuses the shared blur shader and render-target helpers rather than duplicating them. Tunable via `render-settings.json` (`smallPlayerGlow`: color / alpha / pulseSpeed). UI (glow fades in and out): <img width="474" height="334" alt="image" src="https://github.com/user-attachments/assets/2b94f292-2cbb-43d3-82fb-f274d1afdedf" /> <img width="976" height="400" alt="image" src="https://github.com/user-attachments/assets/f1b972d7-e046-4f7a-ab43-da1eb758c7b5" /> ## 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._ |
||
|
|
050c7604e8 |
feat(alliances): custom alliance duration lobby control (#4522)
## Description:
Replaces the "Disable alliances" toggle in the host and single-player
lobbies with a "Custom alliances" control: a toggle plus a minutes input
(0 to 15, step 1).
- 0 minutes disables alliances, same behavior as the old toggle.
- 1 to 15 sets the alliance duration in minutes.
How it works:
- Adds one game-config field, `customAllianceDuration` (minutes).
- `Config.allianceDuration()` uses it when set (and falls back to the
existing 5 minute default), and `Config.disableAlliances()` returns true
when it is 0.
- The legacy `disableAlliances` boolean is still read, so older/archived
configs keep working.
- Validation reuses the existing `parseBoundedIntegerFromInput` and
`toggle-input-card` helpers, so it behaves like the other numeric lobby
options (spawn immunity, max timer).
- The join-lobby screen shows "Alliances: {x}m", or "Alliances:
Disabled" at 0.
<img width="279" height="145" alt="image"
src="https://github.com/user-attachments/assets/5a608e18-3811-4eef-a3a6-9344aaf667fe"
/>
<img width="270" height="152" alt="image"
src="https://github.com/user-attachments/assets/9d8a4d30-51e7-4e82-8ce0-121b12af1c61"
/>
<img width="270" height="144" alt="image"
src="https://github.com/user-attachments/assets/a65b20fb-0db5-4657-a964-880fad7c864e"
/>
## 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._
|
||
|
|
d3ad1f51bd |
fix(crazygames): guest username on logout, hide fullscreen, in-game pop-ups (#4538)
Addresses four requests from the CrazyGames platform team. Supersedes #4520 (closed when its branch was renamed to `crazygames`). ## 1. Username reverts to guest on CrazyGames logout When a player logged into their CrazyGames account and then logged out, the username stayed the CrazyGames name. It now reverts to a fresh guest (`AnonXXX`) name. A `crazyGamesLoggedIn` flag guards this so it only fires on a real login→logout transition (not on the initial "not logged in" state, which would otherwise wipe a stored name). ## 2. Fullscreen button hidden on CrazyGames CrazyGames provides its own fullscreen control in the game frame, so our in-game fullscreen button is now hidden when running on CrazyGames (and unchanged everywhere else). ## 3. Native browser prompts → in-game pop-ups The `showInGameConfirm()` / `showInGameAlert()` helpers (promise-based, callable from non-Lit contexts like WebSocket/popstate handlers) drive the existing shared `<confirm-dialog>` component, so styling stays consistent with the rest of the app. Every native `confirm()`/`alert()` shown **during gameplay** now uses it: - Exit-game confirmation (right sidebar + browser-back path) - Kick-player confirmation - Host-left notice (dispatches leave-lobby only after dismiss, preserving the old blocking UX) - Connection-refused notice (also removes a stale `// TODO: make this a modal`) ## 4. `gameplayStop` when Settings menu / pop-up is open - The pop-up helpers report `gameplayStop()` while shown and `gameplayStart()` on dismiss. - Settings + Graphics Settings modals now report `gameplayStop` whenever the menu is open — previously it only fired when the modal *also* paused the sim (singleplayer / lobby-creator), so regular multiplayer players never triggered it. Also fixes graphics-settings so gameplay resumes correctly on close for those players. ## Scope Per request, this covers **in-game dialogs only**. The main-menu native alerts (store/checkout, account, subscriptions, friends, login) were intentionally left as-is. ## Testing - `tsc --noEmit`, `eslint`, and `prettier --check` all pass. - Verified the confirm and alert pop-ups render correctly in the running app (shared `<confirm-dialog>`, danger/warning variants), resolve their promises, and tear down their portal. - CrazyGames-iframe-only behaviors (username revert, `gameplayStop`, fullscreen hiding) are verified by code inspection — they require running inside the actual CrazyGames frame. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4ce57efbe2 |
Rebalance Doomsday Clock: late-game stalemate-breaker (10min grace + wave squeeze), slower troop drain, gentler-but-steeper warship attrition (#4518)
Resolves #<add your approved & assigned issue number> ## Description: Rebalances the Doomsday Clock so it acts as a **late-game stalemate-breaker** rather than an early-game culler, softens how fast it removes troops and warships, and makes the HUD countdown clearer. **Clock schedule (all presets):** - A flat **10-minute grace** at 0% required share — the early game is decided by combat, not the clock. - Then a 6-wave squeeze at accelerating levels (4 / 9 / 16 / 26 / 40 / 55%) with short pauses, reaching the final 55% at each preset's cap: **45 / 35 / 25 / 15 min** for slow / normal / fast / veryfast. - `WaveSchedule` `rampSeconds`/`pauseSeconds` are now **per-wave arrays**, so the curve can be shaped (gentle early, steeper late) instead of one uniform ramp. `requiredBasisPoints` and the HUD companion `doomsdayClockWaveState` walk the per-wave segments in lockstep. **Troop drain:** warn window `10s → 30s`, drain eased (`2%→5%` over `90s`), so a caught side takes ~2 minutes to wipe instead of ~1. **Warship attrition:** warships get their own gentler start plus a **convex** decay curve — a ship caught when its side is first doomed lasts about as long as troops, but the rate ramps up steeply so a side at full attrition still loses its fleet in ~2s. Adds a `curveExponent` argument to `doomsdayClockDrain` (1 = linear, used for troops; higher = convex, used for ships). **Determinism:** the drain curve is **integer-only** (fixed-point power, no floats), so the floored per-tick loss is bit-identical on every client in the lockstep sim. The linear troop path keeps its exact existing integer form; only the convex warship path is reshaped. **HUD countdown clarity:** the clock readout now shows a live countdown in both states — `Will reach 16% in M:SS` while the bar is actively climbing, and `Starts rising to 26% in M:SS` during a pause (previously `Next 26% in …`, which read as if it jumped there instantly). Backed by a new `secondsToTarget` field on the shared wave state so the sim and HUD stay in agreement. All display text goes through `translateText()` / `en.json`. <img width="278" height="116" alt="image" src="https://github.com/user-attachments/assets/e0be3d2c-bb88-46be-b344-34d63e4859bd" /> <img width="304" height="171" alt="image" src="https://github.com/user-attachments/assets/da74bd05-0b9a-4aec-bbf5-e7380fbda88e" /> **Danger skull:** while a side is below the bar in the warn window, its on-map skull now blinks progressively faster as the countdown runs out (accelerating to the moment the drain begins), then holds steady once it is actually draining — a clearer "you are about to be hit" cue. Rationale: the previous schedule removed players heavily in the first half of a match and could leave a drawn-out endgame. Holding the clock at 0% early keeps the opening about fighting, and concentrating its pressure in the back half reserves it for actually breaking stalemates. Values are tuning starting points and easy to adjust in `DoomsdayClock.ts` / the config defaults. ## 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: <add your Discord username> |
||
|
|
0c83444dc9 |
feat(client): custom plutonium amount purchase in store (#4515)
## Summary Adds a **custom plutonium amount** purchase option to the Store, wiring the client to the new `POST /stripe/create-custom-currency-checkout` endpoint (infra [PR #400](https://github.com/openfrontio/infra/pull/400)). Players can buy any integer **20–2000** plutonium, priced inline server-side at **20 plutonium = \$1.00**. A new `<custom-currency-card>` renders as the **last tile in the Store "Packs" tab** — a slider + number input with a live price and a "Buy for \$X.XX" button that redirects to Stripe Checkout. ## Changes | File | Change | |---|---| | `src/client/Api.ts` | New `createCustomCurrencyCheckout(hardAmount)` — POSTs `{ hardAmount, hostname }`, returns the Stripe `url` (mirrors `createCheckoutSession`). | | `src/client/components/CustomCurrencyCard.ts` | New `<custom-currency-card>` component: plutonium slider + number input, live price, clamps to 20–2000 integer client-side, redirects to Stripe on buy. | | `src/client/Store.ts` | Renders the card after the fixed packs in the "Packs" tab; drops the now-unreachable "no packs" empty state. | | `src/client/Main.ts` | Handles `type=custom_currency` in the `#purchase-completed` redirect (success alert + strip hash; balance refetches from `/users/@me` on load). | | `src/client/LangSelector.ts` | Registers `custom-currency-card` for translation-reload re-render. | | `resources/lang/en.json` | Adds `store.buy_for`, `store.custom_amount`, `store.custom_currency_purchase_success`; removes the orphaned `store.no_packs`. | ## Notes - **No catalog product** — unlike currency packs, the custom amount is priced inline server-side, so the card is standalone (not a `resolveCosmetics` item). - **Async credit** — no optimistic balance bump; the balance reflects the Stripe webhook via `/users/@me` on return, matching the currency-pack flow. - Server is authoritative on bounds/rate; client-side clamping is UX-only. ## Testing - Full suite green: **1842 tests / 153 files**; `tsc --noEmit` and eslint clean. - Manually verified in the running app (headless Chromium): card renders in the Packs tab; amount/price stay in sync across slider + number field; clamping confirmed (5000→2000/\$100.00, 5→20/\$1.00, 750→\$37.50 — matches the pricing table). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
46b9fe88bb |
fix: survive host modal close during listed-lobby auto-start (#4514)
Follow-up to the listed-lobby auto-start feature (`2a82b01e3`, on main). ## Bug When a listed lobby auto-started (and in some paths on manual start), the game began server-side but a player got ejected back to the menu before it loaded. Root cause: `BaseModal.close()` navigates via `showPage()`, which **force-closes whichever page-modal is currently visible**. During the game-start transition that is the *other* lobby modal — closing the join modal first cascade-closed the host's modal with `leaveLobbyOnClose` still armed (host disconnected → host-left teardown killed the game); reversing the order just moved the victim to the joiner. No close order fixes both sides. ## Fix - **Disarm both lobby modals before closing either** in the prestart transition (`disarmLeaveOnClose()` on `HostLobbyModal` and `JoinLobbyModal`), so no cascade order can disconnect anyone mid game-start. - Both modals re-arm `leaveLobbyOnClose` in `onOpen` instead of `onClose`'s state reset, so once disarmed no later cascade close can re-arm it until the modal is reopened. - `HostLobbyModal.closeWithoutLeaving()` (pattern `JoinLobbyModal` already used), called explicitly instead of the generic modal-close loop. - **Server hardening:** `handleClientDisconnect` bails on `hasStarted()` (which includes prestart) instead of raw `_hasStarted`, so host socket churn during the lobby → game transition can never tear down a starting game regardless of client behavior. Regression test included. ## Verification Headless end-to-end (two browser contexts, host + joiner, `leave-lobby` stack-trace tripwire armed on both pages): - **Manual start:** host and joiner both receive prestart/start and enter the game; zero `leave-lobby` dispatches. - **Auto-start:** same, with nobody interacting after listing. Also confirmed manually in a real browser. The auto-start deadline was temporarily 30s during testing and is restored to 5 minutes in the final commit. Full suite green (1843 + 161), tsc/eslint/prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2a82b01e30 |
feat: auto-start listed lobbies after 5 minutes
Hosts can't sit on a public listing: setListed(true) records listedAt, and once listedAt + HOSTED_LOBBY_AUTO_START_MS passes, GameManager's tick arms the normal start countdown (same path as the host's Start button, honoring startDelay). Cancelling the countdown re-arms on the next tick; unlisting cancels the deadline and relisting starts a fresh one. Duplicate setListed(true) calls don't extend it. The deadline rides to the host as autoStartAt in lobby info, rendered as an amber countdown next to the Public/Private switch in the host modal header (hidden once the real start countdown takes over). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5d21be826d |
feat: subscriber-hosted public lobby listing (#4480)
Part of #4040 (v1 scope: listing + browser + per-subscriber limit; custom lobby name/description left for a follow-up). ## What Subscribers can toggle their **private lobby** to be **publicly listed**; a browsable **"Open Lobbies"** list appears in the Join Lobby modal. Hard limit of **one listed lobby per subscriber**, enforced cluster-wide. ## How **Semantics** — a listed lobby stays `GameType.Private`: the host keeps full control and starts the game manually; the toggle only controls visibility. The `listed` flag lives on `GameServer` (not `GameConfig`), so it cannot be smuggled in through `update_game_config` and never touches core/sim/records. **Distribution** — reuses the existing public-lobby pipeline end to end: a new `"hosted"` `PublicGameType` bucket flows worker → master IPC → `/lobbies` websocket → `PublicLobbySocket`. Master scheduling now iterates only `SCHEDULED_PUBLIC_GAME_TYPES` (`ffa`/`team`/`special`), so it never sets countdowns on or schedules replacements for hosted lobbies. Lobbies delist automatically when the game starts/fills/dies (phase change). The broadcast fingerprint now includes browser-visible config, so host edits (map/mode) refresh the list even though the gameID doesn't change. **Gating** — new authenticated endpoint `POST /api/game/:id/listing`: - creator-only (403), private + not-started only (409) - fresh subscription check via server-side `getUserMe` using the shared `hasActiveSubscription()` helper (`active`/`trialing`); skipped in `GameEnv.Dev` (same precedent as Turnstile) so it's testable locally - one-lobby-per-creator (409): a SHA-256 hash of the creator's persistentID rides worker↔master IPC (`PublicGameInfo.creatorID`); the master dedupes as a race backstop. The hash — and host-only config (whitelist, name reveals) — are **stripped from every client payload** (broadcast + primed snapshot). **Client** — subscriber-gated "List lobby publicly" toggle in the host modal (server rejection reverts the toggle and shows a translated message); "Open Lobbies" rows (map, mode, player count) in the Join Lobby modal that reuse the existing private-join flow. **Compat** — `PublicGames.games` is now a `partialRecord`, so newer clients tolerate servers that don't send every bucket. Note: already-open old clients will fail to parse broadcasts containing the new `hosted` key until refreshed (closed Zod enum) — same class of break as previous wire-schema changes. ## Testing - `tests/server/HostedLobbyListing.test.ts` (15 tests): listed-lobby filtering, flag not settable via config intent, master aggregation + creator dedupe + no scheduling of hosted, creatorID stripping (broadcast + primed snapshot), `creatorHasListedLobby` (broadcast + local), fingerprint refresh on config change - `hasActiveSubscription` cases in `ApiSchemas.test.ts`; hosted counts-delta patch in `LobbySocket.test.ts` - Full suite green (1723 + 141 tests), tsc/eslint/prettier clean - **E2E in the real app** (headless Chromium, two browser contexts): host lists lobby → appears in second browser's Join Lobby list (creatorID absent from payload) → join succeeds (2 players in lobby) → same creator's second lobby rejected 409 with toggle revert → unlist removes it from a fresh browser's list. Curl negatives: missing auth 400, bad token 401, non-creator 403, missing game 404, bad body 400. ## Known follow-ups - Custom lobby name/description in the browser (needs the censor pipeline) — rest of #4040 - A listed lobby whose host closes the tab stays advertised indefinitely (an empty private lobby never leaves the Lobby phase) — pre-existing lifecycle, now more visible; consider delisting on creator disconnect 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22c873cf55 |
perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)
## Problem
Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.
## Harness (`npm run perf:client-tick`)
Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.
Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.
## Baseline (Giant World Map, 400 bots, headless)
Dispatch handler ms — cost **grows with game progression**:
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |
Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.
## Round 1 — algorithmic fixes
- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.
After (same map, same spawn tile):
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |
Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.
## Round 2 — allocation churn + time slicing
Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:
- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.
Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.
## Verification
- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
571f58440d |
perf(client): main-thread memory harness + drop three map-sized render buffers (-23%) (#4511)
## Summary Follow-up to #4507, moving the memory-footprint campaign to the **main thread** (client). Two parts: a headless browser measurement harness, and a first optimization round that cuts the main-thread live heap on Giant World Map by **23%** (166 → 128 MB at tick 2000). ## Part 1 — `npm run perf:client-mem`: headless main-thread memory harness Drives a real singleplayer game in headless Chromium and measures the **page's isolate only** (the core sim worker is a separate CDP target): - Starts its own vite dev server on a private port (default 9017) so it always measures the current checkout. - Double-forced-GC checkpoints every `--window` ticks: JS heap, ArrayBuffer backing-store bytes (`Runtime.getHeapUsage`), DOM nodes, listeners, ticks/s. - `--snapshot-at <ticks>` writes V8 heap snapshots, analyzable with the retainer/summary tools from #4507. - Spoofs the unmasked WebGL renderer string via an init script so the software-GL gate (#4324) admits SwiftShader — no game code touched; rendering still runs software (hence the rAF throttle). - End-of-run screenshot as a rendering sanity check. Baseline (Giant World Map, 400 bots, 12,000 ticks): ~176 MB live, of which ~116 MB is **static per-tile buffers** allocated up front for the 8M-tile map — flat during play, no leaks. ## Part 2 — drop three map-sized render-layer buffer copies | Buffer | Before | After | |---|---|---| | `TrailPass.cpuTrailState` | 15.3 MB copy | **deleted** — dead code; every upload entry point sets the live reference to TrailManager's array | | `RailroadPass.cpuRailroadState` | 15.3 MB across 2 arrays | references `RailroadCache.railroadState` (stable identity, mutated in place) | | `RailroadPass.cpuGhostRailState` | ↑ | sparse `Map<ref, value>`; preview diffs applied as per-texel `texSubImage2D` writes (path-sized work instead of a full 8 MB texture upload per build-preview mouse move) | | `TerrainPass` + `MapRenderer` terrain bytes | 7.6 MB (one buffer, two retainers) | `terrainSource()` provider — re-bakes (theme change, context restore) regenerate from the live game map, which already reflects water-nuke conversions | Tick-2000 snapshot comparison (giant world, 400 bots): **166.4 → 128.4 MB**. ## Verification - `tsc --noEmit`, eslint, full test suite (1924 tests) pass. - 2000-tick headless giant-world game after the change: no GL pageerrors, end-of-run screenshot renders terrain/territory/borders/names correctly, sim speed unchanged (~5 ticks/s headless). - Ghost-rail ops flush before the zoom-fade early-return, so the op queue can't grow while previewing at low zoom. - WebGL context restore recreates all passes fresh and the owner re-uploads state (existing `onContextRestored` path), consistent with the new reference-based buffers. Note: heap snapshots in `tests/perf/output/` are gitignored; the numbers above are from runs recorded in the PR discussion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
eae2be6458 |
feat(store): confirm plutonium and caps purchases before charging (#4510)
## Summary Fixes #4218 Currency purchases (Plutonium and Caps) fired immediately on click with no confirmation. This adds a confirmation modal — reusing the existing `confirm-dialog` Lit component — that gates every currency purchase behind an explicit "Confirm". There were two paths that could trigger a currency purchase, and both are now gated: - **The Plutonium / Caps price buttons** — `PurchaseButton` no longer calls `onPurchaseHard`/`onPurchaseSoft` directly; it opens a `confirm-dialog` ("Buy {item} for {amount} {currency}?", warning variant) and only runs the purchase on confirm. Cancel / backdrop click dismisses. - **Whole-card click** — `CosmeticContainer` auto-fires the purchase when there's exactly one payment option, which bypassed the button entirely for currency-only items. That path now delegates to the purchase button's new `requestCurrencyPurchase()` so it goes through the same dialog. The existing purchase flow (busy guard, loading overlay, insufficient-currency dialog) is unchanged and runs after confirmation. Dollar purchases are untouched (they go through Stripe checkout, which is its own confirmation step). New i18n keys: `store.confirm_purchase_title`, `store.confirm_purchase_body` (en.json only, per Crowdin convention). ## Test plan - [x] ESLint, `tsc --noEmit`, Prettier pass - [ ] Manual check in staging: click a Plutonium or Caps price button → dialog appears with the right currency name and amount; confirm purchases, cancel doesn't - [ ] Manual check: click the card body of a currency-only item → same dialog (not an instant purchase) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
22d5aba5ae |
refactor: standardize cardinal-neighbor iteration on neighbors() N,S,W,E order (#4495)
## Summary Follow-up to #4494. That PR added `forEachNeighborNSWE` as a third neighbor iterator because the existing allocation-free helpers (`forEachNeighbor`, `neighbors4`) visit in W,E,N,S order while `neighbors()` visits N,S,W,E — and substituting one for the other changes simulation behavior at order-sensitive call sites. This PR removes that duplication by standardizing on **one order everywhere**: `forEachNeighbor` and `neighbors4` now visit in the same N,S,W,E order as `neighbors()`, and `forEachNeighborNSWE` is deleted. ## ⚠️ Intentional behavior change Callers of the flipped helpers that are order-sensitive now make different (equally valid) decisions: - `AttackExecution.addNeighbors` — PRNG values are drawn per neighbor while building the conquest frontier, so attack expansion patterns differ - `AttackExecution.handleDeadDefender` — a dead defender's tiles go to the *first-visited* adjacent player - `WarshipExecution.bestNeighborToward` — distance ties break by visit order - `PlayerExecution` surrounded-cluster flood fill — set insertion order propagates to conquer order Game outcomes for a given seed differ from previous builds (verified: the 12k-tick reference run ends with 31 players alive vs 24 before). Determinism across clients *within* a build is unaffected — all clients run the same code, so there is no desync risk. Replays/verification pinned to old hashes will not match this build. New reference hashes for the headless perf harness (seed `perf-default`): | Run | Final hash | |---|---| | giantworldmap, 12,000 ticks | `57830793797434300` | | giantworldmap, 2,000 ticks | `55125379638382860` | | world, 1,800 ticks | `32337437717390864` | ## Verification - [x] Full suite green (1,901 tests), including new exact-order contract tests: `forEachNeighbor` and `neighbors4` must match `neighbors()` contents **and order** for every tile - [x] 20-game-minute Giant World Map benchmark: no perf regression (73 ticks/sec, GC 1.2% of wall, allocation profile unchanged) - [x] Order-sensitivity audit of every `forEachNeighbor`/`neighbors4` call site (sensitive ones listed above; the rest are booleans, counts, or min/max accumulations) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
5e4b2791aa |
perf: reduce core-sim GC churn 42% and add GC-churn profiling to the perf harness (#4494)
## Summary
Reduces core-simulation GC churn by **42%** on a 20-game-minute Giant
World Map run, and extends the headless full-game perf harness so churn
is measurable and regressions are visible.
### 1. GC-churn measurement (`tests/perf/fullgame/GcProfiler.ts`)
`npm run perf:game` now reports:
- **GC pauses** by kind (minor/major/incremental) via a
`PerformanceObserver` on `'gc'` entries, bucketed into tick windows by
timestamp (V8 only delivers these entries on a timer task, so they're
flushed after the run)
- **Allocation rate** per `--window N` ticks (default 1000) from
used-heap deltas sampled every tick, so churn can be tracked across game
phases
- **Top allocating functions** from the V8 sampling heap profiler with
`includeObjectsCollectedBy{Major,Minor}GC` — i.e. actual churn including
short-lived garbage, not live memory — plus a `.heapprofile` loadable in
Chrome DevTools (Memory → Allocation sampling)
New flags: `--window N`, `--no-gc-profile`, `--no-alloc-profile`.
### 2. Allocation reductions in the hot paths it found
| Site | Change |
|---|---|
| `GameMap.bfs` | inline neighbor enumeration instead of an array per
visited tile |
| `GameMap`/`Game` | new `forEachNeighborNSWE` — allocation-free
iterator matching `neighbors()` N,S,W,E order for order-sensitive
callers (`forEachNeighbor` visits W,E,N,S, so substituting it would
change sim behavior) |
| `PlayerImpl.nearby` / `sharesBorderWith` / `shoreReachableNeighbors` |
no per-call neighbor arrays; no materialized shore-tile array |
| `PlayerImpl.units(types)` | gather into a reusable scratch buffer,
return one exact-size slice (still a fresh snapshot array per call) |
| `AiAttackBehavior.maybeAttack` | single pass over border neighbors
replacing the `flatMap`/`filter`/`map` chain over every border tile |
| `AiAttackBehavior.isBorderingNukedTerritory` | reusable `neighbors4`
buffer with early exit |
| `SharedWaterCache.build` | allocation-free neighbor iteration |
| `SpatialQuery.bfsNearest` | first-minimum scan instead of
collect-then-stable-sort (identical result incl. tie-breaking) |
### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-minutes,
seed `perf-default`)
| Metric | Before | After |
|---|---|---|
| Sampled allocations (incl. collected) | 97.7 GB | **56.9 GB (−42%)** |
| GC count / total pause | 1,682 / 3,313 ms (1.8% of wall) | 1,058 /
2,087 ms (1.2%) |
| Ticks/sec | 66 | 70 |
| p99 / max tick | 49.9 ms / 988 ms | 43.5 ms / 689 ms |
| Ticks over 100 ms budget | 31 | 19 |
## Determinism
Every rewrite preserves exact iteration order (the new NSWE iterator
exists precisely for the order-sensitive sites). Verified by identical
final game-state hashes on three runs: Giant World Map 12,000 ticks
(`67286276735690560`), Giant World Map 2,000 ticks, and World 1,800
ticks.
## Test plan
- [x] Full suite green (1,896 tests)
- [x] New tests: `forEachNeighborNSWE` order contract vs `neighbors()`
over every tile; `units()` filtering semantics (insertion order,
fresh-array guarantee, duplicate types, Set path)
- [x] Final-hash equality on 3 seeded headless runs (2 maps)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
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._ |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
006f1690a5 |
Warship veterancy (#4433)
## Description: Warship veterancy! This is an idea inspired by the unit veterancy feature of games like C&C: Red Alert 2 in which unit eliminations increases the level of individual units. I've been trying to build this mechanic for months with different ideas, and I finally landed on this being one of the more balanced implementation. Warships can earn up to three levels, represented by the gold bar insignia in the bottom right of their warship sprite. <img width="622" height="202" alt="image" src="https://github.com/user-attachments/assets/a8c31a45-4ae9-41a9-b054-9c4a7f4ab1f1" /> A veterancy bar grants 20% health from the base amount, and a 20% increase in shell damage applied _after_ the random damage roll. For example, a level 3 warship will apply a 60% damage boost on top of the random shell damage value (something between 200-325. If the random value is 250, the final damage output will be `250 * 1.60 = 400`. There are three ways to achieve a veteran level: 1. **Eliminate another warship:** any time a warship neutralizes another warship, it immediately get's a veterancy increase. https://github.com/user-attachments/assets/6a9e0958-5171-4ca3-94f6-9c2300a12f8b 2. **Eliminate transport boats:** Destroying 10 transport boats will level a warship to the next veterancy bar. https://github.com/user-attachments/assets/619ce0c0-033c-4e0b-9c64-b41eabaa791b 3. **Steal trade ships:** If the warship captures 25 trade ships, it will earn a veterancy bar. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx |
||
|
|
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> |
||
|
|
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> |
||
|
|
cb7f721e73 |
Restore subscriptions in store and account modal (#4461)
Reverts
|
||
|
|
36b23d314f |
QoL: Add billions to money utils (#4460)
## Description: A simple QoL to handle cases where money surpasses $1B. I have seen this happen in both custom games and post-game, and generally looks nicer than just showing a thousand millions. <img width="242" height="71" alt="image" src="https://github.com/user-attachments/assets/abc9adb5-2e8f-485f-93ff-47a43a019d85" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx |
||
|
|
0d2179f5f3 |
Input handler.ts rework (#4225)
> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. **Add approved & assigned issue number here:** Resolves #4193 ## Description: Use activeKeys set in places where it is checking if a key is being pressed in a different way, and it makes more sense to use the activeKeys set. Make the overall code of the InputHandler.ts file more consistent and to make it easier to add new keybinds in the future. <img width="1920" height="1080" alt="Screenshot from 2026-06-13 20-49-56" src="https://github.com/user-attachments/assets/94f6f81c-7278-4bca-845c-2442b6caea39" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: tktk1234567 |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 |
||
|
|
e196d399b4 |
feat: Change factory icons from circles to hexagons (#4439)
Resolves #4413 ## Description: The factory and city icons are currently undistinguishable when zooming out, this PR changes the factory icon to be hexagon-shaped. It also increases the icon scale (now equal to port) to put some padding around. *Before* <img width="159" height="154" alt="Before" src="https://github.com/user-attachments/assets/2967fbc1-c775-4281-9592-6a52cbadd5a4" /> *After (with `scale = 1.0`)* <img width="133" height="135" alt="After (with `scale = 1.0`)" src="https://github.com/user-attachments/assets/f2bb9d8a-2f41-434a-b4df-9c1475f51cce" /> *After (with `scale = 1.08`)* <img width="192" height="191" alt="After (with `scale = 1.08`)" src="https://github.com/user-attachments/assets/c81026d2-f6fe-4115-a00e-e7490d342787" /> ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: lents |
||
|
|
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 |
||
|
|
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> |
||
|
|
0f1a95cbeb |
Restore default terrain colors changed by #4391 (#4447)
## Description The terrain colors settings refactor (#4391) inadvertently changed the **default** land terrain colors. The PR moved the hardcoded terrain colors into configurable settings, but the new default hex values in `render-settings.json` — combined with the new shading math — no longer reproduced the original sand/plains/highland appearance. In particular the new shading formulas use each JSON color as a *base* and apply offsets differently (e.g. highland now adds `2*(magnitude-10)` instead of `2*magnitude`), so the default hex values needed to compensate. ## Fix Surgical, JSON-only change to the defaults so the new formulas produce **pixel-identical** output to the pre-#4391 code: | Terrain | Was (#4391) | Restored | | --- | --- | --- | | sand | `#CC9E9E` | `#CCCB9E` | | plains | `#BECD8A` | `#BEDC8A` | | highland | `#C8B78A` | `#DCCB9E` | | mountain | `#e6e6e6` | `#e6e6e6` (unchanged) | | ocean | `#4785b5` | `#4785b5` (unchanged) | The settings/UI machinery from #4391 stays intact and users can still override colors — only the defaults are corrected. Verified across all magnitude/shoreline combinations that the rendered land colors match the original output exactly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
d42b095304 |
fix: Remove FFA collusion warnings on replay and ranked (#4414) (#4434)
> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. **Add approved & assigned issue number here:** Resolves #4414 ## Description: The 2 lines added check whether it's a replay or a ranked game. If so, don't show the FFA collusion warning ## Please complete the following: - [X] I have added screenshots for all UI updates (couldn't sign in via the dev server) - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: lents (not in the development server yet, sent request to join) |