diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ce91d5ba5..c7c8c0124 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,8 @@ ## Please complete the following: - [ ] I have added screenshots for all UI updates +- [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file +- [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [ ] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3d32eb87a..58fd0819a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ on: permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }} cancel-in-progress: false jobs: diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml index 864bca7a8..292134f60 100644 --- a/.github/workflows/pr-description.yml +++ b/.github/workflows/pr-description.yml @@ -1,8 +1,13 @@ -name: ๐Ÿงผ PR Description +name: ๐Ÿงผ PR on: pull_request: - types: [opened, edited, synchronize] + types: + - demilestoned + - edited + - milestoned + - opened + - synchronize permissions: {} @@ -24,9 +29,11 @@ jobs: errors.push('โŒ Missing or short `## Description:` section.'); } - // Check all three boxes are checked + // Check all five boxes are checked const requiredBoxes = [ /- \[x\] I have added screenshots for all UI updates/i, + /- \[x\] I process any text displayed to the user through translateText\(\) and I\'ve added it to the en\.json file/i, + /- \[x\] I have added relevant tests to the test directory/i, /- \[x\] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced/i, /- \[x\] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors/i ]; @@ -43,3 +50,18 @@ jobs: } else { console.log('โœ… PR description and checklist look good.'); } + + has-milestone: + name: Has Milestone + runs-on: ubuntu-latest + steps: + - 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; + } + console.log(`โœ… Milestone found: ${milestone.title}`); diff --git a/.gitignore b/.gitignore index 90ce6e054..0cbd366d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build/ node_modules/ out/ static/ +coverage/ TODO.txt resources/images/.DS_Store resources/.DS_Store diff --git a/Dockerfile b/Dockerfile index 44870fbe8..513e5dfdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,5 +59,8 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY startup.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/startup.sh +RUN mkdir -p /tmp/.cloudflared && chmod 777 /tmp/.cloudflared +ENV CF_CONFIG_DIR=/tmp/.cloudflared + # Use the startup script as the entrypoint ENTRYPOINT ["/usr/local/bin/startup.sh"] diff --git a/README.md b/README.md index e4bd2aa27..f2081d255 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,16 @@ npm run start:server-dev ``` - **Lint and fix code**: + ```bash npm run lint:fix ``` +- **Testing** + ```bash + npm test + ``` + ## ๐Ÿ—๏ธ Project Structure - `/src/client` - Frontend game client diff --git a/deploy.sh b/deploy.sh index 48c9cc5f5..dd8210778 100755 --- a/deploy.sh +++ b/deploy.sh @@ -171,8 +171,12 @@ if [ $? -ne 0 ]; then exit 1 fi +# Generate a random filename for the environment file to prevent conflicts +# when multiple deployments are happening at the same time. +ENV_FILE="${REMOTE_UPDATE_PATH}/${SUBDOMAIN}-${RANDOM}.env" + ssh -i $SSH_KEY $REMOTE_USER@$SERVER_HOST "chmod +x $REMOTE_UPDATE_SCRIPT && \ -cat > $REMOTE_UPDATE_PATH/.env << 'EOL' +cat > $ENV_FILE << 'EOL' GAME_ENV=$ENV ENV=$ENV HOST=$HOST @@ -192,8 +196,8 @@ OTEL_ENDPOINT=$OTEL_ENDPOINT BASIC_AUTH_USER=$BASIC_AUTH_USER BASIC_AUTH_PASS=$BASIC_AUTH_PASS EOL -chmod 600 $REMOTE_UPDATE_PATH/.env && \ -$REMOTE_UPDATE_SCRIPT" +chmod 600 $ENV_FILE && \ +$REMOTE_UPDATE_SCRIPT $ENV_FILE" if [ $? -ne 0 ]; then echo "โŒ Failed to execute update script on server." diff --git a/jest.config.ts b/jest.config.ts index 02f05ddd8..bb15e1770 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,4 +17,14 @@ export default { }, transformIgnorePatterns: ["node_modules/(?!(node:)/)"], preset: "ts-jest/presets/default-esm", + collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"], + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0, + }, + }, + coverageReporters: ["text", "lcov", "html"], }; diff --git a/package-lock.json b/package-lock.json index 642cf039e..faad9e243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", "@types/jquery": "^3.5.31", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.11.11", "@types/sinon": "^17.0.3", @@ -8263,6 +8264,13 @@ "@types/sizzle": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 2e2d02381..6eb96b8c3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", "test": "jest", + "test:coverage": "jest --coverage", "format": "prettier --ignore-unknown --write .", "lint": "eslint", "lint:fix": "eslint --fix", @@ -31,6 +32,7 @@ "@types/d3": "^7.4.3", "@types/jest": "^29.5.12", "@types/jquery": "^3.5.31", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", "@types/pg": "^8.11.11", "@types/sinon": "^17.0.3", diff --git a/resources/ads.txt b/resources/ads.txt deleted file mode 100644 index a5b191562..000000000 --- a/resources/ads.txt +++ /dev/null @@ -1,999 +0,0 @@ -ownerdomain=openfront.io -managerdomain=adinplay.com -#V 01.04.2025 VH -#V - - -#----------------------------------------------------------------------------# -# . # -# .o8 # -# oooo ooo .ooooo. ooo. .oo. .oooo. .o888oo oooo oooo .oooo.o # -# `88. .8' d88' `88b `888P"Y88b `P )88b 888 `888 `888 d88( "8 # -# `88..8' 888ooo888 888 888 .oP"888 888 888 888 `"Y88b. # -# `888' 888 . 888 888 d8( 888 888 . 888 888 o. )88b # -# `8' `Y8bod8P' o888o o888o `Y888""8o "888" `V88V"V8P' 8""888P' # -# # -# The leading advertising solution for gaming and entertainment # -# # -# To become a publisher or advertise please contact info@venatus.com # -# # -#----------------------------------------------------------------------------# -adagio.io, 1090, DIRECT -rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807 -pubmatic.com, 159110, RESELLER, 5d62403b186f2ace -lijit.com, 367236, RESELLER, fafdf38b16bf6b2b -improvedigital.com, 1790, RESELLER -triplelift.com, 13482, RESELLER, 6c33edb13117fd86 -rubiconproject.com, 12186, RESELLER, 0bfd66d529a55807 -video.unrulymedia.com, 5672421953199218469, RESELLER -amxrtb.com, 105199358, DIRECT -amxrtb.com, 105199778, DIRECT -sharethrough.com, a6a34444, RESELLER, d53b998a7bd4ecd2 -appnexus.com, 12290, RESELLER -pubmatic.com, 158355, RESELLER, 5d62403b186f2ace -rubiconproject.com, 23844, RESELLER, 0bfd66d529a55807 -openx.com, 559680764, RESELLER, 6a698e2ec38604c6 -adform.com, 2767, RESELLER -adyoulike.com, c1314a52de718f3c214c00173d2994f9, DIRECT -pubmatic.com, 160925, RESELLER, 5d62403b186f2ace -aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT -aniview.com, 5f2063121d82c82557194737, RESELLER, 78b21b97965ec3f8 -aniview.com, 643f8e74688b10f72307cc24, DIRECT, 78b21b97965ec3f8 -google.com, pub-6346866704322274, RESELLER, f08c47fec0942fa0 -pubmatic.com, 160993, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13918, RESELLER, 0bfd66d529a55807 -google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0 -gannett.com, 22652678936, RESELLER -richaudience.com, 1ru8dKmJJV, RESELLER -sharethrough.com, zLsEa05k, RESELLER, d53b998a7bd4ecd2 -aps.amazon.com, 1ad7261b-91ea-4b6f-b9e9-b83522205b75, RESELLER -pubmatic.com, 161335, RESELLER, 5d62403b186f2ace -openx.com, 556532676, RESELLER, 6a698e2ec38604c6 -mediago.io, 045ac24b888bcf59a09731e7f0f2084f, RESELLER -blockthrough.com, 5643766199222272, DIRECT -criteo.com, B-062405, DIRECT, 9fac4a4a87c2a44f -themediagrid.com, CVQXOH, DIRECT, 35d5010d7789b49d -freewheel.tv, 211121, DIRECT -freewheel.tv, 211129-524565, DIRECT -freewheel.tv, 211129-169843, DIRECT -google.com, pub-5781531207509232, DIRECT, f08c47fec0942fa0 -google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0 -google.com, pub-2553634189837243, RESELLER, f08c47fec0942fa0 -gumgum.com, 13385, RESELLER, ffdef49475d318a9 -gumgum.com, 14302, RESELLER, ffdef49475d318a9 -rubiconproject.com, 23434, RESELLER, 0bfd66d529a55807 -pubmatic.com, 157897, RESELLER, 5d62403b186f2ace -indexexchange.com, 183921, DIRECT, 50b1c356f2c5c8fc -indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc -indexexchange.com, 194127, DIRECT, 50b1c356f2c5c8fc -indexexchange.com, 205972, RESELLER, 50b1c356f2c5c8fc -Blis.com,33,RESELLER,61453ae19a4b73f4 -conversantmedia.com,40881,RESELLER,03113cd04947736d -insticator.com,843c9a44-60ea-4342-8ad4-68f894283b3e,DIRECT,b3511ffcafb23a32 -sharethrough.com,Q9IzHdvp,DIRECT,d53b998a7bd4ecd2 -rubiconproject.com,17062,RESELLER,0bfd66d529a55807 -risecodes.com,6124caed9c7adb0001c028d8,DIRECT -pubmatic.com,95054,DIRECT,5d62403b186f2ace -openx.com,558230700,RESELLER,6a698e2ec38604c6 -video.unrulymedia.com,136898039,RESELLER -lijit.com,257618,RESELLER,fafdf38b16bf6b2b -minutemedia.com,01garg96c88b,RESELLER -appnexus.com,3695,RESELLER,f5ab79cb980f11d1 -kargo.com, 8688, DIRECT -kueez.com,e5b6208bc94ed2d5788e1e4c1cf5452e, DIRECT -rubiconproject.com, 16920, RESELLER, 0bfd66d529a55807 -openx.com, 557564833, RESELLER, 6a698e2ec38604c6 -lijit.com, 407406, RESELLER, fafdf38b16bf6b2b #SOVRN -appnexus.com, 8826,RESELLER, f5ab79cb980f11d1 -Media.net,8CU4JTRF9, RESELLER -rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807 -media.net, 8CU8ARTF8, DIRECT -Media.net, 8CU198XI2, DIRECT -themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d -ogury.com, 086233d2-e8a8-44fc-907b-f0752e1c85de, DIRECT -appnexus.com, 11470, RESELLER -openx.com, 542378302, RESELLER, 6a698e2ec38604c6 -openx.com, 540134228, RESELLER, 6a698e2ec38604c6 -openx.com, 537144009, RESELLER, 6a698e2ec38604c6 -openx.com, 560557013, RESELLER, 6a698e2ec38604c6 -optidigital.com,p230,DIRECT -pubmatic.com,158939,RESELLER,5d62403b186f2ace -rubiconproject.com,20336,RESELLER,0bfd66d529a55807 -smartadserver.com,3379,RESELLER,060d053dcf45cbf3 -triplelift.com,8183,RESELLER,6c33edb13117fd86 -the-ozone-project.com, ozoneven0005, DIRECT -openx.com, 540731760, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 160557, RESELLER, 5d62403b186f2ace -themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d -Yahoo.com, 60170, DIRECT, e1a5b5b6e3255540 -pubmatic.com, 159234, RESELLER, 5d62403b186f2ace -pubmatic.com, 160552, RESELLER, 5d62403b186f2ace -pubmatic.com, 159401, RESELLER, 5d62403b186f2ace -pubmatic.com, 165533, RESELLER, 5d62403b186f2ace -richaudience.com, 1XvIoD5o0S, DIRECT -pubmatic.com, 81564, DIRECT, 5d62403b186f2ace -pubmatic.com, 156538, DIRECT, 5d62403b186f2ace -appnexus.com, 8233, DIRECT -rubiconproject.com, 13510, DIRECT -risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT -pubmatic.com, 160295, RESELLER, 5d62403b186f2ace -xandr.com, 14082, RESELLER -rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807 -sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2 -yieldmo.com, 2754490424016969782, RESELLER -media.net, 8CUQ6928Q, RESELLER -onetag.com, 69f48c2160c8113, RESELLER -amxrtb.com, 105199691, RESELLER -openx.com, 537140488, RESELLER, 6a698e2ec38604c6 -video.unrulymedia.com, 335119963, RESELLER -seedtag.com, 5aa6c80640c9e209009721e0, DIRECT -xandr.com, 4009, DIRECT, f5ab79cb980f11d1 -rubiconproject.com, 17280, DIRECT, 0bfd66d529a55807 -smartadserver.com, 3050, DIRECT -lijit.com, 397546, DIRECT, fafdf38b16bf6b2b -sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2 -sharethrough.com, awx1H4AI, RESELLER, d53b998a7bd4ecd2 -smaato.com, 1100055690, DIRECT, 07bcf65f187117b4 -smaato.com, 1100049216, DIRECT, 07bcf65f187117b5 -rubiconproject.com, 24600, RESELLER, 0bfd66d529a55807 -pubmatic.com, 156177, RESELLER, 5d62403b186f2ace -smartadserver.com, 3490, DIRECT -smartadserver.com, 4016, DIRECT -smartadserver.com, 4074, DIRECT -sovrn.com, 237754, DIRECT, fafdf38b16bf6b2b -lijit.com, 237754, DIRECT, fafdf38b16bf6b2b -lijit.com, 506352, DIRECT, fafdf38b16bf6b2b -teads.tv, 23348, DIRECT, 15a9c44f6d26cbe1 -triplelift.com, 6059, RESELLER, 6c33edb13117fd86 -video.unrulymedia.com, 985572675, DIRECT -video.unrulymedia.com, 985572675, RESELLER -sharethrough.com, 6qlnf8SY, RESELLER, d53b998a7bd4ecd2 -appnexus.com, 12986, RESELLER, f5ab79cb980f11d1 -improvedigital.com, 1069, RESELLER -pubmatic.com, 158056, RESELLER -Weborama.nl, 10714, DIRECT -adwmg.com, 101261, DIRECT, c9688a22012618e7 -google.com, pub-8622186303703569, DIRECT, f08c47fec0942fa0 -freewheel.tv, 1604590, DIRECT -freewheel.tv, 1604595, DIRECT -pubmatic.com, 156512, DIRECT -indexexchange.com, 183753, DIRECT -wunderkind.co, 6438, DIRECT -wunderkind.co, 6449, DIRECT -criteo.com, B-068503, DIRECT -appnexus.com, 806, DIRECT, f5ab79cb980f11d1 -appnexus.com,1908,RESELLER,f5ab79cb980f11d1 -adinplay.com, FTB, DIRECT -venatus.com, 67f90df66f43edab7e84d165, DIRECT - -################################## -# AdinPlay.com ads.txt - 2025-04-16 -################################## - -adinplay.com, OFI, DIRECT - -#Google -google.com, pub-3282547114800347, RESELLER, f08c47fec0942fa0 - -#Appnexus -appnexus.com, 8631, RESELLER, f5ab79cb980f11d1 - -#Index -indexexchange.com, 186547, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 187218, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 177754, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 196862, RESELLER, 50b1c356f2c5c8fc -indexexchange.com, 207014, RESELLER, 50b1c356f2c5c8fc - - -#Pulsepoint -contextweb.com, 561767, RESELLER, 89ff185a4c4e857c - -#Pubmatic -pubmatic.com, 156975, RESELLER, 5d62403b186f2ace -pubmatic.com, 156857, RESELLER, 5d62403b186f2ace -pubmatic.com, 162231, RESELLER, 5d62403b186f2ace - -#OpenX -openx.com, 540164985, RESELLER, 6a698e2ec38604c6 -openx.com, 540010967, RESELLER, 6a698e2ec38604c6 -openx.com, 540182293, RESELLER, 6a698e2ec38604c6 -openx.com, 556894440, RESELLER, 6a698e2ec38604c6 - - -#Sovrn -sovrn.com, 268781, RESELLER, fafdf38b16bf6b2b -lijit.com, 268781, RESELLER, fafdf38b16bf6b2b -lijit.com, 268781-eb, DIRECT, fafdf38b16bf6b2b -appnexus.com, 1360, RESELLER, f5ab79cb980f11d1 -openx.com, 538959099, RESELLER, 6a698e2ec38604c6 -openx.com, 539924617, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 137711, RESELLER, 5d62403b186f2ace -pubmatic.com, 156212, RESELLER, 5d62403b186f2ace -rubiconproject.com, 17960, RESELLER, 0bfd66d529a55807 -sovrn.com, 264160, RESELLER, fafdf38b16bf6b2b -lijit.com, 264160, RESELLER, fafdf38b16bf6b2b -lijit.com, 264160-eb, DIRECT, fafdf38b16bf6b2b -smartadserver.com, 4125, RESELLER -sharethrough.com,7144eb80,RESELLER - - -#Oath -coxmt.com, 2000067907202, RESELLER -pubmatic.com, 156377, RESELLER, 5d62403b186f2ace #banner -pubmatic.com, 156078, RESELLER, 5d62403b186f2ace #banner -pubmatic.com, 155967, RESELLER, 5d62403b186f2ace #banner -openx.com, 537143344, RESELLER, 6a698e2ec38604c6 -indexexchange.com, 175407, RESELLER, 50b1c356f2c5c8fc - -#Rhythmone -rhythmone.com, 1432377581,DIRECT, a670c89d4a324e47 -rhythmone.com, 665259327, DIRECT, a670c89d4a324e47 -rhythmone.com, 2451244104, RESELLER, a670c89d4a324e47 -video.unrulymedia.com, 2451244104, RESELLER -video.unrulymedia.com, 1432377581, DIRECT - - -#Gumgum -aolcloud.net,9904,RESELLER -appnexus.com,1001,DIRECT,f5ab79cb980f11d1 -appnexus.com,2758,RESELLER,f5ab79cb980f11d1 -appnexus.com,3135,DIRECT,f5ab79cb980f11d1 -bidtellect.com,1407,RESELLER,1c34aa2d85d45e93 -contextweb.com,558355,RESELLER,89ff185a4c4e857c -openx.com,537120563,DIRECT,6a698e2ec38604c6 -openx.com,537149485,RESELLER,6a698e2ec38604c6 -google.com,pub-9557089510405422,DIRECT,f08c47fec0942fa0 -google.com,pub-3848273848634341,RESELLER,f08c47fec0942fa0 -google.com, pub-7861278482560604, RESELLER, f08c47fec0942fa0 -rhythmone.com,78519861,RESELLER, a670c89d4a324e47 -outbrain.com,01a755b08c8c22b15d46a8b753ab6955d4,RESELLER -appnexus.com,7597,RESELLER,f5ab79cb980f11d1 -openx.com,540003333,RESELLER,6a698e2ec38604c6 -33across.com,0013300001r0t9mAAA,RESELLER - - -#Amazon -aps.amazon.com,53b902f9-cf9c-4605-aec3-2c8ce65042b8,DIRECT -gumgum.com,13543,DIRECT,ffdef49475d318a9 -appnexus.com,8631,DIRECT,f5ab79cb980f11d1 -indexexchange.com,196862,DIRECT,50b1c356f2c5c8fc -pubmatic.com,160006,RESELLER,5d62403b186f2ace -pubmatic.com,160096,RESELLER,5d62403b186f2ace -rubiconproject.com,18020,RESELLER,0bfd66d529a55807 -pubmatic.com,162231,DIRECT,5d62403b186f2ace -appnexus.com,1908,RESELLER,f5ab79cb980f11d1 -smaato.com,1100044650,RESELLER,07bcf65f187117b4 -ad-generation.jp,12474,RESELLER,7f4ea9029ac04e53 -districtm.io,100962,RESELLER,3fd707be9c4527c3 -yieldmo.com,2719019867620450718,RESELLER -appnexus.com,3663,RESELLER,f5ab79cb980f11d1 -rhythmone.com,1654642120,RESELLER,a670c89d4a324e47 -yahoo.com,55029,RESELLER,e1a5b5b6e3255540 -gumgum.com,14141,RESELLER,ffdef49475d318a9 -admanmedia.com,726,RESELLER -emxdgt.com,2009,RESELLER,1e1d41537f7cad7f -appnexus.com,1356,RESELLER,f5ab79cb980f11d1 -contextweb.com,562541,RESELLER,89ff185a4c4e857c -themediagrid.com,JTQKMP,RESELLER,35d5010d7789b49d -sovrn.com,375328,RESELLER,fafdf38b16bf6b2b -lijit.com,375328,RESELLER,fafdf38b16bf6b2b -beachfront.com,14804,RESELLER,e2541279e8e2ca4d -improvedigital.com,2050,RESELLER -mintegral.com,10043,RESELLER,0aeed750c80d6423 -sonobi.com,7f5fa520f8,RESELLER,d1a215d9eb5aee9e -openx.com,556894440,DIRECT,6a698e2ec38604c6 -onetag.com,7683ebe7bee7969,DIRECT -media.net,8CUZ1MK22,RESELLER -sharethrough.com,buaxQzOE,DIRECT,d53b998a7bd4ecd2 -smartadserver.com,4571,DIRECT,060d053dcf45cbf3 -mediago.io,045ac24b888bcf59a09731e7f0f2084f,RESELLER -adyoulike.com,7463c359225e043c111036d7a29affa5,RESELLER -minutemedia.com,01gya4708ddm,RESELLER -visiblemeasures.com,1052,RESELLER -undertone.com,4205,RESELLER,d954590d0cb265b9 -admedia.com,AM1601,RESELLER,ae6c32151e71f19d -triplelift.com,8472,DIRECT,6c33edb13117fd86 -kargo.com,8824,RESELLER -start.io,123111883,RESELLER -connectad.io,455,RESELLER,85ac85a30c93b3e5 - -# 33Across -rubiconproject.com, 16414, RESELLER, 0bfd66d529a55807 #33Across #hb #tag -rubiconproject.com, 21642, RESELLER, 0bfd66d529a55807 #33Across #hb #tag #viewable -rubiconproject.com, 21434, RESELLER, 0bfd66d529a55807 #33Across #tag #ebda -rubiconproject.com, 21720, RESELLER, 0bfd66d529a55807 #33Across EU #hb #tag -pubmatic.com, 156423, RESELLER, 5d62403b186f2ace #33Across #hb #tag -pubmatic.com, 158136, RESELLER, 5d62403b186f2ace #33Across EU #hb #tag -pubmatic.com, 158569, RESELLER, 5d62403b186f2ace #33Across #tag #ebda -appnexus.com, 10239, RESELLER, f5ab79cb980f11d1 #33Across #hb #tag #viewable -appnexus.com, 1001, RESELLER, f5ab79cb980f11d1 #33Across #tag -appnexus.com, 3135, RESELLER, f5ab79cb980f11d1 #33Across #tag -openx.com, 537120563, RESELLER, 6a698e2ec38604c6 #33Across #hb #tag -openx.com, 539392223, RESELLER, 6a698e2ec38604c6 #33Across #tag #ebda -openx.com, 540995201, RESELLER, 6a698e2ec38604c6 #33Across #hb #tag #viewable -adtech.com, 12094, RESELLER #33Across #hb #tag -adtech.com, 9993, RESELLER #33Across #tag -aol.com, 47594, RESELLER, e1a5b5b6e3255540 #33Across #hb #tag #viewable -yahoo.com, 55188, DIRECT, e1a5b5b6e3255540 #33Across #tag #ebda -advangelists.com, 8d3bba7425e7c98c50f52ca1b52d3735, RESELLER, 60d26397ec060f98 #33Across #hb #tag -sonobi.com, a416546bb7, RESELLER, d1a215d9eb5aee9e #33Across #tag #ebda -indexexchange.com, 190966, RESELLER, 50b1c356f2c5c8fc #33Across #tag #ebda -indexexchange.com, 183635, RESELLER, 50b1c356f2c5c8fc #33Across #hb #tag #viewable -google.com, pub-9557089510405422, RESELLER, f08c47fec0942fa0 #33Across #tag - -#Rubiconproject -rubiconproject.com, 15636, RESELLER, 0bfd66d529a55807 - -#LockerDome -lockerdome.com, 11908041977355520, DIRECT - -#Yield Nexus -yieldnexus.com, 1, DIRECT -ssp.ynxs.io, 185, DIRECT -appnexus.com, 10617, RESELLER, f5ab79cb980f11d1 -appnexus.com, 9393, RESELLER, f5ab79cb980f11d1 -advertising.com, 25034, RESELLER -sonobi.com, 783272317b, RESELLER, d1a215d9eb5aee9e -indexexchange.com, 186684,RESELLER, 50b1c356f2c5c8fc - -#CPM -appnexus.com, 9624, RESELLER, f5ab79cb980f11d1 -adtech.com, 11506, RESELLER -yahoo.com, 56896, RESELLER -pubmatic.com, 156078, RESELLER, 5d62403b186f2ace -advertising.com, 25218, RESELLER #video, US -beachfront.com, 9065, RESELLER -contextweb.com, 559969, RESELLER, 89ff185a4c4e857c -indexexchange.com, 189455, RESELLER, 50b1c356f2c5c8fc -advertising.com, 28320, RESELLER -richaudience.com, NtMZGaQQTT, RESELLER -adform.com, 1942, RESELLER -adform.com, 1941, RESELLER -adtech.com, 4687, RESELLER -aerserv.com, 2750, RESELLER, 2ce496b9f80eb9fa -aol.com, 27093, RESELLER -aol.com, 46658, RESELLER -aolcloud.net, 4687, RESELLER -appnexus.com, 2928, RESELLER, f5ab79cb980f11d1 -contextweb.com, 560520, RESELLER, 89ff185a4c4e857c -google.com, pub-9115524111147081, RESELLER, f08c47fec0942fa0 -google.com, pub-4673227357197067, RESELLER, f08c47fec0942fa0 -indexexchange.com, 179394, RESELLER, 50b1c356f2c5c8fc -lijit.com, 249425, RESELLER, fafdf38b16bf6b2b -cpmstar.com, 49818, RESELLER -mobfox.com, 74240, RESELLER -mobfox.com, 45499, RESELLER -openx.com, 539625136, RESELLER, 6a698e2ec38604c6 -smaato.com, 1100037086, RESELLER -smaato.com, 1100000579, RESELLER -sovrn.com, 249425, RESELLER, fafdf38b16bf6b2b -openx.com, 541079309, RESELLER, 6a698e2ec38604c6 -openx.com, 541166421, RESELLER, 6a698e2ec38604c6 -contextweb.com, 562263, RESELLER, 89ff185a4c4e857c -districtm.io, 102015, RESELLER, 3fd707be9c4527c3 -lkqd.net, 304, RESELLER, 59c49fa9598a0117 -lkqd.com, 304, RESELLER, 59c49fa9598a0117 -advertising.com, 2694, RESELLER -google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0 -appnexus.com, 806, RESELLER, f5ab79cb980f11d1 -freewheel.tv, 211121, RESELLER -freewheel.tv, 211129, RESELLER -indexexchange.com, 183921, RESELLER, 50b1c356f2c5c8fc -openx.com, 540134228, RESELLER, 6a698e2ec38604c6 -openx.com, 540634629, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 156715, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807 -smartadserver.com, 3490, RESELLER -springserve.com, 550, RESELLER, a24eb641fc82e93d -beachfront.com, 4969, RESELLER, e2541279e8e2ca4d -advertising.com, 26282, RESELLER -pubmatic.com, 157310, RESELLER, 5d62403b186f2ace -rhythmone.com, 2968119028, RESELLER, a670c89d4a324e47 -contextweb.com, 561910, RESELLER, 89ff185a4c4e857c -openx.com, 540226160, RESELLER, 6a698e2ec38604c6 -openx.com, 540255318, RESELLER, 6a698e2ec38604c6 -ssp.ynxs.io, 185, RESELLER -tremorhub.com, hpwve, RESELLER, 1a4e959a1b50034a -telaria.com, hpwve, RESELLER, 1a4e959a1b50034a -video.unrulymedia.com, UNRX-PUB-29dad46b-9bec-43c7-b950-c59d09cc8c71, RESELLER -video.unrulymedia.com, 985572675, RESELLER -rhythmone.com, 2864567592, RESELLER, a670c89d4a324e47 -vidoomy.com, 51019, RESELLER -aol.com, 22762, RESELLER -freewheel.tv, 872257, RESELLER -openx.com, 540804929, RESELLER, 6a698e2ec38604c6 -emxdgt.com, 1495, RESELLER, 1e1d41537f7cad7f - -#Rubicon -rubiconproject.com, 23042, RESELLER, 0bfd66d529a55807 -rubiconproject.com, 23044, RESELLER, 0bfd66d529a55807 - - -#AMX - -amxrtb.com, 105199469, RESELLER -appnexus.com, 12290, RESELLER, f5ab79cb980f11d1 -appnexus.com, 11786, RESELLER, f5ab79cb980f11d1 -indexexchange.com, 191503, RESELLER, 50b1c356f2c5c8fc -lijit.com, 260380, RESELLER, fafdf38b16bf6b2b -sovrn.com, 260380, RESELLER, fafdf38b16bf6b2b -pubmatic.com, 158355, RESELLER, 5d62403b186f2ace -appnexus.com, 9393, RESELLER, f5ab79cb980f11d1 #Video #Display -appnexus.com, 11924, RESELLER, f5ab79cb980f11d1 - -#Kueez -kueez.com, fe46d13305ce1b89f18a84c52275b7fe, DIRECT -appnexus.com, 8826, RESELLER -rubiconproject.com, 16920, RESELLER -openx.com, 557564833, RESELLER -lijit.com, 407406, RESELLER -media.net, 8cu4jtrf9, RESELLER -pubmatic.com, 162110, RESELLER -sharethrough.com, n98xdzel, RESELLER -33across.com, 0010b00002odu4haax, RESELLER -yieldmo.com, 3133660606033240149, RESELLER -onetag.com, 6e053d779444c00, RESELLER -video.unrulymedia.com, 3486482593, RESELLER -sonobi.com, 4c4fba1717, RESELLER -smartadserver.com, 4288, RESELLER -zetaglobal.com, 108, RESELLER -improvedigital.com, 2106, RESELLER -loopme.com, 11576, RESELLER -themediagrid.com, uot45z, RESELLER - - -#Aniview - -aniview.com, 606c5af8b82e996ca965f498, RESELLER, 78b21b97965ec3f8 -advertising.com, 23089, RESELLER -appnexus.com, 12637, RESELLER, f5ab79cb980f11d1 -appnexus.com, 9382, RESELLER, f5ab79cb980f11d1 -synacor.com, 82171, RESELLER, e108f11b2cdf7d5b -pubmatic.com, 156344, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13344, RESELLER, 0bfd66d529a55807 -indexexchange.com, 191740, RESELLER, 50b1c356f2c5c8fc -conversantmedia.com, 100195, DIRECT, 03113cd04947736d -appnexus.com, 4052, RESELLER, f5ab79cb980f11d1 -contextweb.com, 561998, RESELLER, 89ff185a4c4e857c -pubmatic.com, 158100, RESELLER, 5d62403b186f2ace -yahoo.com, 55771, RESELLER, e1a5b5b6e3255540 -onetag.com, 57e618150c70d90, DIRECT -google.com, pub-3769010358500643, RESELLER, f08c47fec0942fa0 -video.unrulymedia.com, 3350674472, DIRECT -rhythmone.com, 3350674472, DIRECT, a670c89d4a324e47 -google.com, pub-4586415728471297, RESELLER, f08c47fec0942fa0 -google.com, pub-3565385483761681, DIRECT, f08c47fec0942fa0 -google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0 -smartadserver.com, 2786, DIRECT -improvedigital.com, 1147, DIRECT -google.com, pub-2930805104418204, RESELLER, f08c47fec0942fa0 -google.com, pub-4903453974745530, RESELLER, f08c47fec0942fa0 -richaudience.com, 1ru8dKmJJV, DIRECT -advertising.com, 7574, RESELLER -appnexus.com, 8233, RESELLER, f5ab79cb980f11d1 -pubmatic.com, 81564, RESELLER, 5d62403b186f2ace -pubmatic.com, 156538, RESELLER, 5d62403b186f2ace -rubiconproject.com, 13510, RESELLER, 0bfd66d529a55807 -smartadserver.com, 2640, RESELLER -smartadserver.com, 2441, RESELLER -yahoo.com, 57857, RESELLER, e1a5b5b6e3255540 -undertone.com, 4077, DIRECT -appnexus.com, 2234, RESELLER, f5ab79cb980f11d1 -rubiconproject.com, 22412, RESELLER, 0bfd66d529a55807 -advertising.com, 28650, RESELLER -pubmatic.com, 160318, RESELLER, 5d62403b186f2ace -pubmatic.com, 160319, RESELLER, 5d62403b186f2ace -appnexus.com, 10112, RESELLER, f5ab79cb980f11d1 -google.com, pub-0679975395820445, RESELLER, f08c47fec0942fa0 -google.com, pub-9936969251765866, RESELLER, f08c47fec0942fa0 - -#Fluct -adingo.jp, 25262, RESELLER -pubmatic.com, 156313, RESELLER, 5d62403b186f2ace -appnexus.com, 7044, RESELLER, f5ab79cb980f11d1 -pubmatic.com, 158060, RESELLER, 5d62403b186f2ace - -#Conversant -conversantmedia.com, 100106, RESELLER, 03113cd04947736d -lijit.com, 411121, RESELLER, fafdf38b16bf6b2b #SOVRN -admanmedia.com, 2050, RESELLER -Appnerve.com, 187287, RESELLER -rubiconproject.com, 23644, RESELLER, 0bfd66d529a55807 - - -#OneTag -onetag.com, 7683ebe7bee7969, RESELLER -onetag.com, 7683ebe7bee7969-OB, RESELLER -appnexus.com, 13099, RESELLER, f5ab79cb980f11d1 -yahoo.com, 58905, RESELLER, e1a5b5b6e3255540 -rubiconproject.com, 11006, RESELLER, 0bfd66d529a55807 -smartadserver.com, 4111, RESELLER - -#Media.net -media.net, 8CUEHU9Y5, RESELLER -openx.com, 537100188, RESELLER, 6a698e2ec38604c6 -pubmatic.com, 159463, RESELLER, 5d62403b186f2ace -emxdgt.com, 1759, RESELLER, 1e1d41537f7cad7f -google.com, pub-7439041255533808, RESELLER, f08c47fec0942fa0 -rubiconproject.com, 19396, RESELLER, 0bfd66d529a55807 -onetag.com, 5d49f482552c9b6, RESELLER -sonobi.com, 83729e979b, RESELLER -33across.com, 0010b00002cGp2AAAS, RESELLER, bbea06d9c4d2853c -rhythmone.com, 3611299104, RESELLER, a670c89d4a324e47 -districtm.io, 100600, RESELLER -lemmatechnologies.com, 399, RESELLER, 7829010c5bebd1fb #LEMMA -e-planning.net,ec771b05828a67fa,RESELLER,c1ba615865ed87b2 -google.com, pub-9685734445476814, RESELLER, f08c47fec0942fa0 - -#EMX Digital -emxdgt.com, 2345, RESELLER, 1e1d41537f7cad7f - - -#The MediaGrid -themediagrid.com, B8ZEVT, RESELLER, 35d5010d7789b49d -themediagrid.com, 3W8S2K, RESELLER, 35d5010d7789b49d - -#triplelift -triplelift.com, 12900, RESELLER, 6c33edb13117fd86 -triplelift.com, 12900-EB, DIRECT, 6c33edb13117fd86 -triplelift.com, 13897, DIRECT, 6c33edb13117fd86 - -#Sharethrough - -sharethrough.com, buaxQzOE, RESELLER, d53b998a7bd4ecd2 -sharethrough.com, jvyAFD6e, DIRECT, d53b998a7bd4ecd2 -pubmatic.com, 156557, RESELLER, 5d62403b186f2ace -rubiconproject.com, 18694, RESELLER, 0bfd66d529a55807 -openx.com, 540274407, RESELLER, 6a698e2ec38604c6 -33across.com, 0013300001kQj2HAAS, RESELLER, bbea06d9c4d2853c -smaato.com, 1100047713, RESELLER, 07bcf65f187117b4 -yahoo.com, 59531, RESELLER, e1a5b5b6e3255540 -smartadserver.com, 4342, RESELLER -smartadserver.com, 4012, RESELLER - - -#V 15.01.2024 PH - -#------------------------------------------------------------------------------------------------------ -adagio.io, 1090, DIRECT # Adagio_0_6 -rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807 # Adagio_0_6 -pubmatic.com, 159110, RESELLER, 5d62403b186f2ace # Adagio_0_6 -improvedigital.com, 1790, RESELLER # Adagio_0_6 -indexexchange.com, 194558, RESELLER # Adagio_0_6 -richaudience.com, 1BTOoaD22a, DIRECT # Adagio_0_6 -33across.com, 0015a00002oUk4aAAC, DIRECT, bbea06d9c4d2853c # Adagio_0_6 -appnexus.com, 10239, RESELLER, f5ab79cb980f11d1 # Adagio_0_6 -rubiconproject.com, 16414, RESELLER, 0bfd66d529a55807 # Adagio_0_6 -lijit.com, 367236, RESELLER, fafdf38b16bf6b2b # Adagio_0_6 -e-planning.net, 83c06e81531537f4, RESELLER, c1ba615865ed87b2 # Adagio_0_6 -amxrtb.com, 105199358, DIRECT # AdaptMX_1_6&7 -indexexchange.com, 191503, RESELLER # AdaptMX_1_6&7 -appnexus.com, 11786, RESELLER # AdaptMX_1_6&7 -appnexus.com, 12290, RESELLER # AdaptMX_1_6&7 -pubmatic.com, 158355, RESELLER, 5d62403b186f2ace # AdaptMX_1_6&7 -advertising.com, 28305, RESELLER # AdaptMX_1_6&7 -rubiconproject.com, 23844, RESELLER, 0bfd66d529a55807 # AdaptMX_1_6&7 -openx.com, 559680764, RESELLER, 6a698e2ec38604c6 # AdaptMX_1_6&7 -adform.com, 2767, RESELLER # Adform_0_6&7 -adyoulike.com, c1314a52de718f3c214c00173d2994f9, DIRECT # AdYouLike_0_6 -pubmatic.com, 160925, RESELLER, 5d62403b186f2ace # AdYouLike_0_6 -rubiconproject.com, 20736, RESELLER, 0bfd66d529a55807 # AdYouLike_0_6 -appnexus.com, 7664, RESELLER # AdYouLike_0_6 -aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT # Amazon_3_6&7 -ad-generation.jp,12474,RESELLER # Amazon_3_6&7 -aniview.com, 5f2063121d82c82557194737, RESELLER, 78b21b97965ec3f8 # Aniview -aniview.com, 643f8e74688b10f72307cc24, DIRECT, 78b21b97965ec3f8 # Aniview -google.com, pub-6346866704322274, RESELLER, f08c47fec0942fa0 # Aniview -pubmatic.com, 160993, RESELLER, 5d62403b186f2ace # Aniview -rubiconproject.com, 13918, RESELLER, 0bfd66d529a55807 # Aniview -google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0 # Aniview -gannett.com, 22652678936, RESELLER # Aniview -richaudience.com, 1ru8dKmJJV, RESELLER # Aniview -appnexus.com, 12637, RESELLER, f5ab79cb980f11d1 # Aniview -google.com, pub-3565385483761681, RESELLER, f08c47fec0942fa0 # Aniview -sharethrough.com, zLsEa05k, RESELLER, d53b998a7bd4ecd2 # Aniview -aps.amazon.com, 1ad7261b-91ea-4b6f-b9e9-b83522205b75, RESELLER # Aniview -pubmatic.com, 161335, RESELLER, 5d62403b186f2ace # Aniview -google.com, pub-7734005103835923, RESELLER, f08c47fec0942fa0 # Aniview -openx.com, 559611024, RESELLER, 6a698e2ec38604c6 # Aniview -yieldlab.net, 495507, DIRECT # Aniview -blockthrough.com, 5643766199222272, DIRECT # Blockthrough -appnexus.com, 6979, RESELLER # Blockthrough -indexexchange.com, 194341, RESELLER, 50b1c356f2c5c8fc # Blockthrough -pubmatic.com, 160377, RESELLER, 5d62403b186f2ace # Blockthrough -rubiconproject.com, 23718, RESELLER, 0bfd66d529a55807 # Blockthrough -onetag.com, 75804861b76a852, DIRECT # Blockthrough -amxrtb.com, 105199664, DIRECT # Blockthrough -criteo.com, B-062405, DIRECT, 9fac4a4a87c2a44f # Criteo_0_6&7 -themediagrid.com, CVQXOH, DIRECT, 35d5010d7789b49d # Criteo_0_6&7 -cpmstar.com, 53615, DIRECT # CPMSTAR -rhythmone.com,1838093862,DIRECT,a670c89d4a324e47 # CPMSTAR -video.unrulymedia.com, 1838093862, DIRECT # CPMSTAR -pubmatic.com, 160251, DIRECT, 5d62403b186f2ace # CPMSTAR -pubmatic.com, 161595, DIRECT, 5d62403b186f2ace # CPMSTAR -rubiconproject.com, 23330, DIRECT, 0bfd66d529a55807 # CPMSTAR -conversantmedia.com, 41150, DIRECT, 03113cd04947736d # Epsilon -adingo.jp, 24379, DIRECT # Fluct_1_6&7 -freewheel.tv, 211121, DIRECT # Freewheel_0_7 -freewheel.tv, 211129, RESELLER # Freewheel_0_7 -google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0 # Google_AdX_6&7 -google.com, pub-2553634189837243, RESELLER, f08c47fec0942fa0 # Google_AdX_6&7 -gumgum.com, 13385, RESELLER, ffdef49475d318a9 # GumGum_JP_0_9_6 -gumgum.com, 14302, RESELLER, ffdef49475d318a9 # GumGum_JP_0_9_6 -improvedigital.com, 1012, DIRECT # Improve_0_6&7 -improvedigital.com, 1640, RESELLER # Improve_1_6 -improvedigital.com, 2114, RESELLER # Improve_kids_1_6&7 -indexexchange.com, 183921, DIRECT, 50b1c356f2c5c8fc # Index Exchange_0_6&7 -indexexchange.com, 188416, DIRECT, 50b1c356f2c5c8fc # Index Exchange_1_6&7 -indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc # Index Exchange_2_6&7 -indexexchange.com, 194127, DIRECT, 50b1c356f2c5c8fc # Index Exchange_7&4_6&7 -indexexchange.com, 205972, RESELLER, 50b1c356f2c5c8fc # Index Exchange_Oz -indexexchange.com, 206870, RESELLER, 50b1c356f2c5c8fc # Index_EasyConnect -iion.io, 10133, DIRECT # iion -kargo.com, 8688, DIRECT # Kargo_0_6 -rubiconproject.com, 17902, RESELLER, 0bfd66d529a55807 # Magnite_1_6&7 -rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807 # Magnite_0&2_6&7 -telaria.com,hpwve,RESELLER,1a4e959a1b50034a # Magnite_Streaming -tremorhub.com,hpwve,RESELLER,1a4e959a1b50034a # Magnite_Streaming -media.net, 8CU8ARTF8, DIRECT # Media.net -Media.net, 8CU5786QK, DIRECT # Media.net -themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d # MediaGrid_2_6&7 -minutemedia.com, 01gerz6y43ck, RESELLER # MinuteMedia_0_6 -pubmatic.com, 161683, RESELLER, 5d62403b186f2ace # MinuteMedia_0_6 -appnexus.com, 8381, RESELLER # MinuteMedia_0_6 -triplelift.com, 6030, RESELLER, 6c33edb13117fd86 # MinuteMedia_0_6 -33across.com, 0013300001jlr99AAA, RESELLER, bbea06d9c4d2853c # MinuteMedia_0_6 -nobid.io, 22629800915, DIRECT # Nobid_0_6 -sonobi.com, 7ad1b9f952, RESELLER, d1a215d9eb5aee9e # Nobid_0_6 -xandr.com, 12701, RESELLER, f5ab79cb980f11d1 # Nobid_0_6 -lijit.com, 273657, DIRECT, fafdf38b16bf6b2b # Nobid_0_6 -onetag.com, 694e68b73971b58, DIRECT # Nobid_0_6 -yahoo.com, 57872, RESELLER # Nobid_0_6 -sharethrough.com, UvcAx8IL, DIRECT, d53b998a7bd4ecd2 # Nobid_0_6 -ogury.com, 086233d2-e8a8-44fc-907b-f0752e1c85de, DIRECT # Ogury_0_6 -appnexus.com, 11470, RESELLER # Ogury_0_6 -openx.com, 537144009, RESELLER, 6a698e2ec38604c6 # OpenX_0_6 -openx.com, 540134228, RESELLER, 6a698e2ec38604c6 # OpenX_0_7 -openx.com, 540368327, RESELLER, 6a698e2ec38604c6 # OpenX_1_6&7 -openx.com, 542378302, RESELLER, 6a698e2ec38604c6 # OpenX_2_6&7 -the-ozone-project.com, ozoneven0005, DIRECT # Ozone_0_6 -appnexus.com, 9979, RESELLER # Ozone_0_6 -openx.com, 540731760, RESELLER, 6a698e2ec38604c6 # Ozone_0_6 -adform.com, 2657, RESELLER, 9f5210a2f0999e32 # Ozone_0_6 -pubmatic.com, 160557, RESELLER, 5d62403b186f2ace # Ozone_0_6 -themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d # Ozone_0_6 -pgamssp.com, 634dc90283fff00f005151f2, DIRECT # PGAM_0_7 -freewheel.tv, 1489202, RESELLER # PGAM_0_7 -freewheel.tv, 1488706, RESELLER # PGAM_0_7 -video.unrulymedia.com, 5921144960123684292, RESELLER # PGAM_0_7 -appnexus.com, 9291, RESELLER # PGAM_0_7 -pubmatic.com, 162623, RESELLER, 5d62403b186f2ace # PGAM_0_7 -primis.tech, 31136, DIRECT, b6b21d256ef43532 # Primis -pubmatic.com, 156595, RESELLER, 5d62403b186f2ace # Primis -google.com, pub-1320774679920841, RESELLER, f08c47fec0942fa0 # Primis -openx.com, 540258065, RESELLER, 6a698e2ec38604c6 # Primis -rubiconproject.com, 20130, RESELLER, 0bfd66d529a55807 # Primis -freewheel.tv, 19133, RESELLER, 74e8e47458f74754 # Primis -smartadserver.com, 3436, RESELLER, 060d053dcf45cbf3 # Primis -indexexchange.com, 191923, RESELLER, 50b1c356f2c5c8fc # Primis -adform.com, 2078, RESELLER # Primis -Media.net, 8CU695QH7, RESELLER # Primis -video.unrulymedia.com, 2338962694, RESELLER # Primis -sharethrough.com, flUyJowI, RESELLER, d53b998a7bd4ecd2 # Primis -triplelift.com, 8210, RESELLER, 6c33edb13117fd86 # Primis -yahoo.com, 59260, RESELLER # Primis -pubmatic.com, 159234, RESELLER, 5d62403b186f2ace # PubMatic_0_6&7 -pubmatic.com, 158940, RESELLER, 5d62403b186f2ace # PubMatic_1_6&7 -pubmatic.com, 160552, RESELLER, 5d62403b186f2ace # PubMatic_4_7 -pubmatic.com, 159401, RESELLER, 5d62403b186f2ace # PubMatic_2_6&7 -pubmatic.com, 163598, RESELLER, 5d62403b186f2ace # Pubmatic_OW -richaudience.com, 1XvIoD5o0S, DIRECT # Rich Audience_0_6&7 -risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT # Rise -pubmatic.com, 160295, RESELLER, 5d62403b186f2ace # Rise -xandr.com, 14082, RESELLER # Rise -rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807 # Rise -media.net, 8CUQ6928Q, RESELLER # Rise_Temp -sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2 # Rise_Temp -sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2 # Sharethrough_0_6&7 -sharethrough.com, Ip2TfKpa, DIRECT, d53b998a7bd4ecd2 # Sharethrough_1_6&7 -smartadserver.com, 2161, RESELLER # Showheroes_7_8 -appnexus.com, 8833, RESELLER, f5ab79cb980f11d1 # Showheroes_7_8 -smartadserver.com, 3668, RESELLER # Showheroes_7_8 -freewheel.tv, 1003361, DIRECT # Showheroes_7_8 -pubmatic.com, 156695, DIRECT, 5d62403b186f2ace # Showheroes_7_8 -showheroes.com, 6829, RESELLER # Showheroes_7_8 -smartadserver.com, 3490, DIRECT # Smart AdServer_0&1&2_6&7 -smartadserver.com, 3490-OB, DIRECT, 060d053dcf45cbf3 # Smart AdServer_0&1&2_6&7 -smartadserver.com, 4016, DIRECT # Smart AdServer_0&1&2_6&7 -smartadserver.com, 4074, DIRECT # Smart AdServer_0&1&2_6&7 -smaato.com, 1100055690, DIRECT, 07bcf65f187117b4 # Smaato -smaato.com, 1100004890, DIRECT, 07bcf65f187117b4 # Smaato -sonobi.com, 116da9d98c, DIRECT, d1a215d9eb5aee9e # Sonobi_0_6&7 -sonobi.com, e017850301, DIRECT, d1a215d9eb5aee9e # Sonobi_4_7 -sovrn.com, 237754, DIRECT, fafdf38b16bf6b2b # Sovrn_0&1&2_6&7 -lijit.com, 237754, DIRECT, fafdf38b16bf6b2b # Sovrn_0&1&2_6&7 -lijit.com, 237754-eb, DIRECT, fafdf38b16bf6b2b # Sovrn_1_6&7 -taboola.com,1422403,DIRECT,c228e6794e811952 # Taboola_6_8 -triplelift.com, 6059, DIRECT, 6c33edb13117fd86 # Triplelift_0&2_6&7 -triplelift.com, 6059-EB, DIRECT, 6c33edb13117fd86 # Triplelift_0&2_6&7 -video.unrulymedia.com, 985572675, DIRECT # Unruly_0&2_7 -rhythmone.com, 2864567592, DIRECT, a670c89d4a324e47 # Unruly_0&2_7 -xandr.com, 13799, RESELLER # Unruly -sharethrough.com, 6qlnf8SY, RESELLER, d53b998a7bd4ecd2 # Unruly -vidazoo.com, 655c85dc63ceeb606a0f365f, DIRECT, b6ada874b4d7d0b2 # Vidazoo -pubmatic.com, 159988, RESELLER, 5d62403b186f2ace # Vidazoo -rubiconproject.com, 17130, RESELLER, 0bfd66d529a55807 # Vidazoo -pubmatic.com, 156512, DIRECT # Wunderkind -indexexchange.com, 183753, DIRECT # Wunderkind -wunderkind.co, 6438, DIRECT # Wunderkind -wunderkind.co, 6449, DIRECT # Wunderkind -criteo.com, B-068503, DIRECT # Wunderkind -appnexus.com, 806, DIRECT, f5ab79cb980f11d1 # Xandr_0&2_6&7 -appnexus.com,1908,RESELLER,f5ab79cb980f11d1 # Xandr_0&2_6&7 - - -#Equativ - -smartadserver.com, 4571, RESELLER, 060d053dcf45cbf3 -smartadserver.com, 4571-OB, RESELLER, 060d053dcf45cbf3 -smartadserver.com, 4016, RESELLER, 060d053dcf45cbf3 #Global -smartadserver.com, 4012, RESELLER, 060d053dcf45cbf3 #EUR -smartadserver.com, 4071, RESELLER, 060d053dcf45cbf3 #USD -smartadserver.com, 4073, RESELLER, 060d053dcf45cbf3 #BRL -smartadserver.com, 4074, RESELLER, 060d053dcf45cbf3 #MXN -smartadserver.com, 4247, RESELLER, 060d053dcf45cbf3 #CAD -smartadserver.com, 4228, RESELLER, 060d053dcf45cbf3 #USD_CTV -pubmatic.com, 156439, RESELLER, 5d62403b186f2ace -pubmatic.com, 154037, RESELLER, 5d62403b186f2ace -rubiconproject.com, 16114, RESELLER, 0bfd66d529a55807 -openx.com, 537149888, RESELLER, 6a698e2ec38604c6 -appnexus.com, 3703, RESELLER, f5ab79cb980f11d1 -loopme.com, 5679, RESELLER, 6c8d5f95897a5a3b -xad.com, 958, RESELLER, 81cbf0a75a5e0e9a -video.unrulymedia.com, 2564526802, RESELLER -smaato.com, 1100044045, RESELLER, 07bcf65f187117b4 -pubnative.net, 1006576, RESELLER, d641df8625486a7b -verve.com, 15503, RESELLER, 0c8f5958fc2d6270 -adyoulike.com, b4bf4fdd9b0b915f746f6747ff432bde, RESELLER, 4ad745ead2958bf7 -axonix.com, 57264, RESELLER -admanmedia.com, 43, RESELLER -sharethrough.com, OAW69Fon, RESELLER, d53b998a7bd4ecd2 -contextweb.com, 560288, RESELLER, 89ff185a4c4e857c - -#nobid - -nobid.io, 22931676975, DIRECT -xandr.com, 11429, RESELLER, f5ab79cb980f11d1 -sharethrough.com, aRE1degH, RESELLER, d53b998a7bd4ecd2 -sonobi.com, 7ad1b9f952, RESELLER, d1a215d9eb5aee9e -sharethrough.com, UvcAx8IL, RESELLER, d53b998a7bd4ecd2 -amxrtb.com, 105199579, RESELLER -yahoo.com,49648,RESELLER -rubiconproject.com, 24434, RESELLER, 0bfd66d529a55807 -minutemedia.com, 01gerz67grgj, RESELLER -pubmatic.com, 161683, RESELLER, 5d62403b186f2ace -appnexus.com, 8381, RESELLER, f5ab79cb980f11d1 -triplelift.com, 6030, RESELLER, 6c33edb13117fd86 -sonobi.com, 37fbaf262c, RESELLER, d1a215d9eb5aee9e -openx.com, 540780517, RESELLER, 6a698e2ec38604c6 -rubiconproject.com, 17598, RESELLER, 0bfd66d529a55807 -indexexchange.com, 196326, RESELLER, 50b1c356f2c5c8fc -yahoo.com, 59407, RESELLER, e1a5b5b6e3255540 -sharethrough.com, xz7QjFBY, RESELLER, d53b998a7bd4ecd2 -inmobi.com,8f261ace12c3486ba2e0d2011cd97976,RESELLER,83e75a7ae333ca9d -risecodes.com, 63ea59eef828de0001cf1773, RESELLER -inmobi.com, 9e311c7a68e94888aac7fbb4272381e2, RESELLER, 83e75a7ae333ca9d -video.unrulymedia.com, 1352466146, RESELLER -yahoo.com, 59261, RESELLER, e1a5b5b6e3255540 -gumgum.com, 13926, RESELLER, ffdef49475d318a9 -onetag.com, 694e68b73971b58, RESELLER -lijit.com, 273657, RESELLER, fafdf38b16bf6b2b -sovrn.com, 273657, RESELLER, fafdf38b16bf6b2b -mediafuse.com, 389, RESELLER -appnexus.com, 9538, RESELLER, f5ab79cb980f11d1 -yahoo.com, 57872, RESELLER -video.unrulymedia.com, 2997140015, RESELLER -indexexchange.com, 182257, RESELLER, 50b1c356f2c5c8fc -152media.info,152M374,RESELLER -appnexus.com, 3153, RESELLER, f5ab79cb980f11d1 -#media.net_serverside_displayvideo -media.net, 8CUV34PJ4, DIRECT -sharethrough.com, koRtppYA, RESELLER, d53b998a7bd4ecd2 -video.unrulymedia.com, 699546687, RESELLER -lijit.com, 264726, RESELLER, fafdf38b16bf6b2b -onetag.com, 765b4e6bb9c8438, RESELLER -amxrtb.com, 105199663, RESELLER -yieldmo.com, 2954622693783052507, RESELLER -loopme.com, 11556, RESELLER, 6c8d5f95897a5a3b -Contextweb.com, 562963, RESELLER, 89ff185a4c4e857c -zeta.com, 591, RESELLER -disqus.com, 591, RESELLER -admanmedia.com, 953, RESELLER -smartadserver.com, 4106, RESELLER, 060d053dcf45cbf3 -imds.tv, 82302, RESELLER, ae6c32151e71f19d -improvedigital.com, 2073, RESELLER -betweendigital.com, 44808, RESELLER -adyoulike.com, 53264963677efeda057eef7db2cb305f, RESELLER -freewheel.tv,1577878,RESELLER -freewheel.tv,1577888,RESELLER -dxkulture.com, 9533, DIRECT, 259726033fc4df0c -dxkulture.com, 0098, DIRECT, 259726033fc4df0c -adswizz.com,dxkulture,DIRECT -adswizz.com,651,DIRECT -pubmatic.com,164751,RESELLER,5d62403b186f2ace -rubiconproject.com,26094,DIRECT,0bfd66d529a55807 -zetaglobal.net,790,DIRECT -ssp.disqus.com,790,DIRECT -video.unrulymedia.com,946176315,RESELLER -video.unrulymedia.com, 347774562, RESELLER -rubiconproject.com, 15268, RESELLER, 0bfd66d529a55807 -pubmatic.com, 159277, RESELLER - -#AdaptMX - -amxrtb.com, 105199723, DIRECT -appnexus.com, 12290, RESELLER -pubmatic.com, 161527, RESELLER -rubiconproject.com, 23844, RESELLER - - - -# Adagio -adagio.io, 1361, RESELLER -# Adagio - Magnite -rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807 -# Adagio - Pubmatic -pubmatic.com, 159110, RESELLER, 5d62403b186f2ace -# Adagio - Improve Digital -improvedigital.com, 1790, RESELLER -# Adagio - Onetag -onetag.com, 6b859b96c564fbe, RESELLER -appnexus.com, 13099, RESELLER -pubmatic.com, 161593, RESELLER, 5d62403b186f2ace -# Adagio - Index Exchange -indexexchange.com, 194558, RESELLER -# Adagio - 33Across -33across.com, 0015a00002oUk4aAAC, RESELLER, bbea06d9c4d2853c -yahoo.com, 57289, RESELLER, e1a5b5b6e3255540 -appnexus.com, 10239, RESELLER, f5ab79cb980f11d1 -rubiconproject.com, 16414, RESELLER, 0bfd66d529a55807 -pubmatic.com, 156423, RESELLER, 5d62403b186f2ace -rubiconproject.com, 21642, RESELLER, 0bfd66d529a55807 -conversantmedia.com, 100141, RESELLER -indexexchange.com, 191973, RESELLER, 50b1c356f2c5c8fc -triplelift.com, 12503, RESELLER, 6c33edb13117fd86 -insticator.com, 4ec3ed85-2830-4174-9f7f-f545620598b9, RESELLER -sharethrough.com, Q9IzHdvp, RESELLER, d53b998a7bd4ecd2 -admanmedia.com, 2216, RESELLER -connectad.io, 456, RESELLER, 85ac85a30c93b3e5 -# Adagio - Equativ -smartadserver.com, 3554, RESELLER -# Adagio - Sovrn -lijit.com, 367236, RESELLER, fafdf38b16bf6b2b -# Adagio - Freewheel -freewheel.tv, 1568036, RESELLER -freewheel.tv, 1568041, RESELLER -# Adagio - OpenX -openx.com, 558899373, RESELLER, 6a698e2ec38604c6 -# Adagio - Triplelift -triplelift.com, 13482, RESELLER, 6c33edb13117fd86 -# Adagio - E-Planning -e-planning.net, 83c06e81531537f4, RESELLER, c1ba615865ed87b2 -pubmatic.com, 156631, RESELLER, 5d62403b186f2ace -openx.com, 541031350, RESELLER, 6a698e2ec38604c6 -rubiconproject.com, 12186, RESELLER, 0bfd66d529a55807 -# Adagio - Nexxen -video.unrulymedia.com, 5672421953199218469, RESELLER - -#Freewheel - -freewheel.tv, 1598995, RESELLER -freewheel.tv, 1599004, RESELLER - - -#Pgam - -pgamssp.com, 64661fa49d522e327b0a8b84, DIRECT -freewheel.tv, 1489202, RESELLER -freewheel.tv, 1488706, RESELLER -rubiconproject.com, 24852, RESELLER, 0bfd66d529a55807 -pubmatic.com, 162623, RESELLER, 5d62403b186f2ace -video.unrulymedia.com, 5921144960123684292, RESELLER -appnexus.com, 9291, RESELLER, f5ab79cb980f11d1 - - -#Sonobi - -sonobi.com, 3ee2ca3952, RESELLER, d1a215d9eb5aee9e - - -#Rich Audience - -richaudience.com, kWVs0vbyki, RESELLER -appnexus.com, 2928, DIRECT, f5ab79cb980f11d1 -smartadserver.com, 1999, RESELLER, 060d053dcf45cbf3 - -#Ozone - -the-ozone-project.com, OZONEAIP0001, DIRECT -appnexus.com, 9979, RESELLER, f5ab79cb980f11d1 -openx.com, 540731760, RESELLER, 6a698e2ec38604c6 -adform.com, 2657, RESELLER, 9f5210a2f0999e32 -pubmatic.com, 160557, RESELLER, 5d62403b186f2ace -indexexchange.com, 206233, RESELLER, 50b1c356f2c5c8fc -themediagrid.com, 1J3ZI6, DIRECT, 35d5010d7789b49d -themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d - -# OptiDigital -optidigital.com,p345,RESELLER -pubmatic.com,158939,RESELLER,5d62403b186f2ace -rubiconproject.com,20336,RESELLER,0bfd66d529a55807 -smartadserver.com,3379,RESELLER,060d053dcf45cbf3 -criteo.com,B-060926,RESELLER,9fac4a4a87c2a44f -themediagrid.com,3ETIX5,RESELLER,35d5010d7789b49d -triplelift.com,8183,RESELLER,6c33edb13117fd86 -appnexus.com,12190,RESELLER,f5ab79cb980f11d1 -onetag.com,806eabb849d0326,RESELLER -rtbhouse.com,mSu1piUSmB9TF4AQDGk4,RESELLER -33across.com,001Pg00000HMy0YIAT,RESELLER,bbea06d9c4d2853c -e-planning.net,a76893b96338e7e9,RESELLER,c1ba615865ed87b2 -appnexus.com,15941,RESELLER,f5ab79cb980f11d1 -video.unrulymedia.com,731539260,RESELLER - -#Rise -risecodes.com, 643813aab7212c00011c3f28, DIRECT -pubmatic.com, 160295, RESELLER, 5d62403b186f2ace -xandr.com, 14082, RESELLER -rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807 -sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2 -media.net, 8CUQ6928Q, RESELLER -sonobi.com, 4a289cdd79, RESELLER, d1a215d9eb5aee9e -video.unrulymedia.com, 335119963, RESELLER -contextweb.com,562615,RESELLER,89ff185a4c4e857c -onetag.com, 69f48c2160c8113, RESELLER -33across.com, 0010b00002Xbn7QAAR, RESELLER, bbea06d9c4d2853c -yieldmo.com, 2754490424016969782, RESELLER -openx.com, 537140488, RESELLER, 6a698e2ec38604c6 -lijit.com, 405318, RESELLER, fafdf38b16bf6b2b -themediagrid.com, 4DQHAP, RESELLER, 35d5010d7789b49d -loopme.com, 11362, RESELLER, 6c8d5f95897a5a3b -amxrtb.com, 105199691, RESELLER -smartadserver.com, 4284, RESELLER -adform.com, 3119, RESELLER, 9f5210a2f0999e32 -smaato.com, 1100057444, RESELLER, 07bcf65f187117b4 -adyoulike.com, 78afbc34fac571736717317117dfa247, RESELLER - -#Block -blockthrough.com, 5130683165442048, DIRECT -pubmatic.com, 160377, RESELLER, 5d62403b186f2ace -rubiconproject.com, 23718, RESELLER, 0bfd66d529a55807 -appnexus.com, 6979, RESELLER -lijit.com, 251666, RESELLER, fafdf38b16bf6b2b -lijit.com, 251666-eb, RESELLER, fafdf38b16bf6b2b -video.unrulymedia.com, 2444764291, RESELLER -contextweb.com, 558511, RESELLER -krushmedia.com, AJxF6R572a9M6CaTvK, RESELLER -criteo.com, 8990, RESELLER -smartadserver.com, 4485, RESELLER, 060d053dcf45cbf3 -smartadserver.com, 4485-OB, RESELLER, 060d053dcf45cbf3 -Contextweb.com, 562926, RESELLER, 89ff185a4c4e857c - -# VT Amazon TAM - -aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT -indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc -triplelift.com, 6059, DIRECT, 6c33edb13117fd86 -sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2 -appnexus.com, 806, DIRECT, f5ab79cb980f11d1 -risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT -minutemedia.com, 01gerz6y43ck, RESELLER -themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d -vidazoo.com, 655c85dc63ceeb606a0f365f, DIRECT, b6ada874b4d7d0b2 -smartadserver.com, 3490, DIRECT - -################################## -# AdinPlay.com ads.txt - 2025-04-16 -################################## - -venatus.com, OFI, DIRECT \ No newline at end of file diff --git a/resources/flags/Sakhalin.svg b/resources/flags/Sakhalin.svg new file mode 100644 index 000000000..ff85f0646 --- /dev/null +++ b/resources/flags/Sakhalin.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/resources/images/AllianceRequestIcon.svg b/resources/images/AllianceRequestBlackIcon.svg old mode 100755 new mode 100644 similarity index 98% rename from resources/images/AllianceRequestIcon.svg rename to resources/images/AllianceRequestBlackIcon.svg index f5981e241..5fa1e9e31 --- a/resources/images/AllianceRequestIcon.svg +++ b/resources/images/AllianceRequestBlackIcon.svg @@ -1,14 +1,14 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/resources/images/AllianceRequestWhiteIcon.svg b/resources/images/AllianceRequestWhiteIcon.svg new file mode 100644 index 000000000..c34774dd4 --- /dev/null +++ b/resources/images/AllianceRequestWhiteIcon.svg @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/BackIconWhite.svg b/resources/images/BackIconWhite.svg new file mode 100644 index 000000000..651867162 --- /dev/null +++ b/resources/images/BackIconWhite.svg @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/resources/images/EmbargoIcon.svg b/resources/images/EmbargoBlackIcon.svg old mode 100755 new mode 100644 similarity index 100% rename from resources/images/EmbargoIcon.svg rename to resources/images/EmbargoBlackIcon.svg diff --git a/resources/images/EmbargoWhiteIcon.svg b/resources/images/EmbargoWhiteIcon.svg new file mode 100644 index 000000000..c5a966b73 --- /dev/null +++ b/resources/images/EmbargoWhiteIcon.svg @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/resources/lang/ar.json b/resources/lang/ar.json index 958265b4b..68492030d 100644 --- a/resources/lang/ar.json +++ b/resources/lang/ar.json @@ -122,7 +122,6 @@ "random": "ุนุดูˆุงุฆูŠ", "iceland": "ุขูŠุณู„ู†ุฏุง", "pangaea": "ุจุงู†ุฌูŠุง", - "japan": "ุงู„ูŠุงุจุงู† ูˆุงู„ุฌูŠุฑุงู†", "betweentwoseas": "ุจูŠู† ุจุญุฑูŠู†", "knownworld": "ุงู„ุนุงู„ู… ุงู„ู…ุนุฑูˆู", "faroeislands": "ุฌุฒุฑ ูุงุฑูˆ", diff --git a/resources/lang/bg.json b/resources/lang/bg.json index e6e174ca7..5889cbf2e 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -135,7 +135,6 @@ "random": "ะŸั€ะพะธะทะฒะพะปะฝะฐ", "iceland": "ะ˜ัะปะฐะฝะดะธั", "pangaea": "ะŸะฐะฝะณะตั", - "japan": "ะฏะฟะพะฝะธั ะธ ััŠัะตะดะธ", "betweentwoseas": "ะœะตะถะดัƒ ะ”ะฒะต ะœะพั€ะตั‚ะฐ", "knownworld": "ะŸะพะทะฝะฐั‚ ะกะฒัั‚", "faroeislands": "ะคะฐั€ัŒะพั€ัะบะธ ะพัั‚ั€ะพะฒะธ", diff --git a/resources/lang/bn.json b/resources/lang/bn.json index 047a5ec1e..4dca36559 100644 --- a/resources/lang/bn.json +++ b/resources/lang/bn.json @@ -118,7 +118,6 @@ "random": "เฆฏเง‡เฆ•เง‹เฆจเง‹", "iceland": "เฆ†เฆ‡เฆธเฆฒเงเฆฏเฆพเฆจเงเฆก", "pangaea": "เฆชเฆพเฆžเงเฆœเฆฟเฆฏเฆผเฆพ", - "japan": "เฆœเฆพเฆชเฆพเฆจ เฆ“ เฆคเฆพเฆฐ เฆชเฆพเฆฐเงเฆถเงเฆฌเฆฌเฆฐเงเฆคเง€ เฆ…เฆžเงเฆšเฆฒ", "betweentwoseas": "เฆฆเงเฆ‡ เฆธเฆฎเงเฆฆเงเฆฐเง‡เฆฐ เฆฎเฆงเงเฆฏเฆฌเฆฐเงเฆคเง€ เฆ…เฆžเงเฆšเฆฒ", "knownworld": "เฆชเฆฐเฆฟเฆšเฆฟเฆค เฆชเงƒเฆฅเฆฟเฆฌเง€", "faroeislands": "เฆซเงเฆฏเฆพเฆฐเง‹ เฆฆเงเฆฌเง€เฆชเฆชเงเฆžเงเฆœ" diff --git a/resources/lang/cs.json b/resources/lang/cs.json index b9a1f3a9d..bc3e827ef 100644 --- a/resources/lang/cs.json +++ b/resources/lang/cs.json @@ -135,7 +135,6 @@ "random": "Nรกhodnรก", "iceland": "Island", "pangaea": "Pangea", - "japan": "Japonsko a okolรญ", "betweentwoseas": "Mezi dvฤ›ma moล™i", "knownworld": "Znรกmรฝ svฤ›t", "faroeislands": "Faerskรฉ ostrovy", diff --git a/resources/lang/de.json b/resources/lang/de.json index 887b23dbd..b37ae2291 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -112,7 +112,6 @@ "pangaea": "Pangaea", "map": "Karte", "betweentwoseas": "Zwischen zwei Meeren", - "japan": "Japan und Nachbarlรคnder", "knownworld": "Bekannte Welt" }, "private_lobby": { diff --git a/resources/lang/debug.json b/resources/lang/debug.json index 0e6eaf3ca..5b7c27515 100644 --- a/resources/lang/debug.json +++ b/resources/lang/debug.json @@ -164,5 +164,8 @@ "Balanced": "difficulty.Balanced", "Intense": "difficulty.Intense", "Impossible": "difficulty.Impossible" + }, + "heads_up_message": { + "choose_spawn": "heads_up_message.choose_spawn" } } diff --git a/resources/lang/en.json b/resources/lang/en.json index 0013f5ff1..d8f49b2de 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -5,6 +5,9 @@ "svg": "uk_us_flag", "lang_code": "en" }, + "common": { + "close": "Close" + }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Join the Discord!", @@ -17,8 +20,12 @@ "single_player": "Single Player", "instructions": "Instructions", "how_to_play": "How to Play", + "advertise": "Advertise", "wiki": "Wiki" }, + "news": { + "title": "Version 23 released!" + }, "help_modal": { "hotkeys": "Hotkeys", "table_key": "Key", @@ -137,7 +144,7 @@ "random": "Random", "iceland": "Iceland", "pangaea": "Pangaea", - "japan": "Japan and Neighbors", + "eastasia": "East Asia", "betweentwoseas": "Between Two Seas", "faroeislands": "Faroe Islands", "deglaciatedantarctica": "Deglaciated Antarctica", @@ -245,6 +252,14 @@ "view_options": "View Options", "toggle_view": "Toggle View", "toggle_view_desc": "Alternate view (terrain/countries)", + "attack_ratio_controls": "Attack Ratio Controls", + "attack_ratio_up": "Increase Attack Ratio", + "attack_ratio_up_desc": "Increase attack ratio by 10%", + "attack_ratio_down": "Decrease Attack Ratio", + "attack_ratio_down_desc": "Decrease attack ratio by 10%", + "attack_keybinds": "Attack Keybinds", + "boat_attack": "Boat Attack", + "boat_attack_desc": "Send a boat attack to the tile under your cursor.", "zoom_controls": "Zoom Controls", "zoom_out": "Zoom Out", "zoom_out_desc": "Zoom out the map", @@ -358,7 +373,8 @@ "you_won": "You Won!", "other_won": "{player} has won!", "exit": "Exit Game", - "keep": "Keep Playing" + "keep": "Keep Playing", + "wishlist": "Wishlist on Steam!" }, "leaderboard": { "title": "Leaderboard", @@ -411,7 +427,9 @@ "start_trade": "Start trading", "stop_trade": "Stop trading", "yes": "Yes", - "no": "No" + "no": "No", + "none": "None", + "alliances": "Alliances" }, "error_modal": { "crashed": "Game crashed!", @@ -420,5 +438,8 @@ "copied": "Copied!", "failed_copy": "Failed to copy", "desync_notice": "You are desynced from other players. What you see might differ from other players." + }, + "heads_up_message": { + "choose_spawn": "Choose a starting location" } } diff --git a/resources/lang/eo.json b/resources/lang/eo.json index 04b4b8781..937c035e7 100644 --- a/resources/lang/eo.json +++ b/resources/lang/eo.json @@ -135,7 +135,6 @@ "random": "Hazarda", "iceland": "Islando", "pangaea": "Pangeo", - "japan": "Japanio kaj najbaroj", "betweentwoseas": "Inter du maroj", "knownworld": "Konata Mondo", "faroeislands": "Ferooj", diff --git a/resources/lang/es.json b/resources/lang/es.json index 8f8b85917..5ff191327 100644 --- a/resources/lang/es.json +++ b/resources/lang/es.json @@ -135,7 +135,6 @@ "random": "Aleatorio", "iceland": "Islandia", "pangaea": "Pangea", - "japan": "Japรณn y alrededores", "betweentwoseas": "Entre dos mares", "knownworld": "El Mundo Conocido", "faroeislands": "Islas Feroe", diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 8308e5d28..4514eaf13 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -135,7 +135,6 @@ "random": "Alรฉatoire", "iceland": "Islande", "pangaea": "Pangรฉe", - "japan": "Japon et pays voisins", "betweentwoseas": "Entre deux mers", "knownworld": "Monde connu", "faroeislands": "รŽles Fรฉroรฉ", diff --git a/resources/lang/he.json b/resources/lang/he.json index 1740952e4..87f1c4845 100644 --- a/resources/lang/he.json +++ b/resources/lang/he.json @@ -135,7 +135,6 @@ "random": "ืจื ื“ื•ืžืœื™", "iceland": "ืื™ืกืœื ื“", "pangaea": "ืคื ื’ื™ืื”", - "japan": "ื™ืคืŸ ื•ืฉื›ื ื•ืชื™ื”", "betweentwoseas": "ื‘ื™ืŸ ืฉื ื™ ื™ืžื™ื", "knownworld": "ื”ืขื•ืœื ื”ื™ื“ื•ืข", "faroeislands": "ืื™ื™ ืคืืจื•", diff --git a/resources/lang/hi.json b/resources/lang/hi.json index b029cf89c..d1741d7e2 100644 --- a/resources/lang/hi.json +++ b/resources/lang/hi.json @@ -118,7 +118,6 @@ "random": "เคฏเคพเคฆเฅƒเคšเฅเค›เคฟเค•", "iceland": "เค†เค‡เคธเคฒเฅˆเค‚เคก", "pangaea": "เคชเคพเค‚เคœเคฟเคฏเคพ", - "japan": "เคœเคพเคชเคพเคจ เค”เคฐ เคธเฅ€เคฎเคพเคตเคฐเฅเคคเฅ€ เคฆเฅ‡เคถ", "betweentwoseas": "เคธเคฎเฅเคฆเฅเคฐเฅ‹เค‚ เค•เฅ‡ เคฎเคงเฅเคฏ เคญเฅ‚เคฎเคฟ", "knownworld": "เคœเฅเคžเคพเคค เคฆเฅเคจเคฟเคฏเคพ", "faroeislands": "เคซเคผเคฐเฅ‹ เคฆเฅเคตเฅ€เคชเคธเคฎเฅ‚เคน" diff --git a/resources/lang/it.json b/resources/lang/it.json index 5fa707f54..65d2e181b 100644 --- a/resources/lang/it.json +++ b/resources/lang/it.json @@ -135,7 +135,6 @@ "random": "Casuale", "iceland": "Islanda", "pangaea": "Pangea", - "japan": "Giappone e paesi confinanti", "betweentwoseas": "Tra I Due Mari", "knownworld": "Mondo Conosciuto", "faroeislands": "Isole Faroe", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 07f880222..1dbd03125 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -135,7 +135,6 @@ "random": "ใƒฉใƒณใƒ€ใƒ ", "iceland": "ใ‚ขใ‚คใ‚นใƒฉใƒณใƒ‰", "pangaea": "ใƒ‘ใƒณใ‚ฒใ‚ข", - "japan": "ๆ—ฅๆœฌใจใใฎ้šฃๅ›ฝ", "betweentwoseas": "2ใคใฎๆตทใฎ้–“", "knownworld": "็Ÿฅใ‚‰ใ‚Œใฆใ‚‹ไธ–็•Œ", "faroeislands": "ใƒ•ใ‚งใƒญใƒผ่ซธๅณถ", diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 4d4dd7e3b..270537185 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -135,7 +135,6 @@ "random": "Willekeurig", "iceland": "IJsland", "pangaea": "Pangea", - "japan": "Japan en buren", "betweentwoseas": "Tussen twee zeeรซn", "knownworld": "Bekende Wereld", "faroeislands": "Faerรถer eilanden", diff --git a/resources/lang/pl.json b/resources/lang/pl.json index 9b45f7937..f5bf6b993 100644 --- a/resources/lang/pl.json +++ b/resources/lang/pl.json @@ -122,7 +122,6 @@ "random": "Losowe", "iceland": "Islandia", "pangaea": "Pangea", - "japan": "Japonia i sฤ…siedzi", "betweentwoseas": "Miฤ™dzy dwoma morzami", "knownworld": "Znany ลšwiat", "faroeislands": "Wyspy Owcze", diff --git a/resources/lang/pt_br.json b/resources/lang/pt_br.json index ba24a59ac..27d79f2a6 100644 --- a/resources/lang/pt_br.json +++ b/resources/lang/pt_br.json @@ -112,7 +112,6 @@ "pangaea": "Pangeia", "map": "Mapa", "betweentwoseas": "Entre Dois Mares", - "japan": "Japรฃo e Vizinhos", "knownworld": "Mundo Conhecido" }, "private_lobby": { diff --git a/resources/lang/ru.json b/resources/lang/ru.json index 0d30d7478..3bb5995b9 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -135,7 +135,6 @@ "random": "ะกะปัƒั‡ะฐะนะฝะพ", "iceland": "ะ˜ัะปะฐะฝะดะธั", "pangaea": "ะŸะฐะฝะณะตั", - "japan": "ะฏะฟะพะฝะธั ะธ ัะพัะตะดะธ", "betweentwoseas": "ะœะตะถะดัƒ ะดะฒัƒั… ะผะพั€ะตะน", "knownworld": "ะ˜ะทะฒะตัั‚ะฝั‹ะน ะผะธั€", "faroeislands": "ะคะฐั€ะตั€ัะบะธะต ะพัั‚ั€ะพะฒะฐ", diff --git a/resources/lang/sh.json b/resources/lang/sh.json index 520ff46f0..d0bbc546c 100644 --- a/resources/lang/sh.json +++ b/resources/lang/sh.json @@ -118,7 +118,6 @@ "random": "Nasumiฤna", "iceland": "Island", "pangaea": "Pangea", - "japan": "Japan i susjedi", "betweentwoseas": "Izmeฤ‘u dva mora", "knownworld": "Poznati svijet", "faroeislands": "Farska ostrva", diff --git a/resources/lang/tp.json b/resources/lang/tp.json index a8de7c97b..c27219372 100644 --- a/resources/lang/tp.json +++ b/resources/lang/tp.json @@ -122,7 +122,6 @@ "random": "ma nasa", "iceland": "ma Isilan", "pangaea": "ma Pansija", - "japan": "ma Nijon en ma poka", "betweentwoseas": "insa pi telo tu", "knownworld": "ma ale", "faroeislands": "ma telo Paja", diff --git a/resources/lang/tr.json b/resources/lang/tr.json index eee305558..6b221eb20 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -112,7 +112,6 @@ "pangaea": "Pangea", "map": "Harita", "betweentwoseas": "ฤฐki Deniz Arasฤฑ", - "japan": "Japonya ve KomลŸularฤฑ", "knownworld": "Bilinen Dรผnya" }, "private_lobby": { diff --git a/resources/lang/uk.json b/resources/lang/uk.json index c7436be3b..5f2f95a63 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -135,7 +135,6 @@ "random": "ะ’ะธะฟะฐะดะบะพะฒะพ", "iceland": "ะ†ัะปะฐะฝะดั–ั", "pangaea": "ะŸะฐะฝะณะตั", - "japan": "ะฏะฟะพะฝั–ั ั‚ะฐ ััƒัั–ะดะธ", "betweentwoseas": "ะŸะพะผั–ะถ ะดะฒะพั… ะผะพั€ั–ะฒ", "knownworld": "ะ’ั–ะดะพะผะธะน ัะฒั–ั‚", "faroeislands": "ะคะฐั€ะตั€ััŒะบั– ะพัั‚ั€ะพะฒะธ", diff --git a/resources/maps/Japan.bin b/resources/maps/EastAsia.bin similarity index 100% rename from resources/maps/Japan.bin rename to resources/maps/EastAsia.bin diff --git a/resources/maps/Japan.json b/resources/maps/EastAsia.json similarity index 95% rename from resources/maps/Japan.json rename to resources/maps/EastAsia.json index 3f9ab541d..d47fad0c3 100644 --- a/resources/maps/Japan.json +++ b/resources/maps/EastAsia.json @@ -1,10 +1,10 @@ { - "name": "Japan", + "name": "East Asia", "width": 1562, "height": 1646, "nations": [ { - "coordinates": [1151, 709], + "coordinates": [1150, 660], "name": "Hokkaido", "strength": 1, "flag": "jp" @@ -31,7 +31,7 @@ "coordinates": [1162, 154], "name": "Sakhalin", "strength": 2, - "flag": "" + "flag": "Sakhalin" }, { "coordinates": [571, 1116], @@ -40,7 +40,7 @@ "flag": "jp" }, { - "coordinates": [8612, 1183], + "coordinates": [595, 1190], "name": "Shikoku", "strength": 2, "flag": "jp" diff --git a/resources/maps/Japan.png b/resources/maps/EastAsia.png similarity index 100% rename from resources/maps/Japan.png rename to resources/maps/EastAsia.png diff --git a/resources/maps/JapanMini.bin b/resources/maps/EastAsiaMini.bin similarity index 100% rename from resources/maps/JapanMini.bin rename to resources/maps/EastAsiaMini.bin diff --git a/resources/maps/JapanThumb.webp b/resources/maps/EastAsiaThumb.webp similarity index 100% rename from resources/maps/JapanThumb.webp rename to resources/maps/EastAsiaThumb.webp diff --git a/resources/maps/Europe.json b/resources/maps/Europe.json index 2fdc5694f..33ff48987 100644 --- a/resources/maps/Europe.json +++ b/resources/maps/Europe.json @@ -187,7 +187,7 @@ "coordinates": [1254, 899], "name": "Slovak Republic", "strength": 3, - "flag": "SK" + "flag": "sk" }, { "coordinates": [1002, 1061], @@ -244,7 +244,7 @@ "flag": "gb-sct" }, { - "coordinates": [2239, 3215], + "coordinates": [2300, 510], "name": "USSR", "strength": 3, "flag": "ussr" @@ -259,7 +259,7 @@ "coordinates": [1522, 48], "name": "Polar Bears", "strength": 2, - "flag": "polar_bear" + "flag": "polar_bears" }, { "coordinates": [821, 628], @@ -280,7 +280,7 @@ "flag": "eg" }, { - "coordinates": [1188, 1612], + "coordinates": [1115, 1650], "name": "State of Libya", "strength": 1, "flag": "ly" @@ -289,13 +289,13 @@ "coordinates": [1919, 1608], "name": "Hashemite Kingdom of Jordan", "strength": 1, - "flag": "hu" + "flag": "jo" }, { "coordinates": [1898, 1535], "name": "Lebanese Republic", "strength": 1, - "flag": "hu" + "flag": "lb" } ] } diff --git a/resources/maps/GatewayToTheAtlantic.json b/resources/maps/GatewayToTheAtlantic.json index c36454b2a..0f90f7fb3 100644 --- a/resources/maps/GatewayToTheAtlantic.json +++ b/resources/maps/GatewayToTheAtlantic.json @@ -19,7 +19,7 @@ "coordinates": [1334, 537], "name": "Duchy of Aquitaine", "strength": 2, - "flag": "aquitane" + "flag": "aquitaine" }, { "coordinates": [2115, 684], @@ -31,7 +31,7 @@ "coordinates": [1207, 763], "name": "The Basque", "strength": 3, - "flag": "" + "flag": "es-pv" }, { "coordinates": [1281, 1142], @@ -49,7 +49,7 @@ "coordinates": [561, 764], "name": "Kingdom of Galicia", "strength": 2, - "flag": "galicia" + "flag": "es-ga" }, { "coordinates": [1004, 1436], @@ -115,13 +115,13 @@ "coordinates": [1755, 1130], "name": "The Old Ones", "strength": 3, - "flag": "nuragic" + "flag": "neuragic_empire" }, { "coordinates": [2097, 1670], - "name": "Jesuit Monks", + "name": "Tamazgha", "strength": 2, - "flag": "" + "flag": "Amazigh flag" }, { "coordinates": [979, 1013], @@ -151,7 +151,7 @@ "coordinates": [1017, 180], "name": "Kingdom of Brittany", "strength": 2, - "flag": "britanny" + "flag": "brittany" }, { "coordinates": [2072, 567], @@ -175,7 +175,7 @@ "coordinates": [1475, 1657], "name": "French Foreign Legion", "strength": 3, - "flag": "french_foreign_legion" + "flag": "French foreign legion" }, { "coordinates": [1685, 417], diff --git a/resources/maps/Mars.json b/resources/maps/Mars.json index 342c707ec..c5d0a9f2d 100644 --- a/resources/maps/Mars.json +++ b/resources/maps/Mars.json @@ -13,7 +13,7 @@ "coordinates": [122, 750], "name": "USSR", "strength": 2, - "flag": "" + "flag": "ussr" }, { "coordinates": [1232, 735], diff --git a/resources/maps/NorthAmerica.json b/resources/maps/NorthAmerica.json index ac55c5ef6..ea8ee8494 100644 --- a/resources/maps/NorthAmerica.json +++ b/resources/maps/NorthAmerica.json @@ -4,37 +4,37 @@ "height": 1448, "nations": [ { - "coordinates": [1693, 1045], + "coordinates": [1625, 1040], "name": "Florida", "strength": 3, "flag": "Florida" }, { - "coordinates": [1001, 427], + "coordinates": [1010, 435], "name": "Canada", "strength": 2, "flag": "ca" }, { - "coordinates": [1364, 1179], + "coordinates": [1250, 1130], "name": "Mexico", "strength": 2, "flag": "mx" }, { - "coordinates": [1556, 1295], + "coordinates": [1460, 1275], "name": "Guatemala", "strength": 1, "flag": "gt" }, { - "coordinates": [1612, 1289], + "coordinates": [1530, 1290], "name": "Honduras", "strength": 1, "flag": "hn" }, { - "coordinates": [1642, 1348], + "coordinates": [1570, 1350], "name": "Nicaragua", "strength": 1, "flag": "ni" @@ -58,7 +58,7 @@ "flag": "ve" }, { - "coordinates": [1775, 1183], + "coordinates": [1725, 1180], "name": "Cuba", "strength": 1, "flag": "cu" @@ -94,7 +94,7 @@ "flag": "Georgia_US" }, { - "coordinates": [420, 1209], + "coordinates": [250, 1200], "name": "Hawaii", "strength": 1, "flag": "Hawaii" @@ -283,10 +283,10 @@ "coordinates": [1189, 240], "name": "Polar Bears", "strength": 3, - "flag": "polar_bear" + "flag": "polar_bears" }, { - "coordinates": [1480, 343], + "coordinates": [1480, 350], "name": "Frost Giants", "strength": 3, "flag": "frost_giant" diff --git a/resources/maps/SouthAmerica.json b/resources/maps/SouthAmerica.json index 36807cf01..ed56a9f8d 100644 --- a/resources/maps/SouthAmerica.json +++ b/resources/maps/SouthAmerica.json @@ -1,7 +1,7 @@ { "name": "Americas", "width": 1746, - "height": 2380, + "height": 2378, "nations": [ { "coordinates": [438, 58], @@ -94,7 +94,7 @@ "flag": "gf" }, { - "coordinates": [801, 242], + "coordinates": [800, 410], "name": "Guyana", "strength": 1, "flag": "gy" @@ -133,7 +133,7 @@ "coordinates": [1270, 1035], "name": "The Biggest Snakes", "strength": 3, - "flag": "" + "flag": "Aztec Empire" }, { "coordinates": [894, 693], diff --git a/resources/privacy-policy.html b/resources/privacy-policy.html index 121539400..efb7b2f78 100644 --- a/resources/privacy-policy.html +++ b/resources/privacy-policy.html @@ -59,7 +59,7 @@

Privacy Policy

-

Last Updated: April 29, 2025

+

Last Updated: May 29, 2025

This Privacy Policy describes Our policies and procedures on the @@ -598,6 +598,18 @@

  • By email: openfrontio@gmail.com
  • +

    Advertising

    +

    + All or partial advertising on this Website or App is managed by Playwire + LLC. If Playwire publisher advertising services are used, Playwire LLC may + collect and use certain aggregated and anonymized data for advertising + purposes. To learn more about the types of data collected, how data is + used and your choices as a user, please visit + https://www.playwire.com/privacy-policy. +

    + @@ -505,7 +456,7 @@ export class HostLobbyModal extends LitElement { private async handleDisableNPCsChange(e: Event) { this.disableNPCs = Boolean((e.target as HTMLInputElement).checked); - consolex.log(`updating disable npcs to ${this.disableNPCs}`); + console.log(`updating disable npcs to ${this.disableNPCs}`); this.putGameConfig(); } @@ -545,6 +496,15 @@ export class HostLobbyModal extends LitElement { return response; } + private toggleUnit(unit: UnitType, checked: boolean): void { + console.log(`Toggling unit type: ${unit} to ${checked}`); + this.disabledUnits = checked + ? [...this.disabledUnits, unit] + : this.disabledUnits.filter((u) => u !== unit); + + this.putGameConfig(); + } + private getRandomMap(): GameMapType { const maps = Object.values(GameMapType); const randIdx = Math.floor(Math.random() * maps.length); @@ -557,7 +517,7 @@ export class HostLobbyModal extends LitElement { } await this.putGameConfig(); - consolex.log( + console.log( `Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, ); this.close(); @@ -585,7 +545,7 @@ export class HostLobbyModal extends LitElement { this.copySuccess = false; }, 2000); } catch (err) { - consolex.error(`Failed to copy text: ${err}`); + console.error(`Failed to copy text: ${err}`); } } @@ -625,11 +585,11 @@ async function createLobby(): Promise { } const data = await response.json(); - consolex.log("Success:", data); + console.log("Success:", data); return data as GameInfo; } catch (error) { - consolex.error("Error creating lobby:", error); + console.error("Error creating lobby:", error); throw error; // Re-throw the error so the caller can handle it } } diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index aa6a225b6..597733705 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -76,6 +76,8 @@ export class ShowEmojiMenuEvent implements GameEvent { ) {} } +export class DoBoatAttackEvent implements GameEvent {} + export class AttackRatioEvent implements GameEvent { constructor(public readonly attackRatio: number) {} } @@ -122,6 +124,9 @@ export class InputHandler { moveRight: "KeyD", zoomOut: "KeyQ", zoomIn: "KeyE", + attackRatioDown: "Digit1", + attackRatioUp: "Digit2", + boatAttack: "KeyB", ...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"), }; this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); @@ -218,8 +223,8 @@ export class InputHandler { "ArrowRight", "Minus", "Equal", - "Digit1", - "Digit2", + keybinds.attackRatioDown, + keybinds.attackRatioUp, keybinds.centerCamera, "ControlLeft", "ControlRight", @@ -240,12 +245,17 @@ export class InputHandler { this.eventBus.emit(new RefreshGraphicsEvent()); } - if (e.code === "Digit1") { + if (e.code === keybinds.boatAttack) { + e.preventDefault(); + this.eventBus.emit(new DoBoatAttackEvent()); + } + + if (e.code === keybinds.attackRatioDown) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(-10)); } - if (e.code === "Digit2") { + if (e.code === keybinds.attackRatioUp) { e.preventDefault(); this.eventBus.emit(new AttackRatioEvent(10)); } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 01424e26a..3640bb220 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,7 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { consolex } from "../core/Consolex"; import { GameInfo, GameRecord } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; @@ -145,13 +144,13 @@ export class JoinPrivateLobbyModal extends LitElement { this.lobbyIdInput.value = lobbyId; } catch (err) { - consolex.error("Failed to read clipboard contents: ", err); + console.error("Failed to read clipboard contents: ", err); } } private async joinLobby(): Promise { const lobbyId = this.lobbyIdInput.value; - consolex.log(`Joining lobby with ID: ${lobbyId}`); + console.log(`Joining lobby with ID: ${lobbyId}`); this.message = `${translateText("private_lobby.checking")}`; try { @@ -165,7 +164,7 @@ export class JoinPrivateLobbyModal extends LitElement { this.message = `${translateText("private_lobby.not_found")}`; } catch (error) { - consolex.error("Error checking lobby existence:", error); + console.error("Error checking lobby existence:", error); this.message = `${translateText("private_lobby.error")}`; } } @@ -218,7 +217,7 @@ export class JoinPrivateLobbyModal extends LitElement { archiveData.success === false && archiveData.error === "Version mismatch" ) { - consolex.warn( + console.warn( `Git commit hash mismatch for game ${lobbyId}`, archiveData.details, ); @@ -266,7 +265,7 @@ export class JoinPrivateLobbyModal extends LitElement { this.players = data.clients?.map((p) => p.username) ?? []; }) .catch((error) => { - consolex.error("Error polling players:", error); + console.error("Error polling players:", error); }); } } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index adee86edf..27b7f9399 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -78,10 +78,18 @@ export class LangSelector extends LitElement { }); } + private getClosestSupportedLang(lang: string): string { + if (!lang) return "en"; + if (lang in this.languageMap) return lang; + const base = lang.split("-")[0]; + if (base in this.languageMap) return base; + return "en"; + } + private async initializeLanguage() { - const locale = new Intl.Locale(navigator.language); - const defaultLang = locale.language; - const userLang = localStorage.getItem("lang") || defaultLang; + const browserLocale = navigator.language; + const savedLang = localStorage.getItem("lang"); + const userLang = this.getClosestSupportedLang(savedLang || browserLocale); this.defaultTranslations = await this.loadLanguage("en"); this.translations = await this.loadLanguage(userLang); diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index 616e40db7..c6dd1df5a 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,4 +1,3 @@ -import { consolex } from "../core/Consolex"; import { GameConfig, GameID, GameRecord } from "../core/Schemas"; import { replacer } from "../core/Util"; @@ -51,7 +50,7 @@ export function endGame(gameRecord: GameRecord) { const gameStat = stats[gameRecord.info.gameID]; if (!gameStat) { - consolex.log("LocalPersistantStats: game not found"); + console.log("LocalPersistantStats: game not found"); return; } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 4d7be1a1c..e3bc08dcf 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -1,4 +1,3 @@ -import { consolex } from "../core/Consolex"; import { AllPlayersStats, ClientMessage, @@ -30,7 +29,7 @@ export class LocalServer { private allPlayersStats: AllPlayersStats = {}; private turnsExecuted = 0; - private lastTurnCompletedTime = 0; + private turnStartTime = 0; private turnCheckInterval: NodeJS.Timeout; @@ -47,9 +46,10 @@ export class LocalServer { if ( this.isReplay || Date.now() > - this.lastTurnCompletedTime + - this.lobbyConfig.serverConfig.turnIntervalMs() + this.turnStartTime + this.lobbyConfig.serverConfig.turnIntervalMs() ) { + this.turnStartTime = Date.now(); + // End turn on the server means the client will start processing the turn. this.endTurn(); } } @@ -140,11 +140,13 @@ export class LocalServer { } } + // This is so the client can tell us when it finished processing the turn. public turnComplete() { this.turnsExecuted++; - this.lastTurnCompletedTime = Date.now(); } + // endTurn in this context means the server has collected all the intents + // and will send the turn to the client. private endTurn() { if (this.paused) { return; @@ -169,7 +171,7 @@ export class LocalServer { } public endGame(saveFullGame: boolean = false) { - consolex.log("local server ending game"); + console.log("local server ending game"); clearInterval(this.turnCheckInterval); if (this.isReplay) { return; diff --git a/src/client/Main.ts b/src/client/Main.ts index 22bd98b65..4138224df 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,6 +1,5 @@ import page from "page"; import favicon from "../../resources/images/Favicon.svg"; -import { consolex } from "../core/Consolex"; import { GameRecord, GameStartInfo } from "../core/Schemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; @@ -62,20 +61,20 @@ class Client { initialize(): void { const newsModal = document.querySelector("news-modal") as NewsModal; if (!newsModal) { - consolex.warn("News modal element not found"); + console.warn("News modal element not found"); } else { - consolex.log("News modal element found"); + console.log("News modal element found"); } newsModal instanceof NewsModal; const newsButton = document.querySelector("news-button") as NewsButton; if (!newsButton) { - consolex.warn("News button element not found"); + console.warn("News button element not found"); } else { - consolex.log("News button element found"); + console.log("News button element found"); } // Comment out to show news button. - newsButton.hidden = true; + // newsButton.hidden = true; const langSelector = document.querySelector( "lang-selector", @@ -84,22 +83,22 @@ class Client { "lang-selector", ) as LanguageModal; if (!langSelector) { - consolex.warn("Lang selector element not found"); + console.warn("Lang selector element not found"); } if (!LanguageModal) { - consolex.warn("Language modal element not found"); + console.warn("Language modal element not found"); } this.flagInput = document.querySelector("flag-input") as FlagInput; if (!this.flagInput) { - consolex.warn("Flag input element not found"); + console.warn("Flag input element not found"); } this.darkModeButton = document.querySelector( "dark-mode-button", ) as DarkModeButton; if (!this.darkModeButton) { - consolex.warn("Dark mode button element not found"); + console.warn("Dark mode button element not found"); } const loginDiscordButton = document.getElementById( @@ -113,7 +112,7 @@ class Client { "username-input", ) as UsernameInput; if (!this.usernameInput) { - consolex.warn("Username input element not found"); + console.warn("Username input element not found"); } this.publicLobby = document.querySelector("public-lobby") as PublicLobby; @@ -122,7 +121,7 @@ class Client { ) as NodeListOf; window.addEventListener("beforeunload", () => { - consolex.log("Browser is closing"); + console.log("Browser is closing"); if (this.gameStop !== null) { this.gameStop(); } @@ -188,8 +187,8 @@ class Client { logoutDiscordButton.hidden = true; return; } - // TODO: Update the page for logged in user loginDiscordButton.translationKey = "main.logged_in"; + loginDiscordButton.hidden = true; const { user, player } = userMeResponse; }); } @@ -240,21 +239,14 @@ class Client { page("/join/:lobbyId", (ctx) => { if (ctx.init && sessionStorage.getItem("inLobby")) { // On page reload, go back home - page.redirect("/"); + page("/"); return; } const lobbyId = ctx.params.lobbyId; - if (lobbyId?.endsWith("#")) { - // When the cookies button is pressed, '#' is added to the url - // causing the page to attempt to rejoin the lobby during game play. - console.error("Invalid lobby ID provided"); - return; - } - this.joinModal.open(lobbyId); - consolex.log(`joining lobby ${lobbyId}`); + console.log(`joining lobby ${lobbyId}`); }); page(); @@ -274,9 +266,9 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail as JoinLobbyEvent; - consolex.log(`joining lobby ${lobby.gameID}`); + console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { - consolex.log("joining lobby, stopping existing game"); + console.log("joining lobby, stopping existing game"); this.gameStop(); } const config = await getServerConfigFromClient(); @@ -348,7 +340,7 @@ class Client { if (this.gameStop === null) { return; } - consolex.log("leaving lobby, cancelling game"); + console.log("leaving lobby, cancelling game"); this.gameStop(); this.gameStop = null; this.publicLobby.leaveLobby(); diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 94c8116bf..13ddb67bb 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -12,6 +12,10 @@ export class NewsModal extends LitElement { }; static styles = css` + :host { + display: block; + } + .news-container { max-height: 60vh; overflow-y: auto; @@ -24,10 +28,20 @@ export class NewsModal extends LitElement { .news-content { color: #ddd; line-height: 1.5; - background: rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.6); border-radius: 8px; padding: 1rem; } + + .news-content a { + color: #4a9eff !important; + text-decoration: underline !important; + transition: color 0.2s ease; + } + + .news-content a:hover { + color: #6fb3ff !important; + } `; render() { @@ -36,7 +50,24 @@ export class NewsModal extends LitElement {
    -
    INSERT NEWS HERE
    +
    +

    Main things to note:

    +
    +
      +
    • Workers reproduce faster than troops.
    • +
    • Defense = troops divided how much land you have.
    • +
    • Attacking troops count toward your population limit.
    • +
    +
    +
    + See full changelog + here. +
    @@ -58,8 +89,4 @@ export class NewsModal extends LitElement { private close() { this.modalEl?.close(); } - - createRenderRoot() { - return this; // light DOM - } } diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index ee3069504..2292d9d8d 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -1,7 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { consolex } from "../core/Consolex"; import { GameMode } from "../core/game/Game"; import { GameID, GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -51,7 +50,7 @@ export class PublicLobby extends LitElement { } }); } catch (error) { - consolex.error("Error fetching lobbies:", error); + console.error("Error fetching lobbies:", error); } } @@ -63,7 +62,7 @@ export class PublicLobby extends LitElement { const data = await response.json(); return data.lobbies; } catch (error) { - consolex.error("Error fetching lobbies:", error); + console.error("Error fetching lobbies:", error); throw error; } } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 09a6d4334..f633c1e31 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -2,7 +2,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import randomMap from "../../resources/images/RandomMap.webp"; import { translateText } from "../client/Utils"; -import { consolex } from "../core/Consolex"; import { Difficulty, Duos, @@ -21,6 +20,7 @@ import "./components/Maps"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; +import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; @customElement("single-player-modal") export class SinglePlayerModal extends LitElement { @@ -39,7 +39,7 @@ export class SinglePlayerModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: number | typeof Duos = 2; - @state() private disabledUnits: string[] = []; + @state() private disabledUnits: UnitType[] = []; render() { return html` @@ -284,48 +284,10 @@ export class SinglePlayerModal extends LitElement {
    - ${[ - [UnitType.City, "unit_type.city"], - [UnitType.DefensePost, "unit_type.defense_post"], - [UnitType.Port, "unit_type.port"], - [UnitType.Warship, "unit_type.warship"], - [UnitType.MissileSilo, "unit_type.missile_silo"], - [UnitType.SAMLauncher, "unit_type.sam_launcher"], - [UnitType.AtomBomb, "unit_type.atom_bomb"], - [UnitType.HydrogenBomb, "unit_type.hydrogen_bomb"], - [UnitType.MIRV, "unit_type.mirv"], - ].map( - ([unitType, translationKey]) => html` - - `, - )} + ${renderUnitTypeOptions({ + disabledUnits: this.disabledUnits, + toggleUnit: this.toggleUnit.bind(this), + })}
    @@ -403,13 +365,20 @@ export class SinglePlayerModal extends LitElement { return maps[randIdx] as GameMapType; } + private toggleUnit(unit: UnitType, checked: boolean): void { + console.log(`Toggling unit type: ${unit} to ${checked}`); + this.disabledUnits = checked + ? [...this.disabledUnits, unit] + : this.disabledUnits.filter((u) => u !== unit); + } + private startGame() { // If random map is selected, choose a random map now if (this.useRandomMap) { this.selectedMap = this.getRandomMap(); } - consolex.log( + console.log( `Starting single player game with map: ${GameMapType[this.selectedMap]}${this.useRandomMap ? " (Randomly selected)" : ""}`, ); const clientID = generateID(); @@ -419,12 +388,12 @@ export class SinglePlayerModal extends LitElement { "username-input", ) as UsernameInput; if (!usernameInput) { - consolex.warn("Username input element not found"); + console.warn("Username input element not found"); } const flagInput = document.querySelector("flag-input") as FlagInput; if (!flagInput) { - consolex.warn("Flag input element not found"); + console.warn("Flag input element not found"); } this.dispatchEvent( new CustomEvent("join-lobby", { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index cbef4bbba..f208a55a1 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,9 +1,9 @@ -import { SendLogEvent } from "../core/Consolex"; import { EventBus, GameEvent } from "../core/EventBus"; import { AllPlayers, Cell, GameType, + Gold, PlayerID, PlayerType, Tick, @@ -15,7 +15,6 @@ import { ClientHashMessage, ClientIntentMessage, ClientJoinMessage, - ClientLogMessage, ClientPingMessage, ClientSendWinnerMessage, Intent, @@ -94,15 +93,13 @@ export class SendEmojiIntentEvent implements GameEvent { export class SendDonateGoldIntentEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly recipient: PlayerView, - public readonly gold: number | null, + public readonly gold: Gold | null, ) {} } export class SendDonateTroopsIntentEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly recipient: PlayerView, public readonly troops: number | null, ) {} @@ -110,7 +107,6 @@ export class SendDonateTroopsIntentEvent implements GameEvent { export class SendQuickChatEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly recipient: PlayerView, public readonly quickChatKey: string, public readonly variables: { [key: string]: string }, @@ -119,17 +115,13 @@ export class SendQuickChatEvent implements GameEvent { export class SendEmbargoIntentEvent implements GameEvent { constructor( - public readonly sender: PlayerView, public readonly target: PlayerView, public readonly action: "start" | "stop", ) {} } export class CancelAttackIntentEvent implements GameEvent { - constructor( - public readonly playerID: PlayerID, - public readonly attackID: string, - ) {} + constructor(public readonly attackID: string) {} } export class CancelBoatIntentEvent implements GameEvent { @@ -217,7 +209,6 @@ export class Transport { ); this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); - this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e)); this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e)); this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e)); @@ -342,16 +333,6 @@ export class Transport { } } - private onSendLogEvent(event: SendLogEvent) { - this.sendMsg( - JSON.stringify({ - type: "log", - log: event.log, - severity: event.severity, - } satisfies ClientLogMessage), - ); - } - joinGame(numTurns: number) { this.sendMsg( JSON.stringify({ @@ -548,8 +529,7 @@ export class Transport { } private onSendHashEvent(event: SendHashEvent) { - if (this.socket === null) return; - if (this.isLocal || this.socket.readyState === WebSocket.OPEN) { + if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { this.sendMsg( JSON.stringify({ type: "hash", @@ -560,7 +540,7 @@ export class Transport { } else { console.log( "WebSocket is not open. Current state:", - this.socket.readyState, + this.socket!.readyState, ); console.log("attempting reconnect"); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index cfd9d21c1..aca371552 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -345,6 +345,41 @@ export class UserSettingModal extends LitElement { @change=${this.handleKeybindChange} > +
    + ${translateText("user_setting.attack_ratio_controls")} +
    + + + + + +
    + ${translateText("user_setting.attack_keybinds")} +
    + + +
    ${translateText("user_setting.zoom_controls")}
    diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 1f699da06..c0cd900c9 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -47,7 +47,7 @@ export class UsernameInput extends LitElement { /> ${this.validationError ? html`
    ${this.validationError}
    ` diff --git a/src/client/Utils.ts b/src/client/Utils.ts index a6c90191f..62fc2e80e 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -4,7 +4,8 @@ export function renderTroops(troops: number): string { return renderNumber(troops / 10); } -export function renderNumber(num: number): string { +export function renderNumber(num: number | bigint): string { + num = Number(num); num = Math.max(num, 0); if (num >= 10_000_000) { diff --git a/src/client/components/Maps.ts b/src/client/components/Maps.ts index cdd952df9..feaa13c2e 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/Maps.ts @@ -22,7 +22,7 @@ export const MapDescription: Record = { GatewayToTheAtlantic: "Gateway to the Atlantic", Australia: "Australia", Iceland: "Iceland", - Japan: "Japan", + EastAsia: "East Asia", BetweenTwoSeas: "Between Two Seas", FaroeIslands: "Faroe Islands", DeglaciatedAntarctica: "Deglaciated Antarctica", diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index 87d0e9431..16d420c6e 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -54,7 +54,7 @@ export class OModal extends LitElement { color: #fff; padding: 1.4rem; max-height: 60dvh; - overflow-y: scroll; + overflow-y: auto; backdrop-filter: blur(8px); } `; diff --git a/src/client/data/countries.json b/src/client/data/countries.json index 6c5cb64f9..90d67d102 100644 --- a/src/client/data/countries.json +++ b/src/client/data/countries.json @@ -473,7 +473,7 @@ "name": "Comoros" }, { - "code": "communist flag", + "code": "Communist flag", "name": "Communist Flag" }, { @@ -740,7 +740,7 @@ "name": "Franks" }, { - "code": "french foreign legion", + "code": "French foreign legion", "name": "French Foreign Legion" }, { @@ -1188,7 +1188,7 @@ "name": "Luxembourg" }, { - "code": "lydia", + "code": "Lydia", "continent": "Asia", "name": "Lydia" }, @@ -1238,7 +1238,7 @@ "name": "Malta" }, { - "code": "Mฤori Flag", + "code": "Mฤori flag", "continent": "Oceania", "name": "Mฤori Flag" }, @@ -1466,7 +1466,7 @@ "name": "Normandy" }, { - "code": "North Karelia", + "code": "North karelia", "continent": "Europe", "name": "North Karelia" }, diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 901ff166e..ee3bbc4b9 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -1,6 +1,4 @@ -import { consolex } from "../../core/Consolex"; import { EventBus } from "../../core/EventBus"; -import { ClientID } from "../../core/Schemas"; import { GameView } from "../../core/game/GameView"; import { GameStartingModal } from "../GameStartingModal"; import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; @@ -13,15 +11,16 @@ import { ControlPanel } from "./layers/ControlPanel"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; import { FxLayer } from "./layers/FxLayer"; +import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; +import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { OptionsMenu } from "./layers/OptionsMenu"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { PlayerTeamLabel } from "./layers/PlayerTeamLabel"; -import { RadialMenu } from "./layers/RadialMenu"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; @@ -37,7 +36,6 @@ export function createRenderer( canvas: HTMLCanvasElement, game: GameView, eventBus: EventBus, - clientID: ClientID, ): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas); @@ -52,7 +50,7 @@ export function createRenderer( // TODO maybe append this to dcoument instead of querying for them? const emojiTable = document.querySelector("emoji-table") as EmojiTable; if (!emojiTable || !(emojiTable instanceof EmojiTable)) { - consolex.error("EmojiTable element not found in the DOM"); + console.error("EmojiTable element not found in the DOM"); } emojiTable.eventBus = eventBus; emojiTable.transformHandler = transformHandler; @@ -61,32 +59,29 @@ export function createRenderer( const buildMenu = document.querySelector("build-menu") as BuildMenu; if (!buildMenu || !(buildMenu instanceof BuildMenu)) { - consolex.error("BuildMenu element not found in the DOM"); + console.error("BuildMenu element not found in the DOM"); } buildMenu.game = game; buildMenu.eventBus = eventBus; const leaderboard = document.querySelector("leader-board") as Leaderboard; if (!emojiTable || !(leaderboard instanceof Leaderboard)) { - consolex.error("EmojiTable element not found in the DOM"); + console.error("EmojiTable element not found in the DOM"); } - leaderboard.clientID = clientID; leaderboard.eventBus = eventBus; leaderboard.game = game; const teamStats = document.querySelector("team-stats") as TeamStats; if (!emojiTable || !(teamStats instanceof TeamStats)) { - consolex.error("EmojiTable element not found in the DOM"); + console.error("EmojiTable element not found in the DOM"); } - teamStats.clientID = clientID; teamStats.eventBus = eventBus; teamStats.game = game; const controlPanel = document.querySelector("control-panel") as ControlPanel; if (!(controlPanel instanceof ControlPanel)) { - consolex.error("ControlPanel element not found in the DOM"); + console.error("ControlPanel element not found in the DOM"); } - controlPanel.clientID = clientID; controlPanel.eventBus = eventBus; controlPanel.uiState = uiState; controlPanel.game = game; @@ -95,28 +90,25 @@ export function createRenderer( "events-display", ) as EventsDisplay; if (!(eventsDisplay instanceof EventsDisplay)) { - consolex.error("events display not found"); + console.error("events display not found"); } eventsDisplay.eventBus = eventBus; eventsDisplay.game = game; - eventsDisplay.clientID = clientID; const chatDisplay = document.querySelector("chat-display") as ChatDisplay; if (!(chatDisplay instanceof ChatDisplay)) { - consolex.error("chat display not found"); + console.error("chat display not found"); } chatDisplay.eventBus = eventBus; chatDisplay.game = game; - chatDisplay.clientID = clientID; const playerInfo = document.querySelector( "player-info-overlay", ) as PlayerInfoOverlay; if (!(playerInfo instanceof PlayerInfoOverlay)) { - consolex.error("player info overlay not found"); + console.error("player info overlay not found"); } playerInfo.eventBus = eventBus; - playerInfo.clientID = clientID; playerInfo.transform = transformHandler; playerInfo.game = game; @@ -172,6 +164,14 @@ export function createRenderer( } playerTeamLabel.game = game; + const headsUpMessage = document.querySelector( + "heads-up-message", + ) as HeadsUpMessage; + if (!(headsUpMessage instanceof HeadsUpMessage)) { + console.error("heads-up message not found"); + } + headsUpMessage.game = game; + const unitInfoModal = document.querySelector( "unit-info-modal", ) as UnitInfoModal; @@ -190,20 +190,19 @@ export function createRenderer( const layers: Layer[] = [ new TerrainLayer(game, transformHandler), - new TerritoryLayer(game, eventBus), + new TerritoryLayer(game, eventBus, transformHandler), structureLayer, - new UnitLayer(game, eventBus, clientID, transformHandler), + new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), - new UILayer(game, eventBus, clientID, transformHandler), - new NameLayer(game, transformHandler, clientID), + new UILayer(game, eventBus, transformHandler), + new NameLayer(game, transformHandler), eventsDisplay, chatDisplay, buildMenu, - new RadialMenu( + new MainRadialMenu( eventBus, game, transformHandler, - clientID, emojiTable as EmojiTable, buildMenu, uiState, @@ -220,6 +219,7 @@ export function createRenderer( topBar, playerPanel, playerTeamLabel, + headsUpMessage, unitInfoModal, multiTabModal, ]; @@ -265,11 +265,8 @@ export class GameRenderer { window.addEventListener("resize", () => this.resizeCanvas()); this.resizeCanvas(); - this.transformHandler = new TransformHandler( - this.game, - this.eventBus, - this.canvas, - ); + //show whole map on startup + this.transformHandler.centerAll(0.9); requestAnimationFrame(() => this.renderGame()); } diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 7fb1ab852..4be363134 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -125,10 +125,11 @@ export const getColoredSprite = ( customBorderColor?: Colord, ): HTMLCanvasElement => { const owner = unit.owner(); - const territoryColor = customTerritoryColor ?? theme.territoryColor(owner); - const borderColor = customBorderColor ?? theme.borderColor(owner); + const territoryColor: Colord = + customTerritoryColor ?? theme.territoryColor(owner); + const borderColor: Colord = customBorderColor ?? theme.borderColor(owner); const spawnHighlightColor = theme.spawnHighlightColor(); - const key = `${unit.type()}-${owner.id()}`; + const key = `${unit.type()}-${owner.id()}-${territoryColor.toRgbString()}-${borderColor.toRgbString()}`; if (coloredSpriteCache.has(key)) { return coloredSpriteCache.get(key)!; diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 475d65eef..7f111a280 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -257,4 +257,31 @@ export class TransformHandler { } this.target = null; } + + override(x: number = 0, y: number = 0, s: number = 1) { + //hardset view position + this.clearTarget(); + this.offsetX = x; + this.offsetY = y; + this.scale = s; + this.changed = true; + } + + centerAll(fit: number = 1) { + //position entire map centered on the screen + + const vpWidth = this.boundingRect().width; + const vpHeight = this.boundingRect().height; + const mapWidth = this.game.width(); + const mapHeight = this.game.height(); + + const scHor = (vpWidth / mapWidth) * fit; + const scVer = (vpHeight / mapHeight) * fit; + const tScale = Math.min(scHor, scVer); + + const oHor = (mapWidth - vpWidth) / 2 / tScale; + const oVer = (mapHeight - vpHeight) / 2 / tScale; + + this.override(oHor, oVer, tScale); + } } diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts index 54b4f18df..9293dd663 100644 --- a/src/client/graphics/fx/SpriteFx.ts +++ b/src/client/graphics/fx/SpriteFx.ts @@ -1,5 +1,4 @@ import { Theme } from "../../../core/configuration/Config"; -import { consolex } from "../../../core/Consolex"; import { PlayerView } from "../../../core/game/GameView"; import { AnimatedSprite } from "../AnimatedSprite"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; @@ -62,7 +61,7 @@ export class SpriteFx implements Fx { theme, ); if (!this.animatedSprite) { - consolex.error("Could not load animated sprite", fxType); + console.error("Could not load animated sprite", fxType); } else { this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000; } diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index f048c2271..f1ab5973b 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -12,14 +12,14 @@ import samlauncherIcon from "../../../../resources/images/SamLauncherIconWhite.s import shieldIcon from "../../../../resources/images/ShieldIconWhite.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { Cell, PlayerActions, UnitType } from "../../../core/game/Game"; +import { Cell, Gold, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { BuildUnitIntentEvent } from "../../Transport"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; -interface BuildItemDisplay { +export interface BuildItemDisplay { unitType: UnitType; icon: string; description?: string; @@ -27,7 +27,7 @@ interface BuildItemDisplay { countable?: boolean; } -const buildTable: BuildItemDisplay[][] = [ +export const buildTable: BuildItemDisplay[][] = [ [ { unitType: UnitType.AtomBomb, @@ -96,12 +96,14 @@ const buildTable: BuildItemDisplay[][] = [ ], ]; +export const flattenedBuildTable = buildTable.flat(); + @customElement("build-menu") export class BuildMenu extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; private clickedTile: TileRef; - private playerActions: PlayerActions | null; + public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; tick() { @@ -302,7 +304,7 @@ export class BuildMenu extends LitElement implements Layer { @state() private _hidden = true; - private canBuild(item: BuildItemDisplay): boolean { + public canBuild(item: BuildItemDisplay): boolean { if (this.game?.myPlayer() === null || this.playerActions === null) { return false; } @@ -314,16 +316,16 @@ export class BuildMenu extends LitElement implements Layer { return unit[0].canBuild !== false; } - private cost(item: BuildItemDisplay): number { + public cost(item: BuildItemDisplay): Gold { for (const bu of this.playerActions?.buildableUnits ?? []) { if (bu.type === item.unitType) { return bu.cost; } } - return 0; + return 0n; } - private count(item: BuildItemDisplay): string { + public count(item: BuildItemDisplay): string { const player = this.game?.myPlayer(); if (!player) { return "?"; diff --git a/src/client/graphics/layers/ChatDisplay.ts b/src/client/graphics/layers/ChatDisplay.ts index e4f376e18..f6940ae0a 100644 --- a/src/client/graphics/layers/ChatDisplay.ts +++ b/src/client/graphics/layers/ChatDisplay.ts @@ -9,7 +9,6 @@ import { GameUpdateType, } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { onlyImages } from "../../../core/Util"; import { Layer } from "./Layer"; @@ -24,7 +23,6 @@ interface ChatEvent { export class ChatDisplay extends LitElement implements Layer { public eventBus: EventBus; public game: GameView; - public clientID: ClientID; private active: boolean = false; @@ -61,7 +59,7 @@ export class ChatDisplay extends LitElement implements Layer { onDisplayMessageEvent(event: DisplayMessageUpdate) { if (event.messageType !== MessageType.CHAT) return; - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID) @@ -90,7 +88,7 @@ export class ChatDisplay extends LitElement implements Layer { if (messages) { for (const msg of messages) { if (msg.messageType === MessageType.CHAT) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( msg.playerID !== null && (!myPlayer || myPlayer.smallID() !== msg.playerID) diff --git a/src/client/graphics/layers/ChatIntegration.ts b/src/client/graphics/layers/ChatIntegration.ts new file mode 100644 index 000000000..044b0b18f --- /dev/null +++ b/src/client/graphics/layers/ChatIntegration.ts @@ -0,0 +1,102 @@ +import { EventBus } from "../../../core/EventBus"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { SendQuickChatEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import { ChatModal, QuickChatPhrase, quickChatPhrases } from "./ChatModal"; +import { COLORS, MenuElement } from "./RadialMenuElements"; + +export class ChatIntegration { + private ctModal: ChatModal; + + constructor( + private game: GameView, + private eventBus: EventBus, + ) { + this.ctModal = document.querySelector("chat-modal") as ChatModal; + + if (!this.ctModal) { + throw new Error( + "Chat modal element not found. Ensure chat-modal element exists in DOM before initializing ChatIntegration", + ); + } + } + + setupChatModal(sender: PlayerView, recipient: PlayerView) { + this.ctModal.setSender(sender); + this.ctModal.setRecipient(recipient); + } + + createQuickChatMenu(recipient: PlayerView): MenuElement[] { + if (!this.ctModal) { + throw new Error("Chat modal not set"); + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + throw new Error("Current player not found"); + } + + return this.ctModal.categories.map((category) => { + const categoryTranslation = translateText(`chat.cat.${category.id}`); + + const categoryColor = + COLORS.chat[category.id as keyof typeof COLORS.chat] || + COLORS.chat.default; + const phrases = quickChatPhrases[category.id] || []; + + const phraseItems: MenuElement[] = phrases.map( + (phrase: QuickChatPhrase) => { + const phraseText = translateText(`chat.${category.id}.${phrase.key}`); + + return { + id: `phrase-${category.id}-${phrase.key}`, + name: phraseText, + disabled: false, + text: this.shortenText(phraseText), + fontSize: "10px", + color: categoryColor, + tooltipItems: [ + { + text: phraseText, + className: "description", + }, + ], + action: () => { + if (phrase.requiresPlayer) { + this.ctModal.openWithSelection( + category.id, + phrase.key, + myPlayer, + recipient, + ); + } else { + this.eventBus.emit( + new SendQuickChatEvent( + recipient, + `${category.id}.${phrase.key}`, + {}, + ), + ); + } + }, + }; + }, + ); + + return { + id: `chat-category-${category.id}`, + name: categoryTranslation, + disabled: false, + text: categoryTranslation, + color: categoryColor, + _action: () => {}, // Empty action placeholder for RadialMenu + subMenu: () => phraseItems, + }; + }); + } + + shortenText(text: string, maxLength = 15): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + "..."; + } +} diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 40cb4e4a4..1118a027e 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -9,14 +9,14 @@ import { EventBus } from "../../../core/EventBus"; import { SendQuickChatEvent } from "../../Transport"; import { translateText } from "../../Utils"; -type QuickChatPhrase = { +export type QuickChatPhrase = { key: string; requiresPlayer: boolean; }; -type QuickChatPhrases = Record; +export type QuickChatPhrases = Record; -const quickChatPhrases: QuickChatPhrases = quickChatData; +export const quickChatPhrases: QuickChatPhrases = quickChatData; @customElement("chat-modal") export class ChatModal extends LitElement { @@ -57,7 +57,7 @@ export class ChatModal extends LitElement { misc: [{ text: "Let's go!", requiresPlayer: false }], }; - private categories = [ + public categories = [ { id: "help" }, { id: "attack" }, { id: "defend" }, @@ -71,17 +71,6 @@ export class ChatModal extends LitElement { } render() { - const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b)); - - const filteredPlayers = sortedPlayers.filter((player) => - player.toLowerCase().includes(this.playerSearchQuery), - ); - - const otherPlayers = sortedPlayers.filter( - (player) => !player.toLowerCase().includes(this.playerSearchQuery), - ); - - const displayPlayers = [...filteredPlayers, ...otherPlayers]; return html`
    @@ -236,7 +225,6 @@ export class ChatModal extends LitElement { this.eventBus.emit( new SendQuickChatEvent( - this.sender, this.recipient, this.selectedQuickChatKey, variables, @@ -307,4 +295,35 @@ export class ChatModal extends LitElement { public setSender(value: PlayerView) { this.sender = value; } + + public openWithSelection( + categoryId: string, + phraseKey: string, + sender?: PlayerView, + recipient?: PlayerView, + ) { + if (sender && recipient) { + const alivePlayerNames = this.g + .players() + .filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot)) + .map((p) => p.data.name); + + this.players = alivePlayerNames; + this.recipient = recipient; + this.sender = sender; + } + + this.selectCategory(categoryId); + + const phrase = this.getPhrasesForCategory(categoryId).find( + (p) => p.key === phraseKey, + ); + + if (phrase) { + this.selectPhrase(phrase); + } + + this.requestUpdate(); + this.modalEl?.open(); + } } diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 0ec35f9af..c01ae4bc9 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -2,8 +2,8 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; +import { Gold } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { AttackRatioEvent } from "../../InputHandler"; import { SendSetTargetTroopRatioEvent } from "../../Transport"; import { renderNumber, renderTroops } from "../../Utils"; @@ -13,7 +13,6 @@ import { Layer } from "./Layer"; @customElement("control-panel") export class ControlPanel extends LitElement implements Layer { public game: GameView; - public clientID: ClientID; public eventBus: EventBus; public uiState: UIState; @@ -48,10 +47,10 @@ export class ControlPanel extends LitElement implements Layer { private _manpower: number = 0; @state() - private _gold: number; + private _gold: Gold; @state() - private _goldPerSecond: number; + private _goldPerSecond: Gold; private _lastPopulationIncreaseRate: number; @@ -126,7 +125,7 @@ export class ControlPanel extends LitElement implements Layer { this._troops = player.troops(); this._workers = player.workers(); this.popRate = this.game.config().populationIncreaseRate(player) * 10; - this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10; + this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n; this.currentTroopRatio = player.troops() / player.population(); this.requestUpdate(); diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index f95f4d84b..ef236985e 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -23,7 +23,6 @@ import { TargetPlayerUpdate, UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; -import { ClientID } from "../../../core/Schemas"; import { CancelAttackIntentEvent, CancelBoatIntentEvent, @@ -66,7 +65,6 @@ interface Event { export class EventsDisplay extends LitElement implements Layer { public eventBus: EventBus; public game: GameView; - public clientID: ClientID; private active: boolean = false; private events: Event[] = []; @@ -184,7 +182,7 @@ export class EventsDisplay extends LitElement implements Layer { renderLayer(): void {} onDisplayMessageEvent(event: DisplayMessageUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID) @@ -202,7 +200,7 @@ export class EventsDisplay extends LitElement implements Layer { } onDisplayChatEvent(event: DisplayChatMessageUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if ( event.playerID === null || !myPlayer || @@ -230,7 +228,7 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceRequestEvent(update: AllianceRequestUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer || update.recipientID !== myPlayer.smallID()) { return; } @@ -282,7 +280,7 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer || update.request.requestorID !== myPlayer.smallID()) { return; } @@ -303,7 +301,7 @@ export class EventsDisplay extends LitElement implements Layer { } onBrokeAllianceEvent(update: BrokeAllianceUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; @@ -341,7 +339,7 @@ export class EventsDisplay extends LitElement implements Layer { } onAllianceExpiredEvent(update: AllianceExpiredUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const otherID = @@ -365,7 +363,7 @@ export class EventsDisplay extends LitElement implements Layer { onTargetPlayerEvent(event: TargetPlayerUpdate) { const other = this.game.playerBySmallID(event.playerID) as PlayerView; - const myPlayer = this.game.playerByClientID(this.clientID) as PlayerView; + const myPlayer = this.game.myPlayer() as PlayerView; if (!myPlayer || !myPlayer.isFriendly(other)) return; const target = this.game.playerBySmallID(event.targetID) as PlayerView; @@ -380,13 +378,13 @@ export class EventsDisplay extends LitElement implements Layer { } emitCancelAttackIntent(id: string) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; - this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id)); + this.eventBus.emit(new CancelAttackIntentEvent(id)); } emitBoatCancelIntent(id: number) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; this.eventBus.emit(new CancelBoatIntentEvent(id)); } @@ -406,7 +404,7 @@ export class EventsDisplay extends LitElement implements Layer { } onEmojiMessageEvent(update: EmojiUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const recipient = @@ -441,7 +439,7 @@ export class EventsDisplay extends LitElement implements Layer { } onUnitIncomingEvent(event: UnitIncomingUpdate) { - const myPlayer = this.game.playerByClientID(this.clientID); + const myPlayer = this.game.myPlayer(); if (!myPlayer || myPlayer.smallID() !== event.playerID) { return; diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts new file mode 100644 index 000000000..5c1e206d9 --- /dev/null +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -0,0 +1,47 @@ +import { LitElement, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { translateText } from "../../Utils"; +import { Layer } from "./Layer"; + +@customElement("heads-up-message") +export class HeadsUpMessage extends LitElement implements Layer { + public game: GameView; + + @state() + private isVisible = false; + + createRenderRoot() { + return this; + } + + init() { + this.isVisible = true; + this.requestUpdate(); + } + + tick() { + if (!this.game.inSpawnPhase()) { + this.isVisible = false; + this.requestUpdate(); + } + } + + render() { + if (!this.isVisible) { + return html``; + } + + return html` +
    e.preventDefault()} + > + ${translateText("heads_up_message.choose_spawn")} +
    + `; + } +} diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index fb22697b4..1d9dbb35d 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -4,7 +4,6 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { translateText } from "../../../client/Utils"; import { EventBus, GameEvent } from "../../../core/EventBus"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; @@ -36,7 +35,6 @@ export class GoToUnitEvent implements GameEvent { @customElement("leader-board") export class Leaderboard extends LitElement implements Layer { public game: GameView | null = null; - public clientID: ClientID | null = null; public eventBus: EventBus | null = null; players: Entry[] = []; @@ -46,6 +44,12 @@ export class Leaderboard extends LitElement implements Layer { private _shownOnInit = false; private showTopFive = true; + @state() + private _sortKey: "tiles" | "gold" | "troops" = "tiles"; + + @state() + private _sortOrder: "asc" | "desc" = "desc"; + init() {} tick() { @@ -64,18 +68,39 @@ export class Leaderboard extends LitElement implements Layer { } } + private setSort(key: "tiles" | "gold" | "troops") { + if (this._sortKey === key) { + this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc"; + } else { + this._sortKey = key; + this._sortOrder = "desc"; + } + this.updateLeaderboard(); + } + private updateLeaderboard() { if (this.game === null) throw new Error("Not initialized"); - if (this.clientID === null) { - return; - } - const myPlayer = - this.game.playerViews().find((p) => p.clientID() === this.clientID) ?? - null; + const myPlayer = this.game.myPlayer(); - const sorted = this.game - .playerViews() - .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + let sorted = this.game.playerViews(); + + const compare = (a: number, b: number) => + this._sortOrder === "asc" ? a - b : b - a; + + switch (this._sortKey) { + case "gold": + sorted = sorted.sort((a, b) => + compare(Number(a.gold()), Number(b.gold())), + ); + break; + case "troops": + sorted = sorted.sort((a, b) => compare(a.troops(), b.troops())); + break; + default: + sorted = sorted.sort((a, b) => + compare(a.numTilesOwned(), b.numTilesOwned()), + ); + } const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); @@ -181,6 +206,8 @@ export class Leaderboard extends LitElement implements Layer { th { background-color: rgb(31 41 55 / 0.5); color: white; + cursor: pointer; + user-select: none; } .myPlayer { font-weight: bold; @@ -282,9 +309,30 @@ export class Leaderboard extends LitElement implements Layer { ${translateText("leaderboard.rank")} ${translateText("leaderboard.player")} - ${translateText("leaderboard.owned")} - ${translateText("leaderboard.gold")} - ${translateText("leaderboard.troops")} + this.setSort("tiles")}> + ${translateText("leaderboard.owned")} + ${this._sortKey === "tiles" + ? this._sortOrder === "asc" + ? "โฌ†๏ธ" + : "โฌ‡๏ธ" + : ""} + + this.setSort("gold")}> + ${translateText("leaderboard.gold")} + ${this._sortKey === "gold" + ? this._sortOrder === "asc" + ? "โฌ†๏ธ" + : "โฌ‡๏ธ" + : ""} + + this.setSort("troops")}> + ${translateText("leaderboard.troops")} + ${this._sortKey === "troops" + ? this._sortOrder === "asc" + ? "โฌ†๏ธ" + : "โฌ‡๏ธ" + : ""} + diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts new file mode 100644 index 000000000..739815f3c --- /dev/null +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -0,0 +1,285 @@ +import { LitElement } from "lit"; +import { customElement } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { PlayerActions, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { BuildMenu } from "./BuildMenu"; +import { ChatIntegration } from "./ChatIntegration"; +import { EmojiTable } from "./EmojiTable"; +import { Layer } from "./Layer"; +import { MenuEventManager } from "./MenuEventManager"; +import { PlayerActionHandler } from "./PlayerActionHandler"; +import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; +import { PlayerPanel } from "./PlayerPanel"; +import { RadialMenu, RadialMenuConfig } from "./RadialMenu"; +import { + COLORS, + MenuElementParams, + Slot, + createRadialMenuItems, + getRootMenuItems, + updateCenterButton, +} from "./RadialMenuElements"; + +import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; +import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import infoIcon from "../../../../resources/images/InfoIcon.svg"; +import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; + +@customElement("main-radial-menu") +export class MainRadialMenu extends LitElement implements Layer { + private radialMenu: RadialMenu; + private lastTickRefresh: number = 0; + private tickRefreshInterval: number = 500; + private needsRefresh: boolean = false; + + private playerActionHandler: PlayerActionHandler; + private menuEventManager: MenuEventManager; + private chatIntegration: ChatIntegration; + + constructor( + private eventBus: EventBus, + private game: GameView, + private transformHandler: TransformHandler, + private emojiTable: EmojiTable, + private buildMenu: BuildMenu, + private uiState: UIState, + private playerInfoOverlay: PlayerInfoOverlay, + private playerPanel: PlayerPanel, + ) { + super(); + + const menuConfig: RadialMenuConfig = { + centerButtonIcon: swordIcon, + tooltipStyle: ` + .radial-tooltip .cost { + margin-top: 4px; + color: ${COLORS.tooltip.cost}; + } + .radial-tooltip .count { + color: ${COLORS.tooltip.count}; + } + `, + }; + + this.radialMenu = new RadialMenu(menuConfig); + + this.playerActionHandler = new PlayerActionHandler( + this.eventBus, + this.uiState, + ); + + this.menuEventManager = new MenuEventManager( + this.eventBus, + this.game, + this.transformHandler, + this.radialMenu, + this.buildMenu, + this.emojiTable, + this.playerInfoOverlay, + this.playerPanel, + ); + + this.chatIntegration = new ChatIntegration(this.game, this.eventBus); + + this.radialMenu.setRootMenuItems(getRootMenuItems()); + } + + init() { + this.radialMenu.init(); + + this.menuEventManager.setContextMenuCallback((myPlayer, tile, actions) => { + this.handlePlayerActions(myPlayer, actions, tile); + }); + + this.menuEventManager.init(); + } + + private async handlePlayerActions( + myPlayer: PlayerView, + actions: PlayerActions, + tile: TileRef, + ) { + this.buildMenu.playerActions = actions; + + const tileOwner = this.game.owner(tile); + const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + + if (myPlayer && recipient) { + this.chatIntegration.setupChatModal(myPlayer, recipient); + } + + const params: MenuElementParams = { + myPlayer, + selected: recipient, + tileOwner, + tile, + playerActions: actions, + game: this.game, + buildMenu: this.buildMenu, + emojiTable: this.emojiTable, + playerActionHandler: this.playerActionHandler, + playerPanel: this.playerPanel, + chatIntegration: this.chatIntegration, + closeMenu: () => this.menuEventManager.closeMenu(), + }; + + const menuItems = createRadialMenuItems(params); + + this.radialMenu.setRootMenuItems(menuItems); + + updateCenterButton(params, (enabled, action) => { + this.radialMenu.enableCenterButton(enabled, action); + }); + } + + async tick() { + const clickedCell = this.menuEventManager.getClickedCell(); + if (!this.radialMenu.isMenuVisible() || clickedCell === null) return; + + const currentTime = new Date().getTime(); + if ( + currentTime - this.lastTickRefresh < this.tickRefreshInterval && + !this.needsRefresh + ) { + return; + } + + const myPlayer = this.game.myPlayer(); + if (myPlayer === null || !myPlayer.isAlive()) return; + + const tile = this.game.ref(clickedCell.x, clickedCell.y); + + const isSpawnPhase = this.game.inSpawnPhase(); + const wasInSpawnPhase = this.menuEventManager.getWasInSpawnPhase(); + + if (wasInSpawnPhase !== isSpawnPhase) { + if (wasInSpawnPhase && !isSpawnPhase) { + this.needsRefresh = true; + this.menuEventManager.setWasInSpawnPhase(isSpawnPhase); + + const actions = await this.playerActionHandler.getPlayerActions( + myPlayer, + tile, + ); + this.updateMenuState(myPlayer, actions, tile); + this.radialMenu.refreshMenu(); + return; + } + + this.menuEventManager.closeMenu(); + return; + } + + // Check if tile ownership has changed + const originalTileOwner = this.menuEventManager.getOriginalTileOwner(); + if (originalTileOwner && originalTileOwner.isPlayer()) { + if (this.game.owner(tile) !== originalTileOwner) { + this.menuEventManager.closeMenu(); + return; + } + } else if (originalTileOwner) { + if ( + this.game.owner(tile).isPlayer() || + this.game.owner(tile) === myPlayer + ) { + this.menuEventManager.closeMenu(); + return; + } + } + + this.lastTickRefresh = currentTime; + this.needsRefresh = false; + + const actions = await this.playerActionHandler.getPlayerActions( + myPlayer, + tile, + ); + this.updateMenuState(myPlayer, actions, tile); + } + + private updateMenuState( + myPlayer: PlayerView, + actions: PlayerActions, + tile: TileRef, + ) { + if (!this.radialMenu.isMenuVisible()) return; + + const tileOwner = this.game.owner(tile); + const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + + const params: MenuElementParams = { + myPlayer, + selected: recipient, + tileOwner, + tile, + playerActions: actions, + game: this.game, + buildMenu: this.buildMenu, + emojiTable: this.emojiTable, + playerActionHandler: this.playerActionHandler, + playerPanel: this.playerPanel, + chatIntegration: this.chatIntegration, + closeMenu: () => this.menuEventManager.closeMenu(), + }; + + if (this.radialMenu.getCurrentLevel() === 0) { + updateCenterButton(params, (enabled, action) => { + this.radialMenu.enableCenterButton(enabled, action); + }); + } + + const canBuildTransport = actions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + )?.canBuild; + + this.radialMenu.updateMenuItem( + Slot.Build, + !this.game.inSpawnPhase(), + COLORS.build, + buildIcon, + ); + + if (actions?.interaction?.canSendAllianceRequest) { + this.radialMenu.updateMenuItem(Slot.Ally, true, COLORS.ally, undefined); + } else if (actions?.interaction?.canBreakAlliance) { + this.radialMenu.updateMenuItem( + Slot.Ally, + true, + COLORS.breakAlly, + undefined, + ); + } else { + this.radialMenu.updateMenuItem(Slot.Ally, false, undefined, undefined); + } + + this.radialMenu.updateMenuItem( + Slot.Boat, + !!canBuildTransport, + COLORS.boat, + boatIcon, + ); + + this.radialMenu.updateMenuItem( + Slot.Info, + this.game.hasOwner(tile), + COLORS.info, + infoIcon, + ); + } + + renderLayer(context: CanvasRenderingContext2D) { + this.radialMenu.renderLayer(context); + } + + shouldTransform(): boolean { + return this.radialMenu.shouldTransform(); + } + + redraw() { + // No redraw implementation needed + } +} diff --git a/src/client/graphics/layers/MenuEventManager.ts b/src/client/graphics/layers/MenuEventManager.ts new file mode 100644 index 000000000..1104529b2 --- /dev/null +++ b/src/client/graphics/layers/MenuEventManager.ts @@ -0,0 +1,185 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { + CloseViewEvent, + ContextMenuEvent, + MouseUpEvent, + ShowBuildMenuEvent, +} from "../../InputHandler"; +import { SendSpawnIntentEvent } from "../../Transport"; +import { TransformHandler } from "../TransformHandler"; +import { BuildMenu } from "./BuildMenu"; +import { EmojiTable } from "./EmojiTable"; +import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; +import { PlayerPanel } from "./PlayerPanel"; +import { RadialMenu } from "./RadialMenu"; + +export type ContextMenuCallback = ( + myPlayer: PlayerView, + tile: TileRef, + actions: PlayerActions, +) => void; + +export class MenuEventManager { + private clickedCell: Cell | null = null; + private lastClosed: number = 0; + private originalTileOwner: PlayerView | TerraNullius | null = null; + private wasInSpawnPhase: boolean = false; + private onContextMenuCallback: ContextMenuCallback | null = null; + + constructor( + private eventBus: EventBus, + private game: GameView, + private transformHandler: TransformHandler, + private radialMenu: RadialMenu, + private buildMenu: BuildMenu, + private emojiTable: EmojiTable, + private playerInfoOverlay: PlayerInfoOverlay, + private playerPanel: PlayerPanel, + ) {} + + init() { + this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e)); + this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e)); + this.eventBus.on(CloseViewEvent, () => this.closeMenu()); + this.eventBus.on(ShowBuildMenuEvent, (e) => this.onShowBuildMenu(e)); + } + + setContextMenuCallback(callback: ContextMenuCallback) { + this.onContextMenuCallback = callback; + } + + onContextMenu(event: ContextMenuEvent): Cell | null { + if (this.lastClosed + 200 > new Date().getTime()) return null; + + this.closeMenu(); + + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + return null; + } else { + this.radialMenu.showRadialMenu(event.x, event.y); + } + + this.radialMenu.disableAllButtons(); + this.clickedCell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + + if ( + !this.clickedCell || + !this.game.isValidCoord(this.clickedCell.x, this.clickedCell.y) + ) { + return null; + } + + const tile = this.game.ref(this.clickedCell.x, this.clickedCell.y); + this.originalTileOwner = this.game.owner(tile); + this.wasInSpawnPhase = this.game.inSpawnPhase(); + + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) { + throw new Error("my player not found"); + } + + if (myPlayer && !myPlayer.isAlive() && !this.game.inSpawnPhase()) { + this.radialMenu.hideRadialMenu(); + return null; + } + + if (this.game.inSpawnPhase()) { + if (this.game.isLand(tile) && !this.game.hasOwner(tile)) { + this.radialMenu.enableCenterButton(true, () => { + if (this.clickedCell === null) return; + this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell)); + this.radialMenu.hideRadialMenu(); + }); + + return this.clickedCell; + } + } + + myPlayer.actions(tile).then((actions) => { + if (this.onContextMenuCallback) { + this.onContextMenuCallback(myPlayer, tile, actions); + } + }); + + return this.clickedCell; + } + + getClickedCell(): Cell | null { + return this.clickedCell; + } + + getOriginalTileOwner(): PlayerView | TerraNullius | null { + return this.originalTileOwner; + } + + getWasInSpawnPhase(): boolean { + return this.wasInSpawnPhase; + } + + setWasInSpawnPhase(value: boolean) { + this.wasInSpawnPhase = value; + } + + onPointerUp(event: MouseUpEvent) { + this.playerInfoOverlay.hide(); + this.hideEverything(); + } + + onShowBuildMenu(e: ShowBuildMenuEvent): TileRef | null { + const clickedCell = this.transformHandler.screenToWorldCoordinates( + e.x, + e.y, + ); + if (clickedCell === null) { + return null; + } + if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) { + return null; + } + const tile = this.game.ref(clickedCell.x, clickedCell.y); + const p = this.game.myPlayer(); + if (p === null) { + return null; + } + this.buildMenu.showMenu(tile); + return tile; + } + + closeMenu() { + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + } + + if (this.buildMenu.isVisible) { + this.buildMenu.hideMenu(); + } + + if (this.emojiTable.isVisible) { + this.emojiTable.hideTable(); + } + + if (this.playerPanel.isVisible) { + this.playerPanel.hide(); + } + } + + hideEverything() { + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + this.lastClosed = new Date().getTime(); + } + this.emojiTable.hideTable(); + this.buildMenu.hideMenu(); + } + + enableCenterButton(enabled: boolean, action: () => void) { + this.radialMenu.enableCenterButton(enabled, action); + } +} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 08b3ef2e4..23f596c01 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,18 +1,20 @@ import allianceIcon from "../../../../resources/images/AllianceIcon.svg"; -import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg"; +import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg"; +import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg"; import crownIcon from "../../../../resources/images/CrownIcon.svg"; import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg"; -import embargoIcon from "../../../../resources/images/EmbargoIcon.svg"; +import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg"; +import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg"; import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg"; import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg"; import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { PseudoRandom } from "../../../core/PseudoRandom"; -import { ClientID } from "../../../core/Schemas"; import { Theme } from "../../../core/configuration/Config"; import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -40,23 +42,24 @@ export class NameLayer implements Layer { private seenPlayers: Set = new Set(); private traitorIconImage: HTMLImageElement; private disconnectedIconImage: HTMLImageElement; - private allianceRequestIconImage: HTMLImageElement; + private allianceRequestBlackIconImage: HTMLImageElement; + private allianceRequestWhiteIconImage: HTMLImageElement; private allianceIconImage: HTMLImageElement; private targetIconImage: HTMLImageElement; private crownIconImage: HTMLImageElement; - private embargoIconImage: HTMLImageElement; + private embargoBlackIconImage: HTMLImageElement; + private embargoWhiteIconImage: HTMLImageElement; private nukeWhiteIconImage: HTMLImageElement; private nukeRedIconImage: HTMLImageElement; private shieldIconImage: HTMLImageElement; private container: HTMLDivElement; - private myPlayer: PlayerView | null = null; private firstPlace: PlayerView | null = null; private theme: Theme = this.game.config().theme(); + private userSettings: UserSettings = new UserSettings(); constructor( private game: GameView, private transformHandler: TransformHandler, - private clientID: ClientID, ) { this.traitorIconImage = new Image(); this.traitorIconImage.src = traitorIcon; @@ -64,14 +67,18 @@ export class NameLayer implements Layer { this.disconnectedIconImage.src = disconnectedIcon; this.allianceIconImage = new Image(); this.allianceIconImage.src = allianceIcon; - this.allianceRequestIconImage = new Image(); - this.allianceRequestIconImage.src = allianceRequestIcon; + this.allianceRequestBlackIconImage = new Image(); + this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon; + this.allianceRequestWhiteIconImage = new Image(); + this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon; this.crownIconImage = new Image(); this.crownIconImage.src = crownIcon; this.targetIconImage = new Image(); this.targetIconImage.src = targetIcon; - this.embargoIconImage = new Image(); - this.embargoIconImage.src = embargoIcon; + this.embargoBlackIconImage = new Image(); + this.embargoBlackIconImage.src = embargoBlackIcon; + this.embargoWhiteIconImage = new Image(); + this.embargoWhiteIconImage.src = embargoWhiteIcon; this.nukeWhiteIconImage = new Image(); this.nukeWhiteIconImage.src = nukeWhiteIcon; this.nukeRedIconImage = new Image(); @@ -218,18 +225,32 @@ export class NameLayer implements Layer { troopsDiv.style.marginTop = "-5%"; element.appendChild(troopsDiv); - const shieldDiv = document.createElement("div"); - shieldDiv.classList.add("player-shield"); - shieldDiv.style.zIndex = "3"; - shieldDiv.style.marginTop = "-5%"; - shieldDiv.style.display = "flex"; - shieldDiv.style.alignItems = "center"; - shieldDiv.style.gap = "0px"; - shieldDiv.innerHTML = ` - - 0 - `; - element.appendChild(shieldDiv); + // TODO: Remove the shield icon. + /* eslint-disable no-constant-condition */ + if (false) { + const shieldDiv = document.createElement("div"); + shieldDiv.classList.add("player-shield"); + shieldDiv.style.zIndex = "3"; + shieldDiv.style.marginTop = "-5%"; + shieldDiv.style.display = "flex"; + shieldDiv.style.alignItems = "center"; + shieldDiv.style.gap = "0px"; + const shieldImg = document.createElement("img"); + shieldImg.src = this.shieldIconImage.src; + shieldImg.style.width = "16px"; + shieldImg.style.height = "16px"; + + const shieldSpan = document.createElement("span"); + shieldSpan.textContent = "0"; + shieldSpan.style.color = "black"; + shieldSpan.style.fontSize = "10px"; + shieldSpan.style.marginTop = "-2px"; + + shieldDiv.appendChild(shieldImg); + shieldDiv.appendChild(shieldSpan); + element.appendChild(shieldDiv); + } + /* eslint-enable no-constant-condition */ // Start off invisible so it doesn't flash at 0,0 element.style.display = "none"; @@ -298,11 +319,10 @@ export class NameLayer implements Layer { const density = renderNumber( render.player.troops() / render.player.numTilesOwned(), ); - const shieldDiv = render.element.querySelector( - ".player-shield", - ) as HTMLDivElement; - const shieldImg = shieldDiv.querySelector("img"); - const shieldNumber = shieldDiv.querySelector("span"); + const shieldDiv: HTMLDivElement | null = + render.element.querySelector(".player-shield"); + const shieldImg = shieldDiv?.querySelector("img"); + const shieldNumber = shieldDiv?.querySelector("span"); if (shieldImg) { shieldImg.style.width = `${render.fontSize * 0.8}px`; shieldImg.style.height = `${render.fontSize * 0.8}px`; @@ -318,7 +338,8 @@ export class NameLayer implements Layer { ".player-icons", ) as HTMLDivElement; const iconSize = Math.min(render.fontSize * 1.5, 48); - const myPlayer = this.getPlayer(); + const myPlayer = this.game.myPlayer(); + const isDarkMode = this.userSettings.darkMode(); // Crown icon const existingCrown = iconsDiv.querySelector('[data-icon="crown"]'); @@ -388,13 +409,27 @@ export class NameLayer implements Layer { } // Alliance request icon - const data = '[data-icon="alliance-request"]'; - const existingRequestAlliance = iconsDiv.querySelector(data); + let existingRequestAlliance = iconsDiv.querySelector( + '[data-icon="alliance-request"]', + ); + const isThemeAllianceRequestIcon = + existingRequestAlliance?.getAttribute("dark-mode") === + isDarkMode.toString(); + const AllianceRequestIconImageSrc = isDarkMode + ? this.allianceRequestWhiteIconImage.src + : this.allianceRequestBlackIconImage.src; + if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) { + // Create new icon to match theme + if (existingRequestAlliance && !isThemeAllianceRequestIcon) { + existingRequestAlliance.remove(); + existingRequestAlliance = null; + } + if (!existingRequestAlliance) { iconsDiv.appendChild( this.createIconElement( - this.allianceRequestIconImage.src, + AllianceRequestIconImageSrc, iconSize, "alliance-request", ), @@ -449,19 +484,28 @@ export class NameLayer implements Layer { existingEmoji.remove(); } - const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]'); + // Embargo icon + let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]'); const hasEmbargo = myPlayer && (render.player.hasEmbargoAgainst(myPlayer) || myPlayer.hasEmbargoAgainst(render.player)); + const isThemeEmbargoIcon = + existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString(); + const embargoIconImageSrc = isDarkMode + ? this.embargoWhiteIconImage.src + : this.embargoBlackIconImage.src; + if (myPlayer && hasEmbargo) { + // Create new icon to match theme + if (existingEmbargo && !isThemeEmbargoIcon) { + existingEmbargo.remove(); + existingEmbargo = null; + } + if (!existingEmbargo) { iconsDiv.appendChild( - this.createIconElement( - this.embargoIconImage.src, - iconSize, - "embargo", - ), + this.createIconElement(embargoIconImageSrc, iconSize, "embargo"), ); } } else if (existingEmbargo) { @@ -535,6 +579,7 @@ export class NameLayer implements Layer { icon.style.width = `${size}px`; icon.style.height = `${size}px`; icon.setAttribute("data-icon", id); + icon.setAttribute("dark-mode", this.userSettings.darkMode().toString()); if (center) { icon.style.position = "absolute"; icon.style.top = "50%"; @@ -542,14 +587,4 @@ export class NameLayer implements Layer { } return icon; } - - private getPlayer(): PlayerView | null { - if (this.myPlayer !== null) { - return this.myPlayer; - } - this.myPlayer = - this.game.playerViews().find((p) => p.clientID() === this.clientID) ?? - null; - return this.myPlayer; - } } diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts new file mode 100644 index 000000000..b8bb154bf --- /dev/null +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -0,0 +1,109 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell, PlayerActions, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { PlayerView } from "../../../core/game/GameView"; +import { + BuildUnitIntentEvent, + SendAllianceRequestIntentEvent, + SendAttackIntentEvent, + SendBoatAttackIntentEvent, + SendBreakAllianceIntentEvent, + SendDonateGoldIntentEvent, + SendDonateTroopsIntentEvent, + SendEmbargoIntentEvent, + SendEmojiIntentEvent, + SendQuickChatEvent, + SendSpawnIntentEvent, + SendTargetPlayerIntentEvent, +} from "../../Transport"; +import { UIState } from "../UIState"; + +export class PlayerActionHandler { + constructor( + private eventBus: EventBus, + private uiState: UIState, + ) {} + + async getPlayerActions( + player: PlayerView, + tile: TileRef, + ): Promise { + return await player.actions(tile); + } + + handleAttack(player: PlayerView, targetId: string | null) { + this.eventBus.emit( + new SendAttackIntentEvent( + targetId, + this.uiState.attackRatio * player.troops(), + ), + ); + } + + handleBoatAttack( + player: PlayerView, + targetId: string, + targetCell: Cell, + spawnTile: Cell | null, + ) { + this.eventBus.emit( + new SendBoatAttackIntentEvent( + targetId, + targetCell, + this.uiState.attackRatio * player.troops(), + spawnTile, + ), + ); + } + + async findBestTransportShipSpawn( + player: PlayerView, + tile: TileRef, + ): Promise { + return await player.bestTransportShipSpawn(tile); + } + + handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) { + this.eventBus.emit( + new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)), + ); + } + + handleSpawn(spawnCell: Cell) { + this.eventBus.emit(new SendSpawnIntentEvent(spawnCell)); + } + + handleAllianceRequest(player: PlayerView, recipient: PlayerView) { + this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient)); + } + + handleBreakAlliance(player: PlayerView, recipient: PlayerView) { + this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient)); + } + + handleTargetPlayer(targetId: string | null) { + if (!targetId) return; + + this.eventBus.emit(new SendTargetPlayerIntentEvent(targetId)); + } + + handleDonateGold(recipient: PlayerView) { + this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null)); + } + + handleDonateTroops(recipient: PlayerView) { + this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, null)); + } + + handleEmbargo(recipient: PlayerView, action: "start" | "stop") { + this.eventBus.emit(new SendEmbargoIntentEvent(recipient, action)); + } + + handleEmoji(targetPlayer: PlayerView | "AllPlayers", emojiIndex: number) { + this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex)); + } + + handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) { + this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params)); + } +} diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 394669f81..099a865e2 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -11,7 +11,6 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { MouseMoveEvent } from "../../InputHandler"; import { renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; @@ -42,9 +41,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { @property({ type: Object }) public game!: GameView; - @property({ type: String }) - public clientID!: ClientID; - @property({ type: Object }) public eventBus!: EventBus; @@ -137,13 +133,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.requestUpdate(); } - private myPlayer(): PlayerView | null { - if (!this.game) { - return null; - } - return this.game.playerByClientID(this.clientID); - } - private getRelationClass(relation: Relation): string { switch (relation) { case Relation.Hostile: @@ -175,7 +164,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { } private renderPlayerInfo(player: PlayerView) { - const myPlayer = this.myPlayer(); + const myPlayer = this.game.myPlayer(); const isFriendly = myPlayer?.isFriendly(player); let relationHtml: TemplateResult | null = null; const attackingTroops = player @@ -212,7 +201,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html`
    @@ -275,8 +264,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private renderUnitInfo(unit: UnitView) { const isAlly = - (unit.owner() === this.myPlayer() || - this.myPlayer()?.isFriendly(unit.owner())) ?? + (unit.owner() === this.game.myPlayer() || + this.game.myPlayer()?.isFriendly(unit.owner())) ?? false; return html` diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 4e82be5bb..b9804d2d1 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -40,7 +40,7 @@ export class PlayerPanel extends LitElement implements Layer { private tile: TileRef | null = null; @state() - private isVisible: boolean = false; + public isVisible: boolean = false; @state() private allianceExpiryText: string | null = null; @@ -90,7 +90,6 @@ export class PlayerPanel extends LitElement implements Layer { e.stopPropagation(); this.eventBus.emit( new SendDonateTroopsIntentEvent( - myPlayer, other, myPlayer.troops() * this.uiState.attackRatio, ), @@ -104,7 +103,7 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendDonateGoldIntentEvent(myPlayer, other, null)); + this.eventBus.emit(new SendDonateGoldIntentEvent(other, null)); this.hide(); } @@ -114,7 +113,7 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start")); + this.eventBus.emit(new SendEmbargoIntentEvent(other, "start")); this.hide(); } @@ -124,7 +123,7 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop")); + this.eventBus.emit(new SendEmbargoIntentEvent(other, "stop")); this.hide(); } @@ -330,6 +329,25 @@ export class PlayerPanel extends LitElement implements Layer {
    + +
    +
    + ${translateText("player_panel.alliances")} + (${other.allies().length}) +
    +
    + ${other.allies().length > 0 + ? other + .allies() + .map((p) => p.name()) + .join(", ") + : translateText("player_panel.none")} +
    +
    + ${this.allianceExpiryText !== null ? html`
    diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index db79c507e..0ce75f91e 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -1,264 +1,166 @@ import * as d3 from "d3"; -import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; -import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; -import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import backIcon from "../../../../resources/images/BackIconWhite.svg"; import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; -import infoIcon from "../../../../resources/images/InfoIcon.svg"; -import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; -import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; -import { consolex } from "../../../core/Consolex"; -import { EventBus } from "../../../core/EventBus"; -import { - Cell, - PlayerActions, - TerraNullius, - UnitType, -} from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; -import { - CloseViewEvent, - ContextMenuEvent, - MouseUpEvent, - ShowBuildMenuEvent, -} from "../../InputHandler"; -import { - SendAllianceRequestIntentEvent, - SendAttackIntentEvent, - SendBoatAttackIntentEvent, - SendBreakAllianceIntentEvent, - SendSpawnIntentEvent, -} from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { BuildMenu } from "./BuildMenu"; -import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; -import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; -import { PlayerPanel } from "./PlayerPanel"; +import { MenuElement } from "./RadialMenuElements"; -enum Slot { - Info, - Boat, - Build, - Ally, +export interface TooltipItem { + text: string; + className: string; } +export interface RadialMenuConfig { + menuSize?: number; + submenuScale?: number; + centerButtonSize?: number; + iconSize?: number; + centerIconSize?: number; + disabledColor?: string; + menuTransitionDuration?: number; + mainMenuInnerRadius?: number; + centerButtonIcon?: string; + maxNestedLevels?: number; + innerRadiusIncrement?: number; + tooltipStyle?: string; +} + +type RequiredRadialMenuConfig = Required; + export class RadialMenu implements Layer { - private clickedCell: Cell | null = null; - private lastClosed: number = 0; - - private originalTileOwner: PlayerView | TerraNullius; private menuElement: d3.Selection; + private tooltipElement: HTMLDivElement | null = null; private isVisible: boolean = false; - private readonly menuItems: Map< - Slot, - { - name: string; - disabled: boolean; - action: () => void; - color?: string | null; - icon?: string | null; - } - > = new Map([ - [ - Slot.Boat, - { - name: "boat", - disabled: true, - action: () => {}, - color: null, - icon: null, - }, - ], - [Slot.Ally, { name: "ally", disabled: true, action: () => {} }], - [Slot.Build, { name: "build", disabled: true, action: () => {} }], - [ - Slot.Info, - { - name: "info", - disabled: true, - action: () => {}, - color: null, - icon: null, - }, - ], - ]); - private readonly menuSize = 190; - private readonly centerButtonSize = 30; - private readonly iconSize = 32; - private readonly centerIconSize = 48; - private readonly disabledColor = d3.rgb(128, 128, 128).toString(); + private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.) + private menuStack: MenuElement[][] = []; // Stack to track menu navigation history + private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level) + private rootMenuItems: MenuElement[] = []; // Store the original root menu items + + private readonly config: RequiredRadialMenuConfig; + private readonly backIconSize: number; private isCenterButtonEnabled = false; + private originalCenterButtonEnabled = false; + private centerButtonAction: (() => void) | null = null; + private originalCenterButtonAction: (() => void) | null = null; + private backAction: (() => void) | null = null; - constructor( - private eventBus: EventBus, - private g: GameView, - private transformHandler: TransformHandler, - private clientID: ClientID, - private emojiTable: EmojiTable, - private buildMenu: BuildMenu, - private uiState: UIState, - private playerInfoOverlay: PlayerInfoOverlay, - private playerPanel: PlayerPanel, - ) {} + private isTransitioning: boolean = false; + private lastHideTime: number = 0; + private reopenCooldownMs: number = 300; - init() { - this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e)); - this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e)); - this.eventBus.on(ShowBuildMenuEvent, (e) => { - const clickedCell = this.transformHandler.screenToWorldCoordinates( - e.x, - e.y, - ); - if (clickedCell === null) { - return; - } - if (!this.g.isValidCoord(clickedCell.x, clickedCell.y)) { - return; - } - const tile = this.g.ref(clickedCell.x, clickedCell.y); - const p = this.g.playerByClientID(this.clientID); - if (p === null) { - return; - } - this.buildMenu.showMenu(tile); - }); + private menuGroups: Map< + number, + d3.Selection + > = new Map(); + private menuPaths: Map< + string, + d3.Selection + > = new Map(); + private menuIcons: Map< + string, + d3.Selection + > = new Map(); - this.eventBus.on(CloseViewEvent, () => this.closeMenu()); + private selectedItemId: string | null = null; + private submenuHoverTimeout: number | null = null; + private backButtonHoverTimeout: number | null = null; + private navigationInProgress: boolean = false; + private originalCenterButtonIcon: string = ""; - this.createMenuElement(); + constructor(config: RadialMenuConfig = {}) { + this.config = { + menuSize: config.menuSize ?? 190, + submenuScale: config.submenuScale ?? 1.5, + centerButtonSize: config.centerButtonSize ?? 30, + iconSize: config.iconSize ?? 32, + centerIconSize: config.centerIconSize ?? 48, + disabledColor: config.disabledColor ?? d3.rgb(128, 128, 128).toString(), + menuTransitionDuration: config.menuTransitionDuration ?? 300, + mainMenuInnerRadius: config.mainMenuInnerRadius ?? 40, + centerButtonIcon: config.centerButtonIcon ?? "", + maxNestedLevels: config.maxNestedLevels ?? 3, + innerRadiusIncrement: config.innerRadiusIncrement ?? 20, + tooltipStyle: config.tooltipStyle ?? "", + }; + this.originalCenterButtonIcon = this.config.centerButtonIcon; + this.backIconSize = this.config.centerIconSize * 0.8; } - private closeMenu() { - if (this.isVisible) { - this.hideRadialMenu(); - } - - if (this.buildMenu.isVisible) { - this.buildMenu.hideMenu(); - } + init() { + this.createMenuElement(); + this.createTooltipElement(); } private createMenuElement() { + // Create an overlay to catch clicks outside the menu this.menuElement = d3 .select(document.body) .append("div") + .attr("class", "radial-menu-container") .style("position", "fixed") .style("display", "none") .style("z-index", "9999") .style("touch-action", "none") + .style("top", "0") + .style("left", "0") + .style("width", "100vw") + .style("height", "100vh") + .on("click", () => { + this.hideRadialMenu(); + }) .on("contextmenu", (e) => { e.preventDefault(); this.hideRadialMenu(); }); + // Calculate the total svg size needed for all potential nested menus + const totalSize = + this.config.menuSize * + Math.pow(this.config.submenuScale, this.config.maxNestedLevels - 1); + const svg = this.menuElement .append("svg") - .attr("width", this.menuSize) - .attr("height", this.menuSize) + .attr("width", totalSize) + .attr("height", totalSize) + .style("position", "absolute") + .style("top", "50%") + .style("left", "50%") + .style("transform", "translate(-50%, -50%)") + .style("pointer-events", "all") + .on("click", (event) => this.hideRadialMenu()); + + const container = svg .append("g") - .attr( - "transform", - `translate(${this.menuSize / 2},${this.menuSize / 2})`, - ); + .attr("class", "menu-container") + .attr("transform", `translate(${totalSize / 2},${totalSize / 2})`); - const pie = d3 - .pie() - .value(() => 1) - .padAngle(0.03) - .startAngle(Math.PI / 4) // Start at 45 degrees (ฯ€/4 radians) - .endAngle(2 * Math.PI + Math.PI / 4); // Complete the circle but shifted by 45 degrees - - const arc = d3 - .arc() - .innerRadius(this.centerButtonSize + 5) - .outerRadius(this.menuSize / 2 - 10); - - const arcs = svg - .selectAll("path") - .data(pie(Array.from(this.menuItems.values()))) - .enter() - .append("g"); - - arcs - .append("path") - .attr("d", arc) - .attr("fill", (d) => - d.data.disabled ? this.disabledColor : d.data.color, - ) - .attr("stroke", "#ffffff") - .attr("stroke-width", "2") - .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) - .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) - .attr("data-name", (d) => d.data.name) - .on("mouseover", function (event, d) { - if (!d.data.disabled) { - d3.select(this) - .transition() - .duration(200) - .attr("transform", "scale(1.05)") - .attr("filter", "url(#glow)"); - } - }) - .on("mouseout", function (event, d) { - if (!d.data.disabled) { - d3.select(this) - .transition() - .duration(200) - .attr("transform", "scale(1)") - .attr("filter", null); - } - }) - .on("click", (event, d) => { - if (!d.data.disabled) { - d.data.action(); - this.hideRadialMenu(); - } - }) - .on("touchstart", (event, d) => { - event.preventDefault(); - if (!d.data.disabled) { - d.data.action(); - this.hideRadialMenu(); - } - }); - - arcs - .append("image") - .attr("xlink:href", (d) => d.data.icon) - .attr("width", this.iconSize) - .attr("height", this.iconSize) - .attr("x", (d) => arc.centroid(d)[0] - this.iconSize / 2) - .attr("y", (d) => arc.centroid(d)[1] - this.iconSize / 2) - .style("pointer-events", "none") - .attr("data-name", (d) => d.data.name); - - // Add glow filter + // Add glow filter for hover effects const defs = svg.append("defs"); const filter = defs.append("filter").attr("id", "glow"); filter .append("feGaussianBlur") - .attr("stdDeviation", "3") + .attr("stdDeviation", "2") .attr("result", "coloredBlur"); const feMerge = filter.append("feMerge"); feMerge.append("feMergeNode").attr("in", "coloredBlur"); feMerge.append("feMergeNode").attr("in", "SourceGraphic"); - const centerButton = svg.append("g").attr("class", "center-button"); + const centerButton = container.append("g").attr("class", "center-button"); centerButton .append("circle") .attr("class", "center-button-hitbox") - .attr("r", this.centerButtonSize) + .attr("r", this.config.centerButtonSize) .attr("fill", "transparent") .style("cursor", "pointer") - .on("click", () => this.handleCenterButtonClick()) + .on("click", (event) => { + event.stopPropagation(); + this.handleCenterButtonClick(); + }) .on("touchstart", (event: Event) => { event.preventDefault(); + event.stopPropagation(); this.handleCenterButtonClick(); }) .on("mouseover", () => this.onCenterButtonHover(true)) @@ -267,41 +169,878 @@ export class RadialMenu implements Layer { centerButton .append("circle") .attr("class", "center-button-visible") - .attr("r", this.centerButtonSize) + .attr("r", this.config.centerButtonSize) .attr("fill", "#2c3e50") .style("pointer-events", "none"); centerButton .append("image") .attr("class", "center-button-icon") - .attr("xlink:href", swordIcon) - .attr("width", this.centerIconSize) - .attr("height", this.centerIconSize) - .attr("x", -this.centerIconSize / 2) - .attr("y", -this.centerIconSize / 2) + .attr("xlink:href", this.config.centerButtonIcon) + .attr("width", this.config.centerIconSize) + .attr("height", this.config.centerIconSize) + .attr("x", -this.config.centerIconSize / 2) + .attr("y", -this.config.centerIconSize / 2) .style("pointer-events", "none"); } - async tick() { - // Only update when menu is visible - if (!this.isVisible || this.clickedCell === null) return; - const myPlayer = this.g.myPlayer(); - if (myPlayer === null || !myPlayer.isAlive()) return; - const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y); - if (this.originalTileOwner.isPlayer()) { - if (this.g.owner(tile) !== this.originalTileOwner) { - this.closeMenu(); - return; + private createTooltipElement() { + this.tooltipElement = document.createElement("div"); + this.tooltipElement.className = "radial-tooltip"; + this.tooltipElement.style.position = "absolute"; + this.tooltipElement.style.pointerEvents = "none"; + this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)"; + this.tooltipElement.style.color = "white"; + this.tooltipElement.style.padding = "6px 10px"; + this.tooltipElement.style.borderRadius = "6px"; + this.tooltipElement.style.fontSize = "12px"; + this.tooltipElement.style.zIndex = "10000"; + this.tooltipElement.style.maxWidth = "250px"; + this.tooltipElement.style.display = "none"; + document.body.appendChild(this.tooltipElement); + + const style = document.createElement("style"); + style.textContent = ` + .radial-tooltip .title { + font-weight: bold; + font-size: 14px; + margin-bottom: 4px; } + + ${this.config.tooltipStyle} + `; + document.head.appendChild(style); + } + + private getInnerRadiusForLevel(level: number): number { + return level === 0 + ? this.config.mainMenuInnerRadius + : this.config.mainMenuInnerRadius + 34; + } + + private getOuterRadiusForLevel(level: number): number { + const innerRadius = this.getInnerRadiusForLevel(level); + const arcWidth = + this.config.menuSize / 2 - this.config.mainMenuInnerRadius - 10; + return innerRadius + arcWidth; + } + + private renderMenuItems(items: MenuElement[], level: number) { + const container = this.menuElement.select(".menu-container"); + container.selectAll(`.menu-level-${level}`).remove(); + + const menuGroup = container + .append("g") + .attr("class", `menu-level-${level}`); + + // Set initial animation styles + if (level === 0) { + menuGroup.style("opacity", 0.5).style("transform", "scale(0.2)"); } else { - if (this.g.owner(tile).isPlayer() || this.g.owner(tile) === myPlayer) { - this.closeMenu(); + menuGroup.style("opacity", 0).style("transform", "scale(0.5)"); + } + + this.menuGroups.set(level, menuGroup as any); + + const pie = d3 + .pie() + .value(() => 1) + .padAngle(0.03) + .startAngle(Math.PI / 3) + .endAngle(2 * Math.PI + Math.PI / 3); + + const innerRadius = this.getInnerRadiusForLevel(level); + const outerRadius = this.getOuterRadiusForLevel(level); + + const arc = d3 + .arc>() + .innerRadius(innerRadius) + .outerRadius(outerRadius); + + const arcs = menuGroup + .selectAll(".menu-item") + .data(pie(items)) + .enter() + .append("g") + .attr("class", "menu-item-group"); + + this.renderPaths(arcs, arc, level); + this.setupEventHandlers(arcs, level); + this.renderIconsAndText(arcs, arc); + this.setupAnimations(menuGroup); + + return menuGroup; + } + + private renderPaths( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + arc: d3.Arc>, + level: number, + ) { + arcs + .append("path") + .attr("class", "menu-item-path") + .attr("d", arc) + .attr("fill", (d) => { + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + const opacity = d.data.disabled ? 0.5 : 0.7; + + if (d.data.id === this.selectedItemId && this.currentLevel > level) { + return color; + } + + return d3.color(color)?.copy({ opacity: opacity })?.toString() || color; + }) + .attr("stroke", "#ffffff") + .attr("stroke-width", "2") + .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) + .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) + .style( + "transition", + `filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${ + this.config.menuTransitionDuration / 2 + }ms, fill ${this.config.menuTransitionDuration / 2}ms`, + ) + .attr("data-id", (d) => d.data.id); + + arcs.each((d) => { + const pathId = d.data.id; + const path = d3.select(`path[data-id="${pathId}"]`); + this.menuPaths.set(pathId, path as any); + + if ( + pathId === this.selectedItemId && + level === 0 && + this.currentLevel > 0 + ) { + path.attr("filter", "url(#glow)"); + path.attr("stroke-width", "3"); + + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + path.attr("fill", color); + } + }); + + // Disable pointer events on previous menu levels + this.menuGroups.forEach((group, menuLevel) => { + if (menuLevel < this.currentLevel) { + group.selectAll("path").each(function () { + const pathElement = d3.select(this); + pathElement.style("pointer-events", "none"); + }); + } else if (menuLevel === this.currentLevel) { + group.selectAll("path").style("pointer-events", "auto"); + } + }); + } + + private setupEventHandlers( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + level: number, + ) { + const onHover = (d: d3.PieArcDatum, path: any) => { + if ( + d.data.disabled || + (this.currentLevel > 0 && this.currentLevel !== level) || + this.navigationInProgress + ) return; + + path.attr("filter", "url(#glow)"); + path.attr("stroke-width", "3"); + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + path.attr("fill", color); + + if (d.data.tooltipItems && d.data.tooltipItems.length > 0) { + this.showTooltip(d.data.tooltipItems); + } + + if ( + d.data.children && + d.data.children.length > 0 && + !d.data.disabled && + !( + this.currentLevel > 0 && + d.data.id === this.selectedItemId && + level === 0 + ) + ) { + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + } + + // Set a small delay before opening submenu to prevent accidental triggers + this.submenuHoverTimeout = window.setTimeout(() => { + if (this.navigationInProgress) return; + this.navigationInProgress = true; + this.selectedItemId = d.data.id; + this.navigateToSubMenu(d.data.children || []); + this.setCenterButtonAsBack(); + }, 200); + } + }; + + const onMouseOut = (d: d3.PieArcDatum, path: any) => { + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + this.submenuHoverTimeout = null; + } + + this.hideTooltip(); + + if ( + d.data.disabled || + (this.currentLevel > 0 && + level === 0 && + d.data.id === this.selectedItemId) + ) + return; + path.attr("filter", null); + path.attr("stroke-width", "2"); + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + const opacity = d.data.disabled ? 0.5 : 0.7; + path.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() || color, + ); + }; + + const onClick = (d: d3.PieArcDatum, event: Event) => { + event.stopPropagation(); + if (d.data.disabled || this.navigationInProgress) return; + + if ( + this.currentLevel > 0 && + level === 0 && + d.data.id !== this.selectedItemId + ) + return; + + if (d.data.children && d.data.children.length > 0) { + this.navigationInProgress = true; + this.selectedItemId = d.data.id; + this.navigateToSubMenu(d.data.children || []); + this.setCenterButtonAsBack(); + } else if (d.data._action) { + d.data._action(); + this.hideRadialMenu(); + } else { + throw new Error(`Menu item action is not a function: ${d.data.id}`); + } + }; + + function handleMouseMove(event: MouseEvent) { + const tooltipEl = document.querySelector( + ".radial-tooltip", + ) as HTMLElement; + if (tooltipEl && tooltipEl.style.display !== "none") { + tooltipEl.style.left = event.pageX + 10 + "px"; + tooltipEl.style.top = event.pageY + 10 + "px"; } } - const actions = await myPlayer.actions(tile); - this.disableAllButtons(); - this.handlePlayerActions(myPlayer, actions, tile); + + arcs.each((d) => { + const pathId = d.data.id; + const path = d3.select(`path[data-id="${pathId}"]`); + + path.on("mouseover", function () { + onHover(d, path); + }); + + path.on("mouseout", function () { + onMouseOut(d, path); + }); + + path.on("mousemove", function (event) { + handleMouseMove(event as MouseEvent); + }); + + path.on("click", function (event) { + onClick(d, event); + }); + + path.on("touchstart", function (event) { + event.preventDefault(); + event.stopPropagation(); + onClick(d, event); + }); + }); + } + + private renderIconsAndText( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + arc: d3.Arc>, + ) { + arcs + .append("g") + .attr("class", "menu-item-content") + .style("pointer-events", "none") + .attr("data-id", (d) => d.data.id) + .each((d) => { + const contentId = d.data.id; + const content = d3.select(`g[data-id="${contentId}"]`); + + if (d.data.text) { + content + .append("text") + .attr("text-anchor", "middle") + .attr("dominant-baseline", "central") + .attr("x", arc.centroid(d)[0]) + .attr("y", arc.centroid(d)[1]) + .attr("fill", "white") + .attr("font-size", d.data.fontSize ?? "12px") + .attr("font-family", "Arial, sans-serif") + .style("opacity", d.data.disabled ? 0.5 : 1) + .text(d.data.text); + } else { + content + .append("image") + .attr( + "xlink:href", + d.data.disabled ? disabledIcon : d.data.icon || disabledIcon, + ) + .attr("width", this.config.iconSize) + .attr("height", this.config.iconSize) + .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2) + .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2); + } + + this.menuIcons.set(contentId, content as any); + }); + } + + private setupAnimations( + menuGroup: d3.Selection, + ) { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("opacity", 1) + .style("transform", "scale(1)") + .on("start", () => { + this.isTransitioning = true; + }) + .on("end", () => { + this.isTransitioning = false; + }); + } + + private navigateToSubMenu(children: MenuElement[]) { + this.isTransitioning = true; + + this.menuStack.push(this.currentMenuItems); + this.currentMenuItems = children; + this.currentLevel++; + + this.renderMenuItems(this.currentMenuItems, this.currentLevel); + this.updateMenuGroupVisibility(); + this.animatePreviousMenu(); + } + + private updateMenuGroupVisibility() { + // Hide all menus except the current and immediate previous one + this.menuGroups.forEach((menuGroup, level) => { + if (level === this.currentLevel) { + menuGroup.style("display", "block"); + } else if (level === this.currentLevel - 1) { + menuGroup.style("display", "block"); + + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(0.59)") + .style("opacity", 0.8); + + menuGroup.selectAll("path").each(function () { + const pathElement = d3.select(this); + pathElement.style("pointer-events", "none"); + }); + } else { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.5) + .style("transform", "scale(0.5)") + .style("opacity", 0) + .on("end", function () { + d3.select(this).style("display", "none"); + }); + } + }); + } + + private animatePreviousMenu() { + const container = this.menuElement.select(".menu-container"); + const currentMenu = container.select( + `.menu-level-${this.currentLevel - 1}`, + ); + + currentMenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`) + .style("opacity", 0.8) + .on("end", () => { + this.navigationInProgress = false; + }); + } + + private navigateBack() { + if (this.menuStack.length === 0) { + return; + } + + this.isTransitioning = true; + + this.updateMenuLevels(); + this.clearSelectedItemHoverState(); + this.updateMenuVisibility(); + this.animateMenuTransitions(); + } + + private updateMenuLevels() { + const previousItems = this.menuStack.pop(); + const previousLevel = this.currentLevel - 1; + this.currentLevel = previousLevel; + + if (previousLevel === 0) { + this.selectedItemId = null; + } + + this.currentMenuItems = previousItems || []; + + if (this.currentLevel === 0) { + this.resetCenterButton(); + } + } + + private clearSelectedItemHoverState() { + // Clear the hover state on the item that opened the submenu + if (this.selectedItemId) { + const selectedPath = this.menuPaths.get(this.selectedItemId); + if (selectedPath) { + selectedPath.attr("filter", null); + selectedPath.attr("stroke-width", "2"); + + const item = this.findMenuItem(this.selectedItemId); + if (item) { + const color = item.disabled + ? this.config.disabledColor + : item.color || "#333333"; + const opacity = item.disabled ? 0.5 : 0.7; + selectedPath.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() || color, + ); + } + } + } + } + + private updateMenuVisibility() { + this.menuGroups.forEach((menuGroup, level) => { + if (level === this.currentLevel) { + menuGroup.style("display", "block"); + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1); + + menuGroup.selectAll("path").style("pointer-events", "auto"); + } else if (level === this.currentLevel - 1 && this.currentLevel > 0) { + menuGroup.style("display", "block"); + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style( + "transform", + `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`, + ) + .style("opacity", 0.8); + } else if (level !== this.currentLevel + 1) { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.5) + .style("opacity", 0) + .on("end", function () { + d3.select(this).style("display", "none"); + }); + } + }); + } + + private animateMenuTransitions() { + const container = this.menuElement.select(".menu-container"); + const currentSubmenu = container.select( + `.menu-level-${this.currentLevel + 1}`, + ); + const previousMenu = container.select(`.menu-level-${this.currentLevel}`); + + // Animate the current submenu (sliding out) + currentSubmenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(0.5)") + .style("opacity", 0) + .on("end", function () { + d3.select(this).remove(); + }); + + // Handle previous menu animation + if (previousMenu.empty()) { + this.renderAndAnimateNewMenu(); + } else { + this.animateExistingMenu(previousMenu); + } + } + + private renderAndAnimateNewMenu() { + const menu = this.renderMenuItems(this.currentMenuItems, this.currentLevel); + menu + .style("transform", "scale(0.8)") + .style("opacity", 0.3) + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1) + .on("end", () => { + this.isTransitioning = false; + this.navigationInProgress = false; + }); + } + + private animateExistingMenu( + previousMenu: d3.Selection, + ) { + previousMenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1) + .on("end", () => { + this.isTransitioning = false; + this.navigationInProgress = false; + }); + + previousMenu.selectAll("path").style("pointer-events", "auto"); + } + + private setCenterButtonAsBack() { + if (this.currentLevel === 1) { + this.originalCenterButtonEnabled = this.isCenterButtonEnabled; + this.originalCenterButtonAction = this.centerButtonAction; + } + + this.backAction = () => { + this.navigateBack(); + }; + + // Clear any hover state on the center button + this.menuElement + .select(".center-button-hitbox") + .transition() + .duration(0) + .attr("r", this.config.centerButtonSize); + this.menuElement + .select(".center-button-visible") + .transition() + .duration(0) + .attr("r", this.config.centerButtonSize); + + const backIconImg = this.menuElement.select(".center-button-icon"); + backIconImg + .attr("xlink:href", backIcon) + .attr("width", this.backIconSize) + .attr("height", this.backIconSize) + .attr("x", -this.backIconSize / 2) + .attr("y", -this.backIconSize / 2); + + this.enableCenterButton(true, this.backAction); + } + + private resetCenterButton() { + this.backAction = null; + + const iconImg = this.menuElement.select(".center-button-icon"); + iconImg + .attr("xlink:href", this.originalCenterButtonIcon) + .attr("width", this.config.centerIconSize) + .attr("height", this.config.centerIconSize) + .attr("x", -this.config.centerIconSize / 2) + .attr("y", -this.config.centerIconSize / 2); + + this.enableCenterButton( + this.originalCenterButtonEnabled, + this.originalCenterButtonAction, + ); + } + + public showRadialMenu(x: number, y: number) { + if (!this.isReopeningAllowed()) return; + + this.resetMenu(); + this.isTransitioning = false; + this.selectedItemId = null; + + this.menuElement.style("display", "block"); + + this.menuElement + .select("svg") + .style("top", `${y}px`) + .style("left", `${x}px`) + .style("transform", `translate(-50%, -50%)`); + + this.isVisible = true; + + this.renderMenuItems(this.currentMenuItems, this.currentLevel); + this.onCenterButtonHover(true); + } + + public hideRadialMenu() { + if (!this.isVisible || this.isTransitioning) { + return; + } + + this.menuElement.style("display", "none"); + this.isVisible = false; + this.selectedItemId = null; + this.hideTooltip(); + + this.resetMenu(); + this.isTransitioning = false; + + this.menuGroups.clear(); + this.menuPaths.clear(); + this.menuIcons.clear(); + + this.lastHideTime = Date.now(); + } + + private handleCenterButtonClick() { + if ( + !this.isCenterButtonEnabled || + !this.centerButtonAction || + this.navigationInProgress + ) { + return; + } + + if (this.currentLevel > 0 && this.backAction) { + this.navigationInProgress = true; + } + + this.centerButtonAction(); + } + + public disableAllButtons() { + this.originalCenterButtonEnabled = this.isCenterButtonEnabled; + this.originalCenterButtonAction = this.centerButtonAction; + + this.enableCenterButton(false); + + for (const item of this.currentMenuItems) { + item.disabled = true; + item.color = this.config.disabledColor; + } + } + + public enableCenterButton(enabled: boolean, action?: (() => void) | null) { + if (this.currentLevel > 0 && this.backAction) { + this.isCenterButtonEnabled = true; + + if (action !== undefined && action !== this.backAction) { + this.originalCenterButtonAction = action; + } + + this.centerButtonAction = this.backAction; + } else { + this.isCenterButtonEnabled = enabled; + if (action !== undefined) { + this.centerButtonAction = action; + } + } + + const centerButton = this.menuElement.select(".center-button"); + + centerButton + .select(".center-button-hitbox") + .style("cursor", this.isCenterButtonEnabled ? "pointer" : "not-allowed"); + + centerButton + .select(".center-button-visible") + .attr("fill", this.isCenterButtonEnabled ? "#2c3e50" : "#999999"); + + centerButton + .select(".center-button-icon") + .style("opacity", this.isCenterButtonEnabled ? 1 : 0.5); + } + + private onCenterButtonHover(isHovering: boolean) { + if (!this.isCenterButtonEnabled) return; + + const scale = isHovering ? 1.2 : 1; + + this.menuElement + .select(".center-button-hitbox") + .transition() + .duration(200) + .attr("r", this.config.centerButtonSize * scale); + + this.menuElement + .select(".center-button-visible") + .transition() + .duration(200) + .attr("r", this.config.centerButtonSize * scale); + + if (this.currentLevel > 0 && this.backAction) { + if (isHovering) { + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + } + + this.backButtonHoverTimeout = window.setTimeout(() => { + if (this.navigationInProgress || !this.backAction) return; + + this.navigationInProgress = true; + this.backAction(); + }, 300); + } else { + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + this.backButtonHoverTimeout = null; + } + } + } + } + + public isMenuVisible(): boolean { + return this.isVisible; + } + + public getCurrentLevel(): number { + return this.currentLevel; + } + + public updateMenuItem( + id: string, + enabled: boolean, + color?: string, + icon?: string, + text?: string, + ) { + const path = this.menuPaths.get(id); + if (!path) return; + + const item = this.findMenuItem(id); + if (item) { + item.disabled = !enabled; + if (color) item.color = enabled ? color : this.config.disabledColor; + if (icon) item.icon = icon; + if (text !== undefined) item.text = text; + } + + const fillColor = enabled && color ? color : this.config.disabledColor; + const opacity = enabled ? 0.7 : 0.5; + + const isSelected = id === this.selectedItemId && this.currentLevel > 0; + const finalOpacity = isSelected ? 1.0 : opacity; + + path + .attr( + "fill", + d3.color(fillColor)?.copy({ opacity: finalOpacity })?.toString() || + fillColor, + ) + .style("opacity", enabled ? 1 : 0.5) + .style("cursor", enabled ? "pointer" : "not-allowed"); + + const iconElement = this.menuIcons.get(id); + if (iconElement) { + if (item?.text) { + const textElement = iconElement.select("text"); + if (textElement.size() > 0) { + textElement + .style("opacity", enabled ? 1 : 0.5) + .text(text || item.text); + } + } else if (icon) { + const imageElement = iconElement.select("image"); + if (imageElement.size() > 0) { + imageElement.attr("xlink:href", enabled ? icon : disabledIcon); + } + } + } + } + + public setRootMenuItems(items: MenuElement[]) { + this.currentMenuItems = [...items]; + this.rootMenuItems = [...items]; + if (this.isVisible) { + this.refreshMenu(); + } + } + + private findMenuItem(id: string): MenuElement | undefined { + return this.currentMenuItems.find((item) => item.id === id); + } + + private resetMenu() { + this.currentLevel = 0; + this.menuStack = []; + + this.currentMenuItems = [...this.rootMenuItems]; + + this.backAction = null; + this.navigationInProgress = false; + + this.menuGroups.clear(); + this.menuPaths.clear(); + this.menuIcons.clear(); + + const menuContainer = this.menuElement?.select(".menu-container"); + if (menuContainer) { + menuContainer.selectAll("[class^='menu-level-']").remove(); + } + + this.resetCenterButton(); + + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + this.submenuHoverTimeout = null; + } + + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + this.backButtonHoverTimeout = null; + } + } + + public refreshMenu() { + if (!this.isVisible) return; + this.renderMenuItems(this.currentMenuItems, this.currentLevel); } renderLayer(context: CanvasRenderingContext2D) { @@ -312,241 +1051,30 @@ export class RadialMenu implements Layer { return false; } - private onContextMenu(event: ContextMenuEvent) { - if (this.lastClosed + 200 > new Date().getTime()) return; - if (this.buildMenu.isVisible) { - this.buildMenu.hideMenu(); - return; - } - if (this.isVisible) { - this.hideRadialMenu(); - return; - } else { - this.showRadialMenu(event.x, event.y); - } - this.disableAllButtons(); - this.clickedCell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - if (!this.g.isValidCoord(this.clickedCell.x, this.clickedCell.y)) { - return; - } - const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y); - this.originalTileOwner = this.g.owner(tile); - if (this.g.inSpawnPhase()) { - if (this.g.isLand(tile) && !this.g.hasOwner(tile)) { - this.enableCenterButton(true); - } - return; - } - - const myPlayer = this.g.myPlayer(); - if (myPlayer === null) { - consolex.warn("my player not found"); - return; - } - if (myPlayer && !myPlayer.isAlive() && !this.g.inSpawnPhase()) { - return this.hideRadialMenu(); - } - myPlayer.actions(tile).then((actions) => { - this.handlePlayerActions(myPlayer, actions, tile); - }); + private isReopeningAllowed(): boolean { + const now = Date.now(); + const timeSinceHide = now - this.lastHideTime; + return timeSinceHide >= this.reopenCooldownMs; } - private handlePlayerActions( - myPlayer: PlayerView, - actions: PlayerActions, - tile: TileRef, - ) { - if (!this.g.inSpawnPhase()) { - this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => { - this.buildMenu.showMenu(tile); - }); + private showTooltip(items: TooltipItem[]) { + if (!this.tooltipElement) return; + + this.tooltipElement.innerHTML = ""; + + for (const item of items) { + const div = document.createElement("div"); + div.className = item.className; + div.textContent = item.text; + this.tooltipElement.appendChild(div); } - if (this.g.hasOwner(tile)) { - this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => { - this.playerPanel.show(actions, tile); - }); - } - - if (actions?.interaction?.canSendAllianceRequest) { - this.activateMenuElement(Slot.Ally, "#53ac75", allianceIcon, () => { - this.eventBus.emit( - new SendAllianceRequestIntentEvent( - myPlayer, - this.g.owner(tile) as PlayerView, - ), - ); - }); - } - if (actions?.interaction?.canBreakAlliance) { - this.activateMenuElement(Slot.Ally, "#c74848", traitorIcon, () => { - this.eventBus.emit( - new SendBreakAllianceIntentEvent( - myPlayer, - this.g.owner(tile) as PlayerView, - ), - ); - }); - } - if ( - actions.buildableUnits.find((bu) => bu.type === UnitType.TransportShip) - ?.canBuild - ) { - this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => { - // BestTransportShipSpawn is an expensive operation, so - // we calculate it here and send the spawn tile to other clients. - myPlayer.bestTransportShipSpawn(tile).then((spawn) => { - let spawnTile: Cell | null = null; - if (spawn !== false) { - spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn)); - } - - if (this.clickedCell === null) return; - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.g.owner(tile).id(), - this.clickedCell, - this.uiState.attackRatio * myPlayer.troops(), - spawnTile, - ), - ); - }); - }); - } - if (actions.canAttack) { - this.enableCenterButton(true); - } - - if (!this.g.hasOwner(tile)) { - return; - } + this.tooltipElement.style.display = "block"; } - private onPointerUp(event: MouseUpEvent) { - this.hideRadialMenu(); - this.emojiTable.hideTable(); - this.buildMenu.hideMenu(); - this.playerInfoOverlay.hide(); - } - - private showRadialMenu(x: number, y: number) { - // Delay so center button isn't clicked immediately on press. - setTimeout(() => { - this.menuElement - .style("left", `${x - this.menuSize / 2}px`) - .style("top", `${y - this.menuSize / 2}px`) - .style("display", "block"); - this.playerInfoOverlay.maybeShow(x, y); - this.isVisible = true; - }, 50); - } - - private hideRadialMenu() { - this.menuElement.style("display", "none"); - this.isVisible = false; - this.playerInfoOverlay.hide(); - this.lastClosed = new Date().getTime(); - } - - private handleCenterButtonClick() { - if (!this.isCenterButtonEnabled) { - return; + private hideTooltip() { + if (this.tooltipElement) { + this.tooltipElement.style.display = "none"; } - consolex.log("Center button clicked"); - if (this.clickedCell === null) return; - const clicked = this.g.ref(this.clickedCell.x, this.clickedCell.y); - if (this.g.inSpawnPhase()) { - this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell)); - } else { - const myPlayer = this.g.myPlayer(); - if (myPlayer !== null && this.g.owner(clicked) !== myPlayer) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.g.owner(clicked).id(), - this.uiState.attackRatio * myPlayer.troops(), - ), - ); - } - } - this.hideRadialMenu(); - } - - private disableAllButtons() { - this.enableCenterButton(false); - for (const item of this.menuItems.values()) { - item.disabled = true; - this.updateMenuItemState(item); - } - } - - private activateMenuElement( - slot: Slot, - color: string, - icon: string, - action: () => void, - ) { - const menuItem = this.menuItems.get(slot); - if (menuItem === undefined) return; - menuItem.action = action; - menuItem.disabled = false; - menuItem.color = color; - menuItem.icon = icon; - this.updateMenuItemState(menuItem); - } - - private updateMenuItemState(item: any) { - const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`); - menuItem - .attr("fill", item.disabled ? this.disabledColor : item.color) - .style("cursor", item.disabled ? "not-allowed" : "pointer") - .style("opacity", item.disabled ? 0.5 : 1); - - this.menuElement - .select(`image[data-name="${item.name}"]`) - .attr("xlink:href", item.disabled ? disabledIcon : item.icon) - .attr("fill", item.disabled ? "#999999" : "white"); - } - - private onCenterButtonHover(isHovering: boolean) { - if (!this.isCenterButtonEnabled) return; - - const scale = isHovering ? 1.2 : 1; - const fontSize = isHovering ? "18px" : "16px"; - - this.menuElement - .select(".center-button-hitbox") - .transition() - .duration(200) - .attr("r", this.centerButtonSize * scale); - this.menuElement - .select(".center-button-visible") - .transition() - .duration(200) - .attr("r", this.centerButtonSize * scale); - this.menuElement - .select(".center-button-text") - .transition() - .duration(200) - .style("font-size", fontSize); - } - - private enableCenterButton(enabled: boolean) { - this.isCenterButtonEnabled = enabled; - const centerButton = this.menuElement.select(".center-button"); - - centerButton - .select(".center-button-hitbox") - .style("cursor", enabled ? "pointer" : "not-allowed"); - - centerButton - .select(".center-button-visible") - .attr("fill", enabled ? "#2c3e50" : "#999999"); - - centerButton - .select(".center-button-text") - .attr("fill", enabled ? "white" : "#cccccc"); } } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts new file mode 100644 index 000000000..1b5ed0fa0 --- /dev/null +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -0,0 +1,471 @@ +import { + AllPlayers, + Cell, + PlayerActions, + TerraNullius, + UnitType, +} from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { flattenedEmojiTable } from "../../../core/Util"; +import { renderNumber, translateText } from "../../Utils"; +import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; +import { ChatIntegration } from "./ChatIntegration"; +import { EmojiTable } from "./EmojiTable"; +import { PlayerActionHandler } from "./PlayerActionHandler"; +import { PlayerPanel } from "./PlayerPanel"; +import { TooltipItem } from "./RadialMenu"; + +import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; +import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; +import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; +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 targetIcon from "../../../../resources/images/TargetIconWhite.svg"; +import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; + +export interface MenuElementParams { + myPlayer: PlayerView; + selected: PlayerView | null; + tileOwner: PlayerView | TerraNullius; + tile: TileRef; + playerActions: PlayerActions; + game: GameView; + buildMenu: BuildMenu; + emojiTable: EmojiTable; + playerActionHandler: PlayerActionHandler; + playerPanel: PlayerPanel; + chatIntegration: ChatIntegration; + closeMenu: () => void; +} + +export interface MenuElement { + id: string; + name: string; + disabled: boolean; + displayed?: boolean; + color?: string; + icon?: string; + text?: string; + fontSize?: string; + tooltipItems?: TooltipItem[]; + + action?: (params: MenuElementParams) => void; // For leaf items that perform actions + subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus + + // Runtime properties used by RadialMenu (not to be set by menu element creators) + children?: MenuElement[]; + _action?: () => void; +} + +export const COLORS = { + build: "#ebe250", + building: "#2c2c2c", + boat: "#3f6ab1", + ally: "#53ac75", + breakAlly: "#c74848", + info: "#64748B", + target: "#ff0000", + infoDetails: "#7f8c8d", + infoEmoji: "#f1c40f", + trade: "#008080", + embargo: "#6600cc", + tooltip: { + cost: "#ffd700", + count: "#aaa", + }, + chat: { + default: "#66c", + help: "#4caf50", + attack: "#f44336", + defend: "#2196f3", + greet: "#ff9800", + misc: "#9c27b0", + warnings: "#e3c532", + }, +}; + +export enum Slot { + Info = "info", + Boat = "boat", + Build = "build", + Ally = "ally", + Back = "back", +} + +/** + * Convert a MenuElement tree to a version usable by the RadialMenu + * by resolving subMenu functions and setting up actions + */ +export function prepareMenuElementsForRadialMenu( + elements: MenuElement[], + params: MenuElementParams, +): MenuElement[] { + return elements.map((element) => { + const prepared: MenuElement = { ...element }; + + // If the element has a subMenu function, execute it to get the children + if (element.subMenu) { + prepared.children = prepareMenuElementsForRadialMenu( + element.subMenu(params), + params, + ); + // We don't need the subMenu function anymore + prepared.subMenu = undefined; + } + + // Set up the action function to call the element's action with params + if (element.action) { + prepared._action = () => element.action!(params); + } else { + prepared._action = () => {}; + } + + return prepared; + }); +} + +export const buildMenuElement: MenuElement = { + id: Slot.Build, + name: "build", + disabled: false, + icon: buildIcon, + color: COLORS.build, + + subMenu: (params: MenuElementParams) => { + const buildElements: MenuElement[] = flattenedBuildTable.map( + (item: BuildItemDisplay) => ({ + id: `build_${item.unitType}`, + name: item.key + ? item.key.replace("unit_type.", "") + : item.unitType.toString(), + disabled: !params.buildMenu.canBuild(item), + color: params.buildMenu.canBuild(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) => { + params.playerActionHandler.handleBuildUnit( + item.unitType, + params.game.x(params.tile), + params.game.y(params.tile), + ); + params.closeMenu(); + }, + }), + ); + + buildElements.push({ + id: "build_menu", + name: "build", + disabled: false, + color: COLORS.build, + icon: buildIcon, + action: (params: MenuElementParams) => { + params.buildMenu.showMenu(params.tile); + }, + }); + + return buildElements; + }, +}; + +export const boatMenuElement: MenuElement = { + id: Slot.Boat, + name: "boat", + disabled: false, + icon: boatIcon, + color: COLORS.boat, + + action: async (params: MenuElementParams) => { + if (!params.selected) return; + + const spawn = await params.playerActionHandler.findBestTransportShipSpawn( + params.myPlayer, + params.tile, + ); + + let spawnTile: Cell | null = null; + if (spawn !== false) { + spawnTile = new Cell(params.game.x(spawn), params.game.y(spawn)); + } + + params.playerActionHandler.handleBoatAttack( + params.myPlayer, + params.selected.id(), + new Cell(params.game.x(params.tile), params.game.y(params.tile)), + spawnTile, + ); + + params.closeMenu(); + }, +}; + +export const infoMenuElement: MenuElement = { + id: Slot.Info, + name: "info", + disabled: false, + icon: infoIcon, + color: COLORS.info, + + subMenu: (params: MenuElementParams) => { + if (!params.selected) return []; + + return [ + { + id: "info_chat", + name: "chat", + disabled: false, + color: COLORS.chat.default, + icon: chatIcon, + subMenu: (params: MenuElementParams) => + params.chatIntegration + .createQuickChatMenu(params.selected!) + .map((item) => ({ + ...item, + action: item.action + ? (_params: MenuElementParams) => item.action!(params) + : undefined, + })), + }, + { + id: "ally_target", + name: "target", + disabled: false, + color: COLORS.target, + icon: targetIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleTargetPlayer(params.selected!.id()); + params.closeMenu(); + }, + }, + { + id: "ally_trade", + name: "trade", + disabled: !!params.playerActions?.interaction?.canEmbargo, + displayed: !params.playerActions?.interaction?.canEmbargo, + color: COLORS.trade, + text: translateText("player_panel.start_trade"), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleEmbargo(params.selected!, "start"); + params.closeMenu(); + }, + }, + { + id: "ally_embargo", + name: "embargo", + disabled: !params.playerActions?.interaction?.canEmbargo, + displayed: !!params.playerActions?.interaction?.canEmbargo, + color: COLORS.embargo, + text: translateText("player_panel.stop_trade"), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleEmbargo(params.selected!, "stop"); + params.closeMenu(); + }, + }, + { + id: "ally_request", + name: "request", + disabled: !params.playerActions?.interaction?.canSendAllianceRequest, + displayed: !params.playerActions?.interaction?.canBreakAlliance, + color: COLORS.ally, + icon: allianceIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleAllianceRequest( + params.myPlayer, + params.selected!, + ); + params.closeMenu(); + }, + }, + { + id: "ally_break", + name: "break", + disabled: !params.playerActions?.interaction?.canBreakAlliance, + displayed: !!params.playerActions?.interaction?.canBreakAlliance, + color: COLORS.breakAlly, + icon: traitorIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleBreakAlliance( + params.myPlayer, + params.selected!, + ); + params.closeMenu(); + }, + }, + + { + id: "ally_donate_gold", + name: "donate gold", + disabled: !params.playerActions?.interaction?.canDonate, + color: COLORS.ally, + icon: donateGoldIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleDonateGold(params.selected!); + params.closeMenu(); + }, + }, + { + id: "ally_donate_troops", + name: "donate troops", + disabled: !params.playerActions?.interaction?.canDonate, + color: COLORS.ally, + icon: donateTroopIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleDonateTroops(params.selected!); + params.closeMenu(); + }, + }, + { + id: "info_player", + name: "player", + disabled: false, + color: COLORS.info, + icon: infoIcon, + action: (params: MenuElementParams) => { + params.playerPanel.show(params.playerActions, params.tile); + }, + }, + { + id: "info_emoji", + name: "emoji", + disabled: false, + color: COLORS.infoEmoji, + icon: emojiIcon, + subMenu: () => { + const emojiElements: MenuElement[] = []; + + const emojiCount = 15; + for (let i = 0; i < emojiCount; i++) { + emojiElements.push({ + id: `emoji_${i}`, + name: flattenedEmojiTable[i], + text: flattenedEmojiTable[i], + disabled: false, + fontSize: "25px", + action: (params: MenuElementParams) => { + const targetPlayer = + params.selected === params.game.myPlayer() + ? AllPlayers + : params.selected; + params.playerActionHandler.handleEmoji(targetPlayer!, i); + params.closeMenu(); + }, + }); + } + + emojiElements.push({ + id: "emoji_more", + name: "more", + disabled: false, + color: COLORS.infoEmoji, + icon: emojiIcon, + action: (params: MenuElementParams) => { + params.emojiTable.showTable((emoji) => { + const targetPlayer = + params.selected === params.game.myPlayer() + ? AllPlayers + : params.selected; + params.playerActionHandler.handleEmoji( + targetPlayer!, + flattenedEmojiTable.indexOf(emoji), + ); + params.emojiTable.hideTable(); + }); + }, + }); + + return emojiElements; + }, + }, + ].filter((item) => item.displayed !== false); + }, +}; + +export function createMenuItems(params: MenuElementParams): MenuElement[] { + const canBuildTransport = params.playerActions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + )?.canBuild; + + return [ + { + ...boatMenuElement, + disabled: !canBuildTransport || !params.selected, + }, + { + ...buildMenuElement, + disabled: params.game.inSpawnPhase(), + }, + { + ...infoMenuElement, + disabled: !params.game.hasOwner(params.tile), + }, + ]; +} + +export function createRadialMenuItems( + params: MenuElementParams, +): MenuElement[] { + const elements = createMenuItems(params); + return prepareMenuElementsForRadialMenu(elements, params); +} + +export function getRootMenuItems(): MenuElement[] { + return [ + { + id: Slot.Boat, + name: "boat", + disabled: true, + _action: () => {}, + icon: boatIcon, + }, + { + id: Slot.Build, + name: "build", + disabled: true, + _action: () => {}, + icon: buildIcon, + }, + { + id: Slot.Info, + name: "info", + disabled: true, + _action: () => {}, + icon: infoIcon, + }, + ]; +} + +export function updateCenterButton( + params: MenuElementParams, + enableCenterButton: (enabled: boolean, action?: (() => void) | null) => void, +) { + if (params.playerActions.canAttack) { + enableCenterButton(true, () => { + if (params.tileOwner !== params.myPlayer) { + params.playerActionHandler.handleAttack( + params.myPlayer, + params.tileOwner.id(), + ); + } + params.closeMenu(); + }); + } else { + enableCenterButton(false); + } +} diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index b671becb9..dcbbd10b0 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameMode } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { ClientID } from "../../../core/Schemas"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; @@ -18,7 +17,6 @@ interface TeamEntry { @customElement("team-stats") export class TeamStats extends LitElement implements Layer { public game: GameView; - public clientID: ClientID; public eventBus: EventBus; teams: TeamEntry[] = []; @@ -60,7 +58,7 @@ export class TeamStats extends LitElement implements Layer { this.teams = Object.entries(grouped) .map(([teamStr, teamPlayers]) => { - let totalGold = 0; + let totalGold = 0n; let totalTroops = 0; let totalScoreSort = 0; diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index deb2cfd0a..1c2dc1bf1 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -8,6 +8,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { AlternateViewEvent, DragEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; export class TerritoryLayer implements Layer { @@ -32,7 +33,7 @@ export class TerritoryLayer implements Layer { private lastDragTime = 0; private nodrawDragDuration = 200; - private refreshRate = 10; + private refreshRate = 10; //refresh every 10ms private lastRefresh = 0; private lastFocusedPlayer: PlayerView | null = null; @@ -40,6 +41,7 @@ export class TerritoryLayer implements Layer { constructor( private game: GameView, private eventBus: EventBus, + private transformHandler: TransformHandler, ) { this.theme = game.config().theme(); } @@ -48,11 +50,10 @@ export class TerritoryLayer implements Layer { return true; } - paintPlayerBorder(player: PlayerView) { - player.borderTiles().then((playerBorderTiles) => { - playerBorderTiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing - }); + async paintPlayerBorder(player: PlayerView) { + const tiles = await player.borderTiles(); + tiles.borderTiles.forEach((tile: TileRef) => { + this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing }); } @@ -128,11 +129,7 @@ export class TerritoryLayer implements Layer { euclDistFN(centerTile, 9, true), )) { if (!this.game.hasOwner(tile)) { - this.paintHighlightCell( - new Cell(this.game.x(tile), this.game.y(tile)), - color, - 255, - ); + this.paintHighlightTile(tile, color, 255); } } } @@ -155,16 +152,16 @@ export class TerritoryLayer implements Layer { const context = this.canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); this.context = context; + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); this.imageData = this.context.getImageData( 0, 0, - this.game.width(), - this.game.height(), + this.canvas.width, + this.canvas.height, ); this.initImageData(); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); this.context.putImageData(this.imageData, 0, 0); // Add a second canvas for highlights @@ -199,7 +196,19 @@ export class TerritoryLayer implements Layer { ) { this.lastRefresh = now; this.renderTerritory(); - this.context.putImageData(this.imageData, 0, 0); + + const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); + const vx0 = Math.max(0, topLeft.x); + const vy0 = Math.max(0, topLeft.y); + const vx1 = Math.min(this.game.width() - 1, bottomRight.x); + const vy1 = Math.min(this.game.height() - 1, bottomRight.y); + + const w = vx1 - vx0 + 1; + const h = vy1 - vy0 + 1; + + if (w > 0 && h > 0) { + this.context.putImageData(this.imageData, 0, 0, vx0, vy0, w, h); + } } if (this.alternativeView) { return; @@ -231,7 +240,13 @@ export class TerritoryLayer implements Layer { while (numToRender > 0) { numToRender--; - const tile = this.tileToRenderQueue.pop().tile; + + const entry = this.tileToRenderQueue.pop(); + if (!entry) { + break; + } + + const tile = entry.tile; this.paintTerritory(tile); for (const neighbor of this.game.neighbors(tile)) { this.paintTerritory(neighbor, true); @@ -245,15 +260,10 @@ export class TerritoryLayer implements Layer { } if (!this.game.hasOwner(tile)) { if (this.game.hasFallout(tile)) { - this.paintCell( - this.game.x(tile), - this.game.y(tile), - this.theme.falloutColor(), - 150, - ); + this.paintTile(tile, this.theme.falloutColor(), 150); return; } - this.clearCell(new Cell(this.game.x(tile), this.game.y(tile))); + this.clearTile(tile); return; } const owner = this.game.owner(tile) as PlayerView; @@ -273,40 +283,28 @@ export class TerritoryLayer implements Layer { const lightTile = (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); const borderColor = lightTile ? borderColors.light : borderColors.dark; - this.paintCell(x, y, borderColor, 255); + this.paintTile(tile, borderColor, 255); } else { const useBorderColor = playerIsFocused ? this.theme.focusedBorderColor() : this.theme.borderColor(owner); - this.paintCell( - this.game.x(tile), - this.game.y(tile), - useBorderColor, - 255, - ); + this.paintTile(tile, useBorderColor, 255); } } else { - this.paintCell( - this.game.x(tile), - this.game.y(tile), - this.theme.territoryColor(owner), - 150, - ); + this.paintTile(tile, this.theme.territoryColor(owner), 150); } } - paintCell(x: number, y: number, color: Colord, alpha: number) { - const index = y * this.game.width() + x; - const offset = index * 4; + paintTile(tile: TileRef, color: Colord, alpha: number) { + const offset = tile * 4; this.imageData.data[offset] = color.rgba.r; this.imageData.data[offset + 1] = color.rgba.g; this.imageData.data[offset + 2] = color.rgba.b; this.imageData.data[offset + 3] = alpha; } - clearCell(cell: Cell) { - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; + clearTile(tile: TileRef) { + const offset = tile * 4; this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) } @@ -324,13 +322,17 @@ export class TerritoryLayer implements Layer { }); } - paintHighlightCell(cell: Cell, color: Colord, alpha: number) { - this.clearCell(cell); + paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { + this.clearTile(tile); + const x = this.game.x(tile); + const y = this.game.y(tile); this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.highlightContext.fillRect(cell.x, cell.y, 1, 1); + this.highlightContext.fillRect(x, y, 1, 1); } - clearHighlightCell(cell: Cell) { - this.highlightContext.clearRect(cell.x, cell.y, 1, 1); + clearHighlightTile(tile: TileRef) { + const x = this.game.x(tile); + const y = this.game.y(tile); + this.highlightContext.clearRect(x, y, 1, 1); } } diff --git a/src/client/graphics/layers/TopBar.ts b/src/client/graphics/layers/TopBar.ts index 76e218ba5..991fda739 100644 --- a/src/client/graphics/layers/TopBar.ts +++ b/src/client/graphics/layers/TopBar.ts @@ -50,7 +50,7 @@ export class TopBar extends LitElement implements Layer { const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10; const maxPop = this.game.config().maxPopulation(myPlayer); - const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10; + const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n; return html`
    (); private transformHandler: TransformHandler; @@ -55,7 +52,6 @@ export class UnitLayer implements Layer { constructor( private game: GameView, private eventBus: EventBus, - private clientID: ClientID, transformHandler: TransformHandler, ) { this.theme = game.config().theme(); @@ -67,11 +63,11 @@ export class UnitLayer implements Layer { } tick() { - if (this.myPlayer === null) { - this.myPlayer = this.game.playerByClientID(this.clientID); - } + const unitIds = this.game + .updatesSinceLastTick() + ?.[GameUpdateType.Unit]?.map((unit) => unit.id); - this.updateUnitsSprites(); + this.updateUnitsSprites(unitIds ?? []); } init() { @@ -95,18 +91,13 @@ export class UnitLayer implements Layer { } const clickRef = this.game.ref(cell.x, cell.y); - // Make sure we have the current player - if (this.myPlayer === null) { - this.myPlayer = this.game.playerByClientID(this.clientID); - } - // Only select warships owned by the player return this.game .units(UnitType.Warship) .filter( (unit) => unit.isActive() && - unit.owner() === this.myPlayer && // Only allow selecting own warships + unit.owner() === this.game.myPlayer() && // Only allow selecting own warships this.game.manhattanDist(unit.tile(), clickRef) <= this.WARSHIP_SELECTION_RADIUS, ) @@ -202,7 +193,7 @@ export class UnitLayer implements Layer { this.transportShipTrailCanvas.width = this.game.width(); this.transportShipTrailCanvas.height = this.game.height(); - this.updateUnitsSprites(); + this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); this.unitToTrail.forEach((trail, unit) => { for (const t of trail) { @@ -218,10 +209,9 @@ export class UnitLayer implements Layer { }); } - private updateUnitsSprites() { - const unitsToUpdate = this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) + private updateUnitsSprites(unitIds: number[]) { + const unitsToUpdate = unitIds + ?.map((id) => this.game.unit(id)) .filter((unit) => unit !== undefined); if (unitsToUpdate) { @@ -254,13 +244,14 @@ export class UnitLayer implements Layer { } private relationship(unit: UnitView): Relationship { - if (this.myPlayer === null) { + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) { return Relationship.Enemy; } - if (this.myPlayer === unit.owner()) { + if (myPlayer === unit.owner()) { return Relationship.Self; } - if (this.myPlayer.isFriendly(unit.owner())) { + if (myPlayer.isFriendly(unit.owner())) { return Relationship.Ally; } return Relationship.Enemy; diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index ce0739e50..ce705513d 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -148,7 +148,24 @@ export class WinModal extends LitElement implements Layer { } innerHtml() { - return html``; + return html`

    + + ${translateText("win_modal.wishlist")} + +

    `; } show() { diff --git a/src/client/index.html b/src/client/index.html index 3e15ba515..ae94e0ae7 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -99,6 +99,11 @@ display: none; } } + + /* display:none if child has class parent-hidden since we can't use shadow DOM in Lit due to Tailwind */ + .component-hideable:has(> .parent-hidden) { + display: none; + } @@ -224,7 +229,9 @@
    - +
    @@ -299,6 +306,11 @@
    +
    + +
    @@ -368,6 +380,16 @@ Terms of Service +

    + Advertise +

    diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 99c337f5a..9d76ff857 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -1,4 +1,5 @@ import { decodeJwt } from "jose"; +import { z } from "zod/v4"; import { RefreshResponseSchema, TokenPayload, @@ -49,7 +50,7 @@ export async function logOut(allSessions: boolean = false) { __isLoggedIn = false; const response = await fetch( - getApiBase() + allSessions ? "/revoke" : "/logout", + getApiBase() + (allSessions ? "/revoke" : "/logout"), { method: "POST", headers: { @@ -138,12 +139,9 @@ function _isLoggedIn(): IsLoggedInResponse { const result = TokenPayloadSchema.safeParse(payload); if (!result.success) { + const error = z.prettifyError(result.error); // Invalid response - console.error( - "Invalid payload", - // JSON.stringify(payload), - JSON.stringify(result.error), - ); + console.error("Invalid payload", error); return false; } @@ -171,11 +169,8 @@ export async function postRefresh(): Promise { const body = await response.json(); const result = RefreshResponseSchema.safeParse(body); if (!result.success) { - console.error( - "Invalid response", - JSON.stringify(body), - JSON.stringify(result.error), - ); + const error = z.prettifyError(result.error); + console.error("Invalid response", error); return false; } localStorage.setItem("token", result.data.token); @@ -201,11 +196,8 @@ export async function getUserMe(): Promise { const body = await response.json(); const result = UserMeResponseSchema.safeParse(body); if (!result.success) { - console.error( - "Invalid response", - JSON.stringify(body), - JSON.stringify(result.error), - ); + const error = z.prettifyError(result.error); + console.error("Invalid response", error); return false; } return result.data; diff --git a/src/client/utilities/Maps.ts b/src/client/utilities/Maps.ts index f583594e6..97aaac9f8 100644 --- a/src/client/utilities/Maps.ts +++ b/src/client/utilities/Maps.ts @@ -6,6 +6,7 @@ import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp"; import blackSea from "../../../resources/maps/BlackSeaThumb.webp"; import britannia from "../../../resources/maps/BritanniaThumb.webp"; import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp"; +import eastasia from "../../../resources/maps/EastAsiaThumb.webp"; import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp"; import europe from "../../../resources/maps/EuropeThumb.webp"; import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp"; @@ -13,7 +14,6 @@ import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp"; import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp"; import halkidiki from "../../../resources/maps/HalkidikiThumb.webp"; import iceland from "../../../resources/maps/IcelandThumb.webp"; -import japan from "../../../resources/maps/JapanThumb.webp"; import mars from "../../../resources/maps/MarsThumb.webp"; import mena from "../../../resources/maps/MenaThumb.webp"; import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp"; @@ -61,8 +61,8 @@ export function getMapsImage(map: GameMapType): string { return australia; case GameMapType.Iceland: return iceland; - case GameMapType.Japan: - return japan; + case GameMapType.EastAsia: + return eastasia; case GameMapType.BetweenTwoSeas: return betweenTwoSeas; case GameMapType.FaroeIslands: diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts new file mode 100644 index 000000000..c74aaf7ef --- /dev/null +++ b/src/client/utilities/RenderUnitTypeOptions.ts @@ -0,0 +1,48 @@ +// renderUnitTypeOptions.ts +import { html, TemplateResult } from "lit"; +import { UnitType } from "../../core/game/Game"; +import { translateText } from "../Utils"; + +export interface UnitTypeRenderContext { + disabledUnits: UnitType[]; + toggleUnit: (unit: UnitType, checked: boolean) => void; +} + +const unitOptions: { type: UnitType; translationKey: string }[] = [ + { type: UnitType.City, translationKey: "unit_type.city" }, + { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" }, + { type: UnitType.Port, translationKey: "unit_type.port" }, + { type: UnitType.Warship, translationKey: "unit_type.warship" }, + { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" }, + { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" }, + { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, + { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, + { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, +]; + +export function renderUnitTypeOptions({ + disabledUnits, + toggleUnit, +}: UnitTypeRenderContext): TemplateResult[] { + return unitOptions.map( + ({ type, translationKey }) => html` + + `, + ); +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 1c594e1a7..aec1ae506 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -28,25 +28,21 @@ export const TokenPayloadSchema = z.object({ iss: z.string(), aud: z.string(), exp: z.number(), - rol: z - .string() - .optional() - .transform((val) => (val ?? "").split(",")), }); export type TokenPayload = z.infer; export const UserMeResponseSchema = z.object({ user: z.object({ id: z.string(), - avatar: z.string(), + avatar: z.string().nullable(), username: z.string(), - global_name: z.string(), + global_name: z.string().nullable(), discriminator: z.string(), - locale: z.string(), + locale: z.string().optional(), }), player: z.object({ publicId: z.string(), - roles: z.string().array(), + roles: z.string().array().optional(), }), }); export type UserMeResponse = z.infer; diff --git a/src/core/Consolex.ts b/src/core/Consolex.ts deleted file mode 100644 index 7817d3b08..000000000 --- a/src/core/Consolex.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EventBus, GameEvent } from "./EventBus"; -import { LogSeverity } from "./Schemas"; - -export const consolex = { - log: console.log, - warn: console.warn, - error: console.error, -}; - -let inited = false; - -// Only call this in client/browser! -export function initRemoteSender(eventBus: EventBus) { - if (inited) { - return; - } - inited = true; - - consolex.log = (...args: any[]): void => { - console.log(...args); - // eventBus.emit(new SendLogEvent(LogSeverity.Info, args.join(' '))) - }; - - consolex.warn = (...args: any[]): void => { - console.warn(...args); - // eventBus.emit(new SendLogEvent(LogSeverity.Warn, args.join(' '))) - }; - - consolex.error = (...args: any[]): void => { - console.error(...args); - // eventBus.emit(new SendLogEvent(LogSeverity.Error, args.join(' '))) - }; -} -export class SendLogEvent implements GameEvent { - constructor( - public readonly severity: LogSeverity, - public readonly log: string, - ) {} -} diff --git a/src/core/PseudoRandom.ts b/src/core/PseudoRandom.ts index daf27497f..406cfb243 100644 --- a/src/core/PseudoRandom.ts +++ b/src/core/PseudoRandom.ts @@ -9,6 +9,9 @@ export class PseudoRandom { private c: number = 12345; private state: number; + private static readonly POW36_8 = Math.pow(36, 8); // Pre-compute 36^8 + private static readonly INV_2_32 = 1 / 4294967296; // 1 / 2^32 for float conversion + constructor(seed: number) { // Initialize the XorShift state with seed this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR @@ -48,6 +51,13 @@ export class PseudoRandom { return (this.state0 + this.state1) | 0; } + /** + * Optimized version that directly returns unsigned 32-bit integer + */ + private _nextUInt32(): number { + return this._nextIntInternal() >>> 0; + } + /** * Generates the next pseudorandom number. * @returns A number between 0 (inclusive) and 1 (exclusive). @@ -55,7 +65,7 @@ export class PseudoRandom { next(): number { // Get a 32-bit integer and convert to [0,1) range // Using >>> 0 to get unsigned interpretation (positive number) - const int = this._nextIntInternal() >>> 0; + const int = this._nextUInt32(); // Update the state variable to maintain compatibility with original interface this.state = int % this.m; @@ -64,25 +74,33 @@ export class PseudoRandom { return this.state / this.m; } + /** + * Optimized version for internal use - directly converts to [0,1) without state update + */ + private _nextFloat(): number { + return this._nextUInt32() * PseudoRandom.INV_2_32; + } + /** * Generates a random integer between min (inclusive) and max (exclusive). */ nextInt(min: number, max: number): number { - return Math.floor(this.next() * (max - min) + min); + // keep max exclusive, min inclusive โ€“ round down to get an int + return Math.floor(this._nextFloat() * (max - min)) + min; } /** * Generates a random float between min (inclusive) and max (exclusive). */ nextFloat(min: number, max: number): number { - return this.next() * (max - min) + min; + return this._nextFloat() * (max - min) + min; } /** * Generates a random ID (8 characters, alphanumeric). */ nextID(): string { - return this.nextInt(0, Math.pow(36, 8)) // 36^8 possibilities + return Math.floor(this._nextFloat() * PseudoRandom.POW36_8) // 36^8 possibilities .toString(36) // Convert to base36 (0-9 and a-z) .padStart(8, "0"); // Ensure 8 chars by padding with zeros } @@ -94,25 +112,25 @@ export class PseudoRandom { if (arr.length === 0) { throw new Error("array must not be empty"); } - return arr[this.nextInt(0, arr.length)]; + return arr[Math.floor(this._nextFloat() * arr.length)]; } /** * Returns true with probability 1/odds. */ chance(odds: number): boolean { - return this.nextInt(0, odds) === 0; + return Math.floor(this._nextFloat() * odds) === 0; } /** * Returns a shuffled copy of the array using Fisher-Yates algorithm. */ shuffleArray(array: T[]): T[] { - for (let i = array.length - 1; i >= 0; i--) { - const j = this.nextInt(0, i + 1); - [array[i], array[j]] = [array[j], array[i]]; + const result = [...array]; + for (let i = result.length - 1; i >= 0; i--) { + const j = Math.floor(this._nextFloat() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; } - - return array; + return result; } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index fdb6b3f9c..e4fff95a6 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -116,7 +116,7 @@ export enum LogSeverity { Fatal = "FATAL", } -const GameConfigSchema = z.object({ +export const GameConfigSchema = z.object({ gameMap: z.nativeEnum(GameMapType), difficulty: z.nativeEnum(Difficulty), gameType: z.nativeEnum(GameType), @@ -245,7 +245,7 @@ export const EmbargoIntentSchema = BaseIntentSchema.extend({ export const DonateGoldIntentSchema = BaseIntentSchema.extend({ type: z.literal("donate_gold"), recipient: ID, - gold: z.number().nullable(), + gold: z.bigint().nullable(), }); export const DonateTroopIntentSchema = BaseIntentSchema.extend({ @@ -457,10 +457,12 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({ }); export type GameEndInfo = z.infer; +const GitCommitSchema = z.string().regex(/^[0-9a-fA-F]{40}$/); + export const AnalyticsRecordSchema = z.object({ info: GameEndInfoSchema, version: z.literal("v0.0.2"), - gitCommit: z.string(), + gitCommit: GitCommitSchema, }); export type AnalyticsRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index b518f5f1a..d78f7091d 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -195,7 +195,7 @@ export function createGameRecord( ): GameRecord { const duration = Math.floor((end - start) / 1000); const version = "v0.0.2"; - const gitCommit = ""; + const gitCommit = process.env.GIT_COMMIT ?? "unknown"; const num_turns = allTurns.length; const turns = allTurns.filter( (t) => t.intents.length !== 0 || t.hash !== undefined, diff --git a/src/core/WorkerSchemas.ts b/src/core/WorkerSchemas.ts new file mode 100644 index 000000000..0a06b1571 --- /dev/null +++ b/src/core/WorkerSchemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { GameConfigSchema } from "./Schemas"; + +export const CreateGameInputSchema = GameConfigSchema.or( + z + .object({}) + .strict() + .transform((val) => undefined), +); + +export const GameInputSchema = GameConfigSchema.partial(); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 7da156e3b..69315c4db 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -29,7 +29,11 @@ export enum GameEnv { export interface ServerConfig { turnIntervalMs(): number; gameCreationRate(): number; - lobbyMaxPlayers(map: GameMapType, mode: GameMode): number; + lobbyMaxPlayers( + map: GameMapType, + mode: GameMode, + numPlayerTeams: number | undefined, + ): number; numWorkers(): number; workerIndex(gameID: GameID): number; workerPath(gameID: GameID): string; @@ -52,6 +56,11 @@ export interface ServerConfig { jwtAudience(): string; jwtIssuer(): string; jwkPublicKey(): Promise; + domain(): string; + subdomain(): string; + cloudflareAccountId(): string; + cloudflareApiToken(): string; + cloudflareConfigDir(): string; } export interface NukeMagnitude { @@ -80,7 +89,7 @@ export interface Config { startManpower(playerInfo: PlayerInfo): number; populationIncreaseRate(player: Player | PlayerView): number; - goldAdditionRate(player: Player | PlayerView): number; + goldAdditionRate(player: Player | PlayerView): Gold; troopAdjustmentRate(player: Player): number; attackTilesPerTick( attckTroops: number, @@ -125,8 +134,7 @@ export interface Config { defensePostRange(): number; SAMCooldown(): number; SiloCooldown(): number; - defensePostLossMultiplier(): number; - defensePostSpeedMultiplier(): number; + defensePostDefenseBonus(): number; falloutDefenseModifier(percentOfFallout: number): number; difficultyModifier(difficulty: Difficulty): number; warshipPatrolRange(): number; diff --git a/src/core/configuration/ConfigLoader.ts b/src/core/configuration/ConfigLoader.ts index 94a723c13..184902694 100644 --- a/src/core/configuration/ConfigLoader.ts +++ b/src/core/configuration/ConfigLoader.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { UserSettings } from "../game/UserSettings"; import { GameConfig } from "../Schemas"; import { Config, GameEnv, ServerConfig } from "./Config"; @@ -20,7 +19,7 @@ export async function getConfig( return new DevConfig(sc, gameConfig, userSettings, isReplay); case GameEnv.Preprod: case GameEnv.Prod: - consolex.log("using prod config"); + console.log("using prod config"); return new DefaultConfig(sc, gameConfig, userSettings, isReplay); default: throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`); @@ -51,13 +50,13 @@ export function getServerConfigFromServer(): ServerConfig { export function getServerConfig(gameEnv: string) { switch (gameEnv) { case "dev": - consolex.log("using dev server config"); + console.log("using dev server config"); return new DevServerConfig(); case "staging": - consolex.log("using preprod server config"); + console.log("using preprod server config"); return preprodConfig; case "prod": - consolex.log("using prod server config"); + console.log("using prod server config"); return prodConfig; default: throw Error(`unsupported server configuration: ${gameEnv}`); diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 5963148c8..3b1768134 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -53,7 +53,7 @@ const numPlayersConfig = { [GameMapType.Mena]: [60, 50, 30], [GameMapType.Mars]: [50, 40, 30], [GameMapType.Oceania]: [30, 20, 10], - [GameMapType.Japan]: [50, 40, 30], + [GameMapType.EastAsia]: [50, 40, 30], [GameMapType.FaroeIslands]: [50, 40, 30], [GameMapType.DeglaciatedAntarctica]: [50, 40, 30], [GameMapType.EuropeClassic]: [80, 30, 50], @@ -65,13 +65,22 @@ const numPlayersConfig = { [GameMapType.Halkidiki]: [50, 40, 30], } as const satisfies Record; -const TERRAIN_EFFECTS = { - [TerrainType.Plains]: { mag: 0.85, speed: 0.8 }, - [TerrainType.Highland]: { mag: 1, speed: 1 }, - [TerrainType.Mountain]: { mag: 1.2, speed: 1.3 }, -} as const; - export abstract class DefaultServerConfig implements ServerConfig { + domain(): string { + return process.env.DOMAIN ?? ""; + } + subdomain(): string { + return process.env.SUBDOMAIN ?? ""; + } + cloudflareAccountId(): string { + return process.env.CF_ACCOUNT_ID ?? ""; + } + cloudflareApiToken(): string { + return process.env.CF_API_TOKEN ?? ""; + } + cloudflareConfigDir(): string { + return process.env.CF_CONFIG_DIR ?? ""; + } private publicKey: JWK; abstract jwtAudience(): string; jwtIssuer(): string { @@ -143,11 +152,19 @@ export abstract class DefaultServerConfig implements ServerConfig { return 120 * 1000; } - lobbyMaxPlayers(map: GameMapType, mode: GameMode): number { + lobbyMaxPlayers( + map: GameMapType, + mode: GameMode, + numPlayerTeams: number | undefined, + ): number { const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20]; const r = Math.random(); const base = r < 0.3 ? l : r < 0.6 ? m : s; - return mode === GameMode.Team ? Math.ceil(base * 1.5) : base; + let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l); + if (numPlayerTeams !== undefined) { + p -= p % numPlayerTeams; + } + return p; } workerIndex(gameID: GameID): number { @@ -229,8 +246,8 @@ export class DefaultConfig implements Config { falloutDefenseModifier(falloutRatio: number): number { // falloutRatio is between 0 and 1 - // So defense modifier is between [3, 1] - return 3 - falloutRatio * 2; + // So defense modifier is between [5, 2.5] + return 5 - falloutRatio * 2; } SAMCooldown(): number { return 75; @@ -240,13 +257,10 @@ export class DefaultConfig implements Config { } defensePostRange(): number { - return 40; + return 30; } - defensePostLossMultiplier(): number { - return 6; - } - defensePostSpeedMultiplier(): number { - return 3; + defensePostDefenseBonus(): number { + return 5; } playerTeams(): number | typeof Duos { return this._gameConfig.playerTeams ?? 0; @@ -273,54 +287,59 @@ export class DefaultConfig implements Config { return this._gameConfig.infiniteTroops; } tradeShipGold(dist: number): Gold { - return 10000 + 150 * Math.pow(dist, 1.1); + return BigInt(Math.floor(10000 + 150 * Math.pow(dist, 1.1))); } tradeShipSpawnRate(numberOfPorts: number): number { - return Math.round(10 * Math.pow(numberOfPorts, 0.6)); + return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6))); } unitInfo(type: UnitType): UnitInfo { switch (type) { case UnitType.TransportShip: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.Warship: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 1_000_000, - (p.unitsIncludingConstruction(UnitType.Warship).length + 1) * - 250_000, + ? 0n + : BigInt( + Math.min( + 1_000_000, + (p.unitsIncludingConstruction(UnitType.Warship).length + + 1) * + 250_000, + ), ), territoryBound: false, maxHealth: 1000, }; case UnitType.Shell: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, damage: 250, }; case UnitType.SAMMissile: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.Port: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 1_000_000, - Math.pow( - 2, - p.unitsIncludingConstruction(UnitType.Port).length, - ) * 125_000, + ? 0n + : BigInt( + Math.min( + 1_000_000, + Math.pow( + 2, + p.unitsIncludingConstruction(UnitType.Port).length, + ) * 125_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, @@ -328,41 +347,43 @@ export class DefaultConfig implements Config { case UnitType.AtomBomb: return { cost: (p: Player) => - p.type() === PlayerType.Human && this.infiniteGold() ? 0 : 750_000, + p.type() === PlayerType.Human && this.infiniteGold() + ? 0n + : 750_000n, territoryBound: false, }; case UnitType.HydrogenBomb: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : 5_000_000, + ? 0n + : 5_000_000n, territoryBound: false, }; case UnitType.MIRV: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : 25_000_000, + ? 0n + : 25_000_000n, territoryBound: false, }; case UnitType.MIRVWarhead: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.TradeShip: return { - cost: () => 0, + cost: () => 0n, territoryBound: false, }; case UnitType.MissileSilo: return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : 1_000_000, + ? 0n + : 1_000_000n, territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 10 * 10, }; @@ -370,12 +391,14 @@ export class DefaultConfig implements Config { return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 250_000, - (p.unitsIncludingConstruction(UnitType.DefensePost).length + - 1) * - 50_000, + ? 0n + : BigInt( + Math.min( + 250_000, + (p.unitsIncludingConstruction(UnitType.DefensePost).length + + 1) * + 50_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 5 * 10, @@ -384,12 +407,14 @@ export class DefaultConfig implements Config { return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 3_000_000, - (p.unitsIncludingConstruction(UnitType.SAMLauncher).length + - 1) * - 1_500_000, + ? 0n + : BigInt( + Math.min( + 3_000_000, + (p.unitsIncludingConstruction(UnitType.SAMLauncher).length + + 1) * + 1_500_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 30 * 10, @@ -398,20 +423,22 @@ export class DefaultConfig implements Config { return { cost: (p: Player) => p.type() === PlayerType.Human && this.infiniteGold() - ? 0 - : Math.min( - 1_000_000, - Math.pow( - 2, - p.unitsIncludingConstruction(UnitType.City).length, - ) * 125_000, + ? 0n + : BigInt( + Math.min( + 1_000_000, + Math.pow( + 2, + p.unitsIncludingConstruction(UnitType.City).length, + ) * 125_000, + ), ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, }; case UnitType.Construction: return { - cost: () => 0, + cost: () => 0n, territoryBound: true, }; default: @@ -478,27 +505,34 @@ export class DefaultConfig implements Config { defenderTroopLoss: number; tilesPerTickUsed: number; } { + let mag = 0; + let speed = 0; const type = gm.terrainType(tileToConquer); - const mod = TERRAIN_EFFECTS[type]; - if (!mod) { - throw new Error(`terrain type ${type} not supported`); + switch (type) { + case TerrainType.Plains: + mag = 85; + speed = 16.5; + break; + case TerrainType.Highland: + mag = 100; + speed = 20; + break; + case TerrainType.Mountain: + mag = 120; + speed = 25; + break; + default: + throw new Error(`terrain type ${type} not supported`); } - let mag = mod.mag; - let speed = mod.speed; - - const attackerType = attacker.type(); - const defenderIsPlayer = defender.isPlayer(); - const defenderType = defenderIsPlayer ? defender.type() : null; - - if (defenderIsPlayer) { + if (defender.isPlayer()) { for (const dp of gm.nearbyUnits( tileToConquer, gm.config().defensePostRange(), UnitType.DefensePost, )) { if (dp.unit.owner() === defender) { - mag *= this.defensePostLossMultiplier(); - speed *= this.defensePostSpeedMultiplier(); + mag *= this.defensePostDefenseBonus(); + speed *= this.defensePostDefenseBonus(); break; } } @@ -510,50 +544,55 @@ export class DefaultConfig implements Config { speed *= this.falloutDefenseModifier(falloutRatio); } - if (attacker.isPlayer() && defenderIsPlayer) { + if (attacker.isPlayer() && defender.isPlayer()) { if ( - attackerType === PlayerType.Human && - defenderType === PlayerType.Bot + attacker.type() === PlayerType.Human && + defender.type() === PlayerType.Bot ) { mag *= 0.8; } if ( - attackerType === PlayerType.FakeHuman && - defenderType === PlayerType.Bot + attacker.type() === PlayerType.FakeHuman && + defender.type() === PlayerType.Bot ) { mag *= 0.8; } } - if (attackerType === PlayerType.Bot) { - speed *= 4; // slow bot attacks + + let largeLossModifier = 1; + if (attacker.numTilesOwned() > 100_000) { + largeLossModifier = Math.sqrt(100_000 / attacker.numTilesOwned()); } - if (defenderIsPlayer) { - const defenderTroops = defender.troops(); - const defenderTiles = defender.numTilesOwned(); - const defenderDensity = defenderTroops / defenderTiles; - const attackRatio = defenderTroops / attackTroops; - const traitorDebuff = defender.isTraitor() - ? this.traitorDefenseDebuff() - : 1; - const baseTroopLoss = 16; - const baseTileCost = 23; - const attackStandardSize = 10_000; + let largeSpeedMalus = 1; + if (attacker.numTilesOwned() > 75_000) { + // sqrt is only exponent 1/2 which doesn't slow enough huge players + largeSpeedMalus = (75_000 / attacker.numTilesOwned()) ** 0.6; + } + + if (defender.isPlayer()) { return { attackerTroopLoss: - mag * (baseTroopLoss + defenderDensity * traitorDebuff), - defenderTroopLoss: defenderDensity, + within(defender.troops() / attackTroops, 0.6, 2) * + mag * + 0.8 * + largeLossModifier * + (defender.isTraitor() ? this.traitorDefenseDebuff() : 1), + defenderTroopLoss: defender.troops() / defender.numTilesOwned(), tilesPerTickUsed: - baseTileCost * - within(defenderDensity, 3, 100) ** 0.2 * - (attackStandardSize / attackTroops) ** 0.1 * + within(defender.troops() / (5 * attackTroops), 0.2, 1.5) * speed * - within(attackRatio, 0.1, 20) ** 0.4, + largeSpeedMalus, }; } else { return { - attackerTroopLoss: 16 * mag, + attackerTroopLoss: + attacker.type() === PlayerType.Bot ? mag / 10 : mag / 5, defenderTroopLoss: 0, - tilesPerTickUsed: 31 * speed, + tilesPerTickUsed: within( + (2000 * Math.max(10, speed)) / attackTroops, + 5, + 100, + ), }; } } @@ -565,9 +604,13 @@ export class DefaultConfig implements Config { numAdjacentTilesWithEnemy: number, ): number { if (defender.isPlayer()) { - return 10 * numAdjacentTilesWithEnemy; + return ( + within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) * + numAdjacentTilesWithEnemy * + 3 + ); } else { - return 12 * numAdjacentTilesWithEnemy; + return numAdjacentTilesWithEnemy * 2; } } @@ -597,28 +640,28 @@ export class DefaultConfig implements Config { startManpower(playerInfo: PlayerInfo): number { if (playerInfo.playerType === PlayerType.Bot) { - return 6_000; + return 10_000; } if (playerInfo.playerType === PlayerType.FakeHuman) { switch (this._gameConfig.difficulty) { case Difficulty.Easy: - return 2_500 + 1000 * (playerInfo?.nation?.strength ?? 1); + return 2_500 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Medium: - return 6_000 + 2000 * (playerInfo?.nation?.strength ?? 1); + return 5_000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Hard: - return 20_000 + 4000 * (playerInfo?.nation?.strength ?? 1); + return 20_000 * (playerInfo?.nation?.strength ?? 1); case Difficulty.Impossible: - return 50_000 + 8000 * (playerInfo?.nation?.strength ?? 1); + return 50_000 * (playerInfo?.nation?.strength ?? 1); } } - return this.infiniteTroops() ? 1_000_000 : 20_000; + return this.infiniteTroops() ? 1_000_000 : 25_000; } maxPopulation(player: Player | PlayerView): number { const maxPop = player.type() === PlayerType.Human && this.infiniteTroops() ? 1_000_000_000 - : 1 * (player.numTilesOwned() * 30 + 50000) + + : 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) + player.units(UnitType.City).length * this.cityPopulationIncrease(); if (player.type() === PlayerType.Bot) { @@ -631,26 +674,22 @@ export class DefaultConfig implements Config { switch (this._gameConfig.difficulty) { case Difficulty.Easy: - return maxPop * 0.4; + return maxPop * 0.5; case Difficulty.Medium: - return maxPop * 0.8; + return maxPop * 1; case Difficulty.Hard: - return maxPop * 1.4; + return maxPop * 1.5; case Difficulty.Impossible: - return maxPop * 1.8; + return maxPop * 2; } } populationIncreaseRate(player: Player): number { const max = this.maxPopulation(player); - //population grows proportional to current population with growth decreasing as it approaches max - // smaller countries recieve a boost to pop growth to speed up early game - const baseAdditionRate = 10; - const basePopGrowthRate = 1300 / max + 1 / 140; - const reproductionPop = 0.8 * player.troops() + 1.2 * player.workers(); - let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop; - const totalPop = player.totalPopulation(); - const ratio = 1 - totalPop / max; + + let toAdd = 10 + Math.pow(player.population(), 0.73) / 4; + + const ratio = 1 - player.population() / max; toAdd *= ratio; if (player.type() === PlayerType.Bot) { @@ -674,15 +713,15 @@ export class DefaultConfig implements Config { } } - return Math.min(totalPop + toAdd, max) - totalPop; + return Math.min(player.population() + toAdd, max) - player.population(); } - goldAdditionRate(player: Player): number { - return 0.045 * player.workers() ** 0.7; + goldAdditionRate(player: Player): Gold { + return BigInt(Math.floor(0.045 * player.workers() ** 0.7)); } troopAdjustmentRate(player: Player): number { - const maxDiff = this.maxPopulation(player) / 500; + const maxDiff = this.maxPopulation(player) / 1000; const target = player.population() * player.targetTroopRatio(); const diff = target - player.troops(); if (Math.abs(diff) < maxDiff) { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 42d361736..6c765a63c 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -22,7 +22,6 @@ export class AttackExecution implements Execution { private random = new PseudoRandom(123); - private _owner: Player; private target: Player | TerraNullius; private mg: Game; @@ -31,7 +30,7 @@ export class AttackExecution implements Execution { constructor( private startTroops: number | null = null, - private _ownerID: PlayerID, + private _owner: Player, private _targetID: PlayerID | null, private sourceTile: TileRef | null = null, private removeTroops: boolean = true, @@ -51,18 +50,12 @@ export class AttackExecution implements Execution { } this.mg = mg; - if (!mg.hasPlayer(this._ownerID)) { - console.warn(`player ${this._ownerID} not found`); - this.active = false; - return; - } if (this._targetID !== null && !mg.hasPlayer(this._targetID)) { console.warn(`target ${this._targetID} not found`); this.active = false; return; } - this._owner = mg.player(this._ownerID); this.target = this._targetID === this.mg.terraNullius().id() ? mg.terraNullius() diff --git a/src/core/execution/BoatRetreatExecution.ts b/src/core/execution/BoatRetreatExecution.ts index bcef746a4..c6afedff1 100644 --- a/src/core/execution/BoatRetreatExecution.ts +++ b/src/core/execution/BoatRetreatExecution.ts @@ -1,30 +1,15 @@ -import { consolex } from "../Consolex"; -import { Execution, Game, Player, PlayerID, UnitType } from "../game/Game"; +import { Execution, Game, Player, UnitType } from "../game/Game"; export class BoatRetreatExecution implements Execution { private active = true; - private player: Player | undefined; constructor( - private playerID: PlayerID, + private player: Player, private unitID: number, ) {} - init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.playerID)) { - console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`); - this.active = false; - return; - } - this.player = mg.player(this.playerID); - } + init(mg: Game, ticks: number): void {} tick(ticks: number): void { - if (!this.player) { - console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`); - this.active = false; - return; - } - const unit = this.player .units() .find( @@ -33,7 +18,7 @@ export class BoatRetreatExecution implements Execution { ); if (!unit) { - consolex.warn(`Didn't find outgoing boat with id ${this.unitID}`); + console.warn(`Didn't find outgoing boat with id ${this.unitID}`); this.active = false; return; } @@ -43,9 +28,6 @@ export class BoatRetreatExecution implements Execution { } owner(): Player { - if (this.player === undefined) { - throw new Error("Not initialized"); - } return this.player; } diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 3b0354810..84d46768c 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -79,7 +79,6 @@ export class BotExecution implements Execution { } this.behavior.forgetOldEnemies(); - this.behavior.checkIncomingAttacks(); const enemy = this.behavior.selectRandomEnemy(); if (!enemy) return; if (!this.bot.sharesBorderWith(enemy)) return; diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts index 521729a67..644fbc803 100644 --- a/src/core/execution/BotSpawner.ts +++ b/src/core/execution/BotSpawner.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Game, PlayerInfo, PlayerType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; @@ -22,7 +21,7 @@ export class BotSpawner { let tries = 0; while (this.bots.length < numBots) { if (tries > 10000) { - consolex.log("too many retries while spawning bots, giving up"); + console.log("too many retries while spawning bots, giving up"); return this.bots; } const botName = this.randomBotName(); diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts index c6046f2de..880755573 100644 --- a/src/core/execution/CityExecution.ts +++ b/src/core/execution/CityExecution.ts @@ -1,40 +1,25 @@ -import { consolex } from "../Consolex"; -import { - Execution, - Game, - Player, - PlayerID, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; export class CityExecution implements Execution { - private player: Player; private mg: Game; private city: Unit | null = null; private active: boolean = true; constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`CityExecution: player ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } tick(ticks: number): void { if (this.city === null) { const spawnTile = this.player.canBuild(UnitType.City, this.tile); if (spawnTile === false) { - consolex.warn("cannot build city"); + console.warn("cannot build city"); this.active = false; return; } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 49ed2e89f..10d31ddd2 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -1,9 +1,8 @@ -import { consolex } from "../Consolex"; import { Execution, Game, + Gold, Player, - PlayerID, Tick, Unit, UnitType, @@ -19,29 +18,22 @@ import { SAMLauncherExecution } from "./SAMLauncherExecution"; import { WarshipExecution } from "./WarshipExecution"; export class ConstructionExecution implements Execution { - private player: Player; private construction: Unit | null = null; private active: boolean = true; private mg: Game; private ticksUntilComplete: Tick; - private cost: number; + private cost: Gold; constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef, private constructionType: UnitType, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`ConstructionExecution: owner ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } tick(ticks: number): void { @@ -54,7 +46,7 @@ export class ConstructionExecution implements Execution { } const spawnTile = this.player.canBuild(this.constructionType, this.tile); if (spawnTile === false) { - consolex.warn(`cannot build ${this.constructionType}`); + console.warn(`cannot build ${this.constructionType}`); this.active = false; return; } @@ -97,11 +89,11 @@ export class ConstructionExecution implements Execution { case UnitType.AtomBomb: case UnitType.HydrogenBomb: this.mg.addExecution( - new NukeExecution(this.constructionType, player.id(), this.tile), + new NukeExecution(this.constructionType, player, this.tile), ); break; case UnitType.MIRV: - this.mg.addExecution(new MirvExecution(player.id(), this.tile)); + this.mg.addExecution(new MirvExecution(player, this.tile)); break; case UnitType.Warship: this.mg.addExecution( @@ -109,19 +101,19 @@ export class ConstructionExecution implements Execution { ); break; case UnitType.Port: - this.mg.addExecution(new PortExecution(player.id(), this.tile)); + this.mg.addExecution(new PortExecution(player, this.tile)); break; case UnitType.MissileSilo: - this.mg.addExecution(new MissileSiloExecution(player.id(), this.tile)); + this.mg.addExecution(new MissileSiloExecution(player, this.tile)); break; case UnitType.DefensePost: - this.mg.addExecution(new DefensePostExecution(player.id(), this.tile)); + this.mg.addExecution(new DefensePostExecution(player, this.tile)); break; case UnitType.SAMLauncher: - this.mg.addExecution(new SAMLauncherExecution(player.id(), this.tile)); + this.mg.addExecution(new SAMLauncherExecution(player, this.tile)); break; case UnitType.City: - this.mg.addExecution(new CityExecution(player.id(), this.tile)); + this.mg.addExecution(new CityExecution(player, this.tile)); break; default: throw Error(`unit type ${this.constructionType} not supported`); diff --git a/src/core/execution/DefensePostExecution.ts b/src/core/execution/DefensePostExecution.ts index c0d6e4711..ab36f81ae 100644 --- a/src/core/execution/DefensePostExecution.ts +++ b/src/core/execution/DefensePostExecution.ts @@ -1,17 +1,8 @@ -import { consolex } from "../Consolex"; -import { - Execution, - Game, - Player, - PlayerID, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { ShellExecution } from "./ShellExecution"; export class DefensePostExecution implements Execution { - private player: Player; private mg: Game; private post: Unit | null = null; private active: boolean = true; @@ -22,18 +13,12 @@ export class DefensePostExecution implements Execution { private alreadySentShell = new Set(); constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`DefensePostExectuion: owner ${this.ownerId} not found`); - this.active = false; - return; - } - this.player = mg.player(this.ownerId); } private shoot() { @@ -63,7 +48,7 @@ export class DefensePostExecution implements Execution { if (this.post === null) { const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile); if (spawnTile === false) { - consolex.warn("cannot build Defense Post"); + console.warn("cannot build Defense Post"); this.active = false; return; } diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 166f34cde..c4b65939f 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -1,44 +1,38 @@ -import { consolex } from "../Consolex"; -import { Execution, Game, Player, PlayerID } from "../game/Game"; +import { Execution, Game, Gold, Player, PlayerID } from "../game/Game"; export class DonateGoldExecution implements Execution { - private sender: Player; private recipient: Player; private active = true; constructor( - private senderID: PlayerID, + private sender: Player, private recipientID: PlayerID, - private gold: number | null, + private gold: Gold | null, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`DonateExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn(`DonateExecution recipient ${this.recipientID} not found`); this.active = false; return; } - this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); if (this.gold === null) { - this.gold = Math.round(this.sender.gold() / 3); + this.gold = this.sender.gold() / 3n; } } tick(ticks: number): void { if (this.gold === null) throw new Error("not initialized"); - if (this.sender.canDonate(this.recipient)) { - this.sender.donateGold(this.recipient, this.gold); + if ( + this.sender.canDonate(this.recipient) && + this.sender.donateGold(this.recipient, this.gold) + ) { this.recipient.updateRelation(this.sender, 50); } else { - consolex.warn( + console.warn( `cannot send gold from ${this.sender.name()} to ${this.recipient.name()}`, ); } diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 99dc4ca66..0570ac641 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -1,45 +1,42 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Player, PlayerID } from "../game/Game"; export class DonateTroopsExecution implements Execution { - private sender: Player; private recipient: Player; private active = true; constructor( - private senderID: PlayerID, + private sender: Player, private recipientID: PlayerID, private troops: number | null, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`DonateExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn(`DonateExecution recipient ${this.recipientID} not found`); this.active = false; return; } - this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); if (this.troops === null) { this.troops = mg.config().defaultDonationAmount(this.sender); } + const maxDonation = + mg.config().maxPopulation(this.recipient) - this.recipient.population(); + this.troops = Math.min(this.troops, maxDonation); } tick(ticks: number): void { if (this.troops === null) throw new Error("not initialized"); - if (this.sender.canDonate(this.recipient)) { - this.sender.donateTroops(this.recipient, this.troops); + if ( + this.sender.canDonate(this.recipient) && + this.sender.donateTroops(this.recipient, this.troops) + ) { this.recipient.updateRelation(this.sender, 50); } else { - consolex.warn( - `cannot send tropps from ${this.sender} to ${this.recipient}`, + console.warn( + `cannot send troops from ${this.sender} to ${this.recipient}`, ); } this.active = false; diff --git a/src/core/execution/EmbargoExecution.ts b/src/core/execution/EmbargoExecution.ts index 79e4b8773..67a0664d1 100644 --- a/src/core/execution/EmbargoExecution.ts +++ b/src/core/execution/EmbargoExecution.ts @@ -10,11 +10,6 @@ export class EmbargoExecution implements Execution { ) {} init(mg: Game, _: number): void { - if (!mg.hasPlayer(this.player.id())) { - console.warn(`EmbargoExecution: sender ${this.player.id()} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.targetID)) { console.warn(`EmbargoExecution recipient ${this.targetID} not found`); this.active = false; diff --git a/src/core/execution/EmojiExecution.ts b/src/core/execution/EmojiExecution.ts index a544411e4..94f84e58d 100644 --- a/src/core/execution/EmojiExecution.ts +++ b/src/core/execution/EmojiExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { AllPlayers, Execution, @@ -10,30 +9,23 @@ import { import { flattenedEmojiTable } from "../Util"; export class EmojiExecution implements Execution { - private requestor: Player; private recipient: Player | typeof AllPlayers; private active = true; constructor( - private senderID: PlayerID, + private requestor: Player, private recipientID: PlayerID | typeof AllPlayers, private emoji: number, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`EmojiExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (this.recipientID !== AllPlayers && !mg.hasPlayer(this.recipientID)) { console.warn(`EmojiExecution: recipient ${this.recipientID} not found`); this.active = false; return; } - this.requestor = mg.player(this.senderID); this.recipient = this.recipientID === AllPlayers ? AllPlayers @@ -43,7 +35,7 @@ export class EmojiExecution implements Execution { tick(ticks: number): void { const emojiString = flattenedEmojiTable[this.emoji]; if (emojiString === undefined) { - consolex.warn( + console.warn( `cannot send emoji ${this.emoji} from ${this.requestor} to ${this.recipient}`, ); } else if (this.requestor.canSendEmoji(this.recipient)) { @@ -56,7 +48,7 @@ export class EmojiExecution implements Execution { this.recipient.updateRelation(this.requestor, -100); } } else { - consolex.warn( + console.warn( `cannot send emoji from ${this.requestor} to ${this.recipient}`, ); } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index db094ca3e..a333d95ec 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -48,21 +48,21 @@ export class Executor { console.warn(`player with clientID ${intent.clientID} not found`); return new NoOpExecution(); } - const playerID = player.id(); + // create execution switch (intent.type) { case "attack": { return new AttackExecution( intent.troops, - playerID, + player, intent.targetID, null, ); } case "cancel_attack": - return new RetreatExecution(playerID, intent.attackID); + return new RetreatExecution(player, intent.attackID); case "cancel_boat": - return new BoatRetreatExecution(playerID, intent.unitID); + return new BoatRetreatExecution(player, intent.unitID); case "move_warship": return new MoveWarshipExecution(player, intent.unitId, intent.tile); case "spawn": @@ -76,47 +76,47 @@ export class Executor { src = this.mg.ref(intent.srcX, intent.srcY); } return new TransportShipExecution( - playerID, + player, intent.targetID, this.mg.ref(intent.dstX, intent.dstY), intent.troops, src, ); case "allianceRequest": - return new AllianceRequestExecution(playerID, intent.recipient); + return new AllianceRequestExecution(player, intent.recipient); case "allianceRequestReply": return new AllianceRequestReplyExecution( intent.requestor, - playerID, + player, intent.accept, ); case "breakAlliance": - return new BreakAllianceExecution(playerID, intent.recipient); + return new BreakAllianceExecution(player, intent.recipient); case "targetPlayer": - return new TargetPlayerExecution(playerID, intent.target); + return new TargetPlayerExecution(player, intent.target); case "emoji": - return new EmojiExecution(playerID, intent.recipient, intent.emoji); + return new EmojiExecution(player, intent.recipient, intent.emoji); case "donate_troops": return new DonateTroopsExecution( - playerID, + player, intent.recipient, intent.troops, ); case "donate_gold": - return new DonateGoldExecution(playerID, intent.recipient, intent.gold); + return new DonateGoldExecution(player, intent.recipient, intent.gold); case "troop_ratio": - return new SetTargetTroopRatioExecution(playerID, intent.ratio); + return new SetTargetTroopRatioExecution(player, intent.ratio); case "embargo": return new EmbargoExecution(player, intent.targetID, intent.action); case "build_unit": return new ConstructionExecution( - playerID, + player, this.mg.ref(intent.x, intent.y), intent.unit, ); case "quick_chat": return new QuickChatExecution( - playerID, + player, intent.recipient, intent.quickChatKey, intent.variables ?? {}, diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index af71e77c2..8c03a4d49 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,9 +1,9 @@ -import { consolex } from "../Consolex"; import { Cell, Difficulty, Execution, Game, + Gold, Nation, Player, PlayerID, @@ -116,7 +116,7 @@ export class FakeHumanExecution implements Execution { if (this.mg.inSpawnPhase()) { const rl = this.randomLand(); if (rl === null) { - consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`); + console.warn(`cannot spawn ${this.nation.playerInfo.name}`); return; } this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl)); @@ -262,7 +262,6 @@ export class FakeHumanExecution implements Execution { throw new Error("not initialized"); } this.behavior.forgetOldEnemies(); - this.behavior.checkIncomingAttacks(); this.behavior.assistAllies(); const enemy = this.behavior.selectEnemy(); if (!enemy) return; @@ -283,7 +282,7 @@ export class FakeHumanExecution implements Execution { this.lastEmojiSent.set(enemy, this.mg.ticks()); this.mg.addExecution( new EmojiExecution( - this.player.id(), + this.player, enemy.id(), this.random.randElement(this.heckleEmoji), ), @@ -355,7 +354,7 @@ export class FakeHumanExecution implements Execution { const tick = this.mg.ticks(); this.lastNukeSent.push([tick, tile]); this.mg.addExecution( - new NukeExecution(UnitType.AtomBomb, this.player.id(), tile), + new NukeExecution(UnitType.AtomBomb, this.player, tile), ); } @@ -374,14 +373,21 @@ export class FakeHumanExecution implements Execution { return 50_000; case UnitType.Port: return 10_000; - case UnitType.SAMLauncher: - return 5_000; default: return 0; } }) .reduce((prev, cur) => prev + cur, 0); + // Avoid areas defended by SAM launchers + const dist50 = euclDistFN(tile, 50, false); + tileValue -= + 50_000 * + targets.filter( + (unit) => + unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()), + ).length; + // Prefer tiles that are closer to a silo const siloTiles = silos.map((u) => u.tile()); const result = closestTwoTiles(this.mg, siloTiles, [tile]); @@ -415,7 +421,7 @@ export class FakeHumanExecution implements Execution { } this.mg.addExecution( new TransportShipExecution( - this.player.id(), + this.player, other.id(), closest.y, this.player.troops() / 5, @@ -435,7 +441,7 @@ export class FakeHumanExecution implements Execution { if (oceanTiles.length > 0) { const buildTile = this.random.randElement(oceanTiles); this.mg.addExecution( - new ConstructionExecution(player.id(), buildTile, UnitType.Port), + new ConstructionExecution(player, buildTile, UnitType.Port), ); } return; @@ -464,9 +470,7 @@ export class FakeHumanExecution implements Execution { if (canBuild === false) { return; } - this.mg.addExecution( - new ConstructionExecution(this.player.id(), tile, type), - ); + this.mg.addExecution(new ConstructionExecution(this.player, tile, type)); } private maybeSpawnWarship(): boolean { @@ -488,15 +492,11 @@ export class FakeHumanExecution implements Execution { } const canBuild = this.player.canBuild(UnitType.Warship, targetTile); if (canBuild === false) { - consolex.warn("cannot spawn destroyer"); + console.warn("cannot spawn destroyer"); return false; } this.mg.addExecution( - new ConstructionExecution( - this.player.id(), - targetTile, - UnitType.Warship, - ), + new ConstructionExecution(this.player, targetTile, UnitType.Warship), ); return true; } @@ -544,7 +544,7 @@ export class FakeHumanExecution implements Execution { return null; } - private cost(type: UnitType): number { + private cost(type: UnitType): Gold { if (this.player === null) throw new Error("not initialized"); return this.mg.unitInfo(type).cost(this.player); } @@ -567,7 +567,7 @@ export class FakeHumanExecution implements Execution { this.mg.addExecution( new TransportShipExecution( - this.player.id(), + this.player, this.mg.owner(dst).id(), dst, this.player.troops() / 5, diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 41e617f8a..18411e80d 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -1,10 +1,8 @@ -import { consolex } from "../Consolex"; import { Execution, Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -16,8 +14,6 @@ import { simpleHash } from "../Util"; import { NukeExecution } from "./NukeExecution"; export class MirvExecution implements Execution { - private player: Player; - private active = true; private mg: Game; @@ -38,21 +34,14 @@ export class MirvExecution implements Execution { private speed: number = -1; constructor( - private senderID: PlayerID, + private player: Player, private dst: TileRef, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`MIRVExecution: player ${this.senderID} not found`); - this.active = false; - return; - } - - this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID)); + this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id())); this.mg = mg; this.pathFinder = new ParabolaPathFinder(mg); - this.player = mg.player(this.senderID); this.targetPlayer = this.mg.owner(this.dst); this.speed = this.mg.config().defaultNukeSpeed(); @@ -64,7 +53,7 @@ export class MirvExecution implements Execution { if (this.nuke === null) { const spawn = this.player.canBuild(UnitType.MIRV, this.dst); if (spawn === false) { - consolex.warn(`cannot build MIRV`); + console.warn(`cannot build MIRV`); this.active = false; return; } @@ -119,7 +108,7 @@ export class MirvExecution implements Execution { this.mg.addExecution( new NukeExecution( UnitType.MIRVWarhead, - this.senderID, + this.player, dst, this.nuke.tile(), 15 + Math.floor((i / this.warheadCount) * 5), diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index d9b30f9dd..7fd9459d8 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -1,44 +1,25 @@ -import { consolex } from "../Consolex"; -import { - Execution, - Game, - Player, - PlayerID, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; export class MissileSiloExecution implements Execution { private active = true; - private mg: Game | null = null; - private player: Player | null = null; + private mg: Game; private silo: Unit | null = null; constructor( - private _owner: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this._owner)) { - console.warn(`MissileSiloExecution: owner ${this._owner} not found`); - this.active = false; - return; - } - this.mg = mg; - this.player = mg.player(this._owner); } tick(ticks: number): void { - if (this.player === null || this.mg === null) { - throw new Error("Not initialized"); - } if (this.silo === null) { const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile); if (spawn === false) { - consolex.warn( + console.warn( `player ${this.player} cannot build missile silo at ${this.tile}`, ); this.active = false; diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index facdbb649..91ed7d725 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -1,10 +1,8 @@ -import { consolex } from "../Consolex"; import { Execution, Game, MessageType, Player, - PlayerID, TerraNullius, Unit, UnitType, @@ -16,8 +14,7 @@ import { NukeType } from "../StatsSchemas"; export class NukeExecution implements Execution { private active = true; - private player: Player | null = null; - private mg: Game | null = null; + private mg: Game; private nuke: Unit | null = null; private tilesToDestroyCache: Set | undefined; @@ -25,8 +22,8 @@ export class NukeExecution implements Execution { private pathFinder: ParabolaPathFinder; constructor( - private type: NukeType, - private senderID: PlayerID, + private nukeType: NukeType, + private player: Player, private dst: TileRef, private src?: TileRef | null, private speed: number = -1, @@ -34,14 +31,7 @@ export class NukeExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.senderID)) { - console.warn(`NukeExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } - this.mg = mg; - this.player = mg.player(this.senderID); this.random = new PseudoRandom(ticks); if (this.speed === -1) { this.speed = this.mg.config().defaultNukeSpeed(); @@ -50,9 +40,6 @@ export class NukeExecution implements Execution { } public target(): Player | TerraNullius { - if (this.mg === null) { - throw new Error("Not initialized"); - } return this.mg.owner(this.dst); } @@ -60,7 +47,7 @@ export class NukeExecution implements Execution { if (this.tilesToDestroyCache !== undefined) { return this.tilesToDestroyCache; } - if (this.mg === null || this.nuke === null) { + if (this.nuke === null) { throw new Error("Not initialized"); } const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); @@ -75,7 +62,7 @@ export class NukeExecution implements Execution { } private breakAlliances(toDestroy: Set) { - if (this.mg === null || this.player === null || this.nuke === null) { + if (this.nuke === null) { throw new Error("Not initialized"); } const attacked = new Map(); @@ -102,30 +89,26 @@ export class NukeExecution implements Execution { } tick(ticks: number): void { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } - if (this.nuke === null) { - const spawn = this.src ?? this.player.canBuild(this.type, this.dst); + const spawn = this.src ?? this.player.canBuild(this.nukeType, this.dst); if (spawn === false) { - consolex.warn(`cannot build Nuke`); + console.warn(`cannot build Nuke`); this.active = false; return; } this.pathFinder.computeControlPoints( spawn, this.dst, - this.type !== UnitType.MIRVWarhead, + this.nukeType !== UnitType.MIRVWarhead, ); - this.nuke = this.player.buildUnit(this.type, spawn, { + this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, }); if (this.mg.hasOwner(this.dst)) { const target = this.mg.owner(this.dst); if (!target.isPlayer()) { // Ignore terra nullius - } else if (this.type === UnitType.AtomBomb) { + } else if (this.nukeType === UnitType.AtomBomb) { this.mg.displayIncomingUnit( this.nuke.id(), `${this.player.name()} - atom bomb inbound`, @@ -133,7 +116,7 @@ export class NukeExecution implements Execution { target.id(), ); this.breakAlliances(this.tilesToDestroy()); - } else if (this.type === UnitType.HydrogenBomb) { + } else if (this.nukeType === UnitType.HydrogenBomb) { this.mg.displayIncomingUnit( this.nuke.id(), `${this.player.name()} - hydrogen bomb inbound`, @@ -144,9 +127,7 @@ export class NukeExecution implements Execution { } // Record stats - this.mg - .stats() - .bombLaunch(this.player, target, this.nuke.type() as NukeType); + this.mg.stats().bombLaunch(this.player, target, this.nukeType); } // after sending a nuke set the missilesilo on cooldown @@ -161,7 +142,7 @@ export class NukeExecution implements Execution { // make the nuke unactive if it was intercepted if (!this.nuke.isActive()) { - consolex.log(`Nuke destroyed before reaching target`); + console.log(`Nuke destroyed before reaching target`); this.active = false; return; } @@ -182,7 +163,7 @@ export class NukeExecution implements Execution { } private detonate() { - if (this.mg === null || this.nuke === null || this.player === null) { + if (this.nuke === null) { throw new Error("Not initialized"); } @@ -249,9 +230,6 @@ export class NukeExecution implements Execution { } owner(): Player { - if (this.player === null) { - throw new Error("Not initialized"); - } return this.player; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index d68d1eae5..6d68ca783 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -17,35 +17,25 @@ import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; - private player: Player | null = null; - private config: Config | null = null; + private config: Config; private lastCalc = 0; - private mg: Game | null = null; + private mg: Game; private active = true; - constructor(private playerID: PlayerID) {} + constructor(private player: Player) {} activeDuringSpawnPhase(): boolean { return false; } init(mg: Game, ticks: number) { - if (!mg.hasPlayer(this.playerID)) { - console.warn(`PlayerExecution: player ${this.playerID} not found`); - this.active = false; - return; - } this.mg = mg; this.config = mg.config(); - this.player = mg.player(this.playerID); this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc); } tick(ticks: number) { - if (this.mg === null || this.config === null || this.player === null) { - throw new Error("Not initialized"); - } this.player.decayRelations(); this.player.units().forEach((u) => { const tileOwner = this.mg!.owner(u.tile()); @@ -133,16 +123,13 @@ export class PlayerExecution implements Execution { this.removeClusters(); const end = performance.now(); if (end - start > 1000) { - consolex.log(`player ${this.player.name()}, took ${end - start}ms`); + console.log(`player ${this.player.name()}, took ${end - start}ms`); } } } } private removeClusters() { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const clusters = this.calculateClusters(); clusters.sort((a, b) => b.size - a.size); @@ -162,9 +149,6 @@ export class PlayerExecution implements Execution { } private surroundedBySamePlayer(cluster: Set): false | Player { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const enemies = new Set(); for (const tile of cluster) { const isOceanShore = this.mg.isOceanShore(tile); @@ -199,9 +183,6 @@ export class PlayerExecution implements Execution { } private isSurrounded(cluster: Set): boolean { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const enemyTiles = new Set(); for (const tr of cluster) { if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { @@ -225,9 +206,6 @@ export class PlayerExecution implements Execution { } private removeCluster(cluster: Set) { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } if ( Array.from(cluster).some( (t) => this.mg?.ownerID(t) !== this.player?.smallID(), @@ -309,9 +287,6 @@ export class PlayerExecution implements Execution { } private getCapturingPlayer(cluster: Set): Player | null { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const neighborsIDs = new Set(); for (const t of cluster) { for (const neighbor of this.mg.neighbors(t)) { @@ -354,9 +329,6 @@ export class PlayerExecution implements Execution { } private calculateClusters(): Set[] { - if (this.mg === null || this.player === null) { - throw new Error("Not initialized"); - } const seen = new Set(); const border = this.player.borderTiles(); const clusters: Set[] = []; diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index 74b6554f6..1f5e10d64 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -1,12 +1,4 @@ -import { consolex } from "../Consolex"; -import { - Execution, - Game, - Player, - PlayerID, - Unit, - UnitType, -} from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { TradeShipExecution } from "./TradeShipExecution"; @@ -19,16 +11,11 @@ export class PortExecution implements Execution { private checkOffset: number | null = null; constructor( - private _owner: PlayerID, + private player: Player, private tile: TileRef, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this._owner)) { - console.warn(`PortExecution: player ${this._owner} not found`); - this.active = false; - return; - } this.mg = mg; this.random = new PseudoRandom(mg.ticks()); this.checkOffset = mg.ticks() % 10; @@ -40,14 +27,15 @@ export class PortExecution implements Execution { } if (this.port === null) { const tile = this.tile; - const player = this.mg.player(this._owner); - const spawn = player.canBuild(UnitType.Port, tile); + const spawn = this.player.canBuild(UnitType.Port, tile); if (spawn === false) { - consolex.warn(`player ${player} cannot build port at ${this.tile}`); + console.warn( + `player ${this.player.id()} cannot build port at ${this.tile}`, + ); this.active = false; return; } - this.port = player.buildUnit(UnitType.Port, spawn, {}); + this.port = this.player.buildUnit(UnitType.Port, spawn, {}); } if (!this.port.isActive()) { @@ -55,8 +43,8 @@ export class PortExecution implements Execution { return; } - if (this._owner !== this.port.owner().id()) { - this._owner = this.port.owner().id(); + if (this.player.id() !== this.port.owner().id()) { + this.player = this.port.owner(); } // Only check every 10 ticks for performance. @@ -71,16 +59,14 @@ export class PortExecution implements Execution { return; } - const ports = this.player().tradingPorts(this.port); + const ports = this.player.tradingPorts(this.port); if (ports.length === 0) { return; } const port = this.random.randElement(ports); - this.mg.addExecution( - new TradeShipExecution(this.player().id(), this.port, port), - ); + this.mg.addExecution(new TradeShipExecution(this.player, this.port, port)); } isActive(): boolean { @@ -90,11 +76,4 @@ export class PortExecution implements Execution { activeDuringSpawnPhase(): boolean { return false; } - - player(): Player { - if (this.port === null) { - throw new Error("Not initialized"); - } - return this.port.owner(); - } } diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts index 002171a83..4e545b6c5 100644 --- a/src/core/execution/QuickChatExecution.ts +++ b/src/core/execution/QuickChatExecution.ts @@ -1,15 +1,13 @@ -import { consolex } from "../Consolex"; import { Execution, Game, Player, PlayerID } from "../game/Game"; export class QuickChatExecution implements Execution { - private sender: Player; private recipient: Player; private mg: Game; private active = true; constructor( - private senderID: PlayerID, + private sender: Player, private recipientID: PlayerID, private quickChatKey: string, private variables: Record, @@ -17,20 +15,14 @@ export class QuickChatExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.senderID)) { - consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { - consolex.warn( + console.warn( `QuickChatExecution: recipient ${this.recipientID} not found`, ); this.active = false; return; } - this.sender = mg.player(this.senderID); this.recipient = mg.player(this.recipientID); } @@ -55,7 +47,7 @@ export class QuickChatExecution implements Execution { this.recipient.name(), ); - consolex.log( + console.log( `[QuickChat] ${this.sender.name} โ†’ ${this.recipient.name}: ${message}`, ); diff --git a/src/core/execution/RetreatExecution.ts b/src/core/execution/RetreatExecution.ts index c40929adc..3383aec4c 100644 --- a/src/core/execution/RetreatExecution.ts +++ b/src/core/execution/RetreatExecution.ts @@ -1,26 +1,19 @@ -import { Execution, Game, Player, PlayerID } from "../game/Game"; +import { Execution, Game, Player } from "../game/Game"; const cancelDelay = 20; export class RetreatExecution implements Execution { private active = true; private retreatOrdered = false; - private player: Player; private startTick: number; private mg: Game; constructor( - private playerID: PlayerID, + private player: Player, private attackID: string, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.playerID)) { - console.warn(`RetreatExecution: player ${this.playerID} not found`); - return; - } this.mg = mg; - - this.player = mg.player(this.playerID); this.startTick = mg.ticks(); } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index e2547218c..9fffdaab3 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -1,10 +1,8 @@ -import { consolex } from "../Consolex"; import { Execution, Game, MessageType, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; @@ -13,11 +11,11 @@ import { PseudoRandom } from "../PseudoRandom"; import { SAMMissileExecution } from "./SAMMissileExecution"; export class SAMLauncherExecution implements Execution { - private player: Player; private mg: Game; private active: boolean = true; private searchRangeRadius = 80; + private targetRangeRadius = 120; // Nuke's target should be in this range to be focusable // As MIRV go very fast we have to detect them very early but we only // shoot the one targeting very close (MIRVWarheadProtectionRadius) private MIRVWarheadSearchRadius = 400; @@ -26,7 +24,7 @@ export class SAMLauncherExecution implements Execution { private pseudoRandom: PseudoRandom | undefined; constructor( - private ownerId: PlayerID, + private player: Player, private tile: TileRef | null, private sam: Unit | null = null, ) { @@ -37,12 +35,18 @@ export class SAMLauncherExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - if (!mg.hasPlayer(this.ownerId)) { - console.warn(`SAMLauncherExecution: owner ${this.ownerId} not found`); - this.active = false; - return; + } + + private nukeTargetInRange(nuke: Unit) { + const targetTile = nuke.targetTile(); + if (this.sam === null || targetTile === undefined) { + return false; } - this.player = mg.player(this.ownerId); + const targetRangeSquared = this.targetRangeRadius * this.targetRangeRadius; + return ( + this.mg.euclideanDistSquared(this.sam.tile(), targetTile) < + targetRangeSquared + ); } private getSingleTarget(): Unit | null { @@ -54,7 +58,9 @@ export class SAMLauncherExecution implements Execution { ]) .filter( ({ unit }) => - unit.owner() !== this.player && !this.player.isFriendly(unit.owner()), + unit.owner() !== this.player && + !this.player.isFriendly(unit.owner()) && + this.nukeTargetInRange(unit), ); return ( @@ -102,7 +108,7 @@ export class SAMLauncherExecution implements Execution { } const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile); if (spawnTile === false) { - consolex.warn("cannot build SAM Launcher"); + console.warn("cannot build SAM Launcher"); this.active = false; return; } diff --git a/src/core/execution/SetTargetTroopRatioExecution.ts b/src/core/execution/SetTargetTroopRatioExecution.ts index 2d143e245..d43834003 100644 --- a/src/core/execution/SetTargetTroopRatioExecution.ts +++ b/src/core/execution/SetTargetTroopRatioExecution.ts @@ -1,28 +1,18 @@ -import { consolex } from "../Consolex"; -import { Execution, Game, Player, PlayerID } from "../game/Game"; +import { Execution, Game, Player } from "../game/Game"; export class SetTargetTroopRatioExecution implements Execution { - private player: Player; - private active = true; constructor( - private playerID: PlayerID, + private player: Player, private targetTroopsRatio: number, ) {} - init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.playerID)) { - console.warn( - `SetTargetTRoopRatioExecution: player ${this.playerID} not found`, - ); - } - this.player = mg.player(this.playerID); - } + init(mg: Game, ticks: number): void {} tick(ticks: number): void { if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) { - consolex.warn( + console.warn( `target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`, ); } else { diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index add0550a4..d6d45b3d4 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -38,7 +38,7 @@ export class SpawnExecution implements Execution { }); if (!player.hasSpawned()) { - this.mg.addExecution(new PlayerExecution(player.id())); + this.mg.addExecution(new PlayerExecution(player)); if (player.type() === PlayerType.Bot) { this.mg.addExecution(new BotExecution(player)); } diff --git a/src/core/execution/TargetPlayerExecution.ts b/src/core/execution/TargetPlayerExecution.ts index 8b22d2998..e6e454534 100644 --- a/src/core/execution/TargetPlayerExecution.ts +++ b/src/core/execution/TargetPlayerExecution.ts @@ -1,31 +1,22 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; export class TargetPlayerExecution implements Execution { - private requestor: Player; private target: Player; private active = true; constructor( - private requestorID: PlayerID, + private requestor: Player, private targetID: PlayerID, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.requestorID)) { - console.warn( - `TargetPlayerExecution: requestor ${this.requestorID} not found`, - ); - this.active = false; - return; - } if (!mg.hasPlayer(this.targetID)) { console.warn(`TargetPlayerExecution: target ${this.targetID} not found`); this.active = false; return; } - this.requestor = mg.player(this.requestorID); this.target = mg.player(this.targetID); } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 3094b9384..2ae94dca1 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -1,11 +1,9 @@ import { renderNumber } from "../../client/Utils"; -import { consolex } from "../Consolex"; import { Execution, Game, MessageType, Player, - PlayerID, Unit, UnitType, } from "../game/Game"; @@ -17,20 +15,18 @@ import { distSortUnit } from "../Util"; export class TradeShipExecution implements Execution { private active = true; private mg: Game; - private origOwner: Player; private tradeShip: Unit | undefined; private wasCaptured = false; private pathFinder: PathFinder; constructor( - private _owner: PlayerID, + private origOwner: Player, private srcPort: Unit, private _dstPort: Unit, ) {} init(mg: Game, ticks: number): void { this.mg = mg; - this.origOwner = mg.player(this._owner); this.pathFinder = PathFinder.Mini(mg, 2500); } @@ -41,7 +37,7 @@ export class TradeShipExecution implements Execution { this.srcPort.tile(), ); if (spawn === false) { - consolex.warn(`cannot build trade ship`); + console.warn(`cannot build trade ship`); this.active = false; return; } @@ -115,7 +111,7 @@ export class TradeShipExecution implements Execution { this.tradeShip.move(result.tile); break; case PathFindResultType.PathNotFound: - consolex.warn("captured trade ship cannot find route"); + console.warn("captured trade ship cannot find route"); if (this.tradeShip.isActive()) { this.tradeShip.delete(false); } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 8d59d34d9..78cc71c06 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -24,7 +23,6 @@ export class TransportShipExecution implements Execution { private active = true; private mg: Game; - private attacker: Player; private target: Player | TerraNullius; // TODO make private @@ -36,7 +34,7 @@ export class TransportShipExecution implements Execution { private pathFinder: PathFinder; constructor( - private attackerID: PlayerID, + private attacker: Player, private targetID: PlayerID | null, private ref: TileRef, private troops: number, @@ -48,13 +46,6 @@ export class TransportShipExecution implements Execution { } init(mg: Game, ticks: number) { - if (!mg.hasPlayer(this.attackerID)) { - console.warn( - `TransportShipExecution: attacker ${this.attackerID} not found`, - ); - this.active = false; - return; - } if (this.targetID !== null && !mg.hasPlayer(this.targetID)) { console.warn(`TransportShipExecution: target ${this.targetID} not found`); this.active = false; @@ -65,8 +56,6 @@ export class TransportShipExecution implements Execution { this.mg = mg; this.pathFinder = PathFinder.Mini(mg, 10_000, 10); - this.attacker = mg.player(this.attackerID); - if ( this.attacker.units(UnitType.TransportShip).length >= mg.config().boatMaxNumber() @@ -74,7 +63,7 @@ export class TransportShipExecution implements Execution { mg.displayMessage( `No boats available, max ${mg.config().boatMaxNumber()}`, MessageType.WARN, - this.attackerID, + this.attacker.id(), ); this.active = false; this.attacker.addTroops(this.troops); @@ -100,7 +89,7 @@ export class TransportShipExecution implements Execution { this.dst = targetTransportTile(this.mg, this.ref); if (this.dst === null) { - consolex.warn( + console.warn( `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, ); this.active = false; @@ -112,7 +101,7 @@ export class TransportShipExecution implements Execution { this.dst, ); if (closestTileSrc === false) { - consolex.warn(`can't build transport ship`); + console.warn(`can't build transport ship`); this.active = false; return; } @@ -176,7 +165,7 @@ export class TransportShipExecution implements Execution { switch (result.type) { case PathFindResultType.Completed: if (this.mg.owner(this.dst) === this.attacker) { - this.attacker.addTroops(this.troops); + this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); this.active = false; @@ -193,7 +182,7 @@ export class TransportShipExecution implements Execution { this.mg.addExecution( new AttackExecution( this.troops, - this.attacker.id(), + this.attacker, this.targetID, this.dst, false, @@ -215,7 +204,7 @@ export class TransportShipExecution implements Execution { break; case PathFindResultType.PathNotFound: // TODO: add to poisoned port list - consolex.warn(`path not found to dst`); + console.warn(`path not found to dst`); this.attacker.addTroops(this.troops); this.boat.delete(false); this.active = false; diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index f5400c74d..969eec2e0 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Execution, Game, @@ -79,28 +78,45 @@ export class WarshipExecution implements Execution { const hasPort = this.warship.owner().units(UnitType.Port).length > 0; const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2; - const ships = this.mg - .nearbyUnits( - this.warship.patrolTile()!, - this.mg.config().warshipTargettingRange(), - [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip], - ) - .filter( - ({ unit }) => - unit.owner() !== this.warship.owner() && - unit !== this.warship && - !unit.owner().isFriendly(this.warship.owner()) && - !this.alreadySentShell.has(unit) && - (unit.type() !== UnitType.TradeShip || - (hasPort && - this.mg.euclideanDistSquared(this.warship.tile(), unit.tile()) <= - patrolRangeSquared && - unit.targetUnit()?.owner() !== this.warship.owner() && - !unit.targetUnit()?.owner().isFriendly(this.warship.owner()) && - unit.isSafeFromPirates() !== true)), - ); + const ships = this.mg.nearbyUnits( + this.warship.tile()!, + this.mg.config().warshipTargettingRange(), + [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip], + ); + const potentialTargets: { unit: Unit; distSquared: number }[] = []; + for (const { unit, distSquared } of ships) { + if ( + unit.owner() === this.warship.owner() || + unit === this.warship || + unit.owner().isFriendly(this.warship.owner()) || + this.alreadySentShell.has(unit) + ) { + continue; + } + if (unit.type() === UnitType.TradeShip) { + if ( + !hasPort || + unit.isSafeFromPirates() || + unit.targetUnit()?.owner() === this.warship.owner() || // trade ship is coming to my port + unit.targetUnit()?.owner().isFriendly(this.warship.owner()) // trade ship is coming to my ally + ) { + continue; + } + if ( + this.mg.euclideanDistSquared( + this.warship.patrolTile()!, + unit.tile(), + ) > patrolRangeSquared + ) { + // Prevent warship from chasing trade ship that is too far away from + // the patrol tile to prevent warships from wandering around the map. + continue; + } + } + potentialTargets.push({ unit: unit, distSquared }); + } - return ships.sort((a, b) => { + return potentialTargets.sort((a, b) => { const { unit: unitA, distSquared: distA } = a; const { unit: unitB, distSquared: distB } = b; @@ -175,7 +191,7 @@ export class WarshipExecution implements Execution { this.warship.touch(); break; case PathFindResultType.PathNotFound: - consolex.log(`path not found to target`); + console.log(`path not found to target`); break; } } @@ -205,7 +221,7 @@ export class WarshipExecution implements Execution { this.warship.touch(); return; case PathFindResultType.PathNotFound: - consolex.warn(`path not found to target tile`); + console.warn(`path not found to target tile`); this.warship.setTargetTile(undefined); break; } diff --git a/src/core/execution/alliance/AllianceRequestExecution.ts b/src/core/execution/alliance/AllianceRequestExecution.ts index 7698b0c82..419b8b92c 100644 --- a/src/core/execution/alliance/AllianceRequestExecution.ts +++ b/src/core/execution/alliance/AllianceRequestExecution.ts @@ -1,24 +1,15 @@ -import { consolex } from "../../Consolex"; import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class AllianceRequestExecution implements Execution { private active = true; - private requestor: Player | null = null; private recipient: Player | null = null; constructor( - private requestorID: PlayerID, + private requestor: Player, private recipientID: PlayerID, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.requestorID)) { - console.warn( - `AllianceRequestExecution requester ${this.requestorID} not found`, - ); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn( `AllianceRequestExecution recipient ${this.recipientID} not found`, @@ -27,18 +18,17 @@ export class AllianceRequestExecution implements Execution { return; } - this.requestor = mg.player(this.requestorID); this.recipient = mg.player(this.recipientID); } tick(ticks: number): void { - if (this.requestor === null || this.recipient === null) { + if (this.recipient === null) { throw new Error("Not initialized"); } if (this.requestor.isFriendly(this.recipient)) { - consolex.warn("already allied"); + console.warn("already allied"); } else if (!this.requestor.canSendAllianceRequest(this.recipient)) { - consolex.warn("recent or pending alliance request"); + console.warn("recent or pending alliance request"); } else { this.requestor.createAllianceRequest(this.recipient); } diff --git a/src/core/execution/alliance/AllianceRequestReplyExecution.ts b/src/core/execution/alliance/AllianceRequestReplyExecution.ts index 3c6bdc5e7..bd3d90a58 100644 --- a/src/core/execution/alliance/AllianceRequestReplyExecution.ts +++ b/src/core/execution/alliance/AllianceRequestReplyExecution.ts @@ -1,14 +1,12 @@ -import { consolex } from "../../Consolex"; import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class AllianceRequestReplyExecution implements Execution { private active = true; private requestor: Player | null = null; - private recipient: Player | null = null; constructor( private requestorID: PlayerID, - private recipientID: PlayerID, + private recipient: Player, private accept: boolean, ) {} @@ -20,29 +18,21 @@ export class AllianceRequestReplyExecution implements Execution { this.active = false; return; } - if (!mg.hasPlayer(this.recipientID)) { - console.warn( - `AllianceRequestReplyExecution recipient ${this.recipientID} not found`, - ); - this.active = false; - return; - } this.requestor = mg.player(this.requestorID); - this.recipient = mg.player(this.recipientID); } tick(ticks: number): void { - if (this.requestor === null || this.recipient === null) { + if (this.requestor === null) { throw new Error("Not initialized"); } if (this.requestor.isFriendly(this.recipient)) { - consolex.warn("already allied"); + console.warn("already allied"); } else { const request = this.requestor .outgoingAllianceRequests() .find((ar) => ar.recipient() === this.recipient); if (request === undefined) { - consolex.warn("no alliance request found"); + console.warn("no alliance request found"); } else { if (this.accept) { request.accept(); diff --git a/src/core/execution/alliance/BreakAllianceExecution.ts b/src/core/execution/alliance/BreakAllianceExecution.ts index 65e2ebc16..de614c1cc 100644 --- a/src/core/execution/alliance/BreakAllianceExecution.ts +++ b/src/core/execution/alliance/BreakAllianceExecution.ts @@ -1,25 +1,16 @@ -import { consolex } from "../../Consolex"; import { Execution, Game, Player, PlayerID } from "../../game/Game"; export class BreakAllianceExecution implements Execution { private active = true; - private requestor: Player | null = null; private recipient: Player | null = null; private mg: Game | null = null; constructor( - private requestorID: PlayerID, + private requestor: Player, private recipientID: PlayerID, ) {} init(mg: Game, ticks: number): void { - if (!mg.hasPlayer(this.requestorID)) { - console.warn( - `BreakAllianceExecution requester ${this.requestorID} not found`, - ); - this.active = false; - return; - } if (!mg.hasPlayer(this.recipientID)) { console.warn( `BreakAllianceExecution: recipient ${this.recipientID} not found`, @@ -27,7 +18,6 @@ export class BreakAllianceExecution implements Execution { this.active = false; return; } - this.requestor = mg.player(this.requestorID); this.recipient = mg.player(this.recipientID); this.mg = mg; } @@ -42,7 +32,7 @@ export class BreakAllianceExecution implements Execution { } const alliance = this.requestor.allianceWith(this.recipient); if (alliance === null) { - consolex.warn("cant break alliance, not allied"); + console.warn("cant break alliance, not allied"); } else { this.requestor.breakAlliance(alliance); this.recipient.updateRelation(this.requestor, -200); diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index afbb53331..39ff00907 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -40,9 +40,7 @@ export class BotBehavior { private emoji(player: Player, emoji: number) { if (player.type() !== PlayerType.Human) return; - this.game.addExecution( - new EmojiExecution(this.player.id(), player.id(), emoji), - ); + this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } forgetOldEnemies() { @@ -52,7 +50,7 @@ export class BotBehavior { } } - checkIncomingAttacks() { + private checkIncomingAttacks() { // Switch enemies if we're under attack const incomingAttacks = this.player.incomingAttacks(); if (incomingAttacks.length > 0) { @@ -109,6 +107,11 @@ export class BotBehavior { } } + // Retaliate against incoming attacks + if (this.enemy === null) { + this.checkIncomingAttacks(); + } + // Select the most hated player if (this.enemy === null) { const mostHated = this.player.allRelationsSorted()[0]; @@ -145,8 +148,15 @@ export class BotBehavior { this.enemy = neighbor; this.enemyUpdated = this.game.ticks(); } + } - // Select a traitor as an enemy + // Retaliate against incoming attacks + if (this.enemy === null) { + this.checkIncomingAttacks(); + } + + // Select a traitor as an enemy + if (this.enemy === null) { const traitors = this.player .neighbors() .filter((n) => n.isPlayer() && n.isTraitor()) as Player[]; @@ -182,7 +192,7 @@ export class BotBehavior { this.game.addExecution( new AttackExecution( troops, - this.player.id(), + this.player, target.isPlayer() ? target.id() : null, ), ); @@ -190,11 +200,12 @@ export class BotBehavior { } function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) { - const notTraitor = !request.requestor().isTraitor(); - const noMalice = player.relation(request.requestor()) >= Relation.Neutral; + const isTraitor = request.requestor().isTraitor(); + const hasMalice = player.relation(request.requestor()) < Relation.Neutral; const requestorIsMuchLarger = request.requestor().numTilesOwned() > player.numTilesOwned() * 3; - const notTooManyAlliances = - requestorIsMuchLarger || request.requestor().alliances().length < 3; - return notTraitor && noMalice && notTooManyAlliances; + const tooManyAlliances = request.requestor().alliances().length >= 3; + return ( + !isTraitor && !hasMalice && (requestorIsMuchLarger || !tooManyAlliances) + ); } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index b7c0b607d..cccefbb6d 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -12,7 +12,7 @@ import { Stats } from "./Stats"; export type PlayerID = string; export type Tick = number; -export type Gold = number; +export type Gold = bigint; export const AllPlayers = "AllPlayers" as const; @@ -70,7 +70,7 @@ export enum GameMapType { GatewayToTheAtlantic = "Gateway to the Atlantic", Australia = "Australia", Iceland = "Iceland", - Japan = "Japan", + EastAsia = "East Asia", BetweenTwoSeas = "Between Two Seas", FaroeIslands = "Faroe Islands", DeglaciatedAntarctica = "Deglaciated Antarctica", @@ -97,7 +97,7 @@ export const mapCategories: Record = { GameMapType.GatewayToTheAtlantic, GameMapType.BetweenTwoSeas, GameMapType.Iceland, - GameMapType.Japan, + GameMapType.EastAsia, GameMapType.Mena, GameMapType.Australia, GameMapType.FaroeIslands, @@ -449,12 +449,11 @@ export interface Player { // Resources & Population gold(): Gold; population(): number; - totalPopulation(): number; workers(): number; troops(): number; targetTroopRatio(): number; addGold(toAdd: Gold): void; - removeGold(toRemove: Gold): void; + removeGold(toRemove: Gold): Gold; addWorkers(toAdd: number): void; removeWorkers(toRemove: number): void; setTargetTroopRatio(target: number): void; @@ -511,8 +510,8 @@ export interface Player { // Donation canDonate(recipient: Player): boolean; - donateTroops(recipient: Player, troops: number): void; - donateGold(recipient: Player, gold: number): void; + donateTroops(recipient: Player, troops: number): boolean; + donateGold(recipient: Player, gold: Gold): boolean; // Embargo hasEmbargoAgainst(other: Player): boolean; @@ -624,7 +623,7 @@ export interface PlayerActions { export interface BuildableUnit { canBuild: TileRef | false; type: UnitType; - cost: number; + cost: Gold; } export interface PlayerProfile { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index f4ce9ea10..af7a1c41c 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,5 +1,4 @@ import { Config } from "../configuration/Config"; -import { consolex } from "../Consolex"; import { AllPlayersStats, ClientID } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; @@ -196,7 +195,7 @@ export class GameImpl implements Game { recipient: Player, ): AllianceRequest | null { if (requestor.isAlliedWith(recipient)) { - consolex.log("cannot request alliance, already allied"); + console.log("cannot request alliance, already allied"); return null; } if ( @@ -204,14 +203,14 @@ export class GameImpl implements Game { .incomingAllianceRequests() .find((ar) => ar.requestor() === requestor) !== undefined ) { - consolex.log(`duplicate alliance request from ${requestor.name()}`); + console.log(`duplicate alliance request from ${requestor.name()}`); return null; } const correspondingReq = requestor .incomingAllianceRequests() .find((ar) => ar.requestor() === recipient); if (correspondingReq !== undefined) { - consolex.log(`got corresponding alliance requests, accepting`); + console.log(`got corresponding alliance requests, accepting`); correspondingReq.accept(); return null; } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index cbd1b7e78..7078d6b9f 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -2,6 +2,7 @@ import { AllPlayersStats, ClientID } from "../Schemas"; import { EmojiMessage, GameUpdates, + Gold, MessageType, NameViewData, PlayerID, @@ -104,9 +105,8 @@ export interface PlayerUpdate { isAlive: boolean; isDisconnected: boolean; tilesOwned: number; - gold: number; + gold: Gold; population: number; - totalPopulation: number; workers: number; troops: number; targetTroopRatio: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 7684bcbfe..08bdab9b5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -229,9 +229,6 @@ export class PlayerView { population(): number { return this.data.population; } - totalPopulation(): number { - return this.data.totalPopulation; - } workers(): number { return this.data.workers; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 80724feba..da8a1efed 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1,5 +1,4 @@ import { renderNumber, renderTroops } from "../../client/Utils"; -import { consolex } from "../Consolex"; import { PseudoRandom } from "../PseudoRandom"; import { ClientID } from "../Schemas"; import { @@ -139,9 +138,8 @@ export class PlayerImpl implements Player { isAlive: this.isAlive(), isDisconnected: this.isDisconnected(), tilesOwned: this.numTilesOwned(), - gold: Number(this._gold), + gold: this._gold, population: this.population(), - totalPopulation: this.totalPopulation(), workers: this.workers(), troops: this.troops(), targetTroopRatio: this.targetTroopRatio(), @@ -296,7 +294,7 @@ export class PlayerImpl implements Player { orderRetreat(id: string) { const attack = this._outgoingAttacks.filter((attack) => attack.id() === id); if (!attack || !attack[0]) { - consolex.warn(`Didn't find outgoing attack with id ${id}`); + console.warn(`Didn't find outgoing attack with id ${id}`); return; } attack[0].orderRetreat(); @@ -566,9 +564,13 @@ export class PlayerImpl implements Player { return true; } - donateTroops(recipient: Player, troops: number): void { + donateTroops(recipient: Player, troops: number): boolean { + if (troops <= 0) return false; + const removed = this.removeTroops(troops); + if (removed === 0) return false; + recipient.addTroops(removed); + this.sentDonations.push(new Donation(recipient, this.mg.ticks())); - recipient.addTroops(this.removeTroops(troops)); this.mg.displayMessage( `Sent ${renderTroops(troops)} troops to ${recipient.name()}`, MessageType.INFO, @@ -579,10 +581,16 @@ export class PlayerImpl implements Player { MessageType.SUCCESS, recipient.id(), ); + return true; } - donateGold(recipient: Player, gold: number): void { + + donateGold(recipient: Player, gold: Gold): boolean { + if (gold <= 0n) return false; + const removed = this.removeGold(gold); + if (removed === 0n) return false; + recipient.addGold(removed); + this.sentDonations.push(new Donation(recipient, this.mg.ticks())); - recipient.addGold(this.removeGold(gold)); this.mg.displayMessage( `Sent ${renderNumber(gold)} gold to ${recipient.name()}`, MessageType.INFO, @@ -593,6 +601,7 @@ export class PlayerImpl implements Player { MessageType.SUCCESS, recipient.id(), ); + return true; } hasEmbargoAgainst(other: Player): boolean { @@ -659,40 +668,25 @@ export class PlayerImpl implements Player { } gold(): Gold { - return Number(this._gold); + return this._gold; } addGold(toAdd: Gold): void { - this._gold += toInt(toAdd); + this._gold += toAdd; } - removeGold(toRemove: Gold): number { - if (toRemove <= 1) { - return 0; + removeGold(toRemove: Gold): Gold { + if (toRemove <= 0n) { + return 0n; } - const actualRemoved = minInt(this._gold, toInt(toRemove)); + const actualRemoved = minInt(this._gold, toRemove); this._gold -= actualRemoved; - return Number(actualRemoved); + return actualRemoved; } population(): number { return Number(this._troops + this._workers); } - totalPopulation(): number { - return this.population() + this.attackingTroops(); - } - private attackingTroops(): number { - const landAttackTroops = this._outgoingAttacks - .filter((a) => a.isActive()) - .reduce((sum, a) => sum + a.troops(), 0); - - const boatTroops = this.units(UnitType.TransportShip) - .map((u) => u.troops()) - .reduce((sum, n) => sum + n, 0); - - return landAttackTroops + boatTroops; - } - workers(): number { return Math.max(1, Number(this._workers)); } @@ -728,7 +722,7 @@ export class PlayerImpl implements Player { this._troops += toInt(troops); } removeTroops(troops: number): number { - if (troops <= 1) { + if (troops <= 0) { return 0; } const toRemove = minInt(this._troops, toInt(troops)); diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index e77e83716..baefc6623 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -1,4 +1,6 @@ -import { PlayerInfo, Team } from "./Game"; +import { PseudoRandom } from "../PseudoRandom"; +import { simpleHash } from "../Util"; +import { PlayerInfo, PlayerType, Team } from "./Game"; export function assignTeams( players: PlayerInfo[], @@ -57,7 +59,19 @@ export function assignTeams( } // Then, assign non-clan players to balance teams - for (const player of noClanPlayers) { + let nationPlayers = noClanPlayers.filter( + (player) => player.playerType === PlayerType.FakeHuman, + ); + if (nationPlayers.length > 0) { + // Shuffle only nations to randomize their team assignment + const random = new PseudoRandom(simpleHash(nationPlayers[0].id)); + nationPlayers = random.shuffleArray(nationPlayers); + } + const otherPlayers = noClanPlayers.filter( + (player) => player.playerType !== PlayerType.FakeHuman, + ); + + for (const player of otherPlayers.concat(nationPlayers)) { let team: Team | null = null; let teamSize = 0; for (const t of teams) { diff --git a/src/core/game/TerrainMapFileLoader.ts b/src/core/game/TerrainMapFileLoader.ts index 821209f93..5edbe9b67 100644 --- a/src/core/game/TerrainMapFileLoader.ts +++ b/src/core/game/TerrainMapFileLoader.ts @@ -39,7 +39,7 @@ const MAP_FILE_NAMES: Record = { [GameMapType.GatewayToTheAtlantic]: "GatewayToTheAtlantic", [GameMapType.Australia]: "Australia", [GameMapType.Iceland]: "Iceland", - [GameMapType.Japan]: "Japan", + [GameMapType.EastAsia]: "EastAsia", [GameMapType.BetweenTwoSeas]: "BetweenTwoSeas", [GameMapType.FaroeIslands]: "FaroeIslands", [GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica", diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index adf2399fb..73098359e 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { GameMapType } from "./Game"; import { GameMap, GameMapImpl } from "./GameMap"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; @@ -65,13 +64,13 @@ export async function genTerrainFromBin(data: string): Promise { } function logBinaryAsAscii(data: string, length: number = 8) { - consolex.log("Binary data (1 = set bit, 0 = unset bit):"); + console.log("Binary data (1 = set bit, 0 = unset bit):"); for (let i = 0; i < Math.min(length, data.length); i++) { const byte = data.charCodeAt(i); let byteString = ""; for (let j = 7; j >= 0; j--) { byteString += byte & (1 << j) ? "1" : "0"; } - consolex.log(`Byte ${i}: ${byteString}`); + console.log(`Byte ${i}: ${byteString}`); } } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 9d26439e0..049407ad0 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -1,4 +1,3 @@ -import { consolex } from "../Consolex"; import { Game } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; @@ -117,11 +116,11 @@ export class PathFinder { dist: number = 1, ): TileResult { if (curr === null) { - consolex.error("curr is null"); + console.error("curr is null"); return { type: PathFindResultType.PathNotFound }; } if (dst === null) { - consolex.error("dst is null"); + console.error("dst is null"); return { type: PathFindResultType.PathNotFound }; } diff --git a/src/core/pathfinding/SerialAStar.ts b/src/core/pathfinding/SerialAStar.ts index 7655a75ba..927fc4b90 100644 --- a/src/core/pathfinding/SerialAStar.ts +++ b/src/core/pathfinding/SerialAStar.ts @@ -1,5 +1,4 @@ import { PriorityQueue } from "@datastructures-js/priority-queue"; -import { consolex } from "../Consolex"; import { GameMap, TileRef } from "../game/GameMap"; import { AStar, PathFindResultType } from "./AStar"; @@ -154,7 +153,7 @@ export class SerialAStar implements AStar { Math.abs(this.gameMap.y(a) - this.gameMap.y(b))) ); } catch { - consolex.log("uh oh"); + console.log("uh oh"); return 0; } } diff --git a/src/scripts/generateTerrainMaps.ts b/src/scripts/generateTerrainMaps.ts index 92c61062a..2a9fd37a3 100644 --- a/src/scripts/generateTerrainMaps.ts +++ b/src/scripts/generateTerrainMaps.ts @@ -22,7 +22,7 @@ const maps = [ "Pangaea", "Iceland", "BetweenTwoSeas", - "Japan", + "EastAsia", "KnownWorld", "FaroeIslands", "DeglaciatedAntarctica", diff --git a/src/server/Client.ts b/src/server/Client.ts index 6daa49a25..c367c0e04 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -13,6 +13,7 @@ export class Client { public readonly clientID: ClientID, public readonly persistentID: string, public readonly claims: TokenPayload | null, + public readonly roles: string[] | undefined, public readonly ip: string, public readonly username: string, public readonly ws: WebSocket, diff --git a/src/server/Cloudflare.ts b/src/server/Cloudflare.ts new file mode 100644 index 000000000..ceea88fc3 --- /dev/null +++ b/src/server/Cloudflare.ts @@ -0,0 +1,271 @@ +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import yaml from "js-yaml"; +import { join } from "path"; +import { logger } from "./Logger"; + +const log = logger.child({ + module: "cloudflare", +}); + +export interface TunnelConfig { + domain: string; + subdomain: string; + subdomainToService: Map; +} + +interface TunnelResponse { + result: { + id: string; + token: string; + }; +} + +interface ZoneResponse { + result: Array<{ + id: string; + }>; +} + +interface DNSRecordResponse { + result: Array<{ + id: string; + }>; +} + +interface CloudflaredConfig { + tunnel: string; + "credentials-file": string; + ingress: Array<{ + hostname?: string; + service: string; + }>; +} + +export class Cloudflare { + private baseUrl = "https://api.cloudflare.com/client/v4"; + + constructor( + private accountId: string, + private apiToken: string, + private configDir: string, + ) { + log.info(`Using config directory: ${this.configDir}`); + } + + private async makeRequest( + url: string, + method: string = "GET", + data?: any, + ): Promise { + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Cloudflare API error: url ${url} ${response.status} - ${errorText}`, + ); + } + + return response.json() as Promise; + } + + public async createTunnel(config: TunnelConfig): Promise<{ + tunnelId: string; + tunnelToken: string; + tunnelUrl: string; + configPath: string; + }> { + const { domain, subdomain, subdomainToService } = config; + + // Generate unique tunnel name + const timestamp = new Date().toISOString().replace(/[-:.]/g, ""); + const tunnelName = `${subdomain}-tunnel-${timestamp}`; + + log.info(`Creating tunnel with name: ${tunnelName}`); + + // Create tunnel via API to get official tunnel ID and token + const tunnelResponse = await this.makeRequest( + `${this.baseUrl}/accounts/${this.accountId}/cfd_tunnel`, + "POST", + { name: tunnelName }, + ); + + const tunnelId = tunnelResponse.result.id; + const tunnelToken = tunnelResponse.result.token; + + if (!tunnelId) { + throw new Error("Failed to create tunnel"); + } + + log.info(`Tunnel created with ID: ${tunnelId}`); + + // Create local config file instead of using API configuration + const configPath = await this.writeTunnelConfig( + tunnelId, + tunnelToken, + subdomain, + domain, + subdomainToService, + tunnelName, + ); + + // Get zone ID + const zoneResponse = await this.makeRequest( + `${this.baseUrl}/zones?name=${domain}`, + ); + + const zoneId = zoneResponse.result[0]?.id; + if (!zoneId) { + throw new Error(`Could not find zone ID for domain ${domain}`); + } + + await Promise.all( + Array.from(subdomainToService.entries()).map(([subdomain, _]) => + this.updateDNSRecord(zoneId, tunnelId, subdomain, domain), + ), + ); + + const tunnelUrl = `https://${subdomain}.${domain}`; + log.info(`Tunnel is set up! Site will be available at: ${tunnelUrl}`); + + return { tunnelId, tunnelToken, tunnelUrl, configPath }; + } + + private async writeTunnelConfig( + tunnelId: string, + tunnelToken: string, + subdomain: string, + domain: string, + subdomainToService: Map, + tunnelName: string, + ): Promise { + log.info(`Creating local config for tunnel ${subdomain}.${domain}...`); + + const configPath = join(this.configDir, `${tunnelName}.yml`); + const credentialsFile = join(this.configDir, `${tunnelId}.json`); + + const tokenData = JSON.parse( + Buffer.from(tunnelToken, "base64").toString("utf8"), + ); + + const credentials = { + AccountTag: tokenData.a || this.accountId, + TunnelID: tokenData.t || tunnelId, + TunnelName: tunnelName, + TunnelSecret: tokenData.s, + }; + + await fs.writeFile( + credentialsFile, + JSON.stringify(credentials, null, 2), + "utf8", + ); + log.info(`Created credentials file at: ${credentialsFile}`); + + const tunnelConfig: CloudflaredConfig = { + tunnel: tunnelId, + "credentials-file": credentialsFile, + ingress: [ + ...Array.from(subdomainToService.entries()).map( + ([subdomain, service]) => ({ + hostname: `${subdomain}.${domain}`, + service: service, + }), + ), + { + service: "http_status:404", + }, + ], + }; + + // Write config file + await fs.writeFile(configPath, yaml.dump(tunnelConfig), "utf8"); + log.info(`Created config file at: ${configPath}`); + + return configPath; + } + + private async updateDNSRecord( + zoneId: string, + tunnelId: string, + subdomain: string, + domain: string, + ): Promise { + const existingRecords = await this.makeRequest( + `${this.baseUrl}/zones/${zoneId}/dns_records?name=${subdomain}.${domain}`, + ); + + const recordId = existingRecords.result[0]?.id; + const dnsData = { + type: "CNAME", + name: subdomain, + content: `${tunnelId}.cfargotunnel.com`, + ttl: 1, + proxied: true, + }; + + if (recordId) { + log.info(`Updating existing DNS record for ${subdomain}.${domain}...`); + await this.makeRequest( + `${this.baseUrl}/zones/${zoneId}/dns_records/${recordId}`, + "PUT", + dnsData, + ); + } else { + log.info(`Creating new DNS record for ${subdomain}.${domain}...`); + await this.makeRequest( + `${this.baseUrl}/zones/${zoneId}/dns_records`, + "POST", + dnsData, + ); + } + } + + public async startCloudflared(configPath: string) { + const cloudflared = spawn( + "cloudflared", + ["tunnel", "--config", configPath, "--loglevel", "error", "run"], + { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // Set this to bypass origin cert requirement for named tunnels + TUNNEL_ORIGIN_CERT: "/dev/null", + }, + }, + ); + + cloudflared.stdout?.on("data", (data) => { + log.info(data.toString().trim()); + }); + cloudflared.stderr?.on("data", (data) => { + log.error(data.toString().trim()); + }); + + cloudflared.on("error", (error) => { + log.error("Failed to start cloudflared", { + error: error.message, + }); + }); + + cloudflared.on("exit", (code, signal) => { + if (code !== null) { + log.error(`Cloudflared exited with code ${code}`, { + exitCode: code, + signal, + }); + } + }); + + cloudflared.unref(); + } +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 4bc8a9adc..88db909dd 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,9 +1,9 @@ import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; +import { z } from "zod/v4"; import { ClientID, - ClientMessage, ClientMessageSchema, ClientSendWinnerMessage, GameConfig, @@ -147,7 +147,9 @@ export class GameServer { existingIP: ipAnonymize(conflicting.ip), existingPersistentID: conflicting.persistentID, }); - return; + // Kick the existing client instead of the new one, because this was causing issues when + // a client wanted to replay the game afterwards. + this.kickClient(conflicting.clientID); } } @@ -184,12 +186,16 @@ export class GameServer { "message", gatekeeper.wsHandler(client.ip, async (message: string) => { try { - let clientMsg: ClientMessage | null = null; - try { - clientMsg = ClientMessageSchema.parse(JSON.parse(message)); - } catch (error) { - throw Error(`error parsing schema for ${ipAnonymize(client.ip)}`); + const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + this.log.error("Failed to parse client message", error, { + clientID: client.clientID, + }); + client.ws.close(); + return; } + const clientMsg = parsed.data; if (clientMsg.type === "intent") { if (clientMsg.intent.clientID !== client.clientID) { this.log.warn( @@ -581,20 +587,23 @@ export class GameServer { gameID: this.id, winner: this.winner?.winner, }); - const playerRecords: PlayerRecord[] = Array.from( - this.allClients.values(), - ).map((client) => { - const stats = this.winner?.allPlayersStats[client.clientID]; - if (stats === undefined) { - this.log.warn(`Unable to find stats for clientID ${client.clientID}`); - } - return { - clientID: client.clientID, - username: client.username, - persistentID: client.persistentID, - stats, - } satisfies PlayerRecord; - }); + + // Players must stay in the same order as the game start info. + const playerRecords: PlayerRecord[] = this.gameStartInfo.players.map( + (player) => { + const stats = this.winner?.allPlayersStats[player.clientID]; + if (stats === undefined) { + this.log.warn(`Unable to find stats for clientID ${player.clientID}`); + } + return { + clientID: player.clientID, + username: player.username, + persistentID: + this.allClients.get(player.clientID)?.persistentID ?? "", + stats, + } satisfies PlayerRecord; + }, + ); archive( createGameRecord( this.id, diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c961ce389..4e3daaf5a 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -26,7 +26,7 @@ const frequency = { Asia: 1, Mars: 1, BetweenTwoSeas: 1, - Japan: 1, + EastAsia: 1, BlackSea: 1, FaroeIslands: 1, FalklandIslands: 1, @@ -51,18 +51,17 @@ export class MapPlaylist { // Create the default public game config (from your GameManager) return { gameMap: map, - maxPlayers: config.lobbyMaxPlayers(map, mode), + maxPlayers: config.lobbyMaxPlayers(map, mode, numPlayerTeams), gameType: GameType.Public, difficulty: Difficulty.Medium, infiniteGold: false, infiniteTroops: false, instantBuild: false, disableNPCs: mode === GameMode.Team, - disableNukes: false, gameMode: mode, playerTeams: numPlayerTeams, bots: 400, - } as GameConfig; + } satisfies GameConfig; } private getNextMap(): MapWithMode { diff --git a/src/server/Master.ts b/src/server/Master.ts index 19bddcfad..388aba19c 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -282,9 +282,7 @@ async function schedulePublicGame(playlist: MapPlaylist) { "Content-Type": "application/json", [config.adminHeader()]: config.adminToken(), }, - body: JSON.stringify({ - gameConfig: playlist.gameConfig(), - }), + body: JSON.stringify(playlist.gameConfig()), }, ); diff --git a/src/server/Server.ts b/src/server/Server.ts index 59468c10b..92cecff2f 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -1,14 +1,22 @@ import cluster from "cluster"; import * as dotenv from "dotenv"; +import { GameEnv } from "../core/configuration/Config"; +import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; +import { Cloudflare, TunnelConfig } from "./Cloudflare"; import { startMaster } from "./Master"; import { startWorker } from "./Worker"; +const config = getServerConfigFromServer(); + dotenv.config(); // Main entry point of the application async function main() { // Check if this is the primary (master) process if (cluster.isPrimary) { + if (config.env() !== GameEnv.Dev) { + await setupTunnels(); + } console.log("Starting master process..."); await startMaster(); } else { @@ -23,3 +31,31 @@ main().catch((error) => { console.error("Failed to start server:", error); process.exit(1); }); + +async function setupTunnels() { + const cloudflare = new Cloudflare( + config.cloudflareAccountId(), + config.cloudflareApiToken(), + config.cloudflareConfigDir(), + ); + + const domainToService = new Map().set( + config.subdomain(), + `http://localhost:3000`, + ); + + for (let i = 0; i < config.numWorkers(); i++) { + domainToService.set( + `w${i}-${config.subdomain()}`, + `http://localhost:${3000 + i + 1}`, + ); + } + + const tunnel = await cloudflare.createTunnel({ + subdomain: config.subdomain(), + domain: config.domain(), + subdomainToService: domainToService, + } as TunnelConfig); + + await cloudflare.startCloudflared(tunnel.configPath); +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 5ff087242..5bee6b603 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -5,15 +5,21 @@ import ipAnonymize from "ip-anonymize"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; +import { z } from "zod/v4"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; -import { ClientMessageSchema, GameConfig, GameRecord } from "../core/Schemas"; +import { + ClientJoinMessageSchema, + GameRecord, + GameRecordSchema, +} from "../core/Schemas"; +import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { gatekeeper, LimiterType } from "./Gatekeeper"; -import { verifyClientToken } from "./jwt"; +import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -83,7 +89,13 @@ export function startWorker() { return res.status(400).json({ error: "Game ID is required" }); } const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - const gc = req.body?.gameConfig as GameConfig; + const result = CreateGameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + + const gc = result.data; if ( gc?.gameType === GameType.Public && req.headers[config.adminHeader()] !== config.adminToken() @@ -91,9 +103,7 @@ export function startWorker() { log.warn( `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, ); - return res - .status(400) - .json({ error: "Invalid admin token for public game creation" }); + return res.status(401).send("Unauthorized"); } // Double-check this worker should host this game @@ -138,9 +148,15 @@ export function startWorker() { app.put( "/api/game/:id", gatekeeper.httpHandler(LimiterType.Put, async (req, res) => { + const result = GameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + const config = result.data; // TODO: only update public game if from local host const lobbyID = req.params.id; - if (req.body.gameType === GameType.Public) { + if (config.gameType === GameType.Public) { log.info(`cannot update game ${lobbyID} to public`); return res.status(400).json({ error: "Cannot update public game" }); } @@ -161,18 +177,7 @@ export function startWorker() { .status(400) .json({ error: "Cannot update game after it has started" }); } - game.updateGameConfig({ - gameMap: req.body.gameMap, - difficulty: req.body.difficulty, - infiniteGold: req.body.infiniteGold, - infiniteTroops: req.body.infiniteTroops, - instantBuild: req.body.instantBuild, - bots: req.body.bots, - disableNPCs: req.body.disableNPCs, - disabledUnits: req.body.disabledUnits, - gameMode: req.body.gameMode, - playerTeams: req.body.playerTeams, - }); + game.updateGameConfig(config); res.status(200).json({ success: true }); }), ); @@ -241,13 +246,14 @@ export function startWorker() { app.post( "/api/archive_singleplayer_game", gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - const gameRecord: GameRecord = req.body; - - if (!gameRecord) { - log.info("game record not found in request"); - res.status(404).json({ error: "Game record not found" }); - return; + const result = GameRecordSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + log.info(error); + return res.status(400).json({ error }); } + + const gameRecord: GameRecord = result.data; archive(gameRecord); res.json({ success: true, @@ -287,11 +293,17 @@ export function startWorker() { : forwarded || req.socket.remoteAddress || "unknown"; try { - // Process WebSocket messages as in your original code // Parse and handle client messages - const clientMsg = ClientMessageSchema.parse( + const parsed = ClientJoinMessageSchema.safeParse( JSON.parse(message.toString()), ); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + log.warn("Error parsing join message client", error); + ws.close(); + return; + } + const clientMsg = parsed.data; if (clientMsg.type === "join") { // Verify this worker should handle this game @@ -308,11 +320,26 @@ export function startWorker() { config, ); + let roles: string[] | undefined; + + // Check user roles + if (claims !== null) { + const result = await getUserMe(clientMsg.token, config); + if (result === false) { + log.warn("Token is not valid", claims); + return; + } + roles = result.player.roles; + } + + // TODO: Validate client settings based on roles + // Create client and add to game const client = new Client( clientMsg.clientID, persistentId, - claims ?? null, + claims, + roles, ip, clientMsg.username, ws, diff --git a/src/server/jwt.ts b/src/server/jwt.ts index 150402a5f..c7896bd91 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -1,5 +1,10 @@ import { jwtVerify } from "jose"; -import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; +import { + TokenPayload, + TokenPayloadSchema, + UserMeResponse, + UserMeResponseSchema, +} from "../core/ApiSchemas"; import { ServerConfig } from "../core/configuration/Config"; type TokenVerificationResult = { @@ -27,3 +32,31 @@ export async function verifyClientToken( const persistentId = claims.sub; return { persistentId, claims }; } + +export async function getUserMe( + token: string, + config: ServerConfig, +): Promise { + try { + // Get the user object + const response = await fetch(config.jwtIssuer() + "/users/@me", { + headers: { + authorization: `Bearer ${token}`, + }, + }); + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + console.error( + "Invalid response", + JSON.stringify(body), + JSON.stringify(result.error), + ); + return false; + } + return result.data; + } catch (e) { + return false; + } +} diff --git a/startup.sh b/startup.sh index 052c3b200..28903d067 100644 --- a/startup.sh +++ b/startup.sh @@ -1,110 +1,5 @@ #!/bin/bash set -e - -# Check if required environment variables are set -if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$SUBDOMAIN" ] || [ -z "$DOMAIN" ]; then - echo "Error: Required environment variables not set" - echo "Please set CF_API_TOKEN, CF_ACCOUNT_ID, SUBDOMAIN, and DOMAIN" - exit 1 -fi - -# Generate a unique tunnel name using timestamp -TIMESTAMP=$(date +%Y%m%d%H%M%S) -TUNNEL_NAME="${SUBDOMAIN}-tunnel-${TIMESTAMP}" -echo "Using unique tunnel name: ${TUNNEL_NAME}" - -# Create a new tunnel -echo "Creating Cloudflare tunnel for subdomain ${SUBDOMAIN}..." -TUNNEL_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"name\":\"${TUNNEL_NAME}\"}") - -# Extract tunnel ID and token -TUNNEL_ID=$(echo $TUNNEL_RESPONSE | jq -r '.result.id') -TUNNEL_TOKEN=$(echo $TUNNEL_RESPONSE | jq -r '.result.token') - -if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" == "null" ]; then - echo "Failed to create tunnel" - echo $TUNNEL_RESPONSE - exit 1 -fi - -echo "Tunnel created with ID: ${TUNNEL_ID}" - -# Configure the tunnel with hostname -echo "Configuring tunnel to point to ${SUBDOMAIN}.${DOMAIN}..." -curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"config\":{\"ingress\":[{\"hostname\":\"${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}" - -# Update DNS record to point to the new tunnel -echo "Updating DNS record to point to the new tunnel..." - -# First check if DNS record exists -DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json") - -ZONE_ID=$(echo $DNS_RECORDS | jq -r '.result[0].id') - -if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then - echo "Could not find zone ID for domain ${DOMAIN}" - exit 1 -fi - -# Check for existing record -EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}.${DOMAIN}" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json") - -RECORD_ID=$(echo $EXISTING_RECORDS | jq -r '.result[0].id') - -# Create or update the DNS record -if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then - # Create new record - echo "Creating new DNS record..." - DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}") -else - # Update existing record - echo "Updating existing DNS record..." - DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \ - -H "Authorization: Bearer ${CF_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}") -fi - -# Log the tunnel information -echo "Tunnel is set up! Site will be available at: https://${SUBDOMAIN}.${DOMAIN}" - -# Export the tunnel token for supervisord -export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN} - -# Check if Basic Auth credentials are set -if [ -z "$BASIC_AUTH_USER" ] || [ -z "$BASIC_AUTH_PASS" ]; then - echo "HTTP Basic Authentication will be disabled" -else - # Create the htpasswd file - echo "Creating basic auth credentials for user: ${BASIC_AUTH_USER}" - # Ensure apache2-utils is installed for htpasswd - command -v htpasswd > /dev/null 2>&1 || { - echo "htpasswd not found, installing apache2-utils..." - apt-get update && apt-get install -y apache2-utils - } - # Create the password file - htpasswd -bc /etc/nginx/.htpasswd ${BASIC_AUTH_USER} ${BASIC_AUTH_PASS} - - # Update Nginx configuration to enable Basic Auth - sed -i '1i auth_basic "Restricted Access";' /etc/nginx/conf.d/default.conf - sed -i '2i auth_basic_user_file /etc/nginx/.htpasswd;' /etc/nginx/conf.d/default.conf - - echo "HTTP Basic Authentication enabled for user: ${BASIC_AUTH_USER}" -fi - # Start supervisord if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/supervisord.conf b/supervisord.conf index 61b2aec3a..c31d0429c 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -22,11 +22,4 @@ user=node stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:cloudflared] -command=cloudflared tunnel run --token %(ENV_CLOUDFLARE_TUNNEL_TOKEN)s -autostart=true -autorestart=true -stdout_logfile=/var/log/cloudflared.log -stderr_logfile=/var/log/cloudflared-err.log \ No newline at end of file +stderr_logfile_maxbytes=0 \ No newline at end of file diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 81f3b49d7..0fcd709c1 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -21,7 +21,7 @@ let attackerSpawn: TileRef; function sendBoat(target: TileRef, source: TileRef, troops: number) { game.addExecution( - new TransportShipExecution(defender.id(), null, target, troops, source), + new TransportShipExecution(defender, null, target, troops, source), ); } @@ -64,7 +64,9 @@ describe("Attack", () => { attacker = game.player(attackerInfo.id); defender = game.player(defenderInfo.id); - game.addExecution(new AttackExecution(100, defender.id(), null)); + game.addExecution( + new AttackExecution(100, defender, game.terraNullius().id()), + ); game.executeNextTick(); while (defender.outgoingAttacks().length > 0) { game.executeNextTick(); @@ -76,10 +78,10 @@ describe("Attack", () => { test("Nuke reduce attacking troop counts", async () => { // Not building exactly spawn to it's better protected from attacks (but still // on defender territory) - constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo); + constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); - game.addExecution(new AttackExecution(100, attacker.id(), defender.id())); - constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3); + game.addExecution(new AttackExecution(100, attacker, defender.id())); + constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; expect(nuke.isActive()).toBe(true); @@ -94,12 +96,12 @@ describe("Attack", () => { }); test("Nuke reduce attacking boat troop count", async () => { - constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo); + constructionExecution(game, defender, 1, 1, UnitType.MissileSilo); expect(defender.units(UnitType.MissileSilo)).toHaveLength(1); sendBoat(game.ref(15, 8), game.ref(10, 5), 100); - constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3); + constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3); const nuke = defender.units(UnitType.AtomBomb)[0]; expect(nuke.isActive()).toBe(true); diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts new file mode 100644 index 000000000..99df34764 --- /dev/null +++ b/tests/BotBehavior.test.ts @@ -0,0 +1,150 @@ +import { BotBehavior } from "../src/core/execution/utils/BotBehavior"; +import { + AllianceRequest, + Game, + Player, + PlayerInfo, + PlayerType, + Tick, +} from "../src/core/game/Game"; +import { PseudoRandom } from "../src/core/PseudoRandom"; +import { setup } from "./util/Setup"; + +let game: Game; +let player: Player; +let requestor: Player; +let botBehavior: BotBehavior; + +describe("BotBehavior.handleAllianceRequests", () => { + beforeEach(async () => { + game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); + + const playerInfo = new PlayerInfo( + "us", + "player_id", + PlayerType.Bot, + null, + "player_id", + ); + const requestorInfo = new PlayerInfo( + "fr", + "requestor_id", + PlayerType.Human, + null, + "requestor_id", + ); + + game.addPlayer(playerInfo); + game.addPlayer(requestorInfo); + + player = game.player("player_id"); + requestor = game.player("requestor_id"); + + const random = new PseudoRandom(42); + + botBehavior = new BotBehavior(random, game, player, 0.5, 0.5); + }); + + function setupAllianceRequest({ + isTraitor = false, + relationDelta = 2, + numTilesPlayer = 10, + numTilesRequestor = 10, + alliancesCount = 0, + } = {}) { + if (isTraitor) requestor.markTraitor(); + + player.updateRelation(requestor, relationDelta); + requestor.updateRelation(player, relationDelta); + + game.map().forEachTile((tile) => { + if (game.map().isLand(tile)) { + if (numTilesPlayer > 0) { + player.conquer(tile); + numTilesPlayer--; + } else if (numTilesRequestor > 0) { + requestor.conquer(tile); + numTilesRequestor--; + } + } + }); + + jest + .spyOn(requestor, "alliances") + .mockReturnValue(new Array(alliancesCount)); + + const mockRequest = { + requestor: () => requestor, + recipient: () => player, + createdAt: () => 0 as unknown as Tick, + accept: jest.fn(), + reject: jest.fn(), + } as unknown as AllianceRequest; + + jest + .spyOn(player, "incomingAllianceRequests") + .mockReturnValue([mockRequest]); + + return mockRequest; + } + + test("should accept alliance when all conditions are met", () => { + const request = setupAllianceRequest({}); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).toHaveBeenCalled(); + expect(request.reject).not.toHaveBeenCalled(); + }); + + test("should reject alliance if requestor is a traitor", () => { + const request = setupAllianceRequest({ isTraitor: true }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); + + test("should reject alliance if relation is malicious", () => { + const request = setupAllianceRequest({ relationDelta: -2 }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); + + test("should accept alliance if requestor is much larger (> 3 times size of recipient) and has too many alliances (>= 3)", () => { + const request = setupAllianceRequest({ + numTilesRequestor: 40, + alliancesCount: 4, + }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).toHaveBeenCalled(); + expect(request.reject).not.toHaveBeenCalled(); + }); + + test("should accept alliance if requestor is much larger (> 3 times size of recipient) and does not have too many alliances (< 3)", () => { + const request = setupAllianceRequest({ + numTilesRequestor: 40, + alliancesCount: 2, + }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).toHaveBeenCalled(); + expect(request.reject).not.toHaveBeenCalled(); + }); + + test("should reject alliance if requestor is acceptably small (<= 3 times size of recipient) and has too many alliances (>= 3)", () => { + const request = setupAllianceRequest({ alliancesCount: 3 }); + + botBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); +}); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index a80f14ae8..978253262 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -159,7 +159,7 @@ describe("Disconnected", () => { executeTicks(game, 1); expect(player1.isDisconnected()).toBe(true); }); - + test("Breaking alliance with disconnected player doesn't make you a traitor", () => { player1.createAllianceRequest(player2); player2 diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts index d5930680b..04d708546 100644 --- a/tests/MissileSilo.test.ts +++ b/tests/MissileSilo.test.ts @@ -20,7 +20,7 @@ function attackerBuildsNuke( initialize = true, ) { game.addExecution( - new NukeExecution(UnitType.AtomBomb, attacker.id(), target, source), + new NukeExecution(UnitType.AtomBomb, attacker, target, source), ); if (initialize) { game.executeNextTick(); @@ -50,7 +50,7 @@ describe("MissileSilo", () => { attacker = game.player("attacker_id"); - constructionExecution(game, attacker.id(), 1, 1, UnitType.MissileSilo); + constructionExecution(game, attacker, 1, 1, UnitType.MissileSilo); }); test("missilesilo should launch nuke", async () => { diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index d8eb2d325..d3f209673 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -1,3 +1,4 @@ +import { NukeExecution } from "../src/core/execution/NukeExecution"; import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution"; import { SpawnExecution } from "../src/core/execution/SpawnExecution"; import { @@ -13,10 +14,11 @@ import { constructionExecution, executeTicks } from "./util/utils"; let game: Game; let attacker: Player; let defender: Player; +let far_defender: Player; describe("SAM", () => { beforeEach(async () => { - game = await setup("Plains", { infiniteGold: true, instantBuild: true }); + game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); const defender_info = new PlayerInfo( "us", "defender_id", @@ -24,6 +26,13 @@ describe("SAM", () => { null, "defender_id", ); + const far_defender_info = new PlayerInfo( + "us", + "far_defender_id", + PlayerType.Human, + null, + "far_defender_id", + ); const attacker_info = new PlayerInfo( "fr", "attacker_id", @@ -32,10 +41,15 @@ describe("SAM", () => { "attacker_id", ); game.addPlayer(defender_info); + game.addPlayer(far_defender_info); game.addPlayer(attacker_info); game.addExecution( new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)), + new SpawnExecution( + game.player(far_defender_info.id).info(), + game.ref(199, 1), + ), new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)), ); @@ -43,16 +57,19 @@ describe("SAM", () => { game.executeNextTick(); } - defender = game.player("defender_id"); attacker = game.player("attacker_id"); + defender = game.player("defender_id"); + far_defender = game.player("far_defender_id"); - constructionExecution(game, attacker.id(), 7, 7, UnitType.MissileSilo); + constructionExecution(game, attacker, 7, 7, UnitType.MissileSilo); }); test("one sam should take down one nuke", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam)); - attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(2, 1), + }); executeTicks(game, 3); @@ -61,7 +78,7 @@ describe("SAM", () => { test("sam should only get one nuke at a time", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam)); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { targetTile: game.ref(2, 1), }); @@ -77,7 +94,7 @@ describe("SAM", () => { test("sam should cooldown as long as configured", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, 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), @@ -100,9 +117,9 @@ describe("SAM", () => { const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), { cooldownDuration: 10, }); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1)); + game.addExecution(new SAMLauncherExecution(defender, null, sam1)); const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {}); - game.addExecution(new SAMLauncherExecution(defender.id(), null, sam2)); + game.addExecution(new SAMLauncherExecution(defender, null, sam2)); const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), { targetTile: game.ref(2, 2), }); @@ -112,4 +129,36 @@ describe("SAM", () => { expect(nuke.isActive()).toBeFalsy(); expect([sam1, sam2].filter((s) => s.isInCooldown())).toHaveLength(1); }); + + test("SAMs should target only nukes aimed at nearby targets", async () => { + const targetDistance = 199; + // Close SAM: should not intercept anything + const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam1)); + + // Far SAM: Should intercept the nuke. Use the far_defender so the SAM can be built + const sam2 = far_defender.buildUnit( + UnitType.SAMLauncher, + game.ref(targetDistance, 1), + {}, + ); + game.addExecution(new SAMLauncherExecution(far_defender, null, sam2)); + + const nukeExecution = new NukeExecution( + UnitType.AtomBomb, + attacker, + game.ref(targetDistance, 1), + null, + ); + game.addExecution(nukeExecution); + // Long distance nuke: compute the proper number of ticks + const ticksToExecute = Math.ceil( + targetDistance / game.config().defaultNukeSpeed(), + ); + executeTicks(game, ticksToExecute); + + expect(nukeExecution.isActive()).toBeFalsy(); + expect(sam1.isInCooldown()).toBeFalsy(); + expect(sam2.isInCooldown()).toBeTruthy(); + }); }); diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 4c1f76eb4..b6f6f8442 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,21 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + cloudflareConfigDir(): string { + throw new Error("Method not implemented."); + } + domain(): string { + throw new Error("Method not implemented."); + } + subdomain(): string { + throw new Error("Method not implemented."); + } + cloudflareAccountId(): string { + throw new Error("Method not implemented."); + } + cloudflareApiToken(): string { + throw new Error("Method not implemented."); + } jwtAudience(): string { throw new Error("Method not implemented."); } diff --git a/tests/util/utils.ts b/tests/util/utils.ts index aa1b7a05d..921e5d102 100644 --- a/tests/util/utils.ts +++ b/tests/util/utils.ts @@ -4,18 +4,18 @@ // If you also need execution use function below. Does not work with things not import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution"; -import { Game, PlayerID, UnitType } from "../../src/core/game/Game"; +import { Game, Player, UnitType } from "../../src/core/game/Game"; // built via UI (e.g.: trade ships) export function constructionExecution( game: Game, - playerID: PlayerID, + _owner: Player, x: number, y: number, unit: UnitType, ticks = 4, ) { - game.addExecution(new ConstructionExecution(playerID, game.ref(x, y), unit)); + game.addExecution(new ConstructionExecution(_owner, game.ref(x, y), unit)); // 4 ticks by default as it usually goes like this // Init of construction execution diff --git a/update.sh b/update.sh index 91c516346..761c353f9 100755 --- a/update.sh +++ b/update.sh @@ -2,12 +2,25 @@ # update.sh - Script to update Docker container on Hetzner server # Called by deploy.sh after uploading Docker image to Docker Hub -# Load environment variables if .env exists -if [ -f /home/openfront/.env ]; then - echo "Loading environment variables from .env file..." - export $(grep -v '^#' /home/openfront/.env | xargs) +# Check if environment file is provided +if [ $# -ne 1 ]; then + echo "Error: Environment file path is required" + echo "Usage: $0 " + exit 1 fi +ENV_FILE="$1" + +# Check if environment file exists +if [ ! -f "$ENV_FILE" ]; then + echo "Error: Environment file '$ENV_FILE' not found" + exit 1 +fi + +# Load environment variables from the provided file +echo "Loading environment variables from $ENV_FILE..." +export $(grep -v '^#' "$ENV_FILE" | xargs) + echo "======================================================" echo "๐Ÿ”„ UPDATING SERVER: ${HOST} ENVIRONMENT" echo "======================================================" @@ -47,7 +60,7 @@ fi echo "Starting new container for ${HOST} environment..." docker run -d \ --restart="${RESTART}" \ - --env-file /home/openfront/.env \ + --env-file "$ENV_FILE" \ --name "${CONTAINER_NAME}" \ "${DOCKER_IMAGE}" @@ -60,6 +73,11 @@ if [ $? -eq 0 ]; then docker image prune -a -f docker container prune -f echo "Cleanup complete." + + # Remove the environment file + echo "Removing environment file ${ENV_FILE}..." + rm -f "$ENV_FILE" + echo "Environment file removed." else echo "Failed to start container" exit 1 diff --git a/webpack.config.js b/webpack.config.js index 4b4096141..3f6d28936 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,3 +1,4 @@ +import { execSync } from "child_process"; import CopyPlugin from "copy-webpack-plugin"; import ESLintPlugin from "eslint-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin"; @@ -8,6 +9,9 @@ import webpack from "webpack"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const gitCommit = + process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD").toString().trim(); + export default async (env, argv) => { const isProduction = argv.mode === "production"; @@ -116,9 +120,8 @@ export default async (env, argv) => { "process.env.WEBSOCKET_URL": JSON.stringify( isProduction ? "" : "localhost:3000", ), - }), - new webpack.DefinePlugin({ "process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"), + "process.env.GIT_COMMIT": JSON.stringify(gitCommit), }), new CopyPlugin({ patterns: [