Merge branch 'main' into trade3
@@ -120,12 +120,12 @@
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="h-full select-none font-sans min-h-screen bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-row overflow-hidden"
|
||||
class="h-full select-none font-sans min-h-screen bg-neutral-800 transition-opacity duration-300 ease-in-out flex flex-row overflow-hidden"
|
||||
>
|
||||
<div id="hex-grid" class="fixed inset-0 -z-50 pointer-events-none">
|
||||
<div
|
||||
id="background-layer"
|
||||
class="absolute inset-0 bg-cover bg-center opacity-60 [filter:brightness(0.5)_saturate(1.4)] dark:[filter:sepia(0.2)_saturate(1.2)_hue-rotate(180deg)_brightness(0.4)]"
|
||||
class="absolute inset-0 bg-cover bg-center opacity-30 [filter:brightness(1.0)] dark:[filter:sepia(0.2)_saturate(1.2)_hue-rotate(180deg)_brightness(0.9)]"
|
||||
style="
|
||||
background-image: url("/resources/images/background.webp");
|
||||
"
|
||||
@@ -134,14 +134,14 @@
|
||||
class="absolute inset-0 bg-center bg-no-repeat bg-contain hidden lg:block"
|
||||
style="
|
||||
background-image: url("/resources/images/OpenFront.webp");
|
||||
opacity: 0.25;
|
||||
opacity: 0.5;
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-no-repeat bg-contain lg:hidden"
|
||||
style="
|
||||
background-image: url("/resources/images/OF.webp");
|
||||
opacity: 0.25;
|
||||
opacity: 0.5;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
@@ -252,8 +252,11 @@
|
||||
></ranked-modal>
|
||||
</main-layout>
|
||||
|
||||
<!-- Desktop Footer -->
|
||||
<page-footer></page-footer>
|
||||
<!-- Ad above footer -->
|
||||
<div class="[.in-game_&]:hidden mt-auto flex flex-col shrink-0">
|
||||
<home-footer-ad></home-footer-ad>
|
||||
<page-footer></page-footer>
|
||||
</div>
|
||||
|
||||
<!-- Global Modals -->
|
||||
<territory-patterns-modal
|
||||
@@ -266,7 +269,7 @@
|
||||
|
||||
<!-- Bottom HUD: <sm=column, sm..lg=2col (HUD left | events right), lg+=3col grid centered -->
|
||||
<div
|
||||
class="fixed bottom-0 left-0 w-full z-[200] flex flex-col pointer-events-none sm:flex-row sm:items-end lg:grid lg:grid-cols-[1fr_460px_1fr] lg:items-end min-[1200px]:bottom-4 min-[1200px]:px-4"
|
||||
class="fixed bottom-0 left-0 w-full z-[200] flex flex-col pointer-events-none sm:flex-row sm:items-end lg:grid lg:grid-cols-[1fr_500px_1fr] lg:items-end min-[1200px]:px-4"
|
||||
style="
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
@@ -275,7 +278,7 @@
|
||||
>
|
||||
<!-- HUD: <sm contents (children join outer flex), sm+ flex-col 460px, lg+ col-2 -->
|
||||
<div
|
||||
class="contents sm:flex sm:flex-col sm:pointer-events-none w-full sm:w-[460px] lg:col-start-2 sm:z-10"
|
||||
class="contents sm:flex sm:flex-col sm:pointer-events-none w-full sm:w-[500px] lg:col-start-2 sm:z-10"
|
||||
>
|
||||
<attacks-display
|
||||
class="w-full pointer-events-auto order-1 sm:order-none"
|
||||
|
||||
|
After Width: | Height: | Size: 463 KiB |
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "aegean",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [786, 1860],
|
||||
"name": "Crete"
|
||||
},
|
||||
{
|
||||
"coordinates": [1554, 1530],
|
||||
"name": "Rhodes"
|
||||
},
|
||||
{
|
||||
"coordinates": [1051, 539],
|
||||
"name": "Lesbos"
|
||||
},
|
||||
{
|
||||
"coordinates": [1070, 820],
|
||||
"name": "Chios"
|
||||
},
|
||||
{
|
||||
"coordinates": [1235, 1023],
|
||||
"name": "Samos"
|
||||
},
|
||||
{
|
||||
"coordinates": [1193, 301],
|
||||
"name": "Troy"
|
||||
},
|
||||
{
|
||||
"coordinates": [1446, 954],
|
||||
"name": "Ephesus"
|
||||
},
|
||||
{
|
||||
"coordinates": [1515, 1223],
|
||||
"name": "Miletus"
|
||||
},
|
||||
{
|
||||
"coordinates": [824, 305],
|
||||
"name": "Lemnos"
|
||||
},
|
||||
{
|
||||
"coordinates": [1312, 37],
|
||||
"name": "Thrace"
|
||||
},
|
||||
{
|
||||
"coordinates": [1473, 509],
|
||||
"name": "Achaemenid Empire",
|
||||
"flag": "Achaemenid Empire"
|
||||
},
|
||||
{
|
||||
"coordinates": [702, 40],
|
||||
"name": "Thasos"
|
||||
},
|
||||
{
|
||||
"coordinates": [832, 1253],
|
||||
"name": "Cyclades"
|
||||
},
|
||||
{
|
||||
"coordinates": [479, 943],
|
||||
"name": "Athens",
|
||||
"flag": "Athens"
|
||||
},
|
||||
{
|
||||
"coordinates": [110, 1157],
|
||||
"name": "Sparta",
|
||||
"flag": "Sparta"
|
||||
},
|
||||
{
|
||||
"coordinates": [348, 56],
|
||||
"name": "Macedonia",
|
||||
"flag": "Macedonia"
|
||||
},
|
||||
{
|
||||
"coordinates": [175, 456],
|
||||
"name": "Thessaly"
|
||||
},
|
||||
{
|
||||
"coordinates": [71, 742],
|
||||
"name": "Aetolia"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 513 KiB |
@@ -62,6 +62,7 @@ var maps = []struct {
|
||||
{Name: "sierpinski"},
|
||||
{Name: "southamerica"},
|
||||
{Name: "straitofgibraltar"},
|
||||
{Name: "straitofhormuz"},
|
||||
{Name: "surrounded"},
|
||||
{Name: "svalmel"},
|
||||
{Name: "world"},
|
||||
@@ -77,6 +78,7 @@ var maps = []struct {
|
||||
{Name: "niledelta"},
|
||||
{Name: "arctic"},
|
||||
{Name: "sanfrancisco"},
|
||||
{Name: "aegean"},
|
||||
{Name: "big_plains", IsTest: true},
|
||||
{Name: "half_land_half_ocean", IsTest: true},
|
||||
{Name: "ocean_and_land", IsTest: true},
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"ip-anonymize": "^0.1.0",
|
||||
"jose": "^6.0.10",
|
||||
"js-yaml": "^4.1.1",
|
||||
"limiter": "^3.0.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"node-html-parser": "^7.0.2",
|
||||
"obscenity": "^0.4.3",
|
||||
@@ -9068,6 +9069,12 @@
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/limiter": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-3.0.0.tgz",
|
||||
"integrity": "sha512-hev7DuXojsTFl2YwyzUJMDnZ/qBDd3yZQLSH3aD4tdL1cqfc3TMnoecEJtWFaQFdErZsKoFMBTxF/FBSkgDbEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz",
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"ip-anonymize": "^0.1.0",
|
||||
"jose": "^6.0.10",
|
||||
"js-yaml": "^4.1.1",
|
||||
"limiter": "^3.0.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"node-html-parser": "^7.0.2",
|
||||
"obscenity": "^0.4.3",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">
|
||||
<image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAADICAYAAACZBDirAAAAAXNSR0IArs4c6QAADT9JREFUeF7tmL3L3/UZRu+oQwQpXSQERXAoGrOJHd1byODm4AvGP8PBRejYP0FFcRNc3RycHEpdHayD+AJiQUjFV6i/YtoOpf7u4AnneX5HEASfz5X7e65wcZIL859/7piZyzNz38w8NDN3/df/6z8jEIEInGUC38/MBzPz+cx8NjM/HD7mws9f9ODMPDszT8zMwzNz8Sx/abdHIAIR+B8EvpmZD2fmrZl5eWY+OgzgwfRenJkXZubOsEUgAhE45wR+/GnrXjr8exjA+2fm7Zm5es4/us+LQAQicJPA+zNz7TCAj83Mu/2xt98ZEYjACRH4dmYePwzg0zPz+gl9eJ8agQhE4EDgmcMAPjczr8QjAhGIwIkRuN4AnljjfW4EIvBvAg1gvxkiEIGTJdAAnmz1fXgEInD8AN59+dG5eO+VkEkIPH/tkXng0j2Sa077jO8++Xi+fOO104Yg+vr3bnw973x145iLjh/A3159cn7zuz8eE9rP3AYCb/7pD/P7K5duw6/UL/FLBP7x17/M364/9Us/1v+/TQRe/eLv8+dPvzjmV2sAj6Fk/JkG0NNKA+jp4nBJA+jqA7mmAUSw3lJoA3hL2LBHDSCG1hPcAHq6aAA9XWSAri6waxpADO06uAFcI0MfZIAoXkd4A+jo4XBFA+jpIgN0dYFd0wBiaNfBDeAaGfogA0TxOsIbQEcPGaCnh5uXNIC+Tn71ixrAXx3pLQdmgLeMDnnYACJYXaENoKePBtDTRX8H6OoCu6YBxNCugxvANTL0QQaI4nWEN4COHvo7QE8P/R2grwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTQwbo6wK7qAHE0K6DM8A1MvRBBojidYQ3gI4eMkBPDxmgrwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTQwbo6wK7qAHE0K6DM8A1MvRBBojidYQ3gI4eMkBPDxmgrwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTQwbo6wK7qAHE0K6DM8A1MvRBBojidYQ3gI4eMkBPDxmgrwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTQwbo6wK7qAHE0K6DM8A1MvRBBojidYQ3gI4eMkBPDxmgrwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTQwbo6wK7qAHE0K6DM8A1MvRBBojidYQ3gI4eMkBPDxmgrwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTQwbo6wK7qAHE0K6DM8A1MvRBBojidYQ3gI4eMkBPDxmgrwvsogYQQ7sOzgDXyNAHGSCK1xHeADp6yAA9PWSAvi6wixpADO06OANcI0MfZIAoXkd4A+joIQP09JAB+rrALmoAMbTr4AxwjQx9kAGieB3hDaCjhwzQ00MG6OsCu6gBxNCugzPANTL0QQaI4nWEN4COHjJATw8ZoK8L7KIGEEO7Ds4A18jQBxkgitcR3gA6esgAPT1kgL4usIsaQAztOjgDXCNDH2SAKF5HeAPo6CED9PSQAfq6wC5qADG06+AMcI0MfZABongd4Q2go4cM0NNDBujrAruoAcTQroMzwDUy9EEGiOJ1hDeAjh4yQE8PGaCvC+yiBhBDuw7OANfI0AcZIIrXEd4AOnrIAD09ZIC+LrCLGkAM7To4A1wjQx9kgCheR3gD6OghA/T0kAH6usAuagAxtOvgDHCNDH2QAaJ4HeENoKOHDNDTA2qAd19+dC7ee8X3tSd60fPXHpkHLt1zol/v+uzvPvl4vnzjNddRJ3zNeze+nne+unEMgesXZua5mXnlmJ/uZyIQgQicIwIN4Dkqs0+JQAR2BBrAHa9+OgIROEcEGsBzVGafEoEI7Aj8awCfnpnXd+/66QhEIAJnnsAzhwF8bGbenZmLZ/5z+oAIRCACxxH4dmYePwzg/TPz9sxcPe5dPxWBCETgzBN4f2auHQbwrpl5cWZemJk7z/xn9QERiEAE/j+BH3/aupcO/x4G8PDPgzPz7Mw8MTMP98fhfv9EIALnkMA3M/PhzLw1My/PzEc3B/DwrXfMzOWZuW9mHvrZDM8hgz4pAhE4QQLfz8wHM/P5zHw2Mz8cGPwTh+6ZLJADflkAAAAASUVORK5CYII=" x="7.500" y="32.813" width="135.000" height="84.375" />
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150" width="150" height="150">
|
||||
<defs>
|
||||
<image width="136" height="86" id="img1" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIgAAABWCAMAAAAjUC4/AAAAAXNSR0IB2cksfwAAAFFQTFRFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwcOACZU////4QAPEwQDAwkRFwUEACZVAAAAAAAAAAAASSUfPAAAABt0Uk5TAAENE33U334X8//yDzkmOv//////////GfYRaC+kLgAAAJdJREFUeJzt2r0KwmAMhtHGliqIdPX+71AQof7wqRVxdZEEOc+c4UDWN7qI6LJrrUX0FSCrOTYFHN3zLbtbNmJpiOmSbVh6Qcbvv/OTy9Mbsr2mQvojCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICMhfQcb1ORPymQgWaKgzI93HIRvxaIo6U+Mq4+s7oOn23YbpTf8AAAAASUVORK5CYII="/>
|
||||
</defs>
|
||||
<style>
|
||||
</style>
|
||||
<use id="img1" href="#img1" x="7" y="32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 736 B |
@@ -89,6 +89,8 @@
|
||||
"hotkeys": "Hotkeys",
|
||||
"table_key": "Key",
|
||||
"table_action": "Action",
|
||||
"action_esc": "Closes menu. Cancels unit build preview.",
|
||||
"action_enter": "Builds unit under cursor",
|
||||
"action_alt_view": "Alternate view (terrain/countries)",
|
||||
"action_coordinate_grid": "Toggle coordinate grid overlay",
|
||||
"action_attack_altclick": "Attack (when left click is set to open menu)",
|
||||
@@ -193,6 +195,7 @@
|
||||
"infinite_gold": "Infinite gold",
|
||||
"infinite_troops": "Infinite troops",
|
||||
"compact_map": "Compact Map",
|
||||
"disable_alliances": "Disable alliances",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"max_timer_placeholder": "Mins",
|
||||
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
|
||||
@@ -338,7 +341,8 @@
|
||||
"alps": "Alps",
|
||||
"niledelta": "Nile Delta",
|
||||
"arctic": "Arctic",
|
||||
"sanfrancisco": "San Francisco"
|
||||
"sanfrancisco": "San Francisco",
|
||||
"aegean": "Aegean"
|
||||
},
|
||||
"map_categories": {
|
||||
"featured": "Featured",
|
||||
@@ -413,6 +417,7 @@
|
||||
"infinite_troops": "Infinite troops",
|
||||
"donate_troops": "Donate troops",
|
||||
"compact_map": "Compact Map",
|
||||
"disable_alliances": "Disable alliances",
|
||||
"enables_title": "Enable Settings",
|
||||
"player": "Player",
|
||||
"players": "Players",
|
||||
@@ -430,9 +435,12 @@
|
||||
"teams_Trios": "Trios (teams of 3)",
|
||||
"teams_Quads": "Quads (teams of 4)",
|
||||
"teams_Humans Vs Nations": "Humans vs Nations",
|
||||
"starting_gold": "Starting gold",
|
||||
"crowded": "Crowded modifier",
|
||||
"hard_nations": "Hard Nations",
|
||||
"gold_multiplier": "Gold multiplier",
|
||||
"gold_multiplier_placeholder": "2.0x",
|
||||
"starting_gold": "Starting Gold (Millions)",
|
||||
"starting_gold_placeholder": "5",
|
||||
"leave_confirmation": "Are you sure you want to leave the lobby?"
|
||||
},
|
||||
"team_colors": {
|
||||
@@ -477,7 +485,9 @@
|
||||
"compact_map": "Compact Map",
|
||||
"crowded": "Crowded",
|
||||
"hard_nations": "Hard Nations",
|
||||
"starting_gold": "{amount}M Starting Gold"
|
||||
"starting_gold": "{amount}M Starting Gold",
|
||||
"gold_multiplier": "x{amount} Gold Multiplier",
|
||||
"disable_alliances": "Alliances Disabled"
|
||||
},
|
||||
"select_lang": {
|
||||
"title": "Select Language"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "閉じる",
|
||||
"copy": "コピー",
|
||||
"paste": "ペースト",
|
||||
"back": "戻る",
|
||||
"available": "利用可能",
|
||||
"preset_max": "最大",
|
||||
@@ -20,44 +22,70 @@
|
||||
"target_dead_note": "排除されたプレイヤーにはリソースを送ることができません。",
|
||||
"none": "なし",
|
||||
"copied": "コピーに成功しました!",
|
||||
"click_to_copy": "クリックしてコピー"
|
||||
"click_to_copy": "クリックしてコピー",
|
||||
"enabled": "有効"
|
||||
},
|
||||
"main": {
|
||||
"title": "OpenFront (ALPHA)",
|
||||
"join_discord": "Discord",
|
||||
"login_discord": "Discordでログイン",
|
||||
"sign_in": "サインイン",
|
||||
"discord_avatar_alt": "Discordのプロフィールアバター",
|
||||
"user_avatar_alt": "{username}のアバター",
|
||||
"checking_login": "ログイン中...",
|
||||
"logged_in": "ログイン中!",
|
||||
"log_out": "ログアウト",
|
||||
"create": "ロビーを作成",
|
||||
"join": "ロビーに参加",
|
||||
"solo": "ソロ",
|
||||
"instructions": "説明書",
|
||||
"game_info": "ゲームの情報",
|
||||
"wiki": "ウィキ",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"terms_of_service": "利用規約",
|
||||
"copyright": "©️ OpenFront™ と貢献者",
|
||||
"reddit": "Reddit",
|
||||
"play": "プレイ",
|
||||
"news": "お知らせ",
|
||||
"store": "ストア",
|
||||
"store_new_badge": "NEW",
|
||||
"settings": "設定",
|
||||
"keys": "キー設定",
|
||||
"stats": "統計",
|
||||
"leaderboard": "ランキング",
|
||||
"account": "アカウント",
|
||||
"help": "ヘルプ",
|
||||
"menu": "メニュー",
|
||||
"pick_pattern": "模様を選択してください!"
|
||||
"troubleshooting": "トラブルシューティング",
|
||||
"go_to_troubleshooting": "トラブルシューティングページに移動"
|
||||
},
|
||||
"news": {
|
||||
"github_link": "GitHub上で",
|
||||
"title": "更新情報"
|
||||
},
|
||||
"troubleshooting": {
|
||||
"title": "トラブルシューティング",
|
||||
"environment": "環境設定",
|
||||
"rendering": "描画",
|
||||
"power": "パワー",
|
||||
"browser": "ブラウザ",
|
||||
"platform": "プラットフォーム",
|
||||
"copied_to_clipboard": "クリップボードに情報がコピーされました!ヘルプ\nが必要な場合は、Discordで共有してください。",
|
||||
"os": "OS",
|
||||
"device_pixel_ratio": "デバイスのピクセル比",
|
||||
"chromium_tip": "OpenFrontはChromiumベースのブラウザで最適に動作します。",
|
||||
"hardware_acceleration_tip": "良いパフォーマンスを得るために、ブラウザの設定でハードウェアアクセラレーションが有効になっていることを確認してください。",
|
||||
"renderer": "レンダラー",
|
||||
"max_texture_size": "最大テクスチャサイズ",
|
||||
"high_precision_shaders": "高精度シェーダー",
|
||||
"gpu": "GPU",
|
||||
"unavailable": "使用不可",
|
||||
"gpu_tip": "単体GPUがある場合は、表示されているのが単体GPUか確認してください。",
|
||||
"battery": "バッテリー",
|
||||
"charging": "充電中",
|
||||
"battery_level": "バッテリー残量",
|
||||
"power_saving_tip": "ブラウザが省電力モードに設定されていないことを確認してください。",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"unknown": "不明",
|
||||
"software_rendering": "ソフトウェアレンダリング",
|
||||
"canvas_2d_no_gpu": "Canvas 2D(GPUなし)"
|
||||
},
|
||||
"help_modal": {
|
||||
"video_tutorial": "チュートリアル動画",
|
||||
"video_tutorial_title": "OpenFront.io チュートリアル",
|
||||
"hotkeys": "ホットキー",
|
||||
"table_key": "キー",
|
||||
"table_action": "アクション",
|
||||
@@ -77,7 +105,6 @@
|
||||
"ui_leaderboard_desc": "このゲームのトッププレイヤーとその名前、占有した土地の割合、ゴールド、軍隊数を表示します。「すべて表示」を使うと、ゲーム内の全プレイヤーが表示されます。リーダーボードを見たくない場合は、「非表示」をクリックしてください。",
|
||||
"ui_control": "コントロールパネル",
|
||||
"ui_control_desc": "コントロールパネルには以下が含まれます:",
|
||||
"ui_pop": "人口 - 現在のユニット数、最大人口、増加速度を表示。",
|
||||
"ui_gold": "資産 - 所持金と増加速度を表示。",
|
||||
"ui_attack_ratio": "攻撃比率 - 攻撃時に使用する兵力の割合で、スライダーで調整でき、攻撃側の兵力が防御側より多いほど損失が減り、少ないと攻撃側の損害が増えますが、この効果は攻撃と防御の比率が2対1を超えるとそれ以上強化されません。\n",
|
||||
"ui_events": "イベントパネル",
|
||||
@@ -97,12 +124,10 @@
|
||||
"radial_title": "円形メニュー",
|
||||
"radial_desc": "右クリック(またはモバイルでタッチ)すると円形メニューが開きます。右クリックすると、円形メニューを閉じます。メニューから、次のようにできます:",
|
||||
"radial_build": "ビルドメニューを開く。",
|
||||
"radial_attack": "攻撃メニューを開く。",
|
||||
"radial_info": "情報メニューを開く。",
|
||||
"radial_boat": "ボート(輸送船)を派遣して、指定した場所を攻撃します。領地が水辺に接している場合にのみ使用可能です。",
|
||||
"radial_donate_troops": "円形メニューを開いた味方に、攻撃比率スライダーのパーセンテージに相当する軍隊を寄付します。",
|
||||
"radial_donate_gold": "資金寄付スライダー:メニューが開き、味方に資金を素早く送信できるようになります。",
|
||||
"radial_close": "メニューを閉じる。",
|
||||
"info_title": "情報メニュー",
|
||||
"info_enemy_desc": "選択されたプレイヤーの名前、所持金、軍隊数、「あなたとの貿易停止」状態、あなたへの核攻撃の有無、裏切り者かどうかなどの情報を含みます。「貿易停止」とは、相手からのゴールドが受け取れず、相手も貿易船を通じてあなたにゴールドを送らなくなることを意味します。これは、手動(プレイヤーが「貿易を停止」をクリックした場合。両者が「貿易を再開」をクリックするまで継続)または、自動(同盟を裏切った場合。再度同盟になるか、5分経過するまで継続)で発生します。「裏切り者」は、そのプレイヤーが同盟中のプレイヤーを攻撃して裏切った場合に、30秒間「Yes」と表示されます。\n下のアイコンは、以下のプレイヤー間のやりとりを表しています:",
|
||||
"info_chat": "クイックチャットメッセージをプレイヤーに送信します。カテゴリを選び、フレーズを選択してください。フレーズに [P1] が含まれている場合は、それを置き換えるプレイヤー名を選んでください。「送信」をクリックするとメッセージが送られます。",
|
||||
@@ -141,6 +166,7 @@
|
||||
"build_mirv": "MIRV",
|
||||
"build_mirv_desc": "ゲーム中最強の爆弾。複数の小型爆弾に分裂し、広範囲を攻撃します。最初に指定したプレイヤーのみを攻撃し、最寄りのミサイル格納庫から発射されます。",
|
||||
"player_icons": "プレイヤーアイコン",
|
||||
"troubleshooting_desc": "OpenFrontの再生中にパフォーマンスの問題、クラッシュ、その他の問題が発生した場合は、トラブルシューティングページをご覧ください。問題の診断と修正に役立ちます:",
|
||||
"icon_desc": "ゲーム内アイコンとその意味:",
|
||||
"icon_crown": "王冠 - このプレイヤーが現在1位のときに表示されます。",
|
||||
"icon_traitor": "壊れた盾 - このプレイヤーが同盟者に攻撃をしたときに表示されます。",
|
||||
@@ -149,18 +175,17 @@
|
||||
"icon_request": "メール - このプレイヤーがあなたへ同盟の申込みをしているときに表示されます。",
|
||||
"info_enemy_panel": "敵の情報パネル",
|
||||
"exit_confirmation": "本当にゲームを終了しますか?",
|
||||
"bomb_direction": "原子爆弾 / 水素爆弾の軌道の向き"
|
||||
"bomb_direction": "原子爆弾 / 水素爆弾の軌道の向き",
|
||||
"icon_alt_player_leaderboard": "プレーヤーのリーダーボードのアイコン",
|
||||
"icon_alt_team_leaderboard": "チームのリーダーボードのアイコン"
|
||||
},
|
||||
"single_modal": {
|
||||
"title": "ソロ",
|
||||
"random_spawn": "ランダムスポーン",
|
||||
"allow_alliances": "同盟を許可",
|
||||
"toggle_achievements": "実績の表示の切り替え",
|
||||
"sign_in_for_achievements": "実績を確認するにはサインインしてください",
|
||||
"options_title": "オプション",
|
||||
"bots": "ボット数: ",
|
||||
"bots_disabled": "無効",
|
||||
"nations": "国家",
|
||||
"disable_nations": "国家を無効化",
|
||||
"instant_build": "即時建設",
|
||||
"infinite_gold": "資金無限",
|
||||
@@ -169,9 +194,13 @@
|
||||
"max_timer": "ゲーム時間 (分)",
|
||||
"max_timer_placeholder": "分",
|
||||
"max_timer_invalid": "適切な最大プレイ時間(1~120分)を入力してください",
|
||||
"disable_nukes": "核兵器使用禁止",
|
||||
"enables_title": "機能の有効化",
|
||||
"start": "ゲーム開始"
|
||||
"start": "ゲーム開始",
|
||||
"options_changed_no_achievements": "カスタム設定 - 実績は無効です",
|
||||
"gold_multiplier": "取得ゴールドの倍率",
|
||||
"gold_multiplier_placeholder": "2倍",
|
||||
"starting_gold": "開始資金",
|
||||
"starting_gold_placeholder": "5000000"
|
||||
},
|
||||
"token_login_modal": {
|
||||
"title": "ログインしています…",
|
||||
@@ -194,17 +223,22 @@
|
||||
"not_found": "見つかりません",
|
||||
"clear_session": "セッションをクリア",
|
||||
"failed_to_send_recovery_email": "再設定メールを送信できませんでした",
|
||||
"enter_email_address": "メールアドレスを入力してください"
|
||||
"enter_email_address": "メールアドレスを入力してください",
|
||||
"personal_player_id": "プレイヤーID:"
|
||||
},
|
||||
"stats_modal": {
|
||||
"title": "ステータス",
|
||||
"clan_stats": "クランステータス",
|
||||
"loading": "ロード中…",
|
||||
"error": "クランステータスの読み込みに失敗しました",
|
||||
"no_stats": "クランステータスがありません",
|
||||
"leaderboard_modal": {
|
||||
"title": "リーダーボード",
|
||||
"ranked_tab": "1v1ランク",
|
||||
"clans_tab": "クラン",
|
||||
"refresh_time": "1時間ごとに更新",
|
||||
"loading": "読み込み中…",
|
||||
"error": "リーダーボードを読み込めませんでした",
|
||||
"no_stats": "利用可能な統計はありません",
|
||||
"no_data_yet": "まだデータはありません",
|
||||
"clan": "クラン",
|
||||
"games": "ゲーム",
|
||||
"player": "プレイヤー",
|
||||
"games": "ゲーム回数",
|
||||
"elo": "ELO",
|
||||
"win_score": "勝利スコア",
|
||||
"win_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた勝利",
|
||||
"loss_score": "敗北スコア",
|
||||
@@ -212,7 +246,8 @@
|
||||
"win_loss_ratio": "勝利/敗北",
|
||||
"ratio": "比率",
|
||||
"rank": "ランク",
|
||||
"try_again": "もう一度やり直してください"
|
||||
"try_again": "もう一度やり直してください",
|
||||
"your_ranking": "あなたのランキング"
|
||||
},
|
||||
"game_info_modal": {
|
||||
"title": "ゲームの詳細",
|
||||
@@ -224,9 +259,12 @@
|
||||
"total_gold": "合計",
|
||||
"all_gold": "合計資金",
|
||||
"trade": "貿易",
|
||||
"train_trade": "鉄道",
|
||||
"naval_trade": "貿易船",
|
||||
"conquest_gold": "征服したプレイヤーの資金数",
|
||||
"stolen_gold": "戦艦で盗んだ資金",
|
||||
"num_of_conquests": "征服したプレイヤーの数",
|
||||
"num_of_conquests_humans": "プレイヤーキル数",
|
||||
"num_of_conquests_bots": "ボットキル数",
|
||||
"duration": "間隔",
|
||||
"survival_time": "生存時間",
|
||||
"war": "戦争",
|
||||
@@ -239,6 +277,8 @@
|
||||
},
|
||||
"map": {
|
||||
"map": "地図",
|
||||
"featured": "おすすめ",
|
||||
"all": "すべて",
|
||||
"world": "世界",
|
||||
"giantworldmap": "巨大化した世界",
|
||||
"europe": "ヨーロッパ",
|
||||
@@ -281,14 +321,18 @@
|
||||
"manicouagan": "マニクアガン湖",
|
||||
"lemnos": "レムノス島",
|
||||
"sierpinski": "シェルピンスキー",
|
||||
"thebox": "箱庭",
|
||||
"twolakes": "二つの湖",
|
||||
"straitofhormuz": "ホルムズ海峡",
|
||||
"surrounded": "囲まれた島",
|
||||
"didier": "ディディエ",
|
||||
"didierfrance": "ディディエ(フランス)",
|
||||
"amazonriver": "アマゾン川"
|
||||
"amazonriver": "アマゾン川",
|
||||
"tradersdream": "海商人の夢",
|
||||
"hawaii": "ハワイ"
|
||||
},
|
||||
"map_categories": {
|
||||
"featured": "おすすめ",
|
||||
"continental": "大陸",
|
||||
"regional": "地域",
|
||||
"fantasy": "その他",
|
||||
@@ -302,10 +346,7 @@
|
||||
"private_lobby": {
|
||||
"title": "ランダム",
|
||||
"enter_id": "プライベートゲームに参加",
|
||||
"player": "人のプレイヤー",
|
||||
"players": "人のプレイヤー",
|
||||
"join_lobby": "ロビーに参加",
|
||||
"checking": "ロビーを確認中...",
|
||||
"not_found": "ロビーが見つかりません。IDを確認してもう一度お試しください。",
|
||||
"error": "エラーが発生しました。もう一度試すか、サポートにお問い合わせください。",
|
||||
"joined_waiting": "ロビーに参加しました!ホストの開始を待っています…",
|
||||
@@ -313,18 +354,22 @@
|
||||
"disabled_units": "無効ユニット"
|
||||
},
|
||||
"public_lobby": {
|
||||
"title": "ゲーム開始を待っています…",
|
||||
"join": "次のゲームに参加",
|
||||
"waiting": "人が参加しています...",
|
||||
"teams_Duos": "{team_count}個の2人1組のチーム(デュオ)",
|
||||
"teams_Trios": "{team_count}個の3人1組のチーム(トリオ)",
|
||||
"teams_Quads": "{team_count}個の4人1組のチーム(クワッド)",
|
||||
"waiting_for_players": "プレイヤーを待っています",
|
||||
"connecting": "ロビーに接続しています…",
|
||||
"starting_in": "{time}で開始します",
|
||||
"starting_game": "ゲームを開始します...",
|
||||
"teams_hvn": "人類 vs 国家",
|
||||
"teams_hvn_detailed": "{num} 人類 vs {num} 国家",
|
||||
"teams": "{num}チーム",
|
||||
"players_per_team": "{num}人プレイヤー",
|
||||
"started": "開始しています"
|
||||
"started": "開始しています",
|
||||
"status": "状況",
|
||||
"join_timeout": "時間内にゲームに参加していませんでした。"
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "1v1ランクマッチを作成 (アルファ版) ",
|
||||
@@ -353,7 +398,6 @@
|
||||
"bots": "ボット数: ",
|
||||
"bots_disabled": "無効",
|
||||
"player_immunity_duration": "PVPの無敵時間(分)",
|
||||
"nations": "諸国: ",
|
||||
"disable_nations": "国家を無効化",
|
||||
"max_timer": "ゲーム時間 (分)",
|
||||
"mins_placeholder": "分",
|
||||
@@ -379,7 +423,9 @@
|
||||
"teams_Duos": "デュオ(2人1組)",
|
||||
"teams_Trios": "トリオ(3人1組)",
|
||||
"teams_Quads": "クワッド(4人1組)",
|
||||
"teams_Humans Vs Nations": "人類 vs 国家"
|
||||
"teams_Humans Vs Nations": "人類 vs 国家",
|
||||
"starting_gold": "開始資金",
|
||||
"crowded": "過密モード"
|
||||
},
|
||||
"team_colors": {
|
||||
"red": "赤",
|
||||
@@ -409,7 +455,9 @@
|
||||
},
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "ランダムスポーン",
|
||||
"compact_map": "コンパクトマップ"
|
||||
"compact_map": "コンパクトマップ",
|
||||
"crowded": "過密",
|
||||
"starting_gold": "資金5Mで開始"
|
||||
},
|
||||
"select_lang": {
|
||||
"title": "言語を選択"
|
||||
@@ -644,19 +692,15 @@
|
||||
"exit": "ゲームから退出",
|
||||
"keep": "観戦する",
|
||||
"spectate": "観戦する",
|
||||
"requeue": "もう一度プレイする",
|
||||
"wishlist": "Steamでウィッシュリストに追加して下さい!",
|
||||
"ofm_winter": "OpenFront 冬季マスターズトーナメント!",
|
||||
"ofm_winter_description": "競技トーナメントにして、最強のプレイヤーたちに挑もう",
|
||||
"join_tournament": "トーナメントに参加",
|
||||
"join_discord": "Discordコミュニティに参加しよう!",
|
||||
"discord_description": "プレイヤーとつながり、新しい機能を発見し、賞品を獲得しましょう!",
|
||||
"join_server": "サーバに入る",
|
||||
"youtube_tutorial": "ヘルプが必要ですか?"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "ランキング",
|
||||
"hide": "隠す",
|
||||
"rank": "順位",
|
||||
"player": "プレイヤー",
|
||||
"team": "チーム",
|
||||
"owned": "領土",
|
||||
@@ -669,36 +713,14 @@
|
||||
"show_control": "操作方法を表示",
|
||||
"show_units": "ユニットを表示"
|
||||
},
|
||||
"player_info_overlay": {
|
||||
"type": "タイプ",
|
||||
"bot": "ボット",
|
||||
"nation": "国家",
|
||||
"player": "プレイヤー",
|
||||
"team": "チーム",
|
||||
"alliance_timeout": "同盟終了まで",
|
||||
"troops": "軍隊",
|
||||
"maxtroops": "最大兵力",
|
||||
"a_troops": "攻撃兵士数",
|
||||
"gold": "資金",
|
||||
"ports": "港",
|
||||
"cities": "都市",
|
||||
"factories": "工場",
|
||||
"missile_launchers": "ミサイル格納庫",
|
||||
"sams": "SAM",
|
||||
"warships": "戦艦",
|
||||
"health": "体力",
|
||||
"attitude": "態度",
|
||||
"levels": "レベル",
|
||||
"wilderness_title": "荒野",
|
||||
"irradiated_wilderness_title": "放射線に汚染された荒野"
|
||||
},
|
||||
"events_display": {
|
||||
"events": "イベント",
|
||||
"retreating": "撤退中",
|
||||
"retaliate": "反撃する",
|
||||
"boat": "ボート",
|
||||
"alliance_request_status": "{name}が同盟のリクエストを{status}しました",
|
||||
"alliance_accepted": "承認",
|
||||
"alliance_rejected": "拒否",
|
||||
"alliance_nukes_destroyed_outgoing": "{count, plural,other {{name}に向けて発射された核ミサイル#発は、同盟関係により破壊されました}}",
|
||||
"alliance_nukes_destroyed_incoming": "{count, plural,other {{name}に向けて発射された核ミサイル#発は、同盟関係により破壊されました}}",
|
||||
"duration_second": "1秒",
|
||||
"betrayal_description": "あなたは{name}との同盟を破棄し、裏切り者になりました(防御力が{durationText}の間{malusPercent}%低下します)",
|
||||
"duration_seconds_plural": "{seconds} 秒",
|
||||
@@ -720,6 +742,8 @@
|
||||
"attack_cancelled_retreat": "攻撃はキャンセルされました、撤退中に{troops} 人の兵士が死亡しました",
|
||||
"received_gold_from_captured_ship": "{name} から捕獲した船から資金 {gold} を獲得しました",
|
||||
"received_gold_from_trade": "{name} との貿易で資金 {gold}を獲得しました",
|
||||
"received_gold_from_conquest": "{name}を征服して、{gold}ゴールドを獲得しました",
|
||||
"conquered_no_gold": "{name}を征服しました (相手がプレイしていなかったのでゴールドは獲得できません)",
|
||||
"missile_intercepted": "ミサイルが{unit}を迎撃しました",
|
||||
"mirv_warheads_intercepted": "{count, plural, other {{count}発の MIRV 弾頭を迎撃}}",
|
||||
"sent_troops_to_player": "{troops} の兵士を {name} に送信しました",
|
||||
@@ -731,15 +755,6 @@
|
||||
"unit_destroyed": "あなたの{unit}は破壊されました",
|
||||
"no_boats_available": "ボートをこれ以上出せません、最大は{max}隻までです"
|
||||
},
|
||||
"unit_info_modal": {
|
||||
"structure_info": "建造物情報",
|
||||
"unit_type_unknown": "不明",
|
||||
"close": "閉じる",
|
||||
"cooldown": "クールダウン",
|
||||
"type": "タイプ",
|
||||
"upgrade": "アップグレード",
|
||||
"level": "レベル"
|
||||
},
|
||||
"player_type": {
|
||||
"player": "プレイヤー",
|
||||
"nation": "国",
|
||||
@@ -752,11 +767,6 @@
|
||||
"friendly": "友好的",
|
||||
"default": "デフォルト"
|
||||
},
|
||||
"control_panel": {
|
||||
"gold": "資金",
|
||||
"troops": "兵士",
|
||||
"attack_ratio": "攻撃比率"
|
||||
},
|
||||
"player_panel": {
|
||||
"gold": "資金",
|
||||
"troops": "兵士",
|
||||
@@ -766,33 +776,36 @@
|
||||
"active": "取引中",
|
||||
"stopped": "停止中",
|
||||
"alliance_time_remaining": "同盟の有効期限までの残り時間",
|
||||
"embargo": "あなたが貿易制限の対象であるか",
|
||||
"nuke": "相手からあなたへの核攻撃数",
|
||||
"start_trade": "貿易を開始",
|
||||
"stop_trade": "貿易を停止",
|
||||
"stop_trade_all": "全ての国と貿易を停止する",
|
||||
"start_trade_all": "すべての国と貿易を開始する",
|
||||
"alliances": "同盟",
|
||||
"flag": "旗",
|
||||
"chat": "チャット",
|
||||
"target": "ターゲット",
|
||||
"break_alliance": "同盟を破棄",
|
||||
"alliance": "同盟",
|
||||
"send_alliance": "同盟を要請",
|
||||
"send_troops": "軍隊を送信",
|
||||
"send_gold": "資金を送信",
|
||||
"emotes": "絵文字",
|
||||
"moderation": "管理",
|
||||
"kick": "プレイヤーを追い出す",
|
||||
"kicked": "既に追い出されています",
|
||||
"kick_confirm": "{name}を追い出しますか?\n\n再参加ができなくなります。",
|
||||
"arc_up": "上向きの弧",
|
||||
"arc_down": "下向きの弧",
|
||||
"flip_rocket_trajectory": "ロケットの軌道を反転"
|
||||
},
|
||||
"kick_reason": {
|
||||
"duplicate_session": "ゲームから追い出されました(他のタブでもプレイしている可能性があります)",
|
||||
"lobby_creator": "ロビー作成者によってキックされました"
|
||||
},
|
||||
"send_troops_modal": {
|
||||
"title_with_name": "{name}へ軍隊を送信",
|
||||
"available_tooltip": "あなたの兵士数",
|
||||
"min_keep": "最小値を保つ",
|
||||
"slider_tooltip": "{percent}% • {amount}",
|
||||
"aria_slider": "軍隊スライダー",
|
||||
"capacity_note": "現在、受取主は {amount} のみ受け取ることができます。"
|
||||
"capacity_note": "現在、受取主は {amount}のみ受け取ることができます。"
|
||||
},
|
||||
"send_gold_modal": {
|
||||
"title_with_name": "{name}へゴールドを送信",
|
||||
@@ -835,7 +848,9 @@
|
||||
"choose_spawn": "スタート地点を選んで下さい",
|
||||
"random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…",
|
||||
"singleplayer_game_paused": "ゲームを一時停止",
|
||||
"multiplayer_game_paused": "ロビー作成者によってゲームが一時停止されました"
|
||||
"multiplayer_game_paused": "ロビー作成者によってゲームが一時停止されました",
|
||||
"pvp_immunity_active": "{seconds}秒間のPVPの無敵時間が有効です",
|
||||
"catching_up": "取戻し中…"
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "領土スキン",
|
||||
@@ -844,13 +859,16 @@
|
||||
"show_only_owned": "自分の領地",
|
||||
"all_owned": "すべてのスキンを手に入れました!新しいアイテムについては後ほどご確認ください。",
|
||||
"not_logged_in": "ログインされていません",
|
||||
"blocked": {
|
||||
"login": "スキンを解放するにはログインしてください",
|
||||
"purchase": "スキンを解放するには購入してください"
|
||||
},
|
||||
"pattern": {
|
||||
"default": "デフォルト"
|
||||
},
|
||||
"try_me": "お試しください!",
|
||||
"trial_remaining": "残り時間",
|
||||
"trial_granted": "領土スキンのトライアルが許可されました!",
|
||||
"trial_cooldown": "24時間ごとに1回お試しください。しばらくしてからもう一度お試しください。",
|
||||
"trial_login_required": "領土スキンを試すにはログインが必要です",
|
||||
"reward_countdown": "{seconds} 秒後に報酬を受け取れます…",
|
||||
"steam_wishlist_prompt": "Steamをウィッシュリストに追加してOpenFrontをサポートする",
|
||||
"select_skin": "スキンを選択",
|
||||
"selected": "選択済"
|
||||
},
|
||||
@@ -859,15 +877,6 @@
|
||||
"button_title": "国旗を選択!",
|
||||
"search_flag": "検索…"
|
||||
},
|
||||
"spawn_ad": {
|
||||
"loading": "広告を読み込み中…"
|
||||
},
|
||||
"auth": {
|
||||
"login_required": "このサイトにアクセスするにはログインが必要です。",
|
||||
"redirecting": "リダイレクト中…",
|
||||
"not_authorized": "このウェブサイトにアクセスする権限がありません。",
|
||||
"contact_admin": "エラーでこのメッセージが表示されていると思われる場合は、管理者にお問い合わせください。"
|
||||
},
|
||||
"radial_menu": {
|
||||
"delete_unit_title": "ユニットを削除する",
|
||||
"delete_unit_description": "クリックで最も近いユニットを削除します"
|
||||
@@ -926,7 +935,6 @@
|
||||
"replay": "リプレイ",
|
||||
"details": "詳細",
|
||||
"ranking": "ランキング",
|
||||
"started": "既に開始",
|
||||
"map": "地図",
|
||||
"difficulty": "難易度",
|
||||
"type": "タイプ"
|
||||
@@ -934,7 +942,7 @@
|
||||
"player_stats_tree": {
|
||||
"public": "公開",
|
||||
"private": "非公開",
|
||||
"singleplayer": "ソロ",
|
||||
"solo": "ソロ",
|
||||
"mode": "モード",
|
||||
"stats_wins": "勝利数",
|
||||
"stats_losses": "敗北数",
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Создание приватного лобби",
|
||||
"label": "Приватный",
|
||||
"label": "Приватно",
|
||||
"mode": "Режим",
|
||||
"team_count": "Количество команд",
|
||||
"team_type": "Тип команды",
|
||||
@@ -941,7 +941,7 @@
|
||||
},
|
||||
"player_stats_tree": {
|
||||
"public": "Публичный",
|
||||
"private": "Приватный",
|
||||
"private": "Приватно",
|
||||
"solo": "Соло",
|
||||
"mode": "Режим",
|
||||
"stats_wins": "Победы",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"build_defense": "Пункт оборони",
|
||||
"build_defense_desc": "Підсилює оборону навколо найближчих кордонів, що показано візерунком у клітинку. Атаки ворогів уповільнені та несуть більше жертв.",
|
||||
"build_port": "Порт",
|
||||
"build_port_desc": "Може бути збудований лише біля води. Дозволяє будувати військові кораблі. Автоматично відправляє торгові кораблі між портами вашої та інших країн (крім випадків, коли торгівлю припинено), даючи золото обом сторонам. Торгівля автоматично припиняється, коли ви атакуєте гравця або він атакує вас. Її буде відновлено через 5 хвилин або при укладанні союзу. Можна керувати торгівлею вручну за допомогою кнопок «Припинити торгівлю» та «Розпочати торгівлю».",
|
||||
"build_port_desc": "Може бути збудований лише біля води. Дозволяє будувати військові кораблі. Автоматично відправляє торгові кораблі між портами вашої країни та інших країн (окрім випадків, коли торгівлю припинено), даючи золото обом сторонам. Торгівля автоматично припиняється, коли ви атакуєте гравця або він атакує вас. Її буде відновлено через 5 хвилин або при укладанні союзу. Ви можете керувати торгівлею вручну за допомогою кнопок «Припинити торгівлю» та «Розпочати торгівлю».",
|
||||
"build_warship": "Військовий корабель",
|
||||
"build_warship_desc": "Розвідує територію, захоплюючи ворожі торгові кораблі й знищуючи їхні човни (транспортні кораблі) та військові кораблі. Зʼявляється з найближчого порту та розвідує ділянку, вибрану клацанням при створенні. Військовими кораблями можна керувати кнопкою атаки (див. дія «Атака» в розділі «Гарячі клавіші»): спочатку клацніть на корабель, а потім — на ділянку, до якої бажаєте його перемістити.",
|
||||
"build_silo": "Ракетна шахта",
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Створення приватного лобі",
|
||||
"label": "Приватний",
|
||||
"label": "Приватно",
|
||||
"mode": "Режим",
|
||||
"team_count": "Кількість команд",
|
||||
"team_type": "Тип команди",
|
||||
@@ -784,7 +784,7 @@
|
||||
"chat": "Чат",
|
||||
"target": "Ціль",
|
||||
"break_alliance": "Розірвати союз",
|
||||
"send_alliance": "Надіслати союз",
|
||||
"send_alliance": "Пропонувати союз",
|
||||
"send_troops": "Надіслати війська",
|
||||
"send_gold": "Надіслати золото",
|
||||
"emotes": "Емоджі",
|
||||
@@ -941,7 +941,7 @@
|
||||
},
|
||||
"player_stats_tree": {
|
||||
"public": "Публічний",
|
||||
"private": "Приватний",
|
||||
"private": "Приватно",
|
||||
"solo": "Соло",
|
||||
"mode": "Режим",
|
||||
"stats_wins": "Перемоги",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
"paste": "粘贴",
|
||||
"back": "返回",
|
||||
"available": "剩余",
|
||||
"preset_max": "最大",
|
||||
@@ -20,44 +22,70 @@
|
||||
"target_dead_note": "你不能向已淘汰玩家发送资源。",
|
||||
"none": "空",
|
||||
"copied": "已复制!",
|
||||
"click_to_copy": "点击复制"
|
||||
"click_to_copy": "点击复制",
|
||||
"enabled": "已启用"
|
||||
},
|
||||
"main": {
|
||||
"title": "OpenFront (内测版)",
|
||||
"join_discord": "Discord",
|
||||
"login_discord": "用 Discord 登录",
|
||||
"sign_in": "登录",
|
||||
"discord_avatar_alt": "Discord 头像",
|
||||
"user_avatar_alt": "{username} 的头像",
|
||||
"checking_login": "正在检查登录...",
|
||||
"logged_in": "登录成功!",
|
||||
"log_out": "退出登录",
|
||||
"create": "创建房间",
|
||||
"join": "加入房间",
|
||||
"solo": "单人模式",
|
||||
"instructions": "操作说明",
|
||||
"game_info": "游戏信息",
|
||||
"wiki": "游戏百科",
|
||||
"privacy_policy": "隐私政策",
|
||||
"terms_of_service": "服务条款",
|
||||
"copyright": "© OpenFront™ 和贡献者们",
|
||||
"reddit": "Reddit",
|
||||
"play": "游戏",
|
||||
"news": "公告",
|
||||
"store": "商店",
|
||||
"store_new_badge": "新的",
|
||||
"settings": "设置",
|
||||
"keys": "按键",
|
||||
"stats": "统计",
|
||||
"leaderboard": "排行榜",
|
||||
"account": "账号",
|
||||
"help": "帮助",
|
||||
"menu": "菜单",
|
||||
"pick_pattern": "选择一个图案!"
|
||||
"troubleshooting": "疑难解答",
|
||||
"go_to_troubleshooting": "移步到我们的疑难解答页面"
|
||||
},
|
||||
"news": {
|
||||
"github_link": "在 Github 上",
|
||||
"title": "发行说明"
|
||||
},
|
||||
"troubleshooting": {
|
||||
"title": "疑难解答",
|
||||
"environment": "环境",
|
||||
"rendering": "渲染",
|
||||
"power": "电源",
|
||||
"browser": "浏览器",
|
||||
"platform": "平台",
|
||||
"copied_to_clipboard": "信息已复制到剪贴板!如果你需要帮助,可以随时在我们的Discord上分享它。",
|
||||
"os": "操作系统",
|
||||
"device_pixel_ratio": "设备像素比",
|
||||
"chromium_tip": "OpenFront 在基于 Chromium 的浏览器上运行最佳。",
|
||||
"hardware_acceleration_tip": "确保在您的浏览器设置中启用硬件加速以实现最佳性能。",
|
||||
"renderer": "渲染器",
|
||||
"max_texture_size": "最大纹理尺寸",
|
||||
"high_precision_shaders": "高精度阴影",
|
||||
"gpu": "GPU",
|
||||
"unavailable": "不可用",
|
||||
"gpu_tip": "如果有的话,请验证这是否是独显。",
|
||||
"battery": "电池",
|
||||
"charging": "充电中",
|
||||
"battery_level": "电池电量",
|
||||
"power_saving_tip": "请确保您的浏览器没有设置为节能模式。",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"unknown": "未知",
|
||||
"software_rendering": "软件渲染",
|
||||
"canvas_2d_no_gpu": "Canvas 2D (无 GPU)"
|
||||
},
|
||||
"help_modal": {
|
||||
"video_tutorial": "视频教程",
|
||||
"video_tutorial_title": "OpenFront.io 教程",
|
||||
"hotkeys": "快捷键",
|
||||
"table_key": "键",
|
||||
"table_action": "动作",
|
||||
@@ -77,7 +105,6 @@
|
||||
"ui_leaderboard_desc": "显示游戏中的顶尖玩家及其姓名、所占领土百分比、黄金和军队数量。点击“显示全部”可以查看所有玩家的信息。如果你不想看到排行榜,点击“隐藏”即可。",
|
||||
"ui_control": "控制面板",
|
||||
"ui_control_desc": "控制面板包括下列元素:",
|
||||
"ui_pop": "人口 - 你拥有的单位数量、你的最大人口和你获得它们的速度。",
|
||||
"ui_gold": "黄金 - 你拥有的黄金数量和你获得它的速度。",
|
||||
"ui_attack_ratio": "攻击比例 - 攻击时使用的军队数量。你可以通过滑动条来调整攻击比例。进攻部队多于防守部队时,你在战斗中损失的部队会更少;而进攻部队少于防守部队时,你的部队会遭受更多伤害。不过,这种效果在比例超过2:1时不会继续增强。",
|
||||
"ui_events": "事件面板",
|
||||
@@ -97,12 +124,10 @@
|
||||
"radial_title": "环形菜单",
|
||||
"radial_desc": "右击 (或在手机上触摸) 来打开右键菜单。右击其他地方来关闭它。在该菜单中,你可以:",
|
||||
"radial_build": "打开建造菜单。",
|
||||
"radial_attack": "打开攻击菜单。",
|
||||
"radial_info": "打开信息菜单。",
|
||||
"radial_boat": "发送一艘运输船攻击选中的区域。仅当你与水域毗邻时才可用。",
|
||||
"radial_donate_troops": "捐赠相当于你攻击比例的军队给该盟友。",
|
||||
"radial_donate_gold": "打开黄金捐赠菜单,可快速向盟友发送黄金。",
|
||||
"radial_close": "关闭菜单。",
|
||||
"info_title": "信息菜单",
|
||||
"info_enemy_desc": "包含以下信息:所选玩家的名称、黄金数量、军队数量、是否已停止与你贸易、是否对你发射了核弹,以及该玩家是否为叛徒。“停止贸易”表示你将无法从该玩家处获得金币,对方也无法通过商船向你发送金币。这种状态可能是手动触发(该玩家点击了“停止贸易”,此状态将持续,直到你们双方都点击“开始贸易”)或自动触发(当你背叛了联盟时,此状态会持续,直到你们重新结盟或5分钟后自动结束)。当玩家背叛并攻击其盟友时,“叛徒”状态将显示为“是”,持续30秒。下方图标表示你与该玩家的互动关系:",
|
||||
"info_chat": "发送快速聊天消息给该玩家。选择一个类别、一句话,如果句子中包含 [P1],请选择一个玩家名字来替换它。点击发送。",
|
||||
@@ -141,6 +166,7 @@
|
||||
"build_mirv": "MIRV",
|
||||
"build_mirv_desc": "游戏中最强大的导弹。会分裂成多个小型导弹,覆盖大范围区域。只会对你最初点击发射时所选的玩家造成伤害。从最近的导弹发射井发射,并落在你最初点击发射的位置。",
|
||||
"player_icons": "玩家图标",
|
||||
"troubleshooting_desc": "如果您在游玩 OpenFront 时遇到性能问题、崩溃或其他问题,请访问我们的故障排除页面以帮助诊断和修复常见问题:",
|
||||
"icon_desc": "您将遇到的一些游戏内图标及其含义的示例:",
|
||||
"icon_crown": "皇冠 - 榜一玩家。这是排行榜上第一名的玩家。",
|
||||
"icon_traitor": "破损的盾牌 - 叛徒。该玩家攻击了盟友。",
|
||||
@@ -149,18 +175,17 @@
|
||||
"icon_request": "信封 - 结盟请求。该玩家已向你发送结盟请求。",
|
||||
"info_enemy_panel": "敌人信息面板",
|
||||
"exit_confirmation": "确定要退出游戏吗?",
|
||||
"bomb_direction": "原子弹 / 氢弹抛物线方向"
|
||||
"bomb_direction": "原子弹 / 氢弹抛物线方向",
|
||||
"icon_alt_player_leaderboard": "玩家排行榜图标",
|
||||
"icon_alt_team_leaderboard": "团队排行榜图标"
|
||||
},
|
||||
"single_modal": {
|
||||
"title": "单人模式",
|
||||
"random_spawn": "随机出生点",
|
||||
"allow_alliances": "允许结盟",
|
||||
"toggle_achievements": "切换成就",
|
||||
"sign_in_for_achievements": "登录以获取成就",
|
||||
"options_title": "选项",
|
||||
"bots": "机器人: ",
|
||||
"bots_disabled": "已禁用",
|
||||
"nations": "国家:",
|
||||
"disable_nations": "禁用国家",
|
||||
"instant_build": "立即建造",
|
||||
"infinite_gold": "无限黄金",
|
||||
@@ -169,9 +194,13 @@
|
||||
"max_timer": "游戏时长(分钟)",
|
||||
"max_timer_placeholder": "分钟",
|
||||
"max_timer_invalid": "请输入一个有效的最大计时器值(1-120分钟)",
|
||||
"disable_nukes": "禁用核弹",
|
||||
"enables_title": "启用设置",
|
||||
"start": "开始游戏"
|
||||
"start": "开始游戏",
|
||||
"options_changed_no_achievements": "自定义设置 - 成就已禁用",
|
||||
"gold_multiplier": "黄金乘数",
|
||||
"gold_multiplier_placeholder": "2.0x",
|
||||
"starting_gold": "开局黄金",
|
||||
"starting_gold_placeholder": "5000000"
|
||||
},
|
||||
"token_login_modal": {
|
||||
"title": "正在登录……",
|
||||
@@ -194,25 +223,31 @@
|
||||
"not_found": "未找到",
|
||||
"clear_session": "清除会话",
|
||||
"failed_to_send_recovery_email": "发送恢复邮件失败",
|
||||
"enter_email_address": "请输入电子邮件地址"
|
||||
"enter_email_address": "请输入电子邮件地址",
|
||||
"personal_player_id": "个人玩家 ID:"
|
||||
},
|
||||
"stats_modal": {
|
||||
"title": "统计",
|
||||
"clan_stats": "军团统计",
|
||||
"leaderboard_modal": {
|
||||
"title": "排行榜",
|
||||
"ranked_tab": "1v1 排位赛",
|
||||
"clans_tab": "军团",
|
||||
"refresh_time": "每 1 小时刷新一次",
|
||||
"loading": "正在加载……",
|
||||
"error": "加载军团统计数据时出错",
|
||||
"no_stats": "暂无军团统计数据",
|
||||
"error": "加载排行榜时出错",
|
||||
"no_stats": "暂无统计数据",
|
||||
"no_data_yet": "暂无数据",
|
||||
"clan": "军团",
|
||||
"player": "玩家",
|
||||
"games": "游戏场数",
|
||||
"elo": "ELO",
|
||||
"win_score": "胜者积分",
|
||||
"win_score_tooltip": "加权胜场数基于战队参与度和比赛难度计算",
|
||||
"win_score_tooltip": "加权胜场数基于军团参与度和比赛难度计算",
|
||||
"loss_score": "败者积分",
|
||||
"loss_score_tooltip": "加权败场数基于战队参与度和比赛难度计算",
|
||||
"loss_score_tooltip": "加权败场数基于军团参与度和比赛难度计算",
|
||||
"win_loss_ratio": "胜负比",
|
||||
"ratio": "比率",
|
||||
"rank": "排名",
|
||||
"try_again": "再试一次"
|
||||
"try_again": "再试一次",
|
||||
"your_ranking": "你的排名"
|
||||
},
|
||||
"game_info_modal": {
|
||||
"title": "游戏信息",
|
||||
@@ -224,9 +259,12 @@
|
||||
"total_gold": "总计",
|
||||
"all_gold": "总黄金",
|
||||
"trade": "交易",
|
||||
"train_trade": "火车",
|
||||
"naval_trade": "商船",
|
||||
"conquest_gold": "已抢夺黄金",
|
||||
"stolen_gold": "被军舰偷走",
|
||||
"num_of_conquests": "征服的玩家数",
|
||||
"num_of_conquests_humans": "玩家击杀数",
|
||||
"num_of_conquests_bots": "机器人击杀数",
|
||||
"duration": "时长",
|
||||
"survival_time": "存活时长",
|
||||
"war": "战争",
|
||||
@@ -239,6 +277,8 @@
|
||||
},
|
||||
"map": {
|
||||
"map": "地图",
|
||||
"featured": "精选",
|
||||
"all": "全部",
|
||||
"world": "世界",
|
||||
"giantworldmap": "巨型世界地图",
|
||||
"europe": "欧洲",
|
||||
@@ -281,14 +321,18 @@
|
||||
"manicouagan": "马尼夸根陨石坑",
|
||||
"lemnos": "利姆诺斯岛",
|
||||
"sierpinski": "谢尔宾斯基分形",
|
||||
"thebox": "沙盒",
|
||||
"twolakes": "双湖",
|
||||
"straitofhormuz": "霍尔木兹海峡",
|
||||
"surrounded": "环岛",
|
||||
"didier": "迪迪埃",
|
||||
"didierfrance": "迪迪埃(法国)",
|
||||
"amazonriver": "亚马逊河"
|
||||
"amazonriver": "亚马逊河",
|
||||
"tradersdream": "商人之梦",
|
||||
"hawaii": "夏威夷"
|
||||
},
|
||||
"map_categories": {
|
||||
"featured": "精选",
|
||||
"continental": "大陆",
|
||||
"regional": "地区",
|
||||
"fantasy": "其他",
|
||||
@@ -302,10 +346,7 @@
|
||||
"private_lobby": {
|
||||
"title": "加入私人房间",
|
||||
"enter_id": "输入房间 ID",
|
||||
"player": "玩家",
|
||||
"players": "玩家",
|
||||
"join_lobby": "加入房间",
|
||||
"checking": "正在确认房间...",
|
||||
"not_found": "找不到房间。请检查 ID 然后重试。",
|
||||
"error": "发生错误。请再试一次或联系支持人员。",
|
||||
"joined_waiting": "房间已加入!等待房主开始游戏……",
|
||||
@@ -313,18 +354,22 @@
|
||||
"disabled_units": "禁用单位"
|
||||
},
|
||||
"public_lobby": {
|
||||
"title": "正在等待游戏开始……",
|
||||
"join": "加入下一场游戏",
|
||||
"waiting": "等待中的玩家",
|
||||
"teams_Duos": "{team_count} 个 2 人小队",
|
||||
"teams_Trios": "{team_count} 个 3 人小队",
|
||||
"teams_Quads": "{team_count} 个 4 人小队",
|
||||
"waiting_for_players": "正在等待玩家",
|
||||
"connecting": "正在连接到房间……",
|
||||
"starting_in": "在 {time} 后开始",
|
||||
"starting_game": "正在启动游戏……",
|
||||
"teams_hvn": "人类 VS 国家",
|
||||
"teams_hvn_detailed": "{num} 个人类 VS {num} 个国家",
|
||||
"teams": "{num} 个队伍",
|
||||
"players_per_team": "每队 {num} 人",
|
||||
"started": "已开始"
|
||||
"started": "已开始",
|
||||
"status": "状态",
|
||||
"join_timeout": "您没有及时进入游戏。"
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "1v1 排位赛(内测版)",
|
||||
@@ -353,7 +398,6 @@
|
||||
"bots": "机器人: ",
|
||||
"bots_disabled": "禁用",
|
||||
"player_immunity_duration": "PVP 豁免期限(分钟)",
|
||||
"nations": "国家:",
|
||||
"disable_nations": "禁用国家",
|
||||
"max_timer": "游戏时长(分钟)",
|
||||
"mins_placeholder": "分钟",
|
||||
@@ -379,7 +423,9 @@
|
||||
"teams_Duos": "2人小队",
|
||||
"teams_Trios": "3人小队",
|
||||
"teams_Quads": "4人小队",
|
||||
"teams_Humans Vs Nations": "人类 VS 国家"
|
||||
"teams_Humans Vs Nations": "人类 VS 国家",
|
||||
"starting_gold": "开局黄金",
|
||||
"crowded": "密度修改器"
|
||||
},
|
||||
"team_colors": {
|
||||
"red": "红色",
|
||||
@@ -409,7 +455,9 @@
|
||||
},
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "随机出生点",
|
||||
"compact_map": "紧凑地图"
|
||||
"compact_map": "紧凑地图",
|
||||
"crowded": "密度",
|
||||
"starting_gold": "开局黄金 5M"
|
||||
},
|
||||
"select_lang": {
|
||||
"title": "选择语言"
|
||||
@@ -644,19 +692,15 @@
|
||||
"exit": "退出游戏",
|
||||
"keep": "继续游戏",
|
||||
"spectate": "观战",
|
||||
"requeue": "再玩一次",
|
||||
"wishlist": "将游戏加入 Steam 愿望单!",
|
||||
"ofm_winter": "OpenFront 大师冬季锦标赛!",
|
||||
"ofm_winter_description": "加入竞技比赛,与最强玩家一较高下",
|
||||
"join_tournament": "加入比赛",
|
||||
"join_discord": "加入我们的 Discord 社区!",
|
||||
"discord_description": "与玩家交流,发现新功能,并赢取奖品!",
|
||||
"join_server": "加入服务器",
|
||||
"youtube_tutorial": "需要帮助吗?"
|
||||
},
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"hide": "隐藏",
|
||||
"rank": "排名",
|
||||
"player": "玩家",
|
||||
"team": "队伍",
|
||||
"owned": "已占领",
|
||||
@@ -669,36 +713,14 @@
|
||||
"show_control": "显示面板",
|
||||
"show_units": "显示单位"
|
||||
},
|
||||
"player_info_overlay": {
|
||||
"type": "类型",
|
||||
"bot": "机器人",
|
||||
"nation": "国家",
|
||||
"player": "玩家",
|
||||
"team": "队伍",
|
||||
"alliance_timeout": "结盟剩余时长",
|
||||
"troops": "军队",
|
||||
"maxtroops": "最大军队",
|
||||
"a_troops": "进攻军队",
|
||||
"gold": "黄金",
|
||||
"ports": "港口",
|
||||
"cities": "城市",
|
||||
"factories": "工厂",
|
||||
"missile_launchers": "导弹发射井",
|
||||
"sams": "防空塔",
|
||||
"warships": "军舰",
|
||||
"health": "生命值",
|
||||
"attitude": "态度",
|
||||
"levels": "等级",
|
||||
"wilderness_title": "荒野",
|
||||
"irradiated_wilderness_title": "受辐射的荒野"
|
||||
},
|
||||
"events_display": {
|
||||
"events": "事件",
|
||||
"retreating": "正在撤退",
|
||||
"retaliate": "反击",
|
||||
"boat": "船",
|
||||
"alliance_request_status": "{name} {status}你的联盟请求",
|
||||
"alliance_accepted": "已接受",
|
||||
"alliance_rejected": "已拒绝",
|
||||
"alliance_nukes_destroyed_outgoing": "{count, plural, one {# 发送向 {name} 的核弹已由其盟军拦截} other {# 发送向 {name} 的核弹已由其盟军拦截}}",
|
||||
"alliance_nukes_destroyed_incoming": "{count, plural, one {# 由 {name} 发送的核弹已被目标盟军拦截} other {# 由 {name} 发送的核弹已被目标盟军拦截}}",
|
||||
"duration_second": "1 秒",
|
||||
"betrayal_description": "你已撕毁和 {name} 的盟约,因而被标记为“叛徒”(你将获得 {durationText} 的 {malusPercent}% 防御降低惩罚)",
|
||||
"duration_seconds_plural": "{seconds} 秒",
|
||||
@@ -720,6 +742,8 @@
|
||||
"attack_cancelled_retreat": "已取消进攻,在撤退时损失了 {troops} 兵力",
|
||||
"received_gold_from_captured_ship": "捕获了 {name} 的商船,获得 {gold} 黄金",
|
||||
"received_gold_from_trade": "与 {name} 贸易获得了 {gold} 黄金",
|
||||
"received_gold_from_conquest": "征服了 {name} 并获得 {gold} 黄金",
|
||||
"conquered_no_gold": "征服了 {name} (对方没在玩了,因此未掠夺到黄金)",
|
||||
"missile_intercepted": "已拦截导弹 {unit}",
|
||||
"mirv_warheads_intercepted": "{count, plural, one {{count} 个 MIRV 弹头被拦截} other {{count} 个 MIRV 弹头被拦截}}",
|
||||
"sent_troops_to_player": "已向 {name} 发送 {troops} 军队",
|
||||
@@ -731,15 +755,6 @@
|
||||
"unit_destroyed": "你的 {unit} 已被摧毁",
|
||||
"no_boats_available": "无可用船,最多 {max} 个"
|
||||
},
|
||||
"unit_info_modal": {
|
||||
"structure_info": "建筑信息",
|
||||
"unit_type_unknown": "未知",
|
||||
"close": "关闭",
|
||||
"cooldown": "冷却时间",
|
||||
"type": "类型",
|
||||
"upgrade": "升级",
|
||||
"level": "等级"
|
||||
},
|
||||
"player_type": {
|
||||
"player": "玩家",
|
||||
"nation": "国家",
|
||||
@@ -752,11 +767,6 @@
|
||||
"friendly": "友好",
|
||||
"default": "默认"
|
||||
},
|
||||
"control_panel": {
|
||||
"gold": "黄金",
|
||||
"troops": "军队",
|
||||
"attack_ratio": "攻击比例"
|
||||
},
|
||||
"player_panel": {
|
||||
"gold": "黄金",
|
||||
"troops": "军队",
|
||||
@@ -766,30 +776,33 @@
|
||||
"active": "活跃",
|
||||
"stopped": "终止",
|
||||
"alliance_time_remaining": "结盟有效期截至",
|
||||
"embargo": "已停止与你交易",
|
||||
"nuke": "他们向你发射的核弹",
|
||||
"start_trade": "开启交易",
|
||||
"stop_trade": "停止交易",
|
||||
"stop_trade_all": "与所有人停止交易",
|
||||
"start_trade_all": "与所有人开展交易",
|
||||
"alliances": "盟友",
|
||||
"flag": "旗帜",
|
||||
"chat": "聊天",
|
||||
"target": "目标",
|
||||
"break_alliance": "撕毁盟约",
|
||||
"alliance": "结盟",
|
||||
"send_alliance": "请求结盟",
|
||||
"send_troops": "发送军队",
|
||||
"send_gold": "发送黄金",
|
||||
"emotes": "表情符号",
|
||||
"moderation": "管理",
|
||||
"kick": "踢出玩家",
|
||||
"kicked": "已被踢出",
|
||||
"kick_confirm": "要踢掉 {name} 吗?\n\n他无法再重新加入此游戏。",
|
||||
"arc_up": "向上的弧",
|
||||
"arc_down": "向下的弧",
|
||||
"flip_rocket_trajectory": "翻转火箭轨道"
|
||||
},
|
||||
"kick_reason": {
|
||||
"duplicate_session": "已被踢出游戏(也可能你正在另一个标签页上玩游戏)",
|
||||
"lobby_creator": "已被房主踢出房间"
|
||||
},
|
||||
"send_troops_modal": {
|
||||
"title_with_name": "向 {name} 发送军队",
|
||||
"available_tooltip": "你的剩余军队",
|
||||
"min_keep": "最小保留值",
|
||||
"slider_tooltip": "{percent}% • {amount}",
|
||||
"aria_slider": "军队滑块",
|
||||
"capacity_note": "接收者现在仅能接收 {amount}。"
|
||||
@@ -835,7 +848,9 @@
|
||||
"choose_spawn": "选择出生点",
|
||||
"random_spawn": "随机出生点已启用。正在为你选择出生点……",
|
||||
"singleplayer_game_paused": "游戏已暂停",
|
||||
"multiplayer_game_paused": "游戏已被房主暂停"
|
||||
"multiplayer_game_paused": "游戏已被房主暂停",
|
||||
"pvp_immunity_active": "PVP 免疫生效,持续 {seconds} 秒",
|
||||
"catching_up": "正在追赶进度……"
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "皮肤",
|
||||
@@ -844,13 +859,16 @@
|
||||
"show_only_owned": "我的皮肤",
|
||||
"all_owned": "您已拥有所有皮肤!请稍后再来查看新皮肤。",
|
||||
"not_logged_in": "未登录",
|
||||
"blocked": {
|
||||
"login": "您必须登录才能使用此皮肤。",
|
||||
"purchase": "购买以解锁此皮肤。"
|
||||
},
|
||||
"pattern": {
|
||||
"default": "默认"
|
||||
},
|
||||
"try_me": "试试我!",
|
||||
"trial_remaining": "剩余",
|
||||
"trial_granted": "皮肤试用获得了批准!",
|
||||
"trial_cooldown": "每24小时只能试用一次。请稍后再试。",
|
||||
"trial_login_required": "必须登录才能试用皮肤",
|
||||
"reward_countdown": "在 {seconds} 秒内获得奖励……",
|
||||
"steam_wishlist_prompt": "请将 OpenFront 添加到 Steam 愿望单来支持我们",
|
||||
"select_skin": "选择皮肤",
|
||||
"selected": "已选择"
|
||||
},
|
||||
@@ -859,15 +877,6 @@
|
||||
"button_title": "选一个旗帜!",
|
||||
"search_flag": "搜索……"
|
||||
},
|
||||
"spawn_ad": {
|
||||
"loading": "正在加载广告……"
|
||||
},
|
||||
"auth": {
|
||||
"login_required": "需要登录才能访问此网站。",
|
||||
"redirecting": "正在将您重新定向……",
|
||||
"not_authorized": "您没有权限访问此网站。",
|
||||
"contact_admin": "如果您认为您看到此消息有误,请与网站管理员联系。"
|
||||
},
|
||||
"radial_menu": {
|
||||
"delete_unit_title": "删除单位",
|
||||
"delete_unit_description": "点击删除最近的单位"
|
||||
@@ -926,7 +935,6 @@
|
||||
"replay": "回放",
|
||||
"details": "详情",
|
||||
"ranking": "排行",
|
||||
"started": "已开始",
|
||||
"map": "地图",
|
||||
"difficulty": "难度",
|
||||
"type": "类型"
|
||||
@@ -934,7 +942,7 @@
|
||||
"player_stats_tree": {
|
||||
"public": "公开",
|
||||
"private": "私有",
|
||||
"singleplayer": "单人模式",
|
||||
"solo": "单挑",
|
||||
"mode": "模式",
|
||||
"stats_wins": "胜场数",
|
||||
"stats_losses": "败场数",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 2000,
|
||||
"num_land_tiles": 1044110,
|
||||
"width": 1600
|
||||
},
|
||||
"map16x": {
|
||||
"height": 500,
|
||||
"num_land_tiles": 60226,
|
||||
"width": 400
|
||||
},
|
||||
"map4x": {
|
||||
"height": 1000,
|
||||
"num_land_tiles": 253795,
|
||||
"width": 800
|
||||
},
|
||||
"name": "aegean",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [786, 1860],
|
||||
"name": "Crete"
|
||||
},
|
||||
{
|
||||
"coordinates": [1554, 1530],
|
||||
"name": "Rhodes"
|
||||
},
|
||||
{
|
||||
"coordinates": [1051, 539],
|
||||
"name": "Lesbos"
|
||||
},
|
||||
{
|
||||
"coordinates": [1070, 820],
|
||||
"name": "Chios"
|
||||
},
|
||||
{
|
||||
"coordinates": [1235, 1023],
|
||||
"name": "Samos"
|
||||
},
|
||||
{
|
||||
"coordinates": [1193, 301],
|
||||
"name": "Troy"
|
||||
},
|
||||
{
|
||||
"coordinates": [1446, 954],
|
||||
"name": "Ephesus"
|
||||
},
|
||||
{
|
||||
"coordinates": [1515, 1223],
|
||||
"name": "Miletus"
|
||||
},
|
||||
{
|
||||
"coordinates": [824, 305],
|
||||
"name": "Lemnos"
|
||||
},
|
||||
{
|
||||
"coordinates": [1312, 37],
|
||||
"name": "Thrace"
|
||||
},
|
||||
{
|
||||
"coordinates": [1473, 509],
|
||||
"flag": "Achaemenid Empire",
|
||||
"name": "Achaemenid Empire"
|
||||
},
|
||||
{
|
||||
"coordinates": [702, 40],
|
||||
"name": "Thasos"
|
||||
},
|
||||
{
|
||||
"coordinates": [832, 1253],
|
||||
"name": "Cyclades"
|
||||
},
|
||||
{
|
||||
"coordinates": [479, 943],
|
||||
"flag": "Athens",
|
||||
"name": "Athens"
|
||||
},
|
||||
{
|
||||
"coordinates": [110, 1157],
|
||||
"flag": "Sparta",
|
||||
"name": "Sparta"
|
||||
},
|
||||
{
|
||||
"coordinates": [348, 56],
|
||||
"flag": "Macedonia",
|
||||
"name": "Macedonia"
|
||||
},
|
||||
{
|
||||
"coordinates": [175, 456],
|
||||
"name": "Thessaly"
|
||||
},
|
||||
{
|
||||
"coordinates": [71, 742],
|
||||
"name": "Aetolia"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -1,284 +1,240 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1828,
|
||||
"num_land_tiles": 1678775,
|
||||
"num_land_tiles": 1679064,
|
||||
"width": 1828
|
||||
},
|
||||
"map16x": {
|
||||
"height": 457,
|
||||
"num_land_tiles": 97927,
|
||||
"num_land_tiles": 97929,
|
||||
"width": 457
|
||||
},
|
||||
"map4x": {
|
||||
"height": 914,
|
||||
"num_land_tiles": 409832,
|
||||
"num_land_tiles": 409868,
|
||||
"width": 914
|
||||
},
|
||||
"name": "arctic_circle",
|
||||
"name": "Arctic",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [622, 1171],
|
||||
"flag": "gl",
|
||||
"name": "Greenland",
|
||||
"strength": 2
|
||||
"name": "Greenland"
|
||||
},
|
||||
{
|
||||
"coordinates": [632, 1438],
|
||||
"flag": "is",
|
||||
"name": "Iceland",
|
||||
"strength": 1
|
||||
"name": "Iceland"
|
||||
},
|
||||
{
|
||||
"coordinates": [90, 1046],
|
||||
"flag": "Quebec",
|
||||
"name": "Quebec",
|
||||
"strength": 1
|
||||
"name": "Quebec"
|
||||
},
|
||||
{
|
||||
"coordinates": [747, 336],
|
||||
"flag": "Alaska",
|
||||
"name": "Alaska",
|
||||
"strength": 2
|
||||
"name": "Alaska"
|
||||
},
|
||||
{
|
||||
"coordinates": [485, 927],
|
||||
"flag": "ca",
|
||||
"name": "Nunavut",
|
||||
"strength": 1
|
||||
"name": "Nunavut"
|
||||
},
|
||||
{
|
||||
"coordinates": [432, 550],
|
||||
"flag": "ca",
|
||||
"name": "Northwest Territories",
|
||||
"strength": 1
|
||||
"name": "Northwest Territories"
|
||||
},
|
||||
{
|
||||
"coordinates": [608, 447],
|
||||
"flag": "ca",
|
||||
"name": "Yukon",
|
||||
"strength": 1
|
||||
"name": "Yukon"
|
||||
},
|
||||
{
|
||||
"coordinates": [344, 320],
|
||||
"flag": "ca",
|
||||
"name": "British Columbia",
|
||||
"strength": 1
|
||||
"name": "British Columbia"
|
||||
},
|
||||
{
|
||||
"coordinates": [64, 601],
|
||||
"flag": "ca",
|
||||
"name": "Manitoba",
|
||||
"strength": 1
|
||||
"name": "Manitoba"
|
||||
},
|
||||
{
|
||||
"coordinates": [247, 571],
|
||||
"flag": "ca",
|
||||
"name": "Saskatchewan",
|
||||
"strength": 1
|
||||
"name": "Saskatchewan"
|
||||
},
|
||||
{
|
||||
"coordinates": [136, 401],
|
||||
"flag": "ca",
|
||||
"name": "Alberta",
|
||||
"strength": 1
|
||||
"name": "Alberta"
|
||||
},
|
||||
{
|
||||
"coordinates": [67, 795],
|
||||
"flag": "ca",
|
||||
"name": "Ontario",
|
||||
"strength": 2
|
||||
"name": "Ontario"
|
||||
},
|
||||
{
|
||||
"coordinates": [49, 1313],
|
||||
"flag": "ca",
|
||||
"name": "Newfoundland and Labrador",
|
||||
"strength": 1
|
||||
"name": "Newfoundland and Labrador"
|
||||
},
|
||||
{
|
||||
"coordinates": [74, 226],
|
||||
"flag": "us",
|
||||
"name": "United States of America",
|
||||
"strength": 3
|
||||
"name": "United States of America"
|
||||
},
|
||||
{
|
||||
"coordinates": [1457, 1381],
|
||||
"flag": "Communist flag",
|
||||
"name": "Soviet Union",
|
||||
"strength": 3
|
||||
"name": "Soviet Union"
|
||||
},
|
||||
{
|
||||
"coordinates": [1683, 1414],
|
||||
"flag": "Communist flag",
|
||||
"name": "Kazakh SSR",
|
||||
"strength": 2
|
||||
"name": "Kazakh SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1709, 1603],
|
||||
"flag": "Communist flag",
|
||||
"name": "Uzbek SSR",
|
||||
"strength": 1
|
||||
"name": "Uzbek SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1784, 1744],
|
||||
"flag": "Communist flag",
|
||||
"name": "Turkmen SSR",
|
||||
"strength": 2
|
||||
"name": "Turkmen SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1803, 1265],
|
||||
"flag": "Communist flag",
|
||||
"name": "Kirghiz SSR",
|
||||
"strength": 1
|
||||
"name": "Kirghiz SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [745, 1736],
|
||||
"flag": "gb",
|
||||
"name": "United Kingdom",
|
||||
"strength": 3
|
||||
"name": "United Kingdom"
|
||||
},
|
||||
{
|
||||
"coordinates": [893, 1773],
|
||||
"flag": "west_germany",
|
||||
"name": "West Germany",
|
||||
"strength": 1
|
||||
"name": "West Germany"
|
||||
},
|
||||
{
|
||||
"coordinates": [987, 1792],
|
||||
"flag": "east_germany",
|
||||
"name": "East Germany",
|
||||
"strength": 2
|
||||
"name": "East Germany"
|
||||
},
|
||||
{
|
||||
"coordinates": [1333, 1774],
|
||||
"flag": "Communist flag",
|
||||
"name": "Ukrainian SSR",
|
||||
"strength": 2
|
||||
"name": "Ukrainian SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1194, 1814],
|
||||
"flag": "Communist flag",
|
||||
"name": "Moldovan SSR",
|
||||
"strength": 1
|
||||
"name": "Moldovan SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1197, 1626],
|
||||
"flag": "Communist flag",
|
||||
"name": "Belorussian SSR",
|
||||
"strength": 1
|
||||
"name": "Belorussian SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1091, 1744],
|
||||
"flag": "pl",
|
||||
"name": "Poland",
|
||||
"strength": 2
|
||||
"name": "Poland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1805, 1486],
|
||||
"flag": "Communist flag",
|
||||
"name": "Tajik SSR",
|
||||
"strength": 1
|
||||
"name": "Tajik SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1442, 1807],
|
||||
"flag": "Communist flag",
|
||||
"name": "Georgian SSR",
|
||||
"strength": 1
|
||||
"name": "Georgian SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1573, 1790],
|
||||
"flag": "Communist flag",
|
||||
"name": "Azerbaijan SSR",
|
||||
"strength": 1
|
||||
"name": "Azerbaijan SSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1089, 1519],
|
||||
"flag": "fi",
|
||||
"name": "Finland",
|
||||
"strength": 2
|
||||
"name": "Finland"
|
||||
},
|
||||
{
|
||||
"coordinates": [987, 1538],
|
||||
"flag": "se",
|
||||
"name": "Sweden",
|
||||
"strength": 1
|
||||
"name": "Sweden"
|
||||
},
|
||||
{
|
||||
"coordinates": [889, 1587],
|
||||
"flag": "no",
|
||||
"name": "Norway",
|
||||
"strength": 1
|
||||
"name": "Norway"
|
||||
},
|
||||
{
|
||||
"coordinates": [1793, 156],
|
||||
"flag": "jp",
|
||||
"name": "Japan",
|
||||
"strength": 2
|
||||
"name": "Japan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1776, 517],
|
||||
"flag": "cn",
|
||||
"name": "China",
|
||||
"strength": 3
|
||||
"name": "China"
|
||||
},
|
||||
{
|
||||
"coordinates": [1792, 774],
|
||||
"flag": "mn",
|
||||
"name": "Mongolia",
|
||||
"strength": 1
|
||||
"name": "Mongolia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1773, 961],
|
||||
"flag": "Communist flag",
|
||||
"name": "Tannu Tuva",
|
||||
"strength": 2
|
||||
"name": "Tannu Tuva"
|
||||
},
|
||||
{
|
||||
"coordinates": [1142, 382],
|
||||
"flag": "Communist flag",
|
||||
"name": "Far East",
|
||||
"strength": 1
|
||||
"name": "Far East"
|
||||
},
|
||||
{
|
||||
"coordinates": [1410, 625],
|
||||
"flag": "Communist flag",
|
||||
"name": "Yakut ASSR",
|
||||
"strength": 1
|
||||
"name": "Yakut ASSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1817, 364],
|
||||
"flag": "kp",
|
||||
"name": "North Korea",
|
||||
"strength": 2
|
||||
"name": "North Korea"
|
||||
},
|
||||
{
|
||||
"coordinates": [1664, 689],
|
||||
"flag": "Communist flag",
|
||||
"name": "Buryat ASSR",
|
||||
"strength": 1
|
||||
"name": "Buryat ASSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1440, 1170],
|
||||
"flag": "Communist flag",
|
||||
"name": "Komi ASSR",
|
||||
"strength": 1
|
||||
"name": "Komi ASSR"
|
||||
},
|
||||
{
|
||||
"coordinates": [1383, 875],
|
||||
"flag": "Siberia",
|
||||
"name": "Siberia",
|
||||
"strength": 2
|
||||
"name": "Siberia"
|
||||
},
|
||||
{
|
||||
"coordinates": [950, 1174],
|
||||
"flag": "sj",
|
||||
"name": "Svalbard",
|
||||
"strength": 1
|
||||
"name": "Svalbard"
|
||||
},
|
||||
{
|
||||
"coordinates": [789, 1823],
|
||||
"flag": "fr",
|
||||
"name": "France",
|
||||
"strength": 1
|
||||
"name": "France"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -14,7 +14,7 @@
|
||||
"num_land_tiles": 228849,
|
||||
"width": 1000
|
||||
},
|
||||
"name": "britanniaclassic",
|
||||
"name": "Britannia Classic",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [960, 1258],
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
{
|
||||
"coordinates": [1609, 1837],
|
||||
"flag": "dz",
|
||||
"name": "Algeria'"
|
||||
"name": "Algeria"
|
||||
},
|
||||
{
|
||||
"coordinates": [1733, 622],
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -14,7 +14,7 @@
|
||||
"num_land_tiles": 1048576,
|
||||
"width": 1024
|
||||
},
|
||||
"name": "The Box",
|
||||
"name": "TheBox",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [10, 10],
|
||||
|
||||
@@ -11,6 +11,7 @@ const PERSISTENT_ID_KEY = "player_persistent_id";
|
||||
|
||||
let __jwt: string | null = null;
|
||||
let __refreshPromise: Promise<void> | null = null;
|
||||
let __expiresAt: number = 0;
|
||||
|
||||
export function discordLogin() {
|
||||
const redirectUri = encodeURIComponent(window.location.href);
|
||||
@@ -95,7 +96,7 @@ export async function userAuth(
|
||||
// });
|
||||
|
||||
const payload = decodeJwt(jwt);
|
||||
const { iss, aud, exp } = payload;
|
||||
const { iss, aud } = payload;
|
||||
|
||||
if (iss !== getApiBase()) {
|
||||
// JWT was not issued by the correct server
|
||||
@@ -110,8 +111,7 @@ export async function userAuth(
|
||||
logOut();
|
||||
return false;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (exp !== undefined && now >= exp - 3 * 60) {
|
||||
if (Date.now() >= __expiresAt - 3 * 60 * 1000) {
|
||||
console.log("jwt expired or about to expire");
|
||||
if (!shouldRefresh) {
|
||||
console.error("jwt expired and shouldRefresh is false");
|
||||
@@ -163,7 +163,8 @@ async function doRefreshJwt(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const json = await response.json();
|
||||
const { jwt } = json;
|
||||
const { jwt, expiresIn } = json;
|
||||
__expiresAt = Date.now() + expiresIn * 1000;
|
||||
console.log("Refresh succeeded");
|
||||
__jwt = jwt;
|
||||
} catch (e) {
|
||||
|
||||
@@ -94,7 +94,7 @@ export class FlagInput extends LitElement {
|
||||
></span>
|
||||
${showSelect
|
||||
? html`<span
|
||||
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
|
||||
class="text-[10px] font-medium tracking-wider text-white uppercase leading-none break-words w-full text-center px-1"
|
||||
>
|
||||
${translateText("flag_input.title")}
|
||||
</span>`
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { PublicGameInfo, PublicGames } from "../core/Schemas";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { HostLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
|
||||
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
|
||||
const CARD_BG = "bg-sky-950";
|
||||
|
||||
@customElement("game-mode-selector")
|
||||
export class GameModeSelector extends LitElement {
|
||||
@@ -125,7 +126,7 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-sky-600",
|
||||
"bg-sky-600 hover:bg-sky-500 active:bg-sky-700",
|
||||
)}
|
||||
</div>
|
||||
<!-- Create/ranked/join: mobile only, below solo -->
|
||||
@@ -133,17 +134,19 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
|
||||
)}
|
||||
${!crazyGamesSDK.isOnCrazyGames()
|
||||
? this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
|
||||
)
|
||||
: html`<div class="invisible"></div>`}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
|
||||
)}
|
||||
</div>
|
||||
<!-- Game cards grid -->
|
||||
@@ -196,7 +199,7 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-sky-600",
|
||||
"bg-sky-600 hover:bg-sky-500 active:bg-sky-700",
|
||||
)}
|
||||
</div>
|
||||
<!-- Bottom row: create + ranked + join (desktop only) -->
|
||||
@@ -204,17 +207,19 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
|
||||
)}
|
||||
${!crazyGamesSDK.isOnCrazyGames()
|
||||
? this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
|
||||
)
|
||||
: html`<div class="invisible"></div>`}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +260,7 @@ export class GameModeSelector extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
@click=${onClick}
|
||||
class="flex items-center justify-center w-full h-full rounded-xl ${bgClass} border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] text-sm lg:text-base font-bold text-white uppercase tracking-wider text-center"
|
||||
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-colors text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
|
||||
>
|
||||
${title}
|
||||
</button>
|
||||
@@ -306,8 +311,7 @@ export class GameModeSelector extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.validateAndJoin(lobby)}
|
||||
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]"
|
||||
style="background-color: color-mix(in oklab, var(--frenchBlue) 75%, black)"
|
||||
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] bg-sky-950"
|
||||
>
|
||||
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
|
||||
<div
|
||||
@@ -329,11 +333,11 @@ export class GameModeSelector extends LitElement {
|
||||
class="absolute inset-x-2 top-2 flex items-start justify-between gap-2"
|
||||
>
|
||||
${modifierLabels.length > 0
|
||||
? html`<div class="flex flex-col items-start gap-1">
|
||||
? html`<div class="flex flex-col items-start gap-1 mt-[2px]">
|
||||
${modifierLabels.map(
|
||||
(label) =>
|
||||
html`<span
|
||||
class="px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest bg-teal-600 text-white shadow-[0_0_6px_rgba(13,148,136,0.35)]"
|
||||
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-sky-600 text-white shadow-[0_0_6px_rgba(14,165,233,0.35)]"
|
||||
>${label}</span
|
||||
>`,
|
||||
)}
|
||||
@@ -343,7 +347,7 @@ export class GameModeSelector extends LitElement {
|
||||
<span
|
||||
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
|
||||
? "uppercase"
|
||||
: "normal-case"} bg-sky-600 px-2.5 py-1 rounded"
|
||||
: "normal-case"} bg-sky-600 text-white px-2 py-1 rounded"
|
||||
>${timeDisplay}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -20,24 +20,28 @@ export class GameStartingModal extends LitElement {
|
||||
: "opacity-0 invisible"}"
|
||||
></div>
|
||||
<div
|
||||
class="fixed top-1/2 left-1/2 bg-zinc-800/70 p-6 rounded-xl z-[9999] shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-[5px] text-white w-[300px] text-center transition-all duration-300 -translate-x-1/2 ${isVisible
|
||||
class="fixed top-1/2 left-1/2 bg-zinc-900/90 backdrop-blur-md border border-white/10 p-6 rounded-2xl z-[9999] shadow-2xl text-white w-[400px] text-center transition-all duration-300 -translate-x-1/2 ${isVisible
|
||||
? "opacity-100 visible -translate-y-1/2"
|
||||
: "opacity-0 invisible -translate-y-[48%]"}"
|
||||
>
|
||||
<div class="text-xl mt-5 mb-2.5 px-0">
|
||||
<div
|
||||
class="text-base font-medium tracking-wider uppercase text-white/40 mb-3"
|
||||
>
|
||||
© OpenFront and Contributors
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block mt-2.5 mb-4 text-xl text-blue-400 no-underline transition-colors duration-200 hover:text-blue-300 hover:underline"
|
||||
class="block mb-4 text-lg font-medium tracking-wider uppercase text-sky-400 no-underline transition-colors duration-200 hover:text-sky-300"
|
||||
>${translateText("game_starting_modal.credits")}</a
|
||||
>
|
||||
<p class="my-0.5 text-sm">
|
||||
<p class="text-base text-white/40 mb-4">
|
||||
${translateText("game_starting_modal.code_license")}
|
||||
</p>
|
||||
<p class="text-base my-5 bg-black/30 p-2.5 rounded">
|
||||
<p
|
||||
class="text-xl font-medium tracking-wider text-white bg-white/5 border border-white/10 px-4 py-3 rounded-xl"
|
||||
>
|
||||
${translateText("game_starting_modal.title")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { FOOTER_AD_MIN_HEIGHT } from "./components/HomeFooterAd";
|
||||
|
||||
@customElement("gutter-ads")
|
||||
export class GutterAds extends LitElement {
|
||||
@@ -9,6 +10,14 @@ export class GutterAds extends LitElement {
|
||||
@state()
|
||||
private adLoaded: boolean = false;
|
||||
|
||||
@state()
|
||||
private hasFooterAd: boolean = false;
|
||||
|
||||
private onResize = () => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
|
||||
};
|
||||
|
||||
private leftAdType: string = "standard_iab_left2";
|
||||
private rightAdType: string = "standard_iab_rght1";
|
||||
private leftContainerId: string = "gutter-ad-container-left";
|
||||
@@ -23,6 +32,8 @@ export class GutterAds extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.onResize();
|
||||
window.addEventListener("resize", this.onResize);
|
||||
document.addEventListener("userMeResponse", () => {
|
||||
if (window.adsEnabled) {
|
||||
console.log("showing gutter ads");
|
||||
@@ -110,6 +121,7 @@ export class GutterAds extends LitElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -120,8 +132,11 @@ export class GutterAds extends LitElement {
|
||||
return html`
|
||||
<!-- Left Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% - 10.5cm - 208px); top: calc(50% + 10px);"
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
|
||||
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
|
||||
.hasFooterAd
|
||||
? " - 1.2cm"
|
||||
: ""});"
|
||||
>
|
||||
<div
|
||||
id="${this.leftContainerId}"
|
||||
@@ -131,8 +146,11 @@ export class GutterAds extends LitElement {
|
||||
|
||||
<!-- Right Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% + 10.5cm + 48px); top: calc(50% + 10px);"
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
|
||||
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
|
||||
.hasFooterAd
|
||||
? " - 1.2cm"
|
||||
: ""});"
|
||||
>
|
||||
<div
|
||||
id="${this.rightContainerId}"
|
||||
|
||||
@@ -75,6 +75,8 @@ export class HelpModal extends BaseModal {
|
||||
MetaLeft: "⌘",
|
||||
MetaRight: "⌘",
|
||||
Space: "Space",
|
||||
Escape: "Esc",
|
||||
Enter: "↵ Return",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
@@ -259,6 +261,22 @@ export class HelpModal extends BaseModal {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-white/80">
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
${this.renderKey("Escape")}
|
||||
</td>
|
||||
<td class="py-3 border-b border-white/5 text-white/70">
|
||||
${translateText("help_modal.action_esc")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
${this.renderKey("Enter")}
|
||||
</td>
|
||||
<td class="py-3 border-b border-white/5 text-white/70">
|
||||
${translateText("help_modal.action_enter")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
${this.renderKey(keybinds.toggleView)}
|
||||
|
||||
@@ -71,6 +71,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
@state() private goldMultiplierValue: number | undefined = undefined;
|
||||
@state() private startingGold: boolean = false;
|
||||
@state() private startingGoldValue: number | undefined = undefined;
|
||||
@state() private disableAlliances: boolean = false;
|
||||
@state() private lobbyId = "";
|
||||
@state() private lobbyUrlSuffix = "";
|
||||
@state() private clients: ClientInfo[] = [];
|
||||
@@ -174,16 +175,16 @@ export class HostLobbyModal extends BaseModal {
|
||||
.onKeyDown=${this.handleSpawnImmunityDurationKeyDown}
|
||||
></toggle-input-card>`,
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"single_modal.gold_multiplier"}
|
||||
.labelKey=${"host_modal.gold_multiplier"}
|
||||
.checked=${this.goldMultiplier}
|
||||
.inputId=${"gold-multiplier-value"}
|
||||
.inputMin=${0.1}
|
||||
.inputMax=${1000}
|
||||
.inputStep=${"any"}
|
||||
.inputValue=${this.goldMultiplierValue}
|
||||
.inputAriaLabel=${translateText("single_modal.gold_multiplier")}
|
||||
.inputAriaLabel=${translateText("host_modal.gold_multiplier")}
|
||||
.inputPlaceholder=${translateText(
|
||||
"single_modal.gold_multiplier_placeholder",
|
||||
"host_modal.gold_multiplier_placeholder",
|
||||
)}
|
||||
.defaultInputValue=${2}
|
||||
.minValidOnEnable=${0.1}
|
||||
@@ -192,16 +193,16 @@ export class HostLobbyModal extends BaseModal {
|
||||
.onKeyDown=${this.handleGoldMultiplierValueKeyDown}
|
||||
></toggle-input-card>`,
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"single_modal.starting_gold"}
|
||||
.labelKey=${"host_modal.starting_gold"}
|
||||
.checked=${this.startingGold}
|
||||
.inputId=${"starting-gold-value"}
|
||||
.inputMin=${0.1}
|
||||
.inputMax=${1000}
|
||||
.inputStep=${"any"}
|
||||
.inputValue=${this.startingGoldValue}
|
||||
.inputAriaLabel=${translateText("single_modal.starting_gold")}
|
||||
.inputAriaLabel=${translateText("host_modal.starting_gold")}
|
||||
.inputPlaceholder=${translateText(
|
||||
"single_modal.starting_gold_placeholder",
|
||||
"host_modal.starting_gold_placeholder",
|
||||
)}
|
||||
.defaultInputValue=${5}
|
||||
.minValidOnEnable=${0.1}
|
||||
@@ -294,6 +295,10 @@ export class HostLobbyModal extends BaseModal {
|
||||
labelKey: "host_modal.compact_map",
|
||||
checked: this.compactMap,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.disable_alliances",
|
||||
checked: this.disableAlliances,
|
||||
},
|
||||
],
|
||||
inputCards,
|
||||
},
|
||||
@@ -328,7 +333,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
<!-- Player List / footer -->
|
||||
<div class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0">
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
@click=${this.startGame}
|
||||
?disabled=${this.clients.length < 2}
|
||||
>
|
||||
@@ -457,6 +462,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.goldMultiplierValue = undefined;
|
||||
this.startingGold = false;
|
||||
this.startingGoldValue = undefined;
|
||||
this.disableAlliances = false;
|
||||
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
@@ -533,6 +539,10 @@ export class HostLobbyModal extends BaseModal {
|
||||
case "host_modal.compact_map":
|
||||
this.handleCompactMapChange(checked);
|
||||
break;
|
||||
case "host_modal.disable_alliances":
|
||||
this.disableAlliances = checked;
|
||||
this.putGameConfig();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -795,6 +805,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.startingGold === true && this.startingGoldValue !== undefined
|
||||
? Math.round(this.startingGoldValue * 1_000_000)
|
||||
: undefined,
|
||||
disableAlliances: this.disableAlliances || undefined,
|
||||
} satisfies Partial<GameConfig>,
|
||||
},
|
||||
bubbles: true,
|
||||
|
||||
@@ -671,7 +671,7 @@ export class InputHandler {
|
||||
}
|
||||
if (element.tagName === "INPUT") {
|
||||
const input = element as HTMLInputElement;
|
||||
if (input.id === "attack-ratio" && input.type === "range") {
|
||||
if (input.type === "range") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -148,7 +148,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
disabled
|
||||
>
|
||||
${translateText("private_lobby.joined_waiting")}
|
||||
|
||||
@@ -349,13 +349,11 @@ export class LangSelector extends LitElement {
|
||||
id="lang-selector"
|
||||
title="Change Language"
|
||||
@click=${this.openModal}
|
||||
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center transition-transform duration-200 hover:scale-[1.1] active:scale-[0.9]"
|
||||
style="width: 28px; height: 28px;"
|
||||
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center transition-transform duration-200 hover:scale-[1.1] active:scale-[0.9] opacity-60 hover:opacity-100 w-[40px] h-[40px] lg:w-[56px] lg:h-[56px]"
|
||||
>
|
||||
<img
|
||||
id="lang-flag"
|
||||
class="object-contain pointer-events-none"
|
||||
style="width: 28px; height: 28px;"
|
||||
class="object-contain pointer-events-none transition-all w-[40px] h-[40px] lg:w-[48px] lg:h-[48px]"
|
||||
src="/flags/${currentLang.svg}.svg"
|
||||
alt="flag"
|
||||
draggable="false"
|
||||
|
||||
@@ -18,6 +18,7 @@ export class PublicLobbySocket {
|
||||
private wsConnectionAttempts = 0;
|
||||
private wsAttemptCounted = false;
|
||||
private workerPath: string = "";
|
||||
private stopped = true;
|
||||
|
||||
private readonly reconnectDelay: number;
|
||||
private readonly maxWsAttempts: number;
|
||||
@@ -31,6 +32,7 @@ export class PublicLobbySocket {
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.stopped = false;
|
||||
this.wsConnectionAttempts = 0;
|
||||
// Get config to determine number of workers, then pick a random one
|
||||
const config = await getServerConfigFromClient();
|
||||
@@ -39,6 +41,7 @@ export class PublicLobbySocket {
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ export class PublicLobbySocket {
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
if (this.stopped) return;
|
||||
console.log("WebSocket disconnected, attempting to reconnect...");
|
||||
if (!this.wsAttemptCounted) {
|
||||
this.wsAttemptCounted = true;
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
} from "./Utils";
|
||||
import "./components/DesktopNavBar";
|
||||
import "./components/Footer";
|
||||
import "./components/HomeFooterAd";
|
||||
import "./components/MainLayout";
|
||||
import "./components/MobileNavBar";
|
||||
import "./components/PlayPage";
|
||||
|
||||
@@ -56,6 +56,7 @@ const DEFAULT_OPTIONS = {
|
||||
startingGold: false,
|
||||
startingGoldValue: undefined as number | undefined,
|
||||
disabledUnits: [] as UnitType[],
|
||||
disableAlliances: false,
|
||||
} as const;
|
||||
|
||||
@customElement("single-player-modal")
|
||||
@@ -90,6 +91,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
@state() private disabledUnits: UnitType[] = [
|
||||
...DEFAULT_OPTIONS.disabledUnits,
|
||||
];
|
||||
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
|
||||
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
|
||||
@@ -313,6 +315,10 @@ export class SinglePlayerModal extends BaseModal {
|
||||
labelKey: "single_modal.compact_map",
|
||||
checked: this.compactMap,
|
||||
},
|
||||
{
|
||||
labelKey: "single_modal.disable_alliances",
|
||||
checked: this.disableAlliances,
|
||||
},
|
||||
],
|
||||
inputCards,
|
||||
},
|
||||
@@ -344,7 +350,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
: null}
|
||||
<button
|
||||
@click=${this.startGame}
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0"
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0"
|
||||
>
|
||||
${translateText("single_modal.start")}
|
||||
</button>
|
||||
@@ -383,6 +389,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.gameMode !== DEFAULT_OPTIONS.gameMode ||
|
||||
this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier ||
|
||||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
|
||||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
|
||||
this.disabledUnits.length > 0
|
||||
);
|
||||
}
|
||||
@@ -409,6 +416,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.goldMultiplierValue = DEFAULT_OPTIONS.goldMultiplierValue;
|
||||
this.startingGold = DEFAULT_OPTIONS.startingGold;
|
||||
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
|
||||
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
@@ -488,6 +496,9 @@ export class SinglePlayerModal extends BaseModal {
|
||||
case "single_modal.compact_map":
|
||||
this.handleCompactMapChange(checked);
|
||||
break;
|
||||
case "single_modal.disable_alliances":
|
||||
this.disableAlliances = checked;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -696,6 +707,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(this.disableAlliances ? { disableAlliances: true } : {}),
|
||||
},
|
||||
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
|
||||
},
|
||||
|
||||
@@ -78,7 +78,7 @@ export class UsernameInput extends LitElement {
|
||||
@input=${this.handleClanTagChange}
|
||||
placeholder="${translateText("username.tag")}"
|
||||
maxlength="5"
|
||||
class="w-[6rem] text-xl font-bold text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
|
||||
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -86,7 +86,7 @@ export class UsernameInput extends LitElement {
|
||||
@input=${this.handleUsernameChange}
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
class="flex-1 min-w-0 border-0 text-2xl font-bold text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
${this.validationError
|
||||
|
||||
@@ -168,6 +168,23 @@ export function getActiveModifiers(
|
||||
formattedValue: `${millions}M`,
|
||||
});
|
||||
}
|
||||
if (modifiers.goldMultiplier) {
|
||||
result.push({
|
||||
labelKey: "host_modal.gold_multiplier",
|
||||
badgeKey: "public_game_modifier.gold_multiplier",
|
||||
badgeParams: {
|
||||
amount: modifiers.goldMultiplier,
|
||||
},
|
||||
value: modifiers.goldMultiplier,
|
||||
formattedValue: `x${modifiers.goldMultiplier}`,
|
||||
});
|
||||
}
|
||||
if (modifiers.isAlliancesDisabled) {
|
||||
result.push({
|
||||
labelKey: "host_modal.disable_alliances",
|
||||
badgeKey: "public_game_modifier.disable_alliances",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export abstract class BaseModal extends LitElement {
|
||||
* Subclasses can override onOpen() for custom behavior.
|
||||
*/
|
||||
public open(): void {
|
||||
if (this.isModalOpen) return;
|
||||
this.registerEscapeHandler();
|
||||
this.onOpen();
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export class DesktopNavBar extends LitElement {
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-play"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-play"
|
||||
data-i18n="main.play"
|
||||
></button>
|
||||
@@ -111,7 +111,7 @@ export class DesktopNavBar extends LitElement {
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-news"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
@click=${this._notifications.onNewsClick}
|
||||
@@ -131,7 +131,7 @@ export class DesktopNavBar extends LitElement {
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-item-store"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
@click=${this._notifications.onStoreClick}
|
||||
@@ -148,18 +148,18 @@ export class DesktopNavBar extends LitElement {
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-settings"
|
||||
data-i18n="main.settings"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-leaderboard"
|
||||
data-i18n="main.leaderboard"
|
||||
></button>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
@click=${this._notifications.onHelpClick}
|
||||
@@ -175,7 +175,6 @@ export class DesktopNavBar extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<lang-selector></lang-selector>
|
||||
<button
|
||||
id="nav-account-button"
|
||||
class="no-crazygames nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
|
||||
|
||||
@@ -10,9 +10,11 @@ export class Footer extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<footer
|
||||
class="[.in-game_&]:hidden bg-zinc-900/90 backdrop-blur-md flex flex-col items-center justify-center gap-1 pt-1 pb-3 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto relative z-50"
|
||||
class="[.in-game_&]:hidden bg-zinc-900/90 backdrop-blur-md flex flex-col items-center justify-center gap-1 pt-1 pb-3 text-white/50 w-full border-t border-white/10 shrink-0 relative z-50"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-4 lg:gap-6 pt-2">
|
||||
<div
|
||||
class="flex items-center justify-center gap-4 lg:gap-6 pt-2 w-full relative"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO"
|
||||
target="_blank"
|
||||
@@ -72,6 +74,9 @@ export class Footer extends LitElement {
|
||||
draggable="false"
|
||||
/>
|
||||
</a>
|
||||
<lang-selector
|
||||
class="absolute right-4 top-0 sm:top-[10px]"
|
||||
></lang-selector>
|
||||
</div>
|
||||
<div
|
||||
class="text-xs mt-1 lg:mt-2 flex items-center justify-center gap-4 px-4"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
export const FOOTER_AD_MIN_HEIGHT = 880;
|
||||
const FOOTER_AD_TYPE = "standard_iab_head2";
|
||||
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
|
||||
|
||||
@customElement("home-footer-ad")
|
||||
export class HomeFooterAd extends LitElement {
|
||||
@state() private shouldShow: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.style.display = "contents";
|
||||
document.addEventListener("userMeResponse", this.onUserMeResponse);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("userMeResponse", this.onUserMeResponse);
|
||||
this.destroyAd();
|
||||
}
|
||||
|
||||
private onUserMeResponse = () => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldShow = true;
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
});
|
||||
};
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for footer ad");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
|
||||
]);
|
||||
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
|
||||
} catch (e) {
|
||||
console.error("Failed to add footer ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load footer ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyAd(): void {
|
||||
try {
|
||||
window.ramp.destroyUnits(FOOTER_AD_TYPE);
|
||||
console.log("successfully destroyed footer ad");
|
||||
} catch (e) {
|
||||
console.error("error destroying footer ad", e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="${FOOTER_AD_CONTAINER_ID}"
|
||||
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
|
||||
style="margin: 0; padding: 0; line-height: 0;"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,10 @@ export class MainLayout extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<main
|
||||
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.75rem,1.5vw,1.5rem)]"
|
||||
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.375rem,0.75vw,0.75rem)]"
|
||||
>
|
||||
<div
|
||||
class="w-full lg:max-w-[20cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden sm:px-4 lg:px-0"
|
||||
class="w-full lg:max-w-[20cm] 2xl:max-w-[24cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden sm:px-4 lg:px-0"
|
||||
>
|
||||
${this._initialChildren}
|
||||
</div>
|
||||
|
||||
@@ -189,9 +189,7 @@ export class MobileNavBar extends LitElement {
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col w-full mt-auto [.in-game_&]:hidden items-end justify-end pt-4 border-t border-white/10"
|
||||
>
|
||||
<lang-selector></lang-selector>
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class OButton extends LitElement {
|
||||
@property({ type: Boolean }) fill = false;
|
||||
@property({ type: Boolean }) submit = false;
|
||||
private static readonly BASE_CLASS =
|
||||
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
|
||||
"bg-sky-600 hover:bg-sky-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { PlayerType } from "../../../core/game/Game";
|
||||
import {
|
||||
BrokeAllianceUpdate,
|
||||
GameUpdateType,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -198,6 +199,12 @@ export class AlertFrame extends LitElement implements Layer {
|
||||
for (const attack of incomingAttacks) {
|
||||
// Only alert for non-retreating attacks
|
||||
if (!attack.retreating && !this.seenAttackIds.has(attack.id)) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||
if ((attacker as PlayerView).type() === PlayerType.Bot) {
|
||||
this.seenAttackIds.add(attack.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a retaliation (we attacked them recently)
|
||||
const ourAttackTick = this.outgoingAttackTicks.get(attack.attackerID);
|
||||
const isRetaliation =
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import soldierIcon from "/images/SoldierIcon.svg?url";
|
||||
import swordIcon from "/images/SwordIcon.svg?url";
|
||||
|
||||
@customElement("attacks-display")
|
||||
@@ -224,14 +225,13 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
content: html`<span class="inline-flex items-center"
|
||||
><img
|
||||
src="${soldierIcon}"
|
||||
class="h-4 w-4"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>↓</span
|
||||
><span class="ml-1">${renderTroops(attack.troops)}</span>
|
||||
<span class="truncate ml-1"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.attackerID) as PlayerView
|
||||
@@ -272,14 +272,13 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
content: html`<span class="inline-flex items-center"
|
||||
><img
|
||||
src="${soldierIcon}"
|
||||
class="h-4 w-4"
|
||||
style="filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"
|
||||
/>↑</span
|
||||
><span class="ml-1">${renderTroops(attack.troops)}</span>
|
||||
<span class="truncate ml-1"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.targetID) as PlayerView
|
||||
@@ -287,7 +286,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
> `,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
@@ -314,17 +313,16 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(landAttack.troops)}</span
|
||||
>
|
||||
content: html`<span class="inline-flex items-center"
|
||||
><img
|
||||
src="${soldierIcon}"
|
||||
class="h-4 w-4"
|
||||
style="filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"
|
||||
/>↑</span
|
||||
><span class="ml-1">${renderTroops(landAttack.troops)}</span>
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className:
|
||||
"text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
@@ -441,7 +439,7 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base"
|
||||
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base max-h-[7rem] overflow-y-auto"
|
||||
>
|
||||
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
|
||||
${this.renderBoats()} ${this.renderIncomingAttacks()}
|
||||
|
||||
@@ -125,11 +125,16 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
private handleRatioSliderInput(e: Event) {
|
||||
const value = Number((e.target as HTMLInputElement).value);
|
||||
const input = e.target as HTMLInputElement;
|
||||
const value = Number(input.value);
|
||||
this.attackRatio = value / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private handleRatioSliderPointerUp(e: Event) {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
|
||||
private calculateTroopBar(): { greenPercent: number; orangePercent: number } {
|
||||
const base = Math.max(this._maxTroops, 1);
|
||||
const greenPercentRaw = (this._troops / base) * 100;
|
||||
@@ -153,13 +158,13 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-green-500 transition-[width] duration-200"
|
||||
class="h-full bg-sky-700 transition-[width] duration-200"
|
||||
style="width: ${greenPercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
${orangePercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-orange-400 transition-[width] duration-200"
|
||||
class="h-full bg-sky-600 transition-[width] duration-200"
|
||||
style="width: ${orangePercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
@@ -208,30 +213,46 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-green-500 transition-[width] duration-200"
|
||||
class="h-full bg-sky-700 transition-[width] duration-200"
|
||||
style="width: ${greenPercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
${orangePercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-orange-400 transition-[width] duration-200"
|
||||
class="h-full bg-sky-600 transition-[width] duration-200"
|
||||
style="width: ${orangePercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-start px-1.5 text-xs font-bold leading-none pointer-events-none gap-0.5"
|
||||
class="absolute inset-0 flex items-center text-lg font-bold leading-none pointer-events-none"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._troops)}</span
|
||||
>
|
||||
<span class="text-white/60 drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
<span class="flex-1 flex justify-end h-full items-center pr-0.5">
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._troops)}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="h-full flex items-center px-0.5 text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>/</span
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._maxTroops)}</span
|
||||
<span
|
||||
class="flex-1 flex justify-start h-full items-center pl-0.5 gap-0.5"
|
||||
>
|
||||
<span
|
||||
class="text-white tabular-nums w-[3.5rem] drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._maxTroops)}</span
|
||||
>
|
||||
<img
|
||||
src=${soldierIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="22"
|
||||
height="22"
|
||||
class="shrink-0 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ml-1.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -240,10 +261,10 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
private renderDesktop() {
|
||||
return html`
|
||||
<!-- Row 1: troop rate | troop bar | gold -->
|
||||
<div class="flex gap-1.5 items-center mb-1.5">
|
||||
<div class="flex gap-1.5 items-center mb-1">
|
||||
<!-- Troop rate -->
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-xs p-1 w-[5.5rem] ${this
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-sm py-0.5 px-1 w-[5.5rem] ${this
|
||||
._troopRateIsIncreasing
|
||||
? "border-green-400"
|
||||
: "border-orange-400"}"
|
||||
@@ -261,7 +282,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
: "brightness(0) saturate(100%) invert(65%) sepia(60%) saturate(600%) hue-rotate(330deg) brightness(105%)"}"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-bold tabular-nums ${this._troopRateIsIncreasing
|
||||
class="text-sm font-bold tabular-nums ${this._troopRateIsIncreasing
|
||||
? "text-green-400"
|
||||
: "text-orange-400"}"
|
||||
>+${renderTroops(this.troopRate)}/s</span
|
||||
@@ -271,7 +292,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
<div class="flex-1">${this.renderDesktopTroopBar()}</div>
|
||||
<!-- Gold -->
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs p-1 w-[4.5rem]"
|
||||
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm py-0.5 px-1 w-[4.5rem]"
|
||||
translate="no"
|
||||
>
|
||||
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
|
||||
@@ -279,9 +300,9 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: attack ratio | slider -->
|
||||
<div class="flex items-center gap-2" translate="no">
|
||||
<div class="flex items-center gap-1.5" translate="no">
|
||||
<div
|
||||
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md p-1 text-xs font-bold text-white cursor-pointer w-[7rem]"
|
||||
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md px-1 py-0.5 text-sm font-bold text-white cursor-pointer w-[8rem]"
|
||||
>
|
||||
<img
|
||||
src=${swordIcon}
|
||||
@@ -304,7 +325,8 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
max="100"
|
||||
.value=${String(Math.round(this.attackRatio * 100))}
|
||||
@input=${(e: Event) => this.handleRatioSliderInput(e)}
|
||||
class="flex-1 h-2 accent-blue-500 cursor-pointer"
|
||||
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
|
||||
class="flex-1 h-1.5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -326,7 +348,10 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
${this.renderMobileTroopBar()}
|
||||
</div>
|
||||
<!-- Sword + % label -->
|
||||
<div class="flex flex-col items-center shrink-0 gap-0.5" translate="no">
|
||||
<div
|
||||
class="flex flex-col items-center shrink-0 gap-0.5 w-8"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
@@ -347,6 +372,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
max="100"
|
||||
.value=${String(Math.round(this.attackRatio * 100))}
|
||||
@input=${(e: Event) => this.handleRatioSliderInput(e)}
|
||||
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
|
||||
class="w-full h-1.5 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
@@ -358,7 +384,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
return html`
|
||||
<div
|
||||
class="relative pointer-events-auto ${this._isVisible
|
||||
? "relative w-full text-sm px-2 py-1.5"
|
||||
? "relative w-full text-sm px-2 py-1"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -153,6 +153,7 @@ export class CoordinateGridLayer implements Layer {
|
||||
const bottomRight = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(width, height),
|
||||
);
|
||||
const darkMode = this.game.config().userSettings()?.darkMode() ?? false;
|
||||
return [
|
||||
width,
|
||||
height,
|
||||
@@ -163,6 +164,7 @@ export class CoordinateGridLayer implements Layer {
|
||||
topLeft.y.toFixed(2),
|
||||
bottomRight.x.toFixed(2),
|
||||
bottomRight.y.toFixed(2),
|
||||
darkMode ? "1" : "0",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
@@ -268,10 +270,13 @@ export class CoordinateGridLayer implements Layer {
|
||||
|
||||
context.font = "12px monospace";
|
||||
|
||||
const isDarkMode = this.game.config().userSettings()?.darkMode() ?? false;
|
||||
const drawLabel = (text: string, x: number, y: number) => {
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "rgba(20, 20, 20, 0.9)";
|
||||
context.fillStyle = isDarkMode
|
||||
? "rgba(255, 255, 255, 0.9)"
|
||||
: "rgba(20, 20, 20, 0.9)";
|
||||
context.fillText(text, x, y);
|
||||
};
|
||||
|
||||
|
||||
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
<!-- Content Area -->
|
||||
<div
|
||||
class="bg-gray-800/70 max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
|
||||
class="bg-gray-800/70 max-h-[15vh] lg:max-h-[30vh] overflow-y-auto w-full h-full min-[1200px]:rounded-b-xl events-container"
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
|
||||
@@ -144,7 +144,7 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
|
||||
this.radialMenu.setCenterButtonAppearance(
|
||||
isFriendlyTarget ? donateTroopIcon : swordIcon,
|
||||
isFriendlyTarget ? "#34D399" : "#2c3e50",
|
||||
isFriendlyTarget ? "#22d3ee" : "#0f2744",
|
||||
isFriendlyTarget
|
||||
? this.radialMenu.getDefaultCenterIconSize() * 0.75
|
||||
: this.radialMenu.getDefaultCenterIconSize(),
|
||||
|
||||
@@ -288,7 +288,10 @@ export class NameLayer implements Layer {
|
||||
}
|
||||
|
||||
renderPlayerInfo(render: RenderInfo) {
|
||||
if (!render.player.nameLocation() || !render.player.isAlive()) {
|
||||
if (!render.player.nameLocation()) {
|
||||
return;
|
||||
}
|
||||
if (!render.player.isAlive()) {
|
||||
this.renders = this.renders.filter((r) => r !== render);
|
||||
render.element.remove();
|
||||
return;
|
||||
|
||||
@@ -318,17 +318,39 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
|
||||
|
||||
return html`
|
||||
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
|
||||
<div class="flex items-start gap-1 lg:gap-2 p-1 lg:p-1.5">
|
||||
<!-- Left: Gold & Troop bar -->
|
||||
<div class="flex flex-col gap-1 shrink-0 w-28">
|
||||
<div
|
||||
class="flex items-center justify-center p-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-28 lg:gap-1"
|
||||
translate="no"
|
||||
>
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="px-0.5">${renderNumber(player.gold())}</span>
|
||||
<div class="flex flex-col gap-1 shrink-0 w-28 md:w-36">
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center px-1 py-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm lg:gap-1"
|
||||
translate="no"
|
||||
>
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="px-0.5">${renderNumber(player.gold())}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center text-xs font-bold ${attackingTroops >
|
||||
0
|
||||
? "text-sky-400"
|
||||
: "text-white/40"} drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
translate="no"
|
||||
>
|
||||
<span class="flex items-center gap-px leading-none text-xs"
|
||||
><img
|
||||
src=${soldierIcon}
|
||||
class="w-2.5 h-2.5"
|
||||
style="${attackingTroops > 0
|
||||
? "filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%); opacity:1"
|
||||
: "filter: brightness(0) invert(1); opacity:0.4"}"
|
||||
/>↑</span
|
||||
>
|
||||
<span class="tabular-nums leading-none text-sm mt-0.5"
|
||||
>${renderTroops(attackingTroops)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-28" translate="no">
|
||||
<div class="w-28 md:w-36" translate="no">
|
||||
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +402,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
>`}
|
||||
${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
|
||||
</div>
|
||||
<div class="flex gap-0.5 lg:gap-1 items-center mt-1">
|
||||
<div class="flex gap-0.5 lg:gap-1 items-center mt-0.5">
|
||||
${this.displayUnitCount(player, UnitType.City, cityIcon)}
|
||||
${this.displayUnitCount(player, UnitType.Factory, factoryIcon)}
|
||||
${this.displayUnitCount(player, UnitType.Port, portIcon)}
|
||||
@@ -418,24 +440,24 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full mt-1 lg:mt-2 h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
class="w-full h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
>
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-green-500 transition-[width] duration-200"
|
||||
class="h-full bg-sky-700 transition-[width] duration-200"
|
||||
style="width: ${greenPercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
${orangePercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-orange-400 transition-[width] duration-200"
|
||||
class="h-full bg-sky-600 transition-[width] duration-200"
|
||||
style="width: ${orangePercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 text-sm font-bold leading-none pointer-events-none"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
@@ -496,13 +518,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed top-0 min-[1200px]:top-4 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
||||
class="fixed top-0 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
||||
style="margin-top: ${this.barOffset}px;"
|
||||
@click=${() => this.hide()}
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
|
||||
>
|
||||
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
||||
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
||||
|
||||
@@ -88,7 +88,7 @@ export class RadialMenu implements Layer {
|
||||
private backButtonHoverTimeout: number | null = null;
|
||||
private navigationInProgress: boolean = false;
|
||||
private originalCenterButtonIcon: string = "";
|
||||
private readonly defaultCenterButtonColor = "#2c3e50";
|
||||
private readonly defaultCenterButtonColor = "#0f2744";
|
||||
private centerButtonColor: string;
|
||||
private centerButtonIconSize: number;
|
||||
|
||||
@@ -227,7 +227,7 @@ export class RadialMenu implements Layer {
|
||||
this.tooltipElement.className = "radial-tooltip";
|
||||
this.tooltipElement.style.position = "absolute";
|
||||
this.tooltipElement.style.pointerEvents = "none";
|
||||
this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)";
|
||||
this.tooltipElement.style.background = "rgba(12, 35, 64, 0.88)";
|
||||
this.tooltipElement.style.color = "white";
|
||||
this.tooltipElement.style.padding = "6px 10px";
|
||||
this.tooltipElement.style.borderRadius = "6px";
|
||||
@@ -332,8 +332,8 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
: (resolveColor(d.data, this.params) ?? "#1e3a5f");
|
||||
const opacity = disabled ? 0.4 : 0.82;
|
||||
|
||||
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
|
||||
return color;
|
||||
@@ -341,8 +341,7 @@ export class RadialMenu implements Layer {
|
||||
|
||||
return d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
|
||||
})
|
||||
.attr("stroke", "#ffffff")
|
||||
.attr("stroke-width", "2")
|
||||
.attr("stroke", "none")
|
||||
.style("cursor", (d) =>
|
||||
this.params === null || d.data.disabled(this.params)
|
||||
? "not-allowed"
|
||||
@@ -353,9 +352,7 @@ export class RadialMenu implements Layer {
|
||||
)
|
||||
.style(
|
||||
"transition",
|
||||
`filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${
|
||||
this.config.menuTransitionDuration / 2
|
||||
}ms, fill ${this.config.menuTransitionDuration / 2}ms`,
|
||||
`filter ${this.config.menuTransitionDuration / 2}ms, fill ${this.config.menuTransitionDuration / 2}ms`,
|
||||
)
|
||||
.attr("data-id", (d) => d.data.id);
|
||||
|
||||
@@ -366,8 +363,8 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const baseColor = disabled
|
||||
? this.config.disabledColor
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
: (resolveColor(d.data, this.params) ?? "#1e3a5f");
|
||||
const opacity = disabled ? 0.4 : 0.82;
|
||||
|
||||
const normalColor =
|
||||
d3.color(baseColor)?.copy({ opacity: opacity })?.toString() ??
|
||||
@@ -419,12 +416,11 @@ export class RadialMenu implements Layer {
|
||||
this.currentLevel > 0
|
||||
) {
|
||||
path.attr("filter", "url(#glow)");
|
||||
path.attr("stroke-width", "3");
|
||||
|
||||
const color =
|
||||
this.params === null || d.data.disabled(this.params)
|
||||
? this.config.disabledColor
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#1e3a5f");
|
||||
path.attr("fill", color);
|
||||
}
|
||||
});
|
||||
@@ -466,8 +462,7 @@ export class RadialMenu implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
path.attr("filter", "url(#glow)");
|
||||
path.attr("stroke-width", "3");
|
||||
path.style("filter", "brightness(1.5)");
|
||||
};
|
||||
|
||||
const onMouseOut = (d: d3.PieArcDatum<MenuElement>, path: any) => {
|
||||
@@ -486,12 +481,11 @@ export class RadialMenu implements Layer {
|
||||
d.data.id === this.selectedItemId)
|
||||
)
|
||||
return;
|
||||
path.attr("filter", null);
|
||||
path.attr("stroke-width", "2");
|
||||
path.style("filter", null);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
const opacity = disabled ? 0.4 : 0.82;
|
||||
|
||||
if (d.data.timerFraction) {
|
||||
path.attr("fill", `url(#timer-gradient-${d.data.id})`);
|
||||
@@ -811,7 +805,6 @@ export class RadialMenu implements Layer {
|
||||
const selectedPath = this.menuPaths.get(this.selectedItemId);
|
||||
if (selectedPath) {
|
||||
selectedPath.attr("filter", null);
|
||||
selectedPath.attr("stroke-width", "2");
|
||||
}
|
||||
}
|
||||
// Use refresh() to update all item appearances consistently
|
||||
@@ -1127,7 +1120,7 @@ export class RadialMenu implements Layer {
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (resolveColor(item, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
const opacity = disabled ? 0.4 : 0.82;
|
||||
|
||||
// Update path appearance (skip fill for timer items — gradient handles it)
|
||||
if (!item.timerFraction) {
|
||||
|
||||
@@ -82,32 +82,32 @@ export interface CenterButtonElement {
|
||||
}
|
||||
|
||||
export const COLORS = {
|
||||
build: "#ebe250",
|
||||
building: "#2c2c2c",
|
||||
boat: "#3f6ab1",
|
||||
ally: "#53ac75",
|
||||
breakAlly: "#c74848",
|
||||
breakAllyNoDebuff: "#d4882b",
|
||||
delete: "#ff0000",
|
||||
info: "#64748B",
|
||||
target: "#ff0000",
|
||||
attack: "#ff0000",
|
||||
build: "#e6c74a",
|
||||
building: "#1e3a5f",
|
||||
boat: "#2a82c9",
|
||||
ally: "#4ade80",
|
||||
breakAlly: "#dc2626",
|
||||
breakAllyNoDebuff: "#d97706",
|
||||
delete: "#ef4444",
|
||||
info: "#475569",
|
||||
target: "#ef4444",
|
||||
attack: "#ef4444",
|
||||
infoDetails: "#7f8c8d",
|
||||
infoEmoji: "#f1c40f",
|
||||
trade: "#008080",
|
||||
embargo: "#6600cc",
|
||||
infoEmoji: "#fbbf24",
|
||||
trade: "#0891b2",
|
||||
embargo: "#7c3aed",
|
||||
tooltip: {
|
||||
cost: "#ffd700",
|
||||
count: "#aaa",
|
||||
cost: "#f59e0b",
|
||||
count: "#94a3b8",
|
||||
},
|
||||
chat: {
|
||||
default: "#66c",
|
||||
help: "#4caf50",
|
||||
attack: "#f44336",
|
||||
defend: "#2196f3",
|
||||
greet: "#ff9800",
|
||||
misc: "#9c27b0",
|
||||
warnings: "#e3c532",
|
||||
default: "#6366f1",
|
||||
help: "#22c55e",
|
||||
attack: "#ef4444",
|
||||
defend: "#3b82f6",
|
||||
greet: "#f97316",
|
||||
misc: "#a855f7",
|
||||
warnings: "#fbbf24",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -484,7 +484,7 @@ const donateGoldRadialElement: MenuElement = {
|
||||
params.game.inSpawnPhase() ||
|
||||
!params.playerActions?.interaction?.canDonateGold,
|
||||
icon: donateGoldIcon,
|
||||
color: "#EAB308",
|
||||
color: "#f59e0b",
|
||||
action: (params: MenuElementParams) => {
|
||||
if (!params.selected) return;
|
||||
params.playerPanel.openSendGoldModal(
|
||||
|
||||
@@ -99,7 +99,7 @@ export class GameRunner {
|
||||
}
|
||||
if (this.game.config().bots() > 0) {
|
||||
this.game.addExecution(
|
||||
...this.execManager.spawnBots(this.game.config().numBots()),
|
||||
...this.execManager.spawnTribes(this.game.config().bots()),
|
||||
);
|
||||
}
|
||||
if (this.game.config().spawnNations()) {
|
||||
|
||||
@@ -217,6 +217,8 @@ export const GameConfigSchema = z.object({
|
||||
isCrowded: z.boolean(),
|
||||
isHardNations: z.boolean(),
|
||||
startingGold: z.number().int().min(0).optional(),
|
||||
goldMultiplier: z.number().min(0.1).max(1000).optional(),
|
||||
isAlliancesDisabled: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
nations: z
|
||||
@@ -230,6 +232,7 @@ export const GameConfigSchema = z.object({
|
||||
infiniteTroops: z.boolean(),
|
||||
instantBuild: z.boolean(),
|
||||
disableNavMesh: z.boolean().optional(),
|
||||
disableAlliances: z.boolean().optional(),
|
||||
randomSpawn: z.boolean(),
|
||||
maxPlayers: z.number().optional(),
|
||||
maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "./Schemas";
|
||||
|
||||
import {
|
||||
BOT_NAME_PREFIXES,
|
||||
BOT_NAME_SUFFIXES,
|
||||
} from "./execution/utils/BotNames";
|
||||
TRIBE_NAME_PREFIXES,
|
||||
TRIBE_NAME_SUFFIXES,
|
||||
} from "./execution/utils/TribeNames";
|
||||
|
||||
export function manhattanDistWrapped(
|
||||
c1: Cell,
|
||||
@@ -296,11 +296,12 @@ export function createRandomName(
|
||||
let randomName: string | null = null;
|
||||
if (playerType === PlayerType.Human) {
|
||||
const hash = simpleHash(name);
|
||||
const prefixIndex = hash % BOT_NAME_PREFIXES.length;
|
||||
const prefixIndex = hash % TRIBE_NAME_PREFIXES.length;
|
||||
const suffixIndex =
|
||||
Math.floor(hash / BOT_NAME_PREFIXES.length) % BOT_NAME_SUFFIXES.length;
|
||||
Math.floor(hash / TRIBE_NAME_PREFIXES.length) %
|
||||
TRIBE_NAME_SUFFIXES.length;
|
||||
|
||||
randomName = `👤 ${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
|
||||
randomName = `👤 ${TRIBE_NAME_PREFIXES[prefixIndex]} ${TRIBE_NAME_SUFFIXES[suffixIndex]}`;
|
||||
}
|
||||
return randomName;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ function generateTeamColors(baseColor: Colord): Colord[] {
|
||||
return Array.from({ length: colorCount }, (_, index) => {
|
||||
if (index === 0) return baseColor;
|
||||
|
||||
// Spread hues evenly across ±12° band using golden angle within that range
|
||||
const hueShift = ((index * goldenAngle) % 24) - 12;
|
||||
// Spread hues evenly across ±6° band using golden angle within that range
|
||||
const hueShift = ((index * goldenAngle) % 12) - 6;
|
||||
const h = (lch.h + hueShift + 360) % 360;
|
||||
|
||||
// Chroma oscillates ±10% around the base to add variety without washing out
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface Config {
|
||||
donateTroops(): boolean;
|
||||
instantBuild(): boolean;
|
||||
disableNavMesh(): boolean;
|
||||
disableAlliances(): boolean;
|
||||
isRandomSpawn(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings;
|
||||
@@ -131,7 +132,10 @@ export interface Config {
|
||||
tradeShipSpawnRejections: number,
|
||||
numPlayerPorts: number,
|
||||
): number;
|
||||
trainGold(rel: "self" | "team" | "ally" | "other"): Gold;
|
||||
trainGold(
|
||||
rel: "self" | "team" | "ally" | "other",
|
||||
citiesVisited: number,
|
||||
): Gold;
|
||||
trainSpawnRate(numPlayerFactories: number): number;
|
||||
trainStationMinRange(): number;
|
||||
trainStationMaxRange(): number;
|
||||
|
||||
@@ -201,7 +201,7 @@ export class DefaultConfig implements Config {
|
||||
return 5 - falloutRatio * 2;
|
||||
}
|
||||
SAMCooldown(): number {
|
||||
return 75;
|
||||
return 120;
|
||||
}
|
||||
SiloCooldown(): number {
|
||||
return 75;
|
||||
@@ -240,6 +240,9 @@ export class DefaultConfig implements Config {
|
||||
disableNavMesh(): boolean {
|
||||
return this._gameConfig.disableNavMesh ?? false;
|
||||
}
|
||||
disableAlliances(): boolean {
|
||||
return this._gameConfig.disableAlliances ?? false;
|
||||
}
|
||||
isRandomSpawn(): boolean {
|
||||
return this._gameConfig.randomSpawn;
|
||||
}
|
||||
@@ -268,24 +271,30 @@ export class DefaultConfig implements Config {
|
||||
trainSpawnRate(numPlayerFactories: number): number {
|
||||
// hyperbolic decay, midpoint at 10 factories
|
||||
// expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories)
|
||||
return (numPlayerFactories + 10) * 18;
|
||||
return (numPlayerFactories + 10) * 15;
|
||||
}
|
||||
trainGold(rel: "self" | "team" | "ally" | "other"): Gold {
|
||||
const multiplier = this.goldMultiplier();
|
||||
let baseGold: bigint;
|
||||
trainGold(
|
||||
rel: "self" | "team" | "ally" | "other",
|
||||
citiesVisited: number,
|
||||
): Gold {
|
||||
// No penalty for the first 10 cities.
|
||||
citiesVisited = Math.max(0, citiesVisited - 9);
|
||||
let baseGold: number;
|
||||
switch (rel) {
|
||||
case "ally":
|
||||
baseGold = 35_000n;
|
||||
baseGold = 35_000;
|
||||
break;
|
||||
case "team":
|
||||
case "other":
|
||||
baseGold = 25_000n;
|
||||
baseGold = 25_000;
|
||||
break;
|
||||
case "self":
|
||||
baseGold = 10_000n;
|
||||
baseGold = 10_000;
|
||||
break;
|
||||
}
|
||||
return BigInt(Math.floor(Number(baseGold) * multiplier));
|
||||
const distPenalty = citiesVisited * 5_000;
|
||||
const gold = Math.max(5000, baseGold - distPenalty);
|
||||
return toInt(gold * this.goldMultiplier());
|
||||
}
|
||||
|
||||
trainStationMinRange(): number {
|
||||
@@ -640,7 +649,7 @@ export class DefaultConfig implements Config {
|
||||
const altAttackerLoss =
|
||||
1.3 * defenderTroopLoss * (mag / 100) * traitorMod;
|
||||
const attackerTroopLoss =
|
||||
0.5 * currentAttackerLoss + 0.5 * altAttackerLoss;
|
||||
0.7 * currentAttackerLoss + 0.3 * altAttackerLoss;
|
||||
|
||||
return {
|
||||
attackerTroopLoss,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Game, PlayerInfo, PlayerType } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES } from "./utils/BotNames";
|
||||
|
||||
export class BotSpawner {
|
||||
private random: PseudoRandom;
|
||||
private bots: SpawnExecution[] = [];
|
||||
|
||||
constructor(
|
||||
private gs: Game,
|
||||
private gameID: GameID,
|
||||
) {
|
||||
// Use a different seed than createGameRunner (which uses simpleHash(gameID))
|
||||
// to avoid bot IDs colliding with nation/human IDs from the same PRNG sequence.
|
||||
this.random = new PseudoRandom(simpleHash(gameID) + 2);
|
||||
}
|
||||
|
||||
spawnBots(numBots: number): SpawnExecution[] {
|
||||
for (let i = 0; i < numBots; i++) {
|
||||
const name = this.randomBotName();
|
||||
const spawn = this.spawnBot(name);
|
||||
this.bots.push(spawn);
|
||||
}
|
||||
|
||||
return this.bots;
|
||||
}
|
||||
|
||||
spawnBot(botName: string): SpawnExecution {
|
||||
return new SpawnExecution(
|
||||
this.gameID,
|
||||
new PlayerInfo(botName, PlayerType.Bot, null, this.random.nextID()),
|
||||
);
|
||||
}
|
||||
|
||||
private randomBotName(): string {
|
||||
const prefixIndex = this.random.nextInt(0, BOT_NAME_PREFIXES.length);
|
||||
const suffixIndex = this.random.nextInt(0, BOT_NAME_SUFFIXES.length);
|
||||
return `${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { BoatRetreatExecution } from "./BoatRetreatExecution";
|
||||
import { BotSpawner } from "./BotSpawner";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { DeleteUnitExecution } from "./DeleteUnitExecution";
|
||||
import { DonateGoldExecution } from "./DonateGoldExecution";
|
||||
@@ -26,6 +25,7 @@ import { RetreatExecution } from "./RetreatExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TargetPlayerExecution } from "./TargetPlayerExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { TribeSpawner } from "./TribeSpawner";
|
||||
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
|
||||
import { PlayerSpawner } from "./utils/PlayerSpawner";
|
||||
|
||||
@@ -38,7 +38,7 @@ export class Executor {
|
||||
private gameID: GameID,
|
||||
private clientID: ClientID | undefined,
|
||||
) {
|
||||
// Add one to avoid id collisions with bots.
|
||||
// Add one to avoid id collisions with tribes.
|
||||
this.random = new PseudoRandom(simpleHash(gameID) + 1);
|
||||
}
|
||||
|
||||
@@ -126,8 +126,8 @@ export class Executor {
|
||||
}
|
||||
}
|
||||
|
||||
spawnBots(numBots: number): SpawnExecution[] {
|
||||
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots);
|
||||
spawnTribes(numTribes: number): SpawnExecution[] {
|
||||
return new TribeSpawner(this.mg, this.gameID).spawnTribes(numTribes);
|
||||
}
|
||||
|
||||
spawnPlayers(): SpawnExecution[] {
|
||||
|
||||
@@ -116,12 +116,20 @@ class SAMTargetingSystem {
|
||||
detectionRange,
|
||||
[UnitType.AtomBomb, UnitType.HydrogenBomb],
|
||||
({ unit }) => {
|
||||
return (
|
||||
isUnit(unit) &&
|
||||
unit.owner() !== this.sam.owner() &&
|
||||
!this.sam.owner().isFriendly(unit.owner()) &&
|
||||
!unit.targetedBySAM()
|
||||
);
|
||||
if (!isUnit(unit) || unit.targetedBySAM()) return false;
|
||||
if (unit.owner() === this.sam.owner()) return false;
|
||||
|
||||
const samOwner = this.sam.owner();
|
||||
const nukeOwner = unit.owner();
|
||||
|
||||
// After game-over in team games, SAMs also target teammate nukes (aftergame fun)
|
||||
if (samOwner.isFriendly(nukeOwner)) {
|
||||
return (
|
||||
this.mg.getWinner() !== null && samOwner.isOnSameTeam(nukeOwner)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -271,7 +279,18 @@ export class SAMLauncherExecution implements Execution {
|
||||
({ unit }) => {
|
||||
if (!isUnit(unit)) return false;
|
||||
if (unit.owner() === this.player) return false;
|
||||
if (this.player.isFriendly(unit.owner())) return false;
|
||||
|
||||
// After game-over in team games, SAMs also target teammate MIRVs (aftergame fun)
|
||||
const nukeOwner = unit.owner();
|
||||
if (this.player.isFriendly(nukeOwner)) {
|
||||
if (
|
||||
this.mg.getWinner() === null ||
|
||||
!this.player.isOnSameTeam(nukeOwner)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const dst = unit.targetTile();
|
||||
return (
|
||||
this.sam !== null &&
|
||||
|
||||
@@ -10,8 +10,8 @@ import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { BotExecution } from "./BotExecution";
|
||||
import { PlayerExecution } from "./PlayerExecution";
|
||||
import { TribeExecution } from "./TribeExecution";
|
||||
import { getSpawnTiles } from "./Util";
|
||||
|
||||
type Spawn = { center: TileRef; tiles: TileRef[] };
|
||||
@@ -71,7 +71,7 @@ export class SpawnExecution implements Execution {
|
||||
if (!player.hasSpawned()) {
|
||||
this.mg.addExecution(new PlayerExecution(player));
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
this.mg.addExecution(new BotExecution(player));
|
||||
this.mg.addExecution(new TribeExecution(player));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export class TrainExecution implements Execution {
|
||||
private stations: TrainStation[] = [];
|
||||
private currentRailroad: OrientedRailroad | null = null;
|
||||
private speed: number = 2;
|
||||
private _tradeStopsVisited: number = 0;
|
||||
|
||||
constructor(
|
||||
private railNetwork: RailNetwork,
|
||||
@@ -37,6 +38,10 @@ export class TrainExecution implements Execution {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
public tradeStopsVisited(): number {
|
||||
return this._tradeStopsVisited;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
const stations = this.railNetwork.findStationsPath(
|
||||
@@ -261,6 +266,10 @@ export class TrainExecution implements Execution {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
this.stations[1].onTrainStop(this);
|
||||
const stationType = this.stations[1].unit.type();
|
||||
if (stationType === UnitType.City || stationType === UnitType.Port) {
|
||||
this._tradeStopsVisited++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Execution, Game, Player, Structures } from "../game/Game";
|
||||
import { Execution, Game, Player, Structures } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
|
||||
import { DeleteUnitExecution } from "./DeleteUnitExecution";
|
||||
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
|
||||
|
||||
export class BotExecution implements Execution {
|
||||
export class TribeExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private mg: Game;
|
||||
@@ -18,8 +18,8 @@ export class BotExecution implements Execution {
|
||||
private reserveRatio: number;
|
||||
private expandRatio: number;
|
||||
|
||||
constructor(private bot: Player) {
|
||||
this.random = new PseudoRandom(simpleHash(bot.id()));
|
||||
constructor(private tribe: Player) {
|
||||
this.random = new PseudoRandom(simpleHash(tribe.id()));
|
||||
this.attackRate = this.random.nextInt(40, 80);
|
||||
this.attackTick = this.random.nextInt(0, this.attackRate);
|
||||
this.triggerRatio = this.random.nextInt(50, 60) / 100;
|
||||
@@ -38,8 +38,8 @@ export class BotExecution implements Execution {
|
||||
tick(ticks: number) {
|
||||
if (ticks % this.attackRate !== this.attackTick) return;
|
||||
|
||||
if (!this.bot.isAlive()) {
|
||||
//removeOnDeath is called from bot's PlayerExecution
|
||||
if (!this.tribe.isAlive()) {
|
||||
//removeOnDeath is called from tribe's PlayerExecution
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export class BotExecution implements Execution {
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.bot,
|
||||
this.tribe,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
@@ -66,27 +66,27 @@ export class BotExecution implements Execution {
|
||||
|
||||
private acceptAllAllianceRequests() {
|
||||
// Accept all alliance requests
|
||||
for (const req of this.bot.incomingAllianceRequests()) {
|
||||
for (const req of this.tribe.incomingAllianceRequests()) {
|
||||
req.accept();
|
||||
}
|
||||
|
||||
// Accept all alliance extension requests
|
||||
for (const alliance of this.bot.alliances()) {
|
||||
for (const alliance of this.tribe.alliances()) {
|
||||
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
|
||||
// Skip if no expiration yet/ ally didn't request extension yet / bot already agreed to extend
|
||||
// Skip if no expiration yet/ ally didn't request extension yet / tribe already agreed to extend
|
||||
if (!alliance.onlyOneAgreedToExtend()) continue;
|
||||
|
||||
const human = alliance.other(this.bot);
|
||||
const human = alliance.other(this.tribe);
|
||||
this.mg.addExecution(
|
||||
new AllianceExtensionExecution(this.bot, human.id()),
|
||||
new AllianceExtensionExecution(this.tribe, human.id()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteAllStructures() {
|
||||
for (const unit of this.bot.units()) {
|
||||
if (Structures.has(unit.type()) && this.bot.canDeleteUnit()) {
|
||||
this.mg.addExecution(new DeleteUnitExecution(this.bot, unit.id()));
|
||||
for (const unit of this.tribe.units()) {
|
||||
if (Structures.has(unit.type()) && this.tribe.canDeleteUnit()) {
|
||||
this.mg.addExecution(new DeleteUnitExecution(this.tribe, unit.id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,13 +97,13 @@ export class BotExecution implements Execution {
|
||||
}
|
||||
const toAttack = this.attackBehavior.getNeighborTraitorToAttack();
|
||||
if (toAttack !== null) {
|
||||
const odds = this.bot.isFriendly(toAttack) ? 6 : 3;
|
||||
const odds = this.tribe.isFriendly(toAttack) ? 6 : 3;
|
||||
if (this.random.chance(odds)) {
|
||||
// Check and break alliance before attacking if needed
|
||||
const alliance = this.bot.allianceWith(toAttack);
|
||||
const alliance = this.tribe.allianceWith(toAttack);
|
||||
|
||||
if (alliance !== null) {
|
||||
this.bot.breakAlliance(alliance);
|
||||
this.tribe.breakAlliance(alliance);
|
||||
}
|
||||
|
||||
this.attackBehavior.sendAttack(toAttack);
|
||||
@@ -112,7 +112,7 @@ export class BotExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.neighborsTerraNullius) {
|
||||
if (this.bot.neighbors().some((n) => !n.isPlayer())) {
|
||||
if (this.tribe.neighbors().some((n) => !n.isPlayer())) {
|
||||
this.attackBehavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Game, PlayerInfo, PlayerType } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TRIBE_NAME_PREFIXES, TRIBE_NAME_SUFFIXES } from "./utils/TribeNames";
|
||||
|
||||
export class TribeSpawner {
|
||||
private random: PseudoRandom;
|
||||
|
||||
constructor(
|
||||
private gs: Game,
|
||||
private gameID: GameID,
|
||||
) {
|
||||
// Use a different seed than createGameRunner (which uses simpleHash(gameID))
|
||||
// to avoid tribe IDs colliding with nation/human IDs from the same PRNG sequence.
|
||||
this.random = new PseudoRandom(simpleHash(gameID) + 2);
|
||||
}
|
||||
|
||||
spawnTribes(numTribes: number): SpawnExecution[] {
|
||||
const tribes: SpawnExecution[] = [];
|
||||
for (let i = 0; i < numTribes; i++) {
|
||||
tribes.push(this.spawnTribe(this.randomTribeName()));
|
||||
}
|
||||
return tribes;
|
||||
}
|
||||
|
||||
spawnTribe(tribeName: string): SpawnExecution {
|
||||
return new SpawnExecution(
|
||||
this.gameID,
|
||||
new PlayerInfo(tribeName, PlayerType.Bot, null, this.random.nextID()),
|
||||
);
|
||||
}
|
||||
|
||||
private randomTribeName(): string {
|
||||
const prefixIndex = this.random.nextInt(0, TRIBE_NAME_PREFIXES.length);
|
||||
const suffixIndex = this.random.nextInt(0, TRIBE_NAME_SUFFIXES.length);
|
||||
return `${TRIBE_NAME_PREFIXES[prefixIndex]} ${TRIBE_NAME_SUFFIXES[suffixIndex]}`;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export class NationNukeBehavior {
|
||||
const silos = this.player.units(UnitType.MissileSilo);
|
||||
if (
|
||||
silos.length === 0 ||
|
||||
nukeTarget.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans)
|
||||
nukeTarget.type() === PlayerType.Bot || // Don't nuke tribes (as opposed to nations and humans)
|
||||
this.player.isOnSameTeam(nukeTarget) ||
|
||||
this.attackBehavior.shouldAttack(nukeTarget) === false
|
||||
) {
|
||||
|
||||
@@ -697,7 +697,10 @@ export class NationStructureBehavior {
|
||||
unitToCluster.set(station.unit, station.getCluster());
|
||||
}
|
||||
|
||||
const maxTradeGold = Math.max(Number(game.config().trainGold("ally")), 1);
|
||||
const maxTradeGold = Math.max(
|
||||
Number(game.config().trainGold("ally", 0)),
|
||||
1,
|
||||
);
|
||||
const result: Array<{
|
||||
tile: TileRef;
|
||||
cluster: Cluster | null;
|
||||
@@ -705,7 +708,8 @@ export class NationStructureBehavior {
|
||||
}> = [];
|
||||
|
||||
// Own structures — weighted by "self" trade gold.
|
||||
const selfWeight = Number(game.config().trainGold("self")) / maxTradeGold;
|
||||
const selfWeight =
|
||||
Number(game.config().trainGold("self", 0)) / maxTradeGold;
|
||||
for (const unit of player.units(
|
||||
UnitType.City,
|
||||
UnitType.Port,
|
||||
@@ -730,7 +734,7 @@ export class NationStructureBehavior {
|
||||
: player.isAlliedWith(neighbor)
|
||||
? "ally"
|
||||
: "other";
|
||||
const weight = Number(game.config().trainGold(relType)) / maxTradeGold;
|
||||
const weight = Number(game.config().trainGold(relType, 0)) / maxTradeGold;
|
||||
for (const unit of neighbor.units(
|
||||
UnitType.City,
|
||||
UnitType.Port,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const BOT_NAME_PREFIXES = [
|
||||
export const TRIBE_NAME_PREFIXES = [
|
||||
"Akkadian",
|
||||
"Babylonian",
|
||||
"Sumerian",
|
||||
@@ -159,8 +159,6 @@ export const BOT_NAME_PREFIXES = [
|
||||
"Armenian",
|
||||
"Circassian",
|
||||
"Georgian",
|
||||
"Phoenician",
|
||||
"Chaldean",
|
||||
"Kurdish",
|
||||
"Turkic",
|
||||
"Kazakh",
|
||||
@@ -171,7 +169,6 @@ export const BOT_NAME_PREFIXES = [
|
||||
"Pashtun",
|
||||
"Baloch",
|
||||
"Afghan",
|
||||
"Persian",
|
||||
"Kenyan",
|
||||
"Ugandan",
|
||||
"Bhutanese",
|
||||
@@ -180,7 +177,7 @@ export const BOT_NAME_PREFIXES = [
|
||||
"Militant",
|
||||
"Spartan",
|
||||
];
|
||||
export const BOT_NAME_SUFFIXES = [
|
||||
export const TRIBE_NAME_SUFFIXES = [
|
||||
"Empire",
|
||||
"Dynasty",
|
||||
"Kingdom",
|
||||
@@ -218,7 +215,6 @@ export const BOT_NAME_SUFFIXES = [
|
||||
"Confederacy",
|
||||
"Order",
|
||||
"Regime",
|
||||
"Dominion",
|
||||
"Syndicate",
|
||||
"Guild",
|
||||
"Corporation",
|
||||
@@ -231,10 +227,7 @@ export const BOT_NAME_SUFFIXES = [
|
||||
"Sisterhood",
|
||||
"Ascendancy",
|
||||
"Supremacy",
|
||||
"Province",
|
||||
"Tribe",
|
||||
"Dominion",
|
||||
"Assembly",
|
||||
"Republics",
|
||||
"Army",
|
||||
"Dictatorship",
|
||||
@@ -138,6 +138,7 @@ export enum GameMapType {
|
||||
NileDelta = "Nile Delta",
|
||||
Arctic = "Arctic",
|
||||
SanFrancisco = "San Francisco",
|
||||
Aegean = "Aegean",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -188,6 +189,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.NileDelta,
|
||||
GameMapType.Arctic,
|
||||
GameMapType.SanFrancisco,
|
||||
GameMapType.Aegean,
|
||||
],
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
@@ -241,6 +243,8 @@ export interface PublicGameModifiers {
|
||||
isCrowded: boolean;
|
||||
isHardNations: boolean;
|
||||
startingGold?: number;
|
||||
goldMultiplier?: number;
|
||||
isAlliancesDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface UnitInfo {
|
||||
@@ -504,7 +508,7 @@ export class PlayerInfo {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly playerType: PlayerType,
|
||||
// null if bot.
|
||||
// null if tribe.
|
||||
public readonly clientID: ClientID | null,
|
||||
// TODO: make player id the small id
|
||||
public readonly id: PlayerID,
|
||||
|
||||
@@ -477,6 +477,9 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
canSendAllianceRequest(other: Player): boolean {
|
||||
if (this.mg.config().disableAlliances()) {
|
||||
return false;
|
||||
}
|
||||
if (other === this) {
|
||||
return false;
|
||||
}
|
||||
@@ -1177,16 +1180,20 @@ export class PlayerImpl implements Player {
|
||||
return false;
|
||||
}
|
||||
const owner = this.mg.owner(tile);
|
||||
// Allow nuking teammates after the game is over (aftergame fun)
|
||||
const gameOver = this.mg.getWinner() !== null;
|
||||
if (owner.isPlayer()) {
|
||||
if (this.isOnSameTeam(owner)) {
|
||||
if (this.isOnSameTeam(owner) && !gameOver) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent launching nukes that would hit teammate structures (only in team games)
|
||||
// Prevent launching nukes that would hit teammate structures (only in team games).
|
||||
// Disabled after game-over so players can nuke teammates in the aftergame.
|
||||
if (
|
||||
this.mg.config().gameConfig().gameMode === GameMode.Team &&
|
||||
nukeType !== UnitType.MIRV
|
||||
nukeType !== UnitType.MIRV &&
|
||||
!gameOver
|
||||
) {
|
||||
const magnitude = this.mg.config().nukeMagnitudes(nukeType);
|
||||
const wouldHitTeammate = this.mg.anyUnitNearby(
|
||||
|
||||
@@ -20,7 +20,12 @@ class TradeStationStopHandler implements TrainStopHandler {
|
||||
): void {
|
||||
const stationOwner = station.unit.owner();
|
||||
const trainOwner = trainExecution.owner();
|
||||
const gold = mg.config().trainGold(rel(trainOwner, stationOwner));
|
||||
const gold = mg
|
||||
.config()
|
||||
.trainGold(
|
||||
rel(trainOwner, stationOwner),
|
||||
trainExecution.tradeStopsVisited(),
|
||||
);
|
||||
// Share revenue with the station owner if it's not the current player
|
||||
if (trainOwner !== stationOwner) {
|
||||
stationOwner.addGold(gold, station.tile());
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { RateLimiter } from "limiter";
|
||||
import { ClientID } from "../core/Schemas";
|
||||
|
||||
const INTENTS_PER_SECOND = 10;
|
||||
const INTENTS_PER_MINUTE = 150;
|
||||
const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client
|
||||
const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small
|
||||
export type RateLimitResult = "ok" | "limit" | "kick";
|
||||
|
||||
// Allow 3 winner messages per client since a player can rejoin and resend.
|
||||
const MAX_WINNER_MSGS = 3;
|
||||
|
||||
interface ClientBucket {
|
||||
perSecond: RateLimiter;
|
||||
perMinute: RateLimiter;
|
||||
bytesPerMinute: RateLimiter;
|
||||
winnerMsgCount: number;
|
||||
}
|
||||
|
||||
export class ClientMsgRateLimiter {
|
||||
private buckets = new Map<ClientID, ClientBucket>();
|
||||
|
||||
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
|
||||
const bucket = this.getOrCreate(clientID);
|
||||
|
||||
// Winner message contains stats for all players and can be large (100s of KB).
|
||||
// It bypasses the byte rate limit but is strictly limited to one per client.
|
||||
if (type === "winner") {
|
||||
if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick";
|
||||
bucket.winnerMsgCount++;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// Intents are stored in turn history for the duration of the game, so
|
||||
// oversized intents would accumulate and fill up server RAM.
|
||||
if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick";
|
||||
|
||||
if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick";
|
||||
|
||||
if (
|
||||
!bucket.perSecond.tryRemoveTokens(1) ||
|
||||
!bucket.perMinute.tryRemoveTokens(1)
|
||||
)
|
||||
return "limit";
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
private getOrCreate(clientID: ClientID): ClientBucket {
|
||||
const existing = this.buckets.get(clientID);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const bucket = {
|
||||
perSecond: new RateLimiter({
|
||||
tokensPerInterval: INTENTS_PER_SECOND,
|
||||
interval: "second",
|
||||
}),
|
||||
perMinute: new RateLimiter({
|
||||
tokensPerInterval: INTENTS_PER_MINUTE,
|
||||
interval: "minute",
|
||||
}),
|
||||
bytesPerMinute: new RateLimiter({
|
||||
tokensPerInterval: MAX_BYTES_PER_MINUTE,
|
||||
interval: "minute",
|
||||
}),
|
||||
winnerMsgCount: 0,
|
||||
};
|
||||
this.buckets.set(clientID, bucket);
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
@@ -104,11 +104,10 @@ export class GameManager {
|
||||
}
|
||||
|
||||
desyncCount(): number {
|
||||
let totalDesyncs = 0;
|
||||
this.games.forEach((game: GameServer) => {
|
||||
totalDesyncs += game.desyncCount;
|
||||
});
|
||||
return totalDesyncs;
|
||||
return [...this.games.values()].reduce(
|
||||
(acc, game) => acc + game.numDesyncedClients(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
tick() {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { createPartialGameRecord, getClanTag } from "../core/Util";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
|
||||
export enum GamePhase {
|
||||
Lobby = "LOBBY",
|
||||
Active = "ACTIVE",
|
||||
@@ -34,10 +35,14 @@ export enum GamePhase {
|
||||
|
||||
const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session";
|
||||
const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator";
|
||||
const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data";
|
||||
const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message";
|
||||
|
||||
export class GameServer {
|
||||
private sentDesyncMessageClients = new Set<ClientID>();
|
||||
|
||||
private intentRateLimiter = new ClientMsgRateLimiter();
|
||||
|
||||
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
|
||||
|
||||
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
|
||||
@@ -51,6 +56,7 @@ export class GameServer {
|
||||
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
|
||||
private _hasStarted = false;
|
||||
private _startTime: number | null = null;
|
||||
private hasReachedMaxPlayerCount: boolean = false;
|
||||
|
||||
private endTurnIntervalID: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
@@ -79,8 +85,6 @@ export class GameServer {
|
||||
|
||||
private _hasEnded = false;
|
||||
|
||||
public desyncCount = 0;
|
||||
|
||||
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -157,6 +161,9 @@ export class GameServer {
|
||||
if (gameConfig.startingGold !== undefined) {
|
||||
this.gameConfig.startingGold = gameConfig.startingGold;
|
||||
}
|
||||
if (gameConfig.disableAlliances !== undefined) {
|
||||
this.gameConfig.disableAlliances = gameConfig.disableAlliances;
|
||||
}
|
||||
}
|
||||
|
||||
private isKicked(clientID: ClientID): boolean {
|
||||
@@ -246,6 +253,10 @@ export class GameServer {
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
|
||||
if (this.activeClients.length >= (this.gameConfig.maxPlayers ?? Infinity)) {
|
||||
this.hasReachedMaxPlayerCount = true;
|
||||
}
|
||||
|
||||
// In case a client joined the game late and missed the start message.
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, 0);
|
||||
@@ -305,22 +316,48 @@ export class GameServer {
|
||||
client.ws.removeAllListeners("message");
|
||||
client.ws.on("message", async (message: string) => {
|
||||
try {
|
||||
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
|
||||
if (!parsed.success) {
|
||||
const error = z.prettifyError(parsed.error);
|
||||
this.log.warn(`Failed to parse client message ${error}`, {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
this.log.warn(`Failed to parse client message JSON, kicking`, {
|
||||
clientID: client.clientID,
|
||||
error: String(e),
|
||||
});
|
||||
client.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error,
|
||||
message: `Server could not parse message from client: ${message}`,
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const parsed = ClientMessageSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
this.log.warn(`Failed to parse client message, kicking`, {
|
||||
clientID: client.clientID,
|
||||
error: z.prettifyError(parsed.error),
|
||||
});
|
||||
this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
const bytes = Buffer.byteLength(message, "utf8");
|
||||
const rateResult = this.intentRateLimiter.check(
|
||||
client.clientID,
|
||||
clientMsg.type,
|
||||
bytes,
|
||||
);
|
||||
if (rateResult === "kick") {
|
||||
this.log.warn(`Client rate limit exceeded, kicking`, {
|
||||
clientID: client.clientID,
|
||||
type: clientMsg.type,
|
||||
});
|
||||
this.kickClient(client.clientID, KICK_REASON_TOO_MUCH_DATA);
|
||||
return;
|
||||
}
|
||||
if (rateResult === "limit") {
|
||||
this.log.warn(`Client message rate limit exceeded, dropping`, {
|
||||
clientID: client.clientID,
|
||||
type: clientMsg.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
switch (clientMsg.type) {
|
||||
case "rejoin": {
|
||||
// Client is already connected, no auth required, send start game message if game has started
|
||||
@@ -530,6 +567,10 @@ export class GameServer {
|
||||
return this.activeClients.length;
|
||||
}
|
||||
|
||||
public numDesyncedClients(): number {
|
||||
return this.outOfSyncClients.size;
|
||||
}
|
||||
|
||||
public prestart() {
|
||||
if (this.hasStarted()) {
|
||||
return;
|
||||
@@ -808,11 +849,11 @@ export class GameServer {
|
||||
// Public Games
|
||||
|
||||
const lessThanLifetime = this.startsAt ? Date.now() < this.startsAt : true;
|
||||
const notEnoughPlayers =
|
||||
this.gameConfig.gameType === GameType.Public &&
|
||||
this.gameConfig.maxPlayers &&
|
||||
this.activeClients.length < this.gameConfig.maxPlayers;
|
||||
if (lessThanLifetime && notEnoughPlayers) {
|
||||
if (
|
||||
lessThanLifetime &&
|
||||
!this.hasStarted() &&
|
||||
!this.hasReachedMaxPlayerCount
|
||||
) {
|
||||
return GamePhase.Lobby;
|
||||
}
|
||||
const warmupOver = now > this.startsAt! + 30 * 1000;
|
||||
@@ -980,8 +1021,6 @@ export class GameServer {
|
||||
const { mostCommonHash, outOfSyncClients } =
|
||||
this.findOutOfSyncClients(lastHashTurn);
|
||||
|
||||
this.desyncCount += outOfSyncClients.length;
|
||||
|
||||
if (outOfSyncClients.length === 0) {
|
||||
this.turns[lastHashTurn].hash = mostCommonHash;
|
||||
return;
|
||||
|
||||
@@ -61,7 +61,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
SouthAmerica: 5,
|
||||
StraitOfGibraltar: 5,
|
||||
Svalmel: 8,
|
||||
World: 8,
|
||||
World: 20,
|
||||
Lemnos: 3,
|
||||
Passage: 4,
|
||||
TwoLakes: 6,
|
||||
@@ -81,6 +81,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
NileDelta: 4,
|
||||
Arctic: 6,
|
||||
SanFrancisco: 3,
|
||||
Aegean: 6,
|
||||
};
|
||||
|
||||
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
|
||||
@@ -102,7 +103,9 @@ type ModifierKey =
|
||||
| "isCrowded"
|
||||
| "isHardNations"
|
||||
| "startingGold"
|
||||
| "startingGoldHigh";
|
||||
| "startingGoldHigh"
|
||||
| "goldMultiplier"
|
||||
| "isAlliancesDisabled";
|
||||
|
||||
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
|
||||
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
|
||||
@@ -112,6 +115,8 @@ const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
|
||||
...Array<ModifierKey>(1).fill("isHardNations"),
|
||||
...Array<ModifierKey>(8).fill("startingGold"),
|
||||
...Array<ModifierKey>(1).fill("startingGoldHigh"),
|
||||
...Array<ModifierKey>(1).fill("goldMultiplier"),
|
||||
...Array<ModifierKey>(1).fill("isAlliancesDisabled"),
|
||||
];
|
||||
|
||||
// Modifiers that cannot be active at the same time.
|
||||
@@ -196,6 +201,7 @@ export class MapPlaylist {
|
||||
isCrowded,
|
||||
isHardNations,
|
||||
startingGold,
|
||||
isAlliancesDisabled: false,
|
||||
},
|
||||
startingGold,
|
||||
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
|
||||
@@ -262,7 +268,14 @@ export class MapPlaylist {
|
||||
undefined,
|
||||
poolCountReduction,
|
||||
);
|
||||
let { isCrowded, startingGold, isCompact, isRandomSpawn } = poolResult;
|
||||
let {
|
||||
isCrowded,
|
||||
startingGold,
|
||||
isCompact,
|
||||
isRandomSpawn,
|
||||
goldMultiplier,
|
||||
isAlliancesDisabled,
|
||||
} = poolResult;
|
||||
let isHardNations =
|
||||
hardNationsFromIndependentRoll ?? poolResult.isHardNations;
|
||||
|
||||
@@ -279,7 +292,9 @@ export class MapPlaylist {
|
||||
!isRandomSpawn &&
|
||||
!isCompact &&
|
||||
!isHardNations &&
|
||||
startingGold === undefined
|
||||
startingGold === undefined &&
|
||||
goldMultiplier === undefined &&
|
||||
!isAlliancesDisabled
|
||||
) {
|
||||
excludedModifiers.push("isCrowded");
|
||||
const fallback = this.getRandomSpecialGameModifiers(
|
||||
@@ -287,7 +302,13 @@ export class MapPlaylist {
|
||||
1,
|
||||
poolCountReduction,
|
||||
);
|
||||
({ isRandomSpawn, isCompact, startingGold } = fallback);
|
||||
({
|
||||
isRandomSpawn,
|
||||
isCompact,
|
||||
startingGold,
|
||||
goldMultiplier,
|
||||
isAlliancesDisabled,
|
||||
} = fallback);
|
||||
isHardNations =
|
||||
hardNationsFromIndependentRoll ?? fallback.isHardNations;
|
||||
}
|
||||
@@ -320,8 +341,12 @@ export class MapPlaylist {
|
||||
isCrowded,
|
||||
isHardNations,
|
||||
startingGold,
|
||||
goldMultiplier,
|
||||
isAlliancesDisabled,
|
||||
},
|
||||
startingGold,
|
||||
goldMultiplier,
|
||||
disableAlliances: isAlliancesDisabled,
|
||||
difficulty: isHardNations ? Difficulty.Hard : Difficulty.Medium,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
@@ -481,6 +506,7 @@ export class MapPlaylist {
|
||||
playerTeams === HumansVsNations
|
||||
? Math.random() < HARD_NATIONS_HVN_PROBABILITY
|
||||
: Math.random() < 0.025, // 2.5% chance
|
||||
isAlliancesDisabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -529,6 +555,8 @@ export class MapPlaylist {
|
||||
: selected.has("startingGold")
|
||||
? 5_000_000
|
||||
: undefined,
|
||||
goldMultiplier: selected.has("goldMultiplier") ? 2 : undefined,
|
||||
isAlliancesDisabled: selected.has("isAlliancesDisabled"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export class MasterLobbyService {
|
||||
if (this.readyWorkers.size === this.config.numWorkers() && !this.started) {
|
||||
this.started = true;
|
||||
this.log.info("All workers ready, starting game scheduling");
|
||||
startPolling(async () => this.broadcastLobbies(), 250);
|
||||
startPolling(async () => this.broadcastLobbies(), 500);
|
||||
startPolling(async () => await this.maybeScheduleLobby(), 1000);
|
||||
}
|
||||
}
|
||||
@@ -117,10 +117,14 @@ export class MasterLobbyService {
|
||||
games: this.getAllLobbies(),
|
||||
},
|
||||
} satisfies MasterLobbiesBroadcast;
|
||||
for (const worker of this.workers.values()) {
|
||||
for (const [workerId, worker] of this.workers.entries()) {
|
||||
worker.send(msg, (e) => {
|
||||
if (e) {
|
||||
this.log.error("Failed to send lobbies broadcast to worker:", e);
|
||||
this.log.error(
|
||||
`Failed to send lobbies broadcast to worker ${workerId}, killing worker:`,
|
||||
e,
|
||||
);
|
||||
worker.kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -131,12 +135,13 @@ export class MasterLobbyService {
|
||||
|
||||
for (const type of Object.keys(lobbiesByType) as PublicGameType[]) {
|
||||
const lobbies = lobbiesByType[type];
|
||||
if (lobbies.length >= 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always ensure the next lobby has a timer, even if we already have 2+
|
||||
// lobbies. This prevents a race where two lobbies are created before
|
||||
// either receives a startsAt (IPC round-trip delay), leaving both stuck
|
||||
// without a countdown.
|
||||
const nextLobby = lobbies[0];
|
||||
if (nextLobby && nextLobby.startsAt === undefined) {
|
||||
// The previous game has started, so we need to set the timer on the next game.
|
||||
this.sendMessageToWorker({
|
||||
type: "updateLobby",
|
||||
gameID: nextLobby.gameID,
|
||||
@@ -144,6 +149,10 @@ export class MasterLobbyService {
|
||||
});
|
||||
}
|
||||
|
||||
if (lobbies.length >= 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.sendMessageToWorker({
|
||||
type: "createGame",
|
||||
gameID: generateID(),
|
||||
@@ -162,7 +171,11 @@ export class MasterLobbyService {
|
||||
}
|
||||
worker.send(msg, (e) => {
|
||||
if (e) {
|
||||
this.log.error("Failed to send message to worker:", e);
|
||||
this.log.error(
|
||||
`Failed to send message to worker ${workerId}, killing worker:`,
|
||||
e,
|
||||
);
|
||||
worker.kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,9 +50,13 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
);
|
||||
|
||||
for (const word of bannedWords) {
|
||||
customDataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
|
||||
);
|
||||
try {
|
||||
customDataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Invalid banned word pattern "${word}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new RegExpMatcher({
|
||||
|
||||
@@ -48,7 +48,10 @@ export async function startWorker() {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 2 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const gm = new GameManager(config, log);
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ export class WorkerLobbyService {
|
||||
private readonly gm: GameManager,
|
||||
private readonly log: typeof logger,
|
||||
) {
|
||||
this.lobbiesWss = new WebSocketServer({ noServer: true });
|
||||
this.lobbiesWss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 256 * 1024,
|
||||
});
|
||||
this.setupUpgradeHandler();
|
||||
this.setupLobbiesWebSocket();
|
||||
this.setupIPCListener();
|
||||
@@ -109,6 +112,9 @@ export class WorkerLobbyService {
|
||||
private setupLobbiesWebSocket() {
|
||||
this.lobbiesWss.on("connection", (ws: WebSocket) => {
|
||||
this.lobbyClients.add(ws);
|
||||
ws.on("message", () => {
|
||||
ws.terminate();
|
||||
});
|
||||
ws.on("close", () => {
|
||||
this.lobbyClients.delete(ws);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ function makeStation(unit: any, cluster: Cluster | null = null): any {
|
||||
function makeGame(stations: any[] = []): any {
|
||||
return {
|
||||
config: () => ({
|
||||
trainGold: (rel: string) => TRAIN_GOLD[rel] ?? 0n,
|
||||
trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n,
|
||||
}),
|
||||
railNetwork: () => ({
|
||||
stationManager: () => ({ getAll: () => new Set(stations) }),
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { GameUpdateType } from "src/core/game/GameUpdates";
|
||||
import { vi, type Mocked } from "vitest";
|
||||
import { DefaultConfig } from "../../../src/core/configuration/DefaultConfig";
|
||||
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
|
||||
import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
Player,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../../../src/core/game/Game";
|
||||
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
|
||||
import { UserSettings } from "../../../src/core/game/UserSettings";
|
||||
import { GameConfig } from "../../../src/core/Schemas";
|
||||
import { TestServerConfig } from "../../util/TestServerConfig";
|
||||
|
||||
vi.mock("../../../src/core/game/Game");
|
||||
vi.mock("../../../src/core/execution/TrainExecution");
|
||||
@@ -18,8 +32,8 @@ describe("TrainStation", () => {
|
||||
game = {
|
||||
ticks: vi.fn().mockReturnValue(123),
|
||||
config: vi.fn().mockReturnValue({
|
||||
trainGold: (isFriendly: boolean) =>
|
||||
isFriendly ? BigInt(1000) : BigInt(500),
|
||||
trainGold: (rel: string, _tradeStopsVisited: number) =>
|
||||
rel !== "other" ? BigInt(1000) : BigInt(500),
|
||||
}),
|
||||
addUpdate: vi.fn(),
|
||||
addExecution: vi.fn(),
|
||||
@@ -48,6 +62,7 @@ describe("TrainStation", () => {
|
||||
loadCargo: vi.fn(),
|
||||
owner: vi.fn().mockReturnValue(player),
|
||||
level: vi.fn(),
|
||||
tradeStopsVisited: vi.fn().mockReturnValue(0),
|
||||
} as any;
|
||||
});
|
||||
|
||||
@@ -74,6 +89,20 @@ describe("TrainStation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes tradeStopsVisited to trainGold", () => {
|
||||
unit.type.mockReturnValue(UnitType.City);
|
||||
const trainGoldSpy = vi.fn().mockReturnValue(500n);
|
||||
(game.config as any).mockReturnValue({
|
||||
trainGold: trainGoldSpy,
|
||||
});
|
||||
(trainExecution as any).tradeStopsVisited = vi.fn().mockReturnValue(3);
|
||||
const station = new TrainStation(game, unit);
|
||||
|
||||
station.onTrainStop(trainExecution);
|
||||
|
||||
expect(trainGoldSpy).toHaveBeenCalledWith(expect.any(String), 3);
|
||||
});
|
||||
|
||||
it("checks trade availability (same owner)", () => {
|
||||
const otherUnit = {
|
||||
owner: vi.fn().mockReturnValue(unit.owner()),
|
||||
@@ -133,3 +162,65 @@ describe("TrainStation", () => {
|
||||
expect(station.isActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultConfig.trainGold trade stop penalty", () => {
|
||||
let config: DefaultConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
const serverConfig = new TestServerConfig();
|
||||
const gameConfig: GameConfig = {
|
||||
gameMap: GameMapType.Asia,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
gameMode: GameMode.FFA,
|
||||
gameType: GameType.Singleplayer,
|
||||
difficulty: Difficulty.Medium,
|
||||
nations: "default",
|
||||
donateGold: false,
|
||||
donateTroops: false,
|
||||
bots: 0,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
disableNavMesh: false,
|
||||
randomSpawn: false,
|
||||
};
|
||||
config = new DefaultConfig(
|
||||
serverConfig,
|
||||
gameConfig,
|
||||
new UserSettings(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns full base gold within free window (stops 0-9)", () => {
|
||||
// first 10 stops (0-9) are free — no penalty
|
||||
expect(config.trainGold("self", 0)).toBe(10_000n);
|
||||
expect(config.trainGold("self", 9)).toBe(10_000n);
|
||||
});
|
||||
|
||||
it("reduces gold by 5k per stop after the free window", () => {
|
||||
// stop 10: effective = 10-9 = 1 -> 10k - 5k = 5k
|
||||
expect(config.trainGold("self", 10)).toBe(5_000n);
|
||||
});
|
||||
|
||||
it("floors at 5k when penalty exceeds base gold", () => {
|
||||
// stop 12: effective = 3 -> 10k - 15k -> floor at 5k
|
||||
expect(config.trainGold("self", 12)).toBe(5_000n);
|
||||
});
|
||||
|
||||
it("floors at 5k for ally base even with heavy penalty", () => {
|
||||
// ally base 35k, stop 20: effective = 11 -> penalty 55k -> floor at 5k
|
||||
expect(config.trainGold("ally", 20)).toBe(5_000n);
|
||||
});
|
||||
|
||||
it("ally base gold reduces correctly after free window", () => {
|
||||
// ally base 35k, stop 11: effective = 2 -> 35k - 10k = 25k
|
||||
expect(config.trainGold("ally", 11)).toBe(25_000n);
|
||||
});
|
||||
|
||||
it("other/team base gold reduces correctly after free window", () => {
|
||||
// other base 25k, stop 10: effective = 1 -> 25k - 5k = 20k
|
||||
expect(config.trainGold("other", 10)).toBe(20_000n);
|
||||
expect(config.trainGold("team", 10)).toBe(20_000n);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClientMsgRateLimiter } from "../../src/server/ClientMsgRateLimiter";
|
||||
|
||||
const CLIENT_A = "clientA" as any;
|
||||
const CLIENT_B = "clientB" as any;
|
||||
|
||||
const SMALL = 100;
|
||||
const LARGE = 501; // over MAX_INTENT_BYTES
|
||||
|
||||
describe("ClientMsgRateLimiter", () => {
|
||||
describe("intent messages", () => {
|
||||
it("allows intents within limits", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks on oversized intent", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick");
|
||||
});
|
||||
|
||||
it("limits when per-second count exceeded", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
}
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("limit");
|
||||
});
|
||||
|
||||
it("rate limits are per client", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.check(CLIENT_A, "intent", SMALL);
|
||||
}
|
||||
expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("winner messages", () => {
|
||||
it("allows first winner message", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
});
|
||||
|
||||
it("allows up to 3 winner messages", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick");
|
||||
});
|
||||
|
||||
it("winner does not consume intent rate limit", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
limiter.check(CLIENT_A, "winner", 50000);
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("other messages", () => {
|
||||
it("applies rate limiting to other message types", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok");
|
||||
}
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,7 @@ export class TestServerConfig implements ServerConfig {
|
||||
isRandomSpawn: false,
|
||||
isCrowded: false,
|
||||
isHardNations: false,
|
||||
isAlliancesDisabled: false,
|
||||
};
|
||||
}
|
||||
async supportsCompactMapForTeams(): Promise<boolean> {
|
||||
|
||||