Merge branch 'main' into icslucas-patch-1
@@ -2,6 +2,8 @@ name: 🧪 CI
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -9,3 +9,4 @@ resources/.DS_Store
|
||||
.env*
|
||||
.DS_Store
|
||||
.clinic/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 198 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 291 B |
|
After Width: | Height: | Size: 812 KiB |
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 224 B After Width: | Height: | Size: 235 B |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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")}"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -156,6 +156,7 @@ export interface Config {
|
||||
nukeAllianceBreakThreshold(): number;
|
||||
defaultNukeSpeed(): number;
|
||||
defaultNukeTargetableRange(): number;
|
||||
defaultSamMissileSpeed(): number;
|
||||
defaultSamRange(): number;
|
||||
nukeDeathFactor(
|
||||
nukeType: NukeType,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"strictNullChecks": true,
|
||||
"useDefineForClassFields": false,
|
||||
"strictPropertyInitialization": false
|
||||
"strictPropertyInitialization": false,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
|
||||