Merge branch 'main' into icslucas-patch-1

This commit is contained in:
Drills Kibo
2025-08-05 04:53:42 +02:00
committed by GitHub
94 changed files with 4075 additions and 1030 deletions
+2
View File
@@ -2,6 +2,8 @@ name: 🧪 CI
on:
merge_group:
types:
- checks_requested
pull_request:
push:
branches: [main]
+20 -6
View File
@@ -1,6 +1,9 @@
name: 🧼 PR
on:
merge_group:
types:
- checks_requested
pull_request:
types:
- demilestoned
@@ -20,6 +23,11 @@ jobs:
- uses: actions/github-script@v7
with:
script: |
if (context.eventName === 'merge_group') {
// Ignore merge_group events
return;
}
const body = context.payload.pull_request.body || '';
const errors = [];
@@ -60,10 +68,16 @@ jobs:
- uses: actions/github-script@v7
with:
script: |
// Get the pull request data
const milestone = context.payload.pull_request.milestone;
if (!milestone) {
core.setFailed('❌ Pull request must have a milestone assigned before merging.');
return;
if (context.eventName === 'merge_group') {
// Ignore merge_group events
} else if (context.eventName === 'pull_request') {
// Get the pull request data
const milestone = context.payload.pull_request.milestone;
if (!milestone) {
core.setFailed('❌ Pull request must have a milestone assigned before merging.');
return;
}
console.log(`✅ Milestone found: ${milestone.title}`);
} else {
core.setFailed(`❌ Unknown event type ${context.eventName}.`);
}
console.log(`✅ Milestone found: ${milestone.title}`);
+1
View File
@@ -9,3 +9,4 @@ resources/.DS_Store
.env*
.DS_Store
.clinic/
CLAUDE.md
+492 -2
View File
@@ -1,3 +1,493 @@
# header
- This is a sample changelog based off of v0.24.0.
- This file will be replaced with real release notes during the release build process.
- Indented bullets look like this
changelog here
📦 **OpenFront v24 Changelog**
⚖️ **Balance Changes**
- Trade ships are now capped at 150 (Evan)
→ Each port you own now increases the gold per trade, counterbalancing the cap.
- MIRVs have been nerfed
→ Expect less devastating multi-warhead nukes. Land in-between the fallout can be more quickly conquered.
- Warships prioritize enemy transport ships over warships. Reload instantly after shooting a transport ship. (Evan)
- Building discounts can only be used one time.
- AI nukes now avoid SAM launchers
🚅 **Major Features**
- Trains added for new movement mechanics (experimental for private lobbies and single player) (DevelopingTom)
- Factories spawn trains and railroads (choose Factory as unit in private lobby or for single player, to use trains)
- Railroads can form loops
- Added Trios and Quads. Add them to public lobby rotation together with Duos. (FakeNeo)
- Upgradable structures: Cities, Ports, SAMs, and Silos can now be improved
- Multi-level radial menu with dynamic build options
- Creative Commons License added to non-commercial resources
- Factories added for private lobbies and single player games
- Hash-based routing implemented
- Flares system implemented
- GitHub Releases with release notes are now supported (click the What's New button/megaphone icon)
🔧 **Game Improvements**
- Improved territory drawing performance
- SAMs now only target nukes threatening nearby areas
- Nukes are now faster (speed increased from 4 → 6)
- Better color mixing for small player counts (Ble4Ch)
- Unique player colors to avoid confusion (Ble4Ch)
- Better and optimized bot behaviour and spawn logic (tryout33 & FakeNeo)
- Boat build discounts now scale with unit ownership
- Improved username censoring and management
- Updated East Asia map (formerly "Japan and Neighbors")
- Reworked and optimized leaderboard UI
- Improved visual clarity for alliances and stacked buildings
🔧 **Game Improvements (continued)**
- Better handling for betrayal alerts and radial menu behavior
- Red alert frame when betrayed (devalnor)
- Attack hotkeys added (Engla)
- Boat hotkey added
- Nations can spawn cities without a port
- Team sizes now equalized
- MIRV warhead intercepted stats are now recorded
- Text FX added
- Terrain manipulation for attack advantage
- New logo added
- Fix Duo partner (Nation) always same in Single player (tryout33)
- Rename Replay Speed to Game Speed for Single player (tryout33)
- Fix Nations building more than allowed (tryout33)
🧪 **UI & Quality of Life**
- Fixed text overflow in UI (Diessel)
- Fixed websocket and join bugs
- Fixed boat-on-land issues
- Fixed modal errors and null pointer warnings
- Fixed input handler edge cases on Mac (proper modifier and emoji key detection) (Ble4Ch)
- Fixed scrollbar appearing unnecessarily in small boxes on Chromium browsers
- Fixed giant world map key
- Leaderboards, alerts, and modals now support translation & dark mode
- New custom flag support and pattern icons
- Various patterns available (Sword, Shells, White Rabbit, Goat, Cats, Hand, Radiation, Cursor, QR)
- Patterned territory support
- More responsive scrollbar and player info panels
- Top bar redesign (Diessel)
- More responsive design for in-game elements
- New icon layer/sprites for structures
- Building/loading/HP bars improved
- Proper alliance timer naming
- Logout button added
- Handle not spawned player fixes
- Multiple patterns support
- Fix: anonymized name isn't displayed in chat message (tryout33)
- Fix Leaderboard: show 0% instead of NaN when all terrain is nuked (tryout33)
- Some fixes to the new Radial menu (tryout33)
- Fix bug/performance improvements for trade ships (tryout33)
- News Notification Badge for new release notes (floriankilian)
- Translation improvements
🛠️ **Backend & Technical**
- Stats endpoints are now available
- Added CORS origin headers
- Added support for mobile apps native login
- Discord user and guild member caching
- Improved session error handling
- Changed server logging
- Improved data loading and fixed various bugs
🔒 **Security & Bug Fixes**
- Fixed naval attack spam exploit
- Fixed gold donation validation exploit
- Fixed pot issue
- Various stability improvements and bug fixes
🌐 **Translations**
- Bulgarian🇧🇬: Nikola123 & NewHappyRabbit
- Japanese🇯🇵: Aotumuri, daimyo_panda2 & gafunuko
- French🇫🇷: cldprv, gx21 & r3ms
- Dutch🇳🇱: cldprv & tryout33
- German🇩🇪: Pilkey, jacks0n, floriankilian, Fibig & Texxter
- Spanish🇪🇸: 6uzm4n
- Russian🇷🇺: Rulfam
- Ukrainian🇺🇦: Rulfam
- Polish🇵🇱: zibi, RinkyDinky & Rulfam
- Serbo-Croatian🇷🇸🇭🇷🇧🇦🇲🇪: Vekser
- Italian🇮🇹: frappa10 & Lollosean
- Brazilian Portuguese🇧🇷: theskeleton4393 & juliosilvaqwerty5
- Turkish🇹🇷: Toyatak
- Arabic🇸🇦: N0ur, Moha & SyntaxPM
- Swedish🇸🇪: Moha, theangel2 & Keevee
- Hindi🇮🇳: sheikh
- Bengali🇧🇩: sheikh
- Esperanto: r3ms
- Toki Pona: Makonede
- Slovak🇸🇰: extraextra
- Czech🇨🇿: Xaelor & erinthegirl
- Hebrew🇮🇱: Goblinon
- Finnish🇫🇮: Tanepro193
- Korean🇰🇷: Jinyoon
- Danish🇩🇰: NiclasWK
- Chinese Simplified🇨🇳: Moki
- Galician: toldinsound
## What's Changed
- Bugfix: don't allow other players to move warships by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/879
- Proper alliance timer naming by @tnhnblgl in https://github.com/openfrontio/OpenFrontIO/pull/886
- Add naval combat animations by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/858
- Use array index access instead of .at by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/889
- mls by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/888
- Revert "add addinplay ads" by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/897
- Fix Toki Pona by @Duwibi in https://github.com/openfrontio/OpenFrontIO/pull/898
- remove player id from Schemas, fix archive bug by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/907
- Unit menu by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/867
- Convert stats to bigints by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/909
- Flag fixes for Europe map and for Brittany in flag menu and Gateway To the Atlantic map by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/910
- Add deploy concurrency configuration by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/911
- Add Github Logo on footer by @LucasLion in https://github.com/openfrontio/OpenFrontIO/pull/875
- Revert "Population visualization (#842)" by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/908
- floor by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/913
- remove known world by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/914
- Main menu UI cleanup by @Demonessica in https://github.com/openfrontio/OpenFrontIO/pull/857
- Improve territory drawing performances by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/696
- bug: Clicking out of bounds throws uncaught exception by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/920
- Removes CSS rule causing performance issues by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/925
- Always delete tradeship on pathfinding fail by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/921
- Fix bigint serialization error by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/916
- Revert tradeship path caching by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/927
- Meta Adjustments from [UN] clan test by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/932
- fix alternate view regression by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/937
- fix warship targetting range by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/938
- Add instructional overlay message during spawn phase by @spicydll in https://github.com/openfrontio/OpenFrontIO/pull/934
- Add test coverage script by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/929
- Added two checkboxes to the default pull request template by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/930
- Fix slow singleplayer timer by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/943
- improved perfomance of PseudoRandom by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/933
- Change deploy concurrency group by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/944
- Set singleplayer gitCommit in the client by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/945
- Simplify bots retaliation logic by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/946
- Add close label by @drillskibo in https://github.com/openfrontio/OpenFrontIO/pull/949
- Remove ClientID from GameRenderer by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/878
- Resolve code scanning warning about HTML injection by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/953
- Fix invalid username popup being behind public game button by @Demonessica in https://github.com/openfrontio/OpenFrontIO/pull/951
- Server role lookup by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/954
- Flag fixes in several maps by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/957
- Fix map jsons by @Duwibi in https://github.com/openfrontio/OpenFrontIO/pull/960
- change defaults to reflect meta by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/942
- Even more flag flair by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/959
- Only load tiles when viewed by player by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/887
- Hide login button by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/965
- Fix discord user schema by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/969
- Balance Adjustment for Attack Mechanism by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/973
- Prevent Attack Spam by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/977
- Update HeadsUpMessage.ts to support translations by @spicydll in https://github.com/openfrontio/OpenFrontIO/pull/981
- Cap lobby sizes at 150 by @Duwibi in https://github.com/openfrontio/OpenFrontIO/pull/984
- Fix Translations showing as untranslated keys by @Duwibi in https://github.com/openfrontio/OpenFrontIO/pull/983
- Another Balance Change by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/987
- make bots weaker by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/985
- Remove shield icon from bots by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/986
- Balance Update by @1brucben in https://github.com/openfrontio/OpenFrontIO/pull/996
- Revert meta by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1002
- Fix text overflow in instructions for longer translations by @ERHash in https://github.com/openfrontio/OpenFrontIO/pull/971
- Add dynamic sorting to leaderboard by tiles, gold, and troops by @ERHash in https://github.com/openfrontio/OpenFrontIO/pull/961
- Fix Player Name Monospaced Text Overflow on PlayerInfo by @ERHash in https://github.com/openfrontio/OpenFrontIO/pull/975
- Scroll bar Behavior on Chromium Browsers, c-modal_content by @andrewNiziolek in https://github.com/openfrontio/OpenFrontIO/pull/976
- Synced the single player and host files together, and fix issue withc… by @shaan150 in https://github.com/openfrontio/OpenFrontIO/pull/991
- Equalize team sizes by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/992
- Added support for dark mode icons for Alliance Request Icon and Embargo Icon by @Vermylion in https://github.com/openfrontio/OpenFrontIO/pull/993
- Use bigint for gold by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1000
- Fix : Donation when max pop already reached by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/904
- Validate incoming API data with zod by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/891
- this is a fix for the "possibly null" error. dosent seem to cause runtime issues but does cause the compiler to throw an error by @Jerryslang in https://github.com/openfrontio/OpenFrontIO/pull/1005
- Fixnukeboatbug by @rldtech in https://github.com/openfrontio/OpenFrontIO/pull/1011
- added ratio controls by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/963
- Add a status check for the milestone field by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1029
- Fix discord login issue by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1028
- Changed consolex to console logging by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/1036
- Center map on start by @Demonessica in https://github.com/openfrontio/OpenFrontIO/pull/1013
- Rev: Update "Japan and Neighbors" map to "East Asia" by @andrewNiziolek in https://github.com/openfrontio/OpenFrontIO/pull/1007
- Close socket on ClientMessageSchema, improve zod error by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1003
- SAMs should target only nukes aimed at nearby targets by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1038
- AI nukes avoid SAM launchers by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1045
- Show alliances on the PlayerPanel by @Maaxion in https://github.com/openfrontio/OpenFrontIO/pull/1053
- Improve readability of alliance acceptation logic for bots and add tests by @Nephty in https://github.com/openfrontio/OpenFrontIO/pull/1049
- [Cleanup] Pass Player into execution constructor instead of PlayerID by @LJoyL in https://github.com/openfrontio/OpenFrontIO/pull/1022
- Monitoring client connections by @aqw42 in https://github.com/openfrontio/OpenFrontIO/pull/941
- have master create tunnels for all workers #780 by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1042
- Add Boat hotkey by @tnhnblgl in https://github.com/openfrontio/OpenFrontIO/pull/1060
- bug: logout by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1073
- fix cloudflare tunnels by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1076
- Duo partner SP always same: randomize players before team assignment by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1051
- Multi-level radial menu by @oleksandr-shysh in https://github.com/openfrontio/OpenFrontIO/pull/1018
- Fix broken flag images by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1078
- kick existing client when duplicate persistent id is found by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1077
- Update PlayerImpl.ts by @E-EE-E in https://github.com/openfrontio/OpenFrontIO/pull/1079
- Add back #646 - trade ship gold by travelled distance by @Maaxion in https://github.com/openfrontio/OpenFrontIO/pull/1085
- #1086 prevent clicking on other structures than your own by @Maaxion in https://github.com/openfrontio/OpenFrontIO/pull/1087
- rename Event interface -> GameEvent by @Maaxion in https://github.com/openfrontio/OpenFrontIO/pull/1094
- refactor radial, fix boat on terra nullius not working fixes by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1095
- Disable donations public ffa matches by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1097
- Nations can spawn cities without a port by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1072
- Ci coverage by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1099
- Revert "Ci coverage" by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1101
- Add filters tabs to EvensDisplay to let users filter events by @Maaxion in https://github.com/openfrontio/OpenFrontIO/pull/1080
- Fix bug in FakeHumanExecution by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1102
- Fix: Hide username validation error in-game by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1110
- cloudflare fixed tunnel name by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1096
- Remove duplicate gold accumulation in team stats calculation by @rldtech in https://github.com/openfrontio/OpenFrontIO/pull/1010
- Optimizations for botbehaviour by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1114
- fix: correct mac modifier and emoji key detection in input handler by @Ble4Ch in https://github.com/openfrontio/OpenFrontIO/pull/1118
- fix duplicate websocket handler by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1124
- Adding unit info modal translation support. by @its-sii in https://github.com/openfrontio/OpenFrontIO/pull/1122
- increase nuke speed from 4 to 6 by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1125
- Avoid using as to cast values by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1115
- Fix Māori flag name by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1133
- use newer attack, delete existing attack by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1134
- counter attack doesn't cancel out attack by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1132
- Move version and changelog to files by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1109
- Fix non valid SafeString flag codes by @ghisloufou in https://github.com/openfrontio/OpenFrontIO/pull/1135
- Add a Replay speed control feature by @ghisloufou in https://github.com/openfrontio/OpenFrontIO/pull/1106
- Add progress bars to show loading time and healthbars by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1107
- feat: assign unique colors for players by @Ble4Ch in https://github.com/openfrontio/OpenFrontIO/pull/1063
- lazy loading and current data var by @falcolnic in https://github.com/openfrontio/OpenFrontIO/pull/988
- fix(client): use the right language-modal selector by @ghisloufou in https://github.com/openfrontio/OpenFrontIO/pull/1136
- Simple Upgradable Structures (Cities, Ports, SAMs and Silos) by @Egraveline in https://github.com/openfrontio/OpenFrontIO/pull/1012
- Rename Replay speed to Game speed in Singleplayer by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1145
- discriminatedUnion by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1130
- Fixed bad translation string bug for unit info modal. by @its-sii in https://github.com/openfrontio/OpenFrontIO/pull/1143
- fix timer overflow by @DiesselOne in https://github.com/openfrontio/OpenFrontIO/pull/1148
- optimize leaderboard by @DiesselOne in https://github.com/openfrontio/OpenFrontIO/pull/1151
- Fix regression cooldown bars by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1154
- favor transport ships, no reload penalty by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1153
- dynamic radial menu build options by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1152
- Update building images and adjust border/territory radii for unit configuration by @rldtech in https://github.com/openfrontio/OpenFrontIO/pull/1037
- Fixed quick chat text injection by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1144
- Rework leaderboard and team stats by @DiesselOne in https://github.com/openfrontio/OpenFrontIO/pull/1164
- Extend token lifetime to 3 days by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1172
- Redraw stacked buildings sprites by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1170
- Fix Nations building more than allowed by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1176
- Set a targetable status for nukes by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1174
- fixed giantworldmap key by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1188
- Fix Leaderboard: convert NaN into 0% by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1190
- Update pr-description regex by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1181
- discriminatedUnion by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1193
- UsernameSchema, FlagSchema by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1185
- feat: colors are better mixed up when players count is low by @Ble4Ch in https://github.com/openfrontio/OpenFrontIO/pull/1149
- Improve handling of HTTP 401 by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1194
- increase worker connections by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1179
- Fix: Handle not spawned player focus by @tnhnblgl in https://github.com/openfrontio/OpenFrontIO/pull/1186
- Fix Radial menu undefined params error during spawn phase by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1192
- Better handling of bad tokens by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1180
- Hash-based routing by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1198
- cache busting: Import version, changelog by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1201
- REV - Improved Username Censoring by @andrewNiziolek in https://github.com/openfrontio/OpenFrontIO/pull/1119
- Jest v30 by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1206
- Release workflow by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1202
- Fix unnecessary join check by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1209
- fix websocket error by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1208
- add playwire ads by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1128
- Update webpack-dev-server to 5.2.2 by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1207
- Add a 30 minute timeout to actions by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1210
- Update release workflow by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1212
- update leaderboard align by @DiesselOne in https://github.com/openfrontio/OpenFrontIO/pull/1189
- Fix gutter ads, move in-game add to bottom right corner. by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1214
- have worker send error back to client by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1178
- Fix build menu on water tile by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1216
- Update default version number by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1218
- Schema cleanup by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1219
- ads on death screen by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1223
- Delay win modal by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1224
- Dependency removals and updates by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1215
- add spawn ads by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1228
- upgrade to zod 4 by @omrih4 in https://github.com/openfrontio/OpenFrontIO/pull/1161
- Record MIRV warhead intercepted stats, perf improvements by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1220
- Simplfiy LangSelector by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1226
- Pot issue fix by @tnhnblgl in https://github.com/openfrontio/OpenFrontIO/pull/1233
- Logout Button Fix by @tnhnblgl in https://github.com/openfrontio/OpenFrontIO/pull/1234
- fix bad tile crash by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1237
- fix is valid ref by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1240
- Remove babel-jest from devDependencies by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1247
- Refactor radial menu by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1246
- Simplify ClientMessage handling by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1235
- Add trains by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1159
- Add back the trade ship send stat by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1253
- Remove maxTokenAge by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1255
- Patterned territory by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/786
- Discounts can only be used one time by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/892
- Fix singleplayer check by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1260
- Move maps generation out of repo, new map structure by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1256
- Show a red alert frame when the player is betrayed by @devalnor in https://github.com/openfrontio/OpenFrontIO/pull/1195
- Allow boat discount based on number of units owned by @devalnor in https://github.com/openfrontio/OpenFrontIO/pull/1261
- Move map metadata to map manifest by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1262
- Refactor cosmetics.json by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1263
- bug: StatsSchema zod validation error by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1267
- White Rabbit pattern by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1264
- Cleanup log spam in TerritoryPatternsModal by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1269
- Fix pattern locking logic by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1270
- Keybind Ground Attack by @dengh in https://github.com/openfrontio/OpenFrontIO/pull/1258
- UrlEncode patterns in cosmetics.json by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1273
- improve astar perf by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1268
- Log public id by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1278
- clarify license by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1277
- Fix sam targetting everything by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1280
- Add Creative Commons License to resources/non-commercial by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1284
- Sword pattern by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1283
- Display OFM25 ad in WinModal by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1281
- QR code pattern by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1288
- custom flag (1) by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1257
- Allow railroad loops by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1274
- patterns by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1290
- Split build & deploy scripts by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1239
- New icons by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1287
- Add GitHub deployment support by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1291
- bug: Fix version number and changelog by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1293
- Revert "counter attack doesn't cancel out attack (#1132)" by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1301
- Graceful handling of ping before join by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1295
- refactor cosmetics out of PlayerInfo by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1299
- Remove unused MON\_\* credentials by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1304
- Add new patterns by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1294
- Fix error-modal filling up the whole screen by @fraxxio in https://github.com/openfrontio/OpenFrontIO/pull/1298
- Reapply "enable otel logs and metrics for staging environments" by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1310
- Separate prod release environments by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1311
- Change news title to release notes by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1312
- Add localization support for leaderboard and team-related UI elements by @TomaszOleszko in https://github.com/openfrontio/OpenFrontIO/pull/1308
- Better In Game UI by @DiesselOne in https://github.com/openfrontio/OpenFrontIO/pull/1243
- w-320 by @PilkeySEK in https://github.com/openfrontio/OpenFrontIO/pull/1316
- Patterns by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1318
- Show structure levels by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1305
- fix alliance expired message by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1323
- Mark train stations and factories as experimental by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1309
- allow alliance extension Fixes #491 by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1314
- Additional patterns and subclass creation by @Sgt-lewis in https://github.com/openfrontio/OpenFrontIO/pull/1327
- fix healthbars not being removed by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1329
- lighten pattern by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1326
- custom flag (2) by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1303
- Make patterns puchasable with stripe by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1313
- Improve icons readability by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1321
- remove select on hover by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1330
- Fix role lookup by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1335
- Extend winner schema by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1333
- mls 4.0 by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1336
- upgrade unit when building a unit of same type by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1328
- remove unit menu by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1338
- unit upgrade minor improvements by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1337
- Add gold fx when a tradeship lands by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1322
- validate coords in construction execution by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1339
- fix pattern and role bugs by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1343
- Disable trains in public games by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1342
- Add levels on structure sprites by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1346
- Automatic train stations by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1353
- Quads by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1347
- Quads fix by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1356
- Revert "enable otel logs and metrics for staging environments" by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1358
- alliance renewal: fix request to renew when ally is dead, fix translation keys by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1359
- Add new icon shapes and filter for filtering icons on the layer by @jrouillard in https://github.com/openfrontio/OpenFrontIO/pull/1348
- upgrades not counting towards building discount bugfix by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1361
- Add strait of Gibraltar and Italia maps by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1363
- Radial menu: remove player info sub-radial by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1362
- move unit display to bottom of screen by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1365
- Move settings to it's own modal by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1366
- update ui by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1368
- Add localization support for game events, settings, and UI text elements by @TomaszOleszko in https://github.com/openfrontio/OpenFrontIO/pull/1372
- Validate incoming parameters by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1371
- Add domain, subdomain to GameRecord by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1370
- bugfix: Crash during replay by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1375
- fix top bar small screens by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1377
- add domain and subdomain for dev env by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1379
- fix pop delta number in TopBar by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1373
- Add expand ratio to bot behavior class by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1376
- bugfix: Crash by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1381
- Don't erase patterns on page load by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1383
- Require login to connect to staging by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1360
- feat(news-button): highlight button when new version is available by @floriankilian in https://github.com/openfrontio/OpenFrontIO/pull/1385
- Fix local development by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1388
- fixed Custom Flags via Path Traversal by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1384
- fix odd dimension maps by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1389
- Improve unit updates & reloading by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1394
- update meta by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1397
- port execution bugfixes by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1400
- Internationalization: Add i18n support for login/auth messages in main by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1406
- Update README.md by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1407
- Redraw existing railroads when redrawing the complete layer by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1410
- Unit count by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1402
- fix color allocator not selecting distinct colors by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1404
- mls (v4.1) by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1357
- remove levels player overview panel by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1414
- Remove top bar & revert control panel by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1415
- move player overview higher up by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1418
- have mirv attack enemy units by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1419
- fix team bar by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1422
- fix team bar by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1425
- Leaderboard improvements by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1424
- radial menu attack self bugfix by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1426
- remove radial animation, fix back button by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1427
- Factory spawns trains by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1408
- Followup: news-button: blue-glow; simpler localStorage by @floriankilian in https://github.com/openfrontio/OpenFrontIO/pull/1431
- fix unit upgrade not considering cost by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1434
- Enable @typescript eslint/prefer nullish coalescing eslint rule by @g-santos-m in https://github.com/openfrontio/OpenFrontIO/pull/1420
- Eslint by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/998
- Restore nation AI by @scottanderson in https://github.com/openfrontio/OpenFrontIO/pull/1440
- fix number of land tiles fixes #1409 by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1445
- Have radial menu refresh when open by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1437
- make radial menu thicker by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1446
- Fix: anonymized name isn't used in chat message by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1265
- Revert MIRV attacks enemy units by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1452
- Tradeship performance by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1448
- Fix: "Game speed" not "Replay speed" during Single player game by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1457
- Update asset license by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1458
- Fix: attack on ally even with greyed out button by @VariableVince in https://github.com/openfrontio/OpenFrontIO/pull/1460
- Create CLA.md by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1462
- update pr template to have CLA checkbox. by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1465
- Increase trade ship spawn rate by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1455
- Increase traitor punishment by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1456
- fix team leaderboard margin by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1469
- leaderboard bugfix: show by default for medium to large screens. by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1470
- fix control panel & events display scaling on mobile by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1471
- alert on ws 1002 error by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1472
- Fix Regex to allow Umlaute "üÜ" in username by @floriankilian in https://github.com/openfrontio/OpenFrontIO/pull/1466
- Have port destination likelihood scale with level by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1473
- remove spawn ad by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1482
- fix squad allocator color palette by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1483
- bug fix?: Hide UnitDisplay frame when all unit types are disabled by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1392
- fix pop & gold not showing up on mobile UI by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1484
- meta: reduce port gold multiplier & trade ship frequency by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1486
- Fix language code mismatch during language switching by @Aotumuri in https://github.com/openfrontio/OpenFrontIO/pull/1416
- Add close button to emoji table by @DevelopingTom in https://github.com/openfrontio/OpenFrontIO/pull/1479
- increase MIRV to 35M by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1487
- increase player panel z index so it is on top of spawn timer by @evanpelle in https://github.com/openfrontio/OpenFrontIO/pull/1488
## New Contributors
- @LucasLion made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/875
- @spicydll made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/934
- @falcolnic made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/933
- @drillskibo made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/949
- @ERHash made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/971
- @andrewNiziolek made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/976
- @shaan150 made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/991
- @Vermylion made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/993
- @Jerryslang made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1005
- @rldtech made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1011
- @Maaxion made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1053
- @Nephty made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1049
- @LJoyL made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1022
- @oleksandr-shysh made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1018
- @E-EE-E made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1079
- @Ble4Ch made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1118
- @its-sii made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1122
- @ghisloufou made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1135
- @Egraveline made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1012
- @omrih4 made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1161
- @devalnor made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1195
- @dengh made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1258
- @fraxxio made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1298
- @TomaszOleszko made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1308
- @Sgt-lewis made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1327
- @floriankilian made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1385
- @g-santos-m made their first contribution in https://github.com/openfrontio/OpenFrontIO/pull/1420
**Full Changelog**: https://github.com/openfrontio/OpenFrontIO/compare/v0.23.19...v0.24.0
-24
View File
@@ -1,24 +0,0 @@
Admin 1286738076386856991
OG 1286743849707769936
Creator 1286745100411473930
Bots 1286910984702791711
Challenger 1292157381496799264
OG100 1314802550314237952
Contributor 1314972008362020957
Ping 1316444187276738612
Server Booster 1319387513206345770
Content Creator 1320961080750637076
Beta Tester 1327125593791397929
Early Access Supporter 1330243292306341969
Mod 1338654590043820148
Support Staff 1343759662545244296
DevChatAccess 1345831753528377425
Member 1347621713852235808
Active Contributor 1354828445489692692
Retired Staff 1355753028099117147
Head Mod 1357747869742010661
Money Haters 1359441841371480176
Translator 1367345579272831128
Head Translator 1367345660852174930
Development Stream Ping 1369340951109304340
Core Contributor 1370238576868200488
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">
<image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAADICAYAAACZBDirAAAAAXNSR0IArs4c6QAACp1JREFUeF7t3U2InWcVB/D/NPRjIxYqtBSloaR+ZNMPCraCdEAhWSnSjSBUjTR1pYgNzaZoEbGlinanKYmlGxWsS0lAoaELg4JEkFZsGhoUMdBCFiLWmo55U0dCdfHc4cy9c3h/A6GQPnPe8/zOy5+bmfs+dy3/+3VfkgeSrCe59f/8f39FgACBTgJnL+XZ85fy7NkkJ69sfO0duziS5MFOO9MrAQIEFhB4OsnBzfVXBuDxJPsWKGQpAQIEOgqcSLJ/anwzAL3y6zhGPRMgsFWBy68EpwCcfuY3/fvYFwECBOYksD4F4NEkB+a0a3slQIBAkmNTAL7it71uBgIEZihwdgrAjRlu3JYJECBw+ZcgAtCNQIDALAUE4CzHbtMECEwCWw7Ae+5IPn9/8qE9yd49yQ3XAyVAgMByBF6/kLx45u0/zzyXnDq9tesuHIDXXZs89uXkqweSXbu2dlHfRYAAgSqBixeTbx9NHv1u8ua/Fqu6UADe/sHkJ08lH/CE8GLKVhMgsO0Cv/tD8tlDyfTf0a+FAvDxQ8kj/32KbvQS1hEgQGA5Ak8cSQ4/OX6t4QD8yF3JCz9KrrpqvLiVBAgQWKbAW28ld31y/FXgcAB+/xvJQ59e5lZciwABAosL/ODHyRcfHfu+4QA89dPkw7ePFbWKAAECqxI4+etk/TNjVx8OwAu/Td79rrGiVhEgQGBVAudfS266d+zqwwG48fJYQasIECCwaoG128Y6EIBjTlYRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIQAA2GpZWCRCoFRCAtZ6qESDQSEAANhqWVgkQqBUQgLWeqhEg0EhAADYallYJEKgVEIC1nqoRINBIoD4AzzXavVYJEJi1wNotY9tfS7IxsvSfv7x6ZJk1BAgQWLnANR97c6iH4QD8y8dvHCpoEQECBFYtcPMvzg+1MByAr+5+31BBiwgQILBqgd2v/mmoheEAPPf+9w4VtIgAAQKrFrjlj38eakEADjFZRIBAJwEB2GlaeiVAoFRAAJZyKkaAQCcBAdhpWnolQKBUQACWcipGgEAnAQHYaVp6JUCgVEAAlnIqRoBAJ4HyAPRG6E7j1yuBeQuUvxHao3DzvqHsnkAngfJH4RyG0Gn8eiUwb4HywxA2HIc17zvK7gk0Eig/Dmvj5Ua71yoBArMWqD8QVQDO+oayeQKdBARgp2nplQCBUgEBWMqpGAECnQQEYKdp6ZUAgVIBAVjKqRgBAp0EBGCnaemVAIFSAQFYyqkYAQKdBOoD0BuhO81frwRmLVD+RmiPws36frJ5Aq0Eyh+FcxhCq/lrlsCsBcoPQ3Ac1qzvJ5sn0Eqg/Dgsnwvcav6aJTBrgfIDUQXgrO8nmyfQSkAAthqXZgkQqBQQgJWaahEg0EpAALYal2YJEKgUEICVmmoRINBKQAC2GpdmCRCoFBCAlZpqESDQSqA8AL0RutX8NUtg1gLlb4T2KNys7yebJ9BKoPxROIchtJq/ZgnMWqD8MASfCzzr+8nmCbQSKD8Oy+cCt5q/ZgnMWqD+QFSfCzzrG8rmCXQSEICdpqVXAgRKBQRgKadiBAh0EhCAnaalVwIESgUEYCmnYgQIdBIQgJ2mpVcCBEoFBGApp2IECHQSqA9Anwvcaf56JTBrgfI3QnsUbtb3k80TaCVQ/iicwxBazV+zBGYtUH4YguOwZn0/2TyBVgLlx2H5WMxW89csgVkLlB+IKgBnfT/ZPIFWAgKw1bg0S4BApYAArNRUiwCBVgICsNW4NEuAQKWAAKzUVIsAgVYCArDVuDRLgEClgACs1FSLAIFWAuUB6I3QreavWQKzFih/I7RH4WZ9P9k8gVYC5Y/COQyh1fw1S2DWAuWHIfhc4FnfTzZPoJVA+XFYPhe41fw1S2DWAvUHovpc4FnfUDZPoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJOAAOw0Lb0SIFAqIABLORUjQKCTgADsNC29EiBQKiAASzkVI0Cgk4AA7DQtvRIgUCogAEs5FSNAoJNAeQD+9VfJje/pRKBXAgTmKHD+teSme8d2vpZkY2Tp8WPJvo+OrLSGAAECqxM48UKy/8DY9YcD8PFDySMHx4paRYAAgVUJPHEkOfzk2NWHA/C23cnvf55cc/VYYasIECCwbIG//T258xPJmXNjVx4OwKnc4YeSbz08VtgqAgQILFvgK99MvvfM+FUXCsBdu5Lf/Cy5c+/4BawkQIDAMgROv5Tc/ank4sXxqy0UgFPZ665Nvv6l5OEvJFMg+iJAgMAqBabA+86x5GtPJf94Y7FOFg7AzfL33JF87v5k7563/9xw/WIXtpoAAQJbFXj9QvLimeSlM8kPn0tOnd5apS0H4NYu57sIECCwcwQE4M6ZhU4IEFiygABcMrjLESCwcwSmAHwlya07pyWdECBAYCkCZ6cAPJpk8MGRpTTlIgQIEFiGwLEpAO9L8vwyruYaBAgQ2EEC61MATl9Hkjy4gxrTCgECBLZT4OkkBzcDcLrQ8ST7tvOKahMgQGAHCJxIsn/q48oA9EpwB0xGCwQIbKvA5Vd+m1d4ZwBOfz/9TPCBSz8XXPfb4W0dhOIECCxH4Ox/fs/xbJKTV17y3+OTpZ+KLXH6AAAAAElFTkSuQmCC" x="7.500" y="32.813" width="135.000" height="84.375" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 235 B

+7 -1
View File
@@ -26,7 +26,7 @@
"terms_of_service": "Terms of Service"
},
"news": {
"full_changelog": "See the complete change log",
"see_all_releases": "See all releases",
"github_link": "on GitHub",
"title": "Release Notes"
},
@@ -43,6 +43,7 @@
"action_move_camera": "Move camera",
"action_ratio_change": "Decrease/Increase attack ratio",
"action_reset_gfx": "Reset graphics",
"action_auto_upgrade": "Auto-upgrade nearest building",
"ui_section": "Game UI",
"ui_leaderboard": "Leaderboard",
"ui_your_team": "Your team:",
@@ -69,6 +70,7 @@
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
"radial_attack": "Open the Attack menu.",
"radial_info": "Open the Info menu.",
"radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.",
"radial_close": "Close the menu.",
@@ -275,6 +277,10 @@
"special_effects_desc": "Toggle special effects. Deactivate to improve performances",
"special_effects_enabled": "Special effects enabled",
"special_effects_disabled": "Special effects disabled",
"structure_sprites_label": "Structure Sprites",
"structure_sprites_desc": "Toggle structure sprites",
"structure_sprites_enabled": "Structure Sprites enabled",
"structure_sprites_disabled": "Structure Sprites disabled",
"anonymous_names_label": "Hidden Names",
"anonymous_names_desc": "Hide real player names with random ones on your screen.",
"anonymous_names_enabled": "Anonymous names enabled",
+477 -159
View File
@@ -12,267 +12,585 @@
"name": "Giant_World_Map",
"nations": [
{
"coordinates": [777, 540],
"flag": "us",
"name": "United States",
"strength": 3
},
{
"coordinates": [770.784, 287.776],
"flag": "ca",
"name": "Canada",
"strength": 2
},
{
"coordinates": [777, 780],
"flag": "mx",
"name": "Mexico",
"coordinates": [2309, 535],
"flag": "tr",
"name": "Türkiye",
"strength": 1
},
{
"coordinates": [1025, 744],
"flag": "cu",
"name": "Cuba",
"coordinates": [2030, 409],
"flag": "west_germany",
"name": "West Germany",
"strength": 1
},
{
"coordinates": [1085.728, 990],
"flag": "co",
"name": "Colombia",
"coordinates": [2074, 382],
"flag": "east_germany",
"name": "East Germany",
"strength": 1
},
{
"coordinates": [1228.6960000000001, 990],
"flag": "ve",
"name": "Venezuela",
"coordinates": [1966, 442],
"flag": "fr",
"name": "France",
"strength": 1
},
{
"coordinates": [1220, 1485],
"flag": "ar",
"name": "Argentina",
"strength": 1
},
{
"coordinates": [1330, 1190],
"flag": "br",
"name": "Brazil",
"strength": 1
},
{
"coordinates": [2650, 1897],
"flag": "aq",
"name": "Antarctica",
"strength": 3
},
{
"coordinates": [1469.048, 120.61200000000001],
"flag": "gl",
"name": "Greenland",
"strength": 2
},
{
"coordinates": [1721.832, 236.99200000000002],
"flag": "is",
"name": "Iceland",
"strength": 1
},
{
"coordinates": [1916.6000000000001, 393.576],
"flag": "gb",
"name": "United Kingdom",
"strength": 3
},
{
"coordinates": [1837.864, 387.228],
"flag": "ie",
"name": "Ireland",
"strength": 1
},
{
"coordinates": [1885, 550],
"flag": "es",
"coordinates": [1872, 528],
"flag": "Fascist Spain",
"name": "Spain",
"strength": 1
},
{
"coordinates": [2080.288, 529],
"coordinates": [2074, 498],
"flag": "it",
"name": "Italy",
"strength": 1
},
{
"coordinates": [1980, 455],
"flag": "fr",
"name": "France",
"strength": 2
},
{
"coordinates": [2060, 425],
"flag": "de",
"name": "Germany",
"coordinates": [1912, 379],
"flag": "gb",
"name": "United Kingdom",
"strength": 1
},
{
"coordinates": [2111, 277],
"flag": "se",
"name": "Sweden",
"coordinates": [1841, 373],
"flag": "ie",
"name": "Ireland",
"strength": 1
},
{
"coordinates": [2165, 400],
"coordinates": [2153, 378],
"flag": "pl",
"name": "Poland",
"strength": 1
},
{
"coordinates": [2205, 397.808],
"flag": "by",
"name": "Belarus",
"coordinates": [2178, 539],
"flag": "gr",
"name": "Greece",
"strength": 1
},
{
"coordinates": [2223.256, 514.188],
"flag": "ro",
"coordinates": [2222, 493],
"flag": "bg",
"name": "Bulgaria",
"strength": 1
},
{
"coordinates": [2135, 481],
"flag": "yugoslavia",
"name": "Yugoslavia",
"strength": 1
},
{
"coordinates": [2242, 461],
"flag": "Communist Romania",
"name": "Romania",
"strength": 1
},
{
"coordinates": [2405.592, 579.784],
"flag": "tr",
"name": "Turkey",
"coordinates": [2163, 441],
"flag": "hu",
"name": "Hungary",
"strength": 1
},
{
"coordinates": [2007.768, 281.428],
"coordinates": [2272, 418],
"flag": "Ukrainian SSR",
"name": "Ukrainian SSR",
"strength": 1
},
{
"coordinates": [2093, 297],
"flag": "se",
"name": "Sweden",
"strength": 1
},
{
"coordinates": [2027, 282],
"flag": "no",
"name": "Norway",
"strength": 1
},
{
"coordinates": [2200.464, 281.428],
"coordinates": [2191, 194],
"flag": "Sami flag",
"name": "Sapmi",
"strength": 1
},
{
"coordinates": [2206, 262],
"flag": "fi",
"name": "Finland",
"strength": 1
},
{
"coordinates": [2277.128, 443],
"flag": "ua",
"name": "Ukraine",
"coordinates": [2376, 363],
"flag": "Russian SSR",
"name": "Russian SSR",
"strength": 1
},
{
"coordinates": [2480, 311],
"flag": "ru",
"name": "Russia",
"strength": 3
},
{
"coordinates": [3175, 400],
"flag": "mn",
"name": "Mongolia",
"coordinates": [2222, 371],
"flag": "Byelorussian SSR",
"name": "Byelorussian SSR",
"strength": 1
},
{
"coordinates": [3170, 680],
"flag": "cn",
"name": "China",
"strength": 3
},
{
"coordinates": [2834.496, 789.268],
"flag": "in",
"name": "India",
"strength": 2
},
{
"coordinates": [2643.8720000000003, 505.72400000000005],
"flag": "kz",
"name": "Kazakhstan",
"coordinates": [2441, 507],
"flag": "Georgian SSR",
"name": "Georgian SSR",
"strength": 1
},
{
"coordinates": [2565.136, 653.844],
"flag": "ir",
"name": "Islamic Republic Of Iran",
"coordinates": [2402, 580],
"flag": "Second Republic of Iraq",
"name": "Iraq",
"strength": 1
},
{
"coordinates": [2440.8160000000003, 742.716],
"coordinates": [2353, 595],
"flag": "sy",
"name": "Syria",
"strength": 1
},
{
"coordinates": [2414, 679],
"flag": "sa",
"name": "Saudi Arabia",
"strength": 1
},
{
"coordinates": [3478, 1370],
"coordinates": [2434, 815],
"flag": "North yemen",
"name": "North Yemen",
"strength": 1
},
{
"coordinates": [2479, 824],
"flag": "south yemen",
"name": "South Yemen",
"strength": 1
},
{
"coordinates": [2554, 724],
"flag": "ae",
"name": "United Arab Emirates",
"strength": 1
},
{
"coordinates": [2532, 609],
"flag": "Pahlavi Iran",
"name": "Iran",
"strength": 1
},
{
"coordinates": [2683, 650],
"flag": "pk",
"name": "Pakistan",
"strength": 1
},
{
"coordinates": [2654, 580],
"flag": "af",
"name": "Afghanistan",
"strength": 1
},
{
"coordinates": [2727, 416],
"flag": "Kazakh SSR",
"name": "Kazakh SSR",
"strength": 1
},
{
"coordinates": [2556, 544],
"flag": "Turkmen SSR",
"name": "Turkmen SSR",
"strength": 1
},
{
"coordinates": [2947, 362],
"flag": "Zheleznogorsk",
"name": "Zheleznogorsk",
"strength": 1
},
{
"coordinates": [3252, 229],
"flag": "Siberia",
"name": "Siberia",
"strength": 1
},
{
"coordinates": [2810, 744],
"flag": "in",
"name": "India",
"strength": 1
},
{
"coordinates": [1717, 237],
"flag": "is",
"name": "Iceland",
"strength": 1
},
{
"coordinates": [2944, 709],
"flag": "bd",
"name": "Bangladesh",
"strength": 1
},
{
"coordinates": [2868, 635],
"flag": "np",
"name": "Nepal",
"strength": 1
},
{
"coordinates": [3254, 672],
"flag": "cn",
"name": "China",
"strength": 1
},
{
"coordinates": [3373, 521],
"flag": "kp",
"name": "North Korea",
"strength": 1
},
{
"coordinates": [3389, 573],
"flag": "kr",
"name": "South Korea",
"strength": 1
},
{
"coordinates": [3515, 571],
"flag": "jp",
"name": "Japan",
"strength": 1
},
{
"coordinates": [3026, 457],
"flag": "mn",
"name": "Mongolia",
"strength": 1
},
{
"coordinates": [3229, 995],
"flag": "id",
"name": "Indonesia",
"strength": 1
},
{
"coordinates": [3121, 755],
"flag": "vn",
"name": "North Vietnam",
"strength": 1
},
{
"coordinates": [3153, 833],
"flag": "South Vietnam",
"name": "South Vietnam",
"strength": 1
},
{
"coordinates": [3013, 722],
"flag": "Burma2",
"name": "Burma",
"strength": 1
},
{
"coordinates": [3095, 822],
"flag": "kh",
"name": "Cambodia",
"strength": 1
},
{
"coordinates": [3538, 1067],
"flag": "pg",
"name": "Papua New Guinea",
"strength": 1
},
{
"coordinates": [3542, 1356],
"flag": "au",
"name": "Australia",
"strength": 2
"strength": 1
},
{
"coordinates": [3880, 1516],
"coordinates": [3422, 1203],
"flag": "Australian Aboriginal Flag",
"name": "Nawan-mirri",
"strength": 1
},
{
"coordinates": [3880, 1521],
"flag": "nz",
"name": "New Zealand",
"strength": 0.5
"strength": 1
},
{
"coordinates": [1902.096, 700],
"coordinates": [2632, 1893],
"flag": "aq",
"name": "Antarctica",
"strength": 1
},
{
"coordinates": [2038, 590],
"flag": "tn",
"name": "Tunisia",
"strength": 1
},
{
"coordinates": [2116, 653],
"flag": "ly",
"name": "Libya",
"strength": 1
},
{
"coordinates": [2281, 653],
"flag": "United Arab Republic",
"name": "United Arab Republic",
"strength": 1
},
{
"coordinates": [1859, 613],
"flag": "ma",
"name": "Morocco",
"strength": 1
},
{
"coordinates": [1943, 615],
"flag": "dz",
"name": "Algeria",
"strength": 1
},
{
"coordinates": [2134.16, 680],
"flag": "ly",
"name": "Libyan Arab Jamahiriya",
"strength": 1
},
{
"coordinates": [2262.6240000000003, 708.86],
"flag": "eg",
"name": "Egypt",
"strength": 1
},
{
"coordinates": [1995.336, 867.5600000000001],
"flag": "ne",
"name": "Niger",
"strength": 1
},
{
"coordinates": [2304.064, 859.096],
"coordinates": [2317, 754],
"flag": "sd",
"name": "Sudan",
"strength": 1
},
{
"coordinates": [2225.328, 1074.928],
"flag": "cd",
"name": "The Democratic Republic of the Congo",
"coordinates": [2466, 918],
"flag": "so",
"name": "Somalia",
"strength": 1
},
{
"coordinates": [2391.088, 937.388],
"flag": "et",
"coordinates": [2352, 895],
"flag": "Imperial Ethiopia",
"name": "Ethiopia",
"strength": 1
},
{
"coordinates": [2188, 1374.0120000000002],
"flag": "za",
"coordinates": [1790, 729],
"flag": "Mauritania",
"name": "Mauritania",
"strength": 1
},
{
"coordinates": [2154, 764],
"flag": "td",
"name": "Chad",
"strength": 1
},
{
"coordinates": [2051, 745],
"flag": "ne",
"name": "Niger",
"strength": 1
},
{
"coordinates": [2040, 930],
"flag": "ng",
"name": "Nigeria",
"strength": 1
},
{
"coordinates": [1805, 907],
"flag": "lr",
"name": "Liberia",
"strength": 1
},
{
"coordinates": [2195, 918],
"flag": "cf",
"name": "Central African Republic",
"strength": 1
},
{
"coordinates": [2197, 1070],
"flag": "Zaire",
"name": "Zaire",
"strength": 1
},
{
"coordinates": [2189, 1372],
"flag": "Apartheid South Africa",
"name": "South Africa",
"strength": 1
},
{
"coordinates": [2459, 1230],
"coordinates": [2452, 1247],
"flag": "mg",
"name": "Madagascar",
"strength": 0.5
"strength": 1
},
{
"coordinates": [2170, 880],
"flag": "td",
"name": "Chad",
"coordinates": [2356, 1165],
"flag": "mz",
"name": "Mozambique",
"strength": 1
},
{
"coordinates": [2368, 1032],
"flag": "tz",
"name": "Tanzania",
"strength": 1
},
{
"coordinates": [1934, 762],
"flag": "ml",
"name": "Mali",
"strength": 1
},
{
"coordinates": [2128, 1292],
"flag": "Apartheid South Africa",
"name": "South West Africa",
"strength": 1
},
{
"coordinates": [2099, 1178],
"flag": "ao",
"name": "Angola",
"strength": 1
},
{
"coordinates": [1375, 1121],
"flag": "br",
"name": "Brazil",
"strength": 1
},
{
"coordinates": [1203, 1059],
"flag": "amazonas",
"name": "Amazonas",
"strength": 1
},
{
"coordinates": [1210, 1395],
"flag": "ar",
"name": "Argentina",
"strength": 1
},
{
"coordinates": [1107, 1419],
"flag": "cl",
"name": "Chile",
"strength": 1
},
{
"coordinates": [1064, 1114],
"flag": "pe",
"name": "Peru",
"strength": 1
},
{
"coordinates": [1065, 938],
"flag": "co",
"name": "Colombia",
"strength": 1
},
{
"coordinates": [1192, 938],
"flag": "ve",
"name": "Venezuela",
"strength": 1
},
{
"coordinates": [913, 833],
"flag": "ni",
"name": "Nicaragua",
"strength": 1
},
{
"coordinates": [788, 744],
"flag": "mx",
"name": "Mexico",
"strength": 1
},
{
"coordinates": [1011, 555],
"flag": "us",
"name": "USA",
"strength": 1
},
{
"coordinates": [800, 624],
"flag": "Texas",
"name": "Texas",
"strength": 1
},
{
"coordinates": [551, 564],
"flag": "California",
"name": "California",
"strength": 1
},
{
"coordinates": [703, 483],
"flag": "Utah",
"name": "Utah",
"strength": 1
},
{
"coordinates": [1077, 444],
"flag": "Quebec",
"name": "Quebec",
"strength": 1
},
{
"coordinates": [1231, 395],
"flag": "Newfoundland",
"name": "Newfoundland",
"strength": 1
},
{
"coordinates": [967, 418],
"flag": "ca",
"name": "Canada",
"strength": 1
},
{
"coordinates": [170, 244],
"flag": "Alaska",
"name": "Alaska",
"strength": 1
},
{
"coordinates": [741, 234],
"flag": "Nunavut",
"name": "Nunavut",
"strength": 1
},
{
"coordinates": [484, 256],
"flag": "Yukon",
"name": "Yukon",
"strength": 1
},
{
"coordinates": [1434, 223],
"flag": "gl",
"name": "Greenland",
"strength": 1
},
{
"coordinates": [2247, 1229],
"flag": "Rhodesia",
"name": "Rhodesia",
"strength": 1
}
]
+73
View File
@@ -26,6 +26,7 @@ import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import { UserSettings } from "../core/game/UserSettings";
import { WorkerClient } from "../core/worker/WorkerClient";
import {
AutoUpgradeEvent,
DoBoatAttackEvent,
DoGroundAttackEvent,
InputHandler,
@@ -40,6 +41,7 @@ import {
SendBoatAttackIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
SendUpgradeStructureIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
@@ -248,6 +250,7 @@ export class ClientGameRunner {
}, 20000);
this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this));
this.eventBus.on(MouseMoveEvent, this.onMouseMove.bind(this));
this.eventBus.on(AutoUpgradeEvent, this.autoUpgradeEvent.bind(this));
this.eventBus.on(
DoBoatAttackEvent,
this.doBoatAttackUnderCursor.bind(this),
@@ -424,6 +427,76 @@ export class ClientGameRunner {
});
}
private autoUpgradeEvent(event: AutoUpgradeEvent) {
if (!this.isActive) {
return;
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return;
}
const tile = this.gameView.ref(cell.x, cell.y);
if (this.myPlayer === null) {
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
if (this.gameView.inSpawnPhase()) {
return;
}
this.findAndUpgradeNearestBuilding(tile);
}
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
this.myPlayer!.actions(clickedTile).then((actions) => {
const upgradeUnits: {
unitId: number;
unitType: UnitType;
distance: number;
}[] = [];
for (const bu of actions.buildableUnits) {
if (bu.canUpgrade !== false) {
const existingUnit = this.gameView
.units()
.find((unit) => unit.id() === bu.canUpgrade);
if (existingUnit) {
const distance = this.gameView.manhattanDist(
clickedTile,
existingUnit.tile(),
);
upgradeUnits.push({
unitId: bu.canUpgrade,
unitType: bu.type,
distance: distance,
});
}
}
}
if (upgradeUnits.length > 0) {
upgradeUnits.sort((a, b) => a.distance - b.distance);
const bestUpgrade = upgradeUnits[0];
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
bestUpgrade.unitId,
bestUpgrade.unitType,
),
);
}
});
}
private doBoatAttackUnderCursor(): void {
const tile = this.getTileUnderCursor();
if (tile === null) {
+62 -126
View File
@@ -1,149 +1,85 @@
import { UserMeResponse } from "../core/ApiSchemas";
import { COSMETICS } from "../core/CosmeticSchemas";
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
import { getApiBase, getAuthHeader } from "./jwt";
import { translateText } from "./Utils";
interface StripeProduct {
id: string;
object: "product";
active: boolean;
created: number;
description: string | null;
images: string[];
livemode: boolean;
metadata: Record<string, string>;
name: string;
shippable: boolean | null;
type: "good" | "service";
updated: number;
url: string | null;
price: string;
price_id: string;
}
export interface Pattern {
name: string;
key: string;
roles: string[];
price?: string;
priceId?: string;
lockedReason?: string;
notShown?: boolean;
}
export async function patterns(
userMe: UserMeResponse | null,
): Promise<Pattern[]> {
const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map(
([key, patternData]) => {
return {
name: patternData.name,
key,
roles: patternData.role_group
? (COSMETICS.role_groups[patternData.role_group] ?? [])
: [],
};
},
);
const cosmetics = await getCosmetics();
const products = await listAllProducts();
patterns.forEach((pattern) => {
addRestrictions(pattern, userMe, products);
});
if (cosmetics === undefined) {
return [];
}
const patterns: Pattern[] = [];
const playerFlares = new Set(userMe?.player.flares);
for (const name in cosmetics.patterns) {
const patternData = cosmetics.patterns[name];
const hasAccess = playerFlares.has(`pattern:${name}`);
if (hasAccess) {
// Remove product info because player already has access.
patternData.product = null;
patterns.push(patternData);
} else if (patternData.product !== null) {
// Player doesn't have access, but product is available for purchase.
patterns.push(patternData);
}
// If player doesn't have access and product is null, don't show it.
}
return patterns;
}
function addRestrictions(
pattern: Pattern,
userMe: UserMeResponse | null,
products: Map<string, StripeProduct>,
) {
if (userMe === null) {
if (products.has(`pattern:${pattern.name}`)) {
// Purchasable (flare-gated) patterns are shown as disabled
pattern.lockedReason = translateText("territory_patterns.blocked.login");
} else {
// Role-gated patterns are not shown
pattern.notShown = true;
}
return;
}
const flares = userMe.player.flares ?? [];
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}`)
) {
// Pattern is unlocked by flare
return;
}
const myRoles = userMe.player.roles ?? [];
if (
pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole))
) {
// Pattern is unlocked by role
return;
}
const product = products.get(`pattern:${pattern.name}`);
if (product) {
pattern.price = product.price;
pattern.priceId = product.price_id;
pattern.lockedReason = translateText("territory_patterns.blocked.purchase");
return;
}
// Pattern is locked by role group and not purchasable, don't show it.
pattern.notShown = true;
}
export async function handlePurchase(priceId: string) {
try {
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: getAuthHeader(),
},
body: JSON.stringify({
priceId: priceId,
successUrl: `${window.location.href}purchase-success`,
cancelUrl: `${window.location.href}purchase-cancel`,
}),
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: getAuthHeader(),
},
body: JSON.stringify({
priceId: priceId,
successUrl: `${window.location.href}purchase-success`,
cancelUrl: `${window.location.href}purchase-cancel`,
}),
},
);
if (!response.ok) {
console.error(
`Error purchasing pattern:${response.status} ${response.statusText}`,
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
if (response.status === 401) {
alert("You are not logged in. Please log in to purchase a pattern.");
} else {
alert("Something went wrong. Please try again later.");
}
const { url } = await response.json();
// Redirect to Stripe checkout
window.location.href = url;
} catch (error) {
console.error("Purchase error:", error);
alert("Something went wrong. Please try again later.");
return;
}
const { url } = await response.json();
// Redirect to Stripe checkout
window.location.href = url;
}
// Returns a map of flare -> product
export async function listAllProducts(): Promise<Map<string, StripeProduct>> {
async function getCosmetics(): Promise<Cosmetics | undefined> {
try {
const response = await fetch(`${getApiBase()}/stripe/products`);
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
console.error(`HTTP error! status: ${response.status}`);
return;
}
const products = (await response.json()) as StripeProduct[];
const productMap = new Map<string, StripeProduct>();
products.forEach((product) => {
productMap.set(product.metadata.flare, product);
});
return productMap;
const result = CosmeticsSchema.safeParse(await response.json());
if (!result.success) {
console.error(`Invalid cosmetics: ${result.error.message}`);
return;
}
return result.data;
} catch (error) {
console.error("Failed to fetch products:", error);
return new Map();
console.error("Error getting cosmetics:", error);
}
}
+32 -90
View File
@@ -1,13 +1,11 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import Countries from "./data/countries.json";
import { renderPlayerFlag } from "../core/CustomFlag";
const flagKey: string = "flag";
@customElement("flag-input")
export class FlagInput extends LitElement {
@state() private flag: string = "";
@state() private search: string = "";
@state() private showModal: boolean = false;
@state() public flag: string = "";
static styles = css`
@media (max-width: 768px) {
@@ -21,19 +19,6 @@ export class FlagInput extends LitElement {
}
`;
private handleSearch(e: Event) {
this.search = String((e.target as HTMLInputElement).value);
}
private setFlag(flag: string) {
if (flag === "xx") {
flag = "";
}
this.flag = flag;
this.showModal = false;
this.storeFlag(flag);
}
public getCurrentFlag(): string {
return this.flag;
}
@@ -46,14 +31,6 @@ export class FlagInput extends LitElement {
return "";
}
private storeFlag(flag: string) {
if (flag) {
localStorage.setItem(flagKey, flag);
} else if (flag === "") {
localStorage.removeItem(flagKey);
}
}
private dispatchFlagEvent() {
this.dispatchEvent(
new CustomEvent("flag-change", {
@@ -68,86 +45,51 @@ export class FlagInput extends LitElement {
super.connectedCallback();
this.flag = this.getStoredFlag();
this.dispatchFlagEvent();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.showModal = false;
}
};
createRenderRoot() {
return this;
}
render() {
return html`
<div
class="absolute left-0 top-0 w-full h-full ${this.showModal
? ""
: "hidden"}"
@click=${() => (this.showModal = false)}
></div>
<div class="flex relative">
<button
@click=${() => (this.showModal = !this.showModal)}
id="flag-input_"
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
title="Pick a flag!"
>
<img class="size-[48px]" src="/flags/${this.flag || "xx"}.svg" />
<span
id="flag-preview"
style="display:inline-block;width:48px;height:64px;vertical-align:middle;background:#333;border-radius:6px;overflow:hidden;"
></span>
</button>
${this.showModal
? html`
<div
class="text-white flex flex-col gap-[0.5rem] absolute top-[60px] left-[0px] w-[780%] h-[500px] max-h-[50vh] max-w-[87vw] bg-gray-900/80 backdrop-blur-md p-[10px] rounded-[8px] z-[3] ${this
.showModal
? ""
: "hidden"}"
>
<input
class="h-[2rem] border-none text-center border border-gray-300 rounded-xl shadow-sm text-2xl text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="Search..."
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
<div
class="flex flex-wrap justify-evenly gap-[1rem] overflow-y-auto overflow-x-hidden"
>
${Countries.filter(
(country) =>
country.name
.toLowerCase()
.includes(this.search.toLowerCase()) ||
country.code
.toLowerCase()
.includes(this.search.toLowerCase()),
).map(
(country) => html`
<button
@click=${() => this.setFlag(country.code)}
class="text-center cursor-pointer border-none bg-none opacity-70 sm:w-[calc(33.3333%-15px) w-[calc(100%/3-15px)] md:w-[calc(100%/4-15px)]"
>
<img
class="country-flag w-full h-auto"
src="/flags/${country.code}.svg"
/>
<span class="country-name">${country.name}</span>
</button>
`,
)}
</div>
</div>
`
: ""}
</div>
`;
}
updated() {
const preview = this.renderRoot.querySelector(
"#flag-preview",
) as HTMLElement;
if (!preview) return;
preview.innerHTML = "";
if (this.flag?.startsWith("!")) {
renderPlayerFlag(this.flag, preview);
} else {
const img = document.createElement("img");
img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`;
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "contain";
img.onerror = () => {
if (!img.src.endsWith("/flags/xx.svg")) {
img.src = "/flags/xx.svg";
}
};
preview.appendChild(img);
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import Countries from "./data/countries.json";
@customElement("flag-input-modal")
export class FlagInputModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
@state() private search: string = "";
createRenderRoot() {
return this;
}
render() {
return html`
<o-modal title="Flag Selector Modal" alwaysMaximized>
<input
class="h-[2rem] border-none text-center border border-gray-300 rounded-xl shadow-sm text-2xl text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="Search..."
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
<div
class="flex flex-wrap justify-evenly gap-[1rem] overflow-y-auto overflow-x-hidden h-[90%]"
>
${Countries.filter(
(country) =>
country.name.toLowerCase().includes(this.search.toLowerCase()) ||
country.code.toLowerCase().includes(this.search.toLowerCase()),
).map(
(country) => html`
<button
@click=${() => {
this.setFlag(country.code);
this.close();
}}
class="text-center cursor-pointer border-none bg-none opacity-70
w-[calc(100%/2-15px)] sm:w-[calc(100%/4-15px)]
md:w-[calc(100%/6-15px)] lg:w-[calc(100%/8-15px)]
xl:w-[calc(100%/10-15px)] min-w-[80px]"
>
<img
class="country-flag w-full h-auto"
src="/flags/${country.code}.svg"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = "/flags/xx.svg";
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>
<span class="country-name">${country.name}</span>
</button>
`,
)}
</div>
</o-modal>
`;
}
private handleSearch(event: Event) {
this.search = (event.target as HTMLInputElement).value;
}
private setFlag(flag: string) {
localStorage.setItem("flag", flag);
this.dispatchEvent(
new CustomEvent("flag-change", {
detail: { flag },
bubbles: true,
composed: true,
}),
);
}
public open() {
this.modalEl?.open();
}
public close() {
this.modalEl?.close();
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
}
+8
View File
@@ -138,6 +138,14 @@ export class HelpModal extends LitElement {
</td>
<td>${translateText("help_modal.action_reset_gfx")}</td>
</tr>
<tr>
<td>
<div class="mouse-shell">
<div class="mouse-wheel" id="highlighted-wheel"></div>
</div>
</td>
<td>${translateText("help_modal.action_auto_upgrade")}</td>
</tr>
</tbody>
</table>
</div>
+23
View File
@@ -107,6 +107,13 @@ export class CenterCameraEvent implements GameEvent {
constructor() {}
}
export class AutoUpgradeEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
@@ -325,6 +332,12 @@ export class InputHandler {
}
private onPointerDown(event: PointerEvent) {
if (event.button === 1) {
event.preventDefault();
this.eventBus.emit(new AutoUpgradeEvent(event.clientX, event.clientY));
return;
}
if (event.button > 0) {
return;
}
@@ -346,6 +359,11 @@ export class InputHandler {
}
onPointerUp(event: PointerEvent) {
if (event.button === 1) {
event.preventDefault();
return;
}
if (event.button > 0) {
return;
}
@@ -398,6 +416,11 @@ export class InputHandler {
}
private onPointerMove(event: PointerEvent) {
if (event.button === 1) {
event.preventDefault();
return;
}
if (event.button > 0) {
return;
}
+16 -13
View File
@@ -135,14 +135,25 @@ export class JoinPrivateLobbyModal extends LitElement {
);
}
private setLobbyId(id: string) {
if (id.startsWith("http")) {
this.lobbyIdInput.value = id.split("join/")[1];
private extractLobbyIdFromUrl(input: string): string {
if (input.startsWith("http")) {
if (input.includes("#join=")) {
const params = new URLSearchParams(input.split("#")[1]);
return params.get("join") ?? input;
} else if (input.includes("join/")) {
return input.split("join/")[1];
} else {
return input;
}
} else {
this.lobbyIdInput.value = id;
return input;
}
}
private setLobbyId(id: string) {
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
}
private handleChange(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
this.setLobbyId(value);
@@ -151,15 +162,7 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pasteFromClipboard() {
try {
const clipText = await navigator.clipboard.readText();
let lobbyId: string;
if (clipText.startsWith("http")) {
lobbyId = clipText.split("join/")[1];
} else {
lobbyId = clipText;
}
this.lobbyIdInput.value = lobbyId;
this.setLobbyId(clipText);
} catch (err) {
console.error("Failed to read clipboard contents: ", err);
}
+12 -10
View File
@@ -1,4 +1,3 @@
import favicon from "../../resources/images/Favicon.svg";
import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
@@ -12,6 +11,7 @@ import "./DarkModeButton";
import { DarkModeButton } from "./DarkModeButton";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
import { FlagInputModal } from "./FlagInputModal";
import { GameStartingModal } from "./GameStartingModal";
import "./GoogleAdElement";
import { HelpModal } from "./HelpModal";
@@ -64,6 +64,7 @@ declare global {
// Extend the global interfaces to include your custom events
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
}
}
@@ -163,7 +164,6 @@ class Client {
}
});
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
@@ -195,6 +195,16 @@ class Client {
hlpModal.open();
});
const flagInputModal = document.querySelector(
"flag-input-modal",
) as FlagInputModal;
flagInputModal instanceof FlagInputModal;
const flgInput = document.getElementById("flag-input_");
if (flgInput === null) throw new Error("Missing flag-input_");
flgInput.addEventListener("click", () => {
flagInputModal.open();
});
const territoryModal = document.querySelector(
"territory-patterns-modal",
) as TerritoryPatternsModal;
@@ -536,14 +546,6 @@ document.addEventListener("DOMContentLoaded", () => {
new Client().initialize();
});
function setFavicon(): void {
const link = document.createElement("link");
link.type = "image/x-icon";
link.rel = "shortcut icon";
link.href = favicon;
document.head.appendChild(link);
}
// WARNING: DO NOT EXPOSE THIS ID
function getPlayToken(): string {
const result = isLoggedIn();
+14 -1
View File
@@ -83,7 +83,7 @@ export class NewsModal extends LitElement {
</div>
<div>
${translateText("news.full_changelog")}
${translateText("news.see_all_releases")}
<a
href="https://github.com/openfrontio/OpenFrontIO/releases"
target="_blank"
@@ -105,6 +105,19 @@ export class NewsModal extends LitElement {
this.initialized = true;
fetch(changelog)
.then((response) => (response.ok ? response.text() : "Failed to load"))
.then((markdown) =>
markdown
.replace(
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g,
(_match, prNumber) =>
`[#${prNumber}](https://github.com/openfrontio/OpenFrontIO/pull/${prNumber})`,
)
.replace(
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/compare\/([\w.-]+)\b/g,
(_match, comparison) =>
`[${comparison}](https://github.com/openfrontio/OpenFrontIO/compare/${comparison})`,
),
)
.then((markdown) => (this.markdown = markdown));
}
this.requestUpdate();
+13 -12
View File
@@ -3,11 +3,12 @@ import type { TemplateResult } from "lit";
import { html, LitElement, render } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Pattern } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PatternDecoder } from "../core/PatternDecoder";
import "./components/Difficulties";
import "./components/Maps";
import { handlePurchase, Pattern, patterns } from "./Cosmetics";
import { handlePurchase, patterns } from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("territory-patterns-modal")
@@ -107,14 +108,14 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderTooltip(): TemplateResult | null {
if (this.hoveredPattern && this.hoveredPattern.lockedReason) {
if (this.hoveredPattern && this.hoveredPattern.product !== undefined) {
return html`
<div
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
.x + 12}px;"
>
${this.hoveredPattern.lockedReason}
${translateText("territory_patterns.blocked.purchase")}
</div>
`;
}
@@ -122,7 +123,7 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderPatternButton(pattern: Pattern): TemplateResult {
const isSelected = this.selectedPattern === pattern.key;
const isSelected = this.selectedPattern === pattern.pattern;
return html`
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
@@ -131,9 +132,9 @@ export class TerritoryPatternsModal extends LitElement {
${isSelected
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
${pattern.lockedReason ? "opacity-50 cursor-not-allowed" : ""}"
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
@click=${() =>
!pattern.lockedReason && this.selectPattern(pattern.key)}
pattern.product === null && this.selectPattern(pattern.pattern)}
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
@mouseleave=${() => this.handleMouseLeave()}
@@ -155,23 +156,23 @@ export class TerritoryPatternsModal extends LitElement {
"
>
${this.renderPatternPreview(
pattern.key,
pattern.pattern,
this.buttonWidth,
this.buttonWidth,
)}
</div>
</button>
${pattern.priceId !== undefined && pattern.lockedReason
${pattern.product !== null
? html`
<button
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
@click=${(e: Event) => {
e.stopPropagation();
handlePurchase(pattern.priceId!);
handlePurchase(pattern.product!.priceId);
}}
>
${translateText("territory_patterns.purchase")}
(${pattern.price})
(${pattern.product!.price})
</button>
`
: null}
@@ -183,7 +184,6 @@ export class TerritoryPatternsModal extends LitElement {
const buttons: TemplateResult[] = [];
for (const pattern of this.patterns) {
if (!this.showChocoPattern && pattern.name === "choco") continue;
if (pattern.notShown === true) continue;
const result = this.renderPatternButton(pattern);
buttons.push(result);
@@ -243,6 +243,7 @@ export class TerritoryPatternsModal extends LitElement {
this.modalEl?.open();
window.addEventListener("keydown", this.handleKeyDown);
this.isActive = true;
this.requestUpdate();
}
public close() {
@@ -332,7 +333,7 @@ export class TerritoryPatternsModal extends LitElement {
}
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
if (pattern.lockedReason) {
if (pattern.product !== null) {
this.hoveredPattern = pattern;
this.hoverPosition = { x: event.clientX, y: event.clientY };
}
+18
View File
@@ -125,6 +125,15 @@ export class UserSettingModal extends LitElement {
console.log("💥 Special effects:", enabled ? "ON" : "OFF");
}
private toggleStructureSprites(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.structureSprites", enabled);
console.log("🏠 Structure sprites:", enabled ? "ON" : "OFF");
}
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
@@ -291,6 +300,15 @@ export class UserSettingModal extends LitElement {
@change=${this.toggleFxLayer}
></setting-toggle>
<!-- 🏠 Structure Sprites -->
<setting-toggle
label="${translateText("user_setting.structure_sprites_label")}"
description="${translateText("user_setting.structure_sprites_desc")}"
id="structure_sprites-toggle"
.checked=${this.userSettings.structureSprites()}
@change=${this.toggleStructureSprites}
></setting-toggle>
<!-- 🖱️ Left Click Menu -->
<setting-toggle
label="${translateText("user_setting.left_click_label")}"
+8 -5
View File
@@ -5,24 +5,27 @@ export function renderTroops(troops: number): string {
return renderNumber(troops / 10);
}
export function renderNumber(num: number | bigint): string {
export function renderNumber(
num: number | bigint,
fixedPoints?: number,
): string {
num = Number(num);
num = Math.max(num, 0);
if (num >= 10_000_000) {
const value = Math.floor(num / 100000) / 10;
return value.toFixed(1) + "M";
return value.toFixed(fixedPoints ?? 1) + "M";
} else if (num >= 1_000_000) {
const value = Math.floor(num / 10000) / 100;
return value.toFixed(2) + "M";
return value.toFixed(fixedPoints ?? 2) + "M";
} else if (num >= 100000) {
return Math.floor(num / 1000) + "K";
} else if (num >= 10000) {
const value = Math.floor(num / 100) / 10;
return value.toFixed(1) + "K";
return value.toFixed(fixedPoints ?? 1) + "K";
} else if (num >= 1000) {
const value = Math.floor(num / 10) / 100;
return value.toFixed(2) + "K";
return value.toFixed(fixedPoints ?? 2) + "K";
} else {
return Math.floor(num).toString();
}
+17 -1
View File
@@ -7,6 +7,7 @@ export class OModal extends LitElement {
@state() public isModalOpen = false;
@property({ type: String }) title = "";
@property({ type: String }) translationKey = "";
@property({ type: Boolean }) alwaysMaximized = false;
static styles = css`
.c-modal {
@@ -31,6 +32,17 @@ export class OModal extends LitElement {
max-width: 860px;
}
.c-modal__wrapper.always-maximized {
width: 100%;
min-width: 340px;
max-width: 860px;
min-height: 320px;
/* Fallback for older browsers */
height: 60vh;
/* Use dvh if supported for dynamic viewport handling */
height: 60dvh;
}
.c-modal__header {
position: relative;
border-top-left-radius: 4px;
@@ -74,7 +86,11 @@ export class OModal extends LitElement {
${this.isModalOpen
? html`
<aside class="c-modal">
<div class="c-modal__wrapper">
<div
class="c-modal__wrapper ${this.alwaysMaximized
? "always-maximized"
: ""}"
>
<header class="c-modal__header">
${`${this.translationKey}` === ""
? `${this.title}`
+4 -10
View File
@@ -66,33 +66,27 @@ export class FxLayer implements Layer {
}
onBonusEvent(bonus: BonusEventUpdate) {
const tile = bonus.tile;
if (this.game.owner(tile) !== this.game.myPlayer()) {
if (this.game.player(bonus.player) !== this.game.myPlayer()) {
// Only display text fx for the current player
return;
}
const tile = bonus.tile;
const x = this.game.x(tile);
let y = this.game.y(tile);
const gold = bonus.gold;
const troops = bonus.troops;
const workers = bonus.workers;
if (gold > 0) {
const shortened = renderNumber(gold);
const shortened = renderNumber(gold, 0);
this.addTextFx(`+ ${shortened}`, x, y);
y += 10; // increase y so the next popup starts bellow
}
if (troops > 0) {
const shortened = renderNumber(troops);
const shortened = renderNumber(troops, 0);
this.addTextFx(`+ ${shortened} troops`, x, y);
y += 10;
}
if (workers > 0) {
const shortened = renderNumber(workers);
this.addTextFx(`+ ${shortened} workers`, x, y);
}
}
addTextFx(text: string, x: number, y: number) {
+105 -48
View File
@@ -19,6 +19,7 @@ import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import infoIcon from "../../../../resources/images/InfoIcon.svg";
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
@@ -66,6 +67,7 @@ export const COLORS = {
breakAlly: "#c74848",
info: "#64748B",
target: "#ff0000",
attack: "#ff0000",
infoDetails: "#7f8c8d",
infoEmoji: "#f1c40f",
trade: "#008080",
@@ -89,6 +91,7 @@ export enum Slot {
Info = "info",
Boat = "boat",
Build = "build",
Attack = "attack",
Ally = "ally",
Back = "back",
}
@@ -319,6 +322,88 @@ function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
return Units;
}
const ATTACK_UNIT_TYPES: UnitType[] = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
function createMenuElements(
params: MenuElementParams,
filterType: "attack" | "build",
elementIdPrefix: string,
): MenuElement[] {
const unitTypes: Set<UnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
return flattenedBuildTable
.filter(
(item) =>
unitTypes.has(item.unitType) &&
(filterType === "attack"
? ATTACK_UNIT_TYPES.includes(item.unitType)
: !ATTACK_UNIT_TYPES.includes(item.unitType)),
)
.map((item: BuildItemDisplay) => ({
id: `${elementIdPrefix}_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (params: MenuElementParams) =>
!params.buildMenu.canBuildOrUpgrade(item),
color: params.buildMenu.canBuildOrUpgrade(item)
? filterType === "attack"
? COLORS.attack
: COLORS.building
: undefined,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key ?? ""), className: "title" },
{
text: translateText(item.description ?? ""),
className: "description",
},
{
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
className: "cost",
},
item.countable
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
: null,
].filter(
(tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
),
action: (params: MenuElementParams) => {
const buildableUnit = params.playerActions.buildableUnits.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
return;
}
if (params.buildMenu.canBuildOrUpgrade(item)) {
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
}
params.closeMenu();
},
}));
}
export const attackMenuElement: MenuElement = {
id: Slot.Attack,
name: "radial_attack",
disabled: (params: MenuElementParams) => params.game.inSpawnPhase(),
icon: swordIcon,
color: COLORS.attack,
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
return createMenuElements(params, "attack", "attack");
},
};
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
@@ -328,53 +413,7 @@ export const buildMenuElement: MenuElement = {
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
const unitTypes: Set<UnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
const buildElements: MenuElement[] = flattenedBuildTable
.filter((item) => unitTypes.has(item.unitType))
.map((item: BuildItemDisplay) => ({
id: `build_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (params: MenuElementParams) =>
!params.buildMenu.canBuildOrUpgrade(item),
color: params.buildMenu.canBuildOrUpgrade(item)
? COLORS.building
: undefined,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key ?? ""), className: "title" },
{
text: translateText(item.description ?? ""),
className: "description",
},
{
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
className: "cost",
},
item.countable
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
: null,
].filter((item): item is TooltipItem => item !== null),
action: (params: MenuElementParams) => {
const buildableUnit = params.playerActions.buildableUnits.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
return;
}
if (params.buildMenu.canBuildOrUpgrade(item)) {
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
}
params.closeMenu();
},
}));
return buildElements;
return createMenuElements(params, "build", "build");
},
};
@@ -444,6 +483,24 @@ export const rootMenuElement: MenuElement = {
if (params.selected?.isAlliedWith(params.myPlayer)) {
ally = allyBreakElement;
}
return [infoMenuElement, boatMenuElement, ally, buildMenuElement];
const tileOwner = params.game.owner(params.tile);
const isOwnTerritory =
tileOwner.isPlayer() &&
(tileOwner as PlayerView).id() === params.myPlayer.id();
const menuItems: (MenuElement | null)[] = [
infoMenuElement,
boatMenuElement,
ally,
];
if (isOwnTerritory) {
menuItems.push(buildMenuElement);
} else {
menuItems.push(attackMenuElement);
}
return menuItems.filter((item): item is MenuElement => item !== null);
},
};
@@ -1,5 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import structureIcon from "../../../../resources/images/CityIconWhite.svg";
import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
@@ -93,6 +94,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onToggleStructureSpritesButtonClick() {
this.userSettings.toggleStructureSprites();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this.userSettings.toggleFxLayer();
this.requestUpdate();
@@ -259,6 +265,33 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onToggleStructureSpritesButtonClick}"
>
<img
src=${structureIcon}
alt="structureSprites"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.structure_sprites_label")}
</div>
<div class="text-sm text-slate-400">
${this.userSettings.structureSprites()
? translateText("user_setting.structure_sprites_enabled")
: translateText("user_setting.structure_sprites_disabled")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.structureSprites()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onToggleRandomNameModeButtonClick}"
+210 -94
View File
@@ -16,7 +16,7 @@ import { ToggleStructureEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
type ShapeType = "triangle" | "square" | "octagon" | "circle";
type ShapeType = "triangle" | "square" | "pentagon" | "octagon" | "circle";
class StructureRenderInfo {
public isOnScreen: boolean = false;
@@ -25,6 +25,7 @@ class StructureRenderInfo {
public owner: PlayerID,
public iconContainer: PIXI.Container,
public levelContainer: PIXI.Container,
public dotContainer: PIXI.Container,
public level: number = 0,
public underConstruction: boolean = true,
) {}
@@ -32,20 +33,31 @@ class StructureRenderInfo {
const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[UnitType.City]: "circle",
[UnitType.Port]: "circle",
[UnitType.Port]: "pentagon",
[UnitType.Factory]: "circle",
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
};
const ZOOM_THRESHOLD = 3.5;
const ICON_SIZE = 24;
const OFFSET_ZOOM_Y = 5; // offset for the y position of the icon to avoid hiding the structure beneath
const LEVEL_SCALE_FACTOR = 3;
const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5;
const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4;
const DOTS_ZOOM_THRESHOLD = 0.5;
const ZOOM_THRESHOLD = 4.3;
const ICON_SIZE = {
circle: 28,
octagon: 28,
pentagon: 30,
square: 28,
triangle: 28,
};
const OFFSET_ZOOM_Y = 4; // offset for the y position of the level over the sprite
export class StructureIconsLayer implements Layer {
private pixicanvas: HTMLCanvasElement;
private iconsStage: PIXI.Container;
private levelsStage: PIXI.Container;
private dotsStage: PIXI.Container;
private shouldRedraw: boolean = true;
private textureCache: Map<string, PIXI.Texture> = new Map();
private theme: Theme;
@@ -72,6 +84,7 @@ export class StructureIconsLayer implements Layer {
{ visible: true, iconPath: SAMMissileIcon, image: null },
],
]);
private renderSprites = true;
constructor(
private game: GameView,
@@ -95,19 +108,22 @@ export class StructureIconsLayer implements Layer {
this.iconsStage = new PIXI.Container();
this.iconsStage.position.set(0, 0);
this.iconsStage.width = this.pixicanvas.width;
this.iconsStage.height = this.pixicanvas.height;
this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
this.levelsStage = new PIXI.Container();
this.levelsStage.position.set(0, 0);
this.levelsStage.width = this.pixicanvas.width;
this.levelsStage.height = this.pixicanvas.height;
this.levelsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
this.dotsStage = new PIXI.Container();
this.dotsStage.position.set(0, 0);
this.dotsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
await this.renderer.init({
canvas: this.pixicanvas,
resolution: 1,
width: this.pixicanvas.width,
height: this.pixicanvas.height,
antialias: false,
clearBeforeRender: true,
backgroundAlpha: 0,
backgroundColor: 0x00000000,
@@ -168,6 +184,8 @@ export class StructureIconsLayer implements Layer {
this.handleInactiveUnit(unitView);
}
});
this.renderSprites =
this.game.config().userSettings()?.structureSprites() ?? true;
}
private toggleStructure(toggleStructureType: UnitType | null): void {
@@ -227,12 +245,17 @@ export class StructureIconsLayer implements Layer {
}
if (structureInfos) {
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
if (structureInfos.visible && focusStructure) {
render.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
render.dotContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
} else {
render.iconContainer.filters = [];
render.dotContainer.filters = [];
}
}
}
@@ -247,7 +270,9 @@ export class StructureIconsLayer implements Layer {
) {
render.underConstruction = false;
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
@@ -257,7 +282,9 @@ export class StructureIconsLayer implements Layer {
if (render.owner !== unit.owner().id()) {
render.owner = unit.owner().id();
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
@@ -268,8 +295,10 @@ export class StructureIconsLayer implements Layer {
render.level = unit.level();
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.levelContainer = this.createLevelSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
@@ -291,17 +320,19 @@ export class StructureIconsLayer implements Layer {
}
if (this.transformHandler.hasChanged() || this.shouldRedraw) {
if (this.transformHandler.scale > ZOOM_THRESHOLD) {
if (this.transformHandler.scale > ZOOM_THRESHOLD && this.renderSprites) {
this.renderer.render(this.levelsStage);
} else {
} else if (this.transformHandler.scale > DOTS_ZOOM_THRESHOLD) {
this.renderer.render(this.iconsStage);
} else {
this.renderer.render(this.dotsStage);
}
this.shouldRedraw = false;
}
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
private createTexture(unit: UnitView): PIXI.Texture {
private createTexture(unit: UnitView, renderIcon: boolean): PIXI.Texture {
const isConstruction = unit.type() === UnitType.Construction;
const constructionType = unit.constructionType();
if (isConstruction && constructionType === undefined) {
@@ -312,15 +343,22 @@ export class StructureIconsLayer implements Layer {
}
const structureType = isConstruction ? constructionType! : unit.type();
const cacheKey = isConstruction
? `construction-${structureType}`
: `${unit.owner().id()}-${structureType}`;
? `construction-${structureType}` + (renderIcon ? "-icon" : "")
: `${this.theme.territoryColor(unit.owner()).toRgbString()}-${structureType}` +
(renderIcon ? "-icon" : "");
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const shape = STRUCTURE_SHAPES[structureType];
const texture = shape
? this.createIcon(unit.owner(), structureType, isConstruction, shape)
? this.createIcon(
unit.owner(),
structureType,
isConstruction,
shape,
renderIcon,
)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
@@ -331,11 +369,16 @@ export class StructureIconsLayer implements Layer {
owner: PlayerView,
structureType: UnitType,
isConstruction: boolean,
shape: "triangle" | "square" | "octagon" | "circle",
) {
shape: ShapeType,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
structureCanvas.width = ICON_SIZE;
structureCanvas.height = ICON_SIZE;
let iconSize = ICON_SIZE[shape];
if (!renderIcon) {
iconSize /= 2.5;
}
structureCanvas.width = Math.ceil(iconSize);
structureCanvas.height = Math.ceil(iconSize);
const context = structureCanvas.getContext("2d")!;
let borderColor: string;
@@ -345,35 +388,37 @@ export class StructureIconsLayer implements Layer {
} else {
context.fillStyle = this.theme
.territoryColor(owner)
.lighten(0.06)
.lighten(0.13)
.alpha(renderIcon ? 0.65 : 1)
.toRgbString();
borderColor = this.theme.borderColor(owner).darken(0.08).toRgbString();
const darken = this.theme.borderColor(owner).isLight() ? 0.17 : 0.15;
borderColor = this.theme.borderColor(owner).darken(darken).toRgbString();
}
context.strokeStyle = borderColor;
context.lineWidth = 1;
const halfIconSize = iconSize / 2;
switch (shape) {
case "triangle":
context.beginPath();
context.moveTo(ICON_SIZE / 2, 0); // Top
context.lineTo(ICON_SIZE, ICON_SIZE); // Bottom right
context.lineTo(0, ICON_SIZE); // Bottom left
context.moveTo(halfIconSize, 1); // Top
context.lineTo(iconSize - 1, iconSize - 1); // Bottom right
context.lineTo(0, iconSize - 1); // Bottom left
context.closePath();
context.fill();
context.stroke();
break;
case "square":
context.fillRect(0, 0, ICON_SIZE - 2, ICON_SIZE - 2);
context.strokeRect(0.5, 0.5, ICON_SIZE - 3, ICON_SIZE - 3);
context.fillRect(1, 1, iconSize - 2, iconSize - 2);
context.strokeRect(1, 1, iconSize - 3, iconSize - 3);
break;
case "octagon":
{
const cx = ICON_SIZE / 2;
const cy = ICON_SIZE / 2;
const r = ICON_SIZE / 2 - 1;
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 8;
context.beginPath();
@@ -392,13 +437,35 @@ export class StructureIconsLayer implements Layer {
context.stroke();
}
break;
case "pentagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 5;
context.beginPath();
for (let i = 0; i < 5; i++) {
const angle = step * i - Math.PI / 2; // rotate to have flat base or point up
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "circle":
context.beginPath();
context.arc(
ICON_SIZE / 2,
ICON_SIZE / 2,
ICON_SIZE / 2 - 1,
halfIconSize,
halfIconSize,
halfIconSize - 1,
0,
Math.PI * 2,
);
@@ -416,81 +483,111 @@ export class StructureIconsLayer implements Layer {
return PIXI.Texture.from(structureCanvas);
}
const SHAPE_OFFSETS = {
triangle: [4, 8],
square: [3, 3],
octagon: [4, 4],
circle: [4, 4],
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
offsetX,
offsetY,
);
if (renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
octagon: [6, 6],
pentagon: [7, 7],
circle: [6, 6],
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
offsetX,
offsetY,
);
}
return PIXI.Texture.from(structureCanvas);
}
private createLevelSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
addIcon: false,
type: "level",
stage: this.levelsStage,
});
}
private createDotSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
type: "dot",
stage: this.dotsStage,
});
}
private createIconSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
addIcon: true,
type: "icon",
stage: this.iconsStage,
});
}
private createUnitContainer(
unit: UnitView,
options: { addIcon?: boolean; stage: PIXI.Container },
options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container },
): PIXI.Container {
const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldX = this.game.x(tile);
const worldY = this.game.y(tile);
const screenPos = this.transformHandler.worldToScreenCoordinates(
new Cell(worldX, worldY),
);
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
if (options.addIcon) {
const sprite = new PIXI.Sprite(this.createTexture(unit));
sprite.anchor.set(0.5, 0.5);
const { type, stage } = options;
const scale = this.transformHandler.scale;
const spritesEnabled = this.game
.config()
.userSettings()
?.structureSprites?.();
// Add sprite if needed
if (type === "icon" || type === "dot") {
const texture = this.createTexture(unit, type === "icon");
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
parentContainer.addChild(sprite);
}
if (unit.level() > 1) {
// Add level text if needed
if ((type === "icon" || type === "level") && unit.level() > 1) {
const text = new PIXI.BitmapText({
text: unit.level().toString(),
style: {
fontFamily: "round_6x6_modified",
fontSize: 12,
fontSize: 14,
},
});
text.anchor.set(0.5, 0.5);
text.position.y = -ICON_SIZE / 2 - 2;
text.anchor.set(0.5);
const unitType =
unit.type() === UnitType.Construction
? unit.constructionType()
: unit.type();
const shape = STRUCTURE_SHAPES[unitType!];
if (shape !== undefined) {
text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2);
}
parentContainer.addChild(text);
}
// Positioning
const posX = Math.round(screenPos.x);
let posY = Math.round(screenPos.y);
if (type === "level" && scale >= ZOOM_THRESHOLD && spritesEnabled) {
posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y);
}
parentContainer.position.set(posX, posY);
if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
posY = Math.round(
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
);
// Scaling
if (type === "icon") {
const s =
scale >= ZOOM_THRESHOLD && !spritesEnabled
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
parentContainer.scale.set(s);
} else if (type === "level") {
parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR));
}
parentContainer.position.set(posX, posY);
parentContainer.scale.set(Math.min(1, this.transformHandler.scale));
options.stage.addChild(parentContainer);
stage.addChild(parentContainer);
return parentContainer;
}
@@ -511,23 +608,27 @@ export class StructureIconsLayer implements Layer {
private computeNewLocation(render: StructureRenderInfo) {
const tile = render.unit.tile();
const worldX = this.game.x(tile);
const worldY = this.game.y(tile);
const screenPos = this.transformHandler.worldToScreenCoordinates(
new Cell(worldX, worldY),
);
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
screenPos.x = Math.round(screenPos.x);
if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
// Adjust the y position based on zoom level to avoid hiding the structure beneath
screenPos.y = Math.round(
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
);
} else {
screenPos.y = Math.round(screenPos.y);
}
// Check if the sprite is on screen (with margin for partial visibility)
const margin = ICON_SIZE;
const scale = this.transformHandler.scale;
screenPos.y = Math.round(
scale >= ZOOM_THRESHOLD &&
this.game.config().userSettings()?.structureSprites()
? screenPos.y - scale * OFFSET_ZOOM_Y
: screenPos.y,
);
const type =
render.unit.type() === UnitType.Construction
? render.unit.constructionType()
: render.unit.type();
const margin =
type !== undefined && STRUCTURE_SHAPES[type] !== undefined
? ICON_SIZE[STRUCTURE_SHAPES[type]]
: 28;
const onScreen =
screenPos.x + margin > 0 &&
screenPos.x - margin < this.pixicanvas.width &&
@@ -535,21 +636,34 @@ export class StructureIconsLayer implements Layer {
screenPos.y - margin < this.pixicanvas.height;
if (onScreen) {
if (this.transformHandler.scale > ZOOM_THRESHOLD) {
render.levelContainer.x = screenPos.x;
render.levelContainer.y = screenPos.y;
} else {
render.iconContainer.x = screenPos.x;
render.iconContainer.y = screenPos.y;
render.iconContainer.scale.set(
Math.min(1, this.transformHandler.scale),
if (scale > ZOOM_THRESHOLD) {
const target = this.game.config().userSettings()?.structureSprites()
? render.levelContainer
: render.iconContainer;
target.position.set(screenPos.x, screenPos.y);
target.scale.set(
Math.max(
1,
scale /
(target === render.levelContainer
? LEVEL_SCALE_FACTOR
: ICON_SCALE_FACTOR_ZOOMED_IN),
),
);
} else if (scale > DOTS_ZOOM_THRESHOLD) {
render.iconContainer.position.set(screenPos.x, screenPos.y);
render.iconContainer.scale.set(
Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT),
);
} else {
render.dotContainer.position.set(screenPos.x, screenPos.y);
}
}
if (render.isOnScreen !== onScreen) {
// prevent unnecessary updates
render.isOnScreen = onScreen;
render.iconContainer.visible = onScreen;
render.dotContainer.visible = onScreen;
render.levelContainer.visible = onScreen;
}
}
@@ -561,6 +675,7 @@ export class StructureIconsLayer implements Layer {
unitView.owner().id(),
this.createIconSprite(unitView),
this.createLevelSprite(unitView),
this.createDotSprite(unitView),
unitView.level(),
unitView.type() === UnitType.Construction,
);
@@ -573,6 +688,7 @@ export class StructureIconsLayer implements Layer {
private deleteStructure(render: StructureRenderInfo) {
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
this.renders = this.renders.filter((r) => r.unit !== render.unit);
this.seenUnits.delete(render.unit);
}
+5 -2
View File
@@ -21,7 +21,7 @@ const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
const BASE_BORDER_RADIUS = 16.5;
const BASE_TERRITORY_RADIUS = 13.5;
const RADIUS_SCALE_FACTOR = 0.5;
const ZOOM_THRESHOLD = 3.5; // below this zoom level, structures are not rendered
const ZOOM_THRESHOLD = 4.3; // below this zoom level, structures are not rendered
interface UnitRenderConfig {
icon: string;
@@ -146,7 +146,10 @@ export class StructureLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.transformHandler.scale <= ZOOM_THRESHOLD) {
if (
this.transformHandler.scale <= ZOOM_THRESHOLD ||
!this.game.config().userSettings()?.structureSprites()
) {
return;
}
context.drawImage(
+2 -2
View File
@@ -1,11 +1,11 @@
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import portIcon from "../../../../resources/images/AnchorIcon.png";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samLauncherIcon from "../../../../resources/images/SamLauncherUnitWhite.png";
import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg";
import samLauncherIcon from "../../../../resources/non-commercial/svg/SamLauncherIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
+1 -1
View File
@@ -545,7 +545,7 @@ export class UnitLayer implements Layer {
const targetable = unit.targetable();
if (!targetable) {
this.context.save();
this.context.globalAlpha = 0.4;
this.context.globalAlpha = 0.5;
}
this.context.drawImage(
sprite,
+30 -1
View File
@@ -2,9 +2,37 @@
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<title>OpenFront (ALPHA)</title>
<link rel="manifest" href="../../resources/manifest.json" />
<link
rel="icon"
type="image/x-icon"
href="../../resources/images/Favicon.svg"
/>
<!-- SEO -->
<link rel="canonical" href="https://openfront.io/" />
<meta
name="description"
content="Conquer the world in this multiplayer battle royale! Expand your nation, eliminate opponents, and dominate the map in this fast-paced IO game."
/>
<!-- Open Graph -->
<meta property="og:url" content="https://openfront.io/" />
<meta property="og:title" content="OpenFront - Battle Royale" />
<meta
property="og:description"
content="Conquer the world in this multiplayer battle royale! Expand your nation, eliminate opponents, and dominate the map in this fast-paced IO game."
/>
<meta
property="og:image"
content="https://openfront.io/resources/images/GameplayScreenshot.png"
/>
<meta property="og:type" content="game" />
<!-- Critical CSS to prevent FOUC -->
<style>
@@ -386,6 +414,7 @@
<news-modal></news-modal>
<game-left-sidebar></game-left-sidebar>
<spawn-ad></spawn-ad>
<flag-input-modal></flag-input-modal>
<fps-display></fps-display>
<div
id="language-modal"
+35 -30
View File
@@ -1,36 +1,41 @@
import { z } from "zod";
import cosmetics_json from "../../resources/cosmetics/cosmetics.json" with { type: "json" };
import { z } from "zod/v4";
import { RequiredPatternSchema } from "./Schemas";
export const ProductSchema = z.object({
productId: z.string(),
priceId: z.string(),
price: z.string(),
});
const PatternSchema = z.object({
name: z.string(),
pattern: RequiredPatternSchema,
product: ProductSchema.nullable(),
});
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
role_groups: z.record(z.string(), z.string().array().min(1)),
patterns: z.record(
RequiredPatternSchema,
z.object({
name: z.string(),
role_group: z.string().optional(),
}),
),
flag: z.object({
layers: z.record(
z.string(),
z.object({
name: z.string(),
role_group: z.string().optional(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
z.string(),
z.object({
color: z.string(),
name: z.string(),
role_group: z.string().optional(),
flares: z.array(z.string()).optional(),
}),
),
}),
patterns: z.record(z.string(), PatternSchema),
flag: z
.object({
layers: z.record(
z.string(),
z.object({
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
z.string(),
z.object({
color: z.string(),
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
})
.optional(),
});
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export const COSMETICS: Cosmetics = CosmeticsSchema.parse(cosmetics_json);
export type Pattern = z.infer<typeof PatternSchema>;
export type Product = z.infer<typeof ProductSchema>;
+15 -4
View File
@@ -1,4 +1,4 @@
import { COSMETICS } from "./CosmeticSchemas";
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
rainbow: 4000,
@@ -11,7 +11,18 @@ const ANIMATION_DURATIONS: Record<string, number> = {
water: 6200,
};
export function renderPlayerFlag(flag: string, target: HTMLElement) {
// TODO: Pass in cosmetics as a parameter when
// remote cosmetics are implemented for custom flags
export function renderPlayerFlag(
flag: string,
target: HTMLElement,
cosmetics: Cosmetics | undefined = undefined,
) {
if (cosmetics === undefined) {
console.warn("No cosmetics provided for flag", flag);
return;
}
if (!flag.startsWith("!")) return;
const code = flag.slice("!".length);
@@ -26,7 +37,7 @@ export function renderPlayerFlag(flag: string, target: HTMLElement) {
target.style.aspectRatio = "3/4";
for (const { layerKey, colorKey } of layers) {
const layerName = COSMETICS.flag.layers[layerKey]?.name ?? layerKey;
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
const mask = `/flags/custom/${layerName}.svg`;
if (!mask) continue;
@@ -38,7 +49,7 @@ export function renderPlayerFlag(flag: string, target: HTMLElement) {
layer.style.width = "100%";
layer.style.height = "100%";
const colorValue = COSMETICS.flag.color[colorKey]?.color ?? colorKey;
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
const isSpecial =
!colorValue.startsWith("#") &&
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
+3 -2
View File
@@ -4,6 +4,7 @@ import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
Cell,
Game,
GameUpdates,
@@ -35,7 +36,7 @@ export async function createGameRunner(
gameStart: GameStartInfo,
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData) => void,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(gameStart.config.gameMap, mapLoader);
@@ -231,7 +232,7 @@ export class GameRunner {
throw new Error(`player with id ${playerID} not found`);
}
const condition = (a) => a.id() === attackID;
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
+5 -1
View File
@@ -213,7 +213,11 @@ export const RequiredPatternSchema = z
new PatternDecoder(val, base64url.decode);
return true;
} catch (e) {
console.error(JSON.stringify(e.message, null, 2));
if (e instanceof Error) {
console.error(JSON.stringify(e.message, null, 2));
} else {
console.error(String(e));
}
return false;
}
},
+1
View File
@@ -156,6 +156,7 @@ export interface Config {
nukeAllianceBreakThreshold(): number;
defaultNukeSpeed(): number;
defaultNukeTargetableRange(): number;
defaultSamMissileSpeed(): number;
defaultSamRange(): number;
nukeDeathFactor(
nukeType: NukeType,
+7 -3
View File
@@ -329,7 +329,7 @@ export class DefaultConfig implements Config {
return Math.min(1400, Math.round(20 * Math.pow(numberOfStations, 0.5)));
}
trainGold(): Gold {
return BigInt(10_000);
return BigInt(4_000);
}
trainStationMinRange(): number {
return 15;
@@ -802,11 +802,15 @@ export class DefaultConfig implements Config {
}
defaultNukeTargetableRange(): number {
return 120;
return 150;
}
defaultSamRange(): number {
return 80;
return 70;
}
defaultSamMissileSpeed(): number {
return 12;
}
// Humans can be soldiers, soldiers attacking, soldiers in boat etc.
+10 -3
View File
@@ -17,6 +17,7 @@ import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if neede
const malusForRetreat = 25;
export class AttackExecution implements Execution {
private breakAlliance = false;
private wasAlliedAtInit = false; // Store alliance state at initialization
private active: boolean = true;
private toConquer = new FlatBinaryHeap();
@@ -147,8 +148,9 @@ export class AttackExecution implements Execution {
}
if (this.target.isPlayer()) {
if (this._owner.isAlliedWith(this.target)) {
// No updates should happen in init.
// Store the alliance state at initialization time to prevent race conditions
this.wasAlliedAtInit = this._owner.isAlliedWith(this.target);
if (this.wasAlliedAtInit) {
this.breakAlliance = true;
}
this.target.updateRelation(this._owner, -80);
@@ -226,8 +228,13 @@ export class AttackExecution implements Execution {
this.breakAlliance = false;
this._owner.breakAlliance(alliance);
}
if (targetPlayer && this._owner.isAlliedWith(targetPlayer)) {
if (
targetPlayer &&
this._owner.isAlliedWith(targetPlayer) &&
!this.wasAlliedAtInit
) {
// In this case a new alliance was created AFTER the attack started.
// We should retreat to avoid the attacker becoming a traitor.
this.retreat();
return;
}
+1 -1
View File
@@ -350,7 +350,7 @@ export class FakeHumanExecution implements Execution {
const dist = euclDistFN(tile, 25, false);
let tileValue = targets
.filter((unit) => dist(this.mg, unit.tile()))
.map((unit) => {
.map((unit): number => {
switch (unit.type()) {
case UnitType.City:
return 25_000;
+35 -11
View File
@@ -5,6 +5,7 @@ import {
MessageType,
Player,
TerraNullius,
TrajectoryTile,
Unit,
UnitType,
} from "../game/Game";
@@ -20,8 +21,6 @@ export class NukeExecution implements Execution {
private mg: Game;
private nuke: Unit | null = null;
private tilesToDestroyCache: Set<TileRef> | undefined;
private random: PseudoRandom;
private pathFinder: ParabolaPathFinder;
constructor(
@@ -35,7 +34,6 @@ export class NukeExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.random = new PseudoRandom(ticks);
if (this.speed === -1) {
this.speed = this.mg.config().defaultNukeSpeed();
}
@@ -107,10 +105,12 @@ export class NukeExecution implements Execution {
this.pathFinder.computeControlPoints(
spawn,
this.dst,
this.speed,
this.nukeType !== UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.nukeType, spawn, {
targetTile: this.dst,
trajectory: this.getTrajectory(this.dst),
});
this.maybeBreakAlliances(this.tilesToDestroy());
if (this.mg.hasOwner(this.dst)) {
@@ -169,6 +169,8 @@ export class NukeExecution implements Execution {
} else {
this.updateNukeTargetable();
this.nuke.move(nextTile);
// Update index so SAM can interpolate future position
this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex());
}
}
@@ -176,21 +178,43 @@ export class NukeExecution implements Execution {
return this.nuke;
}
private getTrajectory(target: TileRef): TrajectoryTile[] {
const trajectoryTiles: TrajectoryTile[] = [];
const targetRangeSquared =
this.mg.config().defaultNukeTargetableRange() ** 2;
const allTiles: TileRef[] = this.pathFinder.allTiles();
for (const tile of allTiles) {
trajectoryTiles.push({
tile,
targetable: this.isTargetable(target, tile, targetRangeSquared),
});
}
return trajectoryTiles;
}
private isTargetable(
targetTile: TileRef,
nukeTile: TileRef,
targetRangeSquared: number,
): boolean {
return (
this.mg.euclideanDistSquared(nukeTile, targetTile) < targetRangeSquared ||
(this.src !== undefined &&
this.src !== null &&
this.mg.euclideanDistSquared(this.src, nukeTile) < targetRangeSquared)
);
}
private updateNukeTargetable() {
if (this.nuke === null || this.nuke.targetTile() === undefined) {
return;
}
const targetRangeSquared =
this.mg.config().defaultNukeTargetableRange() *
this.mg.config().defaultNukeTargetableRange();
this.mg.config().defaultNukeTargetableRange() ** 2;
const targetTile = this.nuke.targetTile();
this.nuke.setTargetable(
this.mg.euclideanDistSquared(this.nuke.tile(), targetTile!) <
targetRangeSquared ||
(this.src !== undefined &&
this.src !== null &&
this.mg.euclideanDistSquared(this.src, this.nuke.tile()) <
targetRangeSquared),
this.isTargetable(targetTile!, this.nuke.tile(), targetRangeSquared),
);
}
+6 -2
View File
@@ -1,7 +1,7 @@
import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { TileRef } from "../game/GameMap";
import { GameMap, TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
export class PlayerExecution implements Execution {
@@ -190,7 +190,11 @@ export class PlayerExecution implements Execution {
}
const firstTile = cluster.values().next().value;
const filter = (_, t: TileRef): boolean =>
if (!firstTile) {
return;
}
const filter = (_: GameMap, t: TileRef): boolean =>
this.mg?.ownerID(t) === this.player?.smallID();
const tiles = this.mg.bfs(firstTile, filter);
+147 -57
View File
@@ -1,6 +1,7 @@
import {
Execution,
Game,
isUnit,
MessageType,
Player,
Unit,
@@ -10,6 +11,120 @@ import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { SAMMissileExecution } from "./SAMMissileExecution";
type Target = {
unit: Unit;
tile: TileRef;
};
/**
* Smart SAM targeting system preshoting nukes so its range is strictly enforced
*/
class SAMTargetingSystem {
// Store unreachable nukes so the SAM won't compute an interception point for them every frame
private nukesToIgnore: Set<number> = new Set();
constructor(
private mg: Game,
private player: Player,
private sam: Unit,
) {}
updateUnreachableNukes(nearbyUnits: { unit: Unit; distSquared: number }[]) {
const nearbyUnitSet = new Set(nearbyUnits.map((u) => u.unit.id()));
for (const nukeId of this.nukesToIgnore) {
if (!nearbyUnitSet.has(nukeId)) {
this.nukesToIgnore.delete(nukeId);
}
}
}
private storeUnreachableNukes(nukeId: number) {
this.nukesToIgnore.add(nukeId);
}
private isInRange(tile: TileRef) {
const samTile = this.sam.tile();
const rangeSquared = this.mg.config().defaultSamRange() ** 2;
return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared;
}
private tickToReach(currentTile: TileRef, tile: TileRef): number {
const missileSpeed = this.mg.config().defaultSamMissileSpeed();
return Math.ceil(this.mg.manhattanDist(currentTile, tile) / missileSpeed);
}
private computeInterceptionTile(unit: Unit): TileRef | undefined {
const trajectory = unit.trajectory();
const samTile = this.sam.tile();
const currentIndex = unit.trajectoryIndex();
const explosionTick: number = trajectory.length - currentIndex;
for (let i = unit.trajectoryIndex(); i < trajectory.length; i++) {
const trajectoryTile = trajectory[i];
if (trajectoryTile.targetable && this.isInRange(trajectoryTile.tile)) {
const nukeTickToReach = i - currentIndex;
const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile);
const reachableOnTime = Math.abs(nukeTickToReach - samTickToReach) <= 1;
if (reachableOnTime && samTickToReach < explosionTick) {
return trajectoryTile.tile;
}
}
}
return undefined;
}
public getSingleTarget(): Target | null {
// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.mg.config().defaultSamRange() * 1.5;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
detectionRange,
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) => {
return (
isUnit(unit) &&
unit.owner() !== this.player &&
!this.player.isFriendly(unit.owner())
);
},
);
// Clear unreachable nukes that went out of range
this.updateUnreachableNukes(nukes);
const targets: Array<Target> = [];
for (const nuke of nukes) {
if (this.nukesToIgnore.has(nuke.unit.id())) {
continue;
}
const interceptionTile = this.computeInterceptionTile(nuke.unit);
if (interceptionTile !== undefined) {
targets.push({ unit: nuke.unit, tile: interceptionTile });
} else {
// Store unreachable nukes in order to prevent useless interception computation
this.storeUnreachableNukes(nuke.unit.id());
}
}
return (
targets.sort((a: Target, b: Target) => {
// Prioritize Hydrogen Bombs
if (
a.unit.type() === UnitType.HydrogenBomb &&
b.unit.type() !== UnitType.HydrogenBomb
)
return -1;
if (
a.unit.type() !== UnitType.HydrogenBomb &&
b.unit.type() === UnitType.HydrogenBomb
)
return 1;
return 0;
})[0] ?? null
);
}
}
export class SAMLauncherExecution implements Execution {
private mg: Game;
private active: boolean = true;
@@ -18,6 +133,7 @@ export class SAMLauncherExecution implements Execution {
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
private MIRVWarheadSearchRadius = 400;
private MIRVWarheadProtectionRadius = 50;
private targetingSystem: SAMTargetingSystem;
private pseudoRandom: PseudoRandom | undefined;
@@ -35,41 +151,6 @@ export class SAMLauncherExecution implements Execution {
this.mg = mg;
}
private getSingleTarget(): Unit | null {
if (this.sam === null) return null;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
this.mg.config().defaultSamRange(),
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) =>
unit.owner() !== this.player &&
!this.player.isFriendly(unit.owner()) &&
unit.isTargetable(),
);
return (
nukes.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
// Prioritize Hydrogen Bombs
if (
unitA.type() === UnitType.HydrogenBomb &&
unitB.type() !== UnitType.HydrogenBomb
)
return -1;
if (
unitA.type() !== UnitType.HydrogenBomb &&
unitB.type() === UnitType.HydrogenBomb
)
return 1;
// If both are the same type, sort by distance (lower `distSquared` means closer)
return distA - distB;
})[0]?.unit ?? null
);
}
private isHit(type: UnitType, random: number): boolean {
if (type === UnitType.AtomBomb) {
return true;
@@ -98,6 +179,26 @@ export class SAMLauncherExecution implements Execution {
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {});
}
this.targetingSystem ??= new SAMTargetingSystem(
this.mg,
this.player,
this.sam,
);
if (this.sam.isInCooldown()) {
const frontTime = this.sam.missileTimerQueue()[0];
if (frontTime === undefined) {
return;
}
const cooldown =
this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime);
if (cooldown <= 0) {
this.sam.reloadMissile();
}
return;
}
if (!this.sam.isActive()) {
this.active = false;
return;
@@ -114,6 +215,7 @@ export class SAMLauncherExecution implements Execution {
this.MIRVWarheadSearchRadius,
UnitType.MIRVWarhead,
({ unit }) => {
if (!isUnit(unit)) return false;
if (unit.owner() === this.player) return false;
if (this.player.isFriendly(unit.owner())) return false;
const dst = unit.targetTile();
@@ -126,19 +228,18 @@ export class SAMLauncherExecution implements Execution {
},
);
let target: Unit | null = null;
let target: Target | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.getSingleTarget();
target = this.targetingSystem.getSingleTarget();
}
const isSingleTarget = target && !target.targetedBySAM();
if (
(isSingleTarget || mirvWarheadTargets.length > 0) &&
!this.sam.isInCooldown()
) {
const isSingleTarget = target && !target.unit.targetedBySAM();
if (isSingleTarget || mirvWarheadTargets.length > 0) {
this.sam.launch();
const type =
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target?.type();
mirvWarheadTargets.length > 0
? UnitType.MIRVWarhead
: target?.unit.type();
if (type === undefined) throw new Error("Unknown unit type");
const random = this.pseudoRandom.next();
const hit = this.isHit(type, random);
@@ -172,31 +273,20 @@ export class SAMLauncherExecution implements Execution {
mirvWarheadTargets.length,
);
} else if (target !== null) {
target.setTargetedBySAM(true);
target.unit.setTargetedBySAM(true);
this.mg.addExecution(
new SAMMissileExecution(
this.sam.tile(),
this.sam.owner(),
this.sam,
target,
target.unit,
target.tile,
),
);
} else {
throw new Error("target is null");
}
}
const frontTime = this.sam.missileTimerQueue()[0];
if (frontTime === undefined) {
return;
}
const cooldown =
this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime);
if (cooldown <= 0) {
this.sam.reloadMissile();
}
}
isActive(): boolean {
+4 -2
View File
@@ -16,18 +16,20 @@ export class SAMMissileExecution implements Execution {
private pathFinder: AirPathFinder;
private SAMMissile: Unit | undefined;
private mg: Game;
private speed: number = 0;
constructor(
private spawn: TileRef,
private _owner: Player,
private ownerUnit: Unit,
private target: Unit,
private speed: number = 12,
private targetTile: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
this.mg = mg;
this.speed = this.mg.config().defaultSamMissileSpeed();
}
tick(ticks: number): void {
@@ -55,7 +57,7 @@ export class SAMMissileExecution implements Execution {
for (let i = 0; i < this.speed; i++) {
const result = this.pathFinder.nextTile(
this.SAMMissile.tile(),
this.target.tile(),
this.targetTile,
);
if (result === true) {
this.mg.displayMessage(
+12 -1
View File
@@ -9,6 +9,7 @@ export class ShellExecution implements Execution {
private shell: Unit | undefined;
private mg: Game;
private destroyAtTick: number = -1;
private random: PseudoRandom;
constructor(
private spawn: TileRef,
@@ -20,6 +21,7 @@ export class ShellExecution implements Execution {
init(mg: Game, ticks: number): void {
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
this.mg = mg;
this.random = new PseudoRandom(mg.ticks());
}
tick(ticks: number): void {
@@ -61,7 +63,16 @@ export class ShellExecution implements Execution {
private effectOnTarget(): number {
const { damage } = this.mg.config().unitInfo(UnitType.Shell);
return damage ?? 0;
const baseDamage = damage ?? 250;
const roll = this.random.nextInt(1, 6);
const damageMultiplier = (roll - 1) * 25 + 200;
return Math.round((baseDamage / 250) * damageMultiplier);
}
public getEffectOnTargetForTesting(): number {
return this.effectOnTarget();
}
isActive(): boolean {
+4
View File
@@ -32,6 +32,10 @@ export class TrainExecution implements Execution {
private numCars: number,
) {}
public owner(): Player {
return this.player;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const stations = this.railNetwork.findStationsPath(
+3 -1
View File
@@ -31,7 +31,9 @@ export class BinaryLoaderGameMapLoader implements GameMapLoader {
return cachedMap;
}
const key = Object.keys(GameMapType).find((k) => GameMapType[k] === map);
const key = Object.keys(GameMapType).find(
(k) => GameMapType[k as keyof typeof GameMapType] === map,
);
const fileName = key?.toLowerCase();
const mapData = {
+3 -1
View File
@@ -17,7 +17,9 @@ export class FetchGameMapLoader implements GameMapLoader {
return cachedMap;
}
const key = Object.keys(GameMapType).find((k) => GameMapType[k] === map);
const key = Object.keys(GameMapType).find(
(k) => GameMapType[k as keyof typeof GameMapType] === map,
);
const fileName = key?.toLowerCase();
if (!fileName) {
+16 -5
View File
@@ -9,6 +9,7 @@ import {
} from "./GameUpdates";
import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats";
import { UnitPredicate } from "./UnitGrid";
export type PlayerID = string;
export type Tick = number;
@@ -189,6 +190,10 @@ export interface OwnerComp {
owner: Player;
}
export type TrajectoryTile = {
tile: TileRef;
targetable: boolean;
};
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
@@ -207,10 +212,12 @@ export interface UnitParamsMap {
[UnitType.AtomBomb]: {
targetTile?: number;
trajectory: TrajectoryTile[];
};
[UnitType.HydrogenBomb]: {
targetTile?: number;
trajectory: TrajectoryTile[];
};
[UnitType.TradeShip]: {
@@ -383,9 +390,10 @@ export class PlayerInfo {
}
}
export function isUnit(unit: Unit | UnitParams<UnitType>): unit is Unit {
export function isUnit(unit: unknown): unit is Unit {
return (
unit !== undefined &&
unit &&
typeof unit === "object" &&
"isUnit" in unit &&
typeof unit.isUnit === "function" &&
unit.isUnit()
@@ -420,6 +428,9 @@ export interface Unit {
// Targeting
setTargetTile(cell: TileRef | undefined): void;
targetTile(): TileRef | undefined;
setTrajectoryIndex(i: number): void;
trajectoryIndex(): number;
trajectory(): TrajectoryTile[];
setTargetUnit(unit: Unit | undefined): void;
targetUnit(): Unit | undefined;
setTargetedBySAM(targeted: boolean): void;
@@ -653,12 +664,12 @@ export interface Game extends GameMap {
searchRange: number,
type: UnitType,
playerId: PlayerID,
);
): boolean;
nearbyUnits(
tile: TileRef,
searchRange: number,
types: UnitType | UnitType[],
predicate?: (value: { unit: Unit; distSquared: number }) => boolean,
predicate?: UnitPredicate,
): Array<{ unit: Unit; distSquared: number }>;
addExecution(...exec: Execution[]): void;
@@ -694,7 +705,7 @@ export interface Game extends GameMap {
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
conquerPlayer(conqueror: Player, conquered: Player);
conquerPlayer(conqueror: Player, conquered: Player): void;
}
export interface PlayerActions {
+2 -2
View File
@@ -40,7 +40,7 @@ import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
export function createGame(
humans: PlayerInfo[],
@@ -758,7 +758,7 @@ export class GameImpl implements Game {
tile: TileRef,
searchRange: number,
types: UnitType | UnitType[],
predicate?: (value: { unit: Unit; distSquared: number }) => boolean,
predicate?: UnitPredicate,
): Array<{ unit: Unit; distSquared: number }> {
return this.unitGrid.nearbyUnits(
tile,
+2 -2
View File
@@ -18,7 +18,7 @@ export interface GameUpdateViewData {
tick: number;
updates: GameUpdates;
packedTileUpdates: BigUint64Array;
playerNameViewData: Record<number, NameViewData>;
playerNameViewData: Record<string, NameViewData>;
}
export interface ErrorUpdate {
@@ -69,9 +69,9 @@ export type GameUpdate =
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
player: PlayerID;
tile: TileRef;
gold: number;
workers: number;
troops: number;
}
+2 -2
View File
@@ -34,7 +34,7 @@ import {
} from "./GameUpdates";
import { TerrainMapData } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
@@ -472,7 +472,7 @@ export class GameView implements GameMap {
tile: TileRef,
searchRange: number,
types: UnitType | UnitType[],
predicate?: (value: { unit: UnitView; distSquared: number }) => boolean,
predicate?: UnitPredicate,
): Array<{ unit: UnitView; distSquared: number }> {
return this.unitGrid.nearbyUnits(
tile,
+3 -3
View File
@@ -211,9 +211,9 @@ export class PlayerImpl implements Player {
return this._units.filter((u) => ts.has(u.type()));
}
private numUnitsConstructed: number[] = [];
private numUnitsConstructed: Partial<Record<UnitType, number>> = {};
private recordUnitConstructed(type: UnitType): void {
if (type in this.numUnitsConstructed) {
if (this.numUnitsConstructed[type] !== undefined) {
this.numUnitsConstructed[type]++;
} else {
this.numUnitsConstructed[type] = 1;
@@ -704,9 +704,9 @@ export class PlayerImpl implements Player {
if (tile) {
this.mg.addUpdate({
type: GameUpdateType.BonusEvent,
player: this.id(),
tile,
gold: Number(toAdd),
workers: 0,
troops: 0,
});
}
+31 -6
View File
@@ -25,8 +25,15 @@ class CityStopHandler implements TrainStopHandler {
trainExecution: TrainExecution,
): void {
const level = BigInt(station.unit.level() + 1);
const goldBonus = (mg.config().trainGold() * level) / this.factor;
station.unit.owner().addGold(goldBonus, station.tile());
let goldBonus = (mg.config().trainGold() * level) / this.factor;
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
// Share revenue with the station owner if it's not the current player
if (stationOwner.isFriendly(trainOwner)) {
goldBonus += BigInt(1_000); // Bonus for everybody when trading with an ally!
stationOwner.addGold(goldBonus, station.tile());
}
trainOwner.addGold(goldBonus, station.tile());
}
}
@@ -39,8 +46,15 @@ class PortStopHandler implements TrainStopHandler {
trainExecution: TrainExecution,
): void {
const level = BigInt(station.unit.level() + 1);
const goldBonus = (mg.config().trainGold() * level) / this.factor;
station.unit.owner().addGold(goldBonus, station.tile());
let goldBonus = (mg.config().trainGold() * level) / this.factor;
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
// Share revenue with the station owner if it's not the current player
if (stationOwner.isFriendly(trainOwner)) {
goldBonus += BigInt(1_000); // Bonus for everybody when trading with an ally!
stationOwner.addGold(goldBonus, station.tile());
}
trainOwner.addGold(goldBonus, station.tile());
}
}
@@ -51,7 +65,15 @@ class FactoryStopHandler implements TrainStopHandler {
station: TrainStation,
trainExecution: TrainExecution,
): void {
station.unit.owner().addGold(mg.config().trainGold(), station.tile());
let goldBonus = mg.config().trainGold();
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
// Share revenue with the station owner if it's not the current player
if (stationOwner.isFriendly(trainOwner)) {
goldBonus += BigInt(1_000); // Bonus for everybody when trading with an ally!
stationOwner.addGold(goldBonus, station.tile());
}
trainOwner.addGold(goldBonus, station.tile());
}
}
@@ -207,7 +229,10 @@ export class Cluster {
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.stations) {
if (station.tradeAvailable(player)) {
if (
station.unit.owner() === player ||
station.unit.owner().isFriendly(player)
) {
tradingStations.add(station);
}
}
+7 -5
View File
@@ -2,6 +2,11 @@ import { PlayerID, Unit, UnitType } from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { UnitView } from "./GameView";
export type UnitPredicate = (value: {
unit: Unit | UnitView;
distSquared: number;
}) => boolean;
export class UnitGrid {
private grid: Map<UnitType, Set<Unit | UnitView>>[][];
private readonly cellSize = 100;
@@ -130,11 +135,8 @@ export class UnitGrid {
nearbyUnits(
tile: TileRef,
searchRange: number,
types: UnitType | UnitType[],
predicate?: (value: {
unit: Unit | UnitView;
distSquared: number;
}) => boolean,
types: readonly UnitType[] | UnitType,
predicate?: UnitPredicate,
): Array<{ unit: Unit | UnitView; distSquared: number }> {
const nearby: Array<{ unit: Unit | UnitView; distSquared: number }> = [];
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
+18
View File
@@ -5,6 +5,7 @@ import {
Player,
Tick,
TrainType,
TrajectoryTile,
Unit,
UnitInfo,
UnitType,
@@ -35,6 +36,9 @@ export class UnitImpl implements Unit {
private _targetable: boolean = true;
private _loaded: boolean | undefined;
private _trainType: TrainType | undefined;
// Nuke only
private _trajectoryIndex: number = 0;
private _trajectory: TrajectoryTile[];
constructor(
private _type: UnitType,
@@ -48,6 +52,7 @@ export class UnitImpl implements Unit {
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._targetTile =
"targetTile" in params ? (params.targetTile ?? undefined) : undefined;
this._trajectory = "trajectory" in params ? (params.trajectory ?? []) : [];
this._troops = "troops" in params ? (params.troops ?? 0) : 0;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params
@@ -323,6 +328,19 @@ export class UnitImpl implements Unit {
return this._targetTile;
}
setTrajectoryIndex(i: number): void {
const max = this._trajectory.length - 1;
this._trajectoryIndex = i < 0 ? 0 : i > max ? max : i;
}
trajectoryIndex(): number {
return this._trajectoryIndex;
}
trajectory(): TrajectoryTile[] {
return this._trajectory;
}
setTargetUnit(target: Unit | undefined): void {
this._targetUnit = target;
}
+8
View File
@@ -40,6 +40,10 @@ export class UserSettings {
return this.get("settings.specialEffects", true);
}
structureSprites() {
return this.get("settings.structureSprites", true);
}
darkMode() {
return this.get("settings.darkMode", false);
}
@@ -90,6 +94,10 @@ export class UserSettings {
this.set("settings.specialEffects", !this.fxLayer());
}
toggleStructureSprites() {
this.set("settings.structureSprites", !this.structureSprites());
}
toggleTerritoryPatterns() {
this.set("settings.territoryPatterns", !this.territoryPatterns());
}
+18 -1
View File
@@ -14,6 +14,7 @@ export class ParabolaPathFinder {
computeControlPoints(
orig: TileRef,
dst: TileRef,
increment: number = 3,
distanceBasedHeight = true,
) {
const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) };
@@ -34,7 +35,7 @@ export class ParabolaPathFinder {
y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0),
};
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3);
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment);
}
nextTile(speed: number): TileRef | true {
@@ -47,6 +48,22 @@ export class ParabolaPathFinder {
}
return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y));
}
currentIndex(): number {
if (!this.curve) {
return 0;
}
return this.curve.getCurrentIndex();
}
allTiles(): TileRef[] {
if (!this.curve) {
return [];
}
return this.curve
.getAllPoints()
.map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y)));
}
}
export class AirPathFinder {
+78 -51
View File
@@ -78,76 +78,103 @@ export class CubicBezierCurve {
*/
export class DistanceBasedBezierCurve extends CubicBezierCurve {
private totalDistance: number = 0;
private distanceLUT: Array<{ t: number; distance: number }> = [];
private lastFoundIndex: number = 0; // To keep track of the last found index
private cachedPoints: Point[] = [];
private currentIndex: number = 0;
constructor(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
distanceIncrement: number,
) {
super(p0, p1, p2, p3);
this.computeAllPoints(distanceIncrement, 0.002);
}
getAllPoints(): Point[] {
return this.cachedPoints;
}
/**
* Move forward along the curve by the given distance.
* Returns the next cached point, or null if at the end.
*/
increment(distance: number): Point | null {
this.totalDistance += distance;
const targetDistance = Math.min(
this.totalDistance,
this.distanceLUT[this.distanceLUT.length - 1]?.distance ||
this.totalDistance,
);
const t = this.computeTForDistance(targetDistance);
if (t >= 1) {
return null; // end reached
// Step forward through cached points until we're at the correct distance
while (
this.currentIndex < this.cachedPoints.length - 1 &&
this.getDistanceUpToIndex(this.currentIndex + 1) < this.totalDistance
) {
this.currentIndex++;
}
return this.getPointAt(t);
if (this.currentIndex >= this.cachedPoints.length - 1) {
return null; // End of curve
}
return this.cachedPoints[this.currentIndex];
}
getCurrentIndex(): number {
return this.currentIndex;
}
/**
* Generate @p numSteps segments, starting from the beginning of the curve
* Each segment size is added in the LUT
* Precompute all points spaced @p pixelSpacing apart
*/
generateCumulativeDistanceLUT(numSteps: number = 500): void {
this.distanceLUT = [];
let cumulativeDistance = 0;
let prevPoint = this.getPointAt(0);
computeAllPoints(pixelSpacing: number, precision: number): void {
this.cachedPoints = [];
this.totalDistance = 0;
this.currentIndex = 0;
for (let i = 1; i <= numSteps; i++) {
const t = i / numSteps;
let t = 0;
let prevPoint = this.getPointAt(t);
this.cachedPoints.push(prevPoint);
let cumulativeDistance = 0;
while (t < 1) {
t = Math.min(t + precision, 1);
const currentPoint = this.getPointAt(t);
const dx = currentPoint.x - prevPoint.x;
const dy = currentPoint.y - prevPoint.y;
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistance += segmentLength;
this.distanceLUT.push({ t, distance: cumulativeDistance });
if (cumulativeDistance >= pixelSpacing) {
this.cachedPoints.push(currentPoint);
cumulativeDistance = 0;
}
prevPoint = currentPoint;
}
// Make sure the last point is exactly at t=1
const finalPoint = this.getPointAt(1);
if (
this.cachedPoints.length === 0 ||
finalPoint.x !== this.cachedPoints[this.cachedPoints.length - 1].x ||
finalPoint.y !== this.cachedPoints[this.cachedPoints.length - 1].y
) {
this.cachedPoints.push(finalPoint);
}
}
computeTForDistance(distance: number): number {
if (this.distanceLUT.length === 0) {
this.generateCumulativeDistanceLUT();
/**
* Optional helper: get distance along the cached points up to a given index
*/
private getDistanceUpToIndex(index: number): number {
let dist = 0;
for (let i = 1; i <= index; i++) {
const p1 = this.cachedPoints[i - 1];
const p2 = this.cachedPoints[i];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
dist += Math.sqrt(dx * dx + dy * dy);
}
if (distance <= 0) return 0;
if (distance >= this.distanceLUT[this.distanceLUT.length - 1].distance) {
return 1;
}
let lowerIndex = this.lastFoundIndex;
let upperIndex = this.distanceLUT.length - 1;
// Binary search for the closest range
while (upperIndex - lowerIndex > 1) {
const midIndex = Math.floor((upperIndex + lowerIndex) / 2);
if (this.distanceLUT[midIndex].distance < distance) {
lowerIndex = midIndex;
} else {
upperIndex = midIndex;
}
}
const lower = this.distanceLUT[lowerIndex];
const upper = this.distanceLUT[upperIndex];
this.lastFoundIndex = lowerIndex;
// Linear interpolation of t based on the distance
const t =
lower.t +
((distance - lower.distance) * (upper.t - lower.t)) /
(upper.distance - lower.distance);
return t;
return dist;
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
import version from "../../../resources/version.txt";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { GameUpdateViewData } from "../game/GameUpdates";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
InitializedMessage,
@@ -17,7 +17,11 @@ const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
function gameUpdate(gu: GameUpdateViewData) {
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
if (!("updates" in gu)) {
return;
}
sendMessage({
type: "game_update",
gameUpdate: gu,
+43 -45
View File
@@ -1,19 +1,36 @@
import { Cosmetics } from "../core/CosmeticSchemas";
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { PatternDecoder } from "../core/PatternDecoder";
export class PrivilegeChecker {
export interface PrivilegeChecker {
isPatternAllowed(
base64: string,
flares: readonly string[] | undefined,
): true | "restricted" | "unlisted" | "invalid";
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid";
}
export class PrivilegeCheckerImpl implements PrivilegeChecker {
private b64ToPattern: Record<string, Pattern> = {};
constructor(
private cosmetics: Cosmetics,
private b64urlDecode: (base64: string) => Uint8Array,
) {}
) {
for (const name in this.cosmetics.patterns) {
const pattern = this.cosmetics.patterns[name];
this.b64ToPattern[pattern.pattern] = pattern;
}
}
isPatternAllowed(
base64: string,
roles: readonly string[] | undefined,
flares: readonly string[] | undefined,
): true | "restricted" | "unlisted" | "invalid" {
// Look for the pattern in the cosmetics.json config
const found = this.cosmetics.patterns[base64];
const found = this.b64ToPattern[base64];
if (found === undefined) {
try {
// Ensure that the pattern will not throw for clients
@@ -30,27 +47,9 @@ export class PrivilegeChecker {
return "unlisted";
}
const { role_group, name } = found;
if (role_group === undefined) {
// Pattern has no restrictions
return true;
}
for (const groupName of role_group) {
if (
roles !== undefined &&
roles.some((role) =>
this.cosmetics.role_groups[groupName]?.includes(role),
)
) {
// Player is in a role group for this pattern
return true;
}
}
if (
flares !== undefined &&
(flares.includes(`pattern:${name}`) || flares.includes("pattern:*"))
(flares.includes(`pattern:${found.name}`) || flares.includes("pattern:*"))
) {
// Player has a flare for this pattern
return true;
@@ -61,7 +60,6 @@ export class PrivilegeChecker {
isCustomFlagAllowed(
flag: string,
roles: readonly string[] | undefined,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
if (!flag.startsWith("!")) return "invalid";
@@ -78,8 +76,8 @@ export class PrivilegeChecker {
for (const segment of segments) {
const [layerKey, colorKey] = segment.split("-");
if (!layerKey || !colorKey) return "invalid";
const layer = this.cosmetics.flag.layers[layerKey];
const color = this.cosmetics.flag.color[colorKey];
const layer = this.cosmetics.flag?.layers[layerKey];
const color = this.cosmetics.flag?.color[colorKey];
if (!layer || !color) return "invalid";
// Super-flare bypasses all restrictions
@@ -90,17 +88,9 @@ export class PrivilegeChecker {
// Check layer restrictions
const layerSpec = layer;
let layerAllowed = false;
if (!layerSpec.role_group && !layerSpec.flares) {
if (!layerSpec.flares) {
layerAllowed = true;
} else {
// By role
if (layerSpec.role_group) {
const allowedRoles =
this.cosmetics.role_groups[layerSpec.role_group] || [];
if (roles?.some((r) => allowedRoles.includes(r))) {
layerAllowed = true;
}
}
// By flare
if (
layerSpec.flares &&
@@ -117,17 +107,9 @@ export class PrivilegeChecker {
// Check color restrictions
const colorSpec = color;
let colorAllowed = false;
if (!colorSpec.role_group && !colorSpec.flares) {
if (!colorSpec.flares) {
colorAllowed = true;
} else {
// By role
if (colorSpec.role_group) {
const allowedRoles =
this.cosmetics.role_groups[colorSpec.role_group] || [];
if (roles?.some((r) => allowedRoles.includes(r))) {
colorAllowed = true;
}
}
// By flare
if (
colorSpec.flares &&
@@ -149,3 +131,19 @@ export class PrivilegeChecker {
return true;
}
}
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isPatternAllowed(
name: string,
flares: readonly string[] | undefined,
): true | "restricted" | "unlisted" | "invalid" {
return true;
}
isCustomFlagAllowed(
flag: string,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
return true;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { base64url } from "jose";
import { Logger } from "winston";
import { CosmeticsSchema } from "../core/CosmeticSchemas";
import {
FailOpenPrivilegeChecker,
PrivilegeChecker,
PrivilegeCheckerImpl,
} from "./Privilege";
// Refreshes the privilege checker every 5 minutes.
// WARNING: This fails open if cosmetics.json is not available.
export class PrivilegeRefresher {
private privilegeChecker: PrivilegeChecker | null = null;
private failOpenPrivilegeChecker: PrivilegeChecker =
new FailOpenPrivilegeChecker();
private log: Logger;
constructor(
private endpoint: string,
parentLog: Logger,
private refreshInterval: number = 1000 * 60 * 3,
) {
this.log = parentLog.child({ comp: "privilege-refresher" });
}
public async start() {
this.log.info(
`Starting privilege refresher with interval ${this.refreshInterval}`,
);
// Add some jitter to the initial load and the interval.
setTimeout(() => this.loadPrivilegeChecker(), Math.random() * 1000);
setInterval(
() => this.loadPrivilegeChecker(),
this.refreshInterval + Math.random() * 1000,
);
}
public get(): PrivilegeChecker {
return this.privilegeChecker ?? this.failOpenPrivilegeChecker;
}
private async loadPrivilegeChecker(): Promise<void> {
this.log.info(`Loading privilege checker from ${this.endpoint}`);
try {
const response = await fetch(this.endpoint);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const cosmeticsData = await response.json();
const result = CosmeticsSchema.safeParse(cosmeticsData);
if (!result.success) {
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
}
this.privilegeChecker = new PrivilegeCheckerImpl(
result.data,
base64url.decode,
);
this.log.info(`Privilege checker loaded successfully`);
} catch (error) {
this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error);
throw error;
}
}
}
+17 -24
View File
@@ -2,14 +2,12 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import { base64url } from "jose";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { COSMETICS } from "../core/CosmeticSchemas";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
@@ -25,7 +23,8 @@ import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { PrivilegeChecker } from "./Privilege";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics";
const config = getServerConfigFromServer();
@@ -34,7 +33,7 @@ const workerId = parseInt(process.env.WORKER_ID ?? "0");
const log = logger.child({ comp: `w_${workerId}` });
// Worker setup
export function startWorker() {
export async function startWorker() {
log.info(`Worker starting...`);
const __filename = fileURLToPath(import.meta.url);
@@ -46,12 +45,16 @@ export function startWorker() {
const gm = new GameManager(config, log);
const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
if (config.otelEnabled()) {
initWorkerMetrics(gm);
}
const privilegeRefresher = new PrivilegeRefresher(
config.jwtIssuer() + "/cosmetics.json",
log,
);
privilegeRefresher.start();
// Middleware to handle /wX path prefix
app.use((req, res, next) => {
// Extract the original path without the worker prefix
@@ -334,15 +337,9 @@ export function startWorker() {
// Ignore ping
return;
} else if (clientMsg.type !== "join") {
const error = `Invalid message before join: ${JSON.stringify(clientMsg)}`;
log.warn(error);
ws.send(
JSON.stringify({
type: "error",
error,
} satisfies ServerErrorMessage),
log.warn(
`Invalid message before join: ${JSON.stringify(clientMsg)}`,
);
ws.close(1002, "ClientJoinMessageSchema");
return;
}
@@ -402,11 +399,9 @@ export function startWorker() {
// Check if the flag is allowed
if (clientMsg.flag !== undefined) {
if (clientMsg.flag.startsWith("!")) {
const allowed = privilegeChecker.isCustomFlagAllowed(
clientMsg.flag,
roles,
flares,
);
const allowed = privilegeRefresher
.get()
.isCustomFlagAllowed(clientMsg.flag, flares);
if (allowed !== true) {
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
ws.close(1002, `Custom flag ${allowed}`);
@@ -417,11 +412,9 @@ export function startWorker() {
// Check if the pattern is allowed
if (clientMsg.pattern !== undefined) {
const allowed = privilegeChecker.isPatternAllowed(
clientMsg.pattern,
roles,
flares,
);
const allowed = privilegeRefresher
.get()
.isPatternAllowed(clientMsg.pattern, flares);
if (allowed !== true) {
log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`);
ws.close(1002, `Pattern ${allowed}`);
+144
View File
@@ -112,3 +112,147 @@ describe("Attack", () => {
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
});
});
describe("Attack race condition with alliance requests", () => {
it("should not mark attacker as traitor when alliance is formed after attack starts", async () => {
const game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
game.addPlayer(playerAInfo);
game.addPlayer(playerBInfo);
const playerA = game.player(playerAInfo.id);
const playerB = game.player(playerBInfo.id);
// Spawn both players
const spawnA = game.ref(0, 10);
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(playerAInfo, spawnA),
new SpawnExecution(playerBInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull();
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
// Player B counter-attacks Player A
const counterAttackExecution = new AttackExecution(
null,
playerB,
playerA.id(),
null,
);
game.addExecution(counterAttackExecution);
// Player B accepts the alliance request
if (allianceRequest) {
allianceRequest.accept();
}
// Execute a few ticks to process the attacks
for (let i = 0; i < 5; i++) {
game.executeNextTick();
}
// Player A should not be marked as traitor because the alliance was formed after the attack started
expect(playerA.isTraitor()).toBe(false);
// The attacks should have retreated due to the alliance being formed
expect(playerA.outgoingAttacks()).toHaveLength(0);
expect(playerB.outgoingAttacks()).toHaveLength(0);
});
it("should mark attacker as traitor when alliance existed before attack", async () => {
const game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
game.addPlayer(playerAInfo);
game.addPlayer(playerBInfo);
const playerA = game.player(playerAInfo.id);
const playerB = game.player(playerBInfo.id);
// Spawn both players
const spawnA = game.ref(0, 10);
const spawnB = game.ref(0, 15);
game.addExecution(
new SpawnExecution(playerAInfo, spawnA),
new SpawnExecution(playerBInfo, spawnB),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
// Create an alliance between Player A and Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
if (allianceRequest) {
allianceRequest.accept();
}
// Player A attacks Player B (should break the alliance)
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
// Execute a few ticks to process the attack
for (let i = 0; i < 10; i++) {
game.executeNextTick();
}
// Player A should be marked as traitor because they attacked an ally
expect(playerA.isTraitor()).toBe(true);
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* @jest-environment jsdom
*/
import { AutoUpgradeEvent } from "../src/client/InputHandler";
import { EventBus } from "../src/core/EventBus";
describe("AutoUpgrade Feature", () => {
let eventBus: EventBus;
beforeEach(() => {
eventBus = new EventBus();
});
describe("AutoUpgradeEvent", () => {
test("should create AutoUpgradeEvent with correct coordinates", () => {
const event = new AutoUpgradeEvent(100, 200);
expect(event.x).toBe(100);
expect(event.y).toBe(200);
});
test("should emit AutoUpgradeEvent when created", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const event = new AutoUpgradeEvent(150, 250);
eventBus.emit(event);
expect(mockEmit).toHaveBeenCalledWith(event);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
});
describe("AutoUpgradeEvent Integration", () => {
test("should handle multiple AutoUpgradeEvents", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const event1 = new AutoUpgradeEvent(100, 200);
const event2 = new AutoUpgradeEvent(300, 400);
eventBus.emit(event1);
eventBus.emit(event2);
expect(mockEmit).toHaveBeenCalledTimes(2);
expect(mockEmit).toHaveBeenNthCalledWith(1, event1);
expect(mockEmit).toHaveBeenNthCalledWith(2, event2);
});
test("should handle AutoUpgradeEvent with zero coordinates", () => {
const event = new AutoUpgradeEvent(0, 0);
expect(event.x).toBe(0);
expect(event.y).toBe(0);
});
test("should handle AutoUpgradeEvent with negative coordinates", () => {
const event = new AutoUpgradeEvent(-100, -200);
expect(event.x).toBe(-100);
expect(event.y).toBe(-200);
});
test("should handle AutoUpgradeEvent with decimal coordinates", () => {
const event = new AutoUpgradeEvent(100.5, 200.7);
expect(event.x).toBe(100.5);
expect(event.y).toBe(200.7);
});
});
describe("AutoUpgradeEvent Event Bus Integration", () => {
test("should allow event listeners to subscribe to AutoUpgradeEvent", () => {
const mockListener = jest.fn();
const event = new AutoUpgradeEvent(100, 200);
eventBus.on(AutoUpgradeEvent, mockListener);
eventBus.emit(event);
expect(mockListener).toHaveBeenCalledWith(event);
});
test("should allow multiple listeners for AutoUpgradeEvent", () => {
const mockListener1 = jest.fn();
const mockListener2 = jest.fn();
const event = new AutoUpgradeEvent(100, 200);
eventBus.on(AutoUpgradeEvent, mockListener1);
eventBus.on(AutoUpgradeEvent, mockListener2);
eventBus.emit(event);
expect(mockListener1).toHaveBeenCalledWith(event);
expect(mockListener2).toHaveBeenCalledWith(event);
});
test("should not call unsubscribed listeners", () => {
const mockListener = jest.fn();
const event = new AutoUpgradeEvent(100, 200);
eventBus.on(AutoUpgradeEvent, mockListener);
eventBus.off(AutoUpgradeEvent, mockListener);
eventBus.emit(event);
expect(mockListener).not.toHaveBeenCalled();
});
});
describe("AutoUpgradeEvent Edge Cases", () => {
test("should handle very large coordinates", () => {
const event = new AutoUpgradeEvent(
Number.MAX_SAFE_INTEGER,
Number.MAX_SAFE_INTEGER,
);
expect(event.x).toBe(Number.MAX_SAFE_INTEGER);
expect(event.y).toBe(Number.MAX_SAFE_INTEGER);
});
test("should handle very small coordinates", () => {
const event = new AutoUpgradeEvent(
Number.MIN_SAFE_INTEGER,
Number.MIN_SAFE_INTEGER,
);
expect(event.x).toBe(Number.MIN_SAFE_INTEGER);
expect(event.y).toBe(Number.MIN_SAFE_INTEGER);
});
test("should handle NaN coordinates", () => {
const event = new AutoUpgradeEvent(NaN, NaN);
expect(isNaN(event.x)).toBe(true);
expect(isNaN(event.y)).toBe(true);
});
test("should handle Infinity coordinates", () => {
const event = new AutoUpgradeEvent(Infinity, -Infinity);
expect(event.x).toBe(Infinity);
expect(event.y).toBe(-Infinity);
});
});
describe("AutoUpgradeEvent Serialization", () => {
test("should maintain coordinate precision", () => {
const event = new AutoUpgradeEvent(100.123456789, 200.987654321);
expect(event.x).toBe(100.123456789);
expect(event.y).toBe(200.987654321);
});
test("should handle string conversion", () => {
const event = new AutoUpgradeEvent(100, 200);
const eventString = JSON.stringify(event);
const parsedEvent = JSON.parse(eventString);
expect(parsedEvent.x).toBe(100);
expect(parsedEvent.y).toBe(200);
});
});
});
+400
View File
@@ -0,0 +1,400 @@
/**
* @jest-environment jsdom
*/
import { AutoUpgradeEvent, InputHandler } from "../src/client/InputHandler";
import { EventBus } from "../src/core/EventBus";
class MockPointerEvent {
button: number;
clientX: number;
clientY: number;
pointerId: number;
type: string;
preventDefault: () => void;
constructor(type: string, init: any) {
this.type = type;
this.button = init.button;
this.clientX = init.clientX;
this.clientY = init.clientY;
this.pointerId = init.pointerId;
this.preventDefault = jest.fn();
}
}
global.PointerEvent = MockPointerEvent as any;
describe("InputHandler AutoUpgrade", () => {
let inputHandler: InputHandler;
let eventBus: EventBus;
let mockCanvas: HTMLCanvasElement;
beforeEach(() => {
mockCanvas = document.createElement("canvas");
mockCanvas.width = 800;
mockCanvas.height = 600;
eventBus = new EventBus();
inputHandler = new InputHandler(mockCanvas, eventBus);
});
describe("Middle Mouse Button Handling", () => {
test("should emit AutoUpgradeEvent on middle mouse button press", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should emit MouseDownEvent on left mouse button press instead of AutoUpgradeEvent", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 0,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
const calls = mockEmit.mock.calls;
const lastCall = calls[calls.length - 1];
expect(lastCall[0]).not.toBeInstanceOf(AutoUpgradeEvent);
});
test("should not emit AutoUpgradeEvent on right mouse button press", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 2,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).not.toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should handle multiple middle mouse button presses", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent1 = new PointerEvent("pointerdown", {
button: 1,
clientX: 100,
clientY: 200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent1);
const pointerEvent2 = new PointerEvent("pointerdown", {
button: 1,
clientX: 300,
clientY: 400,
pointerId: 2,
});
inputHandler["onPointerDown"](pointerEvent2);
expect(mockEmit).toHaveBeenCalledTimes(2);
expect(mockEmit).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
x: 100,
y: 200,
}),
);
expect(mockEmit).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
x: 300,
y: 400,
}),
);
});
test("should handle middle mouse button press with zero coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 0,
clientY: 0,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 0,
y: 0,
}),
);
});
test("should handle middle mouse button press with negative coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: -100,
clientY: -200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: -100,
y: -200,
}),
);
});
test("should handle middle mouse button press with decimal coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 100.5,
clientY: 200.7,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 100.5,
y: 200.7,
}),
);
});
});
describe("Pointer Event Handling", () => {
test("should handle pointer events with different pointer IDs", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent1 = new PointerEvent("pointerdown", {
button: 1,
clientX: 100,
clientY: 200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent1);
const pointerEvent2 = new PointerEvent("pointerdown", {
button: 1,
clientX: 300,
clientY: 400,
pointerId: 2,
});
inputHandler["onPointerDown"](pointerEvent2);
expect(mockEmit).toHaveBeenCalledTimes(2);
});
test("should handle pointer events with same pointer ID", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent1 = new PointerEvent("pointerdown", {
button: 1,
clientX: 100,
clientY: 200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent1);
const pointerEvent2 = new PointerEvent("pointerdown", {
button: 1,
clientX: 300,
clientY: 400,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent2);
expect(mockEmit).toHaveBeenCalledTimes(2);
});
});
describe("Edge Cases", () => {
test("should handle very large coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: Number.MAX_SAFE_INTEGER,
clientY: Number.MAX_SAFE_INTEGER,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: Number.MAX_SAFE_INTEGER,
y: Number.MAX_SAFE_INTEGER,
}),
);
});
test("should handle very small coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: Number.MIN_SAFE_INTEGER,
clientY: Number.MIN_SAFE_INTEGER,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: Number.MIN_SAFE_INTEGER,
y: Number.MIN_SAFE_INTEGER,
}),
);
});
test("should handle NaN coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: NaN,
clientY: NaN,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: NaN,
y: NaN,
}),
);
});
test("should handle Infinity coordinates", () => {
const mockEmit = jest.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: Infinity,
clientY: -Infinity,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: Infinity,
y: -Infinity,
}),
);
});
});
describe("Integration with Event Bus", () => {
test("should allow event listeners to receive AutoUpgradeEvents", () => {
const mockListener = jest.fn();
eventBus.on(AutoUpgradeEvent, mockListener);
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockListener).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should allow multiple listeners for AutoUpgradeEvent", () => {
const mockListener1 = jest.fn();
const mockListener2 = jest.fn();
eventBus.on(AutoUpgradeEvent, mockListener1);
eventBus.on(AutoUpgradeEvent, mockListener2);
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockListener1).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
expect(mockListener2).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should not call unsubscribed listeners", () => {
const mockListener = jest.fn();
eventBus.on(AutoUpgradeEvent, mockListener);
eventBus.off(AutoUpgradeEvent, mockListener);
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockListener).not.toHaveBeenCalled();
});
});
});
+305
View File
@@ -0,0 +1,305 @@
import { DefensePostExecution } from "../src/core/execution/DefensePostExecution";
import { ShellExecution } from "../src/core/execution/ShellExecution";
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { setup } from "./util/Setup";
const coastX = 7;
let game: Game;
let player1: Player;
let player2: Player;
describe("Shell Random Damage", () => {
beforeEach(async () => {
game = await setup(
"half_land_half_ocean",
{
infiniteGold: true,
instantBuild: true,
},
[
new PlayerInfo("attacker", PlayerType.Human, null, "player_1_id"),
new PlayerInfo("defender", PlayerType.Human, null, "player_2_id"),
],
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
player1 = game.player("player_1_id");
player2 = game.player("player_2_id");
});
test("Shell damage varies randomly between 200-300 base damage", () => {
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 5, 10),
{
patrolTile: game.ref(coastX + 5, 10),
},
);
const initialHealth = target.health();
const damages: number[] = [];
const numShells = 50;
for (let i = 0; i < numShells; i++) {
const shell = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
shell.init(game, game.ticks() + i);
const healthBefore = target.health();
target.modifyHealth(-shell.getEffectOnTargetForTesting(), player1);
const healthAfter = target.health();
const damage = healthBefore - healthAfter;
if (damage > 0) {
damages.push(damage);
}
target.modifyHealth(-(healthBefore - initialHealth));
}
expect(damages.length).toBeGreaterThan(0);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const minExpectedDamage = Math.round((baseDamage / 250) * 200);
const maxExpectedDamage = Math.round((baseDamage / 250) * 300);
damages.forEach((damage) => {
expect(damage).toBeGreaterThanOrEqual(minExpectedDamage);
expect(damage).toBeLessThanOrEqual(maxExpectedDamage);
});
const uniqueDamages = new Set(damages);
expect(damages.length).toBeGreaterThan(0);
});
test("Warship shell attacks have random damage", () => {
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{
patrolTile: game.ref(coastX + 1, 10),
},
);
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 2, 10),
{
patrolTile: game.ref(coastX + 2, 10),
},
);
const initialHealth = target.health();
warship.setTargetUnit(target);
game.addExecution(new WarshipExecution(warship));
const damages: number[] = [];
const maxAttempts = 100;
let attempts = 0;
while (damages.length < 10 && attempts < maxAttempts) {
const healthBefore = target.health();
game.executeNextTick();
const healthAfter = target.health();
if (healthAfter < healthBefore) {
damages.push(healthBefore - healthAfter);
target.modifyHealth(-(healthBefore - initialHealth));
}
attempts++;
}
expect(damages.length).toBeGreaterThan(0);
const uniqueDamages = new Set(damages);
expect(uniqueDamages.size).toBeGreaterThan(1);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const minExpectedDamage = Math.round((baseDamage / 250) * 200);
const maxExpectedDamage = Math.round((baseDamage / 250) * 300);
damages.forEach((damage) => {
expect(damage).toBeGreaterThanOrEqual(minExpectedDamage);
expect(damage).toBeLessThanOrEqual(maxExpectedDamage);
});
});
test("Defense post shell attacks have random damage", () => {
const defensePost = new DefensePostExecution(player1, game.ref(coastX, 5));
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{
patrolTile: game.ref(coastX + 1, 10),
},
);
const initialHealth = target.health();
defensePost.init(game, game.ticks());
const damages: number[] = [];
const maxAttempts = 100;
let attempts = 0;
while (damages.length < 5 && attempts < maxAttempts) {
const healthBefore = target.health();
defensePost.tick(game.ticks());
game.executeNextTick();
const healthAfter = target.health();
if (healthAfter < healthBefore) {
damages.push(healthBefore - healthAfter);
target.modifyHealth(-(healthBefore - initialHealth));
}
attempts++;
}
if (damages.length > 0) {
const uniqueDamages = new Set(damages);
expect(uniqueDamages.size).toBeGreaterThan(1);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const minExpectedDamage = Math.round((baseDamage / 250) * 200);
const maxExpectedDamage = Math.round((baseDamage / 250) * 300);
damages.forEach((damage) => {
expect(damage).toBeGreaterThanOrEqual(minExpectedDamage);
expect(damage).toBeLessThanOrEqual(maxExpectedDamage);
});
}
});
test("Shell damage distribution follows expected pattern", () => {
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 5, 10),
{
patrolTile: game.ref(coastX + 5, 10),
},
);
const initialHealth = target.health();
const damages: number[] = [];
const numShells = 1000;
for (let i = 0; i < numShells; i++) {
const shell = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
shell.init(game, game.ticks() + i);
const healthBefore = target.health();
target.modifyHealth(-shell.getEffectOnTargetForTesting(), player1);
const healthAfter = target.health();
const damage = healthBefore - healthAfter;
if (damage > 0) {
damages.push(damage);
}
target.modifyHealth(-(healthBefore - initialHealth));
}
expect(damages.length).toBeGreaterThan(0);
const baseDamage = game.config().unitInfo(UnitType.Shell).damage ?? 250;
const expectedDamages = [
Math.round((baseDamage / 250) * 200),
Math.round((baseDamage / 250) * 225),
Math.round((baseDamage / 250) * 250),
Math.round((baseDamage / 250) * 275),
Math.round((baseDamage / 250) * 300),
Math.round((baseDamage / 250) * 325),
];
const uniqueDamages = new Set(damages);
expect(uniqueDamages.size).toBeGreaterThan(0);
const damageCounts = new Map<number, number>();
damages.forEach((damage) => {
damageCounts.set(damage, (damageCounts.get(damage) ?? 0) + 1);
});
const maxCount = Math.max(...damageCounts.values());
const minCount = Math.min(...damageCounts.values());
expect(maxCount - minCount).toBeLessThan(damages.length * 0.8);
});
test("Shell damage is consistent with same random seed", () => {
const target = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 5, 10),
{
patrolTile: game.ref(coastX + 5, 10),
},
);
const initialHealth = target.health();
const seed = 12345;
const shell1 = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
const shell2 = new ShellExecution(
game.ref(coastX, 10),
player1,
player1.buildUnit(UnitType.Warship, game.ref(coastX, 10), {
patrolTile: game.ref(coastX, 10),
}),
target,
);
game.executeNextTick();
const currentTicks = game.ticks();
shell1.init(game, currentTicks);
shell2.init(game, currentTicks);
const healthBefore1 = target.health();
target.modifyHealth(-shell1.getEffectOnTargetForTesting(), player1);
const damage1 = healthBefore1 - target.health();
target.modifyHealth(-(healthBefore1 - initialHealth));
const healthBefore2 = target.health();
target.modifyHealth(-shell2.getEffectOnTargetForTesting(), player1);
const damage2 = healthBefore2 - target.health();
expect(damage1).toBe(damage2);
});
});
+5 -12
View File
@@ -29,7 +29,7 @@ async function nearbyUnits(
unitPosX: number,
rangeCheck: number,
range: number,
unitTypes: UnitType[],
unitTypes: readonly UnitType[],
) {
const game = await setup(mapName, { infiniteGold: true, instantBuild: true });
const grid = new UnitGrid(game.map());
@@ -51,7 +51,7 @@ describe("Unit Grid range tests", () => {
["plains", 0, 10, 11, false], // Exactly 1px outside
["big_plains", 0, 198, 42, true], // Inside huge range
["big_plains", 0, 198, 199, false], // Exactly 1px outside huge range
];
] as const;
describe("Is unit in range", () => {
test.each(hasUnitCases)(
@@ -77,25 +77,18 @@ describe("Unit Grid range tests", () => {
["plains", 0, 10, 11, [UnitType.DefensePost], 0], // 1px outside
["big_plains", 0, 198, 42, [UnitType.TradeShip], 1], // Inside huge range
["big_plains", 0, 198, 199, [UnitType.TransportShip], 0], // 1px outside
];
] as const;
describe("Retrieve all units in range", () => {
test.each(unitsInRangeCases)(
"on %p map, look if unit at position %p with a range of %p is in range of %p position, returns %p",
async (
mapName: string,
unitPosX: number,
range: number,
rangeCheck: number,
units: UnitType[],
expectedResult: number,
) => {
async (mapName, unitPosX, range, rangeCheck, units, expectedResult) => {
const result = await nearbyUnits(
mapName,
unitPosX,
rangeCheck,
range,
units,
units, // remove readonly
);
expect(result.length).toBe(expectedResult);
},
@@ -0,0 +1,509 @@
/**
* @jest-environment jsdom
*/
import {
attackMenuElement,
buildMenuElement,
COLORS,
MenuElementParams,
rootMenuElement,
Slot,
} from "../../../src/client/graphics/layers/RadialMenuElements";
import { UnitType } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { GameView, PlayerView } from "../../../src/core/game/GameView";
jest.mock("../../../src/client/Utils", () => ({
translateText: jest.fn((key: string) => key),
renderNumber: jest.fn((num: number) => num.toString()),
}));
jest.mock("../../../src/client/graphics/layers/BuildMenu", () => {
const { UnitType } = jest.requireActual("../../../src/core/game/Game");
return {
flattenedBuildTable: [
{
unitType: UnitType.City,
key: "unit_type.city",
description: "unit_type.city_desc",
icon: "city-icon",
countable: true,
},
{
unitType: UnitType.Factory,
key: "unit_type.factory",
description: "unit_type.factory_desc",
icon: "factory-icon",
countable: true,
},
{
unitType: UnitType.AtomBomb,
key: "unit_type.atom_bomb",
description: "unit_type.atom_bomb_desc",
icon: "atom-bomb-icon",
countable: false,
},
{
unitType: UnitType.Warship,
key: "unit_type.warship",
description: "unit_type.warship_desc",
icon: "warship-icon",
countable: true,
},
{
unitType: UnitType.HydrogenBomb,
key: "unit_type.hydrogen_bomb",
description: "unit_type.hydrogen_bomb_desc",
icon: "hydrogen-bomb-icon",
countable: false,
},
{
unitType: UnitType.MIRV,
key: "unit_type.mirv",
description: "unit_type.mirv_desc",
icon: "mirv-icon",
countable: false,
},
],
};
});
jest.mock("nanoid", () => ({
customAlphabet: jest.fn(() => jest.fn(() => "mock-id")),
}));
jest.mock("dompurify", () => ({
__esModule: true,
default: {
sanitize: jest.fn((str: string) => str),
},
}));
describe("RadialMenuElements", () => {
let mockParams: MenuElementParams;
let mockPlayer: PlayerView;
let mockGame: GameView;
let mockBuildMenu: any;
let mockPlayerActions: any;
let mockTile: TileRef;
beforeEach(() => {
mockPlayer = {
id: () => 1,
isAlliedWith: jest.fn(() => false),
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockGame = {
inSpawnPhase: jest.fn(() => false),
owner: jest.fn(() => mockPlayer),
isLand: jest.fn(() => true),
config: jest.fn(() => ({
theme: () => ({
territoryColor: () => ({
lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
}),
}),
isUnitDisabled: jest.fn(() => false),
})),
} as unknown as GameView;
mockBuildMenu = {
canBuildOrUpgrade: jest.fn(() => true),
cost: jest.fn(() => 100),
count: jest.fn(() => 5),
sendBuildOrUpgrade: jest.fn(),
};
mockPlayerActions = {
buildableUnits: [
{ type: UnitType.City, canBuild: true },
{ type: UnitType.Factory, canBuild: true },
{ type: UnitType.AtomBomb, canBuild: true },
{ type: UnitType.Warship, canBuild: true },
{ type: UnitType.HydrogenBomb, canBuild: true },
{ type: UnitType.MIRV, canBuild: true },
{ type: UnitType.TransportShip, canBuild: true },
],
canAttack: true,
interaction: {
canSendAllianceRequest: true,
canBreakAlliance: false,
canDonate: true,
},
};
mockTile = {} as TileRef;
mockParams = {
myPlayer: mockPlayer,
selected: mockPlayer,
tile: mockTile,
playerActions: mockPlayerActions,
game: mockGame,
buildMenu: mockBuildMenu,
emojiTable: {} as any,
playerActionHandler: {} as any,
playerPanel: {} as any,
chatIntegration: {} as any,
eventBus: {} as any,
closeMenu: jest.fn(),
};
});
describe("attackMenuElement", () => {
it("should have correct basic properties", () => {
expect(attackMenuElement.id).toBe(Slot.Attack);
expect(attackMenuElement.name).toBe("radial_attack");
expect(attackMenuElement.icon).toBeDefined();
expect(attackMenuElement.color).toBe(COLORS.attack);
});
it("should be disabled during spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => true);
expect(attackMenuElement.disabled(mockParams)).toBe(true);
});
it("should be enabled when not in spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => false);
expect(attackMenuElement.disabled(mockParams)).toBe(false);
});
it("should return attack submenu with attack units only", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
expect(subMenu).toBeDefined();
expect(subMenu.length).toBeGreaterThan(0);
const attackUnitTypes = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("attack_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
returnedUnitTypes.forEach((unitType) => {
expect(attackUnitTypes).toContain(unitType);
});
});
it("should not include construction units in attack menu", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const constructionUnitTypes = [UnitType.City, UnitType.Factory];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("attack_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
constructionUnitTypes.forEach((unitType) => {
expect(returnedUnitTypes).not.toContain(unitType);
});
});
it("should handle undefined params in submenu", () => {
const subMenu = attackMenuElement.subMenu!(undefined as any);
expect(subMenu).toEqual([]);
});
});
describe("buildMenuElement", () => {
it("should have correct basic properties", () => {
expect(buildMenuElement.id).toBe(Slot.Build);
expect(buildMenuElement.name).toBe("build");
expect(buildMenuElement.icon).toBeDefined();
expect(buildMenuElement.color).toBe(COLORS.build);
});
it("should be disabled during spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => true);
expect(buildMenuElement.disabled(mockParams)).toBe(true);
});
it("should be enabled when not in spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => false);
expect(buildMenuElement.disabled(mockParams)).toBe(false);
});
it("should return build submenu with construction units only", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
expect(subMenu).toBeDefined();
expect(subMenu.length).toBeGreaterThan(0);
const constructionUnitTypes = [UnitType.City, UnitType.Factory];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("build_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
returnedUnitTypes.forEach((unitType) => {
expect(constructionUnitTypes).toContain(unitType);
});
});
it("should not include attack units in build menu", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const attackUnitTypes = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("build_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
attackUnitTypes.forEach((unitType) => {
expect(returnedUnitTypes).not.toContain(unitType);
});
});
it("should handle undefined params in submenu", () => {
const subMenu = buildMenuElement.subMenu!(undefined as any);
expect(subMenu).toEqual([]);
});
});
describe("rootMenuElement", () => {
it("should have correct basic properties", () => {
expect(rootMenuElement.id).toBe("root");
expect(rootMenuElement.name).toBe("root");
expect(rootMenuElement.disabled(mockParams)).toBe(false);
});
it("should show build menu on own territory", () => {
const subMenu = rootMenuElement.subMenu!(mockParams);
const buildMenu = subMenu.find((item) => item.id === Slot.Build);
const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
expect(buildMenu).toBeDefined();
expect(attackMenu).toBeUndefined();
});
it("should show attack menu on enemy territory", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockGame.owner = jest.fn(() => enemyPlayer);
const subMenu = rootMenuElement.subMenu!(mockParams);
const buildMenu = subMenu.find((item) => item.id === Slot.Build);
const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
expect(attackMenu).toBeDefined();
expect(buildMenu).toBeUndefined();
});
it("should include info and boat menus in both cases", () => {
const subMenu = rootMenuElement.subMenu!(mockParams);
const infoMenu = subMenu.find((item) => item.id === Slot.Info);
const boatMenu = subMenu.find((item) => item.id === Slot.Boat);
expect(infoMenu).toBeDefined();
expect(boatMenu).toBeDefined();
});
it("should handle ally menu correctly", () => {
const allyPlayer = {
id: () => 2,
isAlliedWith: jest.fn(() => true),
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
const subMenu = rootMenuElement.subMenu!(mockParams);
const allyMenu = subMenu.find((item) => item.id === "ally_break");
expect(allyMenu).toBeDefined();
});
});
describe("Menu element actions", () => {
it("should execute build action correctly", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement).toBeDefined();
expect(cityElement!.action).toBeDefined();
if (cityElement!.action) {
cityElement!.action(mockParams);
expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
expect(mockParams.closeMenu).toHaveBeenCalled();
}
});
it("should execute attack action correctly", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const atomBombElement = subMenu.find(
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement).toBeDefined();
expect(atomBombElement!.action).toBeDefined();
if (atomBombElement!.action) {
atomBombElement!.action(mockParams);
expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
expect(mockParams.closeMenu).toHaveBeenCalled();
}
});
it("should not execute action when buildable unit is not found", () => {
mockPlayerActions.buildableUnits = [];
mockBuildMenu.canBuildOrUpgrade = jest.fn(() => false);
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
if (cityElement!.action) {
cityElement!.action(mockParams);
expect(mockBuildMenu.sendBuildOrUpgrade).not.toHaveBeenCalled();
expect(mockParams.closeMenu).not.toHaveBeenCalled();
}
});
});
describe("Menu element tooltips", () => {
it("should generate correct tooltip items for build elements", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.tooltipItems).toBeDefined();
expect(cityElement!.tooltipItems!.length).toBeGreaterThan(0);
const tooltipTexts = cityElement!.tooltipItems!.map((item) => item.text);
expect(tooltipTexts).toContain("unit_type.city");
expect(tooltipTexts).toContain("unit_type.city_desc");
expect(tooltipTexts.some((text) => text.includes("100"))).toBe(true);
expect(tooltipTexts.some((text) => text.includes("5x"))).toBe(true);
});
it("should generate correct tooltip items for attack elements", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const atomBombElement = subMenu.find(
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement!.tooltipItems).toBeDefined();
expect(atomBombElement!.tooltipItems!.length).toBeGreaterThan(0);
const tooltipTexts = atomBombElement!.tooltipItems!.map(
(item) => item.text,
);
expect(tooltipTexts).toContain("unit_type.atom_bomb");
expect(tooltipTexts).toContain("unit_type.atom_bomb_desc");
expect(tooltipTexts.some((text) => text.includes("100"))).toBe(true);
});
});
describe("Menu element colors", () => {
it("should use correct colors for build elements", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.color).toBe(COLORS.building);
});
it("should use correct colors for attack elements", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const atomBombElement = subMenu.find(
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement!.color).toBe(COLORS.attack);
});
it("should not set color when element is disabled", () => {
mockBuildMenu.canBuildOrUpgrade = jest.fn(() => false);
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.color).toBeUndefined();
});
});
describe("Translation integration", () => {
it("should use translateText for tooltip items in build menu", () => {
const { translateText } = jest.requireMock("../../../src/client/Utils");
(translateText as jest.Mock).mockClear();
buildMenuElement.subMenu!(mockParams);
expect(translateText).toHaveBeenCalledWith("unit_type.city");
expect(translateText).toHaveBeenCalledWith("unit_type.city_desc");
expect(translateText).toHaveBeenCalledWith("unit_type.factory");
expect(translateText).toHaveBeenCalledWith("unit_type.factory_desc");
});
it("should use translateText for tooltip items in attack menu", () => {
const { translateText } = jest.requireMock("../../../src/client/Utils");
(translateText as jest.Mock).mockClear();
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
attackMenuElement.subMenu!(mockParams);
expect(translateText).toHaveBeenCalledWith("unit_type.atom_bomb");
expect(translateText).toHaveBeenCalledWith("unit_type.atom_bomb_desc");
expect(translateText).toHaveBeenCalledWith("unit_type.hydrogen_bomb");
expect(translateText).toHaveBeenCalledWith(
"unit_type.hydrogen_bomb_desc",
);
});
});
});
@@ -81,10 +81,16 @@ describe("SAM", () => {
test("one sam should take down one nuke", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender, null, sam));
attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {
targetTile: game.ref(2, 1),
});
// Sam will only target nukes it can destroy before it reaches its target
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {
targetTile: game.ref(3, 1),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(2, 1), targetable: true },
{ tile: game.ref(3, 1), targetable: true },
],
});
executeTicks(game, 3);
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0);
@@ -94,10 +100,20 @@ describe("SAM", () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender, null, sam));
attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), {
targetTile: game.ref(2, 1),
targetTile: game.ref(3, 1),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(2, 1), targetable: true },
{ tile: game.ref(3, 1), targetable: true },
],
});
attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
targetTile: game.ref(1, 2),
targetTile: game.ref(1, 3),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(1, 2), targetable: true },
{ tile: game.ref(1, 3), targetable: true },
],
});
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2);
@@ -111,8 +127,13 @@ describe("SAM", () => {
game.addExecution(new SAMLauncherExecution(defender, null, sam));
expect(sam.isInCooldown()).toBeFalsy();
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
targetTile: game.ref(1, 2),
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {
targetTile: game.ref(1, 3),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(2, 1), targetable: true },
{ tile: game.ref(3, 1), targetable: true },
],
});
executeTicks(game, 3);
@@ -134,8 +155,13 @@ describe("SAM", () => {
game.addExecution(new SAMLauncherExecution(defender, null, sam1));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {});
game.addExecution(new SAMLauncherExecution(defender, null, sam2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), {
targetTile: game.ref(2, 2),
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {
targetTile: game.ref(1, 3),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(1, 2), targetable: true },
{ tile: game.ref(1, 3), targetable: true },
],
});
executeTicks(game, 3);
@@ -159,7 +185,7 @@ describe("SAM", () => {
game.addExecution(nukeExecution);
// Long distance nuke: compute the proper number of ticks
const ticksToExecute = Math.ceil(
targetDistance / game.config().defaultNukeSpeed(),
targetDistance / game.config().defaultNukeSpeed() + 1,
);
executeTicks(game, ticksToExecute);
@@ -194,7 +220,7 @@ describe("SAM", () => {
game.addExecution(nukeExecution);
// Long distance nuke: compute the proper number of ticks
const ticksToExecute = Math.ceil(
targetDistance / game.config().defaultNukeSpeed(),
targetDistance / game.config().defaultNukeSpeed() + 1,
);
executeTicks(game, ticksToExecute);
expect(nukeExecution.isActive()).toBeFalsy();
+28 -9
View File
@@ -1,5 +1,5 @@
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
import { Game, Unit, UnitType } from "../../../src/core/game/Game";
import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game";
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
jest.mock("../../../src/core/game/Game");
@@ -9,25 +9,28 @@ jest.mock("../../../src/core/PseudoRandom");
describe("TrainStation", () => {
let game: jest.Mocked<Game>;
let unit: jest.Mocked<Unit>;
let player: jest.Mocked<Player>;
let trainExecution: jest.Mocked<TrainExecution>;
beforeEach(() => {
game = {
ticks: jest.fn().mockReturnValue(123),
config: jest.fn().mockReturnValue({
trainGold: () => BigInt(10),
trainGold: () => BigInt(4000),
}),
addUpdate: jest.fn(),
addExecution: jest.fn(),
} as any;
player = {
addGold: jest.fn(),
id: 1,
canTrade: jest.fn().mockReturnValue(true),
isFriendly: jest.fn().mockReturnValue(false),
} as any;
unit = {
owner: jest.fn().mockReturnValue({
addGold: jest.fn(),
id: 1,
canTrade: jest.fn().mockReturnValue(true),
tradingPorts: jest.fn().mockReturnValue([{ name: "Port1" }]),
}),
owner: jest.fn().mockReturnValue(player),
level: jest.fn().mockReturnValue(1),
tile: jest.fn().mockReturnValue({ x: 0, y: 0 }),
type: jest.fn(),
@@ -36,6 +39,8 @@ describe("TrainStation", () => {
trainExecution = {
loadCargo: jest.fn(),
owner: jest.fn().mockReturnValue(player),
level: jest.fn(),
} as any;
});
@@ -45,7 +50,21 @@ describe("TrainStation", () => {
station.onTrainStop(trainExecution);
expect(unit.owner().addGold).toHaveBeenCalledWith(10n, unit.tile());
expect(unit.owner().addGold).toHaveBeenCalledWith(4000n, unit.tile());
});
it("handles allied trade", () => {
unit.type.mockReturnValue(UnitType.City);
player.isFriendly.mockReturnValue(true);
const station = new TrainStation(game, unit);
station.onTrainStop(trainExecution);
expect(unit.owner().addGold).toHaveBeenCalledWith(5000n, unit.tile());
expect(trainExecution.owner().addGold).toHaveBeenCalledWith(
5000n,
unit.tile(),
);
});
it("checks trade availability (same owner)", () => {
+30 -122
View File
@@ -1,5 +1,5 @@
import type { Cosmetics } from "../../src/core/CosmeticSchemas";
import { PrivilegeChecker } from "../../src/server/Privilege";
import { PrivilegeCheckerImpl } from "../../src/server/Privilege";
describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
const dummyPatternDecoder = (_base64: string) => {
@@ -7,181 +7,89 @@ describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
};
const mockCosmetics: Cosmetics = {
role_groups: {
donor: ["role_donor"],
admin: ["role_admin"],
},
patterns: {},
flag: {
layers: {
a: {
name: "chocolate",
role_group: "donor",
flares: ["cosmetic:flags"],
},
b: { name: "center_hline" },
c: { name: "admin_layer", role_group: "admin" },
c: { name: "admin_layer" },
},
color: {
a: { color: "#ff0000", name: "red", role_group: "admin" },
a: { color: "#ff0000", name: "red", flares: ["cosmetic:red"] },
b: { color: "#00ff00", name: "green" },
c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] },
},
},
};
const checker = new PrivilegeChecker(mockCosmetics, dummyPatternDecoder);
const checker = new PrivilegeCheckerImpl(mockCosmetics, dummyPatternDecoder);
it("allowed: unrestricted layer/color", () => {
expect(checker.isCustomFlagAllowed("!b-b", [], [])).toBe(true);
});
it("restricted: donor layer without role", () => {
expect(checker.isCustomFlagAllowed("!a-b", [], [])).toBe("restricted");
});
it("allowed: donor layer with donor role", () => {
expect(checker.isCustomFlagAllowed("!a-b", ["role_donor"], [])).toBe(true);
expect(checker.isCustomFlagAllowed("!b-b", [])).toBe(true);
});
it("allowed: donor layer with correct flare", () => {
expect(checker.isCustomFlagAllowed("!a-b", [], ["cosmetic:flags"])).toBe(
true,
);
});
it("restricted: admin color without role", () => {
expect(checker.isCustomFlagAllowed("!b-a", [], [])).toBe("restricted");
});
it("allowed: admin color with admin role", () => {
expect(checker.isCustomFlagAllowed("!b-a", ["role_admin"], [])).toBe(true);
expect(checker.isCustomFlagAllowed("!a-b", ["cosmetic:flags"])).toBe(true);
});
it("allowed: color with correct flare", () => {
expect(checker.isCustomFlagAllowed("!b-c", [], ["cosmetic:blue"])).toBe(
true,
);
expect(checker.isCustomFlagAllowed("!b-c", ["cosmetic:blue"])).toBe(true);
});
it("invalid: non-existent layer", () => {
expect(checker.isCustomFlagAllowed("!zzz-a", ["role_donor"], [])).toBe(
"invalid",
);
expect(checker.isCustomFlagAllowed("!zzz-a", [])).toBe("invalid");
});
it("invalid: non-existent color", () => {
expect(checker.isCustomFlagAllowed("!a-zzz", ["role_donor"], [])).toBe(
"invalid",
);
expect(checker.isCustomFlagAllowed("!a-zzz", [])).toBe("invalid");
});
it("allowed: superFlare allows all listed", () => {
expect(checker.isCustomFlagAllowed("!a-a", [], ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!b-b", [], ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!c-a", [], ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c", [], ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!a-a", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!b-b", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!c-a", ["flag:*"])).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c", ["flag:*"])).toBe(true);
});
it("invalid: superFlare does not allow non-existent", () => {
expect(checker.isCustomFlagAllowed("!zzz-zzz", [], ["flag:*"])).toBe(
"invalid",
);
expect(checker.isCustomFlagAllowed("!zzz-zzz", ["flag:*"])).toBe("invalid");
});
it("allowed: flare flag:layer:chocolate allows chocolate layer", () => {
expect(
checker.isCustomFlagAllowed("!a-b", [], ["flag:layer:chocolate"]),
).toBe(true);
});
it("allowed: flare flag:color:blue allows blue color", () => {
expect(checker.isCustomFlagAllowed("!b-c", [], ["flag:color:blue"])).toBe(
expect(checker.isCustomFlagAllowed("!a-b", ["flag:layer:chocolate"])).toBe(
true,
);
});
it("allowed: flare flag:color:blue allows blue color", () => {
expect(checker.isCustomFlagAllowed("!b-c", ["flag:color:blue"])).toBe(true);
});
it("restricted: only color flare, layer still restricted", () => {
expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe(
expect(checker.isCustomFlagAllowed("!a-c", ["cosmetic:blue"])).toBe(
"restricted",
);
});
it("restricted: only layer flare, color still restricted", () => {
expect(checker.isCustomFlagAllowed("!c-a", [], ["cosmetic:flags"])).toBe(
"restricted",
);
});
it("allowed: layer by role, color by flare", () => {
// layer a: role_group donor, color c: flares ["cosmetic:blue"]
expect(
checker.isCustomFlagAllowed("!a-c", ["role_donor"], ["cosmetic:blue"]),
).toBe(true);
});
it("restricted: layer by role, color by flare (missing flare)", () => {
expect(checker.isCustomFlagAllowed("!a-c", ["role_donor"], [])).toBe(
"restricted",
);
});
it("restricted: layer by role, color by flare (missing role)", () => {
expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe(
"restricted",
);
});
it("allowed: layer by flare, color by role", () => {
// layer a: flares ["cosmetic:flags"], color a: role_group admin
expect(
checker.isCustomFlagAllowed("!a-a", ["role_admin"], ["cosmetic:flags"]),
).toBe(true);
});
it("restricted: layer by flare, color by role (missing flare)", () => {
expect(checker.isCustomFlagAllowed("!a-a", ["role_admin"], [])).toBe(
"restricted",
);
});
it("restricted: layer by flare, color by role (missing role)", () => {
expect(checker.isCustomFlagAllowed("!a-a", [], ["cosmetic:flags"])).toBe(
expect(checker.isCustomFlagAllowed("!c-a", ["cosmetic:flags"])).toBe(
"restricted",
);
});
it("allowed: two segments, both unrestricted", () => {
expect(checker.isCustomFlagAllowed("!b-b_b-b", [], [])).toBe(true);
});
it("restricted: two segments, one restricted by layer role", () => {
expect(checker.isCustomFlagAllowed("!a-b_b-b", [], [])).toBe("restricted");
expect(checker.isCustomFlagAllowed("!a-b_b-b", ["role_donor"], [])).toBe(
true,
);
});
it("restricted: two segments, one restricted by color role", () => {
expect(checker.isCustomFlagAllowed("!b-a_b-b", [], [])).toBe("restricted");
expect(checker.isCustomFlagAllowed("!b-a_b-b", ["role_admin"], [])).toBe(
true,
);
});
it("allowed: two segments, one by role, one by flare", () => {
expect(
checker.isCustomFlagAllowed(
"!a-c_b-b",
["role_donor"],
["cosmetic:blue"],
),
).toBe(true);
expect(checker.isCustomFlagAllowed("!a-c_b-b", ["role_donor"], [])).toBe(
"restricted",
);
expect(checker.isCustomFlagAllowed("!a-c_b-b", [], ["cosmetic:blue"])).toBe(
"restricted",
);
expect(checker.isCustomFlagAllowed("!b-b_b-b", [])).toBe(true);
});
it("allowed: two segments, both by flare", () => {
expect(
checker.isCustomFlagAllowed(
"!a-c_a-c",
[],
["cosmetic:flags", "cosmetic:blue"],
),
checker.isCustomFlagAllowed("!a-c_a-c", [
"cosmetic:flags",
"cosmetic:blue",
]),
).toBe(true);
expect(
checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:flags"]),
).toBe("restricted");
expect(checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:blue"])).toBe(
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:flags"])).toBe(
"restricted",
);
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:blue"])).toBe(
"restricted",
);
});
+2 -1
View File
@@ -21,7 +21,8 @@
"resolveJsonModule": true,
"strictNullChecks": true,
"useDefineForClassFields": false,
"strictPropertyInitialization": false
"strictPropertyInitialization": false,
"strict": true
},
"include": [
"src/**/*",