diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d0909dd6e..29255afb8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -112,8 +112,6 @@ jobs: DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} ENV: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }} HOST: ${{ github.event_name == 'workflow_dispatch' && inputs.target_host || 'staging' }} - MON_PASSWORD: ${{ secrets.MON_PASSWORD }} - MON_USERNAME: ${{ secrets.MON_USERNAME }} OTEL_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }} OTEL_PASSWORD: ${{ secrets.OTEL_PASSWORD }} OTEL_USERNAME: ${{ secrets.OTEL_USERNAME }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 884ab1cc7..5f74cd170 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: types: - created - edited + - published permissions: {} @@ -68,8 +69,6 @@ jobs: DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} - MON_PASSWORD: ${{ secrets.MON_PASSWORD }} - MON_USERNAME: ${{ secrets.MON_USERNAME }} OTEL_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }} OTEL_PASSWORD: ${{ secrets.OTEL_PASSWORD }} OTEL_USERNAME: ${{ secrets.OTEL_USERNAME }} @@ -102,7 +101,7 @@ jobs: runs-on: ubuntu-latest needs: [build, deploy-alpha] timeout-minutes: 30 - environment: prod + environment: prod-beta steps: - uses: actions/checkout@v4 - name: 🔑 Create SSH private key @@ -124,8 +123,6 @@ jobs: DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} - MON_PASSWORD: ${{ secrets.MON_PASSWORD }} - MON_USERNAME: ${{ secrets.MON_USERNAME }} OTEL_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }} OTEL_PASSWORD: ${{ secrets.OTEL_PASSWORD }} OTEL_USERNAME: ${{ secrets.OTEL_USERNAME }} @@ -158,7 +155,7 @@ jobs: runs-on: ubuntu-latest needs: [build, deploy-alpha] timeout-minutes: 30 - environment: prod + environment: prod-blue steps: - uses: actions/checkout@v4 - name: 🔑 Create SSH private key @@ -180,8 +177,6 @@ jobs: DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} - MON_PASSWORD: ${{ secrets.MON_PASSWORD }} - MON_USERNAME: ${{ secrets.MON_USERNAME }} OTEL_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }} OTEL_PASSWORD: ${{ secrets.OTEL_PASSWORD }} OTEL_USERNAME: ${{ secrets.OTEL_USERNAME }} @@ -214,7 +209,7 @@ jobs: runs-on: ubuntu-latest needs: [build, deploy-alpha] timeout-minutes: 30 - environment: prod + environment: prod-green steps: - uses: actions/checkout@v4 - name: 🔑 Create SSH private key @@ -236,8 +231,6 @@ jobs: DOCKER_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} DOMAIN: ${{ vars.DOMAIN }} IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} - MON_PASSWORD: ${{ secrets.MON_PASSWORD }} - MON_USERNAME: ${{ secrets.MON_USERNAME }} OTEL_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }} OTEL_PASSWORD: ${{ secrets.OTEL_PASSWORD }} OTEL_USERNAME: ${{ secrets.OTEL_USERNAME }} diff --git a/example.env b/example.env index 03ce7bd1d..d186dab02 100644 --- a/example.env +++ b/example.env @@ -24,9 +24,5 @@ SERVER_HOST_STAGING=123.456.78.90 SERVER_HOST_EU=123.456.78.91 SERVER_HOST_US=123.456.78.92 -# Monitoring Credentials -MON_USERNAME=monitor_username -MON_PASSWORD=monitor_password - # Version VERSION_TAG="latest" diff --git a/package-lock.json b/package-lock.json index 319f78472..57c67fd60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@stripe/stripe-js": "^7.4.0", "@types/express": "^4.17.21", "@types/google-protobuf": "^3.15.12", "@types/hammerjs": "^2.0.45", @@ -8830,6 +8831,15 @@ "node": ">=18.0.0" } }, + "node_modules/@stripe/stripe-js": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.4.0.tgz", + "integrity": "sha512-lQHQPfXPTBeh0XFjq6PqSBAyR7umwcJbvJhXV77uGCUDD6ymXJU/f2164ydLMLCCceNuPlbV9b+1smx98efwWQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", diff --git a/package.json b/package.json index 049023cb2..03ef20e3a 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@stripe/stripe-js": "^7.4.0", "@types/express": "^4.17.21", "@types/google-protobuf": "^3.15.12", "@types/hammerjs": "^2.0.45", diff --git a/resources/cosmetics/cosmetics.json b/resources/cosmetics/cosmetics.json index 30d02b72e..6c6c8e495 100644 --- a/resources/cosmetics/cosmetics.json +++ b/resources/cosmetics/cosmetics.json @@ -10,6 +10,14 @@ "ABMIDw8": { "name": "stripes_h" }, + "AAEYAwA": { + "name": "horizontal_stripes", + "role_group": "donor" + }, + "AAoACQ": { + "name": "vertical_bars", + "role_group": "donor" + }, "ABMIpaU": { "name": "checkerboard" }, @@ -28,10 +36,6 @@ "name": "mini_cross", "role_group": "donor" }, - "AHE4__8AAAAAAAAAAAAAAAAAAP__AAAAAAAAAAAAAAAAAAA": { - "name": "horizontal_stripes", - "role_group": "donor" - }, "AHI4AOAAkACIAEQAIgARjAhUBCgCKAHQACgB1AILAwUABwA": { "name": "sword", "role_group": "donor" @@ -60,8 +64,8 @@ "name": "circuit_board", "role_group": "donor" }, - "AHEYSZJJkkmSSZJJkkmSSZJJkg": { - "name": "vertical_bars", + "AHEgzGfznzu43XPoL2fMn_O4O3fdL-g": { + "name": "shells", "role_group": "donor" }, "AHEYAAAAAAAAAkCCQUQiLnQWaA": { @@ -76,6 +80,10 @@ "name": "goat", "role_group": "donor" }, + "ALF1AAAAAAAAABABAAAAAACwAQAAAAAA8AEAAAAAIFABIoABABDwATbAA4gQ9AU-wBPZIPgDKoSx-UD4Az4C9Kmf8AEcUlj5v_ABPvb9-X_wAX7k__A_8AN-_H_gH_AFfuJ_wBiwJf_BP0AIEBkAAAAAAAAAAAAAAAAAAAAAAAAAIgAAAAAAADYAiAAA-AA-ANgAELEBKgD4ALDhI_4HqgDwsSf-H_oIUPlH_C9xBP_BT_gn-YT_wD_8J_rEf-Af_hP8yD_gHwAM-NAf4B8AAvzhP8APAAAAAAAAAA": { + "name": "cats", + "role_group": "donor" + }, "AJhYAAAAGACABACQAAASAEACAMgBAMkBIMkAJCngJAkSIEECIEgABAKAQAAQEAACAiCAAAQQgAAECIAAAfA_AAAA": { "name": "hand", "role_group": "donor" @@ -95,102 +103,426 @@ "AAIiAAAAAAAAAAAAAAAAAAAAAIDD8YnweTiiD5FIYEIgEpkIRCKBCoFIpCIQeTwyPB6RjEAkEIgQKEQiApFAIEIgEYkIOAKfCIGIIyIAAAAAAAAAAAA": { "name": "openfront", "role_group": "creator" + }, + "AJlMAAAAAP8A8D8A9gfA_wD4HwAfAOAfAn5A4A8Y_wf3v8D_B_j_AP4PgP8B4B0AGAEAIQBgDAAAAAA": { + "name": "t_rex", + "role_group": "donor" + }, + "AAqFAAACAAAAOAAAAOADAAAAHwAAAPgAAEDABwQAwZxBAAzuDgYw8H9ggAH-AAMO8Ac4OAAfgMMB-AAcDsAH4HgAPgDPB_ABfD6AD-DzAXwAnx_gA_z8gT_w5x_-wz______-f___4____8__P___8H___8H_v__P-D___8A____B_D__x8A__9_APD__wEA_v8DAMD_BwAA-A8AAA": { + "name": "embelem", + "role_group": "donor" + }, + "AMlNAAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAAAA_v8DAAAAAAAMAAAAAAAAAAAAAAA": { + "name": "grogu_head", + "role_group": "donor" + }, + "AMl9AAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAABA_v8DBwAAAAIMACwAAAAwAAAwAQAAAAMAoBgAAAA4COCFAAAAYEbAIAcAAICjgzEbAAAALPwHSQAAAGAjAAoDAAAAJknIDAAAAFBEIj8AAACAOpA4AAAAAHyAwAEAAAAAQQQIAAAAAAAAAAAAAA": { + "name": "grogu", + "role_group": "donor" } }, "flag": { "layers": { - "a": { "name": "center_circle" }, - "b": { "name": "center_hline" }, - "c": { "name": "center_vline" }, - "d": { "name": "center_star" }, - "e": { "name": "center_flower" }, - "f": { "name": "flower_tl" }, - "g": { "name": "flower_tc" }, - "h": { "name": "flower_tr" }, - "i": { "name": "diag_br" }, - "j": { "name": "diag_bl" }, - "k": { "name": "frame" }, - "l": { "name": "full" }, - "m": { "name": "triangle_tl" }, - "n": { "name": "triangle_bl" }, - "o": { "name": "triangle_tr" }, - "p": { "name": "triangle_br" }, - "q": { "name": "half_l" }, - "r": { "name": "half_r" }, - "s": { "name": "half_t" }, - "t": { "name": "half_b" }, - "u": { "name": "mini_tr_bl" }, - "v": { "name": "mini_tr_br" }, - "w": { "name": "mini_tr_tl" }, - "x": { "name": "mini_tr_tr" }, - "y": { "name": "triangle_t" }, - "z": { "name": "triangle_l" }, - "aa": { "name": "triangle_b" }, - "ab": { "name": "triangle_r" }, - "ac": { "name": "tricolor_l" }, - "ad": { "name": "tricolor_c" }, - "ae": { "name": "tricolor_r" }, - "af": { "name": "tricolor_t" }, - "ag": { "name": "tricolor_m" }, - "ah": { "name": "tricolor_b" }, - "ai": { "name": "nato_emblem" }, - "aj": { "name": "eu_star" }, - "ak": { "name": "laurel_wreath" }, - "al": { "name": "ofm_2025" }, - "am": { "name": "octagram" }, - "an": { "name": "octagram_2" }, - "ao": { "name": "og" }, - "ap": { "name": "og_plus" }, - "aq": { "name": "beta_tester" }, - "ar": { "name": "beta_tester_circle" }, - "as": { "name": "rocket" }, - "at": { "name": "rocket_mini" }, - "au": { "name": "translator" }, - "av": { "name": "admin_shield" }, - "aw": { "name": "admin_shield_r" }, - "ax": { "name": "admin_evan" } + "a": { + "name": "center_circle", + "role_group": "donor" + }, + "b": { + "name": "center_hline", + "role_group": "donor" + }, + "c": { + "name": "center_vline", + "role_group": "donor" + }, + "d": { + "name": "center_star", + "role_group": "donor" + }, + "e": { + "name": "center_flower", + "role_group": "donor" + }, + "f": { + "name": "flower_tl", + "role_group": "donor" + }, + "g": { + "name": "flower_tc", + "role_group": "donor" + }, + "h": { + "name": "flower_tr", + "role_group": "donor" + }, + "i": { + "name": "diag_br", + "role_group": "donor" + }, + "j": { + "name": "diag_bl", + "role_group": "donor" + }, + "k": { + "name": "frame" + }, + "l": { + "name": "full" + }, + "m": { + "name": "triangle_tl", + "role_group": "donor" + }, + "n": { + "name": "triangle_bl", + "role_group": "donor" + }, + "o": { + "name": "triangle_tr", + "role_group": "donor" + }, + "p": { + "name": "triangle_br", + "role_group": "donor" + }, + "q": { + "name": "half_l", + "role_group": "donor" + }, + "r": { + "name": "half_r", + "role_group": "donor" + }, + "s": { + "name": "half_t", + "role_group": "donor" + }, + "t": { + "name": "half_b", + "role_group": "donor" + }, + "u": { + "name": "mini_tr_bl", + "role_group": "donor" + }, + "v": { + "name": "mini_tr_br", + "role_group": "donor" + }, + "w": { + "name": "mini_tr_tl", + "role_group": "donor" + }, + "x": { + "name": "mini_tr_tr", + "role_group": "donor" + }, + "y": { + "name": "triangle_t", + "role_group": "donor" + }, + "z": { + "name": "triangle_l", + "role_group": "donor" + }, + "aa": { + "name": "triangle_b", + "role_group": "donor" + }, + "ab": { + "name": "triangle_r", + "role_group": "donor" + }, + "ac": { + "name": "tricolor_l", + "role_group": "donor" + }, + "ad": { + "name": "tricolor_c", + "role_group": "donor" + }, + "ae": { + "name": "tricolor_r", + "role_group": "donor" + }, + "af": { + "name": "tricolor_t", + "role_group": "donor" + }, + "ag": { + "name": "tricolor_m", + "role_group": "donor" + }, + "ah": { + "name": "tricolor_b", + "role_group": "donor" + }, + "ai": { + "name": "nato_emblem", + "role_group": "donor" + }, + "aj": { + "name": "eu_star", + "role_group": "donor" + }, + "ak": { + "name": "laurel_wreath", + "role_group": "donor" + }, + "al": { + "name": "ofm_2025", + "role_group": "donor" + }, + "am": { + "name": "octagram", + "role_group": "donor" + }, + "an": { + "name": "octagram_2", + "role_group": "donor" + }, + "ao": { + "name": "og", + "role_group": "donor" + }, + "ap": { + "name": "og_plus", + "role_group": "donor" + }, + "aq": { + "name": "beta_tester", + "role_group": "donor" + }, + "ar": { + "name": "beta_tester_circle", + "role_group": "donor" + }, + "as": { + "name": "rocket", + "role_group": "donor" + }, + "at": { + "name": "rocket_mini", + "role_group": "donor" + }, + "au": { + "name": "translator", + "role_group": "donor" + }, + "av": { + "name": "admin_shield", + "role_group": "donor" + }, + "aw": { + "name": "admin_shield_r", + "role_group": "donor" + }, + "ax": { + "name": "admin_evan", + "role_group": "donor" + } }, "color": { - "a": { "color": "#ff0000", "name": "red" }, - "b": { "color": "#ffa500", "name": "orange" }, - "c": { "color": "#ffff00", "name": "yellow" }, - "d": { "color": "#008000", "name": "green" }, - "e": { "color": "#00ffff", "name": "cyan" }, - "f": { "color": "#0000ff", "name": "blue" }, - "g": { "color": "#000000", "name": "black" }, - "h": { "color": "#ffffff", "name": "white" }, - "i": { "color": "#800080", "name": "purple" }, - "j": { "color": "#ff69b4", "name": "hotpink" }, - "k": { "color": "#a52a2a", "name": "brown" }, - "l": { "color": "#808080", "name": "gray" }, - "m": { "color": "#20b2aa", "name": "teal" }, - "n": { "color": "#ff6347", "name": "tomato" }, - "o": { "color": "#4682b4", "name": "steelblue" }, - "p": { "color": "#90ee90", "name": "lightgreen" }, - "q": { "color": "#8b0000", "name": "darkred" }, - "r": { "color": "#191970", "name": "navy" }, - "s": { "color": "#ffd700", "name": "gold" }, - "t": { "color": "#add8e6", "name": "lightblue" }, - "u": { "color": "#f5f5dc", "name": "beige" }, - "v": { "color": "#ffb6c1", "name": "lightpink" }, - "w": { "color": "#708090", "name": "slategray" }, - "x": { "color": "#00ff7f", "name": "springgreen" }, - "y": { "color": "#dc143c", "name": "crimson" }, - "z": { "color": "#ffbf00", "name": "amber" }, - "0": { "color": "#3d9970", "name": "olive_green" }, - "1": { "color": "#87ceeb", "name": "sky_blue" }, - "2": { "color": "#6a5acd", "name": "slate_blue" }, - "3": { "color": "#ff66cc", "name": "rose_pink" }, - "4": { "color": "#36454f", "name": "charcoal" }, - "5": { "color": "#fffff0", "name": "ivory" }, - "A": { "color": "rainbow", "name": "rainbow" }, - "B": { "color": "bright-rainbow", "name": "bright_rainbow" }, - "C": { "color": "gold-glow", "name": "gold_glow" }, - "D": { "color": "silver-glow", "name": "silver_glow" }, - "E": { "color": "copper-glow", "name": "copper_glow" }, - "F": { "color": "neon", "name": "neon" }, - "G": { "color": "lava", "name": "lava" }, - "H": { "color": "water", "name": "water" } + "a": { + "color": "#ff0000", + "name": "red", + "role_group": "donor" + }, + "b": { + "color": "#ffa500", + "name": "orange", + "role_group": "donor" + }, + "c": { + "color": "#ffff00", + "name": "yellow", + "role_group": "donor" + }, + "d": { + "color": "#008000", + "name": "green", + "role_group": "donor" + }, + "e": { + "color": "#00ffff", + "name": "cyan", + "role_group": "donor" + }, + "f": { + "color": "#0000ff", + "name": "blue", + "role_group": "donor" + }, + "g": { + "color": "#000000", + "name": "black", + "role_group": "donor" + }, + "h": { + "color": "#ffffff", + "name": "white", + "role_group": "donor" + }, + "i": { + "color": "#800080", + "name": "purple", + "role_group": "donor" + }, + "j": { + "color": "#ff69b4", + "name": "hotpink", + "role_group": "donor" + }, + "k": { + "color": "#a52a2a", + "name": "brown", + "role_group": "donor" + }, + "l": { + "color": "#808080", + "name": "gray", + "role_group": "donor" + }, + "m": { + "color": "#20b2aa", + "name": "teal", + "role_group": "donor" + }, + "n": { + "color": "#ff6347", + "name": "tomato", + "role_group": "donor" + }, + "o": { + "color": "#4682b4", + "name": "steelblue", + "role_group": "donor" + }, + "p": { + "color": "#90ee90", + "name": "lightgreen", + "role_group": "donor" + }, + "q": { + "color": "#8b0000", + "name": "darkred", + "role_group": "donor" + }, + "r": { + "color": "#191970", + "name": "navy", + "role_group": "donor" + }, + "s": { + "color": "#ffd700", + "name": "gold", + "role_group": "donor" + }, + "t": { + "color": "#add8e6", + "name": "lightblue", + "role_group": "donor" + }, + "u": { + "color": "#f5f5dc", + "name": "beige", + "role_group": "donor" + }, + "v": { + "color": "#ffb6c1", + "name": "lightpink", + "role_group": "donor" + }, + "w": { + "color": "#708090", + "name": "slategray", + "role_group": "donor" + }, + "x": { + "color": "#00ff7f", + "name": "springgreen", + "role_group": "donor" + }, + "y": { + "color": "#dc143c", + "name": "crimson", + "role_group": "donor" + }, + "z": { + "color": "#ffbf00", + "name": "amber", + "role_group": "donor" + }, + "0": { + "color": "#3d9970", + "name": "olive_green", + "role_group": "donor" + }, + "1": { + "color": "#87ceeb", + "name": "sky_blue", + "role_group": "donor" + }, + "2": { + "color": "#6a5acd", + "name": "slate_blue", + "role_group": "donor" + }, + "3": { + "color": "#ff66cc", + "name": "rose_pink", + "role_group": "donor" + }, + "4": { + "color": "#36454f", + "name": "charcoal", + "role_group": "donor" + }, + "5": { + "color": "#fffff0", + "name": "ivory", + "role_group": "donor" + }, + "A": { + "color": "rainbow", + "name": "rainbow", + "role_group": "donor" + }, + "B": { + "color": "bright-rainbow", + "name": "bright_rainbow", + "role_group": "donor" + }, + "C": { + "color": "gold-glow", + "name": "gold_glow", + "role_group": "donor" + }, + "D": { + "color": "silver-glow", + "name": "silver_glow", + "role_group": "donor" + }, + "E": { + "color": "copper-glow", + "name": "copper_glow", + "role_group": "donor" + }, + "F": { + "color": "neon", + "name": "neon", + "role_group": "donor" + }, + "G": { + "color": "lava", + "name": "lava", + "role_group": "donor" + }, + "H": { + "color": "water", + "name": "water", + "role_group": "donor" + } } } } diff --git a/resources/fonts/round_6x6_modified.png b/resources/fonts/round_6x6_modified.png new file mode 100644 index 000000000..04ab5461a Binary files /dev/null and b/resources/fonts/round_6x6_modified.png differ diff --git a/resources/fonts/round_6x6_modified.xml b/resources/fonts/round_6x6_modified.xml new file mode 100644 index 000000000..509654b00 --- /dev/null +++ b/resources/fonts/round_6x6_modified.xml @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/DarkModeIconWhite.svg b/resources/images/DarkModeIconWhite.svg new file mode 100644 index 000000000..dfaa4880e --- /dev/null +++ b/resources/images/DarkModeIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/EmojiIconWhite.svg b/resources/images/EmojiIconWhite.svg index f60610a35..08da8d0f5 100644 --- a/resources/images/EmojiIconWhite.svg +++ b/resources/images/EmojiIconWhite.svg @@ -1,258 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/resources/images/ExitIconWhite.svg b/resources/images/ExitIconWhite.svg new file mode 100644 index 000000000..7a963caa1 --- /dev/null +++ b/resources/images/ExitIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/ExplosionIconWhite.svg b/resources/images/ExplosionIconWhite.svg new file mode 100644 index 000000000..8eb4bd102 --- /dev/null +++ b/resources/images/ExplosionIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/FocusIconWhite.svg b/resources/images/FocusIconWhite.svg new file mode 100644 index 000000000..d198044a6 --- /dev/null +++ b/resources/images/FocusIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/InfoIconRegularWhite.svg b/resources/images/InfoIconRegularWhite.svg new file mode 100644 index 000000000..134c3e502 --- /dev/null +++ b/resources/images/InfoIconRegularWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/InfoIconSolidWhite.svg b/resources/images/InfoIconSolidWhite.svg new file mode 100644 index 000000000..f932784ec --- /dev/null +++ b/resources/images/InfoIconSolidWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/LeaderboardIconRegularWhite.svg b/resources/images/LeaderboardIconRegularWhite.svg new file mode 100644 index 000000000..173cd60cb --- /dev/null +++ b/resources/images/LeaderboardIconRegularWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/LeaderboardIconSolidWhite.svg b/resources/images/LeaderboardIconSolidWhite.svg new file mode 100644 index 000000000..8926616c2 --- /dev/null +++ b/resources/images/LeaderboardIconSolidWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/MouseIconWhite.svg b/resources/images/MouseIconWhite.svg new file mode 100644 index 000000000..fcbe98b5d --- /dev/null +++ b/resources/images/MouseIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/NinjaIconWhite.svg b/resources/images/NinjaIconWhite.svg new file mode 100644 index 000000000..e46699b7e --- /dev/null +++ b/resources/images/NinjaIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/PauseIconWhite.svg b/resources/images/PauseIconWhite.svg new file mode 100644 index 000000000..e803fcb7e --- /dev/null +++ b/resources/images/PauseIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/PlayIconWhite.svg b/resources/images/PlayIconWhite.svg new file mode 100644 index 000000000..773dd690e --- /dev/null +++ b/resources/images/PlayIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/PopulationIconSolidWhite.svg b/resources/images/PopulationIconSolidWhite.svg new file mode 100644 index 000000000..afe475e60 --- /dev/null +++ b/resources/images/PopulationIconSolidWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/ReplayRegularIconWhite.svg b/resources/images/ReplayRegularIconWhite.svg new file mode 100644 index 000000000..10a1f799d --- /dev/null +++ b/resources/images/ReplayRegularIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/ReplaySolidIconWhite.svg b/resources/images/ReplaySolidIconWhite.svg new file mode 100644 index 000000000..2e731ccf3 --- /dev/null +++ b/resources/images/ReplaySolidIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/SamLauncherUnitWhite.png b/resources/images/SamLauncherUnitWhite.png new file mode 100644 index 000000000..5a282e98d Binary files /dev/null and b/resources/images/SamLauncherUnitWhite.png differ diff --git a/resources/images/TeamIconRegularWhite.svg b/resources/images/TeamIconRegularWhite.svg new file mode 100644 index 000000000..c6c2645f3 --- /dev/null +++ b/resources/images/TeamIconRegularWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/TeamIconSolidWhite.svg b/resources/images/TeamIconSolidWhite.svg new file mode 100644 index 000000000..10eca9b55 --- /dev/null +++ b/resources/images/TeamIconSolidWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/TreeIconWhite.svg b/resources/images/TreeIconWhite.svg new file mode 100644 index 000000000..c30908a51 --- /dev/null +++ b/resources/images/TreeIconWhite.svg @@ -0,0 +1 @@ + diff --git a/resources/images/TroopIconWhite.svg b/resources/images/TroopIconWhite.svg new file mode 100644 index 000000000..5f1d53bd1 --- /dev/null +++ b/resources/images/TroopIconWhite.svg @@ -0,0 +1,4 @@ + + + diff --git a/resources/images/WorkerIconBlack.svg b/resources/images/WorkerIconBlack.svg new file mode 100644 index 000000000..3852227a4 --- /dev/null +++ b/resources/images/WorkerIconBlack.svg @@ -0,0 +1 @@ + diff --git a/resources/images/WorkerIconWhite.svg b/resources/images/WorkerIconWhite.svg new file mode 100644 index 000000000..5129642bc --- /dev/null +++ b/resources/images/WorkerIconWhite.svg @@ -0,0 +1,4 @@ + + + diff --git a/resources/lang/ar.json b/resources/lang/ar.json index 68492030d..1c608031c 100644 --- a/resources/lang/ar.json +++ b/resources/lang/ar.json @@ -214,7 +214,7 @@ "emojis_desc": "تبديل ما إذا كانت الرموز التعبيرية تظهر في اللعبة", "anonymous_names_label": "🥷 الأسماء المخفية", "anonymous_names_desc": "إخفاء أسماء اللاعبين الحقيقية باستخدام أسماء عشوائية على شاشتك", - "left_click_label": "🖱️ النقر الأيسر لفتح القائمة", + "left_click_label": " النقر الأيسر لفتح القائمة", "left_click_desc": "عند التفعيل، النقر الأيسر يفتح القائمة وزر السيف للهجوم. عند التعطيل، النقر الأيسر يهاجم مباشرة", "attack_ratio_label": "⚔️ نسبة الهجوم", "attack_ratio_desc": "نسبة القوات المُرسلة في الهجوم (1–100%)", diff --git a/resources/lang/bg.json b/resources/lang/bg.json index 5889cbf2e..d1512460a 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -5,10 +5,14 @@ "svg": "bg", "lang_code": "bg" }, + "common": { + "close": "Затвори" + }, "main": { - "title": "ОпенФронт (Алфа)", + "title": "ОпенФронт (АЛФА)", "join_discord": "Влез в Дискорд!", "login_discord": "Влез с Дискорд", + "checking_login": "Проверяване на входа...", "logged_in": "Влезли сте!", "log_out": "Излез от профила си", "create_lobby": "Създай частна игра", @@ -16,8 +20,12 @@ "single_player": "Самостоятелна игра", "instructions": "Инструкции", "how_to_play": "Как се играе", + "advertise": "Рекламиране", "wiki": "Уики" }, + "news": { + "title": "Пусната е версия 23!" + }, "help_modal": { "hotkeys": "Бързи клавиши", "table_key": "Бутон", @@ -135,14 +143,15 @@ "random": "Произволна", "iceland": "Исландия", "pangaea": "Пангея", + "eastasia": "Източна Азия", "betweentwoseas": "Между Две Морета", - "knownworld": "Познат Свят", "faroeislands": "Фарьорски острови", "deglaciatedantarctica": "Обезледена Антарктика", "europeclassic": "Европа (класическа)", "falklandislands": "Фолкландски острови", "baikal": "Байкал", - "halkidiki": "Халкидики" + "halkidiki": "Халкидики", + "giantworldmap": "Гигантска световна карта" }, "map_categories": { "continental": "Континентално", @@ -236,6 +245,8 @@ "attack_ratio_desc": "Какъв процент от Вашите войници да се изпратят в атака (1–100%)", "troop_ratio_label": "🪖🛠️ Съотношение на войници и работници", "troop_ratio_desc": "Коригиране на баланса между войници (за битка) и работници (за производство на злато) (1–100%)", + "territory_patterns_label": "🏳️ Териториални шаблони", + "territory_patterns_desc": "Избиране дали да се показват дизайни на шаблони на територия в играта", "easter_writing_speed_label": "Множител за скорост на писане", "easter_writing_speed_desc": "Регулиране на това колко бързо се преструвате, че кодирате(x1–x100)", "easter_bug_count_label": "Брой грешки", @@ -243,6 +254,14 @@ "view_options": "Вижте настройките", "toggle_view": "Превключване на изгледа", "toggle_view_desc": "Алтернативен изглед (терен/държави)", + "attack_ratio_controls": "Контроли за съотношение на атака", + "attack_ratio_up": "Увеличаване на съотношение на атака", + "attack_ratio_up_desc": "Увеличаване на съотношение на атака с 10%", + "attack_ratio_down": "Намаляване на съотношение на атака", + "attack_ratio_down_desc": "Намаляване на съотношение на атака с 10%", + "attack_keybinds": "Клавиши за атака", + "boat_attack": "Атака с лодка", + "boat_attack_desc": "Изпраща атака с лодка към плочката под курсора ви.", "zoom_controls": "Контроли за позиция на камерата", "zoom_out": "Отдалечаване на камерата", "zoom_out_desc": "Отдалечаване на камерата от картата", @@ -356,7 +375,8 @@ "you_won": "Ти спечели!", "other_won": "{player} спечели!", "exit": "Напускане на играта", - "keep": "Продължаване на играта" + "keep": "Продължаване на играта", + "wishlist": "Добавяне към списъка с желания в Стийм!" }, "leaderboard": { "title": "Класация", @@ -401,13 +421,27 @@ "player_panel": { "gold": "Злато", "troops": "Войници", + "betrayals": "Брой предателства", "traitor": "Предател", - "alliance_time_remaining": "Оставащо време", + "alliance_time_remaining": "Съюзът изтича след", "embargo": "Спряна е търговията с теб", "nuke": "Изпратени ракети към теб", "start_trade": "Започване на търговия", "stop_trade": "Прекратяване на търговия", "yes": "Да", - "no": "Не" + "no": "Не", + "none": "Няма", + "alliances": "Съюзи" + }, + "error_modal": { + "crashed": "Играта крашна!", + "paste_discord": "Моля, поставете следното във вашия бъг репорт в Дискорд:", + "copy_clipboard": "Копиране в клипборда", + "copied": "Копирано!", + "failed_copy": "Копирането не се осъществи", + "desync_notice": "Вие сте десинхронизирани от други играчи. Това, което виждате, може да се различава от другите играчи." + }, + "heads_up_message": { + "choose_spawn": "Изберете начална локация" } } diff --git a/resources/lang/cs.json b/resources/lang/cs.json index bc3e827ef..2f7410f20 100644 --- a/resources/lang/cs.json +++ b/resources/lang/cs.json @@ -230,7 +230,7 @@ "special_effects_desc": "Určuje zda jsou zapnuté speciální efekty. Deaktivuj pro zlepšení výkonu", "anonymous_names_label": "🥷 Skrýt jména", "anonymous_names_desc": "Skrýt skutečná jména hráčů a nahradit je náhodnými názvy na vaší obrazovce.", - "left_click_label": "🖱️ Levým tlačítkem myši Otevřít Menu", + "left_click_label": "Levým tlačítkem myši Otevřít Menu", "left_click_desc": "Pokud je zapnuto otevře \"Levé tlačítko\" myši menu a \"Útok\" tlačítko zaútočí. Pokud je vypnuto, útočí se přímo levým tlačítkem myši.", "attack_ratio_label": "⚔️ Poměr útoku", "attack_ratio_desc": "Jaké procento tvých vojáků poslat při útoku (1–100%)", diff --git a/resources/lang/da.json b/resources/lang/da.json new file mode 100644 index 000000000..1350de111 --- /dev/null +++ b/resources/lang/da.json @@ -0,0 +1,447 @@ +{ + "lang": { + "en": "Danish", + "native": "Dansk", + "svg": "dk", + "lang_code": "da" + }, + "common": { + "close": "Luk" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "Deltag i vores Discord!", + "login_discord": "Log ind med Discord", + "checking_login": "Tjekker login...", + "logged_in": "Logget ind!", + "log_out": "Log ud", + "create_lobby": "Opret Lobby", + "join_lobby": "Deltag I Lobby", + "single_player": "Enkeltspiller", + "instructions": "Instruktioner", + "how_to_play": "Sådan spiller du", + "advertise": "Annoncér", + "wiki": "Wiki" + }, + "news": { + "title": "Version 23 udgivet!" + }, + "help_modal": { + "hotkeys": "Genvejstaster", + "table_key": "Tast", + "table_action": "Handling", + "action_alt_view": "Alternativ visning (terræn/lande)", + "action_attack_altclick": "Angreb (når venstreklik er sat til at åbne menuen)", + "action_build": "Åbn byggemenu", + "action_emote": "Åbn emote-menu", + "action_center": "Centrer kamera på spiller", + "action_zoom": "Zoom ud/ind", + "action_move_camera": "Flyt kamera", + "action_ratio_change": "Reducer/Øg angrebsforhold", + "action_reset_gfx": "Nulstil grafik", + "ui_section": "Spilgrænseflade", + "ui_leaderboard": "Rangliste", + "ui_leaderboard_desc": "Viser de bedste spillere i spillet med deres navne, procentdel af ejet land, guld og tropper. Ved at vælge 'Vis alle' vises alle spillere i spillet. Hvis du ikke ønsker at se ranglisten, klik på 'Skjul'.", + "ui_control": "Betjeningspanel", + "ui_control_desc": "Kontrolpanelet indeholder følgende elementer:", + "ui_pop": "Befolk — Antal enheder, maksimal befolkning og tilvækst.", + "ui_gold": "Guld — Din beholdning og hvor hurtigt du tjener det.", + "ui_troops_workers": "Tropper og arbejdere — Antallet af tildelte tropper og arbejdere. Tropper bruges til at angribe eller forsvare mod angreb. Arbejdere bruges til at generere guld. Du kan justere antallet af tropper og arbejdere med skyderen.", + "ui_attack_ratio": "Angrebsforhold — Antallet af tropper, der bruges, når du angriber. Du kan justere angrebsforholdet med skyderen. Hvis du har flere angribende tropper end forsvarende, vil du tabe færre tropper under angrebet, mens færre angribende tropper øger skaden på dine tropper. Effekten gælder kun op til forholdet 2:1.", + "ui_events": "Begivenhedspanel", + "ui_events_desc": "Begivenhedspanelet viser de seneste begivenheder, anmodninger og Hurtig Chat-beskeder. Nogle eksempler er:", + "ui_events_alliance": "Alliance — Allianceanmodninger kan accepteres eller afvises. Allierede kan dele ressourcer og tropper, men kan ikke angribe hinanden. Klik på 'Fokus' for at flytte visningen til den spiller, der har sendt anmodningen.", + "ui_events_attack": "Angreb — Indkommende og udgående angreb vises. Klik på beskeden for at centrere visningen på angrebet, atommissilerne eller båden (transportskibet). Du kan trække tropper tilbage ved at klikke på den røde X-knap, men det vil koste 25 % af dine angribende tropper livet. Hvis du trækker et bådangreb tilbage, vender båden tilbage til udgangspunktet og angriber der, hvis området er blevet erobret siden. Atommissiler kan ikke trækkes tilbage, når de først er affyret.", + "ui_events_quickchat": "Hurtig Chat — Her kan du se sendte og modtagne chatbeskeder. Send en besked til en spiller ved at klikke på Hurtig Chat-ikonet i deres informationsmenu.", + "ui_options": "Indstillinger", + "ui_options_desc": "Følgende elementer findes indenfor:", + "ui_playeroverlay": "Spillerinfo-overlay", + "ui_playeroverlay_desc": "Når du holder musen over et land, vises spillerinfo-overlayet under ‘Indstillinger’. Det viser spillertypen: Menneske, Nation (smart bot) eller Bot. Adfærd: fra fjendtlig til venlig. Derudover vises forsvarende tropper, guld samt antallet af krigsskibe og forskellige bygninger, spilleren har.", + "option_pause": "Pause/Unpause spillet - Kun tilgængelig i singleplayer-tilstand.", + "option_timer": "Timer – Tid gået siden spillets start.", + "option_exit": "Afslut knap.", + "option_settings": "Indstillinger – Åbn indstillingsmenuen. Her kan du skifte mellem alternativ visning, emojis, mørk tilstand, ninja (anonym/tilfældig navnetilstand) og handling ved venstreklik.", + "radial_title": "Hjulmenu", + "radial_desc": "Højreklik (eller tryk på mobilen) åbner hjulmenuen. Højreklik udenfor for at lukke den. Fra menuen kan du:", + "radial_build": "Åbn Bygge-menu.", + "radial_info": "Åbn Infomenuen.", + "radial_boat": "Send en båd (transportskibet) til at angribe på det valgte sted. Kun tilgængelig, hvis du har adgang til vand.", + "radial_close": "Luk menuen.", + "info_title": "Info menu", + "info_enemy_desc": "Indeholder oplysninger såsom den valgte spillers navn, guld, tropper, stoppet handel med dig, atomvåben sendt til dig, og om spilleren er en forræder. Stoppet handel betyder, at du ikke modtager guld fra dem, og de sender dig ikke guld via handelsskibe. Dette kan ske manuelt (hvis spilleren har klikket på 'Stop handel', hvilket varer, indtil I begge klikker på 'Start handel') eller automatisk (hvis du forrådte din alliance, hvilket varer, indtil I bliver allierede igen eller efter 5 minutter). Forræder vises som 'Ja' i 30 sekunder, når en spiller har forrådt og angrebet en spiller, der var i alliance med dem. Ikonerne nedenfor repræsenterer følgende interaktioner:", + "info_chat": "Send en hurtig chatbesked til spilleren. Vælg en kategori, et udtryk, og hvis udtrykket indeholder [P1] vælg et spillernavn til at erstatte det med. Tryk på Send.", + "info_target": "Placer et målmærke på spilleren, markerer det for alle allierede, bruges til at koordinere angreb.", + "info_alliance": "Send en alliance-anmodning til spilleren. Allierede kan dele ressourcer og tropper, men kan ikke angribe hinanden.", + "info_emoji": "Send en emoji til spilleren.", + "info_trade": "Brug \"Stop handel\" for at stoppe med at give spilleren guld og modtage deres guld via handelsskibe. Hvis I begge klikker på \"Start handel\", vil den starte igen.", + "info_ally_panel": "Allieret-info panel", + "info_ally_desc": "Når du allierer dig med en spiller, bliver følgende nye ikoner tilgængelige:", + "ally_betray": "Forråd din allierede, hvilket afslutter alliancen, stopper handlen og svækker dit forsvar. Handlen mellem jer sættes på pause i 5 minutter (eller indtil I bliver allierede igen), og andre kan også stoppe med at handle. Medmindre den anden spiller selv var en forræder, vil du blive markeret som forræder i 30 sekunder. I løbet af denne tid vil et ikon være over dit navn, og du får en 50 % forsvarsdebuff. Bots er mindre tilbøjelige til at alliere sig med dig, og spillere vil tænke sig om en ekstra gang, før de gør det.", + "ally_donate": "Doner nogle af dine tropper til din allierede. Bruges, når de er lav på tropper og bliver angrebet, eller når de har brug for den ekstra magt til at knuse en fjende.", + "ally_donate_gold": "Doner noget af dit guld til din allierede. Bruges, når de er lave på guld og har brug for det til bygninger, eller når dit holdmedlem sparer op til en MIRV.", + "build_menu_title": "Byggemenu", + "build_menu_desc": "Byg disse, eller se hvor mange af hver du allerede har bygget:", + "build_name": "Navn", + "build_icon": "Ikon", + "build_desc": "Beskrivelse", + "build_city": "By", + "build_city_desc": "Øger din maksimale befolkning. Nyttig, når du ikke kan udvide dit territorium, eller du er ved at ramme befolkningsgrænsen.", + "build_defense": "Forsvarspost", + "build_defense_desc": "Øger forsvaret omkring nærliggende grænser, som viser et ternet mønster. Angreb fra fjender er langsommere og har flere tab.", + "build_port": "Havn", + "build_port_desc": "Kan kun bygges i nærheden af vand. Gør det muligt at bygge krigsskibe. Sender automatisk handelsskibe mellem havne i dit land og andre lande (undtagen når handelen er stoppet), hvilket giver guld til begge parter. Handlen stopper automatisk, når du angriber eller bliver angrebet af en spiller. Den genoptages efter 5 minutter eller, hvis I bliver allierede. Du kan manuelt skifte handel til “Stop handel” eller “Start handel”.", + "build_warship": "Krigsskib", + "build_warship_desc": "Patruljerer et område, erobrer fjendtlige handelsskibe og ødelægger både (transportskibe) og krigsskibe. Spawner fra den nærmeste havn og patruljerer det område, du først klikkede på for at bygge den. Du kan styre krigsskibe ved at angribe-klikke på dem (se handlingen ‘Angreb’ under Genvejstaster) og derefter angribe-klikke på det nye område, du ønsker, de skal flytte til.", + "build_silo": "Missilsilo", + "build_silo_desc": "Tillader affyring af missiler.", + "build_sam": "SAM-affyringsrampe", + "build_sam_desc": "Kan opsnappe fjendtlige missiler inden for dens 100 pixels rækkevidde. Har 100 % træfsikkerhed mod atombomber, 80 % mod brintbomber og 50 % mod individuelle MIRV-sprænghoveder. Luftforsvarsmissilet har en nedkølingsperiode på 7,5 sekunder.", + "build_atom": "Atombombe", + "build_atom_desc": "Lille eksplosiv bombe, der ødelægger territorium, bygninger, skibe og både. Den spawner fra den nærmeste missilsilo og lander i det område, du først klikkede på for at bygge den.", + "build_hydrogen": "Brintbombe", + "build_hydrogen_desc": "Stor eksplosiv bombe. Den spawner fra den nærmeste missilsilo og lander i det område, du først klikkede på for at bygge den.", + "build_mirv": "MIRV", + "build_mirv_desc": "Den kraftigste bombe i spillet. Den opdeles i mindre bomber, som dækker et stort område. Den skader kun den spiller, du først klikkede på for at bygge den. Den dukker op fra den nærmeste missilsilo og lander i det område, du først klikkede på for at bygge den.", + "player_icons": "Spillerikoner", + "icon_desc": "Eksempler på nogle af de ikoner i spillet, du vil støde på, og hvad de betyder:", + "icon_crown": "Krone - Nummer 1. Dette er den øverste spiller på ranglisten.", + "icon_traitor": "Brudt skjold – Forræder. Denne spiller har angrebet en allieret.", + "icon_ally": "Håndtryk - Allieret. Denne spiller er din allierede.", + "icon_embargo": "Dollar-stoptegn – Embargo. Denne spiller har stoppet handlen med dig automatisk eller manuelt.", + "icon_request": "Konvolut - anmodning om Alliance. Denne spiller har sendt dig en allianceanmodning.", + "info_enemy_panel": "Fjende-info panel" + }, + "single_modal": { + "title": "Enkeltspiller", + "allow_alliances": "Tillad alliancer", + "options_title": "Indstillinger", + "bots": "Bots: ", + "bots_disabled": "Slået fra", + "disable_nations": "Deaktiver Nationer", + "instant_build": "Øjeblikkelig opbygning", + "infinite_gold": "Uendeligt guld", + "infinite_troops": "Uendelige tropper", + "disable_nukes": "Deaktiver Atommissiler", + "enables_title": "Aktiver Indstillinger", + "start": "Start spil" + }, + "map": { + "map": "Kort", + "world": "Verden", + "europe": "Europa", + "mena": "MENA", + "northamerica": "Nordamerika", + "oceania": "Oceanien", + "blacksea": "Sortehavet", + "africa": "Afrika", + "asia": "Asien", + "mars": "Mars", + "southamerica": "Sydamerika", + "britannia": "Storbritannien", + "gatewaytotheatlantic": "Porten til Atlanterhavet", + "australia": "Australien", + "random": "Tilfældig", + "iceland": "Island", + "pangaea": "Pangæa", + "eastasia": "Østasien", + "betweentwoseas": "Mellem To Hav", + "faroeislands": "Færøerne", + "deglaciatedantarctica": "Deglacieret Antarktis", + "europeclassic": "Europa (klassisk)", + "falklandislands": "Falklandsøerne", + "baikal": "Baikalsøen", + "halkidiki": "Halkidiki", + "giantworldmap": "Kæmpe Verdenskort" + }, + "map_categories": { + "continental": "Kontinental", + "regional": "Regional", + "fantasy": "Andre" + }, + "private_lobby": { + "title": "Deltag I Privat Lobby", + "enter_id": "Indtast Lobby ID", + "player": "Spiller", + "players": "Spillere", + "join_lobby": "Deltag I Lobby", + "checking": "Kontrollerer lobby...", + "not_found": "Lobbyen blev ikke fundet. Kontroller ID og prøv igen.", + "error": "Der opstod en fejl. Prøv igen.", + "joined_waiting": "Tilslutning gennemført! Venter på, at spillet starter..." + }, + "public_lobby": { + "join": "Deltag i næste Spil", + "waiting": "spillere venter", + "teams": "{num} hold" + }, + "username": { + "enter_username": "Indtast dit brugernavn", + "not_string": "Brugernavn skal være en streng.", + "too_short": "Brugernavn skal bestå af mindst {min} tegn.", + "too_long": "Brugernavn må ikke overskride {max} tegn.", + "invalid_chars": "Brugernavn kan kun indeholde bogstaver, tal, mellemrum, understregninger og [firkantede parenteser]." + }, + "host_modal": { + "title": "Privat Lobby", + "mode": "Tilstand", + "team_count": "Antal Hold", + "options_title": "Indstillinger", + "bots": "Bots: ", + "bots_disabled": "Slået fra", + "disable_nations": "Deaktiver Nationer", + "instant_build": "Øjeblikkelig opbygning", + "infinite_gold": "Uendeligt guld", + "infinite_troops": "Uendelige tropper", + "enables_title": "Aktiver Indstillinger", + "player": "Spiller", + "players": "Spillere", + "waiting": "Venter på spillerne...", + "start": "Start spil" + }, + "game_starting_modal": { + "title": "Spillet Starter...", + "desc": "Forbereder lobbyen på at starte. Vent venligst." + }, + "difficulty": { + "difficulty": "Sværhedsgrad", + "Relaxed": "Afslappet", + "Balanced": "Balanceret", + "Intense": "Intenst", + "Impossible": "Umulig" + }, + "game_mode": { + "ffa": "Alle mod Alle", + "teams": "Hold" + }, + "select_lang": { + "title": "Vælg Sprog" + }, + "unit_type": { + "city": "By", + "defense_post": "Forsvarspost", + "port": "Havn", + "warship": "Krigsskib", + "missile_silo": "Missilsilo", + "sam_launcher": "SAM-affyringsrampe", + "atom_bomb": "Atombombe", + "hydrogen_bomb": "Brintbombe", + "mirv": "MIRV" + }, + "user_setting": { + "title": "Brugerindstillinger", + "tab_basic": "Grundlæggende indstillinger", + "tab_keybinds": "Genvejstaster", + "dark_mode_label": "🌙 Mørk Tilstand", + "dark_mode_desc": "Skift webstedets udseende mellem lyst og mørkt tema", + "emojis_label": "😊 Emojis", + "emojis_desc": "Skift mellem om emojis vises i spillet", + "special_effects_label": "💥 Specialeffekter", + "special_effects_desc": "Slå specialeffekter til eller fra. Deaktivér for at forbedre ydeevnen", + "anonymous_names_label": "🥷 Skjulte Navne", + "anonymous_names_desc": "Skjul rigtige spillernavne med tilfældige navne på din skærm.", + "left_click_label": "🖱️ Venstreklik for at Åbne Menuen", + "left_click_desc": "Når den er TIL, åbner venstreklik menuen, og sværdknappen angriber. Når den er FRA, angriber venstreklik direkte.", + "attack_ratio_label": "⚔️ Angrebsforhold", + "attack_ratio_desc": "Hvor stor en procentdel af dine tropper skal sendes i et angreb (1–100%)", + "troop_ratio_label": "🪖🛠️ Forholdet mellem tropper og arbejdere", + "troop_ratio_desc": "Juster balancen mellem tropper (til kamp) og arbejdere (til guldproduktion) (1–100%)", + "territory_patterns_label": "🏳️ Territoriummønstre", + "territory_patterns_desc": "Vælg, om territoriemønstre skal vises i spillet", + "easter_writing_speed_label": "Skrivehastigheds-multiplikator", + "easter_writing_speed_desc": "Juster, hvor hurtigt du simulerer at kode (x1–x100)", + "easter_bug_count_label": "Fejlantal", + "easter_bug_count_desc": "Hvor mange fejl du har det fint med (0-1000, følelsesmæssigt)", + "view_options": "Vis Indstillinger", + "toggle_view": "Skift Visning", + "toggle_view_desc": "Alternativ visning (terræn/lande)", + "attack_ratio_controls": "Styring af angrebsforhold", + "attack_ratio_up": "Forøg Angrebsforhold", + "attack_ratio_up_desc": "Forøg angrebsforhold med 10%", + "attack_ratio_down": "Formindsk Angrebsforhold", + "attack_ratio_down_desc": "Formindsk angrebsforhold med 10%", + "attack_keybinds": "Angrebsgenvejstaster", + "boat_attack": "Båd Angreb", + "boat_attack_desc": "Send et bådangreb til den flise, din markør peger på.", + "zoom_controls": "Zoom Styring", + "zoom_out": "Zoom Ud", + "zoom_out_desc": "Zoom ud af kortet", + "zoom_in": "Zoom Ind", + "zoom_in_desc": "Zoom ind på kortet", + "camera_movement": "Kamera bevægelse", + "center_camera": "Centrer Kamera", + "center_camera_desc": "Centrer kamera på spiller", + "move_up": "Flyt Kamera Op", + "move_up_desc": "Flyt kameraet opad", + "move_left": "Flyt Kamera Til Venstre", + "move_left_desc": "Flyt kameraet til venstre", + "move_down": "Flyt Kamera Ned", + "move_down_desc": "Flyt kameraet nedad", + "move_right": "Flyt Kamera Til Højre", + "move_right_desc": "Flyt kameraet til højre", + "reset": "Nulstil", + "unbind": "Fjern tilknytning" + }, + "chat": { + "title": "Hurtig Chat", + "to": "Sendt {user}: {msg}", + "from": "Fra {user}: {msg}", + "category": "Kategori", + "phrase": "Sætning", + "player": "Spiller", + "send": "Send", + "search": "Søg efter spiller...", + "build": "Byg din besked...", + "cat": { + "help": "Hjælp", + "attack": "Angrib", + "defend": "Forsvar", + "greet": "Hilsner", + "misc": "Diverse", + "warnings": "Advarsler" + }, + "help": { + "troops": "Vær venlig at give mig tropper!", + "gold": "Vær venlig at give mig guld!", + "no_attack": "Lad være med at angribe mig!", + "sorry_attack": "Beklager, jeg mente ikke at angribe.", + "alliance": "Alliance?", + "help_defend": "Hjælp mig med at forsvare mod [P1]!", + "team_up": "Lad os holde sammen mod [P1]!" + }, + "attack": { + "attack": "Angrib [P1]!", + "mirv": "Affyr en MIRV på [P1]!", + "focus": "Fokuser ilden på [P1]!", + "finish": "Lad os gøre det af med [P1]!" + }, + "defend": { + "defend": "Forsvar [P1]!", + "dont_attack": "Angrib ikke [P1]!", + "ally": "[P1] er min allierede!" + }, + "greet": { + "hello": "Hej!", + "good_luck": "Held og lykke!", + "have_fun": "Hav det sjovt!", + "gg": "GG!", + "nice_to_meet": "Rart at møde dig!", + "well_played": "Godt spillet!", + "hi_again": "Hej igen!", + "bye": "Farvel!", + "thanks": "Tak!", + "oops": "Hovsa, forkert knap!", + "trust_me": "Du kan stole på mig. Det lover jeg!", + "trust_broken": "Jeg stolede på dig..." + }, + "misc": { + "go": "Lad os komme i gang!", + "strategy": "Flot strategi!", + "fun": "Det her spil er sjovt!", + "pr": "Hvornår bliver mine PR endelig slået sammen...?" + }, + "warnings": { + "strong": "[P1] er stærk.", + "weak": "[P1] er svag.", + "mirv_soon": "[P1] kan snart affyre en MIRV!", + "number1_warning": "Spiller #1 vinder snart, medmindre vi slår os sammen!", + "stalemate": "Lad os slutte fred. Dette er en dødvande, og vi vil begge tabe.", + "has_allies": "[P1] har mange allierede.", + "no_allies": "[P1] har ingen allierede.", + "betrayed": "[P1] forrådte deres allierede!", + "getting_big": "[P1] vokser for hurtigt!", + "danger_base": "[P1] er ubeskyttet!", + "saving_for_mirv": "[P1] sparer op til at affyre en MIRV.", + "mirv_ready": "[P1] har nok guld til at affyre en MIRV!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Lille eksplosion", + "hydrogen_bomb": "Stor eksplosion", + "mirv": "Kæmpe eksplosion, rammer kun den valgte spiller", + "missile_silo": "Bruges til affyring af atommissiler", + "sam_launcher": "Forsvarer mod indkommende atommissiler", + "warship": "Indfanger handelsskibe, ødelægger skibe og både", + "port": "Sender handelsskibe for at generere guld", + "defense_post": "Forstærker forsvaret af nærliggende grænser", + "city": "Forøger maks. befolkning" + }, + "not_enough_money": "Ikke nok penge" + }, + "win_modal": { + "died": "Du døde", + "your_team": "Dit hold vandt!", + "other_team": "{team} hold har vundet!", + "you_won": "Du Vandt!", + "other_won": "{player} har vundet!", + "exit": "Afslut Spil", + "keep": "Fortsæt Spillet", + "wishlist": "Føj til ønskelisten på Steam!" + }, + "leaderboard": { + "title": "Rangliste", + "hide": "Skjul", + "rank": "Rang", + "player": "Spiller", + "owned": "Ejet", + "gold": "Guld", + "troops": "Tropper" + }, + "player_info_overlay": { + "type": "Type", + "bot": "Bot", + "nation": "Nation", + "player": "Spiller", + "team": "Hold", + "d_troops": "Forsvarende tropper", + "a_troops": "Angribende tropper", + "gold": "Guld", + "ports": "Havne", + "cities": "Byer", + "missile_launchers": "Missilramper", + "sams": "SAMs", + "warships": "Krigsskibe", + "health": "Helbred", + "attitude": "Adfærd" + }, + "relation": { + "hostile": "Fjendtlig", + "distrustful": "Mistroisk", + "neutral": "Neutral", + "friendly": "Venlig", + "default": "Standard" + }, + "control_panel": { + "pop": "Befolk", + "gold": "Guld", + "troops": "Tropper", + "workers": "Arbejdere", + "attack_ratio": "Angrebsforhold" + }, + "player_panel": { + "gold": "Guld", + "troops": "Tropper", + "betrayals": "Antal forræderier", + "traitor": "Forræder", + "alliance_time_remaining": "Alliancen Udløber Om", + "embargo": "Stoppede med at handle med dig", + "nuke": "Atommissiler sendt af dem til dig", + "start_trade": "Start handel", + "stop_trade": "Stop handel", + "yes": "Ja", + "no": "Nej", + "none": "Ingen", + "alliances": "Alliancer" + }, + "error_modal": { + "crashed": "Spillet crashede!", + "paste_discord": "Indsæt venligst følgende i din fejlrapport i Discord:", + "copy_clipboard": "Kopier til udklipsholder", + "copied": "Kopieret!", + "failed_copy": "Kopiering mislykkedes", + "desync_notice": "Du er ikke synkroniseret med de andre spillere. Det, du ser, kan derfor være forskelligt fra, hvad de andre spillere ser." + }, + "heads_up_message": { + "choose_spawn": "Vælg en startplacering" + } +} diff --git a/resources/lang/en.json b/resources/lang/en.json index dc2848d5c..9b21f9782 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -26,7 +26,7 @@ "news": { "full_changelog": "See the complete change log", "github_link": "on GitHub", - "title": "Version 23 released!" + "title": "Release Notes" }, "help_modal": { "hotkeys": "Hotkeys", @@ -43,6 +43,7 @@ "action_reset_gfx": "Reset graphics", "ui_section": "Game UI", "ui_leaderboard": "Leaderboard", + "ui_your_team": "Your team:", "ui_leaderboard_desc": "Shows the top players of the game and their names, % owned land, gold and troops. Using Show All shows all players in the game. If you don't want to see the leaderboard, click Hide.", "ui_control": "Control panel", "ui_control_desc": "The control panel contains the following elements:", @@ -203,6 +204,16 @@ "waiting": "Waiting for players...", "start": "Start Game" }, + "team_colors": { + "red": "Red", + "blue": "Blue", + "teal": "Teal", + "purple": "Purple", + "yellow": "Yellow", + "orange": "Orange", + "green": "Green", + "bot": "Bot" + }, "game_starting_modal": { "title": "Game is Starting...", "desc": "Preparing for the lobby to start. Please wait." @@ -230,23 +241,25 @@ "sam_launcher": "SAM Launcher", "atom_bomb": "Atom Bomb", "hydrogen_bomb": "Hydrogen Bomb", - "mirv": "MIRV" + "mirv": "MIRV", + "train": "Train", + "factory": "Factory" }, "user_setting": { "title": "User Settings", "tab_basic": "Basic Settings", "tab_keybinds": "Keybinds", - "dark_mode_label": "🌙 Dark Mode", + "dark_mode_label": "Dark Mode", "dark_mode_desc": "Toggle the site’s appearance between light and dark themes", - "emojis_label": "😊 Emojis", + "emojis_label": "Emojis", "emojis_desc": "Toggle whether emojis are shown in game", - "alert_frame_label": "🚨 Alert Frame", + "alert_frame_label": "Alert Frame", "alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed.", - "special_effects_label": "💥 Special effects", + "special_effects_label": "Special effects", "special_effects_desc": "Toggle special effects. Deactivate to improve performances", - "anonymous_names_label": "🥷 Hidden Names", + "anonymous_names_label": "Hidden Names", "anonymous_names_desc": "Hide real player names with random ones on your screen.", - "left_click_label": "🖱️ Left Click to Open Menu", + "left_click_label": "Left Click to Open Menu", "left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.", "attack_ratio_label": "⚔️ Attack Ratio", "attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)", @@ -392,9 +405,12 @@ "hide": "Hide", "rank": "Rank", "player": "Player", + "team": "Team", "owned": "Owned", "gold": "Gold", - "troops": "Troops" + "troops": "Troops", + "show_top_5": "Show Top 5", + "show_all": "Show All" }, "player_info_overlay": { "type": "Type", @@ -416,7 +432,12 @@ }, "events_display": { "retreating": "retreating", - "boat": "Boat" + "boat": "Boat", + "about_to_expire": "Your alliance with {name} is about to expire!", + "renew_alliance": "Request to renew", + "focus": "Focus", + "alliance_renewed": "Your alliance with {name} has been renewed", + "ignore": "Ignore" }, "unit_info_modal": { "structure_info": "Structure Info", @@ -475,21 +496,23 @@ }, "territory_patterns": { "title": "Select Territory Pattern", + "purchase": "Purchase", "blocked": { "login": "You must be logged in to access this pattern.", - "role": "This pattern requires the {role} role." + "purchase": "Purchase this pattern to unlock it." }, "pattern": { "default": "Default", "custom": "Custom", - "stripes_v": "Vertical Stripes", - "stripes_h": "Horizontal Stripes", + "stripes_v": "Vertical", + "stripes_h": "Horizontal", + "horizontal_stripes": "Horizontal (Alt)", + "vertical_bars": "Vertical (Alt)", "checkerboard": "Checkerboard", "choco": "Choco", "diagonal": "Diagonal", "cross": "Cross", "mini_cross": "Mini Cross", - "horizontal_stripes": "Horizontal Stripes (Alt)", "sword": "Sword", "sparse_dots": "Sparse Dots", "evan": "Evan", @@ -497,15 +520,20 @@ "mountain_ridge": "Mountain Ridge", "scattered_dots": "Scattered Dots", "circuit_board": "Circuit Board", - "vertical_bars": "Vertical Bars", + "shells": "Shells", "-w-": ".w.", "white_rabbit": "White Rabbit", "goat": "Goat", + "cats": "Cats", "cursor": "Cursor", "hand": "Hand", "radiation": "Radiation", "openfront_qr": "OpenFront.io QR code", - "openfront": "OpenFront" + "openfront": "OpenFront", + "t_rex": "T-Rex", + "embelem": "Emblem", + "grogu_head": "Grogu Head", + "grogu": "Grogu" } } } diff --git a/resources/lang/eo.json b/resources/lang/eo.json index 937c035e7..208c63c55 100644 --- a/resources/lang/eo.json +++ b/resources/lang/eo.json @@ -5,10 +5,14 @@ "svg": "eo", "lang_code": "eo" }, + "common": { + "close": "Fermi" + }, "main": { "title": "OpenFront (ALFA)", "join_discord": "Kunigu la Discord-servilon !", "login_discord": "Ensaluti kun Diskordo", + "checking_login": "Kontrolado de la ensaluto...", "logged_in": "Ensalutinta!", "log_out": "Elsaluti", "create_lobby": "Krei Ludejon", @@ -16,8 +20,12 @@ "single_player": "Sola Ludanto", "instructions": "Instrukcioj", "how_to_play": "Kiel ludi", + "advertise": "Reklami", "wiki": "Vikio" }, + "news": { + "title": "23-a eldono publikigita!" + }, "help_modal": { "hotkeys": "Rapidklavoj", "table_key": "Klavo", @@ -135,14 +143,15 @@ "random": "Hazarda", "iceland": "Islando", "pangaea": "Pangeo", + "eastasia": "Orienta Azio", "betweentwoseas": "Inter du maroj", - "knownworld": "Konata Mondo", "faroeislands": "Ferooj", "deglaciatedantarctica": "Malglacia Antarkto", "europeclassic": "Eŭropo (klasika)", "falklandislands": "Falklandoj", "baikal": "Bajkalo", - "halkidiki": "Ĥalkidiko" + "halkidiki": "Ĥalkidiko", + "giantworldmap": "Giganta Mondmapo" }, "map_categories": { "continental": "Kontinenta", @@ -236,6 +245,8 @@ "attack_ratio_desc": "Kian procenton de viaj trupoj sendi en atako (1–100%)", "troop_ratio_label": "🪖🛠️ Trupoj kaj Laboristoj kvociento", "troop_ratio_desc": "Alĝustigu la ekvilibron inter soldatoj (por batalo) kaj laboristoj (por orproduktado) (1–100%)", + "territory_patterns_label": "🏳️ Teritoriaj Ŝablonoj", + "territory_patterns_desc": "Elektu ĉu montri teritoriajn ŝablonajn dezajnojn en la ludo", "easter_writing_speed_label": "Rapidskriba multiganto", "easter_writing_speed_desc": "Alĝustigu kiom rapide vi ŝajnigas kodi (x1–x100)", "easter_bug_count_label": "Nombro da cimoj", @@ -243,6 +254,14 @@ "view_options": "Vidi opciojn", "toggle_view": "Baskuli vido", "toggle_view_desc": "Alterna vido (tereno/landoj)", + "attack_ratio_controls": "Kontroloj de atakkvociento", + "attack_ratio_up": "Pliigi atakkvocienton", + "attack_ratio_up_desc": "Pliigi atakkvocienton je 10%", + "attack_ratio_down": "Malpliigi atakkvocienton", + "attack_ratio_down_desc": "Malpliigi atakkvocienton je 10%", + "attack_keybinds": "Atakaj Fulmoklavoj", + "boat_attack": "Boata Atako", + "boat_attack_desc": "Sendi boatan atakon al la kahelo sub via kursoro.", "zoom_controls": "Zomaj kontroloj", "zoom_out": "Malzomi", "zoom_out_desc": "Malzomi la mapon", @@ -356,7 +375,8 @@ "you_won": "Vi venkis!", "other_won": "{player} venkis!", "exit": "Eliri el la ludon", - "keep": "Daŭrigu ludi" + "keep": "Daŭrigu ludi", + "wishlist": "Dezirlisto ĉe Steam!" }, "leaderboard": { "title": "Ĉampionejo", @@ -401,13 +421,27 @@ "player_panel": { "gold": "Oro", "troops": "Trupoj", + "betrayals": "Nombro da perfidoj", "traitor": "Perfidulo", - "alliance_time_remaining": "Restinta Tempo", + "alliance_time_remaining": "Alianco finiĝos en", "embargo": "Ĉesis komerci kun vi", "nuke": "Atombomboj senditaj de ili al vi", "start_trade": "Komenci komercadon", "stop_trade": "Ĉesi komercadon", "yes": "Jes", - "no": "Ne" + "no": "Ne", + "none": "Neniu", + "alliances": "Aliancoj" + }, + "error_modal": { + "crashed": "Ludo kolapsis!", + "paste_discord": "Bonvolu elpoŝigi la jenon en via cimo-raporto en Discord:", + "copy_clipboard": "Kopii al tondujo", + "copied": "Kopiita!", + "failed_copy": "Malsukcesis kopii", + "desync_notice": "Vi estas malsinkronigita de aliaj ludantoj. Kion vi vidas eble diferencas de aliaj ludantoj." + }, + "heads_up_message": { + "choose_spawn": "Elektu komencan lokon" } } diff --git a/resources/lang/es.json b/resources/lang/es.json index 5ff191327..93c877be5 100644 --- a/resources/lang/es.json +++ b/resources/lang/es.json @@ -230,7 +230,7 @@ "special_effects_desc": "Activa los efectos especiales. Desactiva para mejorar el rendimiento", "anonymous_names_label": "🥷 Nombres ocultos", "anonymous_names_desc": "Oculta los nombres de jugadores reales con nombres aleatorios en tu pantalla.", - "left_click_label": "🖱️ Clic izquierdo para abrir el menú", + "left_click_label": "Clic izquierdo para abrir el menú", "left_click_desc": "Cuando está activado, el clic izquierdo abre el menú y el botón de espada ataca. Cuando está desactivado, el clic izquierdo ataca directamente.", "attack_ratio_label": "⚔️ Ratio de ataque", "attack_ratio_desc": "Porcentaje de tus tropas que se envían en un ataque (1–100%)", diff --git a/resources/lang/fi.json b/resources/lang/fi.json new file mode 100644 index 000000000..2638906a6 --- /dev/null +++ b/resources/lang/fi.json @@ -0,0 +1,447 @@ +{ + "lang": { + "en": "Finnish", + "native": "suomi", + "svg": "fi", + "lang_code": "fi" + }, + "common": { + "close": "Sulje" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "Liity Discordiin!", + "login_discord": "Kirjaudu sisään Discordilla", + "checking_login": "Tarkistetaan sisäänkirjautumista...", + "logged_in": "Kirjautuminen onnistui!", + "log_out": "Kirjaudu ulos", + "create_lobby": "Luo aula", + "join_lobby": "Liity aulaan", + "single_player": "Yksinpeli", + "instructions": "Ohjeet", + "how_to_play": "Kuinka pelata", + "advertise": "Mainosta", + "wiki": "Wiki" + }, + "news": { + "title": "Versio 23 julkaistu!" + }, + "help_modal": { + "hotkeys": "Pikanäppäimet", + "table_key": "Näppäin", + "table_action": "Toimenpide", + "action_alt_view": "Vaihtoehtoinen näkymä (maasto/valtiot)", + "action_attack_altclick": "Hyökkäys (kun hiiren vasen painike on asetettu avaamaan valikon)", + "action_build": "Avaa rakennusvalikko", + "action_emote": "Avaa hymiövalikko", + "action_center": "Keskitä kamera pelaajaan", + "action_zoom": "Loitonna/Lähennä", + "action_move_camera": "Liikuta kameraa", + "action_ratio_change": "Vähennä/Lisää hyökkäyksen osuutta", + "action_reset_gfx": "Palauta grafiikat", + "ui_section": "Pelin käyttöliittymä", + "ui_leaderboard": "Pistetaulukko", + "ui_leaderboard_desc": "Näyttää pelin parhaat pelaajat, sekä heidän kullan, sotilaiden ja maan (%) määrän. Painamalla \"Näytä kaikki\" näet kaikki pelin pelaajat. Jos et halua nähdä pistetaulukkoa, paina \"Sulje\".", + "ui_control": "Ohjauspaneeli", + "ui_control_desc": "Ohjauspaneeli sisältää seuraavat toiminnot:", + "ui_pop": "Asukasluku - Asukkaiden määrä, enimmäisasukaslukusi ja vauhti, jolla saat asukkaita lisää.", + "ui_gold": "Kulta - Kullan määrä ja kullan saantinopeus.", + "ui_troops_workers": "Sotilaat ja työntekijät - Kohdennettujen sotilaiden ja työntekijöiden määrä. Joukkoja käytetään hyökkäämiseen tai puolustamiseen. Työntekijöitä käytetään kullan tuottamiseen. Voit säädellä joukkojen ja työntekijöiden määrää liukusäätimellä.", + "ui_attack_ratio": "Hyökkäyksen osuus - Määrä joukkoja, jota käytetään hyökätessäsi. Voit säätää osuutta liukusäätimellä. Jos sinulla on enemmän hyökkääviä joukkoja kuin puolustavia joukkoja, menetät vähemmän joukkoja hyökkäyksessä. Jos sinulla on vähemmän hyökkääviä joukkoja kuin puolustavia joukkoja, hyökkäävät joukot vahingoittuvat enemmän. Tämä ilmiö ei ylitä suhdetta 2:1.", + "ui_events": "Tapahtumapaneeli", + "ui_events_desc": "Tapahtumapaneelista näkee uusimmat tapahtumat, kutsut ja pikaviestit. Esimerkiksi:", + "ui_events_alliance": "Liitto - Liittokutsut voi joko hyväksyä tai hylätä. Liittolaiset voivat jakaa keskenään resursseja ja kultaa, mutta eivät voi hyökätä toisiinsa. Painamalla \"Kohdista\" näet pelaajan, joka lähetti liittokutsun.", + "ui_events_attack": "Hyökkäykset - sinuun kohdistuvat hyökkäykset ja hyökkäyksesi muihin. Napauta viestiä keskittääksesi näkymä hyökkäykseen, ydinohjukseen, tai laivaan (kuljetusalukseen). Voit antautua painamalla punaista X-nappia. Antautuessa 25 prosenttia hyökkäävistä joukoista menettää henkestä. Jos antaudut merihyökkäyksestä, alus palaa lähtöpaikkaansa ja, jos lähtöpaikka on valloitettu, se hyökkää siihen. Lähetettyjä ydinohjuksia ei voi pysäyttää.", + "ui_events_quickchat": "Pikaviestit - Täältä näet lähetetyt ja vastaanotetut viestit. Lähetä viesti pelaajalle klikkaamalla pikaviestit-kuvaketta hänen info-valikostaan.", + "ui_options": "Toiminnot", + "ui_options_desc": "Sisältää seuraavat toiminnot:", + "ui_playeroverlay": "Pelaajan tietojen peittokuva", + "ui_playeroverlay_desc": "Kun pidät hiirtä maan päällä, pelaajan tietojen peittokuva näytetään asetusten alla. Se näyttää onko pelaaja ihminen, kansakunta (eli järkevä botti) vai botti. Kansakunnan asenne sinua kohtaan vaihtelee vihamielisestä ystävälliseen. Peittokuva näyttää myös pelaajan puolustavien joukkojen, kullan, sotalaivojen ja erilaisten rakennusten määrän.", + "option_pause": "Pysäytä/Epäpysäytä peli - Saatavilla vain yhden pelaajan tilassa.", + "option_timer": "Ajastin - Aikaa pelin alusta.", + "option_exit": "Poistu pelistä -painike.", + "option_settings": "Asetukset - Avaa asetusvalikon. Siellä voit säätää vaihtoehtoista näkymää, hymiöitä, tummaa tilaa, ninjatilaa (piilottaa pelaajien oikeat nimet) ja hiiren vasemman napin klikkauksen toimintoa.", + "radial_title": "Ympyrävalikko", + "radial_desc": "Napsauttamalla hiiren oikeaa painiketta (tai koskettamalla mobiililaitteella) avataan ympyrävalikko. Napsauttamalla hiiren oikeaa painiketta sen ulkopuolella suljet sen. Ympyrävalikosta näet:", + "radial_build": "Avaa rakennusvalikko.", + "radial_info": "Avaa tietopaneeli.", + "radial_boat": "Lähetä vene (kuljetusalus) hyökkäämään valittuun sihaintiin. Saatavilla vain, jos sinulla on pääsy vesistöön.", + "radial_close": "Sulje valikko.", + "info_title": "Tietopaneeli", + "info_enemy_desc": "Sisältää tietoa, kuten kyseisen pelaajan nimen, kullan ja joukkojen määrän, sinua kohti laukaistut ydinaseet ja sen, onko pelaaja petturi ja onko hän lopettanut kaupankäynnin kanssasi. Jos hän on lopettanut kaupankäynnin kanssasi, et saa kultaa häneltä kauppa-aluksilla. Kaupankäynnin voi lopettaa manuaalisesti (Jos pelaaja on painanut \"Lopeta kaupankänti, kauppaa ei käydä kunnes molemmat painavat \"Aloita kaupankäynti\".) tai automaattisesti (Jos olet pettänyt liitossa, et käy kauppaa kyseisen pelaajan kanssa, kunnes teistä tulee liittolaisia uudestaan tai viiden minuutin päästä). Pelaaja on petturi 30 sekunttia sen jälkeen, kun hän hylkää liittolaisensa ja hyökkää tähän. Alla olevat kuvakkeet kertovat seuraavista vuorovaikutustilanteista:", + "info_chat": "Lähetä pikaviesti pelaajalle. Valitse kategoria, lause ja, jos lauseessa lukee [P1], valitse pelaajan nimi sen tilalle. Paina \"Lähetä\".", + "info_target": "Aseta pelaajalle kohdemerkki, jolloin suosittelet kaikkia liittolaisia hyökkäämään häneen.", + "info_alliance": "Lähetä liittokutsu pelaajalle. Liittolaiset voivat jakaa resursseja ja joukkoja, mutta eivät voi hyökätä toisiinsa.", + "info_emoji": "Lähetä hymiö pelaajalle.", + "info_trade": "Käytä \"Lopeta kaupankäynti\" - nappia lopettaaksesi antamasta pelaajalle kultaa kauppa-aluksilla. Jos molemmat painavat \"Aloita kaupankäynti\", kaupankäynti alkaa uudelleen.", + "info_ally_panel": "Liittolaisen tietopaneeli", + "info_ally_desc": "Kun liittoudut pelaajan kanssa, seuraavat uudet kuvakkeet tulevat saataville:", + "ally_betray": "Petä liittolaisesi lopettaen liittouman, pysäyttäen kaupankäynnin ja heikentäen puolustustasi. Kaupankäynti teidän välillä on keskeytetty viideksi minuutiksi (tai kunnes teistä tulee liittolaisia uudelleen) ja muut saattavat myös lopettaa kaupankäynnin kanssasi. Jos toinen pelaaja ei ole petturi, sinut merkitään petturiksi 30 sekunniksi, jolloin petturikuvake on nimesi yläpuolella ja sinulla on 50% puolustuksen huononnus. Botit liittoutuvat kanssasi harvemmin ja muut pelaajat tulevat miettimään kahdesti ennen liittoutumista kanssasi.", + "ally_donate": "Lahjota osa joukoistasi liittolaisellesi. Käytetään, kun liittolaisella on vähän joukkoja, kun heihin hyökätään tai, kun he tarvitsevat ylimääräistä voimaa vihollisen murskaamiseen.", + "ally_donate_gold": "Lahjoita osa kullastasi liittolaisellesi. Käytetään, kun liittolainen tarvitsee kultaa rakennuksiin tai, jos liittolainen säästää MIRViin.", + "build_menu_title": "Rakennusvalikko", + "build_menu_desc": "Rakenna näitä tai näe, kuinka monta tietynlaista olet jo rakentanut:", + "build_name": "Nimi", + "build_icon": "Kuvake", + "build_desc": "Kuvaus", + "build_city": "Kaupunki", + "build_city_desc": "Lisää enimmäisväkilukuasi. Hyödyllinen, kun et voi laajentaa aluettasi tai, jos haluat muuten lisätä väkilukuasi.", + "build_defense": "Puolustustorni", + "build_defense_desc": "Lisää puolustuksen tehokkuutta rajojen läheisyydessä. Vihollisten hyökkäykset puolustustornin lähellä ovat hitaampia ja niissä on enemmän uhreja.", + "build_port": "Satama", + "build_port_desc": "Voidaan rakentaa vain veden läheisyyteen. Mahdollistaa sotalaivojen rakentamisen. Lähettää kauppa-aluksia automaattisesti sinun satamista muiden pelaajien satamiin (paitsi, jos kauppa on pysäytetty). Kauppa-alukset tuovat kultaa. Kaupankäynti pysähtyy automaattisesti, kun hyökkäät tai, kun pelaaja hyökkää sinuun. Se palaa 5 minuutin päästä tai, jos teistä tulee liittolaisia. Voit säätää manuaalisesti kaupankäyntiä painamalla \"Lopeta kaupankäynti\" tai \"Aloita kaupankäynti\".", + "build_warship": "Sota-alus", + "build_warship_desc": "Partioivat alueella, vangiten vihollisen kauppa-aluksia ja tuhoten heidän veneitään (kuljetusaluksia) ja sota-aluksiaan. Lähtee lähimmästä satamasta ja partioi aluella, jota painoit rakentaaksesi sen. Voit hallita sotalaivoja napsauttamalla niitä ja valitsemalla hyökkäyksen (katso toiminto \"Hyökkäys\" pikanäppäimistä) ja sitten napsauttamalla ja valitsemalla hyökkäyksen alueelle, jonne haluat niiden siirtyvän.", + "build_silo": "Ohjussiilo", + "build_silo_desc": "Mahdollistaa ohjusten laukaisemisen.", + "build_sam": "Ilmatorjuntaohjusjärjestelmä", + "build_sam_desc": "Torjuu vihollisen ohjuksia 100 pikselin kantamallaan. Osuu atomipommeihin 100% todennäköisyydellä, vetypommeihin 80% todennäköisyydellä ja MIRVIn yksittäisiin kärkiin 50% todennäköisyydellä. Ilmatorjuntaohjusjärjestelmällä kestää 7,5 sekuntia virittyä uudelleen.", + "build_atom": "Atomipommi", + "build_atom_desc": "Pieni räjähtävä pommi, joka tuloaa aluetta, rakennuksia, laivoja ja veneitä. Lähtee lähimmästä ohjussiilostasi ja osuu alueelle, jota ensin klikkasit rakentaaksesi sen.", + "build_hydrogen": "Vetypommi", + "build_hydrogen_desc": "Suuri räjähtävä pommi. Lähtee lähimmästä ohjussiilostasi ja osuu alueelle, jota ensin klikkasit rakentaaksesi sen.", + "build_mirv": "MIRV", + "build_mirv_desc": "Pelin tehokkain pommi. Hajaantuu kärkiin, jotka kattavat valtavan osan alueesta. Vahingoittaa vain pelaajaa, jota klikkasit pommia lähettäessäsi. Lähtee lähimmästä ohjussiilostasi ja osuu alueelle, jota ensin klikkasit.", + "player_icons": "Pelaajien kuvakkeet", + "icon_desc": "Esimerkkejä joistakin pelinsisäisitä kuvakkeista, jota tulet kohtaamaan sekä niiden tarkoituksia:", + "icon_crown": "Kruunu - Numero 1. Tämä pelaaja on ensimmäisenä pistetaulukossa.", + "icon_traitor": "Rikkinäinen kilpi - petturi. Tämä pelaaja on hyökännyt liittolaiseensa.", + "icon_ally": "Kädenpuristus - liittolainen. Tämä pelaaja on liittolaisesi.", + "icon_embargo": "Yliviivattu dollari - kauppasaarto. Tämä pelaaja on lopettanut kapuankäynnin kanssasi automaattisesti tai manuaalisesti.", + "icon_request": "Kirjekutsu - Liittopyyntö. Tämä pelaaja on lähettänyt sinulle liittokutsun.", + "info_enemy_panel": "Vihollisen tietopaneeli" + }, + "single_modal": { + "title": "Yksinpeli", + "allow_alliances": "Sallii liittoumat", + "options_title": "Valinnat", + "bots": "Botit: ", + "bots_disabled": "Poissa käytöstä", + "disable_nations": "Poista kansakunnat", + "instant_build": "Välitön rakentaminen", + "infinite_gold": "Loputon kulta", + "infinite_troops": "Loputtomat joukot", + "disable_nukes": "Poista ydinohjukset käytöstä", + "enables_title": "Aktivoi asetukset", + "start": "Aloita peli" + }, + "map": { + "map": "Kartta", + "world": "Maailma", + "europe": "Eurooppa", + "mena": "MENA", + "northamerica": "Pohjois-Amerikka", + "oceania": "Oseania", + "blacksea": "Mustameri", + "africa": "Afrikka", + "asia": "Aasia", + "mars": "Mars", + "southamerica": "Etelä-Amerikka", + "britannia": "Britannia", + "gatewaytotheatlantic": "Portti Atlantille", + "australia": "Australia", + "random": "Satunnainen", + "iceland": "Islanti", + "pangaea": "Pangaea", + "eastasia": "Itä-Aasia", + "betweentwoseas": "Kahden meren välillä", + "faroeislands": "Färsaaret", + "deglaciatedantarctica": "Jäätön antarktis", + "europeclassic": "Eurooppa (klassinen)", + "falklandislands": "Falklandinsaaret", + "baikal": "Baikal", + "halkidiki": "Halkidiki", + "giantworldmap": "Jättimäinen maailmankartta" + }, + "map_categories": { + "continental": "Mantereet", + "regional": "Alueet", + "fantasy": "Muut" + }, + "private_lobby": { + "title": "Liity yksityiseen aulaan", + "enter_id": "Syötä aulan tunnus", + "player": "Pelaaja", + "players": "Pelaajat", + "join_lobby": "Liity aulaan", + "checking": "Tarkistetaan sisäänkirjautumista...", + "not_found": "Aulaa ei löytynyt. Ole hyvä ja tarkista tunnus ja yritä uudelleen.", + "error": "Tapahtui virhe. Ole hyvä, ja yritä uudelleen.", + "joined_waiting": "Liittyminen onnistui! Odotetaan pelin alkamista..." + }, + "public_lobby": { + "join": "Liity seuraavaan peliin", + "waiting": "pelaajaa odottaa", + "teams": "{num} joukkuetta" + }, + "username": { + "enter_username": "Syötä käyttäjänimesi", + "not_string": "Käyttäjänimen on oltava merkkijono.", + "too_short": "Käyttäjänimen on oltava vähintään {min} merkkiä pitkä.", + "too_long": "Käyttäjänimi ei saa ylittää {max} merkkiä.", + "invalid_chars": "Käyttäjänimi voi sisältää vain kirjaimia, numeroita, välilyöntejä, alaviivoja ja [hakasulkeita]." + }, + "host_modal": { + "title": "Yksityinen aula", + "mode": "Pelimuoto", + "team_count": "Joukkueiden määrä", + "options_title": "Valinnat", + "bots": "Botit: ", + "bots_disabled": "Poissa käytöstä", + "disable_nations": "Poista kansakunnat käytöstä", + "instant_build": "Välitön rakentaminen", + "infinite_gold": "Loputon kulta", + "infinite_troops": "Loputtomat joukot", + "enables_title": "Aktivoi asetukset", + "player": "Pelaaja", + "players": "pelaajaa", + "waiting": "Odotetaan pelaajia...", + "start": "Aloita peli" + }, + "game_starting_modal": { + "title": "Peli alkaa...", + "desc": "Valmistellaan aulan aloittamista. Odota hetki." + }, + "difficulty": { + "difficulty": "Vaikeustaso", + "Relaxed": "Rento", + "Balanced": "Tasapainotettu", + "Intense": "Vaikea", + "Impossible": "Mahdoton" + }, + "game_mode": { + "ffa": "Kaikki vastaan kaikki", + "teams": "Joukkueet" + }, + "select_lang": { + "title": "Valitse kieli" + }, + "unit_type": { + "city": "Kaupunki", + "defense_post": "Puolustustorni", + "port": "Satama", + "warship": "Sota-alus", + "missile_silo": "Ohjussiilo", + "sam_launcher": "Ilmatorjuntaohjusjärjestelmä", + "atom_bomb": "Atomipommi", + "hydrogen_bomb": "Vetypommi", + "mirv": "MIRV" + }, + "user_setting": { + "title": "Käyttäjäasetukset", + "tab_basic": "Perusasetukset", + "tab_keybinds": "Näppäinasetukset", + "dark_mode_label": "🌙 Tumma tila", + "dark_mode_desc": "Vaihda sivuston ulkonäköä vaaleiden ja tummien teemojen välillä", + "emojis_label": "😊 Hymiöt", + "emojis_desc": "Valitse, ovatko hymiöt näkyvissä pelissä", + "special_effects_label": "💥 Erikoistehosteet", + "special_effects_desc": "Valitse, ovatko erikoistehosteet päällä. Poista ne käytöstä parantaaksesi suorituskykyä", + "anonymous_names_label": "🥷 Piilotetut nimet", + "anonymous_names_desc": "Korvaa pelaajien oikeat nimet satunnaisilla nimillä.", + "left_click_label": "🖱️Klikkaa hiiren vasenta näppäintä avataksesi valikon", + "left_click_desc": "Kun päällä, hiiren vasen nappi avaa valikon, mistä voit hyökätä painamalla miekkaa. Kun pois päältä, vasen nappi hyökkää suoraan.", + "attack_ratio_label": "⚔️ Hyökkäyksen osuus", + "attack_ratio_desc": "Mikä osuus joukoistasi lähetetään hyökkäykseen (1–100%)", + "troop_ratio_label": "🪖🛠️ Joukkojen ja työntekijöiden suhde", + "troop_ratio_desc": "Säädä joukkojen (sotia varten) ja työntekijöiden (tekevät kultaa) määrää (1 - 100%)", + "territory_patterns_label": "🏳️ Aluekuviot", + "territory_patterns_desc": "Valitse, näkyvätkö aluekuviot pelissä", + "easter_writing_speed_label": "Kirjoittamisen nopeuskerroin", + "easter_writing_speed_desc": "Säädä kuinka nopeasti teeskentelet koodaavasi (x1–x100)", + "easter_bug_count_label": "Vikojen määrä", + "easter_bug_count_desc": "Kuinka monta vikaa kestät (0 - 1000, tunteellisesti)", + "view_options": "Näkymävalinnat", + "toggle_view": "Vaihda näkymää", + "toggle_view_desc": "Vaihtele näkymää (maasto/valtiot)", + "attack_ratio_controls": "Hyökkäyksen osuus -säätimet", + "attack_ratio_up": "Lisää hyökkäyksen osuutta", + "attack_ratio_up_desc": "Lisää hyökkäyksen osuutta 10%", + "attack_ratio_down": "Vähennä hyökkäyksen osuutta", + "attack_ratio_down_desc": "Vähennä hyökkäyksen osuutta 10%", + "attack_keybinds": "Hyökkäyksen näppäinasetukset", + "boat_attack": "Venehyökkäys", + "boat_attack_desc": "Lähetä venehyökkäys hiiresi alla olevaan pikseliin.", + "zoom_controls": "Zoomausohjaimet", + "zoom_out": "Loitonna", + "zoom_out_desc": "Loitonna karttaa", + "zoom_in": "Lähennä", + "zoom_in_desc": "Lähennä karttaa", + "camera_movement": "Kameran liike", + "center_camera": "Keskitä kamera", + "center_camera_desc": "Keskitä kamera pelaajaan", + "move_up": "Siirrä kameraa ylös", + "move_up_desc": "Siirrä kameraa ylöspäin", + "move_left": "Siirrä kameraa vasempaan", + "move_left_desc": "Siirrä kameraa vasemmalle", + "move_down": "Siirrä kameraa alas", + "move_down_desc": "Siirrä kameraa alaspäin", + "move_right": "Siirrä kameraa oikeaan", + "move_right_desc": "Siirrä kameraa oikealle", + "reset": "Palauta", + "unbind": "Irrota" + }, + "chat": { + "title": "Pikaviestit", + "to": "Lähetit pelaajalle {user}: {msg}", + "from": "Viesti pelaajalta {user}: {msg}", + "category": "Kategoria", + "phrase": "Fraasi", + "player": "Pelaaja", + "send": "Lähetä", + "search": "Hae pelaajaa...", + "build": "Rakenna viestisi...", + "cat": { + "help": "Tuki", + "attack": "Hyökkäys", + "defend": "Puolustus", + "greet": "Tervehdys", + "misc": "Sekalaiset", + "warnings": "Varoitukset" + }, + "help": { + "troops": "Lahjoita minulle joukkoja!", + "gold": "Lahjoita minulle kultaa!", + "no_attack": "Älä hyökkää minuun!", + "sorry_attack": "Anteeksi, en tarkoittanut hyökätä.", + "alliance": "Liittoudutaanko?", + "help_defend": "Auta minua puolustautumaan pelaajaa [P1] vastaan!", + "team_up": "Lyöttäydytään pelaaja [P1] vastaan!" + }, + "attack": { + "attack": "Hyökkää pelaajaan [P1]!", + "mirv": "Laukaise MIRV pelaajaan [P1]!", + "focus": "Keskitä tuli pelaajaan [P1]!", + "finish": "Tapetaan [P1]!" + }, + "defend": { + "defend": "Puolusta pelaajaa [P1]!", + "dont_attack": "Älä hyökkää pelaajaan [P1]!", + "ally": "[P1] on minun liittolaiseni!" + }, + "greet": { + "hello": "Terve!", + "good_luck": "Lykkyä tykö!", + "have_fun": "Pidä hauskaa!", + "gg": "GG!", + "nice_to_meet": "Hauska tutustua!", + "well_played": "Hyvin pelattu!", + "hi_again": "Hei taas!", + "bye": "Näkemiin!", + "thanks": "Kiitos!", + "oops": "Hups, väärä nappi!", + "trust_me": "Voit luottaa minuun. Lupaan sen!", + "trust_broken": "Luotin sinuun..." + }, + "misc": { + "go": "Mennään!", + "strategy": "Hieno strategia!", + "fun": "Tämä peli on hauska!", + "pr": "Milloin vetopyyntöni vihdoin yhdistetään...?" + }, + "warnings": { + "strong": "[P1] on voimakas.", + "weak": "[P1] on heikko.", + "mirv_soon": "[P1] voi laukaista MIRVin pian!", + "number1_warning": "#1 pelaaja voittaa pian, ellemme liittoudu!", + "stalemate": "Tehdään rauha. Tämä on pattitilanne, me tulemme molemmat häviämään.", + "has_allies": "Pelaajalla [P1] on monia liittolaisia.", + "no_allies": "Pelaajalla [P1] ei ole iittolaisia.", + "betrayed": "[P1] petti liittolaisensa!", + "getting_big": "[P1] kasvaa liian nopeasti!", + "danger_base": "[P1] on suojaamatta!", + "saving_for_mirv": "[P1] säästää MIRVin laukaisemiseen kultaa.", + "mirv_ready": "Pelaajalla [P1] on tarpeeksi kultaa MIRVin laukaisemiseen!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Pieni räjähdys", + "hydrogen_bomb": "Suuri räjähdys", + "mirv": "Valtava räjähdys, kohdistuu vain valittuun pelaajaan", + "missile_silo": "Käytetään ohjusten laukaisemiseen", + "sam_launcher": "Puolustaa saapuvia ohjuksia vastaan", + "warship": "Kaappaa kauppa-aluksia, tuhoaa laivoja ja veneitä", + "port": "Lähettää kauppa-aluksia tuottaakseen kultaa", + "defense_post": "Lisää läheisten rajojen puolustusta", + "city": "Lisää enimmäisväkilukuasi" + }, + "not_enough_money": "Ei tarpeeksi rahaa" + }, + "win_modal": { + "died": "Sinä kuolit", + "your_team": "Joukkueesi voitti!", + "other_team": "{team} joukkue on voittanut!", + "you_won": "Sinä voitit!", + "other_won": "{player} on voittanut!", + "exit": "Poistu pelistä", + "keep": "Jatka pelaamista", + "wishlist": "Aseta toivelistalle Steamissa!" + }, + "leaderboard": { + "title": "Pistetaulukko", + "hide": "Sulje", + "rank": "Sijoitus", + "player": "Pelaaja", + "owned": "Maata", + "gold": "Kultaa", + "troops": "Joukkoja" + }, + "player_info_overlay": { + "type": "Tyyppi", + "bot": "Botti", + "nation": "Kansakunta", + "player": "Pelaaja", + "team": "Joukkue", + "d_troops": "Puolustavia joukkoja", + "a_troops": "Hyökkääviä joukkoja", + "gold": "Kultaa", + "ports": "Satamia", + "cities": "Kaupunkeja", + "missile_launchers": "Ohjussiiloja", + "sams": "Ilmatorjuntaohjusjärjestelmiä", + "warships": "Sota-aluksia", + "health": "Terveys", + "attitude": "Asenne" + }, + "relation": { + "hostile": "Vihamielinen", + "distrustful": "Epäluotettava", + "neutral": "Neutraali", + "friendly": "Ystävällinen", + "default": "Oletus" + }, + "control_panel": { + "pop": "Asukasluku", + "gold": "Kultaa", + "troops": "Joukkoja", + "workers": "Työntekijöitä", + "attack_ratio": "Hyökkäyksen osuus" + }, + "player_panel": { + "gold": "Kultaa", + "troops": "Joukkoja", + "betrayals": "Petosten määrä", + "traitor": "Petturi", + "alliance_time_remaining": "Liittouma päättyy", + "embargo": "Lopettanut kaupankäynnin kanssasi", + "nuke": "Lähetetyt ohjukset sinua kohti", + "start_trade": "Aloita kaupankäynti", + "stop_trade": "Lopeta kaupankäynti", + "yes": "Kyllä", + "no": "Ei", + "none": "Tyhjä", + "alliances": "Liittoumat" + }, + "error_modal": { + "crashed": "Peli kaatui!", + "paste_discord": "Ole hyvä ja liitä seuraava vikailmoitukseesi Discordissa:", + "copy_clipboard": "Kopioi leikepöydälle", + "copied": "Kopioitu!", + "failed_copy": "Kopiointi epäonnistui", + "desync_notice": "Olet muiden pelaajien kanssa eri tahdissa. Mitä näet saattaa poiketa muiden pelaajien näkymästä." + }, + "heads_up_message": { + "choose_spawn": "Valitse aloituspaikka" + } +} diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 4514eaf13..b489aae54 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -5,10 +5,14 @@ "svg": "fr", "lang_code": "fr" }, + "common": { + "close": "Fermer" + }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Rejoignez le Discord !", "login_discord": "Se connecter avec Discord", + "checking_login": "Vérification de la connexion...", "logged_in": "Connecté !", "log_out": "Se déconnecter", "create_lobby": "Créer un salon", @@ -16,8 +20,12 @@ "single_player": "Mode solo", "instructions": "Instructions", "how_to_play": "Comment jouer ?", + "advertise": "Annoncer", "wiki": "Wiki" }, + "news": { + "title": "Version 23 disponible !" + }, "help_modal": { "hotkeys": "Raccourcis clavier", "table_key": "Touche", @@ -135,14 +143,15 @@ "random": "Aléatoire", "iceland": "Islande", "pangaea": "Pangée", + "eastasia": "Asie de l'Est", "betweentwoseas": "Entre deux mers", - "knownworld": "Monde connu", "faroeislands": "Îles Féroé", "deglaciatedantarctica": "Antarctique Déglacée", "europeclassic": "Europe (classique)", "falklandislands": "Îles Malouines", "baikal": "Lac Baïkal", - "halkidiki": "Chalcidique" + "halkidiki": "Chalcidique", + "giantworldmap": "Carte du monde géante" }, "map_categories": { "continental": "Continental", @@ -236,6 +245,8 @@ "attack_ratio_desc": "Quel pourcentage de vos troupes envoyer dans une attaque (1–100%)", "troop_ratio_label": "🪖🛠️ Ratio des troupes et des ouvriers", "troop_ratio_desc": "Ajuster l'équilibre entre les troupes (pour le combat) et les ouvriers (pour la production d'or) (1–100%)", + "territory_patterns_label": "🏳️ Modèles de territoire", + "territory_patterns_desc": "Choisissez si vous voulez afficher les modèles de territoire en jeu", "easter_writing_speed_label": "Multiplicateur de vitesse d'écriture", "easter_writing_speed_desc": "Ajuster la vitesse à laquelle vous prétendez coder (x1–x100)", "easter_bug_count_label": "Nombre de bugs", @@ -243,6 +254,14 @@ "view_options": "Options d'affichage", "toggle_view": "Activer/désactiver l'affichage", "toggle_view_desc": "Vue alternative (terrain/pays)", + "attack_ratio_controls": "Contrôles du ratio d'attaque", + "attack_ratio_up": "Augmenter le ratio d'attaque", + "attack_ratio_up_desc": "Augmenter le ratio d'attaque de 10%", + "attack_ratio_down": "Diminuer le ratio d'attaque", + "attack_ratio_down_desc": "Diminuer le ratio d'attaque de 10%", + "attack_keybinds": "Raccourcis d'attaque", + "boat_attack": "Attaque navale", + "boat_attack_desc": "Envoyer une attaque navale à la tuile sous votre curseur.", "zoom_controls": "Contrôles de zoom", "zoom_out": "Zoom arrière", "zoom_out_desc": "Dézoom de la carte", @@ -356,7 +375,8 @@ "you_won": "Vous avez gagné !", "other_won": "{player} a gagné !", "exit": "Quitter la partie", - "keep": "Continuer à jouer" + "keep": "Continuer à jouer", + "wishlist": "Liste de souhaits sur Steam !" }, "leaderboard": { "title": "Classement", @@ -401,13 +421,27 @@ "player_panel": { "gold": "Or", "troops": "Troupes", + "betrayals": "Nombre de trahisons", "traitor": "Traître", - "alliance_time_remaining": "Temps restant", + "alliance_time_remaining": "L'alliance expire dans", "embargo": "A cessé le commerce avec vous", "nuke": "Bombes nucléaires qu'ils vous envoient", "start_trade": "Commencer le commerce", "stop_trade": "Arrêter le commerce", "yes": "Oui", - "no": "Non" + "no": "Non", + "none": "Aucun", + "alliances": "Alliances" + }, + "error_modal": { + "crashed": "Le jeu a planté !", + "paste_discord": "Veuillez coller ce qui suit dans votre rapport de bogue dans Discord :", + "copy_clipboard": "Copier dans le presse-papier", + "copied": "Copié !", + "failed_copy": "Échec de la copie", + "desync_notice": "Vous êtes désynchronisé des autres joueurs. Ce que vous voyez peut être différent des autres joueurs." + }, + "heads_up_message": { + "choose_spawn": "Choisissez un emplacement de départ" } } diff --git a/resources/lang/he.json b/resources/lang/he.json index 87f1c4845..16567bf6c 100644 --- a/resources/lang/he.json +++ b/resources/lang/he.json @@ -5,10 +5,14 @@ "svg": "il", "lang_code": "he" }, + "common": { + "close": "סגור" + }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "הצטרף לשרת הדיסקורד!", "login_discord": "התחבר עם דיסקורד", + "checking_login": "בודק התחברות...", "logged_in": "מחובר!", "log_out": "התנתק", "create_lobby": "צור לובי", @@ -16,8 +20,12 @@ "single_player": "שחקן יחיד", "instructions": "הוראות", "how_to_play": "כיצד לשחק", + "advertise": "פרסם", "wiki": "ויקי" }, + "news": { + "title": "גרסא 23 שוחררה!" + }, "help_modal": { "hotkeys": "מקשי קיצור", "table_key": "מקש", @@ -135,14 +143,15 @@ "random": "רנדומלי", "iceland": "איסלנד", "pangaea": "פנגיאה", + "eastasia": "מזרח אסיה", "betweentwoseas": "בין שני ימים", - "knownworld": "העולם הידוע", "faroeislands": "איי פארו", "deglaciatedantarctica": "אנטארקטיקה נטולת קרח", "europeclassic": "אירופה (קלאסי)", "falklandislands": "איי פוקלנד", "baikal": "באיקל", - "halkidiki": "כלקידיקי" + "halkidiki": "כלקידיקי", + "giantworldmap": "מפת עולם ענקית" }, "map_categories": { "continental": "יבשתי", @@ -236,6 +245,8 @@ "attack_ratio_desc": "כמה אחוז מהחיילים שלך לשלוח בהתקפה (1-100%)", "troop_ratio_label": "יחס חיילים ועובדים 🪖🛠️", "troop_ratio_desc": "בחר את היחס בין חיילים (לקרב) והעובדים (כדי לייצר זהב) (1-100%)", + "territory_patterns_label": "🏳️דפוס שטח", + "territory_patterns_desc": "בחר האם להראות דפוסי שטח במשחק", "easter_writing_speed_label": "מכפיל כמות הקלדה", "easter_writing_speed_desc": "תאם כמה מהר אתה מעמיד פנים שאתה כותב קוד (x1-x100)", "easter_bug_count_label": "מספר באגים", @@ -243,6 +254,14 @@ "view_options": "אפשרויות תצוגה", "toggle_view": "החלף תצוגה", "toggle_view_desc": "צפיה חלופית (שטח\\מדינות)", + "attack_ratio_controls": "שליטת יחס התקפה", + "attack_ratio_up": "העלה יחס התקפה", + "attack_ratio_up_desc": "העלה יחס התקפה ב-10%", + "attack_ratio_down": "הורד יחס התקפה", + "attack_ratio_down_desc": "הורד יחס התקפה ב-10%", + "attack_keybinds": "קיצורי מקשים להתקפה", + "boat_attack": "התקפת סירה", + "boat_attack_desc": "שלח ספינת התקפה היכן שאתה לוחץ עם העכבר.", "zoom_controls": "שליטת זום", "zoom_out": "זום החוצה", "zoom_out_desc": "התרחק מהמפה", @@ -356,7 +375,8 @@ "you_won": "זכית!", "other_won": "{player} זכה!", "exit": "צא מהמשחק", - "keep": "המשך לשחק" + "keep": "המשך לשחק", + "wishlist": "הוסף ל-wishlist ב-Steam!" }, "leaderboard": { "title": "שחקנים מובילים", @@ -401,13 +421,27 @@ "player_panel": { "gold": "זהב", "troops": "חיילים", + "betrayals": "מספר בגידות", "traitor": "בוגד", - "alliance_time_remaining": "זמן שנותר", + "alliance_time_remaining": "ברית מתבטל בעוד", "embargo": "הפסיק לסחור איתך", "nuke": "כמות פצצות אטום ששלח אליך", "start_trade": "התחל סחר", "stop_trade": "הפסק סחר", "yes": "כן", - "no": "לא" + "no": "לא", + "none": "ללא", + "alliances": "בריתות" + }, + "error_modal": { + "crashed": "המשחק קרס!", + "paste_discord": "בבקשה הדבק את החלק הזה בדיווח הבאג בדיסקורד:", + "copy_clipboard": "העתק ל-clipboard", + "copied": "הועתק!", + "failed_copy": "ההעתקה נכשלה", + "desync_notice": "אתה לא מסונכרן עם שאר השחקנים. מה שאתה רואה עלול להיות שונה משאר השחקנים." + }, + "heads_up_message": { + "choose_spawn": "בחר מיקום התחלתי" } } diff --git a/resources/lang/it.json b/resources/lang/it.json index 65d2e181b..555aec201 100644 --- a/resources/lang/it.json +++ b/resources/lang/it.json @@ -230,7 +230,7 @@ "special_effects_desc": "Attiva/disattiva gli effetti speciali. Disattiva per migliorare le prestazioni", "anonymous_names_label": "🥷 Nomi Nascosti", "anonymous_names_desc": "Nascondi i nomi dei giocatori reali sullo schermo con nomi casuali.", - "left_click_label": "🖱️ Clic sinistro per aprire il menu", + "left_click_label": "Clic sinistro per aprire il menu", "left_click_desc": "Quando attivo, il clic sinistro apre il menu e il pulsante di attacco. Quando non attivo, fare clic con il tasto sinistro attaccherà direttamente.", "attack_ratio_label": "⚔️ Rapporto Di Attacco", "attack_ratio_desc": "Quale percentuale delle tue truppe inviare in attacco (1–100%)", diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 1dbd03125..f21baae6f 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -5,10 +5,14 @@ "svg": "jp", "lang_code": "ja" }, + "common": { + "close": "閉じる" + }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Discordサーバーに参加!", "login_discord": "Discordでログイン", + "checking_login": "ログイン中...", "logged_in": "ログイン中!", "log_out": "ログアウト", "create_lobby": "ロビーを作成", @@ -16,8 +20,12 @@ "single_player": "シングルプレイヤー", "instructions": "説明書", "how_to_play": "遊び方", + "advertise": "広告", "wiki": "ウィキ" }, + "news": { + "title": "バージョン23がリリースされました!" + }, "help_modal": { "hotkeys": "ホットキー", "table_key": "キー", @@ -135,14 +143,15 @@ "random": "ランダム", "iceland": "アイスランド", "pangaea": "パンゲア", + "eastasia": "東アジア", "betweentwoseas": "2つの海の間", - "knownworld": "知られてる世界", "faroeislands": "フェロー諸島", "deglaciatedantarctica": "退氷した南極大陸", "europeclassic": "ヨーロッパ (クラシック)", "falklandislands": "フォークランド諸島", "baikal": "バイカル湖付近", - "halkidiki": "ハルキディキ半島" + "halkidiki": "ハルキディキ半島", + "giantworldmap": "巨大化した世界" }, "map_categories": { "continental": "大陸", @@ -222,27 +231,37 @@ "title": "ユーザー設定", "tab_basic": "基本設定", "tab_keybinds": "キーの割り当て", - "dark_mode_label": "ダークモード", + "dark_mode_label": "🌙 ダークモード", "dark_mode_desc": "画面を黒基調に切り替えます", - "emojis_label": "絵文字を表示", + "emojis_label": "😊 絵文字を表示", "emojis_desc": "ゲーム中で絵文字を表示します", - "special_effects_label": "スペシャルエフェクト", + "special_effects_label": "💥 スペシャルエフェクト", "special_effects_desc": "特殊効果を切り替えます。無効にするとパフォーマンスが向上します。", - "anonymous_names_label": "ユーザー名を匿名にする", + "anonymous_names_label": "🥷 ユーザー名を匿名にする", "anonymous_names_desc": "自分の画面では他のプレイヤーのユーザー名を非表示にし、代わりに別の名前で表示します。", - "left_click_label": "左クリックでメニューを開く", + "left_click_label": "🖱️ 左クリックでメニューを開く", "left_click_desc": "オンにすると左クリックでメニューを開くことができ、剣ボタンで攻撃します。オフにすると左クリックでそのまま攻撃します。", - "attack_ratio_label": "出撃兵力の比率", + "attack_ratio_label": "⚔️ 出撃兵力の比率", "attack_ratio_desc": "初期時点で出撃する兵力の割合を設定します(1–100%)", - "troop_ratio_label": "兵士と労働者の比率", + "troop_ratio_label": "🪖🛠️ 兵士と労働者の比率", "troop_ratio_desc": "初期時点で兵士と金を生産する労働者の割合を設定します(1–100%)", + "territory_patterns_label": "🏳️ 領土の模様", + "territory_patterns_desc": "ゲーム内で領土の模様を表示するかどうか", "easter_writing_speed_label": "書き込み速度の倍率", "easter_writing_speed_desc": "コードを書く速さを調節する(x1-x100)", "easter_bug_count_label": "バグの個数", - "easter_bug_count_desc": "いくつでもバグがあっても構わないけど、(具体的に言えば0–1000)", + "easter_bug_count_desc": "どのぐらいの個数のバグを許容できるか(0–1000個)", "view_options": "表示オプション", "toggle_view": "表示切り替え", "toggle_view_desc": "国境を非表示にし、地形だけが見れます", + "attack_ratio_controls": "攻撃比率の調整", + "attack_ratio_up": "出撃兵力の割合を上げる", + "attack_ratio_up_desc": "出撃兵力を10%増加させる", + "attack_ratio_down": "出撃兵力の割合を下げる", + "attack_ratio_down_desc": "出撃兵力を10%減少させる", + "attack_keybinds": "攻撃機能", + "boat_attack": "ボート攻撃", + "boat_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", "zoom_controls": "ズーム操作", "zoom_out": "ズームアウト", "zoom_out_desc": "マップを縮小します", @@ -356,7 +375,8 @@ "you_won": "勝利!", "other_won": "{player}の勝利!", "exit": "ゲームから退出", - "keep": "観戦する" + "keep": "観戦する", + "wishlist": "Steamでウィッシュリストに追加して下さい!" }, "leaderboard": { "title": "ランキング", @@ -401,13 +421,27 @@ "player_panel": { "gold": "資金", "troops": "兵士", + "betrayals": "裏切った回数", "traitor": "裏切り者かどうか", - "alliance_time_remaining": "残り時間", + "alliance_time_remaining": "同盟の有効期限までの残り時間", "embargo": "あなたが貿易制限の対象であるか", "nuke": "相手からあなたへの核攻撃数", "start_trade": "貿易を開始", "stop_trade": "貿易を停止", "yes": "はい", - "no": "いいえ" + "no": "いいえ", + "none": "該当なし", + "alliances": "同盟" + }, + "error_modal": { + "crashed": "ゲームがクラッシュしました!", + "paste_discord": "以下の内容をDiscordのバグ報告に貼り付けてください:", + "copy_clipboard": "クリップボードにコピーする", + "copied": "コピーしました!", + "failed_copy": "コピーに失敗しました", + "desync_notice": "他のプレイヤと同期ズレが発生しています。他のプレイヤーとは違う画面になっている可能性があります。" + }, + "heads_up_message": { + "choose_spawn": "スタート地点を選んで下さい" } } diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 270537185..248d8b5cf 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -230,7 +230,7 @@ "special_effects_desc": "Speciale effecten aanzetten. Zet uit om de prestaties van het spel te verbeteren", "anonymous_names_label": "🥷 Verborgen namen", "anonymous_names_desc": "Vervang echte spelersnamen door willekeurige namen op je scherm.", - "left_click_label": "🖱️ Linkermuisknop voor openen menu", + "left_click_label": "Linkermuisknop voor openen menu", "left_click_desc": "Als AAN: linkermuisknop opent het Radiale menu met zwaard-aanvalsknop. Als UIT: linkermuisknop opent direct de aanval.", "attack_ratio_label": "⚔️ Aanvalsverhouding", "attack_ratio_desc": "Welk percentage van je troepen je bij een aanval stuurt (1-100%)", diff --git a/resources/lang/pl.json b/resources/lang/pl.json index f5bf6b993..92d9b2cfb 100644 --- a/resources/lang/pl.json +++ b/resources/lang/pl.json @@ -214,7 +214,7 @@ "emojis_desc": "Przełącz, czy emotki mają być wyświetlane w grze", "anonymous_names_label": "🥷 Ukryte Nazwy", "anonymous_names_desc": "Ukryj prawdziwe nazwy graczy, zastępując je losowymi na swoim ekranie.", - "left_click_label": "🖱️ Kliknij lewym przyciskiem myszy aby otworzyć Menu", + "left_click_label": "Kliknij lewym przyciskiem myszy aby otworzyć Menu", "left_click_desc": "Gdy WŁĄCZONE, lewy przycisk myszy otwiera menu, a przycisk z mieczem atakuje. Gdy WYŁĄCZONE, lewy przycisk myszy atakuje bezpośrednio.", "attack_ratio_label": "⚔️ Współczynnik ataku", "attack_ratio_desc": "Jaki procent swoich żołnierzy chcesz wysłać do ataku (1–100%)", diff --git a/resources/lang/ru.json b/resources/lang/ru.json index 3bb5995b9..7097489a9 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -5,10 +5,14 @@ "svg": "ru", "lang_code": "ru" }, + "common": { + "close": "Закрыть" + }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Присоединяйтесь к Discord!", "login_discord": "Войти через Discord", + "checking_login": "Проверка авторизации...", "logged_in": "Вход выполнен!", "log_out": "Выйти", "create_lobby": "Создать лобби", @@ -16,8 +20,12 @@ "single_player": "Одиночная игра", "instructions": "Инструкции", "how_to_play": "Как играть", + "advertise": "Рекламирование", "wiki": "Вики" }, + "news": { + "title": "Версия 23 выпущена!" + }, "help_modal": { "hotkeys": "Горячие клавиши", "table_key": "Клавиша", @@ -135,14 +143,15 @@ "random": "Случайно", "iceland": "Исландия", "pangaea": "Пангея", + "eastasia": "Восточная Азия", "betweentwoseas": "Между двух морей", - "knownworld": "Известный мир", "faroeislands": "Фарерские острова", "deglaciatedantarctica": "Растаявшая Антарктида", "europeclassic": "Европа (классическая)", "falklandislands": "Фолклендские острова", "baikal": "Байкал", - "halkidiki": "Халкидики" + "halkidiki": "Халкидики", + "giantworldmap": "Гигантская карта мира" }, "map_categories": { "continental": "Континентальные", @@ -221,7 +230,7 @@ "user_setting": { "title": "Пользовательские настройки", "tab_basic": "Основные настройки", - "tab_keybinds": "Назначение клавиш", + "tab_keybinds": "Привязки клавиш", "dark_mode_label": "🌙 Тёмный режим", "dark_mode_desc": "Переключение внешнего вида сайта между светлой и тёмной темой", "emojis_label": "😊 Смайлы", @@ -236,6 +245,8 @@ "attack_ratio_desc": "Какой процент ваших войск отправлять в бой (1–100%)", "troop_ratio_label": "🪖🛠️ Соотношение войск и рабочих", "troop_ratio_desc": "Настройте соотношение между войсками (для боя) и рабочими (для добычи золота) (1–100%)", + "territory_patterns_label": "🏳️ Узоры Территорий", + "territory_patterns_desc": "Выберите, показывать ли узоры территорий в игре", "easter_writing_speed_label": "Множитель скорости печати", "easter_writing_speed_desc": "Настройте скорость, с которой вы делаете вид, что программируете (x1–x100)", "easter_bug_count_label": "Количество багов", @@ -243,6 +254,14 @@ "view_options": "Настройки просмотра", "toggle_view": "Переключить представление", "toggle_view_desc": "Альтернативное представление (ландшафт/страны)", + "attack_ratio_controls": "Управление соотношением атаки", + "attack_ratio_up": "Увеличить соотношение атаки", + "attack_ratio_up_desc": "Увеличить соотношение атаки на 10%", + "attack_ratio_down": "Уменьшить соотношение атаки", + "attack_ratio_down_desc": "Уменьшить соотношение атаки на 10%", + "attack_keybinds": "Привязки клавиш атаки", + "boat_attack": "Атака лодкой", + "boat_attack_desc": "Отправить атаку лодкой на клетку под курсором.", "zoom_controls": "Масштабирование", "zoom_out": "Отдалить", "zoom_out_desc": "Отдалить карту", @@ -356,7 +375,8 @@ "you_won": "Вы победили!", "other_won": "Игрок {player} победил!", "exit": "Выйти из игры", - "keep": "Продолжить игру" + "keep": "Продолжить игру", + "wishlist": "Добавляйте в список желаемого в Steam!" }, "leaderboard": { "title": "Таблица лидеров", @@ -401,13 +421,27 @@ "player_panel": { "gold": "Золото", "troops": "Войска", + "betrayals": "Количество предательств", "traitor": "Предатель", - "alliance_time_remaining": "Оставшееся время", + "alliance_time_remaining": "Альянс заканчивается через", "embargo": "Прекратил торговлю с вами", "nuke": "Ракеты, запущенные ими на вас", "start_trade": "Начать торговлю", "stop_trade": "Прекратить торговлю", "yes": "Да", - "no": "Нет" + "no": "Нет", + "none": "Ничего", + "alliances": "Альянсы" + }, + "error_modal": { + "crashed": "Игра крашнулась!", + "paste_discord": "Пожалуйста, вставьте следующее в отчет об ошибке в Discord:", + "copy_clipboard": "Копировать в буфер обмена", + "copied": "Скопировано!", + "failed_copy": "Не удалось скопировать", + "desync_notice": "Вы десинхронизированы с другими игроками. То, что вы видите, может отличаться от того, что видят другие игроки." + }, + "heads_up_message": { + "choose_spawn": "Выберите стартовое местоположение" } } diff --git a/resources/lang/sv_se.json b/resources/lang/sv_se.json new file mode 100644 index 000000000..15978f83f --- /dev/null +++ b/resources/lang/sv_se.json @@ -0,0 +1,447 @@ +{ + "lang": { + "en": "Swedish", + "native": "Svenska", + "svg": "se", + "lang_code": "sv" + }, + "common": { + "close": "Stäng" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "Gå med i Discord!", + "login_discord": "Logga in med Discord", + "checking_login": "Kontrollerar inloggning...", + "logged_in": "Inloggad!", + "log_out": "Logga ut", + "create_lobby": "Skapa lobby", + "join_lobby": "Gå med i lobby", + "single_player": "Enspelarläge", + "instructions": "Instruktioner", + "how_to_play": "Hur man Spelar", + "advertise": "Annonsera", + "wiki": "Wiki" + }, + "news": { + "title": "Version 23 släppt!" + }, + "help_modal": { + "hotkeys": "Kortkommandon", + "table_key": "Tangent", + "table_action": "Åtgärd", + "action_alt_view": "Alternativ vy (Terräng/Länder)", + "action_attack_altclick": "Anfall (när vänsterklick för meny är aktiverat)", + "action_build": "Öppna byggmenyn", + "action_emote": "Öppna emote-menyn", + "action_center": "Centrera kameran på spelaren", + "action_zoom": "Zooma in/ut", + "action_move_camera": "Flytta kameran", + "action_ratio_change": "Ändra anfallsförhållande", + "action_reset_gfx": "Återställ grafik", + "ui_section": "Spelgränssnitt", + "ui_leaderboard": "Topplista", + "ui_leaderboard_desc": "Visar toppspelarna i spelet och deras namn, % ägd mark, guld och trupper. Använd Visa Alla för att visa alla spelare i spelet. Om du inte vill se topplistan, klicka Dölj.", + "ui_control": "Kontrollpanel", + "ui_control_desc": "Kontrollpanelen innehåller följande element:", + "ui_pop": "Pop - Mängden befolkning du har, din max befolkning och takten som du får fler.", + "ui_gold": "Guld - Mängden guld som du har och takten du får mer.", + "ui_troops_workers": "Trupper och arbetare - Mängden allokerade trupper och arbetare. Trupperna används för att anfalla och skydda mot anfall. Arbetare används för att generera guld. Du kan anpassa mängden trupper och arbetare med reglaget.", + "ui_attack_ratio": "Anfallsskala - Mängden trupper som används när du anfaller. Du kan anpassa anfallsskalan med reglaget. När du har fler anfallande trupper än skyddande trupper kommer du att förlora färre trupper vid anfallet, genom att ha färre kommer det att öka skadan gjord till dina anfallande trupper. Effekten går ej över skalan 2:1.", + "ui_events": "Evenemangspanel", + "ui_events_desc": "Evenemangspanelen visar de senaste händelserna, förfrågningarna och snabbchattsmeddelandena. Några exempel är:", + "ui_events_alliance": "Allianser - Alliansförfrågningar kan accepteras eller nekas. Allierade kan dela resurser och trupper, men kan inte anfalla varandra. Klicka på Fokusera för att flytta vyn till spelaren som skickade förfrågan.", + "ui_events_attack": "Anfall - Inkommande anfall och dina utgående anfall visas. Klicka meddelandet för att centrera vyn på anfallen, kärnvapnet eller båten (transportskepp). Du kan få trupper att gå på reträtt genom att klicka det röda krysset. Detta kommer att kosta livet av 25% av dina anfallande trupper. Om retirerar en båt kommer båten retirera till sin startpunkt och anfalla där om ytan blivit erövrad sedan avfart. Kärnvapen kan inte bli återkallade efter avfyrning.", + "ui_events_quickchat": "Snabbchat - Du kan se skickade och mottagna chatmeddelanden här. Skicka ett meddelande till en spelare genom att klicka på Snabbchat ikonen i deras infomeny.", + "ui_options": "Inställningar", + "ui_options_desc": "Följande alternativ finns:", + "ui_playeroverlay": "Överlägg för spelar info", + "ui_playeroverlay_desc": "När du håller över ett land så kommer Spelarinformationen visas under Alternativ. Det visar typen av spelare: Människa, Nation (smart bot), eller Bot. En Nations attityd mot dig, från ondsint till vänlig. Och skyddande trupper, guld, plus mängden Krigsskepp och olika byggnader spelaren har.", + "option_pause": "Pausa/Avpausa spelet - Enbart tillgänglig i läget Enspelarläge.", + "option_timer": "Timer - Tiden som gått sedan spelets start.", + "option_exit": "Avsluta knappen.", + "option_settings": "Inställningar - Öppna menyn för inställningar. Inuti kan du ändra Alternativa vyn, Emojis, Mörkt läge, Ninja (Läge för Anonyma/Slumpmässiga namn), och funktionen för vänster musknapp.", + "radial_title": "Radiell meny", + "radial_desc": "Högerklick (Eller touch på mobil) öppnar den radiella menyn. Högerklicka utanför för att stänga den. Från menyn kan du:", + "radial_build": "Öppna byggmenyn.", + "radial_info": "Öppna infomenyn.", + "radial_boat": "Skicka en båt (Transportskepp) att anfalla på den valda platsen. Enbart valbar om du har tillgång till vatten.", + "radial_close": "Stäng menyn.", + "info_title": "Infomeny", + "info_enemy_desc": "Innehåller information så som den valda spelarens namn, guld, trupper, om de slutat handla med dig, skickat kärnvapen på dig, eller om spelaren är en förrädare. Att sluta handla innebär att du inte kommer få guld från dom, och de kommer inte att skicka dig guld via handelsfartyg. Manuellt (Om spelaren klickat \"Avsluta handel\", som varar tills ni båda klickar Påbörja handel\") eller automatiskt (Om du förråder din allians, vilket varar tills ni blir allierade igen eller efter 5 minuter). Förrädare visar Ja i 30 sekunder när spelaren förråder och anfaller en spelare som var med i en allians med dem. Ikonerna representerar följande interaktioner:", + "info_chat": "Skicka en Snabbchat till en spelare. Välj Kategori, en Fras och om frasen innehåller [P1] välj Spelarnamnet att ersätta det med. Tryck Skicka.", + "info_target": "Placera en anfallsmarkör på spelaren, som markerar den för alla allierade, används för att koordinera anfall.", + "info_alliance": "Skicka en alliansförfrågan till spelaren. Allierade kan dela resurser och trupper, men kan inte anfalla varandra.", + "info_emoji": "Skicka en emoji till spelaren.", + "info_trade": "Använd \"Avsluta handel\" för att sluta ge spelaren guld och ta mot guld via deras handelsfartyg. Om ni båda klickar \"Påbörja handel\" kommer handel återupptas.", + "info_ally_panel": "Allians infopanel", + "info_ally_desc": "När du allierar en spelare, så kommer följande ikoner bli tillgängliga:", + "ally_betray": "Förråd er allierade, avbryt alliansen, avbryt handel, och försvagar ditt försvar. Handel mellan parterna pausas i 5 minuter (eller tills ni åter blir allierade) andra kan sluta handel också. Och om inte den andra spelaren var en förrädare själv, skulle du bli markerad som förrädare i 30 sekunder. Under den här tiden kommer en ikon att visas över ditt namn och du kommer ha 50% sämre försvar. Bottar kommer med mindre sannolikhet att alliera sig med dig och andra spelare kommer tänka dubbelt innan de accepterar en allians.", + "ally_donate": "Donera några av dina trupper till din allierade. Används när de har låg mängd trupper och är under anfall, eller när de behöver extra kraft vid ett anfall för att krossa en fiende.", + "ally_donate_gold": "Donera en mängd guld till din allierade. Används när de har låg mängd guld och behöver det för byggnader, eller när ditt lag sparar för en MIRV.", + "build_menu_title": "Byggmeny", + "build_menu_desc": "Bygg dessa eller se hur många som redan byggts:", + "build_name": "Namn", + "build_icon": "Ikon", + "build_desc": "Beskrivning", + "build_city": "Stad", + "build_city_desc": "Ökar din max befolkningsmängd. Användbar när du inte kan expandera ditt territorium eller om du kommer nå taket för din befolkningsmängd.", + "build_defense": "Försvarspost", + "build_defense_desc": "Ökar försvaret runt närliggande gränser, vilket visas som ett rutigt mönster. Anfall från fiender är långsammare och tar mer skada.", + "build_port": "Hamn", + "build_port_desc": "Kan enbart byggas nära vatten. Tillåter byggande av Krigsskepp. Skickar automatiskt handelsbåtar mellan din hamn och andra länder. (Förutom när handel är stoppad), ger guld till båda sidorna. Handel upphör automatiskt när du anfaller eller är anfallen av en spelare. Handel återupptas efter 5 minuter eller att ni blir allierade. Du kan manuellt tillåta/blockera handel med \"Avsluta handel\" eller \"Påbörja handel\".", + "build_warship": "Krigsfartyg", + "build_warship_desc": "Patrullerar i ett område, fångar in fientliga handelsfartyg och förstör deras båtar (transportskepp) och Krigsskepp. Tillverkas vid den närmaste hamnen och patrullerar området där du först klickade för att bygga den. Du kan kontrollera Krigsskepp genom att anfallsklicka på dom. (Se alternativ Anfall under Snabbtangenter) och sen anfallsklicka på det nya området du vill att de ska flytta sig till.", + "build_silo": "Missilsilo", + "build_silo_desc": "Möjliggör missilavfyrning.", + "build_sam": "Luftvärn", + "build_sam_desc": "Kan förinta fientliga missiler inom 100 pixlars räckvidd. Med 100% träffsäkerhet för Atombomber, 80% för Vätebomber och 50% för individuella MIRV stridsspetsar. Luftvärn laddar om på 7.5 sekunder.", + "build_atom": "Liten atombomb", + "build_atom_desc": "Liten explosiv bomb som förstör territorium, byggnader, skepp och båtar. Avfyras från närmaste Missilsilo och landar på området du först tryckte för att avfyra mot.", + "build_hydrogen": "Vätebomb", + "build_hydrogen_desc": "Stor explosiv bomb. Avfyras från närmaste Missilsilo och landar på området du först tryckte för att avfyra mot.", + "build_mirv": "MIRV", + "build_mirv_desc": "Den kraftfullaste bomben i spelet. Delar upp sig i mindre bomber som täcker ett enormt spann av territorium. Skadar enbart spelaren som du först tryckte för att avfyra mot.", + "player_icons": "Spelarikoner", + "icon_desc": "Exempel på några ikoner i spelet som du kommer stöta på och vad de betyder:", + "icon_crown": "Krona - Nummer 1. Det här är toppspelaren på topplistan.", + "icon_traitor": "Brusten sköld - Förrädare. Den här spelaren anföll en allierad.", + "icon_ally": "Handskakning - Allierad. Den här spelaren är din allierade.", + "icon_embargo": "Stoppskylt med dollartecken - Embargo. Den här spelaren har avbrutit handel med dig automatiskt eller manuellt.", + "icon_request": "Kuvert - Alliansförfrågan. Denna spelare har skickat dig en alliansförfrågan.", + "info_enemy_panel": "Fiendeinfopanel" + }, + "single_modal": { + "title": "Enspelarläge", + "allow_alliances": "Tillåt allianser", + "options_title": "Alternativ", + "bots": "Botar: ", + "bots_disabled": "Avaktiverat", + "disable_nations": "Inaktivera nationer", + "instant_build": "Omedelbar byggnation", + "infinite_gold": "Obegränsat guld", + "infinite_troops": "Obegränsade trupper", + "disable_nukes": "Inaktivera Kärnvapen", + "enables_title": "Aktivera Inställningar", + "start": "Starta spelet" + }, + "map": { + "map": "Karta", + "world": "Världen", + "europe": "Europa", + "mena": "MENA", + "northamerica": "Nordamerika", + "oceania": "Oceanien", + "blacksea": "Svarta havet", + "africa": "Afrika", + "asia": "Asien", + "mars": "Mars", + "southamerica": "Sydamerika", + "britannia": "Britannien", + "gatewaytotheatlantic": "Atlantporten", + "australia": "Australien", + "random": "Slumpmässig", + "iceland": "Island", + "pangaea": "Pangaea", + "eastasia": "Östra asien", + "betweentwoseas": "Mellan två hav", + "faroeislands": "Färöarna", + "deglaciatedantarctica": "Antarktis avglacifierat", + "europeclassic": "Europa (klassisk)", + "falklandislands": "Falklandsöarna", + "baikal": "Baikal", + "halkidiki": "Halkidiki", + "giantworldmap": "Massiv världskarta" + }, + "map_categories": { + "continental": "Kontinental", + "regional": "Regional", + "fantasy": "Övrigt" + }, + "private_lobby": { + "title": "Gå med i Privat Lobby", + "enter_id": "Ange Lobby-ID", + "player": "Spelare", + "players": "Spelare", + "join_lobby": "Gå med i Lobby", + "checking": "Kontrollerar Lobby...", + "not_found": "Lobby hittades inte. Kontrollera ID:t och försök igen.", + "error": "Ett fel inträffade. Vänligen försök igen.", + "joined_waiting": "Du kom in! Väntar på att spelet ska starta..." + }, + "public_lobby": { + "join": "Gå med i nästa spel", + "waiting": "Spelare som väntar", + "teams": "{num} lag" + }, + "username": { + "enter_username": "Ange ditt användarnamn", + "not_string": "Användarnamnet måste vara en sträng.", + "too_short": "Användarnamnet måste innehålla minst {min} tecken.", + "too_long": "Användarnamnet får inte vara längre än {max} tecken.", + "invalid_chars": "Användarnamnet får enbart innehålla bokstäver, siffror, mellanslag, understreck och [hakparenteser]." + }, + "host_modal": { + "title": "Privat lobby", + "mode": "Läge", + "team_count": "Antal Lag", + "options_title": "Alternativ", + "bots": "Botar: ", + "bots_disabled": "Avaktiverat", + "disable_nations": "Inaktivera Nationer", + "instant_build": "Omedelbar byggnation", + "infinite_gold": "Obegränsat guld", + "infinite_troops": "Obegränsade trupper", + "enables_title": "Aktivera inställningar", + "player": "Spelare", + "players": "Spelare", + "waiting": "Väntar på spelare...", + "start": "Starta spelet" + }, + "game_starting_modal": { + "title": "Spelet startar...", + "desc": "Förbereder lobbyn för att starta. Vänligen vänta." + }, + "difficulty": { + "difficulty": "Svårighetsgrad", + "Relaxed": "Avslappnat", + "Balanced": "Balanserat", + "Intense": "Intensivt", + "Impossible": "Omöjligt" + }, + "game_mode": { + "ffa": "Alla mot alla", + "teams": "Lag" + }, + "select_lang": { + "title": "Välj Språk" + }, + "unit_type": { + "city": "Stad", + "defense_post": "Försvarspost", + "port": "Hamn", + "warship": "Krigsfartyg", + "missile_silo": "Missilbas", + "sam_launcher": "Luftvärn", + "atom_bomb": "Liten atombomb", + "hydrogen_bomb": "Vätebomb", + "mirv": "MIRV" + }, + "user_setting": { + "title": "Användarinställningar", + "tab_basic": "Grundläggande inställningar", + "tab_keybinds": "Tangentbindningar", + "dark_mode_label": "🌙 Mörkt Läge", + "dark_mode_desc": "Ändrar hemsidans utseende mellan mörkt och ljust tema.", + "emojis_label": "😊 Emojis", + "emojis_desc": "Välj om emojis ska visas i spelet", + "special_effects_label": "💥 Specialeffekter", + "special_effects_desc": "Välj om specialeffekter visas. Avaktivera för att öka prestandan", + "anonymous_names_label": "🥷 Dölj namn", + "anonymous_names_desc": "Dölj riktiga spelarnamn med slumpmässiga namn på din skärm.", + "left_click_label": "🖱️ Vänsterklicka för att öppna menyn", + "left_click_desc": "När det är PÅ öppnar vänsterklick en meny, och svärdknappen anfaller. När det är AV, anfaller vänster musknapp direkt.", + "attack_ratio_label": "⚔️ Anfallsskala", + "attack_ratio_desc": "Vilken procent av dina trupper som ska skickas in vid ett anfall.(1–100%)", + "troop_ratio_label": "🪖🛠️ Trupp- och Arbetarskala", + "troop_ratio_desc": "Anpassa balansen mellan trupper (för strid) och arbetare (för guldproduktion) (1–100%)", + "territory_patterns_label": "🏳️ Mönster för Territorium", + "territory_patterns_desc": "Välj mellan att visa territorium mönsterdesign i spelet", + "easter_writing_speed_label": "Multiplikator för skrivhastighet", + "easter_writing_speed_desc": "Ändra hur snabbt du låtsas att koda (x1–x100)", + "easter_bug_count_label": "Mängd buggar", + "easter_bug_count_desc": "Hur många buggar du är okej med (0–1000, emotionellt)", + "view_options": "Se Alternativ", + "toggle_view": "Växla vy", + "toggle_view_desc": "Alternativ vy (terräng/länder)", + "attack_ratio_controls": "Anfallsskalningskontroller", + "attack_ratio_up": "Öka Anfallsskalning", + "attack_ratio_up_desc": "Öka anfallsskalan med 10%", + "attack_ratio_down": "Minska anfallskalan", + "attack_ratio_down_desc": "Minska anfallsskalan med 10%", + "attack_keybinds": "Anfallstangentbindningar", + "boat_attack": "Båtanfall", + "boat_attack_desc": "Skicka en båt att anfalla rutan under din muspekare.", + "zoom_controls": "Zoom Kontroller", + "zoom_out": "Zooma Ut", + "zoom_out_desc": "Zooma ut kartan", + "zoom_in": "Zooma In", + "zoom_in_desc": "Zooma in kartan", + "camera_movement": "Kamerarörelser", + "center_camera": "Centrera Kameran", + "center_camera_desc": "Centrera kartan på spelaren", + "move_up": "Flytta Kameran Upp", + "move_up_desc": "Flytta kameran uppåt", + "move_left": "Flytta Kameran Vänster", + "move_left_desc": "Flytta kameran åt vänster", + "move_down": "Flytta Kameran Ner", + "move_down_desc": "Flytta kameran nedåt", + "move_right": "Flytta Kameran Höger", + "move_right_desc": "Flytta kameran åt höger", + "reset": "Återställ", + "unbind": "Ta bort knappbindning" + }, + "chat": { + "title": "Snabbchat", + "to": "Skickat {user}: {msg}", + "from": "Från {user}: {msg}", + "category": "Kategori", + "phrase": "Fras", + "player": "Spelare", + "send": "Skicka", + "search": "Söker spelare...", + "build": "Bygg ditt meddelande...", + "cat": { + "help": "Hjälp", + "attack": "Anfall", + "defend": "Försvar", + "greet": "Hälsningar", + "misc": "Diverse", + "warnings": "Varningar" + }, + "help": { + "troops": "Vänligen ge mig trupper!", + "gold": "Vänligen ge mig guld!", + "no_attack": "Vänligen anfall mig ej!", + "sorry_attack": "Ursäkta, jag hade inte som avsikt att anfalla.", + "alliance": "Allians?", + "help_defend": "Hjälp mig försvara mot [P1]!", + "team_up": "Låt oss gå samman mot [P1]!" + }, + "attack": { + "attack": "Anfall [P1]!", + "mirv": "Avfyra en MIRV mot [P1]!", + "focus": "Fokusera [P1]!", + "finish": "Låt oss avsluta [P1]!" + }, + "defend": { + "defend": "Försvara [P1]!", + "dont_attack": "Anfall inte [P1]!", + "ally": "[P1] är min allierade!" + }, + "greet": { + "hello": "Hej!", + "good_luck": "Lycka till!", + "have_fun": "Ha det så kul!", + "gg": "GG!", + "nice_to_meet": "Trevligt att träffas!", + "well_played": "Väl spelat!", + "hi_again": "Hej igen!", + "bye": "Hej då!", + "thanks": "Tack!", + "oops": "Oj, fel knapp!", + "trust_me": "Du kan lita på mig. Jag lovar!", + "trust_broken": "Jag litade på dig..." + }, + "misc": { + "go": "Vi kör!", + "strategy": "Bra strategi!", + "fun": "Detta spelet är kul!", + "pr": "När kommer min PR äntligen bli mergad...?" + }, + "warnings": { + "strong": "[P1] är stark.", + "weak": "[P1] är svag.", + "mirv_soon": "[P1] kan avfyra en MIRV snart!", + "number1_warning": "Spelaren nummer #1 kommer snart vinna om vi inte går samman!", + "stalemate": "Låt oss skapa fred. Detta är ett dödläge, vi kommer båda förlora.", + "has_allies": "[P1] har många allierade.", + "no_allies": "[P1] har inga allierade.", + "betrayed": "[P1] förrådde deras allierade!", + "getting_big": "[P1] växer för snabbt!", + "danger_base": "[P1] är oskyddad!", + "saving_for_mirv": "[P1] sparar upp till en MIRV.", + "mirv_ready": "[P1] har tillräckligt med guld för att avfyra en MIRV!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Liten explosion", + "hydrogen_bomb": "Stor explosion", + "mirv": "Massiv explosion, träffar enbart vald spelare", + "missile_silo": "Används för att avfyra kärnvapen", + "sam_launcher": "Skyddar mot inkommande kärnvapen", + "warship": "Fångar in handelsfartyg, förstör skepp och båtar", + "port": "Skickar handelsfartyg för att generera guld", + "defense_post": "Ökar försvaret av närliggande gränser", + "city": "Ökar max befolkning" + }, + "not_enough_money": "Ej tillräckligt med guld" + }, + "win_modal": { + "died": "Du dog", + "your_team": "Ditt lag vann!", + "other_team": "{team} laget har vunnit!", + "you_won": "Du vann!", + "other_won": "{player} har vunnit!", + "exit": "Avsluta spelet", + "keep": "Fortsätt spela", + "wishlist": "Lägg till i önskelistan på Steam!" + }, + "leaderboard": { + "title": "Topplista", + "hide": "Dölj", + "rank": "Rank", + "player": "Spelare", + "owned": "Ägd", + "gold": "Guld", + "troops": "Trupper" + }, + "player_info_overlay": { + "type": "Typ", + "bot": "Bot", + "nation": "Nation", + "player": "Spelare", + "team": "Lag", + "d_troops": "Försvarande trupper", + "a_troops": "Anfallande trupper", + "gold": "Guld", + "ports": "Hamnar", + "cities": "Städer", + "missile_launchers": "Missilavfyringsramper", + "sams": "Luftvärn", + "warships": "Krigsskepp", + "health": "Hälsa", + "attitude": "Attityd" + }, + "relation": { + "hostile": "Fientlig", + "distrustful": "Misstänksam", + "neutral": "Neutral", + "friendly": "Vänlig", + "default": "Standard" + }, + "control_panel": { + "pop": "Pop", + "gold": "Guld", + "troops": "Trupper", + "workers": "Arbetare", + "attack_ratio": "Anfallsskala" + }, + "player_panel": { + "gold": "Guld", + "troops": "Trupper", + "betrayals": "Antal förräderier", + "traitor": "Förrädare", + "alliance_time_remaining": "Alliansen går ut om", + "embargo": "Avbröt handel med dig", + "nuke": "Kärnvapen skickade av dem mot dig", + "start_trade": "Börja handel", + "stop_trade": "Avsluta handel", + "yes": "Ja", + "no": "Nej", + "none": "Ingen", + "alliances": "Allianser" + }, + "error_modal": { + "crashed": "Spelet kraschade!", + "paste_discord": "Vänligen klistra in följande i din buggrapport på Discord:", + "copy_clipboard": "Kopiera till urklipp", + "copied": "Kopierat!", + "failed_copy": "Misslyckades att kopiera", + "desync_notice": "Du har blivit avsynkroniserad från de andra spelarna. Det du ser kan komma att skilja från andra spelare." + }, + "heads_up_message": { + "choose_spawn": "Välj en plats att börja på" + } +} diff --git a/resources/lang/tp.json b/resources/lang/tp.json index c27219372..5cfeb1bbe 100644 --- a/resources/lang/tp.json +++ b/resources/lang/tp.json @@ -214,7 +214,7 @@ "emojis_desc": "o ante e lon pi sitelen Emosi", "anonymous_names_label": "🥷 nimi len", "anonymous_names_desc": "o len e nimi lon pi jan musi kepeken nimi nasa lon musi sina", - "left_click_label": "🖱️ ilo luka soto li open e lipu ken", + "left_click_label": "ilo luka soto li open e lipu ken", "left_click_desc": "ni li lon la ilo luka soto li open e lipu ken la ilo luka utala li utala. ni li pini la ilo luka soto li utala taso.", "attack_ratio_label": "⚔️ nanpa utala", "attack_ratio_desc": "sina utala kepeken kulupu utala la o pana e kulupu suli ni (kipisi wan tawa wan ale)", diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 5f2f95a63..ac043c32c 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -5,10 +5,14 @@ "svg": "ua", "lang_code": "uk" }, + "common": { + "close": "Закрити" + }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Приєднуйтеся до Discord!", "login_discord": "Увійти з Discord", + "checking_login": "Перевірка авторизації...", "logged_in": "Вхід виконано!", "log_out": "Вийти", "create_lobby": "Створити лобі", @@ -16,20 +20,24 @@ "single_player": "Гра наодинці", "instructions": "Інструкції", "how_to_play": "Як грати", + "advertise": "Рекламування", "wiki": "Вікі" }, + "news": { + "title": "Версію 23 випущено!" + }, "help_modal": { "hotkeys": "Гарячі клавіші", "table_key": "Клавіш", "table_action": "Дія", "action_alt_view": "Альтернативний вигляд (топогорафія/країни)", - "action_attack_altclick": "Атака (коли лівий клац призначено на відкриття меню)", + "action_attack_altclick": "Напад (коли лівий клац призначено на відкриття меню)", "action_build": "Відкрити меню будівництва", "action_emote": "Відкрити меню емодзі", "action_center": "Відцентрувати камеру на гравцеві", "action_zoom": "Зменшити/Збільшити масштаб", "action_move_camera": "Перемістити камеру", - "action_ratio_change": "Зменшити/Збільшити співвідношення атаки", + "action_ratio_change": "Зменшити/Збільшити співвідношення нападу", "action_reset_gfx": "Скинути графіку", "ui_section": "Ігровий інтерфейс", "ui_leaderboard": "Таблиця лідерів", @@ -38,12 +46,12 @@ "ui_control_desc": "Панель керування містить наступні елементи:", "ui_pop": "Населення — Кількість ваших підрозділів, максимальне населення та темп його приросту.", "ui_gold": "Золото — Обсяг вашого золота та швидкість, з якою ви отримуєте його.", - "ui_troops_workers": "Війська та Робітники — Кількість призначених військ і робітників. Війська використовуються для атаки або захисту від атак. Робітники використовуються для видобування золота. Ви можете регулювати кількість військ і робітників використовуючи повзунок.", - "ui_attack_ratio": "Співвідношення атаки — Кількість військ, що буде використано під час атаки. Ви можете налаштувати співвідношення атаки використовуючи повзунок. Маючи більше військ нападу, аніж захисту, ви зменшите втрати під час атаки, а менше — збільшите шкоду, що буде завдано вашим військам нападу. Ефект не перевищує співвідношення 2:1.", + "ui_troops_workers": "Війська та Робітники — Кількість розподілених військ і робітників. Війська використовуються для нападу або захисту від нападів. Робітники використовуються для генерації золота. Ви можете регулювати кількість військ і робітників використовуючи повзунок.", + "ui_attack_ratio": "Співвідношення нападу — Кількість військ, що буде використано під час нападу. Ви можете налаштувати співвідношення нападу використовуючи повзунок. Маючи більше військ нападу, аніж захисту, ви зменшите втрати під час нападу, а менше — збільшите шкоду, що буде завдано вашим військам нападу. Ефект не перевищує співвідношення 2:1.", "ui_events": "Панель подій", "ui_events_desc": "На панелі подій показуються останні події, запити та повідомлення швидкого чату. Деякими прикладами є:", "ui_events_alliance": "Альянс — Запити на укладення альянсу можна прийняти або відхилити. Союзники можуть ділитися ресурсами та військами, але не можуть нападати один на одного. Клацання на кнопку «Оглянути» переміщає камеру на гравця, який надіслав запит.", - "ui_events_attack": "Напади — Відображення вхідних та вихідних атак. Натисніть на повідомлення, щоб центрувати камеру на напад, ракету чи човен (транспортний корабель). Ви можете відкликати військам, натиснувши на червону кнопку «X». Це коштуватиме життя 25% військ, що нападають. Якщо відкликати човен — він повернеться до свого початкового розташування та здійсне напад, якщо територію було захоплено. Ракети не можна відкликати після запуску.", + "ui_events_attack": "Напади — Відображення вхідних та вихідних нападів. Натисніть на повідомлення, щоб центрувати камеру на напад, ракету чи човен (транспортний корабель). Ви можете відкликати війська, натиснувши на червону кнопку «X». Це коштуватиме життя 25% військ, що нападають. Якщо відкликати човен — він повернеться до свого початкового розташування та здійсне напад, якщо територію було захоплено. Ракети не можна відкликати після запуску.", "ui_events_quickchat": "Швидкий чат — Тут ви можете переглядати надіслані та отримані повідомлення. Надішліть повідомлення гравцю клацанням на значок швидкого чату в його меню інформації.", "ui_options": "Налаштування", "ui_options_desc": "Серед них можна знайти наступні елементи:", @@ -62,14 +70,14 @@ "info_title": "Меню інформації", "info_enemy_desc": "Містить таку інформацію вибраного гравця, як його ім'я, золото, війська, стан торгувілі з вами, запущені на вас ракети та мітку зрадника. Припинена торгівля означає, що ви не отримуватиме золото від гравця й він не надсилатиме вам золото торговельними кораблями. Свідомо (якщо гравець натиснув на «Припинити торгівлю», що триваме доки ви не натиснете на «Розпочати торгівлю») або автоматично (якщо ви зрадили свій альянс, що триває доки ви знову не станете союзниками або через 5 хвилин). Поле «Зрадник» відображає стан «Так» протягом 30 секунд після того, як граіець зрадив і напав на гравця, що укладав з ними альянс. Значки, перераховані нижче, зображають наступні взаємодії:", "info_chat": "Надсилає швидке повідомлення гравцю. Виберіть категорію, фразу та, якщо фраза містить слово «[P1]», оберіть ім'я гравця, котрим бажаєте заміни його. Тицьніть «Надіслати».", - "info_target": "Розмістити мітку цілі на гравці, позначивши його для всіх союзників. Використовується для координації атак.", - "info_alliance": "Надіслати гравцю запит на альянс. Союзники можуть ділитися ресурсами та військами, але не можуть атакувати один одного.", + "info_target": "Розмістити мітку цілі на гравці, позначивши його для всіх союзників. Використовується для координації нападів.", + "info_alliance": "Надіслати гравцю запит на альянс. Союзники можуть ділитися ресурсами та військами, але не можуть нападати один на одного.", "info_emoji": "Відправити емодзі гравцю.", "info_trade": "Використайте «Припинити торгівлю», щоб припинити давати гравцеві золото та отримувати його золото використовуючи торгові кораблі. Якщо ви обидва натиснете «Розпочати торгівлю» — вона розпочнеться знову.", "info_ally_panel": "Панель інформації союзника", "info_ally_desc": "Коли ви укладете альянс із гравцем, стануть доступними наступні значки:", "ally_betray": "Зрадьте свого союзника, розриваючи альянс, припиняючи торгівлю та послаблюючи свою оборону. Торгівля між вами зупиняється на 5 хвилин (або доки ви знову не станете союзниками), а інші також можуть припинити з вами торгівлю. Якщо інший гравець сам не був зрадником, ви отримаєте мітку зрадника на 30 секунд. Протягом цього часу над вашим ім'ям показуватиметься значок і ваш захист буде знижено на 50%. Боти з меншим шансом укладатимуть із вами альянси, а гравці подумають двічі, перш ніж мати з вами справу.", - "ally_donate": "Пожертвувати частину своїх військ союзнику. Використовується, коли в нього мало військ і його атакують, або коли йому необхідна додаткова сила для знищення ворога.", + "ally_donate": "Пожертвувати частину своїх військ союзнику. Використовується, коли в нього мало військ і на нього нападають, або коли йому необхідна додаткова сила для знищення ворога.", "ally_donate_gold": "Пожертвувати частину свого золота союзнику. Використовуйте, коли в нього мало золота, яке він потребує для будівель, або коли член команди заощаджує на РГЧ ІН.", "build_menu_title": "Меню будівництва", "build_menu_desc": "Будуйте споруди чи переглядайте їх збудовану кількість:", @@ -79,7 +87,7 @@ "build_city": "Місто", "build_city_desc": "Збільшує вашу максимальну кількість населення. Може бути корисним, коли ви не можете розширити свою територію, або незабаром досягнете ліміту населення.", "build_defense": "Пункт оборони", - "build_defense_desc": "Підсилює захист навколо найближчих кордонів, що показано візерунком у клітинку. Атаки ворогів уповільнені та несуть більше жертв.", + "build_defense_desc": "Підсилює захист навколо найближчих кордонів, що показано візерунком у клітинку. Напади ворогів уповільнені та несуть більше жертв.", "build_port": "Порт", "build_port_desc": "Можуть бути збудовані лише біля води. Дозволяє будувати військові кораблі. Автоматично відправляє торгові кораблі між портами вашої та інших країнами (крім випадків, коли торгівлю припинено), даючи золото обом сторонам. Торгівля автоматично припиняється коли ви нападаєте на гравця або він нападає на вас. Вона відновлюється через 5 хвилин або якщо ви укладаєте альянс. Ви можете вручну керувати торгівлею кнопками «Припинити торгівлю» та «Розпочати торгівлю».", "build_warship": "Військовий корабель", @@ -97,7 +105,7 @@ "player_icons": "Значки гравця", "icon_desc": "Приклади деяких ікон, з якими ви зіткнетесь, і їх значення:", "icon_crown": "Корона — Номер 1. Це — найкращий гравець у таблиці лідерів.", - "icon_traitor": "Зламаний щит — Зрадник. Цей гравець атакував союзника.", + "icon_traitor": "Зламаний щит — Зрадник. Цей гравець напав на союзника.", "icon_ally": "Рукостискання — Союзник. Цей гравець є вашим союзником.", "icon_embargo": "Закреслений знак долара — Ембарго. Цей гравець припинив торгівлю з вами; автоматично чи свідомо.", "icon_request": "Конверт — Запит альянсу. Гравець надіслав вам запит на укладення альянсу.", @@ -135,14 +143,15 @@ "random": "Випадково", "iceland": "Ісландія", "pangaea": "Пангея", + "eastasia": "Східна Азія", "betweentwoseas": "Поміж двох морів", - "knownworld": "Відомий світ", "faroeislands": "Фарерські острови", "deglaciatedantarctica": "Розтала Антарктида", "europeclassic": "Європа (класична)", "falklandislands": "Фолклендські острови", "baikal": "Байкал", - "halkidiki": "Халкідіки" + "halkidiki": "Халкідіки", + "giantworldmap": "Велетенська мапа світу" }, "map_categories": { "continental": "Континентальні", @@ -231,11 +240,13 @@ "anonymous_names_label": "🥷 Приховані імена", "anonymous_names_desc": "Приховати справжні імена гравців і замінити їх випадковими.", "left_click_label": "🖱️ Відкриття меню лівою кнопкою миші", - "left_click_desc": "УВІМКНЕНО: лівий клац відкриває меню, атака здійснюється кнопкою з мечем. ВИМКНЕНО: лівий клац здійснює атаку напряму.", - "attack_ratio_label": "⚔️ Співвідношення атаки", + "left_click_desc": "УВІМКНЕНО: лівий клац відкриває меню, напад здійснюється кнопкою з мечем. ВИМКНЕНО: лівий клац здійснює напад безпосередньо.", + "attack_ratio_label": "⚔️ Співвідношення нападу", "attack_ratio_desc": "Який відсоток ваших військ відправляти в напад (1–100%)", "troop_ratio_label": "🪖🛠️ Співвідношення військ і працівників", "troop_ratio_desc": "Налаштуйте співвідношення між військами (для бою) та працівниками (для видобування золота) (1–100%)", + "territory_patterns_label": "🏳️ Візерунки територій", + "territory_patterns_desc": "Виберіть, чи показувати візерунки територій у грі", "easter_writing_speed_label": "Множник швидкості друку", "easter_writing_speed_desc": "Налаштуйте швидкість, з якою ви удаєте, що програмуєте (x1–x100)", "easter_bug_count_label": "Кількість багів", @@ -243,6 +254,14 @@ "view_options": "Налаштування вигляду", "toggle_view": "Змінити вигляд", "toggle_view_desc": "Альтернативний вигляд (топогорафія/країни)", + "attack_ratio_controls": "Керування співвідношенням нападу", + "attack_ratio_up": "Збільшити співвідношення атаки", + "attack_ratio_up_desc": "Збільшити співвідношення атаки на 10%", + "attack_ratio_down": "Зменшити співвідношення атаки", + "attack_ratio_down_desc": "Зменшити співвідношення атаки на 10%", + "attack_keybinds": "Призначення клавіш нападу", + "boat_attack": "Напад човном", + "boat_attack_desc": "Відправити човен на плитку під вказівником.", "zoom_controls": "Масштабування", "zoom_out": "Зменшити масштаб", "zoom_out_desc": "Зменшити масштаб мапи", @@ -356,7 +375,8 @@ "you_won": "Ви перемогли!", "other_won": "Гравець {player} переміг!", "exit": "Вийти з гри", - "keep": "Продовжити гру" + "keep": "Продовжити гру", + "wishlist": "Додавайте до списку бажаного в Steam!" }, "leaderboard": { "title": "Таблиця лідерів", @@ -396,18 +416,32 @@ "gold": "Золото", "troops": "Війська", "workers": "Робітники", - "attack_ratio": "Співвідношення атаки" + "attack_ratio": "Співвідношення нападу" }, "player_panel": { "gold": "Золото", "troops": "Війська", + "betrayals": "Кількість зрад", "traitor": "Зрадник", - "alliance_time_remaining": "Залишок часу", + "alliance_time_remaining": "Альянс закінчиться через", "embargo": "Припинив торгівлю з вами", "nuke": "Ракети, запущені на вас", "start_trade": "Розпочати торгівлю", "stop_trade": "Припинити торгівлю", "yes": "Так", - "no": "Ні" + "no": "Ні", + "none": "Нічого", + "alliances": "Альянси" + }, + "error_modal": { + "crashed": "Гра крашнулася!", + "paste_discord": "Будь ласка, вставте наступне у ваш звіт про помилку в Discord:", + "copy_clipboard": "Скопіювати в буфер обміну", + "copied": "Скопійовано!", + "failed_copy": "Не вдалося скопіювати", + "desync_notice": "Ви десинхронізовані з іншими гравцями. Те, що ви бачите, може відрізнятися від того, що бачать інші гравці." + }, + "heads_up_message": { + "choose_spawn": "Оберіть стартове розташування" } } diff --git a/resources/lang/zh_cn.json b/resources/lang/zh_cn.json new file mode 100644 index 000000000..ca23bb390 --- /dev/null +++ b/resources/lang/zh_cn.json @@ -0,0 +1,447 @@ +{ + "lang": { + "en": "Chinese Simplified", + "native": "简体中文", + "svg": "cn", + "lang_code": "zh" + }, + "common": { + "close": "关闭" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "加入 Discord", + "login_discord": "用 Discord 登录", + "checking_login": "正在检查登录...", + "logged_in": "登录成功!", + "log_out": "退出登录", + "create_lobby": "创建房间", + "join_lobby": "加入房间", + "single_player": "单人游戏", + "instructions": "操作说明", + "how_to_play": "如何游玩", + "advertise": "广告", + "wiki": "游戏百科" + }, + "news": { + "title": "版本 23 已发布!" + }, + "help_modal": { + "hotkeys": "快捷键", + "table_key": "键", + "table_action": "动作", + "action_alt_view": "备选视图 (地形/国家)", + "action_attack_altclick": "攻击 (当左键单击被设置为打开菜单时)", + "action_build": "打开建造菜单", + "action_emote": "打开表情菜单", + "action_center": "将镜头居中在玩家身上", + "action_zoom": "放大/缩小", + "action_move_camera": "移动镜头", + "action_ratio_change": "减少/增加进攻部队的比率", + "action_reset_gfx": "重置图形", + "ui_section": "游戏 UI", + "ui_leaderboard": "排行榜", + "ui_leaderboard_desc": "显示游戏中的顶尖玩家及其姓名、所占领土百分比、黄金和军队数量。点击“显示全部”可以查看所有玩家的信息。如果你不想看到排行榜,点击“隐藏”即可。", + "ui_control": "控制面板", + "ui_control_desc": "控制面板包括下列元素:", + "ui_pop": "人口 - 你拥有的单位数量、你的最大人口和你获得它们的速度。", + "ui_gold": "黄金 - 你拥有的黄金数量和你获得它的速度。", + "ui_troops_workers": "军队和工人 - 已分配军队和工人的数量。军队用来攻击或防御。工人用来生产黄金。你可以通过滑动条来调整军队和工人的数量。", + "ui_attack_ratio": "攻击比例 - 攻击时使用的军队数量。你可以通过滑动条来调整攻击比例。进攻部队多于防守部队时,你在战斗中损失的部队会更少;而进攻部队少于防守部队时,你的部队会遭受更多伤害。不过,这种效果在比例超过2:1时不会继续增强。", + "ui_events": "事件面板", + "ui_events_desc": "事件面板展示最新事件、请求和快速聊天信息。例如:", + "ui_events_alliance": "结盟 - 可以接受或拒绝结盟请求。盟友之间可发送资源和军队,但无法互相攻击。点击“焦点”可将视角移动到发送该请求的玩家。", + "ui_events_attack": "攻击 - 敌袭和我军攻击都会显示出来。点击该消息可将视角移到该攻击、核弹或运输船上。你可以点击红叉来撤回军队,但此举将损耗25%的进攻军队。如果你撤回了一艘运输船,该船会返航至起始点,如果该起始点已被他人攻占,该船会转而进攻该点。核弹一旦发射将无法撤回。", + "ui_events_quickchat": "快速聊天 - 你可以在此查看聊天信息。点击信息菜单中的“快速聊天”按钮可向玩家发送消息。", + "ui_options": "选项", + "ui_options_desc": "内含以下内容:", + "ui_playeroverlay": "玩家信息叠层", + "ui_playeroverlay_desc": "当鼠标悬停在一个国家上时,玩家信息叠层将显示在“选项”下方。它将显示该玩家的类型,包括真人玩家、国家(智能AI)或机器人;还会显示该国对你的态度 (从敌对到友好);该国防守军队数量,黄金,军舰及各种建筑物的数量。", + "option_pause": "暂停/继续游戏 - 仅在单人模式下有效。", + "option_timer": "计时器 - 自游戏开始以来经过的时长。", + "option_exit": "退出按钮。", + "option_settings": "设置 — 打开设置菜单。你可以在其中切换以下选项:备用视图、表情符号、深色模式、忍者模式 (匿名/随机玩家名模式),以及左键点击的行为。", + "radial_title": "环形菜单", + "radial_desc": "右击 (或在手机上触摸) 来打开右键菜单。右击其他地方来关闭它。在该菜单中,你可以:", + "radial_build": "打开建造菜单。", + "radial_info": "打开信息菜单。", + "radial_boat": "发送一艘运输船攻击选中的区域。仅当你与水域毗邻时才可用。", + "radial_close": "关闭菜单。", + "info_title": "信息菜单", + "info_enemy_desc": "包含以下信息:所选玩家的名称、黄金数量、军队数量、是否已停止与你贸易、是否对你发射了核弹,以及该玩家是否为叛徒。“停止贸易”表示你将无法从该玩家处获得金币,对方也无法通过贸易船向你发送金币。这种状态可能是手动触发(该玩家点击了“停止贸易”,此状态将持续,直到你们双方都点击“开始贸易”)或自动触发(当你背叛了联盟时,此状态会持续,直到你们重新结盟或5分钟后自动结束)。当玩家背叛并攻击其盟友时,“叛徒”状态将显示为“是”,持续30秒。下方图标表示你与该玩家的互动关系:", + "info_chat": "发送快速聊天消息给该玩家。选择一个类别、一句话,如果句子中包含 [P1],请选择一个玩家名字来替换它。点击发送。", + "info_target": "在该玩家身上放置目标标记,使所有盟友都能看到,用于协调攻击。", + "info_alliance": "向该玩家发送结盟请求。盟友间可发送资源和军队,但无法互相攻击。", + "info_emoji": "向玩家发送表情符号。", + "info_trade": "点击“停止交易”将停止与该玩家的商船贸易互惠。如果再次点击“开始交易”可重启贸易关系。", + "info_ally_panel": "盟友信息面板", + "info_ally_desc": "当你与其他玩家结盟时,下列新图标将可用:", + "ally_betray": "背叛你的盟友,终结联盟,停止贸易,并削弱你的防御。与其的交易将暂停5分钟 (或直到你们再次结盟),且其他人也可能与您停止交易。你将被标记为背叛者30秒,但其他背叛者玩家看不到该标记。在此期间,一个图表将显示在你的名字里,且你将获得50%的防御减益效果。机器人将更不愿与你结盟,其他玩家与你结盟时也将再三考虑。", + "ally_donate": "捐赠一些军队给盟友。用于当他们军队不足且被攻击时,或者他们需要额外的力量来击垮敌人时。", + "ally_donate_gold": "捐赠一些金钱给盟友。用于当他们没钱且需要建造建筑时,或者你的队友正在攒钱造MIRV时。", + "build_menu_title": "建造菜单", + "build_menu_desc": "建造它们或者查看你已建造每个建筑的数量:", + "build_name": "名称", + "build_icon": "图标", + "build_desc": "描述", + "build_city": "城市", + "build_city_desc": "增加你的最大人口。当你无法拓展领土,或者即将达到人口上限时很有用。", + "build_defense": "防守据点", + "build_defense_desc": "增加附近边境的防御力,并以棋盘图案标记。敌人在此处的进攻将更慢,伤亡也更高。", + "build_port": "港口", + "build_port_desc": "只能建造在水域附近。解锁建造军舰功能。会自动在你国家与其他国家的港口之间发送商船 (在未停止贸易的情况下),双方都可获得黄金。当你攻击他人或被攻击时,贸易将自动中止;5分钟后或重新结为盟友后自动恢复。你也可以通过点击“停止贸易”或“开始贸易”手动切换贸易状态。", + "build_warship": "军舰", + "build_warship_desc": "在指定区域巡逻,可俘获敌方的贸易船并摧毁其运输船和军舰。军舰从最近的港口出发,巡逻你最初点击部署的位置。你可以通过点击来控制军舰移动 (详见快捷键中的“攻击”操作):先点击军舰,再点击你希望其前往的新区域。", + "build_silo": "导弹发射井", + "build_silo_desc": "允许发射导弹。", + "build_sam": "防空塔", + "build_sam_desc": "可以截获100像素范围内的敌方导弹。原子弹、氢弹和单个MIRV弹头的拦截命中概率分别是100%、80%和50%。该防空导弹拥有7.5秒冷却。", + "build_atom": "原子弹", + "build_atom_desc": "小型爆弹可摧毁领土、建筑、船只。从最近的导弹发射井发射并坠落在你初次点击部署它的区域。", + "build_hydrogen": "氢弹", + "build_hydrogen_desc": "大型爆弹。从最近的导弹发射井发射并坠落在你初次点击部署它的区域。", + "build_mirv": "MIRV (分导式多弹头)", + "build_mirv_desc": "游戏中最强大的导弹。会分裂成多个小型导弹,覆盖大范围区域。只会对你最初点击发射时所选的玩家造成伤害。从最近的导弹发射井发射,并落在你最初点击发射的位置。", + "player_icons": "玩家图标", + "icon_desc": "您将遇到的一些游戏内图标及其含义的示例:", + "icon_crown": "皇冠 - 榜一玩家。这是排行榜上第一名的玩家。", + "icon_traitor": "破损的盾牌 - 叛徒。该玩家攻击了盟友。", + "icon_ally": "握手 - 盟友。该玩家是你的盟友。", + "icon_embargo": "美元符号停止标志 - 禁商。该玩家已自动或手动停止与您的交易。", + "icon_request": "信封 - 结盟请求。该玩家已向你发送结盟请求。", + "info_enemy_panel": "敌人信息面板" + }, + "single_modal": { + "title": "单人玩家", + "allow_alliances": "允许结盟", + "options_title": "选项", + "bots": "机器人: ", + "bots_disabled": "已禁用", + "disable_nations": "禁用国家", + "instant_build": "立即建造", + "infinite_gold": "无限黄金", + "infinite_troops": "无限军队", + "disable_nukes": "禁用核弹", + "enables_title": "启用设置", + "start": "开始游戏" + }, + "map": { + "map": "地图", + "world": "世界", + "europe": "欧洲", + "mena": "中东/北非", + "northamerica": "北美洲", + "oceania": "大洋洲", + "blacksea": "黑海", + "africa": "非洲", + "asia": "亚洲", + "mars": "火星", + "southamerica": "南美洲", + "britannia": "不列颠尼亚", + "gatewaytotheatlantic": "大西洋枢纽", + "australia": "澳大利亚", + "random": "随机", + "iceland": "冰岛", + "pangaea": "盘古大陆", + "eastasia": "东亚", + "betweentwoseas": "二海之间", + "faroeislands": "法罗群岛", + "deglaciatedantarctica": "冰消的南极洲", + "europeclassic": "欧洲 (古典)", + "falklandislands": "福克兰群岛", + "baikal": "贝加尔湖", + "halkidiki": "哈尔基迪基", + "giantworldmap": "巨型世界地图" + }, + "map_categories": { + "continental": "大陆", + "regional": "地区", + "fantasy": "其他" + }, + "private_lobby": { + "title": "加入私人房间", + "enter_id": "输入房间 ID", + "player": "玩家", + "players": "玩家", + "join_lobby": "加入房间", + "checking": "正在确认房间...", + "not_found": "找不到房间。请检查 ID 然后重试。", + "error": "发生了错误,请再试一次。", + "joined_waiting": "加入成功!正在等待游戏开始..." + }, + "public_lobby": { + "join": "加入下一场游戏", + "waiting": "等待中的玩家", + "teams": "{num} 个队伍" + }, + "username": { + "enter_username": "输入用户名", + "not_string": "用户名必须是字符串。", + "too_short": "用户名最少包含 {min} 个字符。", + "too_long": "用户名不得超过 {max} 个字符。", + "invalid_chars": "用户名只能包含字母、 数字、 空格、 下划线和 [方括号]。" + }, + "host_modal": { + "title": "私人房间", + "mode": "模式", + "team_count": "队伍数量", + "options_title": "选项", + "bots": "机器人: ", + "bots_disabled": "禁用", + "disable_nations": "禁用国家", + "instant_build": "立即建造", + "infinite_gold": "无限金钱", + "infinite_troops": "无限军队", + "enables_title": "启用设置", + "player": "玩家", + "players": "玩家", + "waiting": "正在等待玩家...", + "start": "开始游戏" + }, + "game_starting_modal": { + "title": "游戏正在启动...", + "desc": "正在等待游戏开始,请稍候。" + }, + "difficulty": { + "difficulty": "难度", + "Relaxed": "休闲", + "Balanced": "平衡", + "Intense": "困难", + "Impossible": "地狱" + }, + "game_mode": { + "ffa": "混战", + "teams": "团队" + }, + "select_lang": { + "title": "选择语言" + }, + "unit_type": { + "city": "城市", + "defense_post": "防守据点", + "port": "港口", + "warship": "军舰", + "missile_silo": "导弹发射井", + "sam_launcher": "防空塔", + "atom_bomb": "原子弹", + "hydrogen_bomb": "氢弹", + "mirv": "MIRV (分导式多弹头)" + }, + "user_setting": { + "title": "用户设置", + "tab_basic": "基本设置", + "tab_keybinds": "热键绑定", + "dark_mode_label": "🌙 深色模式", + "dark_mode_desc": "在浅色和深色主题之间切换网站外观", + "emojis_label": "😊 表情符号", + "emojis_desc": "切换是否在游戏中显示表情符号", + "special_effects_label": "💥 特效", + "special_effects_desc": "切换特效开关。停用以改进性能", + "anonymous_names_label": "🥷 隐藏名称", + "anonymous_names_desc": "将真实玩家名字替换为随机名字。", + "left_click_label": "🖱️ 左键单击打开菜单", + "left_click_desc": "开启时,先左键单击打开菜单,然后再点进攻。关闭时,左键将直接进攻。", + "attack_ratio_label": "⚔️ 攻击比例", + "attack_ratio_desc": "你要派出多少比例的军队进攻 (1–100%)", + "troop_ratio_label": "🛠️ 军队和工人比例", + "troop_ratio_desc": "调整军队 (用于战斗) 和工人 (用于生产黄金) 之间的比例 (1-100%)", + "territory_patterns_label": "🏳️ 领土样式", + "territory_patterns_desc": "选择在游戏中如何显示领土样式", + "easter_writing_speed_label": "写入速度乘数", + "easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)", + "easter_bug_count_label": "Bug 计数", + "easter_bug_count_desc": "你能接受多少个 Bug? (0–1000,心理承受范围)", + "view_options": "视图选项", + "toggle_view": "切换视图", + "toggle_view_desc": "备选视图 (地形/国家)", + "attack_ratio_controls": "攻击比例控制", + "attack_ratio_up": "增加攻击比例", + "attack_ratio_up_desc": "增加 10% 攻击比例", + "attack_ratio_down": "降低攻击比例", + "attack_ratio_down_desc": "降低 10% 攻击比例", + "attack_keybinds": "攻击快捷键", + "boat_attack": "船只攻击", + "boat_attack_desc": "向鼠标所指地块发送船只攻击。", + "zoom_controls": "缩放控制", + "zoom_out": "缩小", + "zoom_out_desc": "缩小地图", + "zoom_in": "放大", + "zoom_in_desc": "放大地图", + "camera_movement": "镜头移动", + "center_camera": "镜头居中", + "center_camera_desc": "将镜头居中在玩家身上", + "move_up": "镜头上移", + "move_up_desc": "向上移动镜头", + "move_left": "镜头左移", + "move_left_desc": "向左移动镜头", + "move_down": "镜头下移", + "move_down_desc": "向下移动镜头", + "move_right": "镜头右移", + "move_right_desc": "向右移动镜头", + "reset": "重置", + "unbind": "解绑" + }, + "chat": { + "title": "快捷聊天", + "to": "发送给 {user}: {msg}", + "from": "{user} 说: {msg}", + "category": "分类", + "phrase": "短语", + "player": "玩家", + "send": "发送", + "search": "搜索玩家...", + "build": "编辑您的消息...", + "cat": { + "help": "帮助", + "attack": "攻击", + "defend": "防御", + "greet": "问候", + "misc": "杂项", + "warnings": "警告" + }, + "help": { + "troops": "请给我军队!", + "gold": "请给我金钱!", + "no_attack": "请不要攻击我!", + "sorry_attack": "抱歉,我没打算攻击的。", + "alliance": "结盟吗?", + "help_defend": "帮我防御 [P1]!", + "team_up": "让我们联合对抗 [P1]!" + }, + "attack": { + "attack": "攻击 [P1]!", + "mirv": "向 [P1] 发射一个MIRV !", + "focus": "集火攻击 [P1]!", + "finish": "让我们终结 [P1] 吧!" + }, + "defend": { + "defend": "防御 [P1]!", + "dont_attack": "不要攻击 [P1]!", + "ally": "[P1] 是我的盟友!" + }, + "greet": { + "hello": "你好 !", + "good_luck": "祝你好运!", + "have_fun": "玩得开心!", + "gg": "GG!", + "nice_to_meet": "很高兴见到你!", + "well_played": "干得漂亮!", + "hi_again": "又见面了!", + "bye": "拜拜!", + "thanks": "谢谢!", + "oops": "哎呀,点错了!", + "trust_me": "信我,兄弟!", + "trust_broken": "我竟然会相信你…" + }, + "misc": { + "go": "走吧!", + "strategy": "厉害,这波操作!", + "fun": "这场游戏真有趣!", + "pr": "我的 PR 什么时候才能被合并…?" + }, + "warnings": { + "strong": "[P1] 很强。", + "weak": "[P1] 很弱。", + "mirv_soon": "[P1] 马上就能发射 MIRV 了!", + "number1_warning": "如果我们不结盟,榜一玩家就要赢了!", + "stalemate": "我们停战吧。这种僵局对我们双方都不利。", + "has_allies": "[P1] 有很多盟友。", + "no_allies": "[P1] 没有盟友。", + "betrayed": "[P1] 背叛了他的盟友!", + "getting_big": "[P1] 扩张太快了!", + "danger_base": "[P1] 没有防守!", + "saving_for_mirv": "[P1] 正在存钱以发射 MIRV。", + "mirv_ready": "[P1] 有足够的金钱来发射 MIRV!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "小型爆炸", + "hydrogen_bomb": "大型爆炸", + "mirv": "巨型爆炸,仅针对选中的玩家", + "missile_silo": "用于发射核弹", + "sam_launcher": "防御飞来的核弹", + "warship": "捕获商船、摧毁船只", + "port": "发送商船来获得黄金", + "defense_post": "增加附近边界的防御力", + "city": "增加最大人口" + }, + "not_enough_money": "金钱不足" + }, + "win_modal": { + "died": "你已经死了", + "your_team": "你的队伍获胜了!", + "other_team": "{team} 队获胜了!", + "you_won": "你获胜了!", + "other_won": "{player} 获胜了!", + "exit": "退出游戏", + "keep": "继续游戏", + "wishlist": "将游戏加入 Steam 愿望单!" + }, + "leaderboard": { + "title": "排行榜", + "hide": "隐藏", + "rank": "排名", + "player": "玩家", + "owned": "已拥有", + "gold": "黄金", + "troops": "军队" + }, + "player_info_overlay": { + "type": "类型", + "bot": "机器人", + "nation": "国家", + "player": "玩家", + "team": "队伍", + "d_troops": "防守军队", + "a_troops": "进攻军队", + "gold": "黄金", + "ports": "港口", + "cities": "城市", + "missile_launchers": "导弹发射井", + "sams": "防空塔", + "warships": "军舰", + "health": "生命值", + "attitude": "态度" + }, + "relation": { + "hostile": "敌对", + "distrustful": "不可信", + "neutral": "中立", + "friendly": "友好", + "default": "默认" + }, + "control_panel": { + "pop": "人口", + "gold": "黄金", + "troops": "军队", + "workers": "工人", + "attack_ratio": "攻击比例" + }, + "player_panel": { + "gold": "黄金", + "troops": "军队", + "betrayals": "叛变者数量", + "traitor": "叛变者", + "alliance_time_remaining": "结盟有效期截至", + "embargo": "已停止与你交易", + "nuke": "他们向你发射的核弹", + "start_trade": "开始交易", + "stop_trade": "停止交易", + "yes": "是", + "no": "否", + "none": "空", + "alliances": "盟友" + }, + "error_modal": { + "crashed": "游戏崩溃了!", + "paste_discord": "请在 Discord 中粘贴以下错误报告:", + "copy_clipboard": "复制到剪贴板", + "copied": "已复制!", + "failed_copy": "复制失败", + "desync_notice": "你与其他玩家失去同步。你看到的可能与其他玩家不同。" + }, + "heads_up_message": { + "choose_spawn": "选择出生点" + } +} diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 0614732cd..ebee81856 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -7,7 +7,6 @@ import { GameStartInfo, PlayerRecord, ServerMessage, - Winner, } from "../core/Schemas"; import { createGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; @@ -150,9 +149,10 @@ export async function createClientGame( const gameView = new GameView( worker, config, - gameMap.gameMap, + gameMap, lobbyConfig.clientID, lobbyConfig.gameStartInfo.gameID, + lobbyConfig.gameStartInfo.players, ); console.log("going to init path finder"); @@ -199,13 +199,6 @@ export class ClientGameRunner { this.lastMessageTime = Date.now(); } - private getWinner(update: WinUpdate): Winner { - if (update.winner[0] !== "player") return update.winner; - const clientId = this.gameView.playerBySmallID(update.winner[1]).clientID(); - if (clientId === null) return; - return ["player", clientId]; - } - private saveGame(update: WinUpdate) { if (this.myPlayer === null) { return; @@ -218,7 +211,6 @@ export class ClientGameRunner { stats: update.allPlayersStats[this.lobby.clientID], }, ]; - const winner = this.getWinner(update); if (this.lobby.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); @@ -231,7 +223,7 @@ export class ClientGameRunner { [], startTime(), Date.now(), - winner, + update.winner, ); endGame(record); } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts new file mode 100644 index 000000000..7e694207f --- /dev/null +++ b/src/client/Cosmetics.ts @@ -0,0 +1,149 @@ +import { UserMeResponse } from "../core/ApiSchemas"; +import { COSMETICS } from "../core/CosmeticSchemas"; +import { getApiBase, getAuthHeader } from "./jwt"; +import { translateText } from "./Utils"; + +interface StripeProduct { + id: string; + object: "product"; + active: boolean; + created: number; + description: string | null; + images: string[]; + livemode: boolean; + metadata: Record; + name: string; + shippable: boolean | null; + type: "good" | "service"; + updated: number; + url: string | null; + price: string; + price_id: string; +} + +export interface Pattern { + name: string; + key: string; + roles: string[]; + price?: string; + priceId?: string; + lockedReason?: string; + notShown?: boolean; +} + +export async function patterns( + userMe: UserMeResponse | null, +): Promise { + const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map( + ([key, patternData]) => { + return { + name: patternData.name, + key, + roles: patternData.role_group + ? (COSMETICS.role_groups[patternData.role_group] ?? []) + : [], + }; + }, + ); + + const products = await listAllProducts(); + patterns.forEach((pattern) => { + addRestrictions(pattern, userMe, products); + }); + return patterns; +} + +function addRestrictions( + pattern: Pattern, + userMe: UserMeResponse | null, + products: Map, +) { + if (userMe === null) { + if (products.has(`pattern:${pattern.name}`)) { + // Purchasable (flare-gated) patterns are shown as disabled + pattern.lockedReason = translateText("territory_patterns.blocked.login"); + } else { + // Role-gated patterns are not shown + pattern.notShown = true; + } + return; + } + const flares = userMe.player.flares ?? []; + if ( + flares.includes("pattern:*") || + flares.includes(`pattern:${pattern.name}`) + ) { + // Pattern is unlocked by flare + return; + } + + const myRoles = userMe.player.roles ?? []; + if ( + pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole)) + ) { + // Pattern is unlocked by role + return; + } + + const product = products.get(`pattern:${pattern.name}`); + if (product) { + pattern.price = product.price; + pattern.priceId = product.price_id; + pattern.lockedReason = translateText("territory_patterns.blocked.purchase"); + return; + } + + // Pattern is locked by role group and not purchasable, don't show it. + pattern.notShown = true; +} + +export async function handlePurchase(priceId: string) { + try { + const response = await fetch( + `${getApiBase()}/stripe/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: getAuthHeader(), + }, + body: JSON.stringify({ + priceId: priceId, + successUrl: `${window.location.href}purchase-success`, + cancelUrl: `${window.location.href}purchase-cancel`, + }), + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const { url } = await response.json(); + + // Redirect to Stripe checkout + window.location.href = url; + } catch (error) { + console.error("Purchase error:", error); + alert("Something went wrong. Please try again later."); + } +} + +// Returns a map of flare -> product +export async function listAllProducts(): Promise> { + try { + const response = await fetch(`${getApiBase()}/stripe/products`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const products = (await response.json()) as StripeProduct[]; + const productMap = new Map(); + products.forEach((product) => { + productMap.set(product.metadata.flare, product); + }); + return productMap; + } catch (error) { + console.error("Failed to fetch products:", error); + return new Map(); + } +} diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 2a71ec7be..b0dbc6ae8 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -39,7 +39,10 @@ export class HostLobbyModal extends LitElement { @state() private copySuccess = false; @state() private players: string[] = []; @state() private useRandomMap: boolean = false; - @state() private disabledUnits: UnitType[] = []; + @state() private disabledUnits: UnitType[] = [ + UnitType.Factory, + UnitType.Train, + ]; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index e590dc6dd..4333ee4ac 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -6,10 +6,12 @@ import ar from "../../resources/lang/ar.json"; import bg from "../../resources/lang/bg.json"; import bn from "../../resources/lang/bn.json"; import cs from "../../resources/lang/cs.json"; +import da from "../../resources/lang/da.json"; import de from "../../resources/lang/de.json"; import en from "../../resources/lang/en.json"; import eo from "../../resources/lang/eo.json"; import es from "../../resources/lang/es.json"; +import fi from "../../resources/lang/fi.json"; import fr from "../../resources/lang/fr.json"; import he from "../../resources/lang/he.json"; import hi from "../../resources/lang/hi.json"; @@ -20,9 +22,11 @@ import pl from "../../resources/lang/pl.json"; import pt_br from "../../resources/lang/pt_br.json"; import ru from "../../resources/lang/ru.json"; import sh from "../../resources/lang/sh.json"; +import sv_se from "../../resources/lang/sv_se.json"; import tp from "../../resources/lang/tp.json"; import tr from "../../resources/lang/tr.json"; import uk from "../../resources/lang/uk.json"; +import zh_cn from "../../resources/lang/zh_cn.json"; @customElement("lang-selector") export class LangSelector extends LitElement { @@ -57,6 +61,10 @@ export class LangSelector extends LitElement { uk, cs, he, + da, + fi, + sv_se, + zh_cn, }; createRenderRoot() { diff --git a/src/client/Main.ts b/src/client/Main.ts index a25a1cd0d..e1a72f887 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -182,13 +182,13 @@ class Client { const territoryModal = document.querySelector( "territory-patterns-modal", ) as TerritoryPatternsModal; - const tpButton = document.getElementById( + const patternButton = document.getElementById( "territory-patterns-input-preview-button", ); territoryModal instanceof TerritoryPatternsModal; - if (tpButton === null) + if (patternButton === null) throw new Error("territory-patterns-input-preview-button"); - territoryModal.previewButton = tpButton; + territoryModal.previewButton = patternButton; territoryModal.updatePreview(); territoryModal.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { @@ -197,7 +197,7 @@ class Client { } } }); - tpButton.addEventListener("click", () => { + patternButton.addEventListener("click", () => { territoryModal.open(); }); @@ -207,6 +207,7 @@ class Client { loginDiscordButton.translationKey = "main.login_discord"; loginDiscordButton.addEventListener("click", discordLogin); logoutDiscordButton.hidden = true; + territoryModal.onUserMe(null); } else { // JWT appears to be valid loginDiscordButton.disable = true; @@ -215,12 +216,12 @@ class Client { logoutDiscordButton.addEventListener("click", () => { // Log out logOut(); + territoryModal.onUserMe(null); loginDiscordButton.disable = false; loginDiscordButton.translationKey = "main.login_discord"; loginDiscordButton.hidden = false; loginDiscordButton.addEventListener("click", discordLogin); logoutDiscordButton.hidden = true; - territoryModal.onLogout(); }); // Look up the discord user object. // TODO: Add caching @@ -231,6 +232,7 @@ class Client { loginDiscordButton.translationKey = "main.login_discord"; loginDiscordButton.addEventListener("click", discordLogin); logoutDiscordButton.hidden = true; + territoryModal.onUserMe(null); return; } console.log( @@ -366,7 +368,7 @@ class Client { "host-lobby-modal", "join-private-lobby-modal", "game-starting-modal", - "top-bar", + "game-top-bar", "help-modal", "user-setting", ].forEach((tag) => { diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index faef1d60b..ec7059c98 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -40,7 +40,10 @@ export class SinglePlayerModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: number | typeof Duos = 2; - @state() private disabledUnits: UnitType[] = []; + @state() private disabledUnits: UnitType[] = [ + UnitType.Factory, + UnitType.Train, + ]; private userSettings: UserSettings = new UserSettings(); diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index e2788d5fb..9984994cd 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -1,12 +1,13 @@ +import { base64url } from "jose"; import type { TemplateResult } from "lit"; import { html, LitElement, render } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { COSMETICS } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { PatternDecoder } from "../core/PatternDecoder"; import "./components/Difficulties"; import "./components/Maps"; +import { handlePurchase, Pattern, patterns } from "./Cosmetics"; import { translateText } from "./Utils"; @customElement("territory-patterns-modal") @@ -17,25 +18,27 @@ export class TerritoryPatternsModal extends LitElement { }; public previewButton: HTMLElement | null = null; - public buttonWidth: number = 100; + public buttonWidth: number = 150; @state() private selectedPattern: string | undefined; @state() private lockedPatterns: string[] = []; @state() private lockedReasons: Record = {}; - @state() private hoveredPattern: string | null = null; + @state() private hoveredPattern: Pattern | null = null; @state() private hoverPosition = { x: 0, y: 0 }; @state() private keySequence: string[] = []; @state() private showChocoPattern = false; + private patterns: Pattern[] = []; + private me: UserMeResponse | null = null; + public resizeObserver: ResizeObserver; private userSettings: UserSettings = new UserSettings(); constructor() { super(); - this.checkPatternPermission(undefined, undefined); } connectedCallback() { @@ -59,52 +62,14 @@ export class TerritoryPatternsModal extends LitElement { this.resizeObserver.disconnect(); } - onLogout() { - this.checkPatternPermission(undefined, undefined); - } - - onUserMe(userMeResponse: UserMeResponse) { - const { player } = userMeResponse; - const { roles, flares } = player; - this.checkPatternPermission(roles, flares); - } - - private checkPatternPermission( - roles: string[] | undefined, - flares: string[] | undefined, - ) { - this.lockedPatterns = []; - this.lockedReasons = {}; - for (const key in COSMETICS.patterns) { - const patternData = COSMETICS.patterns[key]; - const roleGroup: string[] | string | undefined = patternData.role_group; - if ( - flares !== undefined && - (flares.includes("pattern:*") || flares.includes(`pattern:${key}`)) - ) { - continue; - } - - if (!roleGroup || (Array.isArray(roleGroup) && roleGroup.length === 0)) { - if (roles === undefined || roles.length === 0) { - const reason = translateText("territory_patterns.blocked.login"); - this.setLockedPatterns([key], reason); - } - continue; - } - - const groupList = Array.isArray(roleGroup) ? roleGroup : [roleGroup]; - const isAllowed = - roles !== undefined && - groupList.some((required) => roles.includes(required)); - - if (!isAllowed) { - const reason = translateText("territory_patterns.blocked.role", { - role: groupList.join(", "), - }); - this.setLockedPatterns([key], reason); - } + async onUserMe(userMeResponse: UserMeResponse | null) { + this.patterns = await patterns(userMeResponse); + const p = this.patterns.find((p) => p.name === this.selectedPattern); + if (p === undefined || p.lockedReason || p.notShown) { + console.warn("selected pattern is locked or not shown, resetting"); + this.selectPattern(undefined); } + this.me = userMeResponse; this.requestUpdate(); } @@ -140,65 +105,85 @@ export class TerritoryPatternsModal extends LitElement { } private renderTooltip(): TemplateResult | null { - if (this.hoveredPattern && this.lockedReasons[this.hoveredPattern]) { + if (this.hoveredPattern && this.hoveredPattern.lockedReason) { return html`
- ${this.lockedReasons[this.hoveredPattern]} + ${this.hoveredPattern.lockedReason}
`; } return null; } - private renderPatternButton(key: string): TemplateResult { - const isLocked = this.isPatternLocked(key); - const isSelected = this.selectedPattern === key; - const name = COSMETICS.patterns[key]?.name ?? "custom"; + private renderPatternButton(pattern: Pattern): TemplateResult { + const isSelected = this.selectedPattern === pattern.key; + return html` - +
+ ${translateText(`territory_patterns.pattern.${pattern.name}`)} +
+
+ ${this.renderPatternPreview( + pattern.key, + this.buttonWidth, + this.buttonWidth, + )} +
+ + ${pattern.priceId !== undefined && pattern.lockedReason + ? html` + + ` + : null} + `; } private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; - for (const key in COSMETICS.patterns) { - const value = COSMETICS.patterns[key]; - if (!this.showChocoPattern && value.name === "choco") continue; - const result = this.renderPatternButton(key); + for (const pattern of this.patterns) { + if (!this.showChocoPattern && pattern.name === "choco") continue; + if (pattern.notShown === true) continue; + + const result = this.renderPatternButton(pattern); buttons.push(result); } @@ -267,68 +252,12 @@ export class TerritoryPatternsModal extends LitElement { } private renderPatternPreview( - pattern: string, - width: number, - height: number, + pattern?: string, + width?: number, + height?: number, ): TemplateResult { - const decoder = new PatternDecoder(pattern); - const cellCountX = decoder.getTileWidth(); - const cellCountY = decoder.getTileHeight(); - - const cellSize = - cellCountX > 0 && cellCountY > 0 - ? Math.min(height / cellCountY, width / cellCountX) - : 1; - return html` -
-
- ${(() => { - const tiles: TemplateResult[] = []; - for (let py = 0; py < cellCountY; py++) { - for (let px = 0; px < cellCountX; px++) { - const x = px << decoder.getScale(); - const y = py << decoder.getScale(); - const bit = decoder.isSet(x, y); - tiles.push(html` -
- `); - } - } - return tiles; - })()} -
-
+ `; } @@ -376,10 +305,7 @@ export class TerritoryPatternsModal extends LitElement { public updatePreview() { if (this.previewButton === null) return; - const preview = - this.selectedPattern === undefined - ? this.renderBlankPreview(48, 48) - : this.renderPatternPreview(this.selectedPattern, 48, 48); + const preview = this.renderPatternPreview(this.selectedPattern, 48, 48); render(preview, this.previewButton); } @@ -397,13 +323,9 @@ export class TerritoryPatternsModal extends LitElement { }; } - private isPatternLocked(patternKey: string): boolean { - return this.lockedPatterns.includes(patternKey); - } - - private handleMouseEnter(patternKey: string, event: MouseEvent) { - if (this.isPatternLocked(patternKey)) { - this.hoveredPattern = patternKey; + private handleMouseEnter(pattern: Pattern, event: MouseEvent) { + if (pattern.lockedReason) { + this.hoveredPattern = pattern; this.hoverPosition = { x: event.clientX, y: event.clientY }; } } @@ -418,3 +340,54 @@ export class TerritoryPatternsModal extends LitElement { this.hoveredPattern = null; } } + +const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern +const COLOR_SET = [0, 0, 0, 255]; // Black +const COLOR_UNSET = [255, 255, 255, 255]; // White +export function generatePreviewDataUrl( + pattern?: string, + width?: number, + height?: number, +): string { + // Calculate canvas size + const decoder = new PatternDecoder( + pattern ?? DEFAULT_PATTERN_B64, + base64url.decode, + ); + const scaledWidth = decoder.scaledWidth(); + const scaledHeight = decoder.scaledHeight(); + + width = + width === undefined + ? scaledWidth + : Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth; + height = + height === undefined + ? scaledHeight + : Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight; + + // Create the canvas + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D context not supported"); + + // Create an image + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + let i = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const rgba = decoder.isSet(x, y) ? COLOR_SET : COLOR_UNSET; + data[i++] = rgba[0]; // Red + data[i++] = rgba[1]; // Green + data[i++] = rgba[2]; // Blue + data[i++] = rgba[3]; // Alpha + } + } + + // Create a data URL + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 3c8d7d364..6ae2c7c4b 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -67,6 +67,10 @@ export class SendAllianceReplyIntentEvent implements GameEvent { ) {} } +export class SendAllianceExtensionIntentEvent implements GameEvent { + constructor(public readonly recipient: PlayerView) {} +} + export class SendSpawnIntentEvent implements GameEvent { constructor(public readonly cell: Cell) {} } @@ -194,6 +198,9 @@ export class Transport { this.eventBus.on(SendAllianceReplyIntentEvent, (e) => this.onAllianceRequestReplyUIEvent(e), ); + this.eventBus.on(SendAllianceExtensionIntentEvent, (e) => + this.onSendAllianceExtensionIntent(e), + ); this.eventBus.on(SendBreakAllianceIntentEvent, (e) => this.onBreakAllianceRequestUIEvent(e), ); @@ -419,6 +426,16 @@ export class Transport { }); } + private onSendAllianceExtensionIntent( + event: SendAllianceExtensionIntentEvent, + ) { + this.sendIntent({ + type: "allianceExtension", + clientID: this.lobbyConfig.clientID, + recipient: event.recipient.id(), + }); + } + private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) { this.sendIntent({ type: "spawn", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index c6445c170..88e8fe1b3 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -141,6 +141,7 @@ export function getMessageTypeClasses(type: MessageType): string { case MessageType.SAM_MISS: case MessageType.ALLIANCE_EXPIRED: case MessageType.NAVAL_INVASION_INBOUND: + case MessageType.RENEW_ALLIANCE: return severityColors["warn"]; case MessageType.CHAT: case MessageType.ALLIANCE_REQUEST: diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index f61de2d5a..aa8ddbe4a 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -14,6 +14,8 @@ import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; import { FxLayer } from "./layers/FxLayer"; import { GameLeftSidebar } from "./layers/GameLeftSidebar"; +import { GameRightSidebar } from "./layers/GameRightSidebar"; +import { GameTopBar } from "./layers/GameTopBar"; import { GutterAdModal } from "./layers/GutterAdModal"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; @@ -21,7 +23,6 @@ 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 { RailroadLayer } from "./layers/RailroadLayer"; @@ -33,9 +34,7 @@ import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; -import { TopBar } from "./layers/TopBar"; import { UILayer } from "./layers/UILayer"; -import { UnitInfoModal } from "./layers/UnitInfoModal"; import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; @@ -71,6 +70,7 @@ export function createRenderer( } buildMenu.game = game; buildMenu.eventBus = eventBus; + buildMenu.transformHandler = transformHandler; const leaderboard = document.querySelector("leader-board") as Leaderboard; if (!emojiTable || !(leaderboard instanceof Leaderboard)) { @@ -135,25 +135,28 @@ export function createRenderer( winModal.eventBus = eventBus; winModal.game = game; - const optionsMenu = document.querySelector("options-menu") as OptionsMenu; - if (!(optionsMenu instanceof OptionsMenu)) { - console.error("options menu not found"); - } - optionsMenu.eventBus = eventBus; - optionsMenu.game = game; - const replayPanel = document.querySelector("replay-panel") as ReplayPanel; if (!(replayPanel instanceof ReplayPanel)) { - console.error("ReplayPanel element not found in the DOM"); + console.error("replay panel not found"); } replayPanel.eventBus = eventBus; replayPanel.game = game; - const topBar = document.querySelector("top-bar") as TopBar; - if (!(topBar instanceof TopBar)) { + const gameRightSidebar = document.querySelector( + "game-right-sidebar", + ) as GameRightSidebar; + if (!(gameRightSidebar instanceof GameRightSidebar)) { + console.error("Game Right bar not found"); + } + gameRightSidebar.game = game; + gameRightSidebar.eventBus = eventBus; + + const gameTopBar = document.querySelector("game-top-bar") as GameTopBar; + if (!(gameTopBar instanceof GameTopBar)) { console.error("top bar not found"); } - topBar.game = game; + gameTopBar.game = game; + gameTopBar.eventBus = eventBus; const playerPanel = document.querySelector("player-panel") as PlayerPanel; if (!(playerPanel instanceof PlayerPanel)) { @@ -187,21 +190,7 @@ export function createRenderer( } headsUpMessage.game = game; - const unitInfoModal = document.querySelector( - "unit-info-modal", - ) as UnitInfoModal; - if (!(unitInfoModal instanceof UnitInfoModal)) { - console.error("unit info modal not found"); - } - unitInfoModal.game = game; - const structureLayer = new StructureLayer( - game, - eventBus, - transformHandler, - unitInfoModal, - ); - unitInfoModal.structureLayer = structureLayer; - // unitInfoModal.eventBus = eventBus; + const structureLayer = new StructureLayer(game, eventBus, transformHandler); const spawnAd = document.querySelector("spawn-ad") as SpawnAd; if (!(spawnAd instanceof SpawnAd)) { @@ -248,16 +237,15 @@ export function createRenderer( new SpawnTimer(game, transformHandler), leaderboard, gameLeftSidebar, + gameTopBar, + gameRightSidebar, controlPanel, playerInfo, winModal, - optionsMenu, replayPanel, teamStats, - topBar, playerPanel, headsUpMessage, - unitInfoModal, multiTabModal, spawnAd, gutterAdModal, diff --git a/src/client/graphics/fx/TextFx.ts b/src/client/graphics/fx/TextFx.ts index e853dd0c9..200a2ac1e 100644 --- a/src/client/graphics/fx/TextFx.ts +++ b/src/client/graphics/fx/TextFx.ts @@ -1,14 +1,5 @@ import { Fx } from "./Fx"; -// Shorten a number by replacing thousands with "k" -export function shortenNumber(num: number): string { - if (num >= 1_000) { - return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; - } else { - return num.toString(); - } -} - export class TextFx implements Fx { private lifeTime: number = 0; diff --git a/src/client/graphics/icons/LeaderboardRegularIcon.ts b/src/client/graphics/icons/LeaderboardRegularIcon.ts deleted file mode 100644 index 46f455fb0..000000000 --- a/src/client/graphics/icons/LeaderboardRegularIcon.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -@customElement("leaderboard-regular-icon") -export class LeaderboardRegularIcon extends LitElement { - @property({ type: String }) size = "24"; // Accepts "24", "32", etc. - @property({ type: String }) color = "currentColor"; - - static styles = css` - :host { - display: inline-block; - vertical-align: middle; - } - svg { - display: block; - } - `; - - render() { - return html` - - - - `; - } -} diff --git a/src/client/graphics/icons/LeaderboardSolidIcon.ts b/src/client/graphics/icons/LeaderboardSolidIcon.ts deleted file mode 100644 index 31847cda3..000000000 --- a/src/client/graphics/icons/LeaderboardSolidIcon.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -@customElement("leaderboard-solid-icon") -export class LeaderboardSolidIcon extends LitElement { - @property({ type: String }) size = "24"; // Accepts "24", "32", etc. - @property({ type: String }) color = "currentColor"; - - static styles = css` - :host { - display: inline-block; - vertical-align: middle; - } - svg { - display: block; - } - `; - - render() { - return html` - - - - `; - } -} diff --git a/src/client/graphics/icons/TeamRegularIcon.ts b/src/client/graphics/icons/TeamRegularIcon.ts deleted file mode 100644 index cac1c7acf..000000000 --- a/src/client/graphics/icons/TeamRegularIcon.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -@customElement("team-regular-icon") -export class TeamRegularIcon extends LitElement { - @property({ type: String }) size = "24"; // Accepts "24", "32", etc. - @property({ type: String }) color = "currentColor"; - - static styles = css` - :host { - display: inline-block; - vertical-align: middle; - } - svg { - display: block; - } - `; - - render() { - return html` - - - - `; - } -} diff --git a/src/client/graphics/icons/TeamSolidIcon.ts b/src/client/graphics/icons/TeamSolidIcon.ts deleted file mode 100644 index d4e021e6a..000000000 --- a/src/client/graphics/icons/TeamSolidIcon.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -@customElement("team-solid-icon") -export class TeamSolidIcon extends LitElement { - @property({ type: String }) size = "24"; // Accepts "24", "32", etc. - @property({ type: String }) color = "currentColor"; - - static styles = css` - :host { - display: inline-block; - vertical-align: middle; - } - svg { - display: block; - } - `; - - render() { - return html` - - - - `; - } -} diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index f1cfe17b8..9a6fcc942 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -13,11 +13,27 @@ import missileSiloIcon from "../../../../resources/non-commercial/svg/MissileSil import samlauncherIcon from "../../../../resources/non-commercial/svg/SamLauncherIconWhite.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { Cell, Gold, PlayerActions, UnitType } from "../../../core/game/Game"; +import { + BuildableUnit, + 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 { + CloseViewEvent, + MouseDownEvent, + ShowBuildMenuEvent, + ShowEmojiMenuEvent, +} from "../../InputHandler"; +import { + BuildUnitIntentEvent, + SendUpgradeStructureIntentEvent, +} from "../../Transport"; import { renderNumber } from "../../Utils"; +import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; export interface BuildItemDisplay { @@ -113,6 +129,30 @@ export class BuildMenu extends LitElement implements Layer { private clickedTile: TileRef; public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; + public transformHandler: TransformHandler; + + init() { + this.eventBus.on(ShowBuildMenuEvent, (e) => { + if (!this.game.myPlayer()?.isAlive()) { + return; + } + const clickedCell = this.transformHandler.screenToWorldCoordinates( + e.x, + e.y, + ); + if (clickedCell === null) { + return; + } + if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) { + return; + } + const tile = this.game.ref(clickedCell.x, clickedCell.y); + this.showMenu(tile); + }); + this.eventBus.on(CloseViewEvent, () => this.hideMenu()); + this.eventBus.on(ShowEmojiMenuEvent, () => this.hideMenu()); + this.eventBus.on(MouseDownEvent, () => this.hideMenu()); + } tick() { if (!this._hidden) { @@ -312,7 +352,7 @@ export class BuildMenu extends LitElement implements Layer { @state() private _hidden = true; - public canBuild(item: BuildItemDisplay): boolean { + public canBuildOrUpgrade(item: BuildItemDisplay): boolean { if (this.game?.myPlayer() === null || this.playerActions === null) { return false; } @@ -321,7 +361,7 @@ export class BuildMenu extends LitElement implements Layer { if (unit.length === 0) { return false; } - return unit[0].canBuild !== false; + return unit[0].canBuild !== false || unit[0].canUpgrade !== false; } public cost(item: BuildItemDisplay): Gold { @@ -342,15 +382,24 @@ export class BuildMenu extends LitElement implements Layer { return player.units(item.unitType).length.toString(); } - public onBuildSelected = (item: BuildItemDisplay) => { - this.eventBus.emit( - new BuildUnitIntentEvent( - item.unitType, - new Cell(this.game.x(this.clickedTile), this.game.y(this.clickedTile)), - ), - ); + public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void { + if (buildableUnit.canUpgrade !== false) { + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + buildableUnit.canUpgrade, + buildableUnit.type, + ), + ); + } else if (buildableUnit.canBuild) { + this.eventBus.emit( + new BuildUnitIntentEvent( + buildableUnit.type, + new Cell(this.game.x(tile), this.game.y(tile)), + ), + ); + } this.hideMenu(); - }; + } render() { return html` @@ -361,13 +410,23 @@ export class BuildMenu extends LitElement implements Layer { ${this.filteredBuildTable.map( (row) => html`
- ${row.map( - (item) => html` + ${row.map((item) => { + const buildableUnit = this.playerActions?.buildableUnits.find( + (bu) => bu.type === item.unitType, + ); + if (buildableUnit === undefined) { + return html``; + } + const enabled = + buildableUnit.canBuild !== false || + buildableUnit.canUpgrade !== false; + return html`
` : ""} - `, - )} + `; + })} `, )} diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index cce6c9c54..8929ede8e 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -1,13 +1,11 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { Gold, UnitType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { AttackRatioEvent } from "../../InputHandler"; import { SendSetTargetTroopRatioEvent } from "../../Transport"; -import { renderNumber, renderTroops } from "../../Utils"; +import { renderTroops } from "../../Utils"; import { UIState } from "../UIState"; import { Layer } from "./Layer"; @@ -29,37 +27,13 @@ export class ControlPanel extends LitElement implements Layer { @state() private _population: number; - @state() - private _maxPopulation: number; - - @state() - private popRate: number; - - @state() - private _troops: number; - - @state() - private _workers: number; - @state() private _isVisible = false; @state() private _manpower: number = 0; - @state() - private _gold: Gold; - - @state() - private _goldPerSecond: Gold; - - @state() - private _factories: number; - private _lastPopulationIncreaseRate: number; - - private _popRateIsIncreasing: boolean = true; - private init_: boolean = false; init() { @@ -118,22 +92,13 @@ export class ControlPanel extends LitElement implements Layer { const popIncreaseRate = player.population() - this._population; if (this.game.ticks() % 5 === 0) { - this._popRateIsIncreasing = - popIncreaseRate >= this._lastPopulationIncreaseRate; this._lastPopulationIncreaseRate = popIncreaseRate; } this._population = player.population(); - this._maxPopulation = this.game.config().maxPopulation(player); - this._gold = player.gold(); - this._troops = player.troops(); - this._workers = player.workers(); - this.popRate = this.game.config().populationIncreaseRate(player) * 10; - this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n; this.currentTroopRatio = player.troops() / player.population(); this.requestUpdate(); - this._factories = player.units(UnitType.Factory).length; } onAttackRatioChange(newRatio: number) { @@ -209,51 +174,21 @@ export class ControlPanel extends LitElement implements Layer {
e.preventDefault()} > - -
- +
last checked at tick + private alliancesCheckedAt = new Map(); @state() private incomingAttacks: AttackUpdate[] = []; @state() private outgoingAttacks: AttackUpdate[] = []; @state() private outgoingLandAttacks: AttackUpdate[] = []; @@ -152,6 +154,7 @@ export class EventsDisplay extends LitElement implements Layer { [GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)], [GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)], [GameUpdateType.UnitIncoming, (u) => this.onUnitIncomingEvent(u)], + [GameUpdateType.AllianceExpired, (u) => this.onAllianceExpiredEvent(u)], ]); constructor() { @@ -181,6 +184,8 @@ export class EventsDisplay extends LitElement implements Layer { return; } + this.checkForAllianceExpirations(); + const updates = this.game.updatesSinceLastTick(); if (updates) { for (const [ut, fn] of this.updateMap) { @@ -234,6 +239,64 @@ export class EventsDisplay extends LitElement implements Layer { } } + private checkForAllianceExpirations() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + + for (const alliance of myPlayer.alliances()) { + if ( + alliance.expiresAt > + this.game.ticks() + this.game.config().allianceExtensionPromptOffset() + ) { + continue; + } + + if ( + (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= + this.game.ticks() - this.game.config().allianceExtensionPromptOffset() + ) { + // We've already displayed a message for this alliance. + continue; + } + + this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); + + const other = this.game.player(alliance.other) as PlayerView; + + this.addEvent({ + description: translateText("events_display.about_to_expire", { + name: other.name(), + }), + type: MessageType.RENEW_ALLIANCE, + duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer + buttons: [ + { + text: translateText("events_display.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(other)), + preventClose: true, + }, + { + text: translateText("events_display.renew_alliance", { + name: other.name(), + }), + className: "btn", + action: () => + this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), + }, + { + text: translateText("events_display.ignore"), + className: "btn-info", + action: () => {}, + }, + ], + highlight: true, + createdAt: this.game.ticks(), + focusID: other.smallID(), + }); + } + } + private addEvent(event: GameEvent) { this.events = [...this.events, event]; if (this._hidden === true) { diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index f936dc3ce..9e8057073 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -6,11 +6,12 @@ import { RailroadUpdate, } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; +import { renderNumber } from "../../Utils"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "../fx/Fx"; import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; import { SpriteFx } from "../fx/SpriteFx"; -import { shortenNumber, TextFx } from "../fx/TextFx"; +import { TextFx } from "../fx/TextFx"; import { UnitExplosionFx } from "../fx/UnitExplosionFx"; import { Layer } from "./Layer"; export class FxLayer implements Layer { @@ -69,25 +70,25 @@ export class FxLayer implements Layer { const workers = bonus.workers; if (gold > 0) { - const shortened = shortenNumber(gold); - this.addTextFx(`+ ${shortened} gold`, x, y); + const shortened = renderNumber(gold); + this.addTextFx(`+ ${shortened}`, x, y); y += 10; // increase y so the next popup starts bellow } if (troops > 0) { - const shortened = shortenNumber(troops); + const shortened = renderNumber(troops); this.addTextFx(`+ ${shortened} troops`, x, y); y += 10; } if (workers > 0) { - const shortened = shortenNumber(workers); + const shortened = renderNumber(workers); this.addTextFx(`+ ${shortened} workers`, x, y); } } addTextFx(text: string, x: number, y: number) { - const textFx = new TextFx(text, x, y, 500, 20); + const textFx = new TextFx(text, x, y, 1000, 20); this.allFx.push(textFx); } diff --git a/src/client/graphics/layers/GameLeftSidebar.ts b/src/client/graphics/layers/GameLeftSidebar.ts index 2f4bb2f6f..a645ca564 100644 --- a/src/client/graphics/layers/GameLeftSidebar.ts +++ b/src/client/graphics/layers/GameLeftSidebar.ts @@ -1,12 +1,13 @@ import { Colord } from "colord"; import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; +import leaderboardRegularIcon from "../../../../resources/images/LeaderboardIconRegularWhite.svg"; +import leaderboardSolidIcon from "../../../../resources/images/LeaderboardIconSolidWhite.svg"; +import teamRegularIcon from "../../../../resources/images/TeamIconRegularWhite.svg"; +import teamSolidIcon from "../../../../resources/images/TeamIconSolidWhite.svg"; import { GameMode } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; -import "../icons/LeaderboardRegularIcon"; -import "../icons/LeaderboardSolidIcon"; -import "../icons/TeamRegularIcon"; -import "../icons/TeamSolidIcon"; +import { translateText } from "../../Utils"; import { Layer } from "./Layer"; @customElement("game-left-sidebar") @@ -66,10 +67,17 @@ export class GameLeftSidebar extends LitElement implements Layer { return this.game?.config().gameConfig().gameMode === GameMode.Team; } + private getTranslatedPlayerTeamLabel(): string { + if (!this.playerTeam) return ""; + const translationKey = `team_colors.${this.playerTeam.toLowerCase()}`; + const translated = translateText(translationKey); + return translated === translationKey ? this.playerTeam : translated; + } + render() { return html`
` : null} -
+
- ${this.isLeaderboardShow - ? html` ` - : html` `} + treeIcon
${this.isTeamGame ? html` @@ -98,9 +115,14 @@ export class GameLeftSidebar extends LitElement implements Layer { class="w-6 h-6 cursor-pointer" @click=${this.toggleTeamLeaderboard} > - ${this.isTeamLeaderboardShow - ? html` ` - : html` `} + treeIcon
` : null} diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts new file mode 100644 index 000000000..7363efc60 --- /dev/null +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -0,0 +1,139 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import exitIcon from "../../../../resources/images/ExitIconWhite.svg"; +import pauseIcon from "../../../../resources/images/PauseIconWhite.svg"; +import playIcon from "../../../../resources/images/PlayIconWhite.svg"; +import replayRegularIcon from "../../../../resources/images/ReplayRegularIconWhite.svg"; +import replaySolidIcon from "../../../../resources/images/ReplaySolidIconWhite.svg"; +import { EventBus } from "../../../core/EventBus"; +import { GameType } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { PauseGameEvent } from "../../Transport"; +import { Layer } from "./Layer"; + +@customElement("game-right-sidebar") +export class GameRightSidebar extends LitElement implements Layer { + public game: GameView; + public eventBus: EventBus; + @state() + private _isSinglePlayer: boolean = false; + + @state() + private _isReplayVisible: boolean = false; + + @state() + private _isVisible: boolean = true; + + @state() + private isPaused: boolean = false; + + @state() + private isExistButtonVisible: boolean = true; + + createRenderRoot() { + return this; + } + + init() { + this._isSinglePlayer = + this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer || + this.game.config().isReplay(); + this._isVisible = true; + this.game.inSpawnPhase(); + this.requestUpdate(); + } + + tick() { + if (!this.game.inSpawnPhase()) { + this.isExistButtonVisible = false; + } + } + + private toggleReplayPanel(): void { + this._isReplayVisible = !this._isReplayVisible; + } + + private onPauseButtonClick() { + this.isPaused = !this.isPaused; + this.eventBus.emit(new PauseGameEvent(this.isPaused)); + } + + private onExitButtonClick() { + const isAlive = this.game.myPlayer()?.isAlive(); + if (isAlive) { + const isConfirmed = confirm("Are you sure you want to exit the game?"); + if (!isConfirmed) return; + } + // redirect to the home page + window.location.href = "/"; + } + + render() { + return html` + + `; + } +} diff --git a/src/client/graphics/layers/GameTopBar.ts b/src/client/graphics/layers/GameTopBar.ts new file mode 100644 index 000000000..0b2109ac7 --- /dev/null +++ b/src/client/graphics/layers/GameTopBar.ts @@ -0,0 +1,471 @@ +import { html, LitElement } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import cityIcon from "../../../../resources/images/CityIconWhite.svg"; +import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg"; +import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; +import exitIcon from "../../../../resources/images/ExitIconWhite.svg"; +import explosionIcon from "../../../../resources/images/ExplosionIconWhite.svg"; +import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg"; +import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg"; +import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png"; +import mouseIcon from "../../../../resources/images/MouseIconWhite.svg"; +import ninjaIcon from "../../../../resources/images/NinjaIconWhite.svg"; +import populationIcon from "../../../../resources/images/PopulationIconSolidWhite.svg"; +import portIcon from "../../../../resources/images/PortIcon.svg"; +import samLauncherIcon from "../../../../resources/images/SamLauncherUnitWhite.png"; +import settingsIcon from "../../../../resources/images/SettingIconWhite.svg"; +import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg"; +import treeIcon from "../../../../resources/images/TreeIconWhite.svg"; +import troopIcon from "../../../../resources/images/TroopIconWhite.svg"; +import workerIcon from "../../../../resources/images/WorkerIconWhite.svg"; +import { translateText } from "../../../client/Utils"; +import { EventBus } from "../../../core/EventBus"; +import { UnitType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { GameView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; +import { renderNumber, renderTroops } from "../../Utils"; +import { Layer } from "./Layer"; + +@customElement("game-top-bar") +export class GameTopBar extends LitElement implements Layer { + public game: GameView; + public eventBus: EventBus; + private _userSettings: UserSettings = new UserSettings(); + private _population = 0; + private _troops = 0; + private _cities = 0; + private _factories = 0; + private _workers = 0; + private _missileSilo = 0; + private _port = 0; + private _defensePost = 0; + private _samLauncher = 0; + private _lastPopulationIncreaseRate = 0; + private _popRateIsIncreasing = false; + private hasWinner = false; + + @state() + private showSettingsMenu = false; + @state() + private alternateView: boolean = false; + + @state() + private timer: number = 0; + + @query(".settings-container") + private settingsContainer!: HTMLElement; + + createRenderRoot() { + return this; + } + + init() { + this.requestUpdate(); + } + + tick() { + this.updatePopulationIncrease(); + const player = this.game?.myPlayer(); + if (!player) return; + this._troops = player.troops(); + this._workers = player.workers(); + this._cities = player.totalUnitLevels(UnitType.City); + this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo); + this._port = player.totalUnitLevels(UnitType.Port); + this._defensePost = player.totalUnitLevels(UnitType.DefensePost); + this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher); + this._factories = player.totalUnitLevels(UnitType.Factory); + const updates = this.game.updatesSinceLastTick(); + if (updates) { + this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; + } + if (this.game.inSpawnPhase()) { + this.timer = 0; + } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { + this.timer++; + } + this.requestUpdate(); + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener("click", this.handleOutsideClick, true); + } + + disconnectedCallback() { + window.removeEventListener("click", this.handleOutsideClick, true); + super.disconnectedCallback(); + } + private handleOutsideClick = (event: MouseEvent) => { + if ( + this.showSettingsMenu && + this.settingsContainer && + !this.settingsContainer.contains(event.target as Node) + ) { + this.showSettingsMenu = false; + } + }; + + private onExitButtonClick() { + const isAlive = this.game.myPlayer()?.isAlive(); + if (isAlive) { + const isConfirmed = confirm("Are you sure you want to exit the game?"); + if (!isConfirmed) return; + } + // redirect to the home page + window.location.href = "/"; + } + + private onTerrainButtonClick() { + this.alternateView = !this.alternateView; + this.eventBus.emit(new AlternateViewEvent(this.alternateView)); + this.requestUpdate(); + } + + private onToggleEmojisButtonClick() { + this._userSettings.toggleEmojis(); + this.requestUpdate(); + } + + private onToggleSpecialEffectsButtonClick() { + this._userSettings.toggleFxLayer(); + this.requestUpdate(); + } + + private onToggleDarkModeButtonClick() { + this._userSettings.toggleDarkMode(); + this.requestUpdate(); + this.eventBus.emit(new RefreshGraphicsEvent()); + } + + private onToggleRandomNameModeButtonClick() { + this._userSettings.toggleRandomName(); + } + private onToggleLeftClickOpensMenu() { + this._userSettings.toggleLeftClickOpenMenu(); + } + + private toggleSettingsMenu() { + this.showSettingsMenu = !this.showSettingsMenu; + } + + private updatePopulationIncrease() { + const player = this.game?.myPlayer(); + if (player === null) return; + const popIncreaseRate = player.population() - this._population; + if (this.game.ticks() % 5 === 0) { + this._popRateIsIncreasing = + popIncreaseRate >= this._lastPopulationIncreaseRate; + this._lastPopulationIncreaseRate = popIncreaseRate; + } + } + + private secondsToHms = (d: number): string => { + const h = Math.floor(d / 3600); + const m = Math.floor((d % 3600) / 60); + const s = Math.floor((d % 3600) % 60); + let time = d === 0 ? "-" : `${s}s`; + if (m > 0) time = `${m}m` + time; + if (h > 0) time = `${h}h` + time; + return time; + }; + + render() { + const myPlayer = this.game?.myPlayer(); + if (!this.game || !myPlayer || this.game.inSpawnPhase()) { + return null; + } + + const isAlt = this.game.config().isReplay(); + if (isAlt) { + return html` +
+
+ ${this.secondsToHms(this.timer)} +
+
+ `; + } + const popRate = myPlayer + ? this.game.config().populationIncreaseRate(myPlayer) * 10 + : 0; + const maxPop = myPlayer ? this.game.config().maxPopulation(myPlayer) : 0; + const goldPerSecond = myPlayer + ? this.game.config().goldAdditionRate(myPlayer) * 10n + : 0n; + + return html` +
+
+ ${myPlayer?.isAlive() && !this.game.inSpawnPhase() + ? html` +
+
+
+
+ gold + +${renderNumber(goldPerSecond)} +
+
${renderNumber(myPlayer.gold())}
+
+
+
+ population + + +${renderTroops(popRate)} + +
+
+ ${renderTroops(myPlayer.population())} / + ${renderTroops(maxPop)} +
+
+
+
+
+ troops + ${renderTroops(this._troops)} +
+
+ gold + ${renderTroops(this._workers)} +
+
+
+
+
+ gold + ${renderNumber(this._cities)} +
+
+ gold + ${renderNumber(this._factories)} +
+
+ gold + ${renderNumber(this._port)} +
+
+ gold + ${renderNumber(this._defensePost)} +
+
+ gold + ${renderNumber(this._missileSilo)} +
+
+ gold + ${renderNumber(this._samLauncher)} +
+
+
+
+ ` + : html`
`} +
+
+ ${this.secondsToHms(this.timer)} +
+
+ settings + ${this.showSettingsMenu + ? html` +
+ + + + + + + +
+ ` + : null} +
+
+
+
+ `; + } +} diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 5c1e206d9..95881a898 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -34,8 +34,8 @@ export class HeadsUpMessage extends LitElement implements Layer { return html`
e.preventDefault()} diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index a1492d79f..dd66e8090 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -187,7 +187,9 @@ export class Leaderboard extends LitElement implements Layer { this.updateLeaderboard(); }} > - ${this.showTopFive ? "Show All" : "Show Top 5"} + ${this.showTopFive + ? translateText("leaderboard.show_all") + : translateText("leaderboard.show_top_5")}
this.closeMenu(), + eventBus: this.eventBus, }; this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement); diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index bb5056b3b..6b7af3197 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -198,8 +198,8 @@ export class NameLayer implements Layer { element.style.aspectRatio = "3/4"; }; - if (player.flag()) { - const flag = player.flag(); + if (player.cosmetics.flag) { + const flag = player.cosmetics.flag; if (flag !== undefined && flag !== null && flag.startsWith("!")) { const flagWrapper = document.createElement("div"); applyFlagStyles(flagWrapper); diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 3c4bc5937..42eff7ba7 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -1,14 +1,8 @@ import { EventBus } from "../../../core/EventBus"; -import { - Cell, - PlayerActions, - PlayerID, - UnitType, -} from "../../../core/game/Game"; +import { Cell, PlayerActions, PlayerID } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { - BuildUnitIntentEvent, SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, @@ -67,13 +61,6 @@ export class PlayerActionHandler { ): 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)); } diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 5ae2c81a0..161d40e7f 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -207,21 +207,21 @@ export class PlayerInfoOverlay extends LitElement implements Layer { ? "text-green-500" : "text-white"}" > - ${player.flag() - ? player.flag()!.startsWith("!") + ${player.cosmetics.flag + ? player.cosmetics.flag!.startsWith("!") ? html`
{ if (el instanceof HTMLElement) { requestAnimationFrame(() => { - renderPlayerFlag(player.flag()!, el); + renderPlayerFlag(player.cosmetics.flag!, el); }); } })} >
` : html`` : html``} ${player.name()} @@ -352,11 +352,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html`
e.preventDefault()} >
${this.player !== null ? this.renderPlayerInfo(this.player) : ""} ${this.unit !== null ? this.renderUnitInfo(this.unit) : ""} diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index b9804d2d1..ef8819a76 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -176,11 +176,9 @@ export class PlayerPanel extends LitElement implements Layer { if (myPlayer !== null && myPlayer.isAlive()) { this.actions = await myPlayer.actions(this.tile); - if (this.actions?.interaction?.allianceCreatedAtTick !== undefined) { - const createdAt = this.actions.interaction.allianceCreatedAtTick; - const durationTicks = this.g.config().allianceDuration(); - const expiryTick = createdAt + durationTicks; - const remainingTicks = expiryTick - this.g.ticks(); + if (this.actions?.interaction?.allianceExpiresAt !== undefined) { + const expiresAt = this.actions.interaction.allianceExpiresAt; + const remainingTicks = expiresAt - this.g.ticks(); if (remainingTicks > 0) { const remainingSeconds = Math.max( diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 199f28cf6..fa4a35b83 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -381,36 +381,6 @@ export class RadialMenu implements Layer { path.attr("filter", "url(#glow)"); path.attr("stroke-width", "3"); - const color = disabled - ? this.config.disabledColor - : d.data.color || "#333333"; - path.attr("fill", color); - - const subMenu = - this.params !== null ? d.data.subMenu?.(this.params) : null; - if ( - subMenu && - subMenu.length > 0 && - !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(subMenu); - this.updateCenterButtonState("back"); - }, 200); - } }; const onMouseOut = (d: d3.PieArcDatum, path: any) => { diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 76e7a6978..aafd42fad 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -1,3 +1,4 @@ +import { Config } from "../../../core/configuration/Config"; import { AllPlayers, Cell, @@ -25,6 +26,7 @@ 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"; +import { EventBus } from "../../../core/EventBus"; export interface MenuElementParams { myPlayer: PlayerView; @@ -37,6 +39,7 @@ export interface MenuElementParams { playerActionHandler: PlayerActionHandler; playerPanel: PlayerPanel; chatIntegration: ChatIntegration; + eventBus: EventBus; closeMenu: () => void; } @@ -322,6 +325,32 @@ export const infoMenuElement: MenuElement = { }, }; +function getAllEnabledUnits(myPlayer: boolean, config: Config): Set { + const Units: Set = new Set(); + + const addStructureIfEnabled = (unitType: UnitType) => { + if (!config.isUnitDisabled(unitType)) { + Units.add(unitType); + } + }; + + if (myPlayer) { + addStructureIfEnabled(UnitType.City); + addStructureIfEnabled(UnitType.DefensePost); + addStructureIfEnabled(UnitType.Port); + addStructureIfEnabled(UnitType.MissileSilo); + addStructureIfEnabled(UnitType.SAMLauncher); + addStructureIfEnabled(UnitType.Factory); + } else { + addStructureIfEnabled(UnitType.Warship); + addStructureIfEnabled(UnitType.HydrogenBomb); + addStructureIfEnabled(UnitType.MIRV); + addStructureIfEnabled(UnitType.AtomBomb); + } + + return Units; +} + export const buildMenuElement: MenuElement = { id: Slot.Build, name: "build", @@ -332,21 +361,10 @@ export const buildMenuElement: MenuElement = { subMenu: (params: MenuElementParams) => { if (params === undefined) return []; - const unitTypes: Set = new Set(); - if (params.selected === params.myPlayer) { - unitTypes.add(UnitType.City); - unitTypes.add(UnitType.DefensePost); - unitTypes.add(UnitType.Port); - unitTypes.add(UnitType.MissileSilo); - unitTypes.add(UnitType.SAMLauncher); - unitTypes.add(UnitType.Factory); - } else { - unitTypes.add(UnitType.Warship); - unitTypes.add(UnitType.HydrogenBomb); - unitTypes.add(UnitType.MIRV); - unitTypes.add(UnitType.AtomBomb); - } - + const unitTypes: Set = getAllEnabledUnits( + params.selected === params.myPlayer, + params.game.config(), + ); const buildElements: MenuElement[] = flattenedBuildTable .filter((item) => unitTypes.has(item.unitType)) .map((item: BuildItemDisplay) => ({ @@ -355,8 +373,10 @@ export const buildMenuElement: MenuElement = { ? item.key.replace("unit_type.", "") : item.unitType.toString(), disabled: (params: MenuElementParams) => - !params.buildMenu.canBuild(item), - color: params.buildMenu.canBuild(item) ? COLORS.building : undefined, + !params.buildMenu.canBuildOrUpgrade(item), + color: params.buildMenu.canBuildOrUpgrade(item) + ? COLORS.building + : undefined, icon: item.icon, tooltipItems: [ { text: translateText(item.key || ""), className: "title" }, @@ -373,11 +393,15 @@ export const buildMenuElement: MenuElement = { : 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), + const buildableUnit = params.playerActions.buildableUnits.find( + (bu) => bu.type === item.unitType, ); + if (buildableUnit === undefined) { + return; + } + if (params.buildMenu.canBuildOrUpgrade(item)) { + params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile); + } params.closeMenu(); }, })); diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index 622b233ff..0303485c9 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -1,7 +1,6 @@ import { html, LitElement } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; -import { GameType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { ReplaySpeedChangeEvent } from "../../InputHandler"; import { @@ -16,27 +15,26 @@ export class ReplayPanel extends LitElement implements Layer { public game: GameView | undefined; public eventBus: EventBus | undefined; + @property({ type: Boolean }) + visible: boolean = false; + @state() private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier; - private _isSinglePlayer: boolean = false; - @state() - private _isVisible = false; + @property({ type: Boolean }) + isSingleplayer = false; - init() { - this._isSinglePlayer = - this.game?.config().gameConfig().gameType === GameType.Singleplayer; - if (this._isSinglePlayer) { - this.setVisible(true); - } + createRenderRoot() { + return this; // Enable Tailwind CSS } - tick() { - if (!this._isVisible && this.game?.config().isReplay()) { - this.setVisible(true); - } + init() {} - this.requestUpdate(); + tick() { + if (!this.visible) return; + if (this.game!.ticks() % 10 === 0) { + this.requestUpdate(); + } } onReplaySpeedChange(value: ReplaySpeedMultiplier) { @@ -44,85 +42,45 @@ export class ReplayPanel extends LitElement implements Layer { this.eventBus?.emit(new ReplaySpeedChangeEvent(value)); } - renderLayer(context: CanvasRenderingContext2D) { - // Render any necessary canvas elements - } - - shouldTransform(): boolean { + renderLayer(_ctx: CanvasRenderingContext2D) {} + shouldTransform() { return false; } - setVisible(visible: boolean) { - this._isVisible = visible; - this.requestUpdate(); - } - render() { - if (!this._isVisible) { - return html``; - } + if (!this.visible) return html``; return html`
e.preventDefault()} + @contextmenu=${(e: Event) => e.preventDefault()} >
- - - - + ${this.renderSpeedButton(ReplaySpeedMultiplier.slow, "×0.5")} + ${this.renderSpeedButton(ReplaySpeedMultiplier.normal, "×1")} + ${this.renderSpeedButton(ReplaySpeedMultiplier.fast, "×2")} + ${this.renderSpeedButton(ReplaySpeedMultiplier.fastest, "max")}
`; } - createRenderRoot() { - return this; // Disable shadow DOM to allow Tailwind styles + private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) { + const isActive = this._replaySpeedMultiplier === value; + return html` + + `; } } diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts index 081dbcb0b..c3a5f97cc 100644 --- a/src/client/graphics/layers/SpawnTimer.ts +++ b/src/client/graphics/layers/SpawnTimer.ts @@ -16,8 +16,11 @@ export class SpawnTimer implements Layer { tick() { if (this.game.inSpawnPhase()) { - this.ratios[0] = - this.game.ticks() / this.game.config().numSpawnPhaseTurns(); + // During spawn phase, only one segment filling full width + this.ratios = [ + this.game.ticks() / this.game.config().numSpawnPhaseTurns(), + ]; + this.colors = ["rgba(0, 128, 255, 0.7)"]; return; } @@ -33,18 +36,17 @@ export class SpawnTimer implements Layer { const team = player.team(); if (team === null) throw new Error("Team is null"); const tiles = teamTiles.get(team) ?? 0; - const sum = tiles + player.numTilesOwned(); - teamTiles.set(team, sum); + teamTiles.set(team, tiles + player.numTilesOwned()); } const theme = this.game.config().theme(); const total = sumIterator(teamTiles.values()); if (total === 0) return; + for (const [team, count] of teamTiles) { const ratio = count / total; - const color = theme.teamColor(team).toRgbString(); this.ratios.push(ratio); - this.colors.push(color); + this.colors.push(theme.teamColor(team).toRgbString()); } } @@ -53,12 +55,23 @@ export class SpawnTimer implements Layer { } renderLayer(context: CanvasRenderingContext2D) { - if (this.ratios === null) return; - if (this.ratios.length === 0) return; - if (this.colors.length === 0) return; + if (this.ratios.length === 0 || this.colors.length === 0) return; const barHeight = 10; const barWidth = this.transformHandler.width(); + let yOffset: number; + + if (this.game.inSpawnPhase()) { + // At spawn time, draw at top + yOffset = 0; + } else if (this.game.config().gameConfig().gameMode === GameMode.Team) { + // After spawn, only in team mode, offset based on screen width + const screenW = window.innerWidth; + yOffset = screenW > 1024 ? 80 : 58; + } else { + // Not spawn and not team mode: no bar + return; + } let x = 0; let filledRatio = 0; @@ -67,7 +80,7 @@ export class SpawnTimer implements Layer { const segmentWidth = barWidth * ratio; context.fillStyle = this.colors[i]; - context.fillRect(x, 0, segmentWidth, barHeight); + context.fillRect(x, yOffset, segmentWidth, barHeight); x += segmentWidth; filledRatio += ratio; @@ -76,8 +89,6 @@ export class SpawnTimer implements Layer { } function sumIterator(values: MapIterator) { - // To use reduce, we'd need to allocate an array: - // return Array.from(values).reduce((sum, v) => sum + v, 0); let total = 0; for (const value of values) { total += value; diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index ffdf680a5..708ced3b7 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -1,4 +1,5 @@ import * as PIXI from "pixi.js"; +import bitmapFont from "../../../../resources/fonts/round_6x6_modified.xml"; import anchorIcon from "../../../../resources/images/AnchorIcon.png"; import cityIcon from "../../../../resources/images/CityIcon.png"; import factoryIcon from "../../../../resources/images/FactoryUnit.png"; @@ -17,16 +18,20 @@ class StructureRenderInfo { constructor( public unit: UnitView, public owner: PlayerID, - public pixiSprite: PIXI.Sprite, + public iconContainer: PIXI.Container, + public levelContainer: PIXI.Container, + public level: number = 0, + public underConstruction: boolean = true, ) {} } -const ZOOM_THRESHOLD = 2.8; // below this zoom level, structures are not rendered +const ZOOM_THRESHOLD = 2.5; const ICON_SIZE = 24; -const OFFSET_ZOOM_Y = 15; // offset for the y position of the icon to avoid hiding the structure beneath +const OFFSET_ZOOM_Y = 5; // offset for the y position of the icon to avoid hiding the structure beneath export class StructureIconsLayer implements Layer { private pixicanvas: HTMLCanvasElement; - private stage: PIXI.Container; + private iconsStage: PIXI.Container; + private levelsStage: PIXI.Container; private shouldRedraw: boolean = true; private textureCache: Map = new Map(); private theme: Theme; @@ -54,14 +59,26 @@ export class StructureIconsLayer implements Layer { } async setupRenderer() { + try { + await PIXI.Assets.load(bitmapFont); + } catch (error) { + console.error("Failed to load bitmap font:", error); + } this.renderer = new PIXI.WebGLRenderer(); this.pixicanvas = document.createElement("canvas"); this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; - this.stage = new PIXI.Container(); - this.stage.position.set(0, 0); - this.stage.width = this.pixicanvas.width; - this.stage.height = this.pixicanvas.height; + + this.iconsStage = new PIXI.Container(); + this.iconsStage.position.set(0, 0); + this.iconsStage.width = this.pixicanvas.width; + this.iconsStage.height = this.pixicanvas.height; + + this.levelsStage = new PIXI.Container(); + this.levelsStage.position.set(0, 0); + this.levelsStage.width = this.pixicanvas.width; + this.levelsStage.height = this.pixicanvas.height; + await this.renderer.init({ canvas: this.pixicanvas, resolution: 1, @@ -103,7 +120,7 @@ export class StructureIconsLayer implements Layer { } resizeCanvas() { - if (this.renderer.view) { + if (this.renderer) { this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; this.renderer.resize(innerWidth, innerHeight, 1); @@ -119,47 +136,84 @@ export class StructureIconsLayer implements Layer { if (unitView === undefined) return; if (unitView.isActive()) { - if (this.seenUnits.has(unitView)) { - // check if owner has changed - const render = this.renders.find( - (r) => r.unit.id() === unitView.id(), - ); - if (render) { - this.ownerChangeCheck(render, unitView); - } - } else if (this.structures.has(unitView.type())) { - // new unit, create render info - this.seenUnits.add(unitView); - const render = new StructureRenderInfo( - unitView, - unitView.owner().id(), - this.createPixiSprite(unitView), - ); - this.renders.push(render); - this.computeNewLocation(render); - this.shouldRedraw = true; - } - } - - if (!unitView.isActive() && this.seenUnits.has(unitView)) { - const render = this.renders.find( - (r) => r.unit.id() === unitView.id(), - ); - if (render) { - this.deleteStructure(render); - } - this.shouldRedraw = true; - return; + this.handleActiveUnit(unitView); + } else if (this.seenUnits.has(unitView)) { + this.handleInactiveUnit(unitView); } }); } + private findRenderByUnit( + unitView: UnitView, + ): StructureRenderInfo | undefined { + return this.renders.find((render) => render.unit.id() === unitView.id()); + } + + private handleActiveUnit(unitView: UnitView) { + if (this.seenUnits.has(unitView)) { + const render = this.findRenderByUnit(unitView); + if (render) { + this.checkForConstructionState(render, unitView); + this.checkForOwnershipChange(render, unitView); + this.checkForLevelChange(render, unitView); + } + } else if ( + this.structures.has(unitView.type()) || + unitView.type() === UnitType.Construction + ) { + this.addNewStructure(unitView); + } + } + + private handleInactiveUnit(unitView: UnitView) { + const render = this.findRenderByUnit(unitView); + if (render) { + this.deleteStructure(render); + this.shouldRedraw = true; + } + } + + private checkForConstructionState( + render: StructureRenderInfo, + unit: UnitView, + ) { + if ( + render.underConstruction && + render.unit.type() !== UnitType.Construction + ) { + render.underConstruction = false; + render.iconContainer?.destroy(); + render.iconContainer = this.createIconSprite(unit); + this.shouldRedraw = true; + } + } + + private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) { + if (render.owner !== unit.owner().id()) { + render.owner = unit.owner().id(); + render.iconContainer?.destroy(); + render.iconContainer = this.createIconSprite(unit); + this.shouldRedraw = true; + } + } + + private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) { + if (render.level !== unit.level()) { + render.level = unit.level(); + render.iconContainer?.destroy(); + render.levelContainer?.destroy(); + render.iconContainer = this.createIconSprite(unit); + render.levelContainer = this.createLevelSprite(unit); + this.shouldRedraw = true; + } + } + redraw() { this.resizeCanvas(); } renderLayer(mainContext: CanvasRenderingContext2D) { - if (!this.renderer || this.transformHandler.scale > ZOOM_THRESHOLD) { + if (!this.renderer) { return; } @@ -170,38 +224,52 @@ export class StructureIconsLayer implements Layer { } if (this.transformHandler.hasChanged() || this.shouldRedraw) { - this.renderer.render(this.stage); + if (this.transformHandler.scale > ZOOM_THRESHOLD) { + this.renderer.render(this.levelsStage); + } else { + this.renderer.render(this.iconsStage); + } this.shouldRedraw = false; } mainContext.drawImage(this.renderer.canvas, 0, 0); } - private ownerChangeCheck(render: StructureRenderInfo, unit: UnitView) { - if (render.owner !== unit.owner().id()) { - render.owner = unit.owner().id(); - render.pixiSprite?.destroy(); - render.pixiSprite = this.createPixiSprite(unit); - this.shouldRedraw = true; - } - } - private createTexture(unit: UnitView): PIXI.Texture { - const cacheKey = `${unit.owner().id()}-${unit.type()}`; + const isConstruction = unit.type() === UnitType.Construction; + const constructionType = unit.constructionType(); + if (isConstruction && constructionType === undefined) { + console.warn( + `Unit ${unit.id()} is a construction but has no construction type.`, + ); + return PIXI.Texture.EMPTY; + } + const structureType = isConstruction ? constructionType! : unit.type(); + const cacheKey = isConstruction + ? `construction-${structureType}` + : `${unit.owner().id()}-${structureType}`; if (this.textureCache.has(cacheKey)) { return this.textureCache.get(cacheKey)!; } + const structureCanvas = document.createElement("canvas"); structureCanvas.width = ICON_SIZE; structureCanvas.height = ICON_SIZE; const context = structureCanvas.getContext("2d")!; - context.fillStyle = this.theme - .territoryColor(unit.owner()) - .lighten(0.1) - .toRgbString(); - const borderColor = this.theme - .borderColor(unit.owner()) - .darken(0.2) - .toRgbString(); + + let borderColor: string; + if (isConstruction) { + context.fillStyle = "rgb(198, 198, 198)"; + borderColor = "rgb(128, 127, 127)"; + } else { + context.fillStyle = this.theme + .territoryColor(unit.owner()) + .lighten(0.06) + .toRgbString(); + borderColor = this.theme + .borderColor(unit.owner()) + .darken(0.08) + .toRgbString(); + } context.strokeStyle = borderColor; context.beginPath(); context.arc( @@ -214,9 +282,9 @@ export class StructureIconsLayer implements Layer { context.fill(); context.lineWidth = 1; context.stroke(); - const structureInfo = this.structures.get(unit.type()); + const structureInfo = this.structures.get(structureType); if (!structureInfo?.image) { - console.warn(`Image not loaded for unit type: ${unit.type()}`); + console.warn(`Image not loaded for unit type: ${structureType}`); return PIXI.Texture.from(structureCanvas); } context.drawImage( @@ -229,20 +297,65 @@ export class StructureIconsLayer implements Layer { return texture; } - private createPixiSprite(unit: UnitView): PIXI.Sprite { - const sprite = new PIXI.Sprite(this.createTexture(unit)); - sprite.anchor.set(0.5, 0.5); + private createLevelSprite(unit: UnitView): PIXI.Container { + return this.createUnitContainer(unit, { + addIcon: false, + stage: this.levelsStage, + }); + } + + private createIconSprite(unit: UnitView): PIXI.Container { + return this.createUnitContainer(unit, { + addIcon: true, + stage: this.iconsStage, + }); + } + + private createUnitContainer( + unit: UnitView, + options: { addIcon?: boolean; stage: PIXI.Container }, + ): PIXI.Container { + const parentContainer = new PIXI.Container(); const tile = unit.tile(); const worldX = this.game.x(tile); const worldY = this.game.y(tile); const screenPos = this.transformHandler.worldToScreenCoordinates( new Cell(worldX, worldY), ); - sprite.x = screenPos.x; - sprite.y = screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y; - sprite.scale.set(Math.min(1, this.transformHandler.scale)); - this.stage.addChild(sprite); - return sprite; + + if (options.addIcon) { + const sprite = new PIXI.Sprite(this.createTexture(unit)); + sprite.anchor.set(0.5, 0.5); + parentContainer.addChild(sprite); + } + + if (unit.level() > 1) { + const text = new PIXI.BitmapText({ + text: unit.level().toString(), + style: { + fontFamily: "round_6x6_modified", + fontSize: 12, + }, + }); + text.anchor.set(0.5, 0.5); + text.position.y = -ICON_SIZE / 2 - 2; + parentContainer.addChild(text); + } + + const posX = Math.round(screenPos.x); + let posY = Math.round(screenPos.y); + + if (this.transformHandler.scale >= ZOOM_THRESHOLD) { + posY = Math.round( + screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y, + ); + } + + parentContainer.position.set(posX, posY); + parentContainer.scale.set(Math.min(1, this.transformHandler.scale)); + + options.stage.addChild(parentContainer); + return parentContainer; } private getImageColored( @@ -268,9 +381,14 @@ export class StructureIconsLayer implements Layer { new Cell(worldX, worldY), ); screenPos.x = Math.round(screenPos.x); - screenPos.y = Math.round( - screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y, - ); + if (this.transformHandler.scale >= ZOOM_THRESHOLD) { + // Adjust the y position based on zoom level to avoid hiding the structure beneath + screenPos.y = Math.round( + screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y, + ); + } else { + screenPos.y = Math.round(screenPos.y); + } // Check if the sprite is on screen (with margin for partial visibility) const margin = ICON_SIZE; @@ -281,19 +399,49 @@ export class StructureIconsLayer implements Layer { screenPos.y - margin < this.pixicanvas.height; if (onScreen) { - render.pixiSprite.x = screenPos.x; - render.pixiSprite.y = screenPos.y; - render.pixiSprite.scale.set(Math.min(1, this.transformHandler.scale)); + if (this.transformHandler.scale > ZOOM_THRESHOLD) { + render.levelContainer.x = screenPos.x; + render.levelContainer.y = screenPos.y; + } else { + render.iconContainer.x = screenPos.x; + render.iconContainer.y = screenPos.y; + render.iconContainer.scale.set( + Math.min(1, this.transformHandler.scale), + ); + } } if (render.isOnScreen !== onScreen) { // prevent unnecessary updates render.isOnScreen = onScreen; - render.pixiSprite.visible = onScreen; + render.iconContainer.visible = onScreen; + render.levelContainer.visible = onScreen; } } + private addNewStructure(unitView: UnitView) { + this.seenUnits.add(unitView); + const render = new StructureRenderInfo( + unitView, + unitView.owner().id(), + this.createUnitContainer(unitView, { + addIcon: true, + stage: this.iconsStage, + }), + this.createUnitContainer(unitView, { + addIcon: false, + stage: this.levelsStage, + }), + unitView.level(), + unitView.type() === UnitType.Construction, + ); + this.renders.push(render); + this.computeNewLocation(render); + this.shouldRedraw = true; + } + private deleteStructure(render: StructureRenderInfo) { - render.pixiSprite?.destroy(); + render.iconContainer?.destroy(); + render.levelContainer?.destroy(); this.renders = this.renders.filter((r) => r.unit !== render.unit); this.seenUnits.delete(render.unit); } diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 4c68f222b..ee576cc67 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -1,10 +1,8 @@ import { colord, Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; -import { MouseUpEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -import { UnitInfoModal } from "./UnitInfoModal"; import cityIcon from "../../../../resources/non-commercial/images/buildings/cityAlt1.png"; import factoryIcon from "../../../../resources/non-commercial/images/buildings/factoryAlt1.png"; @@ -18,12 +16,12 @@ import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; const underConstructionColor = colord({ r: 150, g: 150, b: 150 }); -const selectedUnitColor = colord({ r: 0, g: 255, b: 255 }); // Base radius values and scaling factor for unit borders and territories const BASE_BORDER_RADIUS = 16.5; const BASE_TERRITORY_RADIUS = 13.5; const RADIUS_SCALE_FACTOR = 0.5; +const ZOOM_THRESHOLD = 2.5; // below this zoom level, structures are not rendered interface UnitRenderConfig { icon: string; @@ -36,8 +34,6 @@ export class StructureLayer implements Layer { private context: CanvasRenderingContext2D; private unitIcons: Map = new Map(); private theme: Theme; - private selectedStructureUnit: UnitView | null = null; - private previouslySelected: UnitView | null = null; private tempCanvas: HTMLCanvasElement; private tempContext: CanvasRenderingContext2D; @@ -79,14 +75,7 @@ export class StructureLayer implements Layer { private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, - private unitInfoModal: UnitInfoModal | null, ) { - if (!unitInfoModal) { - throw new Error( - "UnitInfoModal instance must be provided to StructureLayer.", - ); - } - this.unitInfoModal = unitInfoModal; this.theme = game.config().theme(); this.tempCanvas = document.createElement("canvas"); const tempContext = this.tempCanvas.getContext("2d"); @@ -131,7 +120,6 @@ export class StructureLayer implements Layer { init() { this.redraw(); - this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); } redraw() { @@ -151,6 +139,9 @@ export class StructureLayer implements Layer { } renderLayer(context: CanvasRenderingContext2D) { + if (this.transformHandler.scale <= ZOOM_THRESHOLD) { + return; + } context.drawImage( this.canvas, -this.game.width() / 2, @@ -224,9 +215,6 @@ export class StructureLayer implements Layer { if (!unit.isActive()) return; - if (this.selectedStructureUnit === unit) { - borderColor = selectedUnitColor; - } this.drawBorder(unit, borderColor, config); // Render icon at 1/2 scale for better quality @@ -279,84 +267,4 @@ export class StructureLayer implements Layer { clearCell(cell: Cell) { this.context.clearRect(cell.x * 2, cell.y * 2, 2, 2); } - - private findStructureUnitAtCell( - cell: { x: number; y: number }, - maxDistance: number = 10, - ): UnitView | null { - const targetRef = this.game.ref(cell.x, cell.y); - - const allUnitTypes = Object.values(UnitType); - - const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes); - - for (const { unit } of nearby) { - if (unit.isActive() && this.isUnitTypeSupported(unit.type())) { - return unit; - } - } - - return null; - } - - private onMouseUp(event: MouseUpEvent) { - const cell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } - - const clickedUnit = this.findStructureUnitAtCell(cell); - this.previouslySelected = this.selectedStructureUnit; - - if (clickedUnit) { - if (clickedUnit.owner() !== this.game.myPlayer()) { - return; - } - const wasSelected = this.previouslySelected === clickedUnit; - if (wasSelected) { - this.selectedStructureUnit = null; - if (this.previouslySelected) { - this.handleUnitRendering(this.previouslySelected); - } - this.unitInfoModal?.onCloseStructureModal(); - } else { - this.selectedStructureUnit = clickedUnit; - if ( - this.previouslySelected && - this.previouslySelected !== clickedUnit - ) { - this.handleUnitRendering(this.previouslySelected); - } - this.handleUnitRendering(clickedUnit); - - const screenPos = this.transformHandler.worldToScreenCoordinates(cell); - const unitTile = clickedUnit.tile(); - this.unitInfoModal?.onOpenStructureModal({ - eventBus: this.eventBus, - unit: clickedUnit, - x: screenPos.x, - y: screenPos.y, - tileX: this.game.x(unitTile), - tileY: this.game.y(unitTile), - }); - } - } else { - this.selectedStructureUnit = null; - if (this.previouslySelected) { - this.handleUnitRendering(this.previouslySelected); - } - this.unitInfoModal?.onCloseStructureModal(); - } - } - - public unSelectStructureUnit() { - if (this.selectedStructureUnit) { - this.previouslySelected = this.selectedStructureUnit; - this.selectedStructureUnit = null; - this.handleUnitRendering(this.previouslySelected); - } - } } diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 459b2ab1d..872feb42c 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameMode } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { renderNumber } from "../../Utils"; +import { renderNumber, translateText } from "../../Utils"; import { Layer } from "./Layer"; interface TeamEntry { @@ -113,22 +113,22 @@ export class TeamStats extends LitElement implements Layer {
- Team + ${translateText("leaderboard.team")}
- Owned + ${translateText("leaderboard.owned")}
- Gold + ${translateText("leaderboard.gold")}
- Troops + ${translateText("leaderboard.troops")}
${this.teams.map( diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 28d5def51..59cc8a79e 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -307,7 +307,7 @@ export class TerritoryLayer implements Layer { this.paintTile(tile, useBorderColor, 255); } } else { - const pattern = owner.pattern(); + const pattern = owner.cosmetics.pattern; const patternsEnabled = this.cachedTerritoryPatternsEnabled ?? false; if (pattern === undefined || patternsEnabled === false) { this.paintTile(tile, this.theme.territoryColor(owner), 150); @@ -317,13 +317,10 @@ export class TerritoryLayer implements Layer { const baseColor = this.theme.territoryColor(owner); const decoder = owner.patternDecoder(); - if (decoder !== undefined) { - const bit = decoder.isSet(x, y) ? 1 : 0; - const colorToUse = bit ? baseColor.darken(0.2) : baseColor; - this.paintTile(tile, colorToUse, 150); - } else { - this.paintTile(tile, baseColor, 150); - } + const color = decoder?.isSet(x, y) + ? baseColor.darken(0.125) + : baseColor; + this.paintTile(tile, color, 150); } } } diff --git a/src/client/graphics/layers/TopBar.ts b/src/client/graphics/layers/TopBar.ts deleted file mode 100644 index c6d58536a..000000000 --- a/src/client/graphics/layers/TopBar.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement } from "lit/decorators.js"; -import { translateText } from "../../../client/Utils"; -import { GameView } from "../../../core/game/GameView"; -import { renderNumber, renderTroops } from "../../Utils"; -import { Layer } from "./Layer"; - -@customElement("top-bar") -export class TopBar extends LitElement implements Layer { - public game: GameView; - private isVisible = false; - private _population = 0; - private _lastPopulationIncreaseRate = 0; - private _popRateIsIncreasing = false; - - createRenderRoot() { - return this; - } - - init() { - this.isVisible = true; - this.requestUpdate(); - } - - tick() { - this.updatePopulationIncrease(); - this.requestUpdate(); - } - - private updatePopulationIncrease() { - const player = this.game?.myPlayer(); - if (player === null) return; - const popIncreaseRate = player.population() - this._population; - if (this.game.ticks() % 5 === 0) { - this._popRateIsIncreasing = - popIncreaseRate >= this._lastPopulationIncreaseRate; - this._lastPopulationIncreaseRate = popIncreaseRate; - } - } - - render() { - if (!this.isVisible) { - return html``; - } - - const myPlayer = this.game?.myPlayer(); - if (!myPlayer?.isAlive() || this.game?.inSpawnPhase()) { - return html``; - } - - const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10; - const maxPop = this.game.config().maxPopulation(myPlayer); - const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n; - - return html` -
- -
- ${translateText("control_panel.pop")}: - ${renderTroops(myPlayer.population())} / - ${renderTroops(maxPop)} - (+${renderTroops(popRate)}) -
- -
- ${translateText("control_panel.gold")}: - ${renderNumber(myPlayer.gold())} - (+${renderNumber(goldPerSecond)}) -
-
- `; - } -} diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index a743c5ae7..f3ba9711f 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -340,7 +340,11 @@ export class UILayer implements Layer { // full hp/dead warships dont need a hp bar this.allHealthBars.get(unit.id())?.clear(); this.allHealthBars.delete(unit.id()); - } else if (unit.health() < maxHealth && unit.health() > 0) { + } else if ( + unit.isActive() && + unit.health() < maxHealth && + unit.health() > 0 + ) { this.allHealthBars.get(unit.id())?.clear(); const healthBar = new ProgressBar( COLOR_PROGRESSION, diff --git a/src/client/graphics/layers/UnitInfoModal.ts b/src/client/graphics/layers/UnitInfoModal.ts deleted file mode 100644 index a8d34be30..000000000 --- a/src/client/graphics/layers/UnitInfoModal.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { translateText } from "../../../client/Utils"; -import { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { - SendCreateTrainStationIntentEvent, - SendUpgradeStructureIntentEvent, -} from "../../Transport"; -import { Layer } from "./Layer"; -import { StructureLayer } from "./StructureLayer"; - -@customElement("unit-info-modal") -export class UnitInfoModal extends LitElement implements Layer { - @property({ type: Boolean }) open = false; - @property({ type: Number }) x = 0; - @property({ type: Number }) y = 0; - @property({ type: Object }) unit: UnitView | null = null; - - public game: GameView; - public structureLayer: StructureLayer | null = null; - private eventBus: EventBus; - - constructor() { - super(); - } - - init() {} - - tick() { - if (this.unit) { - this.requestUpdate(); - } - } - - public onOpenStructureModal = ({ - eventBus, - unit, - x, - y, - tileX, - tileY, - }: { - eventBus: EventBus; - unit: UnitView; - x: number; - y: number; - tileX: number; - tileY: number; - }) => { - if (!this.game) return; - this.x = x; - this.y = y; - this.eventBus = eventBus; - const targetRef = this.game.ref(tileX, tileY); - - const allUnitTypes = Object.values(UnitType); - const matchingUnits = this.game.nearbyUnits( - targetRef, - 10, - allUnitTypes, - ({ unit }) => unit.isActive(), - ); - - if (matchingUnits.length > 0) { - matchingUnits.sort((a, b) => a.distSquared - b.distSquared); - this.unit = matchingUnits[0].unit; - } else { - this.unit = null; - } - this.open = this.unit !== null; - }; - - public onCloseStructureModal = () => { - this.open = false; - this.unit = null; - }; - - connectedCallback() { - super.connectedCallback(); - } - - disconnectedCallback() { - super.disconnectedCallback(); - } - - private buildUnitTypeTranslationString(): string { - if (!this.unit) return "unit_type.unknown"; // fallback stays the same - const unitType = this.unit.type().toLowerCase().replace(/\s+/g, "_"); - return `unit_type.${unitType}`; - } - - static styles = css` - :host { - position: fixed; - pointer-events: none; - z-index: 1000; - } - - .modal { - pointer-events: auto; - background: rgba(30, 30, 30, 0.95); - color: #f8f8f8; - border: 1px solid #555; - padding: 12px 18px; - border-radius: 8px; - min-width: 220px; - max-width: 300px; - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); - font-family: "Segoe UI", sans-serif; - font-size: 15px; - line-height: 1.6; - backdrop-filter: blur(6px); - position: relative; - } - - .modal strong { - color: #e0e0e0; - } - - .close-button { - background: #d00; - color: #fff; - border: none; - border-radius: 4px; - font-size: 14px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; - padding: 6px 12px; - } - - .close-button:hover { - background: #a00; - } - - .upgrade-button { - background: #3a0; - color: #fff; - border: none; - border-radius: 4px; - font-size: 14px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; - padding: 6px 12px; - } - - .upgrade-button:hover { - background: #0a0; - } - `; - - render() { - if (!this.unit) return null; - - const ticksLeftInCooldown = this.unit.ticksLeftInCooldown(); - let configTimer; - switch (this.unit.type()) { - case UnitType.MissileSilo: - configTimer = this.game.config().SiloCooldown(); - break; - case UnitType.SAMLauncher: - configTimer = this.game.config().SAMCooldown(); - break; - } - let cooldown = 0; - if (ticksLeftInCooldown !== undefined && configTimer !== undefined) { - cooldown = configTimer - (this.game.ticks() - ticksLeftInCooldown); - } - const secondsLeft = Math.ceil(cooldown / 10); - - return html` - - `; - } -} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index c22dea5f9..2e3b4667b 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -249,7 +249,9 @@ export class WinModal extends LitElement implements Layer { const updates = this.game.updatesSinceLastTick(); const winUpdates = updates !== null ? updates[GameUpdateType.Win] : []; winUpdates.forEach((wu) => { - if (wu.winner[0] === "team") { + if (wu.winner === undefined) { + // ... + } else if (wu.winner[0] === "team") { this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats)); if (wu.winner[1] === this.game.myPlayer()?.team()) { this._title = translateText("win_modal.your_team"); @@ -260,8 +262,8 @@ export class WinModal extends LitElement implements Layer { } this.show(); } else { - const winner = this.game.playerBySmallID(wu.winner[1]); - if (!winner.isPlayer()) return; + const winner = this.game.playerByClientID(wu.winner[1]); + if (!winner?.isPlayer()) return; const winnerClient = winner.clientID(); if (winnerClient !== null) { this.eventBus.emit( diff --git a/src/client/index.html b/src/client/index.html index 9a8ee7255..c903ea953 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -262,7 +262,6 @@
-
-
+
@@ -360,7 +356,8 @@ - + + @@ -369,7 +366,6 @@ - diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 684a737dc..da5af1818 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -14,7 +14,7 @@ function getAudience() { return domainname; } -function getApiBase() { +export function getApiBase() { const domainname = getAudience(); return domainname === "localhost" ? (localStorage.getItem("apiHost") ?? "http://localhost:8787") @@ -47,6 +47,12 @@ export function discordLogin() { window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; } +export function getAuthHeader(): string { + const token = getToken(); + if (!token) return ""; + return `Bearer ${token}`; +} + export async function logOut(allSessions: boolean = false) { const token = localStorage.getItem("token"); if (token === null) return; diff --git a/src/client/styles.css b/src/client/styles.css index dba13d5d7..05950fe70 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -37,6 +37,14 @@ background: rgba(255, 255, 255, 0.3); } +.hide-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} +.hide-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari */ +} + .start-game-button { width: 100%; max-width: 300px; @@ -483,6 +491,7 @@ label.option-card:hover { overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; + max-height: 400px; } #error-modal button.copy-btn { diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts index c74aaf7ef..028f196fa 100644 --- a/src/client/utilities/RenderUnitTypeOptions.ts +++ b/src/client/utilities/RenderUnitTypeOptions.ts @@ -18,6 +18,8 @@ const unitOptions: { type: UnitType; translationKey: string }[] = [ { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" }, { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" }, { type: UnitType.MIRV, translationKey: "unit_type.mirv" }, + { type: UnitType.Train, translationKey: "unit_type.train" }, + { type: UnitType.Factory, translationKey: "unit_type.factory" }, ]; export function renderUnitTypeOptions({ diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 07d97b126..1586b208e 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -17,6 +17,8 @@ export const CosmeticsSchema = z.object({ z.string(), z.object({ name: z.string(), + role_group: z.string().optional(), + flares: z.array(z.string()).optional(), }), ), color: z.record( @@ -24,6 +26,8 @@ export const CosmeticsSchema = z.object({ z.object({ color: z.string(), name: z.string(), + role_group: z.string().optional(), + flares: z.array(z.string()).optional(), }), ), }), diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index f2740c3db..c2567aeb3 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -42,8 +42,6 @@ export async function createGameRunner( const humans = gameStart.players.map( (p) => new PlayerInfo( - p.pattern, - p.flag, p.clientID === clientID ? sanitize(p.username) : fixProfaneUsername(sanitize(p.username)), @@ -60,14 +58,7 @@ export async function createGameRunner( new Nation( new Cell(n.coordinates[0], n.coordinates[1]), n.strength, - new PlayerInfo( - undefined, - n.flag || "", - n.name, - PlayerType.FakeHuman, - null, - random.nextID(), - ), + new PlayerInfo(n.name, PlayerType.FakeHuman, null, random.nextID()), ), ); @@ -205,12 +196,13 @@ export class GameRunner { }; const alliance = player.allianceWith(other as Player); if (alliance) { - actions.interaction.allianceCreatedAtTick = alliance.createdAt(); + actions.interaction.allianceExpiresAt = alliance.expiresAt(); } } return actions; } + public playerProfile(playerID: number): PlayerProfile { const player = this.game.playerBySmallID(playerID); if (!player.isPlayer()) { diff --git a/src/core/PatternDecoder.ts b/src/core/PatternDecoder.ts index e489bceef..1311aec44 100644 --- a/src/core/PatternDecoder.ts +++ b/src/core/PatternDecoder.ts @@ -1,13 +1,15 @@ -import { base64url } from "jose"; - export class PatternDecoder { private bytes: Uint8Array; - private tileWidth: number; - private tileHeight: number; - private scale: number; - constructor(base64: string) { - this.bytes = base64url.decode(base64); + readonly height: number; + readonly width: number; + readonly scale: number; + + constructor( + base64: string, + base64urlDecode: (input: Uint8Array | string) => Uint8Array, + ) { + this.bytes = base64urlDecode(base64); if (this.bytes.length < 3) { throw new Error( @@ -24,10 +26,10 @@ export class PatternDecoder { const byte2 = this.bytes[2]; this.scale = byte1 & 0x07; - this.tileWidth = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2; - this.tileHeight = ((byte2 >> 2) & 0x3f) + 2; + this.width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2; + this.height = ((byte2 >> 2) & 0x3f) + 2; - const expectedBits = this.tileWidth * this.tileHeight; + const expectedBits = this.width * this.height; const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8); if (this.bytes.length - 3 < expectedBytes) { throw new Error( @@ -36,26 +38,22 @@ export class PatternDecoder { } } - getTileWidth(): number { - return this.tileWidth; - } - - getTileHeight(): number { - return this.tileHeight; - } - - getScale(): number { - return this.scale; - } - isSet(x: number, y: number): boolean { - const px = (x >> this.scale) % this.tileWidth; - const py = (y >> this.scale) % this.tileHeight; - const idx = py * this.tileWidth + px; + const px = (x >> this.scale) % this.width; + const py = (y >> this.scale) % this.height; + const idx = py * this.width + px; const byteIndex = idx >> 3; const bitIndex = idx & 7; const byte = this.bytes[3 + byteIndex]; if (byte === undefined) throw new Error("Invalid pattern"); return (byte & (1 << bitIndex)) !== 0; } + + scaledHeight(): number { + return this.height << this.scale; + } + + scaledWidth(): number { + return this.width << this.scale; + } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 9e5efca5b..8bdf09c5e 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,3 +1,4 @@ +import { base64url } from "jose"; import { z } from "zod/v4"; import quickChatData from "../../resources/QuickChat.json" with { type: "json" }; import { @@ -25,6 +26,7 @@ export type Intent = | CancelBoatIntent | AllianceRequestIntent | AllianceRequestReplyIntent + | AllianceExtensionIntent | BreakAllianceIntent | TargetPlayerIntent | EmojiIntent @@ -67,6 +69,9 @@ export type QuickChatIntent = z.infer; export type MarkDisconnectedIntent = z.infer< typeof MarkDisconnectedIntentSchema >; +export type AllianceExtensionIntent = z.infer< + typeof AllianceExtensionIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -170,12 +175,12 @@ export const UsernameSchema = SafeString; export const FlagSchema = z.string().max(128).optional(); export const RequiredPatternSchema = z .string() - .max(128) + .max(1403) .base64url() .refine( (val) => { try { - new PatternDecoder(val); + new PatternDecoder(val, base64url.decode); return true; } catch (e) { console.error(JSON.stringify(e.message, null, 2)); @@ -217,6 +222,11 @@ const BaseIntentSchema = z.object({ clientID: ID, }); +export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({ + type: z.literal("allianceExtension"), + recipient: ID, +}); + export const AttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("attack"), targetID: ID.nullable(), @@ -358,6 +368,7 @@ const IntentSchema = z.discriminatedUnion("type", [ EmbargoIntentSchema, MoveWarshipIntentSchema, QuickChatIntentSchema, + AllianceExtensionIntentSchema, ]); // @@ -386,8 +397,8 @@ export const GameStartInfoSchema = z.object({ export const WinnerSchema = z .union([ - z.tuple([z.literal("player"), ID]), - z.tuple([z.literal("team"), SafeString]), + z.tuple([z.literal("player"), ID]).rest(ID), + z.tuple([z.literal("team"), SafeString]).rest(ID), ]) .optional(); export type Winner = z.infer; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index e2c953bc5..461d99c55 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -62,6 +62,7 @@ export interface ServerConfig { cloudflareApiToken(): string; cloudflareConfigPath(): string; cloudflareCredsPath(): string; + stripePublishableKey(): string; } export interface NukeMagnitude { @@ -158,6 +159,7 @@ export interface Config { nukeDeathFactor(humans: number, tilesOwned: number): number; structureMinDist(): number; isReplay(): boolean; + allianceExtensionPromptOffset(): number; } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 6ad3075fd..69fb22df0 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,6 +66,9 @@ const numPlayersConfig = { } as const satisfies Record; export abstract class DefaultServerConfig implements ServerConfig { + stripePublishableKey(): string { + return process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + } domain(): string { return process.env.DOMAIN ?? ""; } @@ -199,6 +202,11 @@ export class DefaultConfig implements Config { private _userSettings: UserSettings | null, private _isReplay: boolean, ) {} + + stripePublishableKey(): string { + return process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + } + isReplay(): boolean { return this._isReplay; } @@ -422,7 +430,6 @@ export class DefaultConfig implements Config { ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 5 * 10, - upgradable: true, }; case UnitType.SAMLauncher: return { @@ -469,6 +476,8 @@ export class DefaultConfig implements Config { territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 2 * 10, canBuildTrainStation: true, + experimental: true, + upgradable: true, }; case UnitType.Construction: return { @@ -479,6 +488,7 @@ export class DefaultConfig implements Config { return { cost: () => 0n, territoryBound: false, + experimental: true, }; default: assertNever(type); @@ -837,4 +847,8 @@ export class DefaultConfig implements Config { defensePostTargettingRange(): number { return 75; } + + allianceExtensionPromptOffset(): number { + return 300; // 30 seconds before expiration + } } diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 51c07c212..1926d9991 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -122,6 +122,20 @@ export class AttackExecution implements Execution { // Record stats this.mg.stats().attack(this._owner, this.target, this.startTroops); + for (const incoming of this._owner.incomingAttacks()) { + if (incoming.attacker() === this.target) { + // Target has opposing attack, cancel them out + if (incoming.troops() > this.attack.troops()) { + incoming.setTroops(incoming.troops() - this.attack.troops()); + this.attack.delete(); + this.active = false; + return; + } else { + this.attack.setTroops(this.attack.troops() - incoming.troops()); + incoming.delete(); + } + } + } for (const outgoing of this._owner.outgoingAttacks()) { if ( outgoing !== this.attack && diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts index 9b9a00bb2..134a7c666 100644 --- a/src/core/execution/BotSpawner.ts +++ b/src/core/execution/BotSpawner.ts @@ -46,14 +46,7 @@ export class BotSpawner { } } return new SpawnExecution( - new PlayerInfo( - undefined, - "", - botName, - PlayerType.Bot, - null, - this.random.nextID(), - ), + new PlayerInfo(botName, PlayerType.Bot, null, this.random.nextID()), tile, ); } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index a7284da81..eddd3f85c 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -1,4 +1,5 @@ import { + Cell, Execution, Game, Gold, @@ -26,15 +27,37 @@ export class ConstructionExecution implements Execution { private ticksUntilComplete: Tick; private cost: Gold; + private tile: TileRef; constructor( private player: Player, - private tile: TileRef, private constructionType: UnitType, + private tileOrCell: TileRef | Cell, ) {} init(mg: Game, ticks: number): void { this.mg = mg; + + if (this.mg.config().isUnitDisabled(this.constructionType)) { + console.warn( + `cannot build construction ${this.constructionType} because it is disabled`, + ); + this.active = false; + return; + } + + if (this.tileOrCell instanceof Cell) { + if (!this.mg.isValidCoord(this.tileOrCell.x, this.tileOrCell.y)) { + console.warn( + `cannot build construction invalid coordinates ${this.tileOrCell.x}, ${this.tileOrCell.y}`, + ); + this.active = false; + return; + } + this.tile = this.mg.ref(this.tileOrCell.x, this.tileOrCell.y); + } else { + this.tile = this.tileOrCell; + } } tick(ticks: number): void { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 27888e41a..72e552cb9 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -1,7 +1,8 @@ -import { Execution, Game } from "../game/Game"; +import { Cell, Execution, Game } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { ClientID, GameID, Intent, Turn } from "../Schemas"; import { simpleHash } from "../Util"; +import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution"; import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution"; import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution"; import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution"; @@ -108,9 +109,13 @@ export class Executor { case "build_unit": return new ConstructionExecution( player, - this.mg.ref(intent.x, intent.y), intent.unit, + new Cell(intent.x, intent.y), ); + case "allianceExtension": { + return new AllianceExtensionExecution(player, intent.recipient); + } + case "upgrade_structure": return new UpgradeStructureExecution(player, intent.unitId); case "create_station": diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index ad10a8c70..eb9467c8d 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -444,6 +444,9 @@ export class FakeHumanExecution implements Execution { } private maybeSpawnTrainStation(): boolean { + if (this.mg.config().isUnitDisabled(UnitType.Train)) { + return false; + } if (this.player === null) throw new Error("not initialized"); const citiesWithoutStations = this.player.units().filter((unit) => { switch (unit.type()) { @@ -480,7 +483,7 @@ export class FakeHumanExecution implements Execution { if (canBuild === false) { return false; } - this.mg.addExecution(new ConstructionExecution(this.player, tile, type)); + this.mg.addExecution(new ConstructionExecution(this.player, type, tile)); return true; } @@ -519,7 +522,7 @@ export class FakeHumanExecution implements Execution { return false; } this.mg.addExecution( - new ConstructionExecution(this.player, targetTile, UnitType.Warship), + new ConstructionExecution(this.player, UnitType.Warship, targetTile), ); return true; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 4c81d4bb7..c93bcf44f 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -72,10 +72,7 @@ export class PlayerExecution implements Execution { const alliances = Array.from(this.player.alliances()); for (const alliance of alliances) { - if ( - this.mg.ticks() - alliance.createdAt() > - this.mg.config().allianceDuration() - ) { + if (alliance.expiresAt() <= this.mg.ticks()) { alliance.expire(); } } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 343063bb0..b885b68d6 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -129,7 +129,7 @@ export class TradeShipExecution implements Execution { const gold = this.mg.config().tradeShipGold(this.tilesTraveled); if (this.wasCaptured) { - this.tradeShip!.owner().addGold(gold); + this.tradeShip!.owner().addGold(gold, this._dstPort.tile()); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`, MessageType.CAPTURED_ENEMY_UNIT, @@ -138,7 +138,7 @@ export class TradeShipExecution implements Execution { ); } else { this.srcPort.owner().addGold(gold); - this._dstPort.owner().addGold(gold); + this._dstPort.owner().addGold(gold, this._dstPort.tile()); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`, MessageType.RECEIVED_GOLD_FROM_TRADE, diff --git a/src/core/execution/TrainStationExecution.ts b/src/core/execution/TrainStationExecution.ts index 81008e3ef..358899ca7 100644 --- a/src/core/execution/TrainStationExecution.ts +++ b/src/core/execution/TrainStationExecution.ts @@ -1,4 +1,4 @@ -import { Execution, Game, Player, Unit } from "../game/Game"; +import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TrainStation } from "../game/TrainStation"; import { PseudoRandom } from "../PseudoRandom"; import { TrainExecution } from "./TrainExecution"; @@ -21,6 +21,13 @@ export class TrainStationExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; + + if (this.mg.config().isUnitDisabled(UnitType.Train)) { + this.active = false; + console.warn(`train station is disabled`); + return; + } + this.random = new PseudoRandom(mg.ticks()); this.unit = this.player.units().find((unit) => unit.id() === this.unitId); diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts new file mode 100644 index 000000000..e43693f89 --- /dev/null +++ b/src/core/execution/alliance/AllianceExtensionExecution.ts @@ -0,0 +1,61 @@ +import { + Execution, + Game, + MessageType, + Player, + PlayerID, +} from "../../game/Game"; + +export class AllianceExtensionExecution implements Execution { + constructor( + private readonly from: Player, + private readonly toID: PlayerID, + ) {} + + init(mg: Game, ticks: number): void { + if (!mg.hasPlayer(this.toID)) { + console.warn( + `[AllianceExtensionExecution] Player ${this.toID} not found`, + ); + return; + } + const to = mg.player(this.toID); + const alliance = this.from.allianceWith(to); + if (!alliance) { + console.warn( + `[AllianceExtensionExecution] No alliance to extend between ${this.from.id()} and ${this.toID}`, + ); + return; + } + + // Mark this player's intent to extend + alliance.addExtensionRequest(this.from); + + if (alliance.canExtend()) { + alliance.extend(); + + mg.displayMessage( + "alliance.renewed", + MessageType.ALLIANCE_ACCEPTED, + this.from.id(), + ); + mg.displayMessage( + "alliance.renewed", + MessageType.ALLIANCE_ACCEPTED, + this.toID, + ); + } + } + + tick(ticks: number): void { + // No-op + } + + isActive(): boolean { + return false; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts index b5c2c5836..6d2782595 100644 --- a/src/core/game/AllianceImpl.ts +++ b/src/core/game/AllianceImpl.ts @@ -1,12 +1,20 @@ import { Game, MutableAlliance, Player, Tick } from "./Game"; export class AllianceImpl implements MutableAlliance { + private extensionRequestedRequestor_: boolean = false; + private extensionRequestedRecipient_: boolean = false; + + private expiresAt_: Tick; + constructor( private readonly mg: Game, readonly requestor_: Player, readonly recipient_: Player, - readonly createdAtTick_: Tick, - ) {} + private readonly createdAt_: Tick, + private readonly id_: number, + ) { + this.expiresAt_ = createdAt_ + mg.config().allianceDuration(); + } other(player: Player): Player { if (this.requestor_ === player) { @@ -24,10 +32,38 @@ export class AllianceImpl implements MutableAlliance { } createdAt(): Tick { - return this.createdAtTick_; + return this.createdAt_; } expire(): void { this.mg.expireAlliance(this); } + + addExtensionRequest(player: Player): void { + if (this.requestor_ === player) { + this.extensionRequestedRequestor_ = true; + } else if (this.recipient_ === player) { + this.extensionRequestedRecipient_ = true; + } + } + + canExtend(): boolean { + return ( + this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_ + ); + } + + public id(): number { + return this.id_; + } + + extend(): void { + this.extensionRequestedRequestor_ = false; + this.extensionRequestedRecipient_ = false; + this.expiresAt_ = this.mg.ticks() + this.mg.config().allianceDuration(); + } + + expiresAt(): Tick { + return this.expiresAt_; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d2bbade2f..330179000 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -133,6 +133,7 @@ export interface UnitInfo { constructionDuration?: number; upgradable?: boolean; canBuildTrainStation?: boolean; + experimental?: boolean; } export enum UnitType { @@ -338,20 +339,23 @@ export interface Alliance { requestor(): Player; recipient(): Player; createdAt(): Tick; + expiresAt(): Tick; other(player: Player): Player; } export interface MutableAlliance extends Alliance { expire(): void; other(player: Player): Player; + canExtend(): boolean; + addExtensionRequest(player: Player): void; + id(): number; + extend(): void; } export class PlayerInfo { public readonly clan: string | null; constructor( - public readonly pattern: string | undefined, - public readonly flag: string | undefined, public readonly name: string, public readonly playerType: PlayerType, // null if bot. @@ -498,7 +502,7 @@ export interface Player { workers(): number; troops(): number; targetTroopRatio(): number; - addGold(toAdd: Gold): void; + addGold(toAdd: Gold, tile?: TileRef): void; removeGold(toRemove: Gold): Gold; addWorkers(toAdd: number): void; removeWorkers(toRemove: number): void; @@ -518,6 +522,7 @@ export interface Player { spawnTile: TileRef, params: UnitParams, ): Unit; + upgradeUnit(unit: Unit): void; captureUnit(unit: Unit): void; @@ -537,6 +542,7 @@ export interface Player { incomingAllianceRequests(): AllianceRequest[]; outgoingAllianceRequests(): AllianceRequest[]; alliances(): MutableAlliance[]; + expiredAlliances(): Alliance[]; allies(): Player[]; isAlliedWith(other: Player): boolean; allianceWith(other: Player): MutableAlliance | null; @@ -592,7 +598,6 @@ export interface Player { } export interface Game extends GameMap { - expireAlliance(alliance: Alliance); // Map & Dimensions isOnMap(cell: Cell): boolean; width(): number; @@ -614,6 +619,10 @@ export interface Game extends GameMap { teams(): Team[]; + // Alliances + alliances(): MutableAlliance[]; + expireAlliance(alliance: Alliance): void; + // Game State ticks(): Tick; inSpawnPhase(): boolean; @@ -674,6 +683,8 @@ export interface PlayerActions { export interface BuildableUnit { canBuild: TileRef | false; + // unit id of the existing unit that can be upgraded, or false if it cannot be upgraded. + canUpgrade: number | false; type: UnitType; cost: Gold; } @@ -695,7 +706,7 @@ export interface PlayerInteraction { canTarget: boolean; canDonate: boolean; canEmbargo: boolean; - allianceCreatedAtTick?: Tick; + allianceExpiresAt?: Tick; } export interface EmojiMessage { @@ -730,6 +741,7 @@ export enum MessageType { SENT_TROOPS_TO_PLAYER, RECEIVED_TROOPS_FROM_PLAYER, CHAT, + RENEW_ALLIANCE, } // Message categories used for filtering events in the EventsDisplay @@ -760,6 +772,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = { [MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE, [MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE, [MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE, + [MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE, [MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE, [MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE, [MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index e8cef0a77..151ea84a7 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,5 +1,5 @@ import { Config } from "../configuration/Config"; -import { AllPlayersStats, ClientID } from "../Schemas"; +import { AllPlayersStats, ClientID, Winner } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; import { AllianceRequestImpl } from "./AllianceRequestImpl"; @@ -15,6 +15,7 @@ import { GameMode, GameUpdates, MessageType, + MutableAlliance, Nation, Player, PlayerID, @@ -77,6 +78,9 @@ export class GameImpl implements Game { private botTeam: Team = ColoredTeams.Bot; private _railNetwork: RailNetwork = createRailNetwork(this); + // Used to assign unique IDs to each new alliance + private nextAllianceID: number = 0; + constructor( private _humans: PlayerInfo[], private _nations: Nation[], @@ -146,6 +150,11 @@ export class GameImpl implements Game { owner(ref: TileRef): Player | TerraNullius { return this.playerBySmallID(this.ownerID(ref)); } + + alliances(): MutableAlliance[] { + return this.alliances_; + } + playerBySmallID(id: number): Player | TerraNullius { if (id === 0) { return this.terraNullius(); @@ -231,11 +240,20 @@ export class GameImpl implements Game { const requestor = request.requestor(); const recipient = request.recipient(); + const existing = requestor.allianceWith(recipient); + if (existing) { + throw new Error( + `cannot accept alliance request, already allied with ${recipient.name()}`, + ); + } + + // Create and register the new alliance const alliance = new AllianceImpl( this, requestor as PlayerImpl, recipient as PlayerImpl, this._ticks, + this.nextAllianceID++, ); this.alliances_.push(alliance); (request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push( @@ -596,14 +614,31 @@ export class GameImpl implements Game { setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { this.addUpdate({ type: GameUpdateType.Win, - winner: - typeof winner === "string" - ? ["team", winner] - : ["player", winner.smallID()], + winner: this.makeWinner(winner), allPlayersStats, }); } + private makeWinner(winner: string | Player): Winner | undefined { + if (typeof winner === "string") { + return [ + "team", + winner, + ...this.players() + .filter((p) => p.team() === winner && p.clientID() !== null) + .map((p) => p.clientID()!), + ]; + } else { + const clientId = winner.clientID(); + if (clientId === null) return; + return [ + "player", + clientId, + // TODO: Assists (vote for peace) + ]; + } + } + teams(): Team[] { if (this._config.gameConfig().gameMode !== GameMode.Team) { return []; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 0c76f3a95..c3150fa8b 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -1,4 +1,4 @@ -import { AllPlayersStats, ClientID } from "../Schemas"; +import { AllPlayersStats, ClientID, Winner } from "../Schemas"; import { EmojiMessage, GameUpdates, @@ -36,6 +36,7 @@ export enum GameUpdateType { AllianceRequestReply, BrokeAlliance, AllianceExpired, + AllianceExtension, TargetPlayer, Emoji, Win, @@ -60,6 +61,7 @@ export type GameUpdate = | WinUpdate | HashUpdate | UnitIncomingUpdate + | AllianceExtensionUpdate | BonusEventUpdate | RailroadUpdate; @@ -133,8 +135,6 @@ export interface PlayerUpdate { type: GameUpdateType.Player; nameViewData?: NameViewData; clientID: ClientID | null; - pattern: string | undefined; - flag: string | undefined; name: string; displayName: string; id: PlayerID; @@ -157,10 +157,18 @@ export interface PlayerUpdate { outgoingAttacks: AttackUpdate[]; incomingAttacks: AttackUpdate[]; outgoingAllianceRequests: PlayerID[]; + alliances: AllianceView[]; hasSpawned: boolean; betrayals?: bigint; } +export interface AllianceView { + id: number; + other: PlayerID; + createdAt: Tick; + expiresAt: Tick; +} + export interface AllianceRequestUpdate { type: GameUpdateType.AllianceRequest; requestorID: number; @@ -186,6 +194,12 @@ export interface AllianceExpiredUpdate { player2ID: number; } +export interface AllianceExtensionUpdate { + type: GameUpdateType.AllianceExtension; + playerID: number; + allianceID: number; +} + export interface TargetPlayerUpdate { type: GameUpdateType.TargetPlayer; playerID: number; @@ -218,8 +232,7 @@ export type DisplayChatMessageUpdate = { export interface WinUpdate { type: GameUpdateType.Win; allPlayersStats: AllPlayersStats; - // Player id or team name. - winner: ["player", number] | ["team", Team]; + winner: Winner; } export interface HashUpdate { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 47f0e7128..6063acd81 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1,6 +1,7 @@ +import { base64url } from "jose"; import { Config } from "../configuration/Config"; import { PatternDecoder } from "../PatternDecoder"; -import { ClientID, GameID } from "../Schemas"; +import { ClientID, GameID, Player } from "../Schemas"; import { createRandomName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; import { @@ -9,11 +10,9 @@ import { GameUpdates, Gold, NameViewData, - Player, PlayerActions, PlayerBorderTiles, PlayerID, - PlayerInfo, PlayerProfile, PlayerType, Team, @@ -26,18 +25,25 @@ import { } from "./Game"; import { GameMap, TileRef, TileUpdate } from "./GameMap"; import { + AllianceView, AttackUpdate, GameUpdateType, GameUpdateViewData, PlayerUpdate, UnitUpdate, } from "./GameUpdates"; +import { TerrainMapData } from "./TerrainMapLoader"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid } from "./UnitGrid"; import { UserSettings } from "./UserSettings"; const userSettings: UserSettings = new UserSettings(); +interface PlayerCosmetics { + pattern?: string | undefined; + flag?: string | undefined; +} + export class UnitView { public _wasUpdated = true; public lastPos: TileRef[] = []; @@ -145,6 +151,7 @@ export class PlayerView { private game: GameView, public data: PlayerUpdate, public nameData: NameViewData, + public cosmetics: PlayerCosmetics, ) { if (data.clientID === game.myClientID()) { this.anonymousName = this.data.name; @@ -155,7 +162,9 @@ export class PlayerView { ); } this.decoder = - data.pattern === undefined ? undefined : new PatternDecoder(data.pattern); + this.cosmetics.pattern === undefined + ? undefined + : new PatternDecoder(this.cosmetics.pattern, base64url.decode); } patternDecoder(): PatternDecoder | undefined { @@ -202,13 +211,6 @@ export class PlayerView { smallID(): number { return this.data.smallID; } - flag(): string | undefined { - return this.data.flag; - } - - pattern(): string | undefined { - return this.data.pattern; - } name(): string { return this.anonymousName !== null && userSettings.anonymousNames() @@ -236,7 +238,7 @@ export class PlayerView { isAlive(): boolean { return this.data.isAlive; } - isPlayer(): this is Player { + isPlayer(): this is PlayerView { return true; } numTilesOwned(): number { @@ -264,10 +266,17 @@ export class PlayerView { targetTroopRatio(): number { return this.data.targetTroopRatio; } + troops(): number { return this.data.troops; } + totalUnitLevels(type: UnitType): number { + return this.units(type) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0); + } + isAlliedWith(other: PlayerView): boolean { return this.data.allies.some((n) => other.smallID() === n); } @@ -284,6 +293,10 @@ export class PlayerView { return this.data.outgoingAllianceRequests.some((id) => other.id() === id); } + alliances(): AllianceView[] { + return this.data.alliances; + } + hasEmbargoAgainst(other: PlayerView): boolean { return this.data.embargoes.has(other.id()); } @@ -306,16 +319,7 @@ export class PlayerView { outgoingEmojis(): EmojiMessage[] { return this.data.outgoingEmojis; } - info(): PlayerInfo { - return new PlayerInfo( - this.pattern(), - this.flag(), - this.name(), - this.type(), - this.clientID(), - this.id(), - ); - } + hasSpawned(): boolean { return this.data.hasSpawned; } @@ -338,16 +342,35 @@ export class GameView implements GameMap { private toDelete = new Set(); + private _cosmetics: Map = new Map(); + + private _map: GameMap; + constructor( public worker: WorkerClient, private _config: Config, - private _map: GameMap, + private _mapData: TerrainMapData, private _myClientID: ClientID, private _gameID: GameID, + private _hunans: Player[], ) { + this._map = this._mapData.gameMap; this.lastUpdate = null; - this.unitGrid = new UnitGrid(_map); + this.unitGrid = new UnitGrid(this._map); + this._cosmetics = new Map( + this._hunans.map((h) => [ + h.clientID, + { flag: h.flag, pattern: h.pattern } satisfies PlayerCosmetics, + ]), + ); + for (const nation of this._mapData.manifest.nations) { + // Nations don't have client ids, so we use their name as the key instead. + this._cosmetics.set(nation.name, { + flag: nation.flag, + }); + } } + isOnEdgeOfMap(ref: TileRef): boolean { return this._map.isOnEdgeOfMap(ref); } @@ -379,7 +402,15 @@ export class GameView implements GameMap { } else { this._players.set( pu.id, - new PlayerView(this, pu, gu.playerNameViewData[pu.id]), + new PlayerView( + this, + pu, + gu.playerNameViewData[pu.id], + // First check human by clientID, then check nation by name. + this._cosmetics.get(pu.clientID ?? "") ?? + this._cosmetics.get(pu.name) ?? + {}, + ), ); } }); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b9f045d00..c9b86e374 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -42,7 +42,12 @@ import { } from "./Game"; import { GameImpl } from "./GameImpl"; import { andFN, manhattanDistFN, TileRef } from "./GameMap"; -import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates"; +import { + AllianceView, + AttackUpdate, + GameUpdateType, + PlayerUpdate, +} from "./GameUpdates"; import { bestShoreDeploymentSource, canBuildTransportShip, @@ -85,6 +90,7 @@ export class PlayerImpl implements Player { private _displayName: string; public pastOutgoingAllianceRequests: AllianceRequest[] = []; + private _expiredAlliances: Alliance[] = []; private targets_: Target[] = []; @@ -128,8 +134,6 @@ export class PlayerImpl implements Player { return { type: GameUpdateType.Player, clientID: this.clientID(), - pattern: this.pattern(), - flag: this.flag(), name: this.name(), displayName: this.displayName(), id: this.id(), @@ -168,6 +172,15 @@ export class PlayerImpl implements Player { } satisfies AttackUpdate; }), outgoingAllianceRequests: outgoingAllianceRequests, + alliances: this.alliances().map( + (a) => + ({ + id: a.id(), + other: a.other(this).id(), + createdAt: a.createdAt(), + expiresAt: a.expiresAt(), + }) satisfies AllianceView, + ), hasSpawned: this.hasSpawned(), betrayals: stats?.betrayals, }; @@ -177,14 +190,6 @@ export class PlayerImpl implements Player { return this._smallID; } - pattern(): string | undefined { - return this.playerInfo.pattern; - } - - flag(): string | undefined { - return this.playerInfo.flag; - } - name(): string { return this._name; } @@ -352,6 +357,10 @@ export class PlayerImpl implements Player { ); } + expiredAlliances(): Alliance[] { + return [...this._expiredAlliances]; + } + allies(): Player[] { return this.alliances().map((a) => a.other(this)); } @@ -689,8 +698,17 @@ export class PlayerImpl implements Player { return this._gold; } - addGold(toAdd: Gold): void { + addGold(toAdd: Gold, tile?: TileRef): void { this._gold += toAdd; + if (tile) { + this.mg.addUpdate({ + type: GameUpdateType.BonusEvent, + tile, + gold: Number(toAdd), + workers: 0, + troops: 0, + }); + } } removeGold(toRemove: Gold): Gold { @@ -785,6 +803,27 @@ export class PlayerImpl implements Player { return b; } + // Returns the existing unit that can be upgraded, + // or false if it cannot be upgraded. + // New units of the same type can upgrade existing units. + // e.g. if a place a new city here, can it upgrade an existing city? + private canUpgradeExistingUnit( + type: UnitType, + targetTile: TileRef, + ): Unit | false { + if (!this.mg.config().unitInfo(type).upgradable) { + return false; + } + const range = this.mg.config().structureMinDist(); + const existing = this.mg + .nearbyUnits(targetTile, range, type) + .sort((a, b) => a.distSquared - b.distSquared); + if (existing.length > 0) { + return existing[0].unit; + } + return false; + } + upgradeUnit(unit: Unit) { const cost = this.mg.unitInfo(unit.type()).cost(this); this.removeGold(cost); @@ -794,11 +833,19 @@ export class PlayerImpl implements Player { public buildableUnits(tile: TileRef): BuildableUnit[] { const validTiles = this.validStructureSpawnTiles(tile); return Object.values(UnitType).map((u) => { + let canUpgrade: number | false = false; + if (!this.mg.inSpawnPhase()) { + const existingUnit = this.canUpgradeExistingUnit(u, tile); + if (existingUnit !== false) { + canUpgrade = existingUnit.id(); + } + } return { type: u, canBuild: this.mg.inSpawnPhase() ? false : this.canBuild(u, tile, validTiles), + canUpgrade: canUpgrade, cost: this.mg.config().unitInfo(u).cost(this), } as BuildableUnit; }); diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 248fff0df..a86f819fc 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -21,14 +21,7 @@ class CityStopHandler implements TrainStopHandler { trainExecution: TrainExecution, ): void { const goldBonus = mg.config().trainGold(); - station.unit.owner().addGold(goldBonus); - mg.addUpdate({ - type: GameUpdateType.BonusEvent, - tile: station.tile(), - gold: Number(goldBonus), - workers: 0, - troops: 0, - }); + station.unit.owner().addGold(goldBonus, station.tile()); } } diff --git a/src/global.d.ts b/src/global.d.ts index e100dde8e..5c3aadc48 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -36,3 +36,7 @@ declare module "*.html" { const content: string; export default content; } +declare module "*.xml" { + const value: string; + export default value; +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b29e97fc2..6ffe8ffcc 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -21,7 +21,7 @@ import { } from "../core/Schemas"; import { createGameRecord } from "../core/Util"; import { GameEnv, ServerConfig } from "../core/configuration/Config"; -import { GameType } from "../core/game/Game"; +import { GameType, UnitType } from "../core/game/Game"; import { archive } from "./Archive"; import { Client } from "./Client"; import { gatekeeper } from "./Gatekeeper"; @@ -222,6 +222,15 @@ export class GameServer { ); return; } + if ( + clientMsg.intent.type === "create_station" && + this.gameConfig.disabledUnits?.includes(UnitType.Train) + ) { + this.log.warn( + `create_station is disabled, client: ${client.clientID}`, + ); + return; + } this.addIntent(clientMsg.intent); } if (clientMsg.type === "ping") { diff --git a/src/server/Logger.ts b/src/server/Logger.ts index 94e508fbb..19832f50e 100644 --- a/src/server/Logger.ts +++ b/src/server/Logger.ts @@ -7,7 +7,6 @@ import { import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport"; import * as dotenv from "dotenv"; import winston from "winston"; -import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { getOtelResource } from "./OtelResource"; dotenv.config(); @@ -21,7 +20,7 @@ const loggerProvider = new LoggerProvider({ resource, }); -if (config.env() === GameEnv.Prod && config.otelEnabled()) { +if (config.otelEnabled()) { console.log("OTEL enabled"); // Configure OpenTelemetry endpoint with basic auth (if provided) const headers = {}; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c6c07c0ce..6c5c85f3f 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -1,5 +1,11 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game"; +import { + Difficulty, + GameMapType, + GameMode, + GameType, + UnitType, +} from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; import { GameConfig } from "../core/Schemas"; import { logger } from "./Logger"; @@ -61,6 +67,7 @@ export class MapPlaylist { gameMode: mode, playerTeams: numPlayerTeams, bots: 400, + disabledUnits: [UnitType.Train, UnitType.Factory], } satisfies GameConfig; } diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 3f84a5f53..f59aff7e6 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -2,7 +2,10 @@ import { Cosmetics } from "../core/CosmeticSchemas"; import { PatternDecoder } from "../core/PatternDecoder"; export class PrivilegeChecker { - constructor(private cosmetics: Cosmetics) {} + constructor( + private cosmetics: Cosmetics, + private b64urlDecode: (base64: string) => Uint8Array, + ) {} isPatternAllowed( base64: string, @@ -14,7 +17,7 @@ export class PrivilegeChecker { if (found === undefined) { try { // Ensure that the pattern will not throw for clients - new PatternDecoder(base64); + new PatternDecoder(base64, this.b64urlDecode); } catch (e) { // Pattern is invalid return "invalid"; @@ -37,7 +40,7 @@ export class PrivilegeChecker { if ( roles !== undefined && roles.some((role) => - this.cosmetics.role_groups[groupName].includes(role), + this.cosmetics.role_groups[groupName]?.includes(role), ) ) { // Player is in a role group for this pattern @@ -55,4 +58,94 @@ export class PrivilegeChecker { return "restricted"; } + + isCustomFlagAllowed( + flag: string, + roles: readonly string[] | undefined, + flares: readonly string[] | undefined, + ): true | "restricted" | "invalid" { + if (!flag.startsWith("!")) return "invalid"; + const code = flag.slice(1); + if (!code) return "invalid"; + const segments = code.split("_"); + if (segments.length === 0) return "invalid"; + + const MAX_LAYERS = 6; // Maximum number of layers allowed + if (segments.length > MAX_LAYERS) return "invalid"; + + const superFlare = flares?.includes("flag:*") ?? false; + + for (const segment of segments) { + const [layerKey, colorKey] = segment.split("-"); + if (!layerKey || !colorKey) return "invalid"; + const layer = this.cosmetics.flag.layers[layerKey]; + const color = this.cosmetics.flag.color[colorKey]; + if (!layer || !color) return "invalid"; + + // Super-flare bypasses all restrictions + if (superFlare) { + continue; + } + + // Check layer restrictions + const layerSpec = layer; + let layerAllowed = false; + if (!layerSpec.role_group && !layerSpec.flares) { + layerAllowed = true; + } else { + // By role + if (layerSpec.role_group) { + const allowedRoles = + this.cosmetics.role_groups[layerSpec.role_group] || []; + if (roles?.some((r) => allowedRoles.includes(r))) { + layerAllowed = true; + } + } + // By flare + if ( + layerSpec.flares && + flares?.some((f) => layerSpec.flares?.includes(f)) + ) { + layerAllowed = true; + } + // By named flag:layer:{name} + if (flares?.includes(`flag:layer:${layerSpec.name}`)) { + layerAllowed = true; + } + } + + // Check color restrictions + const colorSpec = color; + let colorAllowed = false; + if (!colorSpec.role_group && !colorSpec.flares) { + colorAllowed = true; + } else { + // By role + if (colorSpec.role_group) { + const allowedRoles = + this.cosmetics.role_groups[colorSpec.role_group] || []; + if (roles?.some((r) => allowedRoles.includes(r))) { + colorAllowed = true; + } + } + // By flare + if ( + colorSpec.flares && + flares?.some((f) => colorSpec.flares?.includes(f)) + ) { + colorAllowed = true; + } + // By named flag:color:{name} + if (flares?.includes(`flag:color:${colorSpec.name}`)) { + colorAllowed = true; + } + } + + // If either part is restricted, block + if (!(layerAllowed && colorAllowed)) { + return "restricted"; + } + } + return true; + } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 1e499af54..a93f68412 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; import ipAnonymize from "ip-anonymize"; +import { base64url } from "jose"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; @@ -11,7 +12,7 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { COSMETICS } from "../core/CosmeticSchemas"; import { GameType } from "../core/game/Game"; import { - ClientJoinMessageSchema, + ClientMessageSchema, GameRecord, GameRecordSchema, ServerErrorMessage, @@ -44,9 +45,9 @@ export function startWorker() { const gm = new GameManager(config, log); - const privilegeChecker = new PrivilegeChecker(COSMETICS); + const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode); - if (config.env() === GameEnv.Prod && config.otelEnabled()) { + if (config.otelEnabled()) { initWorkerMetrics(gm); } @@ -301,12 +302,12 @@ export function startWorker() { try { // Parse and handle client messages - const parsed = ClientJoinMessageSchema.safeParse( + const parsed = ClientMessageSchema.safeParse( JSON.parse(message.toString()), ); if (!parsed.success) { const error = z.prettifyError(parsed.error); - log.warn("Error parsing join message client", error); + log.warn("Error parsing client message", error); ws.send( JSON.stringify({ type: "error", @@ -318,6 +319,22 @@ export function startWorker() { } const clientMsg = parsed.data; + if (clientMsg.type === "ping") { + // Ignore ping + return; + } else if (clientMsg.type !== "join") { + const error = `Invalid message before join: ${JSON.stringify(clientMsg)}`; + log.warn(error); + ws.send( + JSON.stringify({ + type: "error", + error, + } satisfies ServerErrorMessage), + ); + ws.close(1002, "ClientJoinMessageSchema"); + return; + } + // Verify this worker should handle this game const expectedWorkerId = config.workerIndex(clientMsg.gameID); if (expectedWorkerId !== workerId) { @@ -355,7 +372,18 @@ export function startWorker() { // Check if the flag is allowed if (clientMsg.flag !== undefined) { - // TODO: Implement custom flag validation + if (clientMsg.flag.startsWith("!")) { + const allowed = privilegeChecker.isCustomFlagAllowed( + clientMsg.flag, + roles, + flares, + ); + if (allowed !== true) { + log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); + ws.close(1002, `Custom flag ${allowed}`); + return; + } + } } // Check if the pattern is allowed diff --git a/tests/AllianceExtensionExecution.test.ts b/tests/AllianceExtensionExecution.test.ts new file mode 100644 index 000000000..39289c0fe --- /dev/null +++ b/tests/AllianceExtensionExecution.test.ts @@ -0,0 +1,72 @@ +import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution"; +import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution"; +import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution"; +import { Game, Player, PlayerType } from "../src/core/game/Game"; +import { playerInfo, setup } from "./util/Setup"; + +let game: Game; +let player1: Player; +let player2: Player; + +describe("AllianceExtensionExecution", () => { + beforeEach(async () => { + game = await setup( + "ocean_and_land", + { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }, + [ + playerInfo("player1", PlayerType.Human), + playerInfo("player2", PlayerType.Human), + ], + ); + + player1 = game.player("player1"); + player2 = game.player("player2"); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + }); + + test("Successfully extends existing alliance", () => { + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); + game.executeNextTick(); + + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); + game.executeNextTick(); + + expect(player1.allianceWith(player2)).toBeTruthy(); + expect(player2.allianceWith(player1)).toBeTruthy(); + + const allianceBefore = player1.allianceWith(player2)!; + const expirationBefore = + allianceBefore.createdAt() + game.config().allianceDuration(); + + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + + const allianceAfter = player1.allianceWith(player2)!; + + expect(allianceAfter.id()).toBe(allianceBefore.id()); + + const expirationAfter = + allianceAfter.createdAt() + game.config().allianceDuration(); + + expect(expirationAfter).toBeGreaterThanOrEqual(expirationBefore); + }); + + test("Fails gracefully if no alliance exists", () => { + game.addExecution(new AllianceExtensionExecution(player1, player2.id())); + game.executeNextTick(); + + expect(player1.allianceWith(player2)).toBeFalsy(); + expect(player2.allianceWith(player1)).toBeFalsy(); + }); +}); diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 71beae6b5..8974f6250 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -33,8 +33,6 @@ describe("Attack", () => { infiniteTroops: true, }); const attackerInfo = new PlayerInfo( - undefined, - "us", "attacker dude", PlayerType.Human, null, @@ -42,8 +40,6 @@ describe("Attack", () => { ); game.addPlayer(attackerInfo); const defenderInfo = new PlayerInfo( - undefined, - "us", "defender dude", PlayerType.Human, null, diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts index e296892bb..9710770ac 100644 --- a/tests/BotBehavior.test.ts +++ b/tests/BotBehavior.test.ts @@ -23,16 +23,12 @@ describe("BotBehavior.handleAllianceRequests", () => { }); const playerInfo = new PlayerInfo( - undefined, - "us", "player_id", PlayerType.Bot, null, "player_id", ); const requestorInfo = new PlayerInfo( - undefined, - "fr", "requestor_id", PlayerType.Human, null, diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 709c8104a..e03138efa 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -16,8 +16,6 @@ describe("Disconnected", () => { }); const player1Info = new PlayerInfo( - undefined, - "us", "Active Player", PlayerType.Human, null, @@ -25,8 +23,6 @@ describe("Disconnected", () => { ); const player2Info = new PlayerInfo( - undefined, - "fr", "Disconnected Player", PlayerType.Human, null, diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts index 6e58ba2c5..c3a1f5ab1 100644 --- a/tests/MissileSilo.test.ts +++ b/tests/MissileSilo.test.ts @@ -33,8 +33,6 @@ describe("MissileSilo", () => { beforeEach(async () => { game = await setup("plains", { infiniteGold: true, instantBuild: true }); const attacker_info = new PlayerInfo( - undefined, - "fr", "attacker_id", PlayerType.Human, null, diff --git a/tests/PlayerImpl.test.ts b/tests/PlayerImpl.test.ts new file mode 100644 index 000000000..64aecd954 --- /dev/null +++ b/tests/PlayerImpl.test.ts @@ -0,0 +1,48 @@ +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; + +let game: Game; +let player: Player; + +describe("PlayerImpl", () => { + beforeEach(async () => { + game = await setup( + "plains", + { + infiniteGold: true, + instantBuild: true, + }, + [new PlayerInfo("player", PlayerType.Human, null, "player_id")], + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + player = game.player("player_id"); + }); + + test("City can be upgraded", () => { + const city = player.buildUnit(UnitType.City, game.ref(0, 0), {}); + const buCity = player + .buildableUnits(game.ref(0, 0)) + .find((bu) => bu.type === UnitType.City); + expect(buCity).toBeDefined(); + expect(buCity!.canUpgrade).toBe(city.id()); + }); + + test("DefensePost cannot be upgraded", () => { + player.buildUnit(UnitType.DefensePost, game.ref(0, 0), {}); + const buDefensePost = player + .buildableUnits(game.ref(0, 0)) + .find((bu) => bu.type === UnitType.DefensePost); + expect(buDefensePost).toBeDefined(); + expect(buDefensePost!.canUpgrade).toBeFalsy(); + }); +}); diff --git a/tests/PlayerInfo.test.ts b/tests/PlayerInfo.test.ts index eca14e173..72ca1cceb 100644 --- a/tests/PlayerInfo.test.ts +++ b/tests/PlayerInfo.test.ts @@ -4,8 +4,6 @@ describe("PlayerInfo", () => { describe("clan", () => { test("should extract clan from name when format is [XX]Name", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[CL]PlayerName", PlayerType.Human, null, @@ -16,8 +14,6 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XXX]Name", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[ABC]PlayerName", PlayerType.Human, null, @@ -28,8 +24,6 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XXXX]Name", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[ABCD]PlayerName", PlayerType.Human, null, @@ -40,8 +34,6 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XXXXX]Name", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[ABCDE]PlayerName", PlayerType.Human, null, @@ -52,8 +44,6 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [xxxxx]Name", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[abcde]PlayerName", PlayerType.Human, null, @@ -64,8 +54,6 @@ describe("PlayerInfo", () => { test("should extract clan from name when format is [XxXxX]Name", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[AbCdE]PlayerName", PlayerType.Human, null, @@ -76,8 +64,6 @@ describe("PlayerInfo", () => { test("should return null when name doesn't start with [", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "PlayerName", PlayerType.Human, null, @@ -88,8 +74,6 @@ describe("PlayerInfo", () => { test("should return null when name doesn't contain ]", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[ABCPlayerName", PlayerType.Human, null, @@ -100,8 +84,6 @@ describe("PlayerInfo", () => { test("should return null when clan tag is not 2-5 uppercase letters", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[A]PlayerName", PlayerType.Human, null, @@ -112,8 +94,6 @@ describe("PlayerInfo", () => { test("should return null when clan tag contains non alphanumeric characters", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[A1c]PlayerName", PlayerType.Human, null, @@ -124,8 +104,6 @@ describe("PlayerInfo", () => { test("should return null when clan tag is too long", () => { const playerInfo = new PlayerInfo( - undefined, - "fr", "[ABCDEF]PlayerName", PlayerType.Human, null, diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index b988210bf..c53804b4f 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -19,22 +19,8 @@ describe("Stats", () => { beforeEach(async () => { stats = new StatsImpl(); game = await setup("half_land_half_ocean", {}, [ - new PlayerInfo( - undefined, - "us", - "boat dude", - PlayerType.Human, - "client1", - "player_1_id", - ), - new PlayerInfo( - undefined, - "us", - "boat dude", - PlayerType.Human, - "client2", - "player_2_id", - ), + new PlayerInfo("boat dude", PlayerType.Human, "client1", "player_1_id"), + new PlayerInfo("boat dude", PlayerType.Human, "client2", "player_2_id"), ]); while (game.inSpawnPhase()) { diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index bea1ea74f..5d9f5bcab 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -7,8 +7,6 @@ describe("assignTeams", () => { const createPlayer = (id: string, clan?: string): PlayerInfo => { const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`; return new PlayerInfo( - undefined, - "🏳️", // flag name, PlayerType.Human, null, // clientID (null for testing) diff --git a/tests/TerritoryCapture.test.ts b/tests/TerritoryCapture.test.ts index d547010ff..e46678c08 100644 --- a/tests/TerritoryCapture.test.ts +++ b/tests/TerritoryCapture.test.ts @@ -6,14 +6,7 @@ describe("Territory management", () => { test("player owns the tile it spawns on", async () => { const game = await setup("plains"); game.addPlayer( - new PlayerInfo( - undefined, - "us", - "test_player", - PlayerType.Human, - null, - "test_id", - ), + new PlayerInfo("test_player", PlayerType.Human, null, "test_id"), ); const spawnTile = game.map().ref(50, 50); game.addExecution( diff --git a/tests/UnitGrid.test.ts b/tests/UnitGrid.test.ts index ffd4ff9f6..9a0869ace 100644 --- a/tests/UnitGrid.test.ts +++ b/tests/UnitGrid.test.ts @@ -11,14 +11,7 @@ async function checkRange( const game = await setup(mapName, { infiniteGold: true, instantBuild: true }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo( - undefined, - "us", - "test_player", - PlayerType.Human, - null, - "test_id", - ), + new PlayerInfo("test_player", PlayerType.Human, null, "test_id"), ); const unitTile = game.map().ref(unitPosX, 0); grid.addUnit(player.buildUnit(UnitType.DefensePost, unitTile, {})); @@ -41,14 +34,7 @@ async function nearbyUnits( const game = await setup(mapName, { infiniteGold: true, instantBuild: true }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo( - undefined, - "us", - "test_player", - PlayerType.Human, - null, - "test_id", - ), + new PlayerInfo("test_player", PlayerType.Human, null, "test_id"), ); const unitTile = game.map().ref(unitPosX, 0); for (const unitType of unitTypes) { @@ -122,14 +108,7 @@ describe("Unit Grid range tests", () => { }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo( - undefined, - "us", - "test_player", - PlayerType.Human, - null, - "test_id", - ), + new PlayerInfo("test_player", PlayerType.Human, null, "test_id"), ); const unitTile = game.map().ref(0, 0); grid.addUnit(player.buildUnit(UnitType.City, unitTile, {})); @@ -146,14 +125,7 @@ describe("Unit Grid range tests", () => { }); const grid = new UnitGrid(game.map()); const player = game.addPlayer( - new PlayerInfo( - undefined, - "us", - "test_player", - PlayerType.Human, - null, - "test_id", - ), + new PlayerInfo("test_player", PlayerType.Human, null, "test_id"), ); const unitType = UnitType.City; const unitTile = game.map().ref(0, 0); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index 16a72e397..ee6c556de 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -24,22 +24,8 @@ describe("Warship", () => { instantBuild: true, }, [ - new PlayerInfo( - undefined, - "us", - "boat dude", - PlayerType.Human, - null, - "player_1_id", - ), - new PlayerInfo( - undefined, - "us", - "boat dude", - PlayerType.Human, - null, - "player_2_id", - ), + new PlayerInfo("boat dude", PlayerType.Human, null, "player_1_id"), + new PlayerInfo("boat dude", PlayerType.Human, null, "player_2_id"), ], ); diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index dbc131cec..0a31d69be 100644 --- a/tests/client/graphics/UILayer.test.ts +++ b/tests/client/graphics/UILayer.test.ts @@ -83,6 +83,26 @@ describe("UILayer", () => { expect(ui["allHealthBars"].has(1)).toBe(false); }); + it("should remove health bars for inactive units", () => { + const ui = new UILayer(game, eventBus, transformHandler); + ui.redraw(); + const unit = { + id: () => 1, + type: () => "Warship", + health: () => 5, + tile: () => ({}), + owner: () => ({}), + isActive: () => true, + } as unknown as UnitView; + ui.drawHealthBar(unit); + expect(ui["allHealthBars"].has(1)).toBe(true); + + // an inactive unit doesnt have a health bar + unit.isActive = () => false; + ui.drawHealthBar(unit); + expect(ui["allHealthBars"].has(1)).toBe(false); + }); + it("should add loading bar for unit", () => { const ui = new UILayer(game, eventBus, transformHandler); ui.redraw(); diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index 435d388b4..5a614ebea 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -25,8 +25,6 @@ describe("NukeExecution", () => { outer: 10, })); const player_info = new PlayerInfo( - undefined, - "us", "player_id", PlayerType.Human, null, diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 7ceb08e1a..993488399 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -25,32 +25,24 @@ describe("SAM", () => { instantBuild: true, }); const defender_info = new PlayerInfo( - undefined, - "us", "defender_id", PlayerType.Human, null, "defender_id", ); const middle_defender_info = new PlayerInfo( - undefined, - "us", "middle_defender_id", PlayerType.Human, null, "middle_defender_id", ); const far_defender_info = new PlayerInfo( - undefined, - "us", "far_defender_id", PlayerType.Human, null, "far_defender_id", ); const attacker_info = new PlayerInfo( - undefined, - "fr", "attacker_id", PlayerType.Human, null, diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts index c5c7ae5c9..316556f1f 100644 --- a/tests/core/game/TrainStation.test.ts +++ b/tests/core/game/TrainStation.test.ts @@ -44,13 +44,7 @@ describe("TrainStation", () => { station.onTrainStop(trainExecution); - expect(unit.owner().addGold).toHaveBeenCalledWith(10); - expect(game.addUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - type: expect.any(Number), - gold: 10, - }), - ); + expect(unit.owner().addGold).toHaveBeenCalledWith(10, unit.tile()); }); it("handles Port stop", () => { diff --git a/tests/server/Privilege.customFlag.test.ts b/tests/server/Privilege.customFlag.test.ts new file mode 100644 index 000000000..330f00f30 --- /dev/null +++ b/tests/server/Privilege.customFlag.test.ts @@ -0,0 +1,188 @@ +import type { Cosmetics } from "../../src/core/CosmeticSchemas"; +import { PrivilegeChecker } from "../../src/server/Privilege"; + +describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => { + const dummyPatternDecoder = (_base64: string) => { + throw new Error("Method not implemented"); + }; + + const mockCosmetics: Cosmetics = { + role_groups: { + donor: ["role_donor"], + admin: ["role_admin"], + }, + patterns: {}, + flag: { + layers: { + a: { + name: "chocolate", + role_group: "donor", + flares: ["cosmetic:flags"], + }, + b: { name: "center_hline" }, + c: { name: "admin_layer", role_group: "admin" }, + }, + color: { + a: { color: "#ff0000", name: "red", role_group: "admin" }, + b: { color: "#00ff00", name: "green" }, + c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] }, + }, + }, + }; + + const checker = new PrivilegeChecker(mockCosmetics, dummyPatternDecoder); + + it("allowed: unrestricted layer/color", () => { + expect(checker.isCustomFlagAllowed("!b-b", [], [])).toBe(true); + }); + + it("restricted: donor layer without role", () => { + expect(checker.isCustomFlagAllowed("!a-b", [], [])).toBe("restricted"); + }); + + it("allowed: donor layer with donor role", () => { + expect(checker.isCustomFlagAllowed("!a-b", ["role_donor"], [])).toBe(true); + }); + + it("allowed: donor layer with correct flare", () => { + expect(checker.isCustomFlagAllowed("!a-b", [], ["cosmetic:flags"])).toBe( + true, + ); + }); + + it("restricted: admin color without role", () => { + expect(checker.isCustomFlagAllowed("!b-a", [], [])).toBe("restricted"); + }); + + it("allowed: admin color with admin role", () => { + expect(checker.isCustomFlagAllowed("!b-a", ["role_admin"], [])).toBe(true); + }); + + it("allowed: color with correct flare", () => { + expect(checker.isCustomFlagAllowed("!b-c", [], ["cosmetic:blue"])).toBe( + true, + ); + }); + + it("invalid: non-existent layer", () => { + expect(checker.isCustomFlagAllowed("!zzz-a", ["role_donor"], [])).toBe( + "invalid", + ); + }); + + it("invalid: non-existent color", () => { + expect(checker.isCustomFlagAllowed("!a-zzz", ["role_donor"], [])).toBe( + "invalid", + ); + }); + + it("allowed: superFlare allows all listed", () => { + expect(checker.isCustomFlagAllowed("!a-a", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!b-b", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!c-a", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!a-c", [], ["flag:*"])).toBe(true); + }); + + it("invalid: superFlare does not allow non-existent", () => { + expect(checker.isCustomFlagAllowed("!zzz-zzz", [], ["flag:*"])).toBe( + "invalid", + ); + }); + it("allowed: flare flag:layer:chocolate allows chocolate layer", () => { + expect( + checker.isCustomFlagAllowed("!a-b", [], ["flag:layer:chocolate"]), + ).toBe(true); + }); + it("allowed: flare flag:color:blue allows blue color", () => { + expect(checker.isCustomFlagAllowed("!b-c", [], ["flag:color:blue"])).toBe( + true, + ); + }); + it("restricted: only color flare, layer still restricted", () => { + expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); + it("restricted: only layer flare, color still restricted", () => { + expect(checker.isCustomFlagAllowed("!c-a", [], ["cosmetic:flags"])).toBe( + "restricted", + ); + }); + it("allowed: layer by role, color by flare", () => { + // layer a: role_group donor, color c: flares ["cosmetic:blue"] + expect( + checker.isCustomFlagAllowed("!a-c", ["role_donor"], ["cosmetic:blue"]), + ).toBe(true); + }); + it("restricted: layer by role, color by flare (missing flare)", () => { + expect(checker.isCustomFlagAllowed("!a-c", ["role_donor"], [])).toBe( + "restricted", + ); + }); + it("restricted: layer by role, color by flare (missing role)", () => { + expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); + it("allowed: layer by flare, color by role", () => { + // layer a: flares ["cosmetic:flags"], color a: role_group admin + expect( + checker.isCustomFlagAllowed("!a-a", ["role_admin"], ["cosmetic:flags"]), + ).toBe(true); + }); + it("restricted: layer by flare, color by role (missing flare)", () => { + expect(checker.isCustomFlagAllowed("!a-a", ["role_admin"], [])).toBe( + "restricted", + ); + }); + it("restricted: layer by flare, color by role (missing role)", () => { + expect(checker.isCustomFlagAllowed("!a-a", [], ["cosmetic:flags"])).toBe( + "restricted", + ); + }); + it("allowed: two segments, both unrestricted", () => { + expect(checker.isCustomFlagAllowed("!b-b_b-b", [], [])).toBe(true); + }); + it("restricted: two segments, one restricted by layer role", () => { + expect(checker.isCustomFlagAllowed("!a-b_b-b", [], [])).toBe("restricted"); + expect(checker.isCustomFlagAllowed("!a-b_b-b", ["role_donor"], [])).toBe( + true, + ); + }); + it("restricted: two segments, one restricted by color role", () => { + expect(checker.isCustomFlagAllowed("!b-a_b-b", [], [])).toBe("restricted"); + expect(checker.isCustomFlagAllowed("!b-a_b-b", ["role_admin"], [])).toBe( + true, + ); + }); + it("allowed: two segments, one by role, one by flare", () => { + expect( + checker.isCustomFlagAllowed( + "!a-c_b-b", + ["role_donor"], + ["cosmetic:blue"], + ), + ).toBe(true); + expect(checker.isCustomFlagAllowed("!a-c_b-b", ["role_donor"], [])).toBe( + "restricted", + ); + expect(checker.isCustomFlagAllowed("!a-c_b-b", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); + it("allowed: two segments, both by flare", () => { + expect( + checker.isCustomFlagAllowed( + "!a-c_a-c", + [], + ["cosmetic:flags", "cosmetic:blue"], + ), + ).toBe(true); + expect( + checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:flags"]), + ).toBe("restricted"); + expect(checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:blue"])).toBe( + "restricted", + ); + }); +}); diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index 7c6e86daa..c552d7d4f 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -83,5 +83,5 @@ export async function setup( } export function playerInfo(name: string, type: PlayerType): PlayerInfo { - return new PlayerInfo(undefined, "fr", name, type, null, name); + return new PlayerInfo(name, type, null, name); } diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index be5155d5a..4858ddafb 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + stripePublishableKey(): string { + throw new Error("Method not implemented."); + } cloudflareConfigPath(): string { throw new Error("Method not implemented."); } diff --git a/tests/util/utils.ts b/tests/util/utils.ts index 921e5d102..dbdfb3d1d 100644 --- a/tests/util/utils.ts +++ b/tests/util/utils.ts @@ -15,7 +15,7 @@ export function constructionExecution( unit: UnitType, ticks = 4, ) { - game.addExecution(new ConstructionExecution(_owner, game.ref(x, y), unit)); + game.addExecution(new ConstructionExecution(_owner, unit, game.ref(x, y))); // 4 ticks by default as it usually goes like this // Init of construction execution diff --git a/webpack.config.js b/webpack.config.js index 78721528f..0820e8cd0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -87,7 +87,7 @@ export default async (env, argv) => { }, }, { - test: /\.(woff|woff2|eot|ttf|otf)$/, + test: /\.(woff|woff2|eot|ttf|otf|xml)$/, type: "asset/resource", // Changed from file-loader generator: { filename: "fonts/[name].[contenthash][ext]", // Added content hash and fixed path @@ -126,6 +126,9 @@ export default async (env, argv) => { ), "process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"), "process.env.GIT_COMMIT": JSON.stringify(gitCommit), + "process.env.STRIPE_PUBLISHABLE_KEY": JSON.stringify( + process.env.STRIPE_PUBLISHABLE_KEY, + ), }), new CopyPlugin({ patterns: [