mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 23:00:52 +00:00
46162c0e279f8ce7f3f422759a9ebd29ec6b3639
4209
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46162c0e27 |
fix(client): fallback to full border recompute on massive tile changes (#4576)
### **Add approved & assigned issue number here:** Resolves #3604 (related) ## Description: Fixes severe client-side lag spikes (framerate dropping to single digits) when multiple nuclear bombs detonate close to each other with `"isWaterNukes": true` enabled. * Water nukes convert land to water on detonation. For an Atom Bomb (radius 30), this changes up to 2,800 tiles. * The client splits these changes across 16 frames (`dripBuckets`), processing ~150 tile changes per frame. * For each tile change, `BorderComputePass.patchTile` computes updates. If the victim's territory is highlighted, the pass expands the repaint area of each tile change to a `highlightThicken` Chebyshev box of radius 4 (meaning `9x9 = 81` points). * This forces the CPU to calculate and push up to `150 * 81 = 12,150` points *per frame* to the border scatter buffer, uploading it, and rendering it as thousands of overlapping GPU `POINTS` using the complex border shader. * Under consecutive detonations, this completely freezes the client rendering thread, leading to disconnections. * Optimizes `BorderComputePass.ts` to fallback to a full-screen quad border recompute if the scatter point count exceeds a threshold of `2,048` points. * Bypasses the expensive CPU coordinate expansions and array resizing for subsequent tile updates in the same frame. * The GPU renders a simple fullscreen quad to rebuild all borders, which is extremely parallelized and executes in **`<0.1ms`**, keeping gameplay smooth. ## Please complete the following: - [x] I have added screenshots for all UI updates (N/A) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file (N/A) - [x] I have added relevant tests to the test directory (N/A) ## Please put your Discord username so you can be contacted if a bug or regression is found: blontd6 |
||
|
|
4621084500 |
feat(lobby): surface Doomsday Clock preset + anonymous names as lobby settings (#4616)
## Description: Public rotation lobbies can run the **Doomsday Clock** at one of four presets (slow/normal/fast/veryfast), and lobbies can **anonymize player names**, but the lobby UI surfaced neither: the Doomsday Clock badge was generic and there were no settings cards for either. Players couldn't tell what a lobby was actually set to. This surfaces both as regular lobby settings, matching how the other modifiers already display: - **Settings cards** (join/waiting view): adds a "Doomsday Clock" card showing the active preset name, and an "Anonymous Players" card, alongside the existing modifier cards. - **Lobby badge** (home lobby list): the Doomsday Clock badge now reads "Doomsday Clock: Fast" instead of the generic label. Falls back to the plain label when no speed is present (older payloads / non-rotation lobbies). Client-only. Both `doomsdayClock.speed` and `anonymizeNames` already ship in the public `GameConfig`, so no server or schema changes are needed. ## Please complete the following: - [] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._ |
||
|
|
f6e1b8fbae |
feat(livestats): per-player killedBy + deathPosition + winner for live standings (#4593)
## Description: Enriches the admin-bot live-stats snapshot (`/api/adminbot/game/:id/stats`) with the per-player fields a semi-live standings board needs, so it can score kills + placement live instead of waiting for the post-game record. Per player in `liveStats.players[]`: - **`killedBy`** — the eliminator's clientID (null while alive / non-client killer). Invert → live kill points. - **`deathPosition`** — finishing place at elimination = non-bot players still standing + 1 (frozen at "last of the then-standing"). Null while alive → live placement. The **full roster** is reported (dead players are retained in the client view), plus top-level **`winner`** (the decided winner's clientID, else null). Deterministic / consensus-safe: `killedBy` (stamped at `recordKill`) and `deathPosition` (stamped in `PlayerExecution`) are pure sim values, so in-sync clients agree on the majority vote; `winner` is added server-side in `GameServer.liveStats()` from the winner vote (like the existing publicID/username/connected enrichment). ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory |
||
|
|
a639a6aa6b |
Gate public lobby listing on the canCreatePublicLobbies entitlement (#4620)
## What The API now returns an explicit `canCreatePublicLobbies` boolean on `/users/@me` (player) and on each subscription tier in `cosmetics.json`. This PR gates public lobby listing on that entitlement instead of inferring it from subscription status (`active`/`trialing`), and advertises the perk on the store's subscription tiles. ## Changes **Entitlement check** - `UserMeResponseSchema` gains a required `canCreatePublicLobbies` boolean (same pattern as `unlimitedRanked`) - The worker's listing endpoint and `HostLobbyModal`'s Public toggle both check the boolean directly; the Dev bypass is unchanged - `hasActiveSubscription()` is removed — these were its only call sites - The server error stays `subscription_required` (subscribing is still how you get the entitlement; renaming would ripple through i18n for no behavior change) **Store** - `SubscriptionSchema` (cosmetics catalog) gains a required `canCreatePublicLobbies` boolean - Subscription tiles show a "Create public lobbies" badge below "Unlimited ranked" when the tier grants it (`cosmetics.public_lobbies` added to en.json) **Tests** - Schema tests for both new fields (accepts true, rejects missing, rejects non-boolean); `UserMeResponse` fixtures updated ## Deploy ordering Both new schema fields are required, so this client must deploy after the API serves them (same constraint as `unlimitedRanked` / infra#427). The API already returns both fields. ## Verified Typecheck, ESLint, and the affected test files pass (165 tests). Confirmed the live payloads against a local API: `/users/@me` returns `player.canCreatePublicLobbies` and both subscription tiers in `cosmetics.json` carry the field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
766fda991a |
Render custom crown cosmetics above player names (#4619)
## What Renders each player's crown cosmetic in-game (follow-up to #4618). The crown **skins the first-place crown**: when the tile leader owns a crown cosmetic, their image replaces the default crown icon above their name — on the map name plate and in the hover overlay. Players without the cosmetic keep the default crown. The image pipeline follows custom flags: the server-resolved crown URL rides `PlayerStatic` into the GPU name pass and loads on demand into a runtime `TEXTURE_2D_ARRAY` (layers deduped by URL, square 128×128 cells). ## Changes **GL name pass** - `FlagAtlasArray` cell size is now a constructor parameter; a second instance holds crown images (square cells, no letterbox margins) - Per-player data texture widens 8 → 9 columns (`PLAYER_DATA_COLS`); column 8 carries the crown atlas layer - `status-icon.vert/frag.glsl`: status slot 0 (first-place crown) samples the crown atlas instead of the status atlas when the player has a crown cosmetic — same size and position as the default crown - `WebGLFrameBuilder.syncPlayers` resolves `cosmetics.crown.url` → `PlayerStatic.crown` **DOM** - `getPlayerIcons` swaps the first-place crown icon src for the cosmetic image (hover overlay) **Dev hook**: `localStorage.setItem("dev-crown", "<image url>")` forces a crown in singleplayer (mirrors `dev-pattern`) ## Verified Drove a headless solo game (real-GPU WebGL via ANGLE Metal) with the dev-crown hook, expanded until first place — the cosmetic replaces the default crown above the name on the map and in the hover overlay (default `CrownIcon.svg` absent, cosmetic present). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f1bbdd20e |
let admins see clan tags in FFA (#4459)
Resolves #4446 ## Description: left is an admin, right is a normal player: <img width="3834" height="669" alt="image" src="https://github.com/user-attachments/assets/c280485c-ae62-4287-bc66-53a2bb68cb2b" /> admin can see their own tag, and the UON tag, the non-admin can only see their own tag. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n Co-authored-by: iamlewis <lewismmmm@gmail.com> |
||
|
|
e05dce3437 |
Add crown cosmetic type (#4618)
## What Adds **crowns** as a new cosmetic category, wired end-to-end like flags/skins, plus a Crowns tab in the lobby cosmetics modal (#4617). Catalog shape in cosmetics.json: ```json "crowns": { "gold_crown": { "name": "gold_crown", "url": "...", "affiliateCode": null, "product": null, "priceHard": 5, "artist": "...", "rarity": "common" } } ``` ## Changes - **Core**: `CrownSchema` + optional `crowns` record in `CosmeticsSchema`; `crownName` in `PlayerCosmeticRefs`; resolved `crown: { name, url }` in `PlayerCosmetics` - **Server**: `isCrownAllowed` in the privilege checker — requires a `crown:*` or `crown:<name>` flare, resolves the ref to the catalog URL - **Client**: crown selection persisted under the `crown` localStorage key (stale/unowned selections self-clear), `crownRelationship` + resolve/refs wiring, `"crown"` purchase type, Crowns tab in the store, and a Crowns tab in the cosmetics modal (owned crowns + Default tile; selecting persists and keeps the modal open) - **i18n**: `store.crowns`, `store.no_crowns` - Tests for schema parsing, privilege checks, and cosmetic resolution ## Not in this PR - In-game rendering of `player.cosmetics.crown` (name pass / leaderboards / player panel) - API-side support (`/shop/purchase` accepting `cosmeticType: "crown"`, granting `crown:<name>` flares, serving `crowns` in cosmetics.json) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7636f4770c |
Merge skin/effects pickers into one lobby Cosmetics modal (#4617)
## What Replaces the three lobby cosmetic buttons (skin, flag, effects) with two: **Flag** and **Cosmetics**. The new Cosmetics button opens a single tabbed modal (`#modal=cosmetics`) with: - **Skins** — the combined patterns + image-skins grid (from TerritoryPatternsModal) - **Effects** — all effect types via the tabbed effects-grid, same layout as the Store ## Changes - New `CosmeticsModal` and `CosmeticsInput` (lobby button previews the selected skin/pattern, shows a "Cosmetics" label on defaults, hidden on CrazyGames via `no-crazygames`) - Removed `TerritoryPatternsModal`, `EffectsModal`, `PatternInput`, `EffectsInput` and their wiring in Main.ts / index.html / LangSelector - Selecting a skin or pattern no longer closes the modal — the tile highlight moves to the new selection (matches the Effects tab behavior) - i18n: added `cosmetics.title/button_title/search`; removed the keys orphaned by the deleted components ## Notes - Deep links to the old `#modal=territory-patterns` and `#modal=effects` no longer resolve - A follow-up PR (crowns cosmetic) stacks on this branch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ef6e8be2be |
Allow mobile players to take part in the aftergame 🙂 (#4615)
## Description: In team games, after the game is over, players should be able to nuke their teammates (the core simulation already allows this via `nukeSpawn()`). On desktop this works because players can use keyboard shortcuts to select nukes directly. However, on mobile, the radial menu unconditionally replaces the attack submenu with "donate gold" when targeting a friendly player, making it impossible to access nukes targeting teammates in the aftergame. The fix checks whether any buildable attack units (AtomBomb, HydrogenBomb, MIRV) are actually available for the target tile. Since `nukeSpawn()` only returns a valid silo for teammate tiles after game over, this naturally detects the aftergame state and shows the attack submenu. During normal gameplay, `nukeSpawn()` blocks teammate nukes so `hasBuildableAttacks` stays false and donate gold is shown as before. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin |
||
|
|
1a34321ac8 |
Fix transport ships targeting unreachable inland-lake shores (#4577)
Resolves #4555 ## Description: Fixes the bug where transport boats could not be sent to certain targets (e.g. between the arrows on the Four Islands map) when the attacker's territory was far enough away that it approached the target from its inland-lake side. **Root cause:** The transport landing tile was selected purely by distance. `targetTransportTile()` called `SpatialQuery.closestShore()`, which returns the nearest shore owned by the target using a Manhattan-distance BFS and only checks `isShore && isLand && owned` — it never considers whether that shore is reachable by water. Water bodies are tracked as connected components (an inland lake is a separate component from the surrounding ocean), and the *source* selection (`closestShoreByWater()`) correctly requires the attacker to have a shore in the same water component as the destination. So when the nearest owned shore happened to face a disconnected inland lake, the source search found no shore in the lake's component and the transport silently failed — even though the same target had an ocean-facing shore that the attacker's boats could actually reach. **Fix:** Make destination selection reachability-aware so a lake-facing shore is never chosen when a reachable one exists. - Added `SpatialQuery.closestReachableShore(targetOwner, attacker, tile)`: it first collects the set of water components adjacent to the attacker's own shoreline, then returns the nearest target-owned shore whose water component is in that reachable set. Shores that only border a disconnected water body (an inland lake) are skipped. It returns `null` only when the target has no reachable shore at all. - `targetTransportTile()` now takes the attacker and delegates to `closestReachableShore()` instead of `closestShore()`. Its two callers — `canBuildTransportShip()` and `TransportShipExecution.init()` — already have the attacker in hand and pass it through, so both the build-time check and the actual execution agree on the same reachable destination. - `closestShore()` is left unchanged; this adds a reachability-aware variant rather than altering existing behavior. **Testing performed:** Reproduced the bug first on a generated map with an inland lake enclosed by a thick land moat (confirmed the lake resolves to a separate water component and that `closestShoreByWater()` returns `null` for the lake-facing destination while a reachable ocean shore exists). Added unit tests for `closestReachableShore()` (picks the reachable ocean shore; returns `null` when every target shore is in an unreachable water body) and a `canBuildTransportShip()` regression test for the lake scenario. Full suite: 1878 tests pass; `tsc --noEmit`, ESLint, and Prettier are all clean. ## Please complete the following: - [x] I have added screenshots for all UI updates — no UI changes in this PR - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — no user-facing text added in this PR - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: Navaneeth Prabha#0825 |
||
|
|
2422a1a7a0 |
feat(anon): memorable, collision-proof animal anonymous names (#4611)
## Description: Makes OpenFront's anonymised names **memorable and collision-proof**. Anonymised lobbies (e.g. OFM tournaments) previously showed opaque names — the `Anon420` fallback and hash-derived civ-tribe names in the overlay — that were hard to remember and could repeat within a viewer's view, so players couldn't reliably track opponents across a match. **What changed** - **`src/core/AnonAnimals.ts`** (new): an 80-word animal bank + `anonAnimalName(slot, offset)`. Consecutive slots map to **distinct** handles — the 80 animals fill first (`round 0` → a bare `AnonWolf`), then a single round digit counts up (`AnonWolf1`, …). At a fixed offset, two slots can never collide. - **`GameServer.anonName`**: assigns each player its **join-order slot** in `allClients` (stable — late-joiners append so existing names never shift; reconnects reuse their slot) plus a **per-viewer offset**. So within any one viewer's view no two players can share a name, while different viewers still see different names for the same player (**anti-team preserved**). Replaces the old per-pair hash and removes the now-dead `anonymousUsername` helper. - **Client fallback** (`genAnonUsername`): draws a random slot through the same helper (no roster client-side → best-effort); the overlay is what guarantees uniqueness in-game. - `createRandomName` (nation/other display names) is untouched. **Desync safety — audited against #4426** #4426 fixed a desync where `PlayerExecution` seeded `removeClusters()` from `player.name()`, a value anonymize-names makes per-client. This change preserves that invariant: | Check | Result | |---|---| | Deterministic state hash reads `name()`? | **No** — `PlayerImpl.hash = simpleHash(id)·(troops+numTilesOwned) + Σ unit.hash`; `UnitImpl.hash = tile + simpleHash(type)·id` | | `removeClusters` seed | `simpleHash(player.id())` — id-based (#4426 fix), untouched | | Where `anonName` is called | only `startInfoFor` / `gameInfo` — per-viewer wire payloads, never the sim | | Archived record | uses `wireGameStartInfo` (real names) — untouched → replay/scoring unaffected | | Net new sim inputs | none — only the display string + server-side roster order | The name is display-only and the simulation is name-blind, so this cannot desync. **Testing** - New `tests/AnonAnimals.test.ts`: the no-collision guarantee (250 distinct slots → 250 distinct names), round roll-over, per-viewer variation, wire-validity/length. - Existing `tests/server/AnonymizeNames.test.ts` overlay suite still passes. - Full suite green locally (`npm test` + `npm run test:coverage`), plus `build-prod`, `eslint`, and `prettier --check .` clean. ## Please complete the following: - [x] I have added screenshots for all UI updates — _N/A, no UI changes (in-game name string only)_ - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — _N/A, generated handles are not translatable identifiers_ - [x] I have added relevant tests to the test directory — `tests/AnonAnimals.test.ts` ## Please put your Discord username so you can be contacted if a bug or regression is found: <!-- TODO: fill in Discord username --> |
||
|
|
99e6b8ac50 |
Adds impassable terrain to Korea Map (#4604)
> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. **Add approved & assigned issue number here:** Resolves #4603 (issue number) ## Description: Korea was supposed to be a two teams country map, but it released for v32 before impassable terrain existed. Currently the Chinese portion of the map covers far too much area, making it cannot be used for two teams and making it not a true Korea map. The PR will convert the china and russia portions to impassable terrain. - Japan is still on the map to serve as a "island base" or something - One missing south korean province equivalent was added - The map was totally redone (because i deleted the original qgis file for korea) so the terrain and map size is slightly different but it should not be too easy to notice - Adds "Countries" category <img width="1089" height="2189" alt="image" src="https://github.com/user-attachments/assets/82c8729f-cc10-4451-890e-27f02cfde919" /> <img width="367" height="579" alt="Screenshot 2026-07-13 174100" src="https://github.com/user-attachments/assets/e24ad971-c2c5-4791-b092-74e0ff90cefc" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME crunchybbbbb |
||
|
|
0cbb0a0b19 |
Standarize the SVG of flags in flags folder, and other flag fixes (#4605)
## Description: ALL OF THESE FLAG FILES ARE ALREADY IN GAME. NO NEW FLAGS WERE ADDED. Standarizes the SVGs of multiple flags. Multiple flags had issues like not having black borders, or having borders that were too thick: armenian SSR, azerbaijan ssr, byelorussian SSR, emirate of asir, emirate of granada, estonian ssr, far Eastern republic, gwent, karelo-finnish ssr, kirguiz ssr, lithuanian ssr, lower silesia, macedonia, ma clique, moldavian ssr, Nepal, polish lithuanian Commonwealth, seville, taiping heavenly, tannu tuva, upper silesia renamed some flags in the menu to more common and accurate names: holy see -> vatican habsburg Austria -> austrian empire circassia -> Adygea macedonia -> macedonian empire alkebulan, kingdom of Portugal and aztec empire used fictional or flags that were not public domain, so they are replaced with public domain and historical accurate ones. Countries.json also gets some missing flags, these flags are fairly generic so they were probably accidentally left out. This change is so that flags have consistent designs for stuff like using them for nations in maps ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: tri.star1011 |
||
|
|
7dc739b89a |
bugfix for not being in a clan on mobile (#4613)
## Description: fixes a bug that didn't show the join clan popup on mobile before: <img width="464" height="534" alt="image" src="https://github.com/user-attachments/assets/9b7c0e2d-b8e8-4901-ab0e-37768e658722" /> after: <img width="414" height="359" alt="image" src="https://github.com/user-attachments/assets/9a655f24-7ec1-49c9-938b-8a677cf65ab3" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n |
||
|
|
4def3ee2cc |
fix(home): remove background wrap-around seam when nav is hidden (#4607)
## Problem The home-screen background layer was lowered with `background-position: center calc(50% + 2cm)` on a `bg-cover` layer sized exactly to the viewport. The shift exposes a 2cm strip at the top with no image content, and since `background-repeat` defaults to `repeat`, CSS fills it by wrapping the image's bottom edge around to the top — a visible seam. The nav bar normally covers that strip, but when a game starts loading, the `in-game` body class hides the nav while the background layer stays visible, exposing the seam. ## Fix A pure translate can't be gap-free (there is no image content above the image's top edge), so the downward shift now comes from oversizing instead: - Layer stays pinned to the viewport top (`top-0`) and extends 4cm below it (`-bottom-[4cm]`) - Image is anchored `center top` The artwork sits ~2cm lower at mid-screen (matching the previous look there) and the image's top edge stays pinned to the viewport top, so no edge is exposed at any viewport size or UI state. ## Verification Drove the real app headless (Playwright): - Home page renders normally with the artwork sitting lower - Reproduced the loading state (`in-game` on body, nav hidden): background renders clean to the top edge, no seam - Control: re-applied the old styles in-browser, which reproduced the exact wrap-around band at the top 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
62abdcd64e |
fix(nav): show loading spinner on account button until auth resolves (#4606)
## Problem While `/users/@me` is still resolving, the desktop nav account button says "Sign in", then flips to the avatar once the response lands. For logged-in users this briefly (and wrongly) claims they're signed out. ## Fix Start the account pill in a neutral loading state and only commit to a real state once auth resolves: - **DesktopNavBar.ts** — added a small inline spinner (same `border-2 … rounded-full animate-spin` pattern used across the client, e.g. TokenLoginModal, UsernameInput) inside the pill, visible by default; the person icon and "Sign in" text now start `hidden`. - **Main.ts** — `updateAccountNavButton` hides the spinner as soon as it runs; each of its paths (avatar / email badge / sign-in) already un-hides the correct elements. - **CrazyGamesAccountButton.ts** — same spinner hide in `updateCrazyGamesNavButton`, which updates the same pill on CrazyGames instead. The mobile hamburger account item says "Account" rather than "Sign in", so it's unaffected. ## Testing Verified end-to-end with headless Chromium against the dev server: - On load the pill shows only the spinner (sign-in text and person icon hidden). - Once auth resolves it swaps to the person icon + "Sign in" (avatar path exercised by existing `updateAccountNavButton` logic, unchanged). - Stalled `/auth/refresh` via request interception to screenshot the loading state — spinner renders centered in the pill, no layout shift with neighboring nav items. | Loading | Resolved | | --- | --- | | small spinner in pill | person icon + "Sign in" | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
376061793a |
Handle ranked play limits in the client (#4602)
## Summary
Client-side handling for the new server-enforced ranked play limits
(infra#427): free players get a lifetime allowance of ranked matches,
then a small daily allowance; subscribers on tiers with the
`unlimitedRanked` entitlement are never limited.
- **Queue rejection**: the matchmaking socket closing with code 1008 and
reason `ranked_limit_reached` now renders a limit-reached view in the
matchmaking modal (message + "Get unlimited ranked" button that opens
the store on the subscriptions tab) and does **not** auto-reconnect.
Auth-failure 1008 (`Invalid session`) is disambiguated by the reason
string and keeps the existing reconnect-with-refreshed-token path; code
1000 ("replaced by another tab") is unchanged. Both 1v1 and 2v2 go
through the same modal, so both queues get identical handling.
- **`/users/@me`**: added required `player.unlimitedRanked: boolean` to
`UserMeResponseSchema`.
- **cosmetics.json**: added required `unlimitedRanked: boolean` to
`SubscriptionSchema`; store subscription tiles now show an "Unlimited
ranked" perk line for tiers that include it.
Display copy avoids hardcoding the limit numbers since the server
constants may be tuned.
## ⚠️ Deploy ordering
Both new schema fields are **required**, so this must ship **after** the
API starts serving them (infra#427). Against the current API,
`/users/@me` parsing fails (players appear logged out) and a catalog
without the subscription field fails the whole cosmetics parse.
## Testing
- Schema tests for both new fields (present / rejected-when-missing) in
`tests/ApiSchemas.test.ts` and `tests/CosmeticSchemas.test.ts`; full
suite, tsc, ESLint, Prettier all pass.
- Verified in the running app (headless Chromium): forced the modal into
the limit-reached state, confirmed the rendered view, and confirmed the
upsell click closes matchmaking and opens the store on the subscriptions
tab.
- Not verifiable locally (needs deployed infra#427): the real close
frame, the midnight-UTC reset, and the perk line against a real catalog
— covered by the handoff QA checklist.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
bf70ef6f45 |
Return the live TileSet from Player.tiles() instead of cloning (#4600)
Fixes #4598
## Problem
`PlayerImpl.tiles()` copied the entire owned-tile set into a fresh
native `Set` on every call — an O(owned tiles) allocation that grows
with player success (100k+ entries for a large late-game player). The
issue reporter measured 71.8% of sim CPU inside this clone in a bot
harness that samples tiles on large players.
## Why the copy existed, and why it's now safe to drop
The snapshot dates to
|
||
|
|
3a5fba2e12 |
Add 2v2 ranked matchmaking (#4596)
## Description: Implements 2v2 ranked matchmaking end-to-end against the matchmaking API's 2v2 queues (API PR #419): core team pinning, the server's second checkin loop and game creation, and the client UI. ## Core — deterministic team pinning The matcher's assignment specifies exactly who plays with whom (`teams: [[a,d],[b,c]]`), but team assignment previously only did clan/friend balancing and could scramble the ELO-balanced split. - `PlayerSchema`/`PlayerInfo` gain an optional **`teamIndex`** — a server-stamped index into the game's team list, part of `GameStartInfo` so it's identical on every client (same category as `clanTag`/`friends`, which already feed deterministic team assignment). - `assignTeams` honors pins **unconditionally** — before clan/friend grouping and past `maxTeamSize` (the matcher's balancing is authoritative) — and seeds the counts that balancing of any unpinned players sees. Pinned players still participate in the friend graph, so an unpinned friend is pulled toward a pinned player's team. - publicIds never enter core: the game server resolves publicId → teamIndex per client at game start. ## Server - **One checkin long-poll per mode.** Both loops send `mode` explicitly (the API deployed ahead of the client, so no omit-for-back-compat needed). - **`get2v2Config()`**: Team mode, `playerTeams: 2`, `maxPlayers: 4`, always-compact map, donations enabled (matching public team games), `rankedType: "2v2"` (the API's 2v2 ingestion has shipped; `RankedType` gains `TwoVTwo`). - **The assignment payload is now used** (it was previously discarded): `players` → `allowedPublicIds` so only the matched accounts can take the slots (also hardens 1v1), and `teams` → `teamIndex` stamps at game start. A malformed assignment logs a warning and falls back to creating the game without pins rather than stranding matched players. - The 3-clients-per-IP cap on public games applies to matchmade games too (an allowlist doesn't stop one person multi-tabbing multiple accounts). It is now skipped in dev, where local testing (multi-tab, the 4-player e2e) is inherently same-IP — matching the existing dev/prod gating of Turnstile and the duplicate-account kick. ## Client - Ranked modal's 2v2 card is enabled; it passes the mode through `open-matchmaking` (dispatchers without a detail — homepage button, requeue URL — still mean 1v1). - Matchmaking modal joins with `&mode=1v1`/`&mode=2v2`, shows a 2v2 title (`matchmaking_modal.title_2v2` in en.json), and shows the real 2v2 ELO from the new `leaderboard.twoVtwo` field in `/users/@me` (the ranked modal's 2v2 card does too). - WinModal shows requeue for any ranked game and carries the mode back into the right queue (`/?requeue=2v2`). - 2v2 ranked stats surface in the player stats tree (labeled via `player_stats_tree.ranked_2v2`). ## Harnesses (`tests/matchmaking/`) - Contained: the fake server captures the `mode` query param; asserts each queue sends its mode explicitly. **10/10.** - E2E: `MM_MODE=2v2` runs four real browser players through the real local worker's 2v2 queue and rides the flow into the started game. Asserts same gameId for all four, the 2v2 config, allowlist admission, and a **deterministic 2 vs 2 in-game split read from each client's GameView** (the software-WebGL gate is spoofed in test pages only). **8/8.** 1v1 e2e still **6/6.** ## Verification - `npm test`: 2,053 tests pass, including 7 new (6 `assignTeams` pinning unit tests + a full-game pinned-split test through `setup()`). - `npx tsc --noEmit`, ESLint clean. - Live e2e against a local `wrangler dev` API worker: 1v1 (6/6) and 2v2 (8/8) as above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory (UI changes — the ranked modal's 1v1/2v2 cards — were verified with before/after screenshots in the live app during development.) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b69b7f422 |
fix(doomsday): keep doomed warships on patrol instead of idling at ports (#4597)
Follow-up to #4587 (the 5% drain floor). ## Description: A side below the Doomsday Clock bar cannot repair its navy: warship healing is suppressed in `WarshipExecution.healWarship` so the decay actually lands. Since #4587 floors the drain at 5% of max instead of sinking ships to zero, warships now survive at 5% health, which is below the 75% `warshipRetreatHealthPercent` retreat threshold. The result was that every doomed warship peeled off to the nearest port, docked, and then never healed, sitting idle and out of combat, instead of the intended crippled-but-still-fighting state. This gates the repair-retreat on the Doomsday Clock, mirroring the existing heal gate: a doomed warship no longer starts a retreat, and any in-flight retreat or dock is cancelled so it returns to patrol. It keeps fighting at the floor until its side climbs back above the bar (healing resumes) or it is destroyed in combat. No config, schema, or UI change. Deterministic (a boolean check over existing player/warship state). Covered by three new tests in `tests/Warship.test.ts` (doomed stays on patrol, non-doomed still retreats, doomed undocks). ## Please complete the following: - [x] I have added screenshots for all UI updates (no UI changes) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file (no user-facing text) - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._ |
||
|
|
290d6922eb |
feat(playlist): add the Doomsday Clock to the public modifier rotation (#4589)
## Description: Adds the anti-stall **Doomsday Clock** to the public "special" modifier rotation, so it rolls into public games like the other spice modifiers, with a lobby badge. - `MapPlaylist`: new `isDoomsdayClock` ticket in `SPECIAL_MODIFIER_POOL` (weight 4, ~20% of special games). When rolled, enables `doomsdayClock` at a speed picked per game (slow/normal/fast/veryfast). - `PublicGameModifiers` (type + schema): `isDoomsdayClock` flag drives the badge. - `getActiveModifiers` + `en.json`: a "Doomsday Clock" badge/label. - Test covering the badge wiring. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory |
||
|
|
a2321eb824 |
balance(doomsday): floor the drain at 5% of max instead of wiping to zero (#4587)
## Description: The Doomsday Clock drain wiped a caught side's troops (and warship health) to **zero**, an effective death sentence, so a brief dip below the bar was unrecoverable. Floor the drain at **5% of each max** (`drainFloorPercent`) for both troops and warships: a doomed side is crippled, not eliminated, and recovers if it climbs back above the bar. The rising territory bar still forces a finish (a 5%-strength side is trivial to conquer, and the leader is drain-exempt). - Config: `drainFloorPercent` (5), applied to both drains. - Execution: clamp troop + warship-health removal to what sits above the floor (integer-only, deterministic). - HUD: the Collapsing rate is shown net of the floor. - Tests: drain-to-zero / warship-scuttle cases updated to the floor + added coverage. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory |
||
|
|
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> |
||
|
|
558f2e20db |
Adds Gulf of Guinea map (#4590)
## Description: Adds Gulf of Guinea map to the game: https://en.wikipedia.org/wiki/Gulf_of_Guinea Final map of the "pirate maps" for v33 , alongside Irish Sea and Levant. The map is designed so that all the trade from the mainland, which covers 2 of the four corners of the "square" (inverse L) passes through 3 small islands, which although small in size they will super-boost pirates in that zone. The mainland is otherwise fairly lineal, playing like maps like Conakry. Also some sub-saharan african representation The map is decorated with Impassable terrain in its southern border, and has additional nations for Hvn and Solo games https://github.com/user-attachments/assets/9c9fd8c0-fefb-46c4-8720-55e90401681e ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: tri.star1011 |
||
|
|
e57939aba1 |
sec: require strict majority in 1v1 winner-vote consensus (backport) (#4581)
**Add approved & assigned issue number here:** Resolves #4136 ## Description: Backport of #4580 to `main`. `VoteRound.result()` (used by `handleWinner` in `GameServer.ts` to reach consensus on a reported game winner) accepted an exact tie as a majority: `votes * 2 >= totalUniqueIPs`. In a 1v1 game the electorate is 2 unique IPs, so a single client's vote alone satisfied `1 * 2 >= 2` and was accepted immediately — letting one player unilaterally declare themselves the winner without any agreement from the other player, and without the server ever checking the report against the actual simulation outcome. This fixes the comparison to require a strict majority (`votes * 2 > totalUniqueIPs`), so a 1v1 now requires both clients to agree. Legitimate forfeit-on-disconnect is unaffected: once a player actually disconnects, the electorate shrinks to 1 unique IP, and the sole remaining vote still resolves immediately (`1 * 2 > 1`). The `v32` hotfix (#4580) is being deployed first; this PR keeps `main` in sync with the same fix. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: jish |
||
|
|
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> |
||
|
|
1cd36325ac |
Adds Irish Sea map (#4546)
## Description: Adds map of the Irish Sea: https://en.wikipedia.org/wiki/Irish_Sea A relatively small-medium size map of 970k tiles, centered around Isle of Man (slightly modified to be more centered), surrounded in all sides by land. Since all trade will go around the island, this will create crazy strong pirate players, which have become popular thanks to Youtubers and the Tradeship buff in v32. 19 default nations, with 43 extra nations for a total of 62, for the Humans vs Nations gamemode and Solo games. https://github.com/user-attachments/assets/18e0ee2c-8d7f-4ce7-813c-2e7c64d75acc ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: tri.star1011 |
||
|
|
c25f898809 |
Adds Scandinavia map (#4561)
> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. **Add approved & assigned issue number here:** Resolves #4543 ## Description: Adds Map of Scandinavia. This is highly requested and I'm surprised this map doesnt exist already. The layout is based on the Territorial version. Land area: 2.47M pixels Nations: 24, with additional nations totaling to 62. <img width="2006" height="2243" alt="image" src="https://github.com/user-attachments/assets/a959acd3-7871-4d94-8722-b0b69defa9db" /> <img width="551" height="590" alt="Screenshot 2026-07-08 162324" src="https://github.com/user-attachments/assets/e4a49073-04dc-416d-841a-ab945a40d260" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME crunchybbbbb --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> |
||
|
|
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> |
||
|
|
6a6c142388 |
Fixed pirating not being disabled when a warship's owner has no port in that body of water (#4458)
> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. **Add approved & assigned issue number here:** Resolves #4291 ## Description: Fixes pirating not being disabled when a warship's owner has no port in that body of water ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: tktk1234567 |
||
|
|
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 |
||
|
|
c5b2d60661 |
Germany (#4559)
Resolves #4558 ## Description: Sets 1 pixel on the Oder river to be water to allow trade to flow through Sets 1 pixel on the left side of the Oder to be land and not impassable <img width="730" height="1197" alt="Screenshot 2026-07-09 203514" src="https://github.com/user-attachments/assets/5f711ea3-0e92-467b-b030-1099c6c8a97e" /> ## 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: Lengy_ |
||
|
|
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> |
||
|
|
901b271acf |
Add Germany Map and state flags (#4513)
Resolves #4390 ## Description: - Adds new map, Germany, filed under the New and Europe categories, for use in Singleplayer and Custom lobbies. - Adds map to lobby rotation. - Adds new flags to represent all 16 Federal German states (including a variant of Bavaria), all to the ISO standard (DE-XX) Map Info: Size: 1548x1519, 2,351,412 total. 451,764 water pixels (19.2%), 826,627 impassable pixels (35.2%) Nations: 16, all of which are Federal German States Yes, the size has changed and also has impassable terrain added now <img width="652" height="887" alt="image" src="https://github.com/user-attachments/assets/6499382e-f073-4ef8-956c-4e80ef09d4b8" /> <img width="917" height="905" alt="image" src="https://github.com/user-attachments/assets/d9c7a532-c2fd-415a-a51d-709be3b43aae" /> <img width="922" height="1140" alt="image" src="https://github.com/user-attachments/assets/fca34d84-414c-4b3f-91ec-f0a21883caa6" /> ## 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: lengy_ Sorry if I've done anything wrong or the files are out of date, I made the map a week ago, my fork is a mess and I'm still quite new to how exactly GitHub works. |
||
|
|
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> |
||
|
|
f78b5c42c9 |
Update Map Outlines - No Resizing (except oceania) (#4509)
Resolves #4450 ## Description: - Cosmetic updates to some existing maps using the new impassable feature. This PR is focused on maps that require no resizing (no increase in size) to accommodate decorative impassable map outlines. A future PR will focus on maps that require resizing. - Form follows function. Established "metro" map style by removing excessive water off of maps, but going no further. Edits are simple and repeatable, but also flow with the terrain of the map. This is NOT about the removal of rectangular maps. - Show off impassable feature on existing maps without changing game play. https://youtu.be/e1gsvjl-CGE Maps Updated baja bosphoruss didier gateway golfofstlawrence iceland juandefuca lisbon newyork niledelta northwestpass oceania san fran south america straitofmalacca surrounded tourney 1-4 ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: PlaysBadly --------- Co-authored-by: RickD004 <realtacoco@gmail.com> |
||
|
|
965c4e8a87 |
1fix(fourislands): fill landlocked lake on NW island to fix port and b… (#4532)
Resolves #4531 ## Description: Fills the 462-pixel landlocked lake on the eastern shore of the North-West island on the Four Islands map by coloring it with the surrounding land color in image.png and recompiling the map assets. This resolves gameplay bugs where ports built on this shore would face the lake instead of the ocean (preventing warships from spawning into the ocean) and transport ship routing to this shore was blocked due to the lake being disconnected from the main ocean water component. ## Please complete the following: - [x] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: blontd6 <img width="1536" height="1448" alt="image" src="https://github.com/user-attachments/assets/f6852d6f-78f8-4ca7-b5b3-c5d4475543e0" /> |
||
|
|
a375d77539 |
Adds new map Branching Paths. (#4526)
It's my first (now second lol) time doing this so let me know if there's anything off and or wack with it. <img width="2480" height="2150" alt="Maping3wayBranches" src="https://github.com/user-attachments/assets/e4d4281d-8024-47ee-ae5d-7f5e6d8731bd" /> Resolves #4524 ## Description: Fictional / Arcade fractal tree based map running in 3 ways using the new wall / void tiles to form a triangle. Total playable volume is ~2.65M pixels with ~545K pixels of water. (For a land volume of ~2.1M.) Walls / void occupies a large volume of the map with ~2.68M black pixels. (total file volume is 5,332,000 pixels.) 19 Native nations with generic names and no flags. I'm bad at that :p Thanks to PlaysBadly for help on this! ## 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: TheGamingFish |
||
|
|
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> |
||
|
|
1db65a1877 |
Big Map PR: Adds map of Russia (#4521)
## Description: **Adds map of Russia, large map for the largest country:** Yet another country map that uses impassable terrain, to go along the China and USA maps. Huge map with 2.5M land pixels (so that it can reach the biggest lobby ammount, 125 players). By total area this is also one of the largest maps, this will create fun crazy matches with massive endgames. 80+ nations/NPCs representing russian Oblasts, Republics and more, each with their own flag (2/3 of the files of the PR are the flags 😂) <img width="1307" height="541" alt="image" src="https://github.com/user-attachments/assets/38c71d1c-03ed-4a75-97a3-d061f46e1b04" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: tri.star1011 |
||
|
|
0859efc06a |
fix(client): prevent Google CCPA button from shifting layout (#4519)
## Description: When Google Funding Choices CMP dynamically injects the CCPA "Do Not Sell or Share My Personal Information" button (`.fc-dns-link`) directly into the document `<body>`, it becomes a flex child because the body uses a `flex flex-row` layout. This pushes the main page container to the left. The PR fixes that. ## Please complete the following: - [x] I have added screenshots for all UI updates *(Tick this if you took a screenshot of the injected button)* - [ ] 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: blontd6 <img width="1470" height="839" alt="Screenshot 2026-07-06 at 7 58 47 PM" src="https://github.com/user-attachments/assets/b8aa20e4-c3af-4cf5-a382-3c4c732bff49" /> |