Commit Graph

1921 Commits

Author SHA1 Message Date
Alex Besios 55e8a4edb7 feat: add NewsBox component and integrate news items into PlayPage (#3545)
Resolves #2998 

## Description:

Adds a news box to the lobby homepage that advertises upcoming clan
tournaments, weekly tournaments, and new player tutorials. The component
sits above the username input and cycles through items automatically.

<img width="1138" height="591" alt="screenshot-2026-03-31_00-48-33"
src="https://github.com/user-attachments/assets/4b79287d-6aca-4c81-9bfe-36aad043f381"
/>

<img width="1107" height="595" alt="screenshot-2026-03-31_00-48-24"
src="https://github.com/user-attachments/assets/598e6b8b-e0f2-4864-a5fb-a00c0cc98f37"
/>

<img width="1367" height="599" alt="screenshot-2026-03-31_00-48-04"
src="https://github.com/user-attachments/assets/14f74e70-9dc0-4d67-af6e-c4708e539490"
/>


## Please complete the following:

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

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

deathllotus

---------

Co-authored-by: Evan <evanpelle@gmail.com>
2026-04-08 10:53:02 -07:00
FloPinguin 50bd075b1c Fix deselected host lobby settings persisting for joiners 🐛 (#3607)
## Description:

### Problem

When a host toggled off certain settings (game length, PVP immunity,
starting gold, gold multiplier, disable alliances) in the host lobby
modal, joiners still saw the old values. The settings appeared "stuck"
once enabled.

### Root Cause

`putGameConfig()` sent `undefined` for disabled settings, but
`JSON.stringify` strips `undefined` properties entirely. The server's
`!== undefined` guard never fired, so the old value was never cleared.

### Fix

- **HostLobbyModal**: Send `null` instead of `undefined` when these
settings are toggled off (`null` survives JSON serialization)
- **Schemas**: Add `.nullable()` to the five affected fields
(`maxTimerValue`, `spawnImmunityDuration`, `goldMultiplier`,
`startingGold`, `disableAlliances`)
- **GameServer**: Use `?? undefined` (nullish coalescing) to convert
incoming `null` back to `undefined` when storing on the config

Other settings are unaffected. Booleans like `infiniteGold` always send
`true`/`false`, and fields like `bots`/`gameMap` always have a concrete
value..

## Please complete the following:

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

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

FloPinguin

---------

Co-authored-by: Evan <evanpelle@gmail.com>
2026-04-07 11:51:42 -07:00
Ralfi Salhon 1cbee79cc7 Reduce Attacking Troops Overlay Reflows (#3608)
## Description:

Vimacs on Discord pointed out a heavier than needed DOM load from the
[AttackingTroopsOverlay
PR](https://github.com/openfrontio/OpenFrontIO/pull/3427)

- Caches a single `labelTemplate` in `AttackingTroopsOverlay`, built
once on init and cloned per label instead of recreating it each time
- Removes redundant inline style assignments that were repeated on every
label creation
- Simplifies `updateLabelContent` by accessing template-guaranteed
children directly by index

## Please complete the following:

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

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

Radyus
2026-04-07 09:51:23 -07:00
Cameron Clark 18da7134c8 Implement FX sound effects (#3394)
## Description:
Adds sound effects for approved events from the [sound asset
pack](https://drive.google.com/drive/folders/1KpGYJkmLxipy8XmTeyHf40XDC4P--Ck8?usp=sharing).
15 new sound effects triggered from `FxLayer`, `EventsDisplay`, and
`RadialMenu`. Sounds play even when visual FX are off, so disabling
explosions doesn't kill audio. Unapproved sounds are included as assets
but not wired up yet.

### SoundManager architecture

Reworked `SoundManager` per [maintainer
feedback](https://github.com/openfrontio/OpenFrontIO/issues/1893#issuecomment-4184649434)
and [follow-up
review](https://github.com/openfrontio/OpenFrontIO/pull/3394):

- No more singleton. `SoundManager` is instantiated in
`createClientGame()` with `EventBus` and `UserSettings`
- Layers emit events (`PlaySoundEffectEvent`,
`SetBackgroundMusicVolumeEvent`, `SetSoundEffectsVolumeEvent`) via
EventBus instead of holding a `SoundManager` reference
- `SoundManager` subscribes to these events in its constructor
- `SoundEffect` is a type union (not an enum), per project convention
- All sound configuration (type, URL mapping, events) lives in
`Sounds.ts`
- Sound effects are lazy-loaded on first play
- Channel limit of 8 concurrent sounds. New sounds always play; when at
the limit, the oldest active sound gets stopped
- `SoundManager` bootstraps volume from `UserSettings` in its
constructor
- All Howler calls are wrapped in try/catch with error logging, so sound
failures never crash the game
- `dispose()` method unsubscribes from EventBus and unloads all Howl
instances on game shutdown
- Sound code stays entirely in `src/client/`, nothing in `core/` touches
it

## Sound approval status (per
[spreadsheet](https://drive.google.com/drive/folders/1KpGYJkmLxipy8XmTeyHf40XDC4P--Ck8?usp=sharing))

### Approved, wired up in this PR

| Event | Sound file | Trigger location |
|-------|-----------|-----------------|
| Message sent/received | `message.mp3` | EventsDisplay |
| Menu open/select | `click.mp3` | RadialMenu |
| Atom bomb launch | `atom-launch.mp3` | FxLayer (unit created) |
| Atom bomb / MIRV hit | `atom-hit.mp3` | FxLayer (reached target) |
| Hydrogen launch | `hydrogen-launch.mp3` | FxLayer (unit created) |
| Hydrogen hit | `hydrogen-hit.mp3` | FxLayer (reached target) |
| MIRV launch | `mirv-launch.mp3` | FxLayer (unit created) |
| Alliance suggested | `alliance-suggested.mp3` | EventsDisplay |
| Alliance broken | `alliance-broken.mp3` | EventsDisplay |
| Port built | `build-port.mp3` | FxLayer (construction complete) |
| City built | `build-city.mp3` | FxLayer (construction complete) |
| Defense post built | `build-defense-post.mp3` | FxLayer (construction
complete) |
| Warship built | `build-warship.mp3` | FxLayer (unit created) |
| SAM built | `sam-built.mp3` | FxLayer (construction complete) |

### Waiting for approval, sound files included but NOT wired up

| Event | Sound file | Notes |
|-------|-----------|-------|
| Missile Silo built | `silo-built.mp3` | Waiting for Approval |
| SAM shoot | `sam-shoot.mp3` | Waiting for Approval |
| SAM hit | - | Waiting for Approval, no sound file assigned |
| Warship sunk | - | Waiting for Approval, no sound file assigned |
| Warship shoot | - | Waiting for Approval, no sound file assigned |

### Not done, no sound files exist yet

| Event | Notes |
|-------|-------|
| Looted player | "Not sure if needed" |
| Invaded | - |
| Ship invasion incoming | - |
| Ship sent | - |
| Menu theme song | - |
| Ambience | "Not sure if needed" |

## Test plan
- [x] Start a private game and launch atom/hydrogen/MIRV nukes, verify
launch and detonation sounds
- [x] Build structures (city, port, defense post, SAM), verify build
completion sounds
- [x] Build a warship, verify warship built sound
- [x] Receive an alliance request, verify alliance suggested sound
- [x] Break an alliance, verify alliance broken sound
- [ ] Receive a chat message, verify message sound
- [x] Open the radial menu and click items, verify click sound
- [x] Disable visual FX in settings, verify sounds still play
- [x] Adjust SFX volume slider, verify it affects all new sounds
- [x] Verify no audio issues with rapid/overlapping events
- [x] Verify SoundManager responds to EventBus events and unsubscribes
cleanly on dispose
- [x] Verify SoundManager swallows Howler errors without crashing the
game
- [x] Verify channel limit of 8, oldest sound stopped when at cap

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

Resolves #1893

## Please put your Discord username so you can be contacted if a bug or
regression is found:
cool_clarky
2026-04-06 21:01:23 -07:00
VariableVince 341f344ce5 Perf/Refactor(UserSettings): caching makes it 10-20x faster (#3481)
## Description:

Skip slow and blocking LocalStorage reads, replace by a Map. Also some
refactoring.

### Contains

- No out-of-sync issue between main and worker thread: Earlier PRs got a
comment from evan about main & worker.worker thread having their own
version of usersettings and possibly getting out-of-sync (see
https://github.com/openfrontio/OpenFrontIO/pull/760#pullrequestreview-2845155737,
https://github.com/openfrontio/OpenFrontIO/pull/896#pullrequestreview-2871836979
and https://github.com/openfrontio/OpenFrontIO/pull/1266.
But userSettings is not used in files ran by worker.worker, not even 10
months after evan's first comment about it. In GameRunner,
createGameRunner sends NULL to getConfig as argument for userSettings.
And DefaultConfig guards against userSettings being null by throwing an
error, but it has never been thrown which points to worker.worker thread
not using userSettings. So we do not need to worry about syncing between
the threads currently.
(If needed in the future after all, we could quite easily sync it, by
loading the userSettings cache on worker.worker and listening to the
"user-settings-changed" event @scamiv to keep it synced (changes in
WorkerMessages and WorkerClient etc would be needed to handle this).

- Went with cache in UserSettings, not with listening to
"user-settings-changed" event: "user-settings-changed" was added by
@scamiv and is used in PerformanceOverlay. Which is great for single
files that need the very best performance. But having to add that same
system to any file reading settings, scales poorly and would lead to
messy code. Also, a developer could make the mistake of not listening to
the event and it would end up just reading LocalStorage again just like
now. Also a developer might forget removing the listener or so etc. The
cache is a central solution and fast, without changes to other files
needed and future-proof.

- Make sure each setting is cached: UserSettingsModal was using
LocalStorage directly by itself for some things. Made it use the central
UserSettings methods instead so we avoid LocalStorage reads as much as
possible. For this, changed get() and set() in UserSettings to getBool()
and setBool(), to introduce a getString() and setString() for use in
UserSettingsModal while keeping getCached() and setCached() private
within UserSettings.

- Remove unused 'focusLocked' and 'toggleFocusLocked' from UserSettings:
was last changed 11 months ago to just return false. Since then we've
moved to different ways of highlighting and this setting isn't used
anymore. No existing references or callers are left.

- Other files:
-- Have callers call the renamed functions (see point above)
-- Remove userSettings from UILayer and Territorylayer: the variable is
unused in those files. Also remove from GameRenderer when it calls
TerritoryLayer.
-- Cache calls to defaultconfig Theme (which in turn calls dark mode
setting)/Config better in: GameView and Terrainlayer.

### Update on Contents later on
It wasn't really in scope of this PR but further consolidation was
called for. These changes could also pave the way for UserSettingsModal
(main menu) perhaps being partly mergable with SettingsModal (in-game)
one day as it begins to look more like it. Even though UserSettingsModal
still does things its own way, and does console.log where SettingsModal
doesn't, etc. They both have partially different content and settings
but also have a large overlap.

- UserSettings: Removed localStorage call from clearFlag() and setFlag()
which were added after creation of this PR, and were neatly merged in
silence without merge conflicts so i wasn't aware of them yet until now.

- UserSettings: added key constants, exported to use both inside
UserSettings and in files that listen to its events.

- UserSettings 'emitChange': now done from setCached, removed from
setBool, setFlag etc. Also removed from the new setFlag. And from
setPattern even though it emitted "pattern" instead of key name
"territoryPattern"; now it emits the default "territoryPattern" from
PATTERN_KEY which is re-used in Store, TerritoryPatternsModal and
PatternInput.

- UserSettingsModal: made UserSettingsModal call existing toggle
functions in UserSettings, or new or existing getter or setter. We do
not need CustomEvent: checked anymore. In UserSettingsModal, its toggle
functions did not all actually toggle, some like
toggleLeftClickOpensMenu actually just set a value. Based on the
'checked' value of the CustomEvent. But we don't need that 'checked'
value anymore and none of the checks for it inside the toggle functions
in UserSettingsModal, now that we just directly call
toggleLeftClickOpensMenu and others in UserSettings.

- SettingToggle: continuing about not needing CustomEvent anymore: the
old way actually fired two events. The native change event from <input>
and our own CustomEvent from handleChange in SettingToggle. It prevented
handling both events by checking e.detail?.checked === undefined. But
now, the native <input> event is all we need to show the visual toggle
change and trigger @changed in UserSettingsModal which calls the toggle
function.

- Use the toggle functions too from CopyButton and
PerformanceOverlay.ts. In PerformanceOverlay, change in
onUserSettingsChanged was needed because of how setBool works.

- UserSettingsModal 'toggleDarkMode': in UserSettingsModal, removed the
event from toggleDarkMode in UserSettingsModal; nothing is listening to
this event anymore after DarkModeButton.ts was removed some time ago.
Also both UserSettingsModal an UserSettings added/removed "dark" from
the document element. Now that UserSettingsModal calls toggleDarkMode in
UserSettings, we could centralize that. But UserSettings is in core, not
in client like UserSettingsModal. But now that we emit
"user-settings-changed", we could handle it even more centralized and
not have UserSettingsModal or UserSettings touch the element directly.
Instead have Main.ts listen to the event and change it dark mode from
there.

- UserSettings: added claryfing comment to attackRatioIncrement and the
new attackRatio setters/getters, to explain their difference. Noticed a
small omitment in its description and fixed that right away in en.json:
you can change attack ratio increment by shift+mouse wheel scroll or by
hotkey. So made "How much the attack ratio keybinds change per press"
also mention "/scroll."



**BEFORE** (with getDisplayName added back to NameLayer as a fix i will
do soon)
get > getItem in UserSettings
![BEFORE get
getItem](https://github.com/user-attachments/assets/5d2bf8b2-9e68-4c58-9b1f-d5636ee5d7e9)

renderLayer in NameLayer (with getDisplayName added back to NameLayer as
a fix i will do soon)
![BEFORE renderLayer NameLayer
ea](https://github.com/user-attachments/assets/ea12d9a4-2ff3-421b-844c-dbc39e5c3193)

**AFTER** (with getDisplayName added back to NameLayer as a fix i will
do soon)
getCached in UserSettings
![AFTER
getCached](https://github.com/user-attachments/assets/7fb1151f-d289-4420-a257-9fe1f9fbcb8f)

renderLayer in NameLayer (with getDisplayName added back to NameLayer as
a fix i will do soon)
![AFTER renderLayer NameLayer
ea](https://github.com/user-attachments/assets/f844e3d4-d6e5-4774-ba18-ba541f066c76)

## Please complete the following:

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

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

tryout33
2026-04-06 20:41:57 -07:00
evanpelle f170f034a7 Merge branch 'v30' 2026-04-06 20:38:22 -07:00
VariableVince b5ca0f9d8f Perf/refactor/fix(NameLayer): about 10% extra improvement (#3540)
## Description:

NameLayer perf part 2 after
https://github.com/openfrontio/OpenFrontIO/pull/3475 with thanks to
@scamiv. Shaves off another 10% or thereabouts, even doing something
extra for a fix (see below).

Also refactor/fixes around NameLayer and PlayerIcons, which is used by
both NameLayer and PlayerInfoOverlay, and underlying function in
GameView.

This would go well with other PR
https://github.com/openfrontio/OpenFrontIO/pull/3481, since this layer
reads multiple settings. Reasoning to not use events and instead rely on
fast caching is explained in that PR.

### Contents

- Fixes:
-- Fixes bug on .dev introduced by wrong assumption by me in previous PR
https://github.com/openfrontio/OpenFrontIO/pull/3475. displayName CAN
change during game, when Hidden Names is toggled from settings, so needs
to be put back in renderPlayerInfo.
-- Fixes longer existing bug: it was assumed Dark Mode didn't change
after creation of icon element. Now it also sets Dark Mode attribute
when updating icons elements.
-- Fixes target mark icons not being shown to team members, while the
icons were shown to normal allies. And EventsDisplay displayed message
"XX requests you attack XX" to both team members and allies already. So
why is the icon not shown to both if the message already is. While we
improve performance of GameView > PlayerView > transitiveTargets (which
is only used by NameLayer/PlayerIcons so only in this context). We can
add team members' targets to it in one go. So previously
transitiveTargets returned: your own targets and allies' targets. Now
transitiveTargets is faster and returns: your own targets and allies'
targets and team members' targets.

- NameLayer:
-- renderLayer: for target icons, getPlayerIcons used to fetch
myPlayer.transitiveTargets each time. While that doesn't change per
player we're rendering for. So now, we fetch myPlayer.transitiveTargets
once per call to renderPlayerInfo, which passes it on to getPlayerIcons.
So now we check it 1x each 100ms (renderCheckRate) inside of
renderLayer. Instead of up to 100s of times each 500ms
(renderRefreshRate) inside of getPlayerIcons inside of renderPlayerInfo
loop.
-- createBasePlayerElement and renderPlayerInfo: use cloneNode where
possible with templates
-- createPlayerElement: only find the elements and set font and flag.
Leave the rest to renderLayer > renderPlayerInfo which fills displayName
and troops and font color very soon after anyway. I haven't noticed a
difference in testing.
-- cache game.config() and others
-- renderPlayerInfo: remove check if render.flagDiv exists, we know it
exists. Check if fontColor changed before assigning it (it never
changes, currently, be it dark mode or light mode). Don't check if
troops or size changes, that happens so often that the overhead for
checking would be smaller than the win, probably.
-- We don't require nameLocation to be changed to change scale (see
previous Namelayer perf PR for the reason). But it seems good to check
if the transform changed before 'overwriting' it, so do that now
instead.
-- Remove Alliance icon DOM traversals. Only do it once, for each time
an alliance icon is displayed. To this end, also made NameLayer more
agnostic on Alliance icon stuff. By moving more code to PlayerIcons. See
below.
-- Use cached allianceDuration instead of fetching this static value
every time
-- Re-use from PlayerIcons: ALLIANCE_ICON_ID, TRAITOR_ICON_ID etc
-- create more sub-functions to make the icons loop in renderLayer more
readable: handleEmojiIcon, handleAllianceIcons,
createOrUpdateIconElement (createIconElement already existed, now
combined), handleTraitorIcon.
For Alliance icons, this was already done in PlayerIcons.ts through
createAllianceProgressIcon (now createAllianceProgressIconRefs), and
more now to skip some DOM traversals. But most of this belongs in
NameLayer itself when it comes to seperation of concern.
- cache dark mode (as boolean and as string)
- use dark mode to update (alliance) icons too, not only on create,
since the setting can change after icon element creation and before it
is removed
-- for getPlayerIcons, add this.alliancesDisabled. If disabled,
getPlayerIcons won't fetch Alliance icon and Alliance Request icon.

- PlayerIcons:
-- use cloneNode where possible
-- added check for alliances disabled: then skip alliance (request) icon
checks
-- See point under NameLayer about the move of Alliance icon code to
PlayerIcons. To make NameLayer even more agnostic on it and keep it in
one place.
-- getPlayerIcons: skip creating a new Set from
myPlayer.transitiveTargets() each time getPlayerIcons is called. One
allocation less. Just do .includes on the returned array. Probably just
as fast in this case, also because not many Targets are present many
times anyway.
-- getPlayerIcons: on outgoingEmojis(), use .find() instead of .filter()
since we only use the first result anyway and it saves us another
allocation.
-- getPlayerIcons: for nukes, only fetch the ones from the player we're
rendering for, not all game nukes. Also don't use .filter() and just a
normal loop to skip an allocation. Logic outcome is the same.
-- getPlayerIcons: for target icons, it used to fetch
myPlayer.transitiveTargets each time. While that doesn't change per
player we're rendering for. So now, NameLayer fetches
myPlayer.transitiveTargets once per call to renderPlayerInfo, which
passes it on to getPlayerIcons.
-- Remove the need for querySelector and getElementsByTagName("img")
alltogether. Since this would be done for every time an alliance was
(re-)created, here in createAllianceProgressIconRefs in PlayerIcons it
makes more sense to not do DOM traversal than in createPlayerElement in
NameLayer where we only do it once per player per game anyway. We assume
updateAllianceProgressIconRefs just knows of all image names in
createAllianceProgressIconRefs. This is a bit less dynamic and
maintainable maybe, but i think worth the win. And the functions are all
one-purpose and not meant to be used dynamically by another caller
anyway.
-- So instead of updateAllianceProgressIconRefs looping through
refs.images, now just update the different images each. See point above.

- PlayerInfoOverlay: also re-use the new exported consts from
PlayerIcons. Since we put those in PlayerIcons anyway, need to be
consistent. Even though PlayerInfoOverlay is outside of the scope of
this PR otherwise.
-- for getPlayerIcons, add this.alliancesDisabled here too. If disabled,
getPlayerIcons won't fetch Alliance icon and Alliance Request icon. We
also send includeAllianceIcon = false, which means Alliance icon will
already be excluded but Alliance Request icon is normally still fetched
and shown.

- GameView > PlayerView: for transitiveTargets (only used in
NameLayer/PlayerIcons so only in this context), improve performance. It
did several allocations. Now it loops directly over the arrays we need.
Also (as mentioned under Fixes) previously transitiveTargets returned:
your own targets and allies' targets. Now transitiveTargets is faster
and returns: your own targets and allies' targets and team members'
targets.


**BEFORE**

![BEFORE](https://github.com/user-attachments/assets/02ff167f-7978-4968-a26e-0c64bf4fb2f3)

**AFTER** (including now getting team members' targets for
myPlayer.transitiveTargets)

![AFTER](https://github.com/user-attachments/assets/1b81f9cc-bb8b-4d6b-97e4-f6db3802e55c)

## Please complete the following:

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

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

tryout33
2026-04-06 20:36:23 -07:00
Evan 592dadf80d Refactor home page ads into a single file, add corner video ad (#3601)
## Description:

<img width="3608" height="1848" alt="image"
src="https://github.com/user-attachments/assets/86074bff-e648-4db4-a3e9-08d49e433df0"
/>

## Please complete the following:

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

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

evan
2026-04-06 16:44:41 -07:00
evanpelle 0a117aead3 add adfree description to cosmetics 2026-04-06 14:05:50 -07:00
Evan 2d28bfcd01 Add Rarity to cosmetic items (#3587)
## Description:


https://github.com/user-attachments/assets/f2216dec-72aa-497a-89cc-169c2a40021e


* Fortnite-style rarity system for cosmetics: New CosmeticContainer
component applies tier-based visual styling (gradient backgrounds,
glowing borders, hover effects) to flag and pattern cards across 5
rarity tiers: Common, Uncommon, Rare, Epic, and Legendary
* Legendary hover effects: Scale-up animation, pulsing orange glow,
shimmer sweep, rotating border sweep, corner sparkles, and screen
dimming backdrop
* Epic hover effects: Purple shimmer sweep glint on hover
* Purchase button overhaul: Green ember particles on container hover
(non-common only), 40-particle burst stream on button hover, pulsating
green glow, shimmer streak animation, and loading spinner on click
* Clickable cosmetic cards: Clicking anywhere on a purchasable card (not
just the purchase button) triggers the purchase flow
* Refactored components: ArtistInfo renamed to CosmeticInfo (now shows
rarity and color palette in tooltip),
* Forward-compatible rarity schema: rarity field uses .or(z.string()) so
unknown backend values won't break the client


## Please complete the following:

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

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

evan
2026-04-06 11:38:24 -07:00
David 494b409f55 Cap StructureLayer Maximum Texture Size (#3574)
Resolves #3569

## Description:

While browsers like Firefox will report their maximum texture size of
16384, going over 8192 causes extreme VRAM usage and massive FPS drops,
even when no structures are actually being rendered (I experienced ~60ms
rendering time on this layer with no structures present).

This PR sets the StructureLayer texture size to cap at 8192, while
keeping near-exact scales. The result is increased performance, reduced
VRAM Usage, (especially in larger maps), and the resolution of the
unplayable performance issues when StructureLayer is present, with zero
noticeable degradation.

VRAM Usage also no longer rises when zoomed in (Sitting at around a
constant 40MB no matter zoom level, previously it would rise to over
160MB when StructureLayer was present).

All tested on Giant World, where the issues were first spotted, but
applies to all maps.

Discord: @enderboy9217
2026-04-04 12:46:30 -07:00
VariableVince 21c286189e Perf/fix(NukeExecution/NukeTrajectoryPreviewLayer): unnecessary fetch and pass to listNukeBreakAlliance (#3546)
## Description:

Perf/fix: listNukeBreakAlliance doesn't ask for allySmallIds and doesn't
do anything with it. But both NukeExecution and
NukeTrajectoryPreviewLayer do fetch and pass allySmallIds to it.

Make allySmallIds optional, have wouldNukeBreakAlliance (which does use
allySmallIds) handle it potentially being undefined, and remove fetch
and pass of allySmallIds to listNukeBreakAlliance.

## Please complete the following:

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

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

tryout33
2026-04-02 11:19:55 -07:00
evanpelle b88c3a3bdd Merge branch 'v30' 2026-04-01 20:03:39 -07:00
evanpelle 54e29c5b4e Revert "Add new bouncing trajectory for april fools (#3534)"
This reverts commit c92895620f.
2026-04-01 19:36:18 -07:00
DevelopingTom c92895620f Add new bouncing trajectory for april fools (#3534)
## Description:

For April Fools day, the nukes can randomly bounce (1/10 chance).

Changes:
- New BouncingParabola trajectory
- Generic "Text Event" to display a `Boing!` where the bounce happens
- The complete trajectory, with or without bounce, is used by the SAM
interceptor so there should be no defense gap.



![bounce](https://github.com/user-attachments/assets/8e7c9cbd-1605-4757-9bf2-f99987470fe2)


## Please complete the following:

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

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

IngloriousTom
2026-03-31 17:03:43 -07:00
Ryan 9b4812f8b8 UI: Search Skin (#3552)
## Description:

Sorta fixes a visual bug:
<img width="1021" height="129" alt="image"
src="https://github.com/user-attachments/assets/2ea46fcd-d85c-4f46-a9b9-28c40b818269"
/>

By adding parity with the flag modal:

<img width="988" height="561" alt="image"
src="https://github.com/user-attachments/assets/29ca0b9f-3887-4867-b67b-8c35b026b021"
/>

(Parity
<img width="1032" height="342" alt="image"
src="https://github.com/user-attachments/assets/b9c551eb-047a-402f-8e7b-c207d967ff38"
/>
)

(code pretty much copy paste from flagmodal)

## Please complete the following:

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

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

w.o.n
2026-03-31 13:18:06 -07:00
Evan e7b4317718 fix: radial build sub-menu items stay grayed out after gaining enough gold (#3415)
## Description:

canBuildOrUpgrade was captured once at sub-menu open time, so
disabled/color
never updated while the menu was open. Evaluate canBuildOrUpgrade
dynamically
inside the disabled and color callbacks so the menu reflects current
gold on
each refresh tick.


## Please complete the following:

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

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

evan
2026-03-30 16:44:44 -07:00
baculinivan-web e4cd753ce2 fix: prevent game zoom runaway after browser zoom shortcut (#3532)
## Description:

When the user changes browser zoom using keyboard shortcuts (cmd+Plus /
cmd+Minus on Mac, ctrl+Plus / ctrl+Minus on Windows), the game would
start zooming in uncontrollably afterwards. The zoom could not be
stopped — only temporarily countered by zooming out, but the game would
continue zooming in on its own.

**Root cause:** Two issues combined:

1. When cmd+Plus/Minus is pressed, the browser intercepts the event and
handles its own zoom. The `keydown` fires and adds `Equal`/`Minus` to
`activeKeys`, but the `keyup` is swallowed by the browser and never
fires — leaving the key stuck. The 1ms `moveInterval` then continuously
emits `ZoomEvent` forever.

2. The `onScroll` handler was passing browser-generated synthetic wheel
events (fired with `ctrlKey: true` and large `deltaY`) directly to the
game zoom logic, amplified by 10x.

**Fix:**
- Skip adding `Minus`/`Equal` to `activeKeys` when a meta/ctrl modifier
is held (browser zoom combo)
- On `MetaLeft`/`MetaRight` keyup, explicitly clear any stuck zoom keys
- Clear all `activeKeys` on `window blur` as a general safety net
- In `onScroll`, ignore synthetic wheel events with `ctrlKey: true` and
`|deltaY| > 10` (browser zoom events vs real pinch-to-zoom which has
small deltas)
- Add a minimum delta threshold of 2 for regular scroll to cut off macOS
momentum tail events

## Please complete the following:

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

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

fghjk_60845

Co-authored-by: Ivan <batsulin.i@mail.ru>
2026-03-30 13:46:45 -07:00
evanpelle 130315cba1 Merge branch 'v30' 2026-03-30 12:59:29 -07:00
Evan 3876967f21 Fall back to default Discord avatar when profile image fails to load (#3543)
## Description:

The api only refreshes user info every week or two, so when a user
changes their profile it image, the api had the reference to the
existing image. So for now just load in a default discord icon.

## Please complete the following:

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

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

evan
2026-03-30 12:59:04 -07:00
Evan fabd1a5fa9 Update achievement schema (#3542)
## Description:

Update the schema for achievements

## Please complete the following:

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

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

evan
2026-03-30 12:18:04 -07:00
Evan 73016bb56b Add bottom left ad in crazygames (#3526)
## Description:

If on crazy games, shows an in-game ad on the bottom left corner

## Please complete the following:

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

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

evan
2026-03-27 11:57:42 -07:00
Ryan 7d1ff91078 playerstats to go with infra (#3520)
## Description:

https://github.com/openfrontio/infra/pull/279 to go with this, splits
out 1v1

## Please complete the following:

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

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

w.o.n
2026-03-26 19:05:37 -07:00
Ryan 14a5128e87 playerstats to go with infra (#3520)
## Description:

https://github.com/openfrontio/infra/pull/279 to go with this, splits
out 1v1

## Please complete the following:

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

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

w.o.n
2026-03-26 19:04:33 -07:00
Evan 1c2bd5df31 add not logged in warning to flags modal, refactored to its own lit component (#3521)
## Description:

So players know they are logged out and don't think their purchased
flags dissappeared.

## Please complete the following:

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

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

evan
2026-03-26 16:45:13 -07:00
evanpelle d99359c456 Add a store button to the patterns modal and flag modal 2026-03-25 15:24:43 -07:00
evanpelle 9d51846932 bugfix: Call modal close() on nav clicks so onClose callback fires 2026-03-25 15:17:12 -07:00
evanpelle 23150f02c3 bugfix: flags not showing up because they need assetUrl 2026-03-25 14:47:17 -07:00
evanpelle 7fdda33fb9 Merge branch 'v30' 2026-03-25 13:34:34 -07:00
evanpelle d809c25d1c bugfix: Make territory patterns modal inline page like flag input modal 2026-03-24 16:15:35 -07:00
Evan dbba1dccb5 Display the name of the creator for flags & skins (#3510)
## Description:

So artists get credit for the work they do.

## Please complete the following:

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

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

evan
2026-03-24 15:17:25 -07:00
FloPinguin 4bf18dfafe Maybe for v30: Add leave confirmation dialog to JoinLobbyModal 🚪 (#3507)
## Description:

Adds a `confirmBeforeClose()` override to `JoinLobbyModal`, matching the
existing behavior in `HostLobbyModal`.

Because the german streamers had a lot of problems with accidentally
leaving today.

When a user is in a lobby and tries to close the modal (Escape key or
clicking outside), they now get a confirmation dialog asking if they
really want to leave. If the user hasn't joined a lobby yet (still on
the join form), the modal closes without prompting.

Reuses the existing `host_modal.leave_confirmation` translation key.

## Please complete the following:

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

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

FloPinguin
2026-03-24 13:28:30 -07:00
FloPinguin 4e126c2e79 Maybe for v30: Add leave confirmation dialog to JoinLobbyModal 🚪 (#3507)
## Description:

Adds a `confirmBeforeClose()` override to `JoinLobbyModal`, matching the
existing behavior in `HostLobbyModal`.

Because the german streamers had a lot of problems with accidentally
leaving today.

When a user is in a lobby and tries to close the modal (Escape key or
clicking outside), they now get a confirmation dialog asking if they
really want to leave. If the user hasn't joined a lobby yet (still on
the join form), the modal closes without prompting.

Reuses the existing `host_modal.leave_confirmation` translation key.

## Please complete the following:

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

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

FloPinguin
2026-03-24 13:26:57 -07:00
FloPinguin 0dc522413e Close private lobby when host leaves 🚪 (#3503)
## Description:

When the host of a private lobby disconnects before the game starts, the
lobby is now closed:

- **Server:** Detects host disconnection via `creatorPersistentID`
match, kicks all remaining clients with a `host_left` reason, and marks
the game as ended so it's cleaned up by the game manager and can no
longer be joined.
- **Client:** Participants receive an `alert()` saying "The host has
left the lobby." and their JoinLobbyModal is closed automatically via
the `leave-lobby` event.
- **Phase logic:** `phase()` now returns `Finished` for ended private
lobbies that haven't started, preventing the game from lingering in
`Lobby` state.

## Please complete the following:

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

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

FloPinguin
2026-03-24 13:00:20 -07:00
FloPinguin cf0cf14a1f Add new public game modifiers 🙂 (#3500)
## Description:

Adds 5 new public game modifiers to the Special game mode modifier pool:

- **Ports Disabled** - disables port construction, focus on factories
- **Nukes Disabled** - disables atom bombs, hydrogen bombs, MIRVs,
missile silos and SAM launchers
- **SAMs Disabled** - disables SAM launchers (thats funny, you cant
protect against nukes, have to space out your stuff) (mutually exclusive
with nukes disabled)
- **1M Starting Gold** - gives all players 1M starting gold (was
requested by people, new chill tier alongside existing 5M and 25M)
- **4min Peace Time** - grants 4 minutes of PVP spawn immunity

All `PublicGameModifiers` boolean fields are now optional - inactive
modifiers are omitted from game JSON instead of being serialized as
`false` (stop bloating the JSON size)

I think we have enough modifiers now :) Good variety

## Please complete the following:

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

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

FloPinguin
2026-03-24 12:57:08 -07:00
Ryan 6e67c2bf0d visibleAt (#3497)
## Description:

 needs prereq of https://github.com/openfrontio/infra/pull/272

## Please complete the following:

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

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

w.o.n
2026-03-24 12:53:32 -07:00
FloPinguin d83a4d2dc6 For v30: Fix base language preferred over regional variant in auto-detection 🌐 (#3506)
## Description:

When the browser reports a locale like `de-DE`, the language selector
didn't find an exact match and fell through to candidate matching, where
it picked `de-CH` (Swiss German) over `de` (German) because longer codes
were sorted first.

This adds an early check: if the base language code (e.g. `de`) is
directly supported, return it immediately before scanning regional
candidates.

FYI @Aotumuri 

## Please complete the following:

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

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

FloPinguin
2026-03-24 12:21:50 -07:00
FloPinguin e2d58380a7 For v30: Fix base language preferred over regional variant in auto-detection 🌐 (#3506)
## Description:

When the browser reports a locale like `de-DE`, the language selector
didn't find an exact match and fell through to candidate matching, where
it picked `de-CH` (Swiss German) over `de` (German) because longer codes
were sorted first.

This adds an early check: if the base language code (e.g. `de`) is
directly supported, return it immediately before scanning regional
candidates.

FYI @Aotumuri 

## Please complete the following:

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

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

FloPinguin
2026-03-24 12:21:13 -07:00
scamiv 2be858869c Split runtime and game logic env loading (#3505)
## Description:


This refactors client configuration loading to make the environment
split explicit.

The app currently has two different env concerns:
- the browser main thread needs the live runtime env to select API /
Turnstile / JWT settings
- the worker and game-logic path need a build-time env to select game
config behavior

Before this change, both responsibilities were hidden behind the same
loader, which made the intent unclear and caused confusion around the
worker fallback behavior.

This PR separates those paths explicitly:
- main-thread browser code now uses `getRuntimeClientServerConfig()`
- game creation and worker/game-logic code now uses
`getGameLogicConfig()`
- the build-time game-logic env is represented explicitly as
`GameLogicEnv`

## What Changed

- Added `GameLogicEnv` to model the build-time game config choice
explicitly.
- Added `getRuntimeClientServerConfig()` for live runtime browser config
from `window.BOOTSTRAP_CONFIG`.
- Added `getBuildTimeGameLogicEnv()` and
`getServerConfigForGameLogicEnv()` for build-time worker/game-logic
config.
- Renamed game config loading from `getConfig()` to
`getGameLogicConfig()` to reflect what it actually does.
- Updated browser call sites to use the runtime client config loader.
- Updated worker/game creation paths to use the game-logic config
loader.
- Updated config loader tests to cover both paths.

## Behavior

This keeps the current intended behavior, but makes it explicit:

- Runtime client env:
  - comes from `window.BOOTSTRAP_CONFIG`
- controls live browser integration settings such as API origin,
Turnstile, and JWT audience/issuer

- Build-time game-logic env:
  - comes from bundled `process.env.GAME_ENV`
  - maps:
    - `dev` -> dev game config
    - `staging` -> default/prod game config
    - `prod` -> default/prod game config

That means preprod/staging deployments can continue using prod game
logic while still using staging API/auth settings on the main thread.

## Why

The previous setup worked, but the naming and loader boundaries were
misleading:
- the same function was used for both runtime browser config and
worker/game config
- the worker fallback looked like an implementation detail instead of an
intentional architectural split

This change makes that intent visible in code without changing the
desired deployment behavior.



## 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
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

DISCORD_USERNAME
2026-03-24 10:54:39 -07:00
Evan 39ad547c04 support for unlockable flags (#3479)
## Description:

Add support for purchasable/gated flags.

* Create a new "Store" modal that renders both skins & flags
* move all store related logic out of TerritoryPatternsModal
* use nation:code for existing nation flags & flag:key for gated flags
* check if user has the appropriate flags before purchasing

## Please complete the following:

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

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

evan
2026-03-23 17:09:18 -07:00
VariableVince 496f1008bb Fix: icons structure icons and others at wrong location (#3453)
## Description:

Fix for all structure icons suddenly being displaced from the actual
structure location. And in some cases, structure itself created at wrong
location, or coordinate grid, nuke trajectory preview target, nuke
circles, naval landing target, or pop-up text of gold earned from train
or tradeship.

- Was triggered on iOS almost exclusively, but was also possible on
other devices when a top/left margin was present. Like when an ad was
shown. Why noticed almost only on iOS? Because of different behaviors
where it shifts the DOM elements around relative to the screen
temporarily, so we get a top/left offset on getBoundingClientRect for
the canvas. Possible culprit is overscroll which lets you scroll outside
of the viewport for several hundred pixels before snapping back. Which
was triggered by mistake by dragging instead of tapping somewhere, or
so.

- Some of the bug reports:
https://discord.com/channels/1284581928254701718/1451393982159523982,
https://discord.com/channels/1284581928254701718/1463548362526822649,
https://discord.com/channels/1284581928254701718/1378672255336189964,
fixes https://github.com/openfrontio/OpenFrontIO/issues/3406

- The fix brings a little performance win as well because we need to be
doing less calculations. It is basically "if drawing on a canvas, work
with canvas coordinates and not with screen coordinates". Was stress
tested by two players and me, see below for reproduction.

- (BTW. When researching if the current logic was intended in any way, I
found CodeRabbit had already noticed part of the bug twice. One of them
was
[resolved](https://github.com/openfrontio/OpenFrontIO/pull/2059#discussion_r2396277710),
the other [left
open](https://github.com/openfrontio/OpenFrontIO/pull/2059#discussion_r2370413213).
Another reminder that we need to heed Rabbits calls!)

**CONTAINS**
- StructureIconsLayer > computeNewLocation and StructureDrawingUtils >
createUnitContainer. In renderLayer, when TransformHandler.hasChanged
(after onZoom, goTo, onMove), computeNewLocation recalculates the
position of all structure icons. Sometimes all icons would suddenly be
displaced, not above their map position but somewhere else. New single
icons/levels/sprites would be placed wrongly too by computeNewLocation
and createUnitContainer.
-- Fix: don't use TransformHandler > worldToScreenCoordinates but the
new worldToCanvasCoordinates. Because worldToScreenCoordinates adds the
canvas boundingRect top/left offset. When the main canvas is already
shifted down with a margin, the icons also get shifted within the
canvas. So they're moved twice as much as they should be. We only need
to know at what canvas location to put the icons, the main GameRender
canvas already handles the overall displacement.

- TransformHandler > worldToCanvasCoordinates
-- Use the new worldToCanvasCoordinates too instead of
worldToScreenCoordinates in CoordinateGridLayer, MoveIncicatorUI,
NavalTarget, NukeTeleGraph, TextIndicator. They were affected by the
same bug as the shifting Structure icons because the boundingRect
top/left offset was applied twice, but it was noticed less. I have seen
reports of NavalTarget or MobveIndicatorUI (for warships) not being in
the correct spot though iirc. And just like for
StructureIconsLayer/StructureDrawingUtils, it's only logical. Since
we're drawing on the canvas, we only need to know where to place things
within that canvas.

- TransformHandler > worldToScreenCoordinates
-- Split in two sub-functions. New seperate function
worldToCanvasCoordinates was needed for the above fix. For
canvasToScreenCoordinates the reason is explained below.

- TransformHandler > screenToWorldCoordinates: this function already
subtracts the canvas boundingRect top/left offset. Some callers were
themselves getting the boundaryRect and subtracting top/left, before
calling screenToWorldCoordinates. Not only unnecessary. But also, when
there was more than 0 top/left offset, it would be subtracted twice from
the mouse/tap position. Meaning a (ghost) structure or nuke trajectory
preview target would not be put where the mouse/tap was. Same bug as
above but reversed.
-- Checked all callers. Most did it right. Fixes where needed in
StructureIconsLayer > createStructure and renderGhost, and in
NukeTrajectoryPreviewLayer > updateTrajectoryPreview and
updateTrajectoryPath.
-- Removed comment in screenToWorldCoordinates that talked about zoom.
It doesn't belong there because we do more than zooming there, it was
probably copied once from onZoom() which has the exact same comment and
it belongs in that function.
-- Small fix in caller BuildMenu when checking all callers of
screenToWorldCoordinates: it checked if clickedCell was null, but
screenToWorldCoordinates never returns null.

- TransformHandler > added public helper functions
screenToCanvasCoordinates and canvasToScreenCoordinates to make code
re-usable
-- screenToCanvasCoordinates is used in screenToWorldCoordinates and
onZoom in TransformHandler itself
-- screenToCanvasCoordinates is now also used also in moveGhost and
createGhostStructure in StructureIconsLayer. No bugs there, but the same
calculation was done (getting boundingRect, subtracting left/top from
mouse/tap position). So they now use the centralized function which also
adds to their readability.
-- canvasToScreenCoordinates is (for now) only used in
worldToScreenCoordinates in TransformHandler. It makes the function more
readable imo. And, since it has such a similar calculation to
screenToWorldCoordinates, it only seemed neat to have them both have
their own function.

**BEFORE** (only showing "all structure icons get displaced", but the
cause and fix is basically the same for all)
https://youtu.be/CfDdUwIRQeE

**AFTER**
https://youtu.be/w5w_wvh5V0g 

## Please complete the following:

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

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

tryout33
2026-03-23 16:27:46 -07:00
VariableVince eb51853b05 Perf/Fix: spawn and other functions that need closest by unit (#3243)
## Description:

Performance improvements.

- **PlayerImpl**: for _nukeSpawn_, cache config to const.
- **Other files**: for nukeSpawn and other functions doing the same,
introduce findClosestBy function.
- for **TradeShipExecution**, with the move from _distSortUnit_ to
_findClosestBy_, also add check if port isActive, !_isMarkedForDeletion_
and !_isUnderConstruction_. These checks should have been there already,
so now do it in one go to make use of the predicate isCandidate in
findClosestBy.
- for **TradeShipExectution.test.ts**, add mock functions for
_isMarkedForDeletion_ and _isUnderConstruction_ because of the above.
Also, set Unit tiles and Pathfinding node to actual valid TileRefs for
the testing map. This prevents NaN as return value from manhattanDist.
This problem was already present with the use of distSortUnit, but that
function just did NaN - NaN, returned the first and only port unit in
the array and called it a day. For findClosestBy we have to make sure
the predicate manhattanDist actually returns a number instead of NaN so
we need actually valid tiles. We now have a working test instead of a
test that actually silently failed like before.
- **PlayerImpl**: _warshipSpawn_ and _nukeSpawn_: Make use of the
isCandidate predicate of findClosestBy to have warshipSpawn not return
ports under construction or (smaller change) inactive. This fixes a bug
i have seen right away (where Warship spawns from under construction
Port).
Same for _nukeSpawn_ silos, don't return inactive silo just to be sure
now that we can easily add it to isCandidate predicate anyway. This
costs no performance in the _nukeSpawn_ benchmarks actually. This should
as a by-effecft fix an edge case bug i have seen, where a nuke is sent
from a phantom silo.

Some of this goes along with PR #3220 since playerImpl buildableUnits
makes use of the underlying spawn functions via canBuild. Just like
ConstructionExecution does. But i didn't want to add more to PR 3220
since there's already a lot in there.

The new function _findClosestBy_ could also be applied to some other
parts of code to benefit of it being faster, so i did that.

_findClosestBy_ uses _findMinimumBy_, which is a little more generic in
name. I think _findMinimumBy_ could be used by other parts of code,
while _findClosestBy_ is more clear naming for what it does now. But we
could ditch _findMinimumBy_ and just leave findClosestBy?

Examples of synthetic benchmarks (not included in this PR):

**BEFORE CHANGES (before Scamiv's PR #3241)**
<img width="705" height="91" alt="image"
src="https://github.com/user-attachments/assets/d6d91c08-39f1-4387-9ccc-e51951caa539"
/>

<img width="751" height="101" alt="image"
src="https://github.com/user-attachments/assets/80d400ac-3408-4107-aa58-6d2a847311e9"
/>

**AFTER CHANGES (before  Scamiv's PR #3241)**
![findunittoupgrade for loop 5th
run](https://github.com/user-attachments/assets/b840111b-e7e0-49b5-ace1-299a322224b5)

![nukespawn for loop 3rd
run](https://github.com/user-attachments/assets/47cfc444-9549-4887-8c0e-007277d24485)


**BEFORE CHANGES (after Scamiv's PR #3241)**
![findunittoupgrade
BEFORE](https://github.com/user-attachments/assets/c51e2cec-6171-4204-ba3f-48ed282978eb)

![nukespawn
BEFORE](https://github.com/user-attachments/assets/f7ce9a33-32d6-4875-a529-41724fd4d89f)

**AFTER CHANGES (after Scamiv's PR #3241)**
<img width="717" height="96" alt="image"
src="https://github.com/user-attachments/assets/5b106843-bf6e-4448-a8e8-94448fb30ced"
/>

<img width="767" height="92" alt="image"
src="https://github.com/user-attachments/assets/e6714c7b-26c1-455b-adae-f0060f1cbc7b"
/>




_Also see more **BEFORE** and **AFTER** in this comment:_

https://github.com/openfrontio/OpenFrontIO/pull/3243#issuecomment-3949060395

_And here a comparison in the flame charts:_

- based on the same replay and tried to get the performance recording
going at the same speed and length but always end up with small
differences
- because of a bug in replays currently, it puts you in with the same
clientID/persistantID currently. This means we can also record part of
what is normally only recordable with live human input (the
playerActions/playerBuildables).


**BEFORE** flame chart with nukeSpawn (human player) and maybeSendNuke
(Nation players, uses nukeSpawn via canBuild):

![BEFORE nukespawn Schermafbeelding 2026-03-04
231707](https://github.com/user-attachments/assets/3de7de16-769e-4748-b201-d71c5b75e16e)

![BEFORE maybesendnuke B Schermafbeelding 2026-03-04
230009](https://github.com/user-attachments/assets/16924c77-21c2-4a2d-b784-a469dce15538)

![BEFORE main build Schermafbeelding 2026-03-04
222017](https://github.com/user-attachments/assets/67e99fd6-335c-4e12-a9dc-ad5ae7d74de4)


**AFTER** flame chart with nukeSpawn (human player) and maybeSendNuke
(Nation players, uses nukeSpawn via canBuild):

![AFTER nukespawn Schermafbeelding 2026-03-04
230613](https://github.com/user-attachments/assets/a4eec0ae-d654-44c9-bf89-61567203d748)

![AFTER maybesendnuke B Schermafbeelding 2026-03-04
230009](https://github.com/user-attachments/assets/80e2366d-406b-403a-854c-6fa156713abc)

![AFTER maybesendnuke C Schermafbeelding 2026-03-04
230009](https://github.com/user-attachments/assets/71497e8a-81d0-4722-80f7-427f09d9c21e)

![AFTER maybesendnuke D Schermafbeelding 2026-03-04
230009](https://github.com/user-attachments/assets/55f131cc-e6e5-48f2-9e8d-771c60280640)

![AFTER main build Schermafbeelding 2026-03-04
222017](https://github.com/user-attachments/assets/1927ecb6-d54d-4e1e-8aa4-4f97602e2234)


## Please complete the following:

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

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

tryout33
2026-03-23 16:23:49 -07:00
evanpelle ef846c895b make ffa the large game card 2026-03-23 13:58:49 -07:00
scamiv 05e2bc9f0a Improve cacheability with content-hashed public assets and a cacheable app shell (#3494)
## Description:

This reworks asset delivery and cacheability across the app and moves
non-bundled public resources onto immutable, content-hashed URLs.

Vite bundle outputs continue to live under `/assets/**` and remain
content-hashed by Vite. Public resources that were previously fetched
from stable paths in `resources/` now go through a custom hashed
namespace under `/_assets/**`, backed by a generated asset manifest that
is available to the server, browser, and worker runtime.

In parallel, the root app shell is now cacheable shared HTML instead of
request-time `no-store` HTML. Dynamic and live routes remain explicitly
uncached.

## Why
- Improve browser and Cloudflare cacheability for static assets.
- Remove query-string and release-version cache busting for
runtime-fetched assets.
- Allow unchanged public assets to keep the same URL across releases.
- Reduce avoidable work on `/` by serving a shared app shell instead of
rendering HTML on every request.
- Make cache behavior explicit instead of relying on mixed framework
defaults and file-extension heuristics.

## What Changed

### 1. Content-hashed public asset pipeline
- Added a build-time public asset manifest and hashing pipeline for
non-Vite resources.
- Production now emits hashed public assets under `/_assets/**`.
- Added runtime manifest loading for Node so server-rendered paths
resolve against built hashed files instead of rebuilding from source at
runtime.
- Emitted the runtime asset manifest as an ESM module for server
consumption.

Result:
- `/assets/**` = Vite-managed hashed bundle outputs
- `/_assets/**` = custom content-hashed public resources

### 2. Runtime asset URL migration
- Added a shared `assetUrl(...)` resolution path.
- Migrated runtime references away from query-string versioning and
stable source paths.
- Updated browser, worker, and server-side rendering paths to resolve
through the asset manifest.
- Moved map manifests, map binaries, thumbnails, sprites, sounds, fonts,
flags, icons, screenshots, and other runtime-fetched resources onto
hashed URLs.

### 3. Map and preview fixes
- Fixed directory and per-file map asset resolution so map manifest and
binary fetches resolve to the correct hashed URLs.
- Updated preview metadata and map thumbnail paths to use the hashed
asset namespace.
- Fixed runtime manifest loading in prod after deployment.

### 4. Explicit cache policies
- Added explicit immutable cache headers for:
  - `/assets/**`
  - `/_assets/**`
  - worker-prefixed equivalents under `/wN/...`
- Added explicit `no-store` headers for live and dynamic APIs.
- Removed the old `/api/env` bootstrap request and baked `gameEnv` into
the HTML bootstrap instead.

### 5. Cacheable root app shell
- Refactored the root HTML path to serve a shared app shell with:
- `Cache-Control: public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`
- `/` and the SPA fallback now serve shared cacheable HTML instead of
request-time `no-store` rendering.
- `/game/:id` remains dynamic and `no-store`, but now reuses the shared
shell before injecting preview tags.

### 6. Matchmaking instance handling
- Because the app shell is now cacheable, `INSTANCE_ID` was removed from
shared HTML.
- Added `/api/instance` as a temporary `no-store` runtime lookup used
only by matchmaking.
- This preserves correctness with the current random-per-boot
`INSTANCE_ID` model while keeping `/` cacheable, but it is not the
intended long-term design.

## Behavior Changes

### Asset URL contract
Production URLs for non-Vite public resources now change from stable
paths such as:
- `/maps/...`
- `/images/...`
- `/manifest.json`

to content-hashed paths under:
- `/_assets/...`

Examples:
- `/_assets/maps/<map>/manifest.<hash>.json`
- `/_assets/images/Favicon.<hash>.svg`

### Bootstrap/config
- `/api/env` is removed.
- `gameEnv` is now bootstrapped from HTML.

### HTML caching
- `/` and the SPA fallback are now cacheable shared HTML.
- `/game/:id` remains dynamic.

## Cache Matrix After This Branch
- `/_assets/**`: `public, max-age=31536000, immutable`
- `/assets/**`: `public, max-age=31536000, immutable`
- live `/api/**`: explicit `no-store`
- `/api/health`: explicit `no-store`
- `/api/instance`: explicit `no-store`
- `/game/:id`: explicit `no-store`
- `/` and SPA fallback: `public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`

## Notes / Tradeoffs
- `/api/instance` is a temporary compromise. It exists because
`INSTANCE_ID` is currently random per boot, which is not safe to embed
into cacheable shared HTML.
- The current matchmaking flow still asks the client to provide
`instance_id` during `matchmaking/join`. That is functional, but it is
the wrong ownership boundary: instance selection should be handled by
the matchmaking service, not by the browser.
- The cleaner end-state would be:
- make `matchmaking/join` stop requiring `instance_id` from the client,
and let the matchmaking service select a healthy instance from worker
check-ins
- This branch makes the origin behavior edge-cache-friendly, but
Cloudflare still needs matching cache rules if HTML itself should be
cached at the edge.

## Validation
Verified during development with:
- `npx tsc --noEmit`
- `node node_modules\\vite\\bin\\vite.js build`
- `node node_modules\\vitest\\vitest.mjs run
tests/server/RenderHtml.test.ts tests/server/NoStoreHeaders.test.ts
tests/server/StaticAssetCache.test.ts
tests/core/configuration/ConfigLoader.test.ts`

Additional targeted tests added:
- `tests/AssetUrls.test.ts`
- `tests/core/game/FetchGameMapLoader.test.ts`
- `tests/core/configuration/ConfigLoader.test.ts`
- `tests/server/NoStoreHeaders.test.ts`
- `tests/server/StaticAssetCache.test.ts`
- `tests/server/RenderHtml.test.ts`

## Known Existing Warnings
The production build still reports pre-existing warnings that are not
addressed by this branch:
- inconsistent JSON import attributes for `resources/countries.json`
- inconsistent JSON import attributes for `resources/QuickChat.json`
- large chunk warnings from Vite

## Rollout Notes
- Cache rules should treat `/_assets/**` and `/assets/**` as immutable.
- Cloudflare will still classify HTML as dynamic after deploy unless
matching edge cache rules are configured for it.

## Follow-ups
- Remove `/api/instance` by changing `matchmaking/join` so the server
selects the target instance, or by making `INSTANCE_ID` deploy-stable if
the current contract must remain.


## 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
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

DISCORD_USERNAME
2026-03-23 11:36:52 -07:00
Mattia Migliorini e3a14671ab Bring dev pattern back (#3495)
## Description:

Dev pattern support was removed in
97d0a05d58
This PR brings it back to allow testing new skins.

## Please complete the following:

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

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

deshack_82603
2026-03-23 10:01:19 -07:00
VariableVince 481d0fa3a1 Fix(HelpModal): small updates and fixes (#3473)
## Description:

- fix info icon spacing
- update multiple texts to reflect current state, rewrote
"ui_playeroverlay_desc" further for better readability
- add text for the options menu, and change their order to reflect
current button order
- add missing "Stop trading" icon, is PNG so lazy load
- remove uneccesary lazy loading for an SVG icon (rest of the SVGs
aren't lazy loaded either)

Didn't touch the rest although more incremental updates are needed
following UI and other changes.

Before:
<img width="242" height="82" alt="image"
src="https://github.com/user-attachments/assets/8f38eef6-21e7-4b18-84ef-adc4161a317f"
/>

<img width="357" height="167" alt="image"
src="https://github.com/user-attachments/assets/c6937b5c-c1b2-4560-b40b-94b24a4906cc"
/>

After:
<img width="262" height="95" alt="image"
src="https://github.com/user-attachments/assets/15c1e9f5-3e27-4f4b-8472-5bb70234ab42"
/>

<img width="345" height="203" alt="image"
src="https://github.com/user-attachments/assets/3d3fe3c5-98b2-41fb-8f79-48d02d7ecf9b"
/>

## Please complete the following:

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

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

tryout33

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-21 12:03:11 -07:00
FloPinguin bf09b9c9be Improve JoinLobbyModal (#3482)
## Description:

Perviously, JoinLobbyModal did not show settings like "Infinite gold" or
"Instant Build" or changed tribe count.
Now it does. Only if the setting differs from the default. I tested a
lot of scenarios, I also thought of the public game modifiers.
And we show a small map image now.

Public game with lots of modifiers:

<img width="780" height="758" alt="Screenshot 2026-03-21 011805"
src="https://github.com/user-attachments/assets/9d3fcaa9-3a50-42b2-a351-ac737ef18230"
/>

A private game with lots of custom settings:

<img width="776" height="530" alt="Screenshot 2026-03-21 011940"
src="https://github.com/user-attachments/assets/8f9a3809-844d-4f24-8f92-46c4ce480f8c"
/>

A private game with disabled units:

<img width="786" height="562" alt="Screenshot 2026-03-21 012134"
src="https://github.com/user-attachments/assets/61058329-1d86-4667-a945-7819b89cbf41"
/>

Regular public FFA (No modifiers):

<img width="780" height="372" alt="Screenshot 2026-03-21 012228"
src="https://github.com/user-attachments/assets/abdc42f0-8f2c-40c1-8719-76c648a12bae"
/>

This PR also includes a fix for UsernameInput:

<img width="910" height="647" alt="Screenshot 2026-03-20 222021"
src="https://github.com/user-attachments/assets/e1922395-9dfc-4b32-b987-e2dbff9af917"
/>

This PR also fixes the default private lobby difficulty in GameManager

## Please complete the following:

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

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

FloPinguin
2026-03-20 20:43:35 -07:00
VariableVince 13df5cf324 Perf/Cleanup/Fix(NameLayer): 40% better performance (#3475)
## Description:

TL;DR: NameLayer cleanup+ fix + about 40% faster. The potential move of
NameLayer to canvas is stalled so this is a welcome improvement until
then imo.

- It was previously attempted to move NameLayer from HTML to canvas. But
currently that work is stalled so it might take awhile. Therefore
optimizations to NameLayer are useful to merge in the meantime. Also
there's a fix in this PR (see point below) and some cleanup. Overall it
would probably be better to base future changes on this better version
of NameLayer.
Messages about attempt on Feb 6 and reference to having done that
attempt on March 3:

https://discord.com/channels/1359946986937258015/1381293863712591872/1469117172767784981

https://discord.com/channels/1359946986937258015/1381293863712591872/1469401090385641573

https://discord.com/channels/1359946986937258015/1381293863712591872/1469435973522686127

https://discord.com/channels/1359946986937258015/1359946989046989063/1478213329079242752

- Fix: TL;DR: Remove redundant comparison that unintentionally didn't
work and always resolved to true. Leading to scale always being
recalculated. It is now still always recalculated because otherwise name
may be too big for the territory for several seconds, which looks buggy.
(More on this: In renderPlayerInfo(), it cached render.location in
oldLocation. Then put new Cell() in render.location. Later on it would
strictly compare render.location against oldLocation, to decide if scale
should be changed. Which would always be true because render.location
would have a new Object (long ago they were compared non-strictly with
==, later on strictly when those checks were updated in the entire repo
to ===). With this comparison always returning true (even if
render.location x and y did not actually change), the scale would always
be updated by updating render.element.style.transform.
After the fix (removing the non-working comparison which always resolved
to true), scale updates happen at same frequency as before. I have not
kept a similar check like "positionChanged". Because in testing,
player/tribe name would be scaled as too big for their territory size
for several seconds. This felt buggy. Cause for this is two delays
sometimes overlap resulting in several seconds of delay before scale is
recalculated after name position changed: time in Namelayer per render
refresh inside renderLayer (renderRefreshRate 500ms) plus the waiting
time in PlayerExecution per recalculation of largestClusterBoundingBox
(every 20 ticks). I ultimately decided that it should not wait for
"positionChanged" and just be updated every 500ms (renderRefreshRate)
just like unintentionally happened before.)

- Remove redundantly re-adding the name, when a player name doesn't
change anyway. Only adding it when creating the element is enough
- Remove dead code for Shield
- Cache DOM lookups
- Use textContext instead of innerHTML for nameSpan
- Only transform container if it has updates
- Remove currently unused Canvas. Also from public renderLayer().
Layer.ts expects renderLayer(context: CanvasRenderingContext2) so i
could put it back, but it isn't needed per se and i think it makes more
clear that NameLayer doesn't use Canvas currently.
- Remove two unneeded/outdated comments, update others
- Move setting render.fontSize and render.fontColor after early return
- Pass baseSize to updateElementVisibility so as to not calculate it
twice
- Cache render.player.nameLocation() to re-use

BEFORE:
![BEFORE 610 PsvZSjuL for 20 2s Schermafbeelding 2026-03-13
003100](https://github.com/user-attachments/assets/33583004-8a01-4587-9908-dafd0816f2b4)

AFTER:
![AFTER 610 PsvZSjuL for 20 2s Schermafbeelding 2026-03-13
003100](https://github.com/user-attachments/assets/a8c70ef6-c16a-4204-82b3-d916ba3d6437)

## Please complete the following:

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

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

tryout33
2026-03-20 15:34:19 -07:00
VariableVince c29fca477b Fix(HelpModal): small updates and fixes (#3473)
## Description:

- fix info icon spacing
- update multiple texts to reflect current state, rewrote
"ui_playeroverlay_desc" further for better readability
- add text for the options menu, and change their order to reflect
current button order
- add missing "Stop trading" icon, is PNG so lazy load
- remove uneccesary lazy loading for an SVG icon (rest of the SVGs
aren't lazy loaded either)

Didn't touch the rest although more incremental updates are needed
following UI and other changes.

Before:
<img width="242" height="82" alt="image"
src="https://github.com/user-attachments/assets/8f38eef6-21e7-4b18-84ef-adc4161a317f"
/>

<img width="357" height="167" alt="image"
src="https://github.com/user-attachments/assets/c6937b5c-c1b2-4560-b40b-94b24a4906cc"
/>

After:
<img width="262" height="95" alt="image"
src="https://github.com/user-attachments/assets/15c1e9f5-3e27-4f4b-8472-5bb70234ab42"
/>

<img width="345" height="203" alt="image"
src="https://github.com/user-attachments/assets/3d3fe3c5-98b2-41fb-8f79-48d02d7ecf9b"
/>

## Please complete the following:

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

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

tryout33

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-20 14:03:06 -07:00
evanpelle 76c0f766af make ffa the large game card 2026-03-19 20:00:33 -07:00