mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:53:31 +00:00
derived-assets
272 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7d5ad0d0ac |
fix: add derived asset rewriting for web manifests and BMFont XML
Hash raw public assets first, then render derived assets from the manifest, hash the rewritten content, and emit that rewritten output. This replaces the manifest.json special case with a small derived-asset registry and adds BMFont XML rewriting so <page file=...> entries point to hashed relative atlas page paths in production. Also move asset-manifest.mjs emission to the end of the Vite asset sync step and add regression tests for rewritten web manifest hashing, BMFont XML page rewrite, BMFont XML hash invalidation when page images change, nested relative BMFont page paths, and hard failure on missing derived asset references. |
||
|
|
b88c3a3bdd | Merge branch 'v30' | ||
|
|
74b5affa75 |
Chore(deps): Migrate Express 4 > 5 (#3549)
## Description: Update from Express 4.22 > 5.2.1. And @types/express 4.17 > 5.0.6. ### CodeQL errors unjustified: Please dismiss the unjustified [CodeQL scanning results](https://github.com/openfrontio/OpenFrontIO/security/code-scanning?query=pr%3A3549+tool%3ACodeQL+is%3Aopen) for playground/server.ts: they were flagged for this PR but i didn't touch those specific parts of the file. More importantly: it is a test server, created by Mole/ @Aareksio. I made requests to dismiss the alerts but don't have the permissions to actually dismiss them myself Version 5 was the first major upgrade in 10 years when it was released in Sept 2024. 5.21 is from Dec 2025 so v5 teething problems should be over by now. Many of its dependencies also updated by some major versions. So it seems a worthy update but that is for you to decide. v4 will be EOL when v6 arrives, however that could be a year from now still maybe. - Migration: -- Updated package.json, ran `npm install "express@5"` and `npm install "@types/express@5.0.6"`. -- Used https://expressjs.com/en/guide/migrating-5.html -- Ran the codemods from the migration guide `npx codemod@latest @expressjs/v5-migration-recipe`. -- Checked manually. -- Checked again with help of Gemini 3.1 Pro based on same guide. -- Master.ts: use `*splat` instead of `*`, tested and going to non-existing URL lands back on index.html like it should. -- Worker.ts: MIME type _webp_ is now supported natively so remove added config. -- playground/server.ts: fix type error after upgrading types/express for `name` in `req.params`. And `app.listen` handles user provided callback on error, use that. The latter may not be not needed per se. -- While v5 does this now "When an error is thrown in an async function or a rejected promise is awaited inside an async function, those errors will be passed to the error handler as if calling next(err).", choose to leave our try/catch'es be. Since we use specific errors, probably easier for consistency in log searches and user reporting. - About performance: -- While [Express 5 seems a bit slower than 4](https://www.repoflow.io/blog/express-4-vs-express-5-benchmark-node-18-24), it is not by much especially on Node24 which we're on. Also there's a working group dedicated to improving Express performance, albeit they expect v6/7 to benefit from that more than v5 will. -- While there are faster alternatives in benchmarks, [in real-world usage Express still holds up as one of the best and even beats most 'faster' alternatives](https://medium.com/deno-the-complete-reference/node-js-the-fastest-web-framework-in-2025-static-file-server-case-1df462ad38cd). ## 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 |
||
|
|
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 |
||
|
|
7fdda33fb9 | Merge branch 'v30' | ||
|
|
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
|
||
|
|
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 |
||
|
|
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)**   **BEFORE CHANGES (after Scamiv's PR #3241)**   **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):    **AFTER** flame chart with nukeSpawn (human player) and maybeSendNuke (Nation players, uses nukeSpawn via canBuild):      ## 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 |
||
|
|
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 |
||
|
|
2ec12f0a3a |
Auto-reject alliance request when transport ship is sent to target player (#3477)
## Description: When a player sends a transport ship toward another player's territory, any pending alliance request from the target is now automatically rejected. This mirrors the behavior already in place for direct attacks, preventing a player from exploiting a pending alliance request while launching a naval invasion. ## 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 |
||
|
|
015e3c7d19 |
feat: Attacking Troops Overlay (#3427)
## Description: https://troop-advantage-layer.openfront.dev/ Hey OpenFront dev team, I've been really enjoying the game, and the v0.30 changes have felt great so far. Happy to start contributing! This PR introduces `AttackingTroopsOverlay`, a layer that renders live attacker vs. defender troop counts directly on active front lines. Players can immediately gauge combat strength without leaving the map view.  A recent change updates the layer to just the # of attackers and a symbol for attack/defence:  Left: Perspective of Anon 667 (Blue) | Right: Perspective of Anon332 (Red)  **How it works:** - Attacker count shown for ground invasions. When attacking, your troop count will display amber for disadvantageous, and green for advantageous battles. When defending, the enemy troop count will switch to red if you are at a severe disadvantage. - Label position recalculates every tick at 200ms, tracking the front line as it moves. - Automatically hidden during Terrain view (spacebar) - Labels clean up when an attack ends or its target becomes invalid **Settings:** An "Attacking Troops Overlay" toggle is added to Settings, enabled by default. --> the screenshot is old, but the text has been updated <img width="448" height="410" alt="Settings toggle" src="https://github.com/user-attachments/assets/2df8ec7a-3f77-48b7-a9b5-ee4a6eed0412" /> ## 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 ## Discord Radyus |
||
|
|
6b9603d445 | Merge branch 'v30' | ||
|
|
1049b7e7dc |
Clan System Part 1 (#3276)
## Description: Properly split out clantags and usernames, a clantag should not be part of a username. <img width="285" height="286" alt="image" src="https://github.com/user-attachments/assets/8ac56e82-b12c-4fc0-9774-e445252a6e61" /> https://api.openfront.dev/game/ojkqZFb2 <img width="296" height="596" alt="image" src="https://github.com/user-attachments/assets/85152f80-c111-4f87-b85b-8516c9c6137b" /> https://api.openfront.dev/game/MF32BkVc requires; https://github.com/openfrontio/infra/pull/264 ## 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 |
||
|
|
9f8a2d2d84 |
Fix "you didn't enter the lobby in time" when device clock isn't synced (#3451)
## Description: If the time on the local device differs from the server time, users may see the message “You did not join the lobby on time.” Resolve this by accounting for the time difference, reusing the logic in `JoinLobbyModal` that was previously in `GameModeSelector`, and centralizing it into `ServerTime.ts`. Bug reports: https://github.com/openfrontio/OpenFrontIO/issues/3428 https://discord.com/channels/1284581928254701718/1482511096597315815 https://discord.com/channels/1284581928254701718/1482382264011591781 Resolves #3428 ## 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 |
||
|
|
82d0fb385d |
Fix "you didn't enter the lobby in time" when device clock isn't synced (#3451)
## Description: If the time on the local device differs from the server time, users may see the message “You did not join the lobby on time.” Resolve this by accounting for the time difference, reusing the logic in `JoinLobbyModal` that was previously in `GameModeSelector`, and centralizing it into `ServerTime.ts`. Bug reports: https://github.com/openfrontio/OpenFrontIO/issues/3428 https://discord.com/channels/1284581928254701718/1482511096597315815 https://discord.com/channels/1284581928254701718/1482382264011591781 Resolves #3428 ## 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 |
||
|
|
11e183366f |
Fix inverse annexation (#3448)
## Description: An inverse annexation could happen where the small player (even with 0,01% tiles owned) could fully annex the large player. **TL;DR:** basically wrong use of calculateBoundingBox in surroundedBySamePlayer, feeding it all bordertiles, making enemyBox far bigger than it actually was in some cases. Which resulted in enemyBox of small player with two small clusters at some distance from each other, being seen as inscribing the largest cluster of the bigger player. While that largest cluster is actually the border tiles of the bigger player surrounding the main cluster of the small player. Instead of an annexation of small by bigger, small would incorrectly annex bigger completely. **Situation:** bigger player fully surrounds main cluster of smaller player. Those border tiles are also the largest cluster of the bigger player, for which surroundedBySamePlayer is called. SurroundedBySamePlayer finds the small player as the only bordering enemy of this cluster. Then it needs to check which of the two players is surrounded by the other one. EnemyBox uses calculateBoundingBox with all border tiles of the small player as argument. The small player also has at least one seperate cluster elsewhere, could be on another island, which count as border tiles too. The enemyBox from the main cluster of the small player to the seperate cluster elsewhere, can be huge. Now inscribed() is called and it determines that largest cluster box of the bigger player (which was in fact calculated correctly, also making use of calculateBoundingBox) is surrounded by the bigger enemyBox. And so the small surrounded player fully annexes the bigger player. **Fix:** instead of a global enemyBox, we only need the localEnemyBox that touches the largest cluster of the bigger player. With that, inscribed() can correctly conclude that largest cluster box surrounds the localEnemyBox. As a matter of fact isSurrounded() already used the same method to calculate its enemyBox as introduced by @scamiv for v30: https://github.com/openfrontio/OpenFrontIO/pull/3127/changes#diff-fb1101a2b50dd7c353d082ff7a3351cff5469b8249b3ebca91c10573a3dfaaf1 - Change in PlayerExecution - Added test NoInverseAnnexation.test.ts, which fails before and passes after the fix The bug was introduced in this commit 10 months ago: https://github.com/openfrontio/OpenFrontIO/commit/c4381a9ad3828b06764ab1a21fc1514e37aacfd7 It has probably led to some weird annexations happening since then. The bug could seemingly happen on any map. But was noted recently a few times on square islands (Sierpinski) or maps (The Box/The Alps), where the circumstances probably highten the chances of the bug occuring. **Bug reports:** https://discord.com/channels/1359946986937258015/1481916231689703477/1481916231689703477 https://discord.com/channels/1359946986937258015/1481916231689703477/1481963273367851030 https://discord.com/channels/1284581928254701718/1479993924432171008/1479995658302652496 https://discord.com/channels/1284581928254701718/1479993924432171008/1481865495492956182 https://discord.com/channels/1284581928254701718/1483047153571201034 **BEFORE:** https://github.com/user-attachments/assets/4440182b-f696-45cf-bb01-b10159df8763 **AFTER**, on the same replay but with the bugfix: https://github.com/user-attachments/assets/5f461ab2-eb62-4cc3-ae07-e2224adbbc6a ## 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 |
||
|
|
0dc8fbf129 |
Fix inverse annexation (#3448)
## Description: An inverse annexation could happen where the small player (even with 0,01% tiles owned) could fully annex the large player. **TL;DR:** basically wrong use of calculateBoundingBox in surroundedBySamePlayer, feeding it all bordertiles, making enemyBox far bigger than it actually was in some cases. Which resulted in enemyBox of small player with two small clusters at some distance from each other, being seen as inscribing the largest cluster of the bigger player. While that largest cluster is actually the border tiles of the bigger player surrounding the main cluster of the small player. Instead of an annexation of small by bigger, small would incorrectly annex bigger completely. **Situation:** bigger player fully surrounds main cluster of smaller player. Those border tiles are also the largest cluster of the bigger player, for which surroundedBySamePlayer is called. SurroundedBySamePlayer finds the small player as the only bordering enemy of this cluster. Then it needs to check which of the two players is surrounded by the other one. EnemyBox uses calculateBoundingBox with all border tiles of the small player as argument. The small player also has at least one seperate cluster elsewhere, could be on another island, which count as border tiles too. The enemyBox from the main cluster of the small player to the seperate cluster elsewhere, can be huge. Now inscribed() is called and it determines that largest cluster box of the bigger player (which was in fact calculated correctly, also making use of calculateBoundingBox) is surrounded by the bigger enemyBox. And so the small surrounded player fully annexes the bigger player. **Fix:** instead of a global enemyBox, we only need the localEnemyBox that touches the largest cluster of the bigger player. With that, inscribed() can correctly conclude that largest cluster box surrounds the localEnemyBox. As a matter of fact isSurrounded() already used the same method to calculate its enemyBox as introduced by @scamiv for v30: https://github.com/openfrontio/OpenFrontIO/pull/3127/changes#diff-fb1101a2b50dd7c353d082ff7a3351cff5469b8249b3ebca91c10573a3dfaaf1 - Change in PlayerExecution - Added test NoInverseAnnexation.test.ts, which fails before and passes after the fix The bug was introduced in this commit 10 months ago: https://github.com/openfrontio/OpenFrontIO/commit/c4381a9ad3828b06764ab1a21fc1514e37aacfd7 It has probably led to some weird annexations happening since then. The bug could seemingly happen on any map. But was noted recently a few times on square islands (Sierpinski) or maps (The Box/The Alps), where the circumstances probably highten the chances of the bug occuring. **Bug reports:** https://discord.com/channels/1359946986937258015/1481916231689703477/1481916231689703477 https://discord.com/channels/1359946986937258015/1481916231689703477/1481963273367851030 https://discord.com/channels/1284581928254701718/1479993924432171008/1479995658302652496 https://discord.com/channels/1284581928254701718/1479993924432171008/1481865495492956182 https://discord.com/channels/1284581928254701718/1483047153571201034 **BEFORE:** https://github.com/user-attachments/assets/4440182b-f696-45cf-bb01-b10159df8763 **AFTER**, on the same replay but with the bugfix: https://github.com/user-attachments/assets/5f461ab2-eb62-4cc3-ae07-e2224adbbc6a ## 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 |
||
|
|
5e7317a818 |
Update socket rate limiting (#3447)
## Description: On replays, there can be a burst of traffic from hashes, so instead just have a 2MB limit per client for the entire game. Also the winner message can be 100s of kb on a large game with many players, so now we don't need to put a special case for that. ## 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 |
||
|
|
f6167d2d94 | allow 3 send winner msgs, in case a client reconnects | ||
|
|
5fb7f75f3d |
Server-side WebSocket message rate limiting & size enforcement (#3424)
## Description: * Adds ClientMsgRateLimiter — a per-client token-bucket rate limiter that gates all incoming WebSocket messages. Returns "ok", "limit" (drop), or "kick" based on the violation type. * Intent messages are capped at 500 bytes each (they are stored in turn history for the game duration, so oversized intents accumulate in server RAM). Violations kick the client. * Winner messages bypass the byte rate limit (they include stats for all players and can be 100s of KB) but are strictly capped at one per client — a second winner message kicks the client. * All other messages go through the standard per-second (10/s) and per-minute (150/min) rate limits. Violations drop the message; byte budget exhaustion kicks the client. * WebSocket maxPayload set to 2 MB on game workers. Invalid (unparseable) messages now immediately kick the client rather than being silently dropped. Unit tests added for all rate limiting behaviors. ## 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 |
||
|
|
e8ee83e4b2 | meta: expand trainGold free window from 6 to 10 stops and update tests | ||
|
|
741a38c62b | meta: only penalize train gold after 5 cities instead of 3 | ||
|
|
3e65d08942 |
reduce train gold after each city (#3400)
## Description: Now that cities snap to existing rails, it's possible to line up dozens of cities in a row, producing way too much gold. This PR reduces the gold after each stop to prevent that. Gold only starts decreasing after the 3rd city. ## 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 |
||
|
|
3838de1d30 |
Option to disable alliances + 2 new modifiers for variety 😄 (#3392)
## Description:
Rex had this idea: "It would be funny to have an option in private
lobbies to disable alliances."
I added it as an option.
Now people can choose to live in constant fear of their neighbors 😆
Also added two new public game modifiers for variety (only for the
special rotation):
- Alliances disabled (low probability)
- x2 gold multiplier (low probability)
Would be nice to squeeze this into v30, last minute?
## 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
|
||
|
|
936928fed9 |
Enhance InputHandler to allow using NumPad (#3317)
## Description: Adds **Enter** and **Numpad Enter** as confirmation for placing a ghost structure after selecting a building with hotkeys (1–0 or numpad). Players can cancel with Esc but previously had to click to confirm; they can now confirm with Enter or Numpad Enter at the current cursor position. This supports keyboard-only or mouse + numpad workflows (e.g. one hand on numpad for select + confirm, one on mouse for aiming). ## 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: .wozniakpl |
||
|
|
e137fcaa6c |
Fix/Perf/Refactor: playerActions and buildableUnits, their callers and related types (#3220)
## Description: TL;DR: it's faster. buildableUnits is called via PlayerView.actions from UnitDisplay (each tick without TileRef), BuildMenu (each tick when open), MainRadialMenu (each tick when open), PlayerPanel (each tick when open), StructureIconsLayer (when placing a building from build bar), NukeTrajectoryPreviewLayer (when placing nuke, on tick when tile changes), ClientGameRunner (on click to attack/auto-boat or hotkey B or G). After https://github.com/openfrontio/OpenFrontIO/pull/3213 got merged, the change with largest impact in https://github.com/openfrontio/OpenFrontIO/pull/3193 was done in such a different way that a new PR was needed The idea in 3193 was to not always ask for Transport Ship from buildableUnits. In such a way that very little extra data was send to the worker. This had the biggest impact on performance (the idea was months older btw, see https://github.com/openfrontio/OpenFrontIO/pull/2295). Now, we do it the other way around, by telling buildableUnits all unit types we want. Or we want them all (undefined). The downside is more data is send in the worker message. The upside is we have more options and can add more in this PR. This PR implements some of the leftovers in 3193 on top of 3213 and adds further improvements. (Some unrelated refactor/perf changes where moved out of this PR and into already merged https://github.com/openfrontio/OpenFrontIO/pull/3233, https://github.com/openfrontio/OpenFrontIO/pull/3234, https://github.com/openfrontio/OpenFrontIO/pull/3235, https://github.com/openfrontio/OpenFrontIO/pull/3236, https://github.com/openfrontio/OpenFrontIO/pull/3237, https://github.com/openfrontio/OpenFrontIO/pull/3238, https://github.com/openfrontio/OpenFrontIO/pull/3239) - **GameRunner**, **WorkerMessages**: _playerActions_ and _PlayerActionsMessage ._ Option to ask for no buildable units (null). It now has 3 modes: get all actions and all buildings (units undefined), get all actions and no buildings (units null), or get all actions and specific building (units contains Unit Types). - **GameRunner**: _playerActions_. fixes wrong assumption in PR 3213: that only if units was undefined, we have to know canAttack. ClientGameRunner wants to know both, in case of a click on non-bordering land, to decide if it should auto-boat using a Transport Ship. So units is not undefined (we only ask for Transport Ship now which has a positive effect on performance for each click/tap) but we need canAttack still. Solved by removing the unit === undefined check before _canAttack_ in _playerActions_. - **GameRunner**, **GameView**, **WorkerClient**, **WorkerMessages**, **Worker.worker**: added _playerBuildables_ / _buildables_ next to existing _playerActions_ / _actions_. With above solved, there was still no option to only get buildable units when the actions are not needed. While **StructureIconsLayer**, **NukeTrajectoryPreviewLayer**, **BuildMenu** and **UnitDisplay** need only that. To not make playerActions more convoluted with more params or so, i've added a new function _playerBuildables_ in **GameView** to only get buildable units (**GameRunner** _playerBuildables_). _playerBuildables_ has 2 modes: get all buildings (units undefined) or get specific buildings (units contains Unit Types). Also update some comments that mentioned .actions in **NukeTrajectoryPreviewLayer**. - **ClientGameRunner**, **PlayerPanel**, **BuildMenu**, **UnitDisplay**, **StructureIconsLayer** and **NukeTrajectoryPreviewLayer**: Since PR 3213, **StructureIconsLayer** and **NukeTrajectoryPreviewLayer** ask for specific types of units from **GameView** _actions_ (**GameRunner** playerActions). Now have the other files do the same. For example **BuildMenu** asks for the new _BuildMenuTypes_ when it calls ._buildables_ and **ClientGameRunner** asks for UnitType.TransportShip when sending a boat - **ClientGameRunner**: canBoatAttack now accepts BuildableUnit[] instead of PlayerActions so we can send it either actions.buildableUnits or just buildables. Have functions call myPlayer.buildables(tileRef, [UnitType.TransportShip]) when we only need a buildable unit and no actions. Or myPlayer.actions(tileRef, null) when we need actions but no buildable units. Or myPlayer.actions(tileRef, [UnitType.TransportShip]) when we need both actions, like canAttack, and a buildable unit. Then if needed send either actions.buildableUnits or buildables to to _canAutoBoat_ / _canBoatAttack_. - **MainRadialMenu**: needs all player buildable unit types including Transport Ship, so the _actions_ call argument for unit types can stay undefined (unchanged) there. - **MainRadialMenu**: now that **BuildMenu** uses _playerBuildables_ instead of _playerActions_, we must put data in _this.buildMenu.playerBuildables_. And since we're not putting the (unneeded) full _actions_ in there anymore, we can now put only the needed and expected _actions._buildableUnits_ in it. - **Game**, **PlayerImpl**, **StructureIconsLayer**: Typesafety and some added perf: new type _PlayerBuildableUnitType_ (see also the below point for how it is formed). So callers of _buildableUnits_ can never ask for the wrong type like e.g. UnitType.Train because it doesn't return data for that type. This type is now used in **PlayerImpl**, **BuildMenu**, **RadialMenuElements**, **StructureDrawingUtils** and **UnitDisplay** for that reason. And **InputHandler**, **StructureIconsLayer** and **UIState** (little more on that in point below). - **InputHandler**, **StructureIconsLayer**, **UIState**: In order to make type safety work for GhostUnit.buildableUnit.type too (line ~217 of StructureIconsLayer), changed type of interface _BuildableUnit_ to _PlayerBuildableType_. Which is only more accurate. Same for and this.structures and uiState.ghostStructure and with the latter, _renderUnitItem_ in **UnitDisplay** and _setGhostStructure_ in **InputHandler**. All Structures are of PlayerBuildableType (there are even some in PlayerBuildables that aren't Structures, but it is much more confined than UnitType). - **Game**: Typesafety and some added perf: added _BuildMenus_ and _BuildableAttacks_ in the same fashion that the existing StructureTypes was already used (simplified it a bit too, with it renamed _StructureTypes_ to _Structures_ and removed _isStructureType_). They can be used with .types or .has(). _BuildableAttacks_.has() is used in **RadialMenuElements**. _BuildableAttacks_ and existing _Structures_ now make up _BuildMenus_. Which is used in **BuildMenu**, **StructureIconsLayer** and **UnitDisplay**. Then _BuildMenus_ together with UnitType.TransportShip make up the _PlayerBuildables_. Which is used in **PlayerImpl** _buildableUnits_ (see point below). And with _PlayerBuildableUnits_ we get the new _PlayerBuildableUnitType_ (see above point on Game / PlayerImpl). - **RadialMenuElements**: replace non-central ATTACK_UNIT_TYPES in **RadialMenuElements** with centralized _BuildableAttackTypes_ too. Use _PlayerBuildableUnitType_ for more type safety (can't by mistake add UnitType.Train to its build menu). Make use of _BuildableAttackTypes_ instead of adding items hardcoded line by line in _getAllEnabledUnits_, just like we already did since PR 3239 with _StructureTypes_. And use _BuildableAttacks.types_ in the same fashion that existing _isStructureTypes_ (now Structures.types) was already used elsewhere. - **PlayerImpl**: _buildableUnits_ -- would do Object.values(UnitTypes) on every call. Now for better perf directly loop over player buildable units by using _PlayerBuildables_ (see above point). In this way we also exclude MIRVWarhead, TradeShip, Train, SamMissile and Shell so there are less unit types to loop through by default. Since a player doesn't build those by themselves, they are only build by Executions which use _canBuild_ directly and not _buildableUnits_. -- for more performance, do for loop instead of using .map and .filter, no intermediate array needed nor callback overhead. We just loop over the given units (which if undefined will contain _PlayerBuildables_). Also pre-allocate the results array to get the most out of it, even if V8 might already be very good at this. -- cache config, railNetwork and inSpawnPhase so they can be re-used inside the for loop. -- cache cost inside the loop -- it would check twice for tile!==null to decide to call findUnitToUpgrade and canBuild. Now once. -- eliminated double/triple checks for the same thing. It called _findUnitToUpgrade_ (and with that _canUpgradeUnit_) and then _canBuild_ which both check if player has enough gold for the cost of the unit type. And they both check if the unit type is disabled. Now we call private functions _canBuildUnitType_, _canUpgradeUnitType_ to first do checks on unit type level for early returns, and _findExistingUnitToUpgrade_ to find existing unit without doing anything extra. in a specific order to check everything only once. The public functions _findUnitToUpgrade_ and _canBuild_ have an unchanged functionality and we don't call them from _buildableUnits_ anymore. -- would get _overlappingRailRoads_ and _computeGhostRailPaths_ when canBuild was true. But this data is only meant for **StructureIconsLayer** and it logically only uses it when placing a new unit, not when upgrading one. Which is also commented on line 351 of **StructureIconsLayer**. So, we now only get overlapping railroads and ghost rails if we're not hovering to upgrade an existing unit. - **PlayerImpl**: _findUnitToUpgrade_: unchanged functionality, but have it call new private function _findExistingUnitToUpgrade_ to find existing unit. - **PlayerImpl**: _canBuild_: unchanged functionality, but have it call new private function _canBuildUnitType_ to do the checks it first did itself. And then new private function _canSpawnUnitType_ for the rest of the checks. This way we can call _canBuildUnitType_ and _canSpawnUnitType_ from _buildableUnits_ in a specific order to prevent double/triple checks. - **PlayerImpl**: _canBuildUnitType_: new private function to be shared by _buildableUnits_, _canBuild_ and _canUpgradeUnit_ to be able do unit type level checks in a specific order to prevent double/triple checks. Via parameter knownCost, _buildableUnits_ can send it the cost it already fetched so that it doesn't have to be fetched again. For caller _canUpgradeUnit_, the isAlive() check (which was previously only done in canBuild) is new but harmless, maybe even better to have also check isAlive() on upgrade now that Nations are also upgrading which might prevent some edge case bugs. - **PlayerImpl**: _canUpgradeUnitType_: new private function to be shared by _buildableUnits_ and _canUpgradeUnit_ to be able do unit type level checks in a specific order to prevent double/triple checks. - **PlayerImpl**: _canSpawnUnitType_: new private function to be shared by _buildableUnits_ and _canBuildUnit_ to be able do unit type level checks in a specific order to prevent double/triple checks. - **PlayerImpl**: _findExistingUnitToUpgrade_: new private function to be shared by _buildableUnits_ and _findUnitToUpgrade_ to be able do unit level checks in a specific order to prevent double/triple checks. - **PlayerImpl**: _isUnitValidToUpgrade_: new private function to be shared by _buildableUnits_ and _canUpgradeUnit_ to be able do unit level checks in a specific order to prevent double/triple checks. - **PlayerImpl.test.ts**: because of the isAlive() check in which is new for _canUpgradeUnit_ (see above at _canBuildUnitType_), the tests needed to have the players be alive at the start, in order to pass. - **BuildMenu**: use .find instead of .filter in canBuildOrUpgrade, a function we already needed to change. This is faster and prevents an allocation. **PERFORMANCE** As for calling ._buildables_ instead of unnecessarily getting ._actions_, there is an obvious win because there's less to send calculate and recieve. Also asking for only the needed buildings helps a lot (especially if TradeShip isn't needed, see the difference in benchmark in original #3193). But the real-world impact is hard to measure. gave it a try in #3193 and those results should be even better now. Now testing only _buildableUnits_ performance in a synthetic benchmark, we get these results. This is after other performance improvments so the base is already better than it was in original #3193: **BEFORE** (only buildableUnits itself) <img width="602" height="96" alt="image" src="https://github.com/user-attachments/assets/7770c0fa-a35e-42fc-90de-1de83242ec23" /> **AFTER** (only buildableUnits itself) <img width="603" height="91" alt="image" src="https://github.com/user-attachments/assets/a1578382-7010-4160-937c-7117bad18beb" /> ## 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> |
||
|
|
0b9d43cb46 |
Configurable nation count 🤖 (#3338)
## Description: I hope we can get this into v30? The nation count is configurable now, just like the bot count. Replaced the "Disable Nations" toggle with a nations slider (0–400) in SinglePlayer and Host Lobby modals. <img width="710" height="121" alt="Screenshot 2026-03-03 021952" src="https://github.com/user-attachments/assets/c8d0f0c3-db51-4303-95fa-dbc770460ec2" /> Public games are staying exactly the same, this is just for singleplayer and private lobby fun. Youtubers could play HvN against 400 nations, for example. Singleplayer enjoyers no longer have to play against 1 nation in HvN, they can freely choose. `GameConfig.disableNations: boolean` got replaced by `nations: number (0-400, optional)` `undefined` = map default, `0` = disabled, number = custom count Nations slider defaults to the map's nation count, shows "(MAP DEFAULT)" label when unchanged Compact map toggle reduces nations to 25% when at default, restores when toggled off (just like we already do with bots) The nation count for HvN no longer automatically matches the human count in singleplayer and private games, only in public games. **What if there aren't enough nations configured for the map?** We just use the HvN logic (Generate random nations) ### Warning **This infra PR also needs to get merged: https://github.com/openfrontio/infra/pull/263 Otherwise players can set 0 nations and get 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: FloPinguin |
||
|
|
60f69a6408 |
perf: remove O(n) StructureIconsLayer render lookups (#3305)
Begins work on #3207 ## Description: This PR is the first optimization slice for #3207: it removes O(n) render lookups in `StructureIconsLayer` by replacing array-first render state with a unit-id keyed map, and tightens hot-path execution to reduce per-tick allocations. ### What changed - Refactored render state from array-first to `rendersByUnitId: Map<number, StructureRenderInfo>`. - Replaced O(n) lookup/delete paths with O(1) `Map#get` / `Map#delete`. - Replaced `seenUnits` object-identity tracking with `seenUnitIds: Set<number>`. - Removed `tick()` array/closure chain (`map(...).forEach(...)`) and switched to index-based loop. - Reduced ghost-path allocation pressure by reusing a layer-level `Set` for connected ally IDs instead of allocating `filter` + `map` + `new Set` per ghost query. - Added dirty-flag caching for structure visibility focus (`visibilityStateDirty`) so expensive visibility-state scans recompute only when toggles change. ### Performance validation (before/after) Benchmark added: `tests/perf/StructureIconsLayerLookupPerf.ts` Command: `npm run perf` Observed result: - `StructureIconsLayer BEFORE (array O(n) lookup/delete) x 0.33 ops/sec ±13.28%` - `StructureIconsLayer AFTER (unit-id map O(1) lookup/delete) x 95.65 ops/sec ±2.46%` - Fastest implementation: AFTER (unit-id map) #### Profiler screenshots are too noisy to be useful for such a focused change ### Verification - `npx tsc --noEmit` ✅ - `npx eslint src/client/graphics/layers/StructureIconsLayer.ts tests/perf/StructureIconsLayerLookupPerf.ts` ✅ - `npm run perf` ✅ ## Please complete the following: ~~- [ ] I have added screenshots for all UI updates~~ ~~- [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file~~ - [x] I have added relevant tests to the test directory - [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: skigim |
||
|
|
f7598369ed |
refactor: consolidate platform detection across client components (#3325)
## Description: This PR consolidates ad hoc platform/environment/viewport detection into a single shared utility. It is scoped to this refactor only, and serves as groundwork for the mobile-focused feature work planned for the v31 milestone. ### What changed - Introduced a shared `Platform` utility centralising: - OS detection (with `userAgentData` + UA fallback) - Electron environment detection - Viewport breakpoint helpers (`isMobileWidth`, `isTabletWidth`, `isDesktopWidth`) - Replaced duplicated inline checks across client files with the shared API. - Normalised Mac detection to derive from the consolidated OS logic rather than a separate regex. ### Why - Multiple client files each independently ran `navigator.userAgent` regexes or copy-pasted `isElectron` logic — this unifies all of that. - Puts a stable, tested abstraction in place before v31 mobile work lands, so mobile feature branches have a consistent surface to build against. ## Please complete the following: - [x] I have added screenshots for all UI updates (N/A: refactor only, no visible UI changes) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file (N/A: no new user-facing strings) - [x] I have added relevant tests to the test directory (N/A: refactor only) - [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: skigim |
||
|
|
417fa0fe09 |
For v30: Add new modifiers (Hard nations and 25M Starting Gold) 🙂 (#3316)
## Description: Adds two new public game modifiers for variety and improves compact map eligibility for team games. ### New Modifiers **Hard Nations (`isHardNations`)** - We need this modifier for HvN, because medium nations are easier now (will result in a much higher human winrate) - In a discord discussion we concluded that HvN should generally be easier (higher winrate than 50%, so players are less frustated) - Thats why only 20% of HvN games have the hard nations modifier (for now) - For PvPvE enjoyers, the modifier is also active in FFA games => (Only 2.5% chance, and 1 ticket in `SPECIAL_MODIFIER_POOL`) **25M Starting Gold (`startingGoldHigh`)** - Some people in the main discord wanted this modifier, and it will result in crazy games - Rare special-only modifier (1 ticket in pool); mutually exclusive with 5M starting gold via `MUTUALLY_EXCLUSIVE_MODIFIERS` - Disables nations (they lack PVP immunity, so 25M gold doesn't work well with them) - Excluded from HumansVsNations games (since it disables nations) - Spawn immunity set to **2 minutes 30 seconds** (vs 30s for 5M gold), so people can spend the gold and prepare ### Other Changes - **Improved `supportsCompactMapForTeams`**: Replaced the hard `smallest >= 50` land-tile cutoff with a per-team-config calculation that simulates worst-case compact player count and checks every team gets at least 2 players. - **HvN spawn immunity**: Always 5 seconds in both regular and special lobbies (to get rid of a confusing PVP immunity HeadsUpMessage in 5M starting gold games) - **Regular public lobby random spawn modifier probabilty**: Reduced from 10% to 5% (Because of the new modifier, so there aren't too many modifiers in non-special-lobbies, should only occur sometimes there) - Rebalanced `SPECIAL_MODIFIER_POOL` a bit ## 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 |
||
|
|
e1125e0c37 |
Fix: Nations reject alliance requests created pre-spawn (#3314)
## Description: This PR fixes an exploit that allows the player to request alliances to Nations, mostly in impossible mode, during spawn phase, with high chances for it to be accepted due to troop count parity. Nations now reject alliance requests during the spawn phase. `GameImpl.executeNextTick()` initializes ALL pending `unInitExecs` in one batch on the first post-spawn tick ( `numSpawnPhaseTurns() + 1` ). So every alliance request submitted during spawn phase is guaranteed to be created with `createdAt = numSpawnPhaseTurns() + 1` on the very first post-spawn tick. Therefore, we check for alliance requests created on the very first post-spawn tick and reject those. ## 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 --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> |
||
|
|
802cc7f16d |
Revert "Fix: Nations reject alliance requests during spawn phase" (#3313)
## Description: Reverts openfrontio/OpenFrontIO#3312 ## 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 |
||
|
|
a9c89e4f15 |
Fix: Nations reject alliance requests during spawn phase (#3312)
## Description: This PR fixes an exploit that allows the player to request alliances to Nations, mostly in impossible mode, during spawn phase, with high chances for it to be accepted due to troop count parity. Nations now reject alliance requests during the spawn phase. ## 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 |
||
|
|
c911bfb2d8 |
Packed unit updates / MotionPlans (#3292)
## Description: Reduce per-step `Unit` update traffic by shipping packed motion plans and letting the client advance plan-driven units locally. Changes: - Add packed motion plan records (`packedMotionPlans?: Uint32Array`) to game updates and transfer the buffer worker -> main. - Introduce `src/core/game/MotionPlans.ts` (schema + pack/unpack) for grid + train motion plans. - Extend `Game` with `recordMotionPlan(...)` and `drainPackedMotionPlans()`, and implement buffering/packing in `GameImpl`. - Treat units with motion plans as “plan-driven”: suppress per-tile `Unit` updates on `move()` and advance positions client-side. - Emit motion plans from executions: - `TradeShipExecution`: record/update grid motion plans and `touch()` when changing target after capture. - `TransportShipExecution`: record initial plan and update it when destination changes. - `TrainExecution`: record a train plan on init (engine + cars). - Client: apply motion plans in `GameView` and ensure `UnitLayer` updates sprites for motion-planned units even when no `Unit` updates arrived. ## 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 |
||
|
|
7b785ea79a |
Fix alliance renewal prompt incorrectly dismissed for both players (#3297)
## Description: NOTE: Applies to current main / beta version. Needs to be included in v30. When a player clicked "Renew Alliance", the `AllianceExtensionUpdate` broadcast caused both players' renewal prompts to be removed, even the one who hadn't yet acted. This happened because `onAllianceExtensionEvent` called `removeAllianceRenewalEvents` unconditionally on every client. This PR fixes the behavior by calling `removeAllianceRenewalEvents` only for the player that executed the action. ## 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 |
||
|
|
7855e1b0e9 |
Feat: Troop transport retreats to closest owned tile v2 (#3286)
If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #1139 ## Description: New version of the #2789 PR that is cleaner after changes made to old pathfinding logic. Adds logic to troop transport retreat behaviour which retreats a transport to the closest owned tile instead of the source. Now if no shores are detected (you lost all your shoreline while the transport was out) we handle the return case same as if the original source was no longer your territory. <img width="2541" height="1593" alt="image" src="https://github.com/user-attachments/assets/4d2ff5e7-d10d-40f4-80e0-9f029cff61a2" /> ## Video example from previous PR (works the exact same way in this PR): https://github.com/user-attachments/assets/e43a3b10-e8b0-4f23-87f3-2dc4739de880 ## 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: bijx |
||
|
|
9b96b07820 |
test: add vitest-canvas-mock for local canvas support
Fixes UILayer tests failing locally due to the native canvas package not being compiled. vitest-canvas-mock provides a jsdom-compatible Canvas 2D API mock without requiring native build tools. |
||
|
|
097c42740c |
Random spawn. Avoid spawning near water. (#3009)
## Description: Fixing https://discord.com/channels/1359946986937258015/1360078040222142564/1463898386854973642 Now, if not all tiles on the spawn circle can be owned, the algorithm tries to select another random spawn tile. ## 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: nikolaj_mykola --------- Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> |
||
|
|
6a30d2b38b |
Smarter factory placement for Nation AI 🤖 (#3244)
## Description: Introduces a dedicated `factoryValue()` scoring function for AI factory placement, replacing the generic `interiorStructureValue()` previously shared with cities and missile silos. Scoring criteria: - High elevation and spacing from other factories (unchanged from city/silo logic) - Rail connectivity: bonus per distinct rail cluster reachable within `trainStationMaxRange`, weighted by trade gold potential — allied clusters score highest (1.0), team/neutral clusters score ~0.71, own clusters ~0.29 (based on `config.tradeGold()` values). Based on difficulty - Cluster deduplication: connecting to the same cluster multiple times does not inflate the score - Embargoed and bot neighbors are excluded; all other non-embargoed neighbors are included The result is that the AI tends to place factories where they can bridge separate rail networks or connect to high-value trade partners, rather than deep in its own interior. ### EDIT Added a dedicated `cityValue()` scoring function that takes into account the connectivity score. This allows placement of cities in a "factory-aware" way, while also enforcing spreading structures (we want the network to grow, not a cluster of cities and factories all together). ## 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 --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> |
||
|
|
05af154b58 |
feat(server): add health api endpoint for increased observability (#3264)
## Description: Adds an additional API endpoint to the server for health, using the master lobby service as the health metric. The master lobby service is considered healthy if the lobby service has started (i.e. it had enough ready workers to start), and the current amount of ready workers is more than half of the desired number. This means that we won't show as healthy until all the workers start, and then we will continue to show as healthy even if a few workers crash, as long as at least more than half are still running. Any less than that, and the service becomes unhealthy. This also is set to "no cache" in the nginx config. This is to ensure that any checks of the server health show the true value, and cannot show false/stale data served by nginx, cloudflare, or anything else. ## 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: jish |
||
|
|
f09d9a3a5f |
Nations can overwhelm SAMs now 💥 (+ 3 little nation improvements) (#3246)
## Description: ### SAM Overwhelming (`NationNukeBehavior.ts`) On Impossible difficulty, nations can now destroy enemy SAMs by overwhelming them with coordinated atom bomb salvos. When no good nuke target is found (all trajectories intercepted by SAMs), the nations will: - Identify the easiest enemy SAM to destroy (lowest level first) - Calculate the total interception capacity of all covering SAMs and send enough bombs to overwhelm them (+1 extra per 5 needed to account for enemy building more SAMs during flight) - Plan launches in NukeExecution's Manhattan-distance silo order, tracking which silos have interceptable trajectories (wasted bombs) - Use a sliding window over parabolic flight times to find the best cluster of bombs that can arrive within half the SAM cooldown window - Compute per-bomb wait ticks to synchronize arrivals from silos at different distances - Skip launching if a salvo is already in flight - Upgrade the best SAM-protected silo when silo capacity is insufficient; wait and save gold when only gold is lacking https://github.com/user-attachments/assets/14fa592f-2902-4604-8e37-1eba2b2f0b85 ### 2-Player Endgame Handling (`NationNukeBehavior.ts`) - On Hard/Impossible with only 2 players remaining, `findBestNukeTarget()` directly targets the other player (bypasses all priority logic) - `getPerceivedNukeCost()` returns actual cost (no MIRV saving inflation) when only 2 players are left ### SAM Build Rate (`NationStructureBehavior.ts`) - Reduced SAM perceived cost increase per owned from 1.0 to 0.5, so nations build more SAMs ### Island Attack Variety (`AiAttackBehavior.ts`) - `findNearestIslandEnemy()` now collects up to 2 reachable candidates and has a 33% chance to pick the second-nearest, adding variety to boat attack targeting ## 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 |
||
|
|
90204f6628 |
Add alliance renewal action to Radial Menu (#3148)
## Description: The following PR replaces the (disabled) alliance request button with an alliance extension/renewal button when the alliance with the target player is expiring. Agreeing to renewal via radial menu also hides the message in the EventsDisplay. <img width="369" height="364" alt="image" src="https://github.com/user-attachments/assets/d8040f5c-ad7b-47d0-852f-925ecbf273a8" /> https://github.com/user-attachments/assets/aa589edf-6505-46bf-88a3-aa4c2df9137f Icon size adjusted: <img width="294" height="252" alt="image" src="https://github.com/user-attachments/assets/7ca63500-b1fb-427b-965c-cf121a5213da" /> ## 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 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> |
||
|
|
ba2a947061 |
Feat: Display ghost railways when building cities and ports (#3202)
## Description: Based on [this suggestion on Discord](https://discord.com/channels/1284581928254701718/1447110257196138577) and feedback gathered in [this thread](https://discord.com/channels/1359946986937258015/1469598906173227184). Supersedes #3143 This PR introduces "ghost railways": when you are going to place a city or port, previews railway connections that will be made when actually building the structure. Ghost railways are skipped if the structure is going to be snapped to existing railways (as in railway snapping functionality introduced in #3156 ). ### Video https://github.com/user-attachments/assets/ff8cf325-6501-4df8-801d-c8ae3ced3d0e ### Ghost rails color revisited black with 40% opacity <img width="695" height="430" alt="image" src="https://github.com/user-attachments/assets/272efbcc-4185-426a-921c-7fae61f6c462" /> ## 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 |
||
|
|
86e51ab790 |
Fix nation spawnkilling 🔧 (#3222)
## Description: As far as I can remember, in v28 the spawn immunity applied to both humans and nations. With the configurable spawn immunity (added for v29) the spawn immunity no longer applies to nations... Because its called PVP immunity now. So right now it's possible to spawnkill nations. This is a big problem for the 5M gold modifier games... And you can "cheat" in singleplayer. This PR changes two things: - Nations always have 5 seconds spawn immunity now, no matter whats configured for the PVP immunity - Nations attack TerraNullius earlier (Otherwise the easy nations would sometimes do their first attack after the 5 seconds are over, spawnkills would still be possible) ## 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: Ryan <7389646+ryanbarlow97@users.noreply.github.com> |
||
|
|
52012e321b |
Fix: npm run perf errors on Windows (#3192)
## Description: Npm script 'perf' errors on Windows: "Error [ERR_MODULE_NOT_FOUND]: Cannot find module '(XXX)\OpenFrontIO\tests\perf\*.ts'". It probably worked fine on Linux or Mac, that i don't know. Replaced it with a file that also runs all tests in the folder, which is then simply ran by the script. There are possibly better ways to address this but this just works. ## 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 |
||
|
|
4bc168dffb |
make usernames linkable in news (#3200)
## Description: make usernames linkable in news now: <img width="399" height="153" alt="image" src="https://github.com/user-attachments/assets/39644fe2-9af1-4765-b839-9f8b5f9d0418" /> before: <img width="409" height="82" alt="image" src="https://github.com/user-attachments/assets/d7a1c37e-63cf-4417-ac61-c6db39a33851" /> ## 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 Co-authored-by: iamlewis <lewismmmm@gmail.com> |
||
|
|
f362e47413 |
Cancel nukes when accepting alliance via radial menu (#3155)
Resolves #3154 ## Description: #2716 introduced nuke cancellation logic on alliance acceptance via `AllianceRequestReplyExecution`. The radial menu action, though, calls `AllianceRequestExecution` instead, which accepts the alliance if a request has already been made by the other player. This PR moves the nuke cancellation logic to `GameImpl`, hooking into the `acceptAllianceRequest` method, therefore accounting for every alliance acceptance, regardless of the specific action that brought to that. ## 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 |
||
|
|
cb6e97ed11 |
Add Leaderboard refresh time (#3190)
## Description: I added a small refresh time text (see screenshots below). > I play ranked a lot since it's been added and I just reached the top 100 (yay !!), I was wondering what was the refresh time so after I found it in the code, I wanted to add a small text for easier understanding :) <details> <summary><h2>Open Screenshots "players" here</h2></summary> Before "players" : <img width="622" height="645" alt="image" src="https://github.com/user-attachments/assets/d3335954-8e16-4465-b09f-89d03defe643" /> After "players" : <img width="628" height="637" alt="image" src="https://github.com/user-attachments/assets/fd89df53-0942-4869-bfb5-9c7e7497af38" /> </details> This can be edited as you want but I did not added the text in the "clans" section. I did not added any test in the tests files since this is a minor UI improvement, but I can if needed, And I do tested everything locally myself to take the screenshots :) ## 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: @noleet |
||
|
|
07e13b3479 |
Fix: remove alliances on death (#3168)
## Description: - Remove alliances on death: after death, alliances would stay active including countdown timers and (when dead player kept spectating) icons. Now remove them when player becomes inActive. - Moved code to private method within PlayerExecution + added comments in NationExecution and BotExecution for more clarity as to where removals are performed from at death - Remove renewal request from Events Display when Alliance doesn't exist anymore (after death or otherwise). - Also cleanup this.alliancesCheckedAt when alliance doesn't exist anymore. Before, old/broken alliance id's would accumulate in it during a game. - Removed now-redundant isAlive check in EventsDisplay. Both the alliances array as the isAlive are updated in the same tick from PlayerUpdates so now alliance is removed from alliances array on player death, the other.isAlive() check is no longer needed. Of course we could keep it in just to be very safe, so just let me know when you're doubtful about this. - Attack.test.ts: fix failing test. Player B dies because of the attack, meaning the alliance now gets removed. Prevent this by gving both a different, adjecent, starting tile. And to be more clear about what is needed for the test to pass, add isAlive check for both of them after the attacks. ## 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 |
||
|
|
6cc0ef7d14 |
Add PVP immunity to 5M starting gold modifier games 🔧 (#3180)
## Description: Adds 30 seconds of PVP immunity to 5M starting gold modifier games. So you cannot insta-nuke other players. Because I'm sure people would be confused "I cannot attack!!!!" I added a HeadsUpMessage which informs about the PVP immunity. We already have a ImmunityTimer progress bar but I don't think its enough. <img width="1270" height="745" alt="image" src="https://github.com/user-attachments/assets/0ee23dc4-1c7b-4d62-8b3d-8de214f03c2b" /> I had a second count in the HeadsUpMessage (seconds until PVP immunity is over) but it felt too busy. So I removed it. You can tell when PVP immunity is over by looking at the progress bar. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [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> |